diff --git a/packages/next-intl/src/extractor/catalog/CatalogManager.tsx b/packages/next-intl/src/extractor/catalog/CatalogManager.tsx index b9910262b..05d632200 100644 --- a/packages/next-intl/src/extractor/catalog/CatalogManager.tsx +++ b/packages/next-intl/src/extractor/catalog/CatalogManager.tsx @@ -11,7 +11,7 @@ import type { ExtractorConfig, ExtractorMessage, Locale, - SourceMessage + SourceExtractedMessage } from '../types.js'; import { compareReferences, @@ -43,17 +43,17 @@ export default class CatalogManager implements Disposable { */ private sourceMessagesByFile: Map< /* File path */ string, - Map> + Map> > = new Map(); /** * Reverse index for rebuilding aggregated messages without scanning all files. - * Contains the same `SourceMessage` arrays as `sourceMessagesByFile` and is + * Contains the same `SourceExtractedMessage` arrays as `sourceMessagesByFile` and is * kept in sync with it. */ private sourceMessagesById: Map< /* ID */ string, - Map> + Map> > = new Map(); /** @@ -308,8 +308,8 @@ export default class CatalogManager implements Disposable { private async extractFile( absoluteFilePath: string - ): Promise | undefined> { - let messages: Array = []; + ): Promise | undefined> { + let messages: Array = []; try { const content = await fs.readFile(absoluteFilePath, 'utf8'); let extraction: Awaited>; @@ -330,7 +330,7 @@ export default class CatalogManager implements Disposable { private applyFileMessages( absoluteFilePath: string, - messages: Array + messages: Array ): boolean { const prevFileMessages = this.sourceMessagesByFile.get(absoluteFilePath); const nextFileMessages = this.groupSourceMessagesById(messages); @@ -378,9 +378,9 @@ export default class CatalogManager implements Disposable { } private groupSourceMessagesById( - messages: Array - ): Map> { - const result = new Map>(); + messages: Array + ): Map> { + const result = new Map>(); for (const message of messages) { const messagesById = result.get(message.id); if (messagesById) { @@ -429,7 +429,7 @@ export default class CatalogManager implements Disposable { } private mergeDescriptions( - messages: Array + messages: Array ): ExtractorMessage['description'] { const sortedByReference = messages.toSorted((a, b) => compareReferences(a.reference, b.reference) @@ -447,8 +447,8 @@ export default class CatalogManager implements Disposable { } private haveMessagesChangedForFile( - beforeMessages: Map> | undefined, - afterMessages: Map> + beforeMessages: Map> | undefined, + afterMessages: Map> ): boolean { // If one exists and the other doesn't, there's a change if (!beforeMessages) { @@ -481,8 +481,8 @@ export default class CatalogManager implements Disposable { } private areSourceMessageArraysEqual( - messages1: Array, - messages2: Array + messages1: Array, + messages2: Array ): boolean { return ( messages1.length === messages2.length && @@ -493,8 +493,8 @@ export default class CatalogManager implements Disposable { } private areSourceMessagesEqual( - msg1: SourceMessage, - msg2: SourceMessage + msg1: SourceExtractedMessage, + msg2: SourceExtractedMessage ): boolean { return ( msg1.id === msg2.id && diff --git a/packages/next-intl/src/extractor/extractor/MessageExtractor.tsx b/packages/next-intl/src/extractor/extractor/MessageExtractor.tsx index 0170bc1c0..3b942f8c5 100644 --- a/packages/next-intl/src/extractor/extractor/MessageExtractor.tsx +++ b/packages/next-intl/src/extractor/extractor/MessageExtractor.tsx @@ -1,7 +1,7 @@ import {createRequire} from 'module'; import path from 'path'; import {transform} from '@swc/core'; -import type {SourceMessage} from '../types.js'; +import type {SourceExtractedMessage} from '../types.js'; import {getDefaultProjectRoot, normalizePathToPosix} from '../utils.js'; import LRUCache from './LRUCache.js'; @@ -12,7 +12,7 @@ export default class MessageExtractor { private projectRoot: string; private sourceMap: boolean; private compileCache = new LRUCache<{ - messages: Array; + messages: Array; code: string; map?: string; }>(750); @@ -31,7 +31,7 @@ export default class MessageExtractor { absoluteFilePath: string, source: string ): Promise<{ - messages: Array; + messages: Array; code: string; map?: string; }> { @@ -79,9 +79,20 @@ export default class MessageExtractor { // TODO: Improve the typing of @swc/core const output = (result as any).output as string; - const messages = JSON.parse( - JSON.parse(output).results - ) as Array; + // The plugin emits a tagged union of extracted messages and + // `useTranslations` usages; this extractor only consumes the former. + const messages = ( + JSON.parse(JSON.parse(output).results) as Array< + {type: 'extracted' | 'translation'} & SourceExtractedMessage + > + ) + .filter((item) => item.type === 'extracted') + .map((item) => ({ + id: item.id, + message: item.message, + description: item.description, + reference: item.reference + })); const extractionResult = { code: result.code, diff --git a/packages/next-intl/src/extractor/types.tsx b/packages/next-intl/src/extractor/types.tsx index 99ffed1f0..effddc0f4 100644 --- a/packages/next-intl/src/extractor/types.tsx +++ b/packages/next-intl/src/extractor/types.tsx @@ -11,7 +11,7 @@ export type ExtractorMessageReference = { }; /** A single statically extracted source-code usage before any aggregation. */ -export type SourceMessage = { +export type SourceExtractedMessage = { id: string; message: string; description: string | null; diff --git a/packages/swc-plugin-extractor/src/lib.rs b/packages/swc-plugin-extractor/src/lib.rs index fc3834681..7b3f6929c 100644 --- a/packages/swc-plugin-extractor/src/lib.rs +++ b/packages/swc-plugin-extractor/src/lib.rs @@ -49,365 +49,412 @@ struct Config { file_path: String, } -pub struct TransformVisitor { - is_development: bool, - file_path: String, - source_map: Option>, - - hook_local_names: FxHashMap, - - translator_map: FxHashMap, - - /// Each statically extracted source-code usage in discovery order. - results: Vec, -} - -impl TransformVisitor { - pub fn new( - is_development: bool, - file_path: String, - source_map: Option>, - ) -> Self { - Self { - is_development, - file_path, - source_map, - hook_local_names: Default::default(), - translator_map: Default::default(), - results: Default::default(), - } - } - - pub fn get_results(&self) -> Vec { - self.results.clone() - } - - fn define_translator(&mut self, name: Id, namespace: Option) { - self.translator_map - .insert(name, TranslatorInfo { namespace }); - } -} - -#[derive(Debug, Clone)] -struct TranslatorInfo { - namespace: Option, +/// A statically analyzable usage, tagged by kind in the serialized output. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum SourceMessage { + /// An inline message from `useExtracted` / `getExtracted`. + Extracted(ExtractedMessage), + /// A `useTranslations` / `getTranslations` key reference. + Translation(TranslationUse), } #[derive(Debug, Clone, Serialize)] -pub struct SourceMessage { +pub struct ExtractedMessage { pub id: Wtf8Atom, pub message: Wtf8Atom, pub description: Option, pub reference: Reference, } +#[derive(Debug, Clone, Serialize)] +pub struct TranslationUse { + pub id: String, + pub reference: Reference, +} + #[derive(Debug, Clone, Serialize)] pub struct Reference { pub path: String, pub line: usize, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum HookType { - UseTranslation, - GetTranslation, +/// A next-intl hook a translator can be bound to. +#[derive(Debug, Clone, Copy)] +enum Hook { + /// `useExtracted` / `getExtracted` — extracts inline messages and is + /// rewritten to the real translations hook. + Extracted(ExtractedHook), + /// Plain `useTranslations` / `getTranslations` — usages are recorded as-is. + Translation, +} + +/// The client (`useExtracted`) or server (`getExtracted`) extraction hook. +#[derive(Debug, Clone, Copy)] +enum ExtractedHook { + Client, + Server, } -impl HookType { - /// The extracted hook name we look for in imports (e.g. `useExtracted`) - fn extracted_name(self) -> &'static str { +impl ExtractedHook { + /// The hook name we look for in imports (e.g. `useExtracted`). + fn imported_name(self) -> &'static str { match self { - HookType::UseTranslation => "useExtracted", - HookType::GetTranslation => "getExtracted", + ExtractedHook::Client => "useExtracted", + ExtractedHook::Server => "getExtracted", } } - /// The real hook name we import from next-intl (e.g. `useTranslations`) + /// The real next-intl hook it's rewritten to (e.g. `useTranslations`). fn target_name(self) -> &'static str { match self { - HookType::UseTranslation => "useTranslations", - HookType::GetTranslation => "getTranslations", + ExtractedHook::Client => "useTranslations", + ExtractedHook::Server => "getTranslations", } } - /// The unique local identifier used to avoid conflicts with existing imports + /// A unique local identifier to avoid conflicts with existing imports. fn local_name(self) -> swc_atoms::Atom { match self { - HookType::UseTranslation => "useTranslations$1".into(), - HookType::GetTranslation => "getTranslations$1".into(), + ExtractedHook::Client => "useTranslations$1".into(), + ExtractedHook::Server => "getTranslations$1".into(), } } } -impl VisitMut for TransformVisitor { - fn visit_mut_call_expr(&mut self, call: &mut CallExpr) { - let mut is_translator_call = false; - let mut namespace = None; +#[derive(Debug, Clone, Copy)] +enum TranslatorKind { + Extracted, + Translation, +} + +/// A translator binding such as `const t = useTranslations('ns')`. +struct Translator { + kind: TranslatorKind, + namespace: Option, +} + +pub struct TransformVisitor { + is_development: bool, + file_path: String, + source_map: Option>, + + /// Local import names of next-intl hooks (`useExtracted`, `useTranslations`, …). + hook_local_names: FxHashMap, + + /// Translator bindings created from those hooks. + translator_map: FxHashMap, - // Handle Identifier case: t("message") - match &call.callee { + /// Each statically analyzable usage in discovery order. + results: Vec, +} + +impl TransformVisitor { + pub fn new( + is_development: bool, + file_path: String, + source_map: Option>, + ) -> Self { + Self { + is_development, + file_path, + source_map, + hook_local_names: Default::default(), + translator_map: Default::default(), + results: Default::default(), + } + } + + pub fn get_results(&self) -> Vec { + self.results.clone() + } + + /// The kind and namespace of the translator a call's callee is bound to, + /// honoring the member methods valid for each kind (`t`, `t.rich`, …). + fn resolve_translator(&self, callee: &Callee) -> Option<(TranslatorKind, Option)> { + match callee { Callee::Expr(box Expr::Ident(ident)) => { - if let Some(translator) = self.translator_map.get(&ident.to_id()) { - is_translator_call = true; - namespace = translator.namespace.clone(); - } + let translator = self.translator_map.get(&ident.to_id())?; + Some((translator.kind, translator.namespace.clone())) } - Callee::Expr(box Expr::Member(MemberExpr { obj: box Expr::Ident(obj), prop: MemberProp::Ident(prop), .. })) => { - if matches!(&*prop.sym, "rich" | "markup" | "has") { - if let Some(translator) = self.translator_map.get(&obj.to_id()) { - is_translator_call = true; - namespace = translator.namespace.clone(); + let translator = self.translator_map.get(&obj.to_id())?; + let valid_method = match translator.kind { + TranslatorKind::Extracted => matches!(&*prop.sym, "rich" | "markup" | "has"), + TranslatorKind::Translation => { + matches!(&*prop.sym, "rich" | "markup" | "has" | "raw") } - } + }; + valid_method.then(|| (translator.kind, translator.namespace.clone())) } - - _ => {} + _ => None, } + } + + /// Records a `useTranslations` / `getTranslations` key reference, resolving + /// the id as far as it is statically known (namespace and/or key). + fn record_translation(&mut self, call: &CallExpr, namespace: Option) { + let key = call + .args + .first() + .and_then(|arg| extract_static_string(&arg.expr)); + let id = match (namespace, key) { + (Some(ns), Some(k)) => format!( + "{}{}{}", + ns.to_string_lossy(), + NAMESPACE_SEPARATOR, + k.to_string_lossy() + ), + // A dynamic key under a namespace covers the whole namespace. + (Some(ns), None) => ns.to_string_lossy().to_string(), + (None, Some(k)) => k.to_string_lossy().to_string(), + // `useTranslations()` with a dynamic key can't be statically analyzed; skip it. + (None, None) => return, + }; + let line = self + .source_map + .as_ref() + .map_or(0, |sm| sm.lookup_char_pos(call.span.lo).line); + self.results + .push(SourceMessage::Translation(TranslationUse { + id, + reference: Reference { + path: self.file_path.clone(), + line, + }, + })); + } - if is_translator_call { - let arg0 = call.args.first(); - - let mut message_text = None; - let mut explicit_id = None; - let mut description = None; - let mut values_node = None; - let mut formats_node = None; - - if let Some(arg0) = arg0 { - match &*arg0.expr { - // Handle object syntax: t({id: 'key', message: 'text'}) - Expr::Object(ObjectLit { props, .. }) => { - for prop in props { - if let PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp { - key: PropName::Ident(key), - value, - .. - })) = prop - { - if key.sym == "id" { - let static_id = extract_static_string(value); - if let Some(static_id) = static_id { - explicit_id = Some(static_id); - } - } else if key.sym == "message" { - let static_message = extract_static_string(value); - if let Some(static_message) = static_message { - message_text = Some(static_message); - } else { - warn_dynamic_expression(value); - } - } else if key.sym == "description" { - let static_description = extract_static_string(value); - if let Some(static_description) = static_description { - description = Some(static_description); - } else { - warn_dynamic_expression(value); - } - } else if key.sym == "values" { - values_node = Some(value.clone()); - } else if key.sym == "formats" { - formats_node = Some(value.clone()); + /// Records an inline `useExtracted` / `getExtracted` message and rewrites the + /// call to reference the generated key. + fn extract_message(&mut self, call: &mut CallExpr, namespace: Option) { + let mut message_text = None; + let mut explicit_id = None; + let mut description = None; + let mut values_node = None; + let mut formats_node = None; + + if let Some(arg0) = call.args.first() { + match &*arg0.expr { + // Handle object syntax: t({id: 'key', message: 'text'}) + Expr::Object(ObjectLit { props, .. }) => { + for prop in props { + if let PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp { + key: PropName::Ident(key), + value, + .. + })) = prop + { + if key.sym == "id" { + let static_id = extract_static_string(value); + if let Some(static_id) = static_id { + explicit_id = Some(static_id); + } + } else if key.sym == "message" { + let static_message = extract_static_string(value); + if let Some(static_message) = static_message { + message_text = Some(static_message); + } else { + warn_dynamic_expression(value); } + } else if key.sym == "description" { + let static_description = extract_static_string(value); + if let Some(static_description) = static_description { + description = Some(static_description); + } else { + warn_dynamic_expression(value); + } + } else if key.sym == "values" { + values_node = Some(value.clone()); + } else if key.sym == "formats" { + formats_node = Some(value.clone()); } } } + } - // Handle string syntax: t('text') or t(`text`) - _ => { - let static_string = extract_static_string(&arg0.expr); - if let Some(static_string) = static_string { - message_text = Some(static_string); - } else { - // Dynamic expression (Identifier, CallExpression, BinaryExpression, - // etc.) - warn_dynamic_expression(&arg0.expr); - } + // Handle string syntax: t('text') or t(`text`) + _ => { + let static_string = extract_static_string(&arg0.expr); + if let Some(static_string) = static_string { + message_text = Some(static_string); + } else { + // Dynamic expression (Identifier, CallExpression, BinaryExpression, etc.) + warn_dynamic_expression(&arg0.expr); } } } + } - if let Some(message_text) = message_text { - let call_key = explicit_id - .unwrap_or_else(|| key_generator::KeyGenerator::generate(&message_text).into()); - let full_key = namespace.map_or(call_key.clone(), |namespace| { - [&*namespace.to_string_lossy(), &*call_key.to_string_lossy()] - .join(NAMESPACE_SEPARATOR) - .into() - }); - let line = self - .source_map - .as_ref() - .map_or(0, |sm| sm.lookup_char_pos(call.span.lo).line); - let new_reference = Reference { + let Some(message_text) = message_text else { + return; + }; + + let call_key = explicit_id + .unwrap_or_else(|| key_generator::KeyGenerator::generate(&message_text).into()); + let full_key = namespace.map_or(call_key.clone(), |namespace| { + [&*namespace.to_string_lossy(), &*call_key.to_string_lossy()] + .join(NAMESPACE_SEPARATOR) + .into() + }); + let line = self + .source_map + .as_ref() + .map_or(0, |sm| sm.lookup_char_pos(call.span.lo).line); + + self.results + .push(SourceMessage::Extracted(ExtractedMessage { + id: full_key, + message: message_text.clone(), + description, + reference: Reference { path: self.file_path.clone(), line, - }; + }, + })); + + // Transform the argument based on type + match &mut *call.args[0].expr { + Expr::Lit(Lit::Str(s)) => { + s.value = call_key; + s.raw = None; + } - self.results.push(SourceMessage { - id: full_key.clone(), - message: message_text.clone(), - description, - reference: new_reference, - }); - - // Transform the argument based on type - match &mut *call.args[0].expr { - Expr::Lit(Lit::Str(s)) => { - s.value = call_key; - s.raw = None; - } + Expr::Tpl(tpl) => { + // Replace template literal with string literal + *call.args[0].expr = Expr::Lit(Lit::Str(Str { + span: tpl.span, + value: call_key, + raw: None, + })); + } - Expr::Tpl(tpl) => { - // Replace template literal with string literal - *call.args[0].expr = Expr::Lit(Lit::Str(Str { - span: tpl.span, - value: call_key, - raw: None, - })); + Expr::Object(ObjectLit { span: obj_span, .. }) => { + // Transform object expression to individual parameters + // Replace the object with the key as first argument + + *call.args[0].expr = Expr::Lit(Lit::Str(Str { + span: *obj_span, + value: call_key, + raw: None, + })); + + // Add values as second argument if present + if let Some(values_node) = values_node { + if call.args.len() < 2 { + call.args.push(ExprOrSpread { + spread: None, + expr: values_node.clone(), + }); + } else { + call.args[1].expr = values_node.clone(); } + } - Expr::Object(ObjectLit { span: obj_span, .. }) => { - // Transform object expression to individual parameters - // Replace the object with the key as first argument - - *call.args[0].expr = Expr::Lit(Lit::Str(Str { - span: *obj_span, - value: call_key, - raw: None, - })); - - // Add values as second argument if present - if let Some(values_node) = values_node { - if call.args.len() < 2 { - call.args.push(ExprOrSpread { - spread: None, - expr: values_node.clone(), - }); - } else { - call.args[1].expr = values_node.clone(); - } - } - - // Add formats as third argument if present - if let Some(formats_node) = formats_node { - while call.args.len() < 2 { - call.args.push(Expr::undefined(DUMMY_SP).as_arg()); - } - - if call.args.len() < 3 { - call.args.push(ExprOrSpread { - spread: None, - expr: formats_node.clone(), - }); - } else { - call.args[2].expr = formats_node.clone(); - } - } + // Add formats as third argument if present + if let Some(formats_node) = formats_node { + while call.args.len() < 2 { + call.args.push(Expr::undefined(DUMMY_SP).as_arg()); } - _ => {} + if call.args.len() < 3 { + call.args.push(ExprOrSpread { + spread: None, + expr: formats_node.clone(), + }); + } else { + call.args[2].expr = formats_node.clone(); + } } + } - // Check if this is a t.has call (which doesn't need fallback) - let is_has_call = match &call.callee { - Callee::Expr(box Expr::Member(MemberExpr { - prop: MemberProp::Ident(prop), - .. - })) => prop.sym == "has", - _ => false, - }; + _ => {} + } - // Add fallback message as 4th parameter in development mode (except for t.has) - if self.is_development && !is_has_call { - while call.args.len() < 3 { - call.args.push(Expr::undefined(DUMMY_SP).as_arg()); - } + // Check if this is a t.has call (which doesn't need fallback) + let is_has_call = match &call.callee { + Callee::Expr(box Expr::Member(MemberExpr { + prop: MemberProp::Ident(prop), + .. + })) => prop.sym == "has", + _ => false, + }; + + // Add fallback message as 4th parameter in development mode (except for t.has) + if self.is_development && !is_has_call { + while call.args.len() < 3 { + call.args.push(Expr::undefined(DUMMY_SP).as_arg()); + } - call.args.push( - Str { - span: DUMMY_SP, - value: message_text, - raw: None, - } - .as_arg(), - ); + call.args.push( + Str { + span: DUMMY_SP, + value: message_text, + raw: None, } + .as_arg(), + ); + } + } +} + +impl VisitMut for TransformVisitor { + fn visit_mut_call_expr(&mut self, call: &mut CallExpr) { + match self.resolve_translator(&call.callee) { + // `useTranslations`/`getTranslations`: record the key, don't transform. + Some((TranslatorKind::Translation, namespace)) => { + self.record_translation(call, namespace); + } + // `useExtracted`/`getExtracted`: extract the inline message and rewrite. + Some((TranslatorKind::Extracted, namespace)) => { + self.extract_message(call, namespace); } + None => {} } call.visit_mut_children_with(self); } fn visit_mut_module(&mut self, module: &mut Module) { - for import in module.body.iter_mut() { - if let ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = import { - match import.src.value.as_bytes() { - b"next-intl" => { - for specifier in &mut import.specifiers { - if let ImportSpecifier::Named(named_spec) = specifier { - let orig_name = named_spec - .imported - .as_ref() - .and_then(|x| match x { - ModuleExportName::Ident(ident) => Some(ident.sym.clone()), - ModuleExportName::Str(..) => None, - }) - .unwrap_or_else(|| named_spec.local.sym.clone()) - .clone(); - - if orig_name == HookType::UseTranslation.extracted_name() { - self.hook_local_names - .insert(named_spec.local.to_id(), HookType::UseTranslation); - - named_spec.imported = Some(ModuleExportName::Ident( - HookType::UseTranslation.target_name().into(), - )); - named_spec.local = Ident::new( - HookType::UseTranslation.local_name(), - DUMMY_SP, - named_spec.local.ctxt, - ); - } - } - } - } - - b"next-intl/server" => { - for specifier in &mut import.specifiers { - if let ImportSpecifier::Named(named_spec) = specifier { - let orig_name = named_spec - .imported - .as_ref() - .and_then(|x| match x { - ModuleExportName::Ident(ident) => Some(ident.sym.clone()), - ModuleExportName::Str(_) => None, - }) - .unwrap_or_else(|| named_spec.local.sym.clone()) - .clone(); - - if orig_name == HookType::GetTranslation.extracted_name() { - self.hook_local_names - .insert(named_spec.local.to_id(), HookType::GetTranslation); - - named_spec.imported = Some(ModuleExportName::Ident( - HookType::GetTranslation.target_name().into(), - )); - named_spec.local = Ident::new( - HookType::GetTranslation.local_name(), - DUMMY_SP, - named_spec.local.ctxt, - ); - } - } - } - } - - _ => {} + for item in module.body.iter_mut() { + let ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = item else { + continue; + }; + let extracted_hook = match import.src.value.as_bytes() { + b"next-intl" => ExtractedHook::Client, + b"next-intl/server" => ExtractedHook::Server, + _ => continue, + }; + + for specifier in &mut import.specifiers { + let ImportSpecifier::Named(named_spec) = specifier else { + continue; + }; + let orig_name = named_spec + .imported + .as_ref() + .and_then(|name| match name { + ModuleExportName::Ident(ident) => Some(ident.sym.clone()), + ModuleExportName::Str(..) => None, + }) + .unwrap_or_else(|| named_spec.local.sym.clone()); + + if orig_name == extracted_hook.imported_name() { + // Track and rewrite `useExtracted` / `getExtracted`. + self.hook_local_names + .insert(named_spec.local.to_id(), Hook::Extracted(extracted_hook)); + named_spec.imported = + Some(ModuleExportName::Ident(extracted_hook.target_name().into())); + named_spec.local = + Ident::new(extracted_hook.local_name(), DUMMY_SP, named_spec.local.ctxt); + } else if orig_name == extracted_hook.target_name() { + // Track plain `useTranslations` / `getTranslations`. + self.hook_local_names + .insert(named_spec.local.to_id(), Hook::Translation); } } } @@ -417,68 +464,44 @@ impl VisitMut for TransformVisitor { fn visit_mut_var_declarator(&mut self, node: &mut VarDeclarator) { if let Some(name) = node.name.as_ident() { - let mut call_expr = None; - - // Handle direct CallExpression: const t = useExtracted(); - if let Some(init) = &mut node.init { - match &mut **init { - Expr::Call(init_call) => { - if let Callee::Expr(box Expr::Ident(callee)) = &init_call.callee { - if let Some(hook_type) = self.hook_local_names.get(&callee.to_id()) { - init_call.callee = Callee::Expr( - Ident::new(hook_type.local_name(), DUMMY_SP, callee.ctxt) - .into(), - ); - call_expr = Some(init_call); - } - } - } - + // Unwrap `await getX(...)` and direct `useX(...)` initializers. + let call = match &mut **init { + Expr::Call(call) => Some(call), Expr::Await(AwaitExpr { - arg: box Expr::Call(arg), + arg: box Expr::Call(call), .. - }) => { - if let CallExpr { - callee: Callee::Expr(box Expr::Ident(callee)), - .. - } = &*arg - { - if let Some(hook_type) = self.hook_local_names.get(&callee.to_id()) { - arg.callee = Callee::Expr( - Ident::new(hook_type.local_name(), DUMMY_SP, callee.ctxt) - .into(), + }) => Some(call), + _ => None, + }; + + if let Some(call) = call { + let binding = match &call.callee { + Callee::Expr(box Expr::Ident(callee)) => self + .hook_local_names + .get(&callee.to_id()) + .copied() + .map(|hook| (hook, callee.ctxt)), + _ => None, + }; + + if let Some((hook, ctxt)) = binding { + let kind = match hook { + Hook::Extracted(extracted_hook) => { + // Rewrite the callee to the real (aliased) hook. + call.callee = Callee::Expr( + Ident::new(extracted_hook.local_name(), DUMMY_SP, ctxt).into(), ); - call_expr = Some(arg); + TranslatorKind::Extracted } - } + Hook::Translation => TranslatorKind::Translation, + }; + let namespace = namespace_of_call(call); + self.translator_map + .insert(name.to_id(), Translator { kind, namespace }); } - - _ => {} } } - - if let Some(call_expr) = call_expr { - let namespace = call_expr.args.first().and_then(|arg| match &*arg.expr { - Expr::Lit(Lit::Str(s)) => Some(s.value.clone()), - Expr::Object(ObjectLit { props, .. }) => props.iter().find_map(|prop| { - let prop = prop.as_prop()?.as_key_value()?; - match &prop.key { - PropName::Ident(ident) => { - if ident.sym == "namespace" { - Some(extract_static_string(&prop.value)) - } else { - None - } - } - _ => None, - } - })?, - _ => None, - }); - - self.define_translator(name.to_id(), namespace) - } } node.visit_mut_children_with(self); @@ -498,6 +521,23 @@ fn warn_dynamic_expression(expr: &Expr) { }) } +/// The namespace passed to a `useTranslations('ns')` / `getTranslations({namespace})` call. +fn namespace_of_call(call: &CallExpr) -> Option { + call.args.first().and_then(|arg| match &*arg.expr { + Expr::Lit(Lit::Str(s)) => Some(s.value.clone()), + Expr::Object(ObjectLit { props, .. }) => props.iter().find_map(|prop| { + let prop = prop.as_prop()?.as_key_value()?; + match &prop.key { + PropName::Ident(ident) if ident.sym == "namespace" => { + Some(extract_static_string(&prop.value)) + } + _ => None, + } + })?, + _ => None, + }) +} + fn extract_static_string(value: &Expr) -> Option { match value { Expr::Lit(Lit::Str(s)) => Some(s.value.clone()), diff --git a/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm b/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm index 5bd7c74aa..fe5e836ab 100755 Binary files a/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm and b/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm differ diff --git a/packages/swc-plugin-extractor/tests/fixture.rs b/packages/swc-plugin-extractor/tests/fixture.rs index 0c5e2dbaa..197c29c4e 100644 --- a/packages/swc-plugin-extractor/tests/fixture.rs +++ b/packages/swc-plugin-extractor/tests/fixture.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; use serde_json::Value; @@ -105,20 +105,34 @@ fn test(input: PathBuf) { program.visit_mut_with(&mut visitor); // Use results directly from visitor - it calculates line numbers correctly with SourceMap - let actual_results = visitor.get_results(); - let actual_json: Value = serde_json::to_value(&actual_results).unwrap(); - - let expected_json_str = fs::read_to_string(&output_json) - .unwrap_or_else(|_| panic!("Expected output.json not found at {:?}", output_json)); - let expected_json: Value = serde_json::from_str(&expected_json_str) - .unwrap_or_else(|_| panic!("Failed to parse expected JSON at {:?}", output_json)); - - if actual_json != expected_json { - panic!( - "JSON output mismatch.\nExpected:\n{}\nActual:\n{}", - serde_json::to_string_pretty(&expected_json).unwrap(), - serde_json::to_string_pretty(&actual_json).unwrap() - ); - } + let update = std::env::var("UPDATE").as_deref() == Ok("1"); + let actual_results = serde_json::to_value(visitor.get_results()).unwrap(); + assert_json(&output_json, &actual_results, update); }); } + +/// Compares `actual` against the JSON at `path`, or rewrites it with `UPDATE=1`. +fn assert_json(path: &Path, actual: &Value, update: bool) { + if update { + fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(actual).unwrap()), + ) + .unwrap(); + return; + } + + let expected: Value = serde_json::from_str( + &fs::read_to_string(path).unwrap_or_else(|_| panic!("Expected {:?} not found", path)), + ) + .unwrap_or_else(|_| panic!("Failed to parse {:?}", path)); + + if actual != &expected { + panic!( + "JSON mismatch at {:?}.\nExpected:\n{}\nActual:\n{}", + path, + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(actual).unwrap() + ); + } +} diff --git a/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json b/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json index fd3f51acc..fc90c612a 100644 --- a/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "-YJVTi", "message": "Hey!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json b/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json index b07e2d8cd..6c36d4b02 100644 --- a/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "0KGiQf", "message": "Hello there!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json b/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json index 9b41e8221..3939e7724 100644 --- a/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello {name}!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json b/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json index b07e2d8cd..6c36d4b02 100644 --- a/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "0KGiQf", "message": "Hello there!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json b/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json index 64b9ad8e6..e13c7c8e9 100644 --- a/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "ui.OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json b/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json index b07e2d8cd..6c36d4b02 100644 --- a/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "0KGiQf", "message": "Hello there!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/basic/output.json b/packages/swc-plugin-extractor/tests/fixture/basic/output.json index fd3f51acc..fc90c612a 100644 --- a/packages/swc-plugin-extractor/tests/fixture/basic/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/basic/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "-YJVTi", "message": "Hey!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/date-format/output.json b/packages/swc-plugin-extractor/tests/fixture/date-format/output.json index 41839e12e..afaeb4dab 100644 --- a/packages/swc-plugin-extractor/tests/fixture/date-format/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/date-format/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "5n-ZPU", "message": "{date, date, short}!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json index 973cc9cbf..4704f6518 100644 --- a/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json @@ -1,20 +1,22 @@ [ { + "description": null, "id": "OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 10 - } + "line": 10, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json b/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json index 5c53d2532..ebccbac03 100644 --- a/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json @@ -1,11 +1,20 @@ [ { + "description": null, "id": "piskIR", "message": "Hello from extracted!", - "description": null, "reference": { - "path": "input.js", - "line": 6 - } + "line": 6, + "path": "input.js" + }, + "type": "extracted" + }, + { + "id": "greeting", + "reference": { + "line": 7, + "path": "input.js" + }, + "type": "translation" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json b/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json index 5c53d2532..ebccbac03 100644 --- a/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json @@ -1,11 +1,20 @@ [ { + "description": null, "id": "piskIR", "message": "Hello from extracted!", - "description": null, "reference": { - "path": "input.js", - "line": 6 - } + "line": 6, + "path": "input.js" + }, + "type": "extracted" + }, + { + "id": "greeting", + "reference": { + "line": 7, + "path": "input.js" + }, + "type": "translation" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/get-translations/input.js b/packages/swc-plugin-extractor/tests/fixture/get-translations/input.js new file mode 100644 index 000000000..aff63fbc5 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/get-translations/input.js @@ -0,0 +1,6 @@ +import {getTranslations} from 'next-intl/server'; + +async function Page() { + const t = await getTranslations('Account'); + t('name'); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/get-translations/output.js b/packages/swc-plugin-extractor/tests/fixture/get-translations/output.js new file mode 100644 index 000000000..3194728e4 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/get-translations/output.js @@ -0,0 +1,5 @@ +import { getTranslations } from 'next-intl/server'; +async function Page() { + const t = await getTranslations('Account'); + t('name'); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/get-translations/output.json b/packages/swc-plugin-extractor/tests/fixture/get-translations/output.json new file mode 100644 index 000000000..b1383cb59 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/get-translations/output.json @@ -0,0 +1,10 @@ +[ + { + "id": "Account.name", + "reference": { + "line": 5, + "path": "input.js" + }, + "type": "translation" + } +] diff --git a/packages/swc-plugin-extractor/tests/fixture/get-translations/output.map b/packages/swc-plugin-extractor/tests/fixture/get-translations/output.map new file mode 100644 index 000000000..d1a24932f --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/get-translations/output.map @@ -0,0 +1 @@ +{"version":3,"sources":["input.js"],"sourcesContent":["import {getTranslations} from 'next-intl/server';\n\nasync function Page() {\n const t = await getTranslations('Account');\n t('name');\n}\n"],"names":[],"mappings":"AAAA,SAAQ,eAAe,QAAO,mBAAmB;AAEjD,eAAe;IACb,MAAM,IAAI,MAAM,gBAAgB;IAChC,EAAE;AACJ"} diff --git a/packages/swc-plugin-extractor/tests/fixture/let/output.json b/packages/swc-plugin-extractor/tests/fixture/let/output.json index fd3f51acc..fc90c612a 100644 --- a/packages/swc-plugin-extractor/tests/fixture/let/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/let/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "-YJVTi", "message": "Hey!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json b/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json index 11b4f0824..7b4aab38b 100644 --- a/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json @@ -1,74 +1,82 @@ [ { + "description": null, "id": "tnuBMt", "message": "Page title", - "description": null, "reference": { - "path": "input.js", - "line": 7 - } + "line": 7, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 13 - } + "line": 13, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "mOPTEA", "message": "Server data message", - "description": null, "reference": { - "path": "input.js", - "line": 18 - } + "line": 18, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "MgvtBu", "message": "Component message", - "description": null, "reference": { - "path": "input.js", - "line": 23 - } + "line": 23, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "sJK5Uk", "message": "Another one 1", - "description": null, "reference": { - "path": "input.js", - "line": 28 - } + "line": 28, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "2k7cS1", "message": "Another one 2", - "description": null, "reference": { - "path": "input.js", - "line": 33 - } + "line": 33, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "another.6jb0KP", "message": "Two 1", - "description": null, "reference": { - "path": "input.js", - "line": 38 - } + "line": 38, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "another.KVQtmd", "message": "Two 2", - "description": null, "reference": { - "path": "input.js", - "line": 43 - } + "line": 43, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/namespace/output.json b/packages/swc-plugin-extractor/tests/fixture/namespace/output.json index 64b9ad8e6..e13c7c8e9 100644 --- a/packages/swc-plugin-extractor/tests/fixture/namespace/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/namespace/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "ui.OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json index 6266a1dd6..b4eea95f6 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json index 6266a1dd6..b4eea95f6 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json index b1355a6bf..e3c7e99e6 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "ui.greeting", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json index 95f2e165b..829b24606 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello Alice!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json index 6266a1dd6..b4eea95f6 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json index 6266a1dd6..b4eea95f6 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json index 6266a1dd6..b4eea95f6 100644 --- a/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "greeting", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json b/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json index 9c4b44d77..d998af4c5 100644 --- a/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json @@ -1,29 +1,32 @@ [ { + "description": null, "id": "OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "-YJVTi", "message": "Hey!", - "description": null, "reference": { - "path": "input.js", - "line": 6 - } + "line": 6, + "path": "input.js" + }, + "type": "extracted" }, { + "description": null, "id": "nm_7yQ", "message": "Hi!", - "description": null, "reference": { - "path": "input.js", - "line": 7 - } + "line": 7, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/rename/output.json b/packages/swc-plugin-extractor/tests/fixture/rename/output.json index 96094b2e0..046b9ac5d 100644 --- a/packages/swc-plugin-extractor/tests/fixture/rename/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/rename/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "OpKKos", "message": "Hello!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/shadow/output.json b/packages/swc-plugin-extractor/tests/fixture/shadow/output.json index fd3f51acc..fc90c612a 100644 --- a/packages/swc-plugin-extractor/tests/fixture/shadow/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/shadow/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "-YJVTi", "message": "Hey!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/t-has/output.json b/packages/swc-plugin-extractor/tests/fixture/t-has/output.json index 1ce5539bc..bdf83c9f1 100644 --- a/packages/swc-plugin-extractor/tests/fixture/t-has/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/t-has/output.json @@ -6,7 +6,8 @@ "reference": { "line": 5, "path": "input.js" - } + }, + "type": "extracted" }, { "description": null, @@ -15,7 +16,8 @@ "reference": { "line": 6, "path": "input.js" - } + }, + "type": "extracted" }, { "description": null, @@ -24,6 +26,7 @@ "reference": { "line": 8, "path": "input.js" - } + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json b/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json index a84a98d47..26b0aa90c 100644 --- a/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "C-nN8a", "message": "Hello Alice!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json b/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json index a84a98d47..26b0aa90c 100644 --- a/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "C-nN8a", "message": "Hello Alice!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ] diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/input.js b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/input.js new file mode 100644 index 000000000..55a983f38 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/input.js @@ -0,0 +1,12 @@ +import {useTranslations} from 'next-intl'; + +function Component() { + const t = useTranslations('Namespace'); + const g = useTranslations(); + const key = 'about'; + + // Dynamic key under a namespace: only the namespace is statically known. + t(key); + // Dynamic key in the global namespace: nothing is statically analyzable. + g(key); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.js b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.js new file mode 100644 index 000000000..fefea484e --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.js @@ -0,0 +1,10 @@ +import { useTranslations } from 'next-intl'; +function Component() { + const t = useTranslations('Namespace'); + const g = useTranslations(); + const key = 'about'; + // Dynamic key under a namespace: only the namespace is statically known. + t(key); + // Dynamic key in the global namespace: nothing is statically analyzable. + g(key); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.json b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.json new file mode 100644 index 000000000..7606ae3eb --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.json @@ -0,0 +1,10 @@ +[ + { + "id": "Namespace", + "reference": { + "line": 9, + "path": "input.js" + }, + "type": "translation" + } +] diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.map b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.map new file mode 100644 index 000000000..6289d8ba3 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-dynamic-key/output.map @@ -0,0 +1 @@ +{"version":3,"sources":["input.js"],"sourcesContent":["import {useTranslations} from 'next-intl';\n\nfunction Component() {\n const t = useTranslations('Namespace');\n const g = useTranslations();\n const key = 'about';\n\n // Dynamic key under a namespace: only the namespace is statically known.\n t(key);\n // Dynamic key in the global namespace: nothing is statically analyzable.\n g(key);\n}\n"],"names":[],"mappings":"AAAA,SAAQ,eAAe,QAAO,YAAY;AAE1C,SAAS;IACP,MAAM,IAAI,gBAAgB;IAC1B,MAAM,IAAI;IACV,MAAM,MAAM;IAEZ,yEAAyE;IACzE,EAAE;IACF,yEAAyE;IACzE,EAAE;AACJ"} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/input.js b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/input.js new file mode 100644 index 000000000..57a3edc8e --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/input.js @@ -0,0 +1,9 @@ +import {useExtracted, useTranslations} from 'next-intl'; + +function Component() { + const e = useExtracted(); + const t = useTranslations('Namespace'); + + e('Hello!'); + t('title'); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.js b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.js new file mode 100644 index 000000000..35c51eeaf --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.js @@ -0,0 +1,7 @@ +import { useTranslations as useTranslations$1, useTranslations } from 'next-intl'; +function Component() { + const e = useTranslations$1(); + const t = useTranslations('Namespace'); + e("OpKKos", void 0, void 0, "Hello!"); + t('title'); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.json b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.json new file mode 100644 index 000000000..23c935fd6 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.json @@ -0,0 +1,20 @@ +[ + { + "description": null, + "id": "OpKKos", + "message": "Hello!", + "reference": { + "line": 7, + "path": "input.js" + }, + "type": "extracted" + }, + { + "id": "Namespace.title", + "reference": { + "line": 8, + "path": "input.js" + }, + "type": "translation" + } +] diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.map b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.map new file mode 100644 index 000000000..6456a1c53 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations-with-extracted/output.map @@ -0,0 +1 @@ +{"version":3,"sources":["input.js"],"sourcesContent":["import {useExtracted, useTranslations} from 'next-intl';\n\nfunction Component() {\n const e = useExtracted();\n const t = useTranslations('Namespace');\n\n e('Hello!');\n t('title');\n}\n"],"names":[],"mappings":"AAAA,SAAQ,oCAAY,EAAE,eAAe,QAAO,YAAY;AAExD,SAAS;IACP,MAAM,IAAI;IACV,MAAM,IAAI,gBAAgB;IAE1B,EAAE;IACF,EAAE;AACJ"} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations/input.js b/packages/swc-plugin-extractor/tests/fixture/use-translations/input.js new file mode 100644 index 000000000..41d4a6d5e --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations/input.js @@ -0,0 +1,13 @@ +import {useTranslations} from 'next-intl'; + +function Component() { + const t = useTranslations('Namespace'); + const g = useTranslations(); + + t('title'); + t.rich('rich'); + t.markup('markup'); + t.has('has'); + t.raw('raw'); + g('global'); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations/output.js b/packages/swc-plugin-extractor/tests/fixture/use-translations/output.js new file mode 100644 index 000000000..ab05f443e --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations/output.js @@ -0,0 +1,11 @@ +import { useTranslations } from 'next-intl'; +function Component() { + const t = useTranslations('Namespace'); + const g = useTranslations(); + t('title'); + t.rich('rich'); + t.markup('markup'); + t.has('has'); + t.raw('raw'); + g('global'); +} diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations/output.json b/packages/swc-plugin-extractor/tests/fixture/use-translations/output.json new file mode 100644 index 000000000..984240126 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations/output.json @@ -0,0 +1,50 @@ +[ + { + "id": "Namespace.title", + "reference": { + "line": 7, + "path": "input.js" + }, + "type": "translation" + }, + { + "id": "Namespace.rich", + "reference": { + "line": 8, + "path": "input.js" + }, + "type": "translation" + }, + { + "id": "Namespace.markup", + "reference": { + "line": 9, + "path": "input.js" + }, + "type": "translation" + }, + { + "id": "Namespace.has", + "reference": { + "line": 10, + "path": "input.js" + }, + "type": "translation" + }, + { + "id": "Namespace.raw", + "reference": { + "line": 11, + "path": "input.js" + }, + "type": "translation" + }, + { + "id": "global", + "reference": { + "line": 12, + "path": "input.js" + }, + "type": "translation" + } +] diff --git a/packages/swc-plugin-extractor/tests/fixture/use-translations/output.map b/packages/swc-plugin-extractor/tests/fixture/use-translations/output.map new file mode 100644 index 000000000..4f77233e3 --- /dev/null +++ b/packages/swc-plugin-extractor/tests/fixture/use-translations/output.map @@ -0,0 +1 @@ +{"version":3,"sources":["input.js"],"sourcesContent":["import {useTranslations} from 'next-intl';\n\nfunction Component() {\n const t = useTranslations('Namespace');\n const g = useTranslations();\n\n t('title');\n t.rich('rich');\n t.markup('markup');\n t.has('has');\n t.raw('raw');\n g('global');\n}\n"],"names":[],"mappings":"AAAA,SAAQ,eAAe,QAAO,YAAY;AAE1C,SAAS;IACP,MAAM,IAAI,gBAAgB;IAC1B,MAAM,IAAI;IAEV,EAAE;IACF,EAAE,IAAI,CAAC;IACP,EAAE,MAAM,CAAC;IACT,EAAE,GAAG,CAAC;IACN,EAAE,GAAG,CAAC;IACN,EAAE;AACJ"} diff --git a/packages/swc-plugin-extractor/tests/fixture/values/output.json b/packages/swc-plugin-extractor/tests/fixture/values/output.json index b4c395de6..3169d0e01 100644 --- a/packages/swc-plugin-extractor/tests/fixture/values/output.json +++ b/packages/swc-plugin-extractor/tests/fixture/values/output.json @@ -1,11 +1,12 @@ [ { + "description": null, "id": "tBFOH1", "message": "Hello, {name}!", - "description": null, "reference": { - "path": "input.js", - "line": 5 - } + "line": 5, + "path": "input.js" + }, + "type": "extracted" } ]