next-tongue 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,19 +82,19 @@ export function useT(namespace) {
82
82
  });
83
83
  }
84
84
  /**
85
- * Stub used when the call-site loader owns message delivery. Calling the
86
- * function (or .rich / .dynamic / .ns) means a key was not rewritten — usually
87
- * a missing loader or a non-literal key without `t.dynamic({ from })`.
85
+ * Stub used when the call-site loader owns message delivery. Literal `t('key')`
86
+ * and `t.dynamic(key, { from })` should be rewritten by the loader; if you still
87
+ * hit this, the rewrite did not run (or a key was not in `from`).
88
88
  */
89
89
  function createLoaderStubT(locale, ns) {
90
- const fail = () => {
91
- throw new TongueError('TONGUE_E_NO_PROVIDER', `useT('${ns}') call was not rewritten to a per-key import. Ensure withTongue() is applied, or pass loadNamespace to TongueProvider. Prefer getT on the server for static copy; for dynamic keys use t.dynamic(key, { from: [...] }).`);
90
+ const fail = (kind) => {
91
+ throw new TongueError('TONGUE_E_NO_PROVIDER', `useT('${ns}') ${kind} was not rewritten to a per-key import. Ensure withTongue() is applied (loader on SSR + client). Prefer getT on the server for static copy; for dynamic keys use t.dynamic(key, { from: [...] }) so the loader can import those keys.`);
92
92
  };
93
- const t = Object.assign((..._args) => fail(), {
94
- rich: fail,
93
+ const t = Object.assign((..._args) => fail('t()'), {
94
+ rich: () => fail('t.rich()'),
95
95
  has: () => false,
96
- dynamic: fail,
97
- ns: fail,
96
+ dynamic: () => fail('t.dynamic()'),
97
+ ns: () => fail('t.ns()'),
98
98
  });
99
99
  void locale;
100
100
  return t;
@@ -6,6 +6,8 @@
6
6
  * const t = useT('home'); t('inbox', { count })
7
7
  * into a static import of the per-key module when the key is a string literal.
8
8
  *
9
+ * Also rewrites `t.dynamic(key, { from: [...] })` to a map of those imports.
10
+ *
9
11
  * Escape analysis: if `t` is passed around, falls back to namespace import + warning.
10
12
  */
11
13
  export type LoaderOptions = {
@@ -17,6 +19,14 @@ export type LoaderResult = {
17
19
  warnings: string[];
18
20
  };
19
21
  export declare function transformCallSites(source: string, resourcePath: string, _options?: LoaderOptions): LoaderResult;
22
+ /**
23
+ * Peel leading `"use client"` / `"use server"` (and preceding comments/whitespace)
24
+ * so we can re-emit them before any injected imports.
25
+ */
26
+ export declare function splitLeadingDirectives(source: string): {
27
+ directives: string;
28
+ body: string;
29
+ };
20
30
  /** Webpack/Turbopack loader entry. */
21
31
  export default function tongueLoader(this: {
22
32
  resourcePath: string;
@@ -6,6 +6,8 @@
6
6
  * const t = useT('home'); t('inbox', { count })
7
7
  * into a static import of the per-key module when the key is a string literal.
8
8
  *
9
+ * Also rewrites `t.dynamic(key, { from: [...] })` to a map of those imports.
10
+ *
9
11
  * Escape analysis: if `t` is passed around, falls back to namespace import + warning.
10
12
  */
11
13
  const USE_T_DECL = /(?:const|let|var)\s+(\w+)\s*=\s*useT\s*\(\s*(['"])([^'"]*)\2\s*\)/g;
@@ -14,10 +16,11 @@ export function transformCallSites(source, resourcePath, _options = {}) {
14
16
  if (!source.includes('useT(')) {
15
17
  return { code: source, warnings };
16
18
  }
19
+ const { directives, body: withoutDirs } = splitLeadingDirectives(source);
17
20
  const translators = new Map(); // varName → namespace
18
21
  let m;
19
22
  const declRe = new RegExp(USE_T_DECL.source, 'g');
20
- while ((m = declRe.exec(source)) !== null) {
23
+ while ((m = declRe.exec(withoutDirs)) !== null) {
21
24
  translators.set(m[1], m[3] || '_root');
22
25
  }
23
26
  if (translators.size === 0) {
@@ -25,21 +28,36 @@ export function transformCallSites(source, resourcePath, _options = {}) {
25
28
  }
26
29
  // Escape analysis: if t is used beyond property calls, warn and skip rewrite
27
30
  for (const [varName, ns] of translators) {
28
- if (escapes(source, varName)) {
31
+ if (escapes(withoutDirs, varName)) {
29
32
  warnings.push(`TONGUE_W_ESCAPE: ${resourcePath}: '${varName}' from useT('${ns}') escapes this file; importing whole namespace`);
30
33
  }
31
34
  }
32
35
  const imports = new Map();
33
- let code = source;
36
+ let code = withoutDirs;
34
37
  for (const [varName, ns] of translators) {
35
- if (escapes(source, varName))
38
+ if (escapes(withoutDirs, varName))
36
39
  continue;
40
+ // Literal t('key') / t.rich('key')
37
41
  const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
38
42
  code = code.replace(callRe, (full, _q, key) => {
39
43
  const ident = safeImportIdent(ns, key);
40
44
  imports.set(`${ns}:${key}`, { ns, key, ident });
41
45
  return full;
42
46
  });
47
+ // t.dynamic(expr, { from: ['a', 'b'] }, values?)
48
+ const dynRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
49
+ let dm;
50
+ while ((dm = dynRe.exec(withoutDirs)) !== null) {
51
+ const tail = withoutDirs.slice(dm.index, dm.index + 500);
52
+ const fromMatch = /from\s*:\s*\[([^\]]*)\]/.exec(tail);
53
+ if (!fromMatch)
54
+ continue;
55
+ const keys = [...fromMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
56
+ for (const key of keys) {
57
+ const ident = safeImportIdent(ns, key);
58
+ imports.set(`${ns}:${key}`, { ns, key, ident });
59
+ }
60
+ }
43
61
  }
44
62
  if (imports.size === 0) {
45
63
  return { code: source, warnings };
@@ -54,9 +72,29 @@ export function transformCallSites(source, resourcePath, _options = {}) {
54
72
  const localeVar = '__tongue_locale';
55
73
  // Inject locale once after the first useT declaration
56
74
  code = code.replace(/((?:const|let|var)\s+\w+\s*=\s*useT\s*\([^)]*\))/, `$1\n const ${localeVar} = useLocale();`);
57
- // Second pass: replace t('key', values) / t.rich('key', values) with per-key calls
75
+ // Replace t.dynamic(expr, { from: [...] }, values?)
76
+ for (const [varName, ns] of translators) {
77
+ if (escapes(withoutDirs, varName))
78
+ continue;
79
+ const dynCallRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(\\s*([^,]+?)\\s*,\\s*\\{[^}]*?from\\s*:\\s*\\[([^\\]]*)][^}]*\\}\\s*(?:,\\s*([^)]*?))?\\)`, 'g');
80
+ code = code.replace(dynCallRe, (_full, keyExpr, fromBody, values) => {
81
+ const keys = [...fromBody.matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
82
+ const mapEntries = keys
83
+ .map((key) => {
84
+ const ident = safeImportIdent(ns, key);
85
+ imports.set(`${ns}:${key}`, { ns, key, ident });
86
+ return `${JSON.stringify(key)}: ${ident}`;
87
+ })
88
+ .join(', ');
89
+ const valuesArg = values != null && String(values).trim() !== ''
90
+ ? `, ${String(values).trim()}`
91
+ : '';
92
+ return `((__tongue_dyn) => { const __tongue_fn = __tongue_dyn[${keyExpr.trim()}]; if (!__tongue_fn) throw new Error(${JSON.stringify(`[next-tongue] t.dynamic key not in from: [${keys.join(', ')}]`)}); return __tongue_fn(${localeVar}${valuesArg}); })({ ${mapEntries} })`;
93
+ });
94
+ }
95
+ // Replace t('key', values) / t.rich('key', values) with per-key calls
58
96
  for (const [varName, ns] of translators) {
59
- if (escapes(source, varName))
97
+ if (escapes(withoutDirs, varName))
60
98
  continue;
61
99
  for (const { key, ident } of [...imports.values()].filter((i) => i.ns === ns)) {
62
100
  const richRe = new RegExp(`\\b${escapeRegExp(varName)}\\.rich\\s*\\(\\s*(['"])${escapeRegExp(key)}\\1\\s*(,\\s*([^)]*))?\\)`, 'g');
@@ -85,13 +123,40 @@ export function transformCallSites(source, resourcePath, _options = {}) {
85
123
  preamble += `import { useLocale } from 'next-tongue/client';\n`;
86
124
  }
87
125
  for (const [varName, ns] of translators) {
88
- if (!escapes(source, varName))
126
+ if (!escapes(withoutDirs, varName))
89
127
  continue;
90
128
  preamble +=
91
129
  `process.env.NODE_ENV !== 'production' && globalThis.__tongueDevEscape?.(` +
92
130
  `${JSON.stringify(ns || '_root')}, ${JSON.stringify(resourcePath)});\n`;
93
131
  }
94
- return { code: preamble + code, warnings };
132
+ // "use client" / "use server" MUST be the first statement(s) — never after imports.
133
+ return { code: directives + preamble + code, warnings };
134
+ }
135
+ /**
136
+ * Peel leading `"use client"` / `"use server"` (and preceding comments/whitespace)
137
+ * so we can re-emit them before any injected imports.
138
+ */
139
+ export function splitLeadingDirectives(source) {
140
+ let rest = source.replace(/^\uFEFF/, '');
141
+ const found = [];
142
+ while (rest.length > 0) {
143
+ // leading whitespace / comments
144
+ const lead = rest.match(/^((?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*)/);
145
+ const leadLen = lead?.[0]?.length ?? 0;
146
+ const afterLead = rest.slice(leadLen);
147
+ const dir = afterLead.match(/^['"]use (client|server)['"];?[ \t]*(?:\r?\n)?/);
148
+ if (!dir)
149
+ break;
150
+ found.push(`"use ${dir[1]}";`);
151
+ rest = afterLead.slice(dir[0].length);
152
+ }
153
+ if (found.length === 0) {
154
+ return { directives: '', body: source.replace(/^\uFEFF/, '') };
155
+ }
156
+ return {
157
+ directives: found.join('\n') + '\n\n',
158
+ body: rest,
159
+ };
95
160
  }
96
161
  function escapes(source, varName) {
97
162
  const v = escapeRegExp(varName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",