next-tongue 0.9.5 → 0.9.7

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: [...] }) with an inline array or a same-file const (imported from arrays are not rewritten).`);
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;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve `t.dynamic(key, { from })` key lists for the call-site loader and
3
+ * `tongue check` scan. Supports:
4
+ * - inline arrays: `{ from: ['a', 'b'] }`
5
+ * - same-file const arrays: `const X = ['a', 'b']; { from: X }`
6
+ * - Object.values of same-file const objects: `{ from: Object.values(BTN_KEYS) }`
7
+ *
8
+ * Imported / computed `from` expressions are not resolved (left as stubs).
9
+ */
10
+ export declare function extractStringLiterals(body: string): string[];
11
+ /** Take a balanced `[...]` / `{...}` / `(...)` starting at `source[start]`. */
12
+ export declare function takeBalanced(source: string, start: number, open: string, close: string): string | null;
13
+ /**
14
+ * Resolve keys from a `from` expression text (already sliced out of the call).
15
+ * Returns null when the expression cannot be resolved statically in-file.
16
+ */
17
+ export declare function resolveFromKeys(fileSource: string, fromExpr: string): string[] | null;
18
+ export type DynamicCallSite = {
19
+ /** Start of `t.dynamic` */
20
+ start: number;
21
+ /** End after closing `)` */
22
+ end: number;
23
+ keyExpr: string;
24
+ fromExpr: string;
25
+ valuesExpr: string | null;
26
+ keys: string[] | null;
27
+ };
28
+ /**
29
+ * Find `varName.dynamic(...)` call sites and resolve their `from` keys.
30
+ */
31
+ export declare function findDynamicCalls(source: string, varName: string): DynamicCallSite[];
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Resolve `t.dynamic(key, { from })` key lists for the call-site loader and
3
+ * `tongue check` scan. Supports:
4
+ * - inline arrays: `{ from: ['a', 'b'] }`
5
+ * - same-file const arrays: `const X = ['a', 'b']; { from: X }`
6
+ * - Object.values of same-file const objects: `{ from: Object.values(BTN_KEYS) }`
7
+ *
8
+ * Imported / computed `from` expressions are not resolved (left as stubs).
9
+ */
10
+ export function extractStringLiterals(body) {
11
+ return [...body.matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]);
12
+ }
13
+ /** Take a balanced `[...]` / `{...}` / `(...)` starting at `source[start]`. */
14
+ export function takeBalanced(source, start, open, close) {
15
+ if (source[start] !== open)
16
+ return null;
17
+ let depth = 0;
18
+ let inStr = null;
19
+ let escape = false;
20
+ for (let i = start; i < source.length; i++) {
21
+ const c = source[i];
22
+ if (inStr) {
23
+ if (escape) {
24
+ escape = false;
25
+ continue;
26
+ }
27
+ if (c === '\\' && inStr !== '`') {
28
+ escape = true;
29
+ continue;
30
+ }
31
+ if (c === inStr)
32
+ inStr = null;
33
+ continue;
34
+ }
35
+ if (c === '"' || c === "'" || c === '`') {
36
+ inStr = c;
37
+ continue;
38
+ }
39
+ if (c === open)
40
+ depth++;
41
+ else if (c === close) {
42
+ depth--;
43
+ if (depth === 0)
44
+ return source.slice(start, i + 1);
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ function escapeRegExp(s) {
50
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
+ }
52
+ /** Index of `ident`'s `const|let|var` initializer expression, or -1. */
53
+ function findDeclInit(source, ident) {
54
+ const re = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(ident)}\\s*=\\s*`, 'g');
55
+ const m = re.exec(source);
56
+ return m ? m.index + m[0].length : -1;
57
+ }
58
+ function skipWs(source, i) {
59
+ while (/\s/.test(source[i] ?? ''))
60
+ i++;
61
+ return i;
62
+ }
63
+ function resolveConstArrayKeys(source, ident) {
64
+ const init = findDeclInit(source, ident);
65
+ if (init < 0)
66
+ return null;
67
+ const arr = takeBalanced(source, skipWs(source, init), '[', ']');
68
+ if (!arr)
69
+ return null;
70
+ return extractStringLiterals(arr.slice(1, -1));
71
+ }
72
+ function resolveObjectValueKeys(source, ident) {
73
+ const init = findDeclInit(source, ident);
74
+ if (init < 0)
75
+ return null;
76
+ // Type assertions (`as const`) sit after the literal; object is first.
77
+ const obj = takeBalanced(source, skipWs(source, init), '{', '}');
78
+ if (!obj)
79
+ return null;
80
+ return [...obj.matchAll(/:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
81
+ }
82
+ /**
83
+ * Resolve keys from a `from` expression text (already sliced out of the call).
84
+ * Returns null when the expression cannot be resolved statically in-file.
85
+ */
86
+ export function resolveFromKeys(fileSource, fromExpr) {
87
+ const trimmed = fromExpr.trim();
88
+ if (!trimmed)
89
+ return null;
90
+ if (trimmed.startsWith('[')) {
91
+ const arr = takeBalanced(trimmed, 0, '[', ']');
92
+ if (!arr)
93
+ return null;
94
+ return extractStringLiterals(arr.slice(1, -1));
95
+ }
96
+ const ov = /^Object\.values\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(trimmed);
97
+ if (ov) {
98
+ const keys = resolveObjectValueKeys(fileSource, ov[1]);
99
+ return keys && keys.length > 0 ? keys : null;
100
+ }
101
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
102
+ const keys = resolveConstArrayKeys(fileSource, trimmed);
103
+ return keys && keys.length > 0 ? keys : null;
104
+ }
105
+ return null;
106
+ }
107
+ /**
108
+ * Find `varName.dynamic(...)` call sites and resolve their `from` keys.
109
+ */
110
+ export function findDynamicCalls(source, varName) {
111
+ const re = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
112
+ const out = [];
113
+ let m;
114
+ while ((m = re.exec(source)) !== null) {
115
+ const openParen = m.index + m[0].length - 1;
116
+ const parsed = parseDynamicArgs(source, openParen);
117
+ if (!parsed)
118
+ continue;
119
+ const keys = resolveFromKeys(source, parsed.fromExpr);
120
+ out.push({
121
+ start: m.index,
122
+ end: parsed.closeParen + 1,
123
+ keyExpr: parsed.keyExpr,
124
+ fromExpr: parsed.fromExpr,
125
+ valuesExpr: parsed.valuesExpr,
126
+ keys,
127
+ });
128
+ }
129
+ return out;
130
+ }
131
+ function parseDynamicArgs(source, openParen) {
132
+ let i = openParen + 1;
133
+ while (/\s/.test(source[i] ?? ''))
134
+ i++;
135
+ const keyStart = i;
136
+ i = scanArgEnd(source, i);
137
+ if (i < 0 || source[i] !== ',')
138
+ return null;
139
+ const keyExpr = source.slice(keyStart, i).trim();
140
+ i++;
141
+ while (/\s/.test(source[i] ?? ''))
142
+ i++;
143
+ if (source[i] !== '{')
144
+ return null;
145
+ const opts = takeBalanced(source, i, '{', '}');
146
+ if (!opts)
147
+ return null;
148
+ const optsEnd = i + opts.length;
149
+ const fromExpr = extractFromProp(opts);
150
+ if (fromExpr == null)
151
+ return null;
152
+ i = optsEnd;
153
+ while (/\s/.test(source[i] ?? ''))
154
+ i++;
155
+ let valuesExpr = null;
156
+ if (source[i] === ',') {
157
+ i++;
158
+ while (/\s/.test(source[i] ?? ''))
159
+ i++;
160
+ const valuesStart = i;
161
+ i = scanArgEnd(source, i);
162
+ if (i < 0)
163
+ return null;
164
+ valuesExpr = source.slice(valuesStart, i).trim() || null;
165
+ }
166
+ while (/\s/.test(source[i] ?? ''))
167
+ i++;
168
+ if (source[i] !== ')')
169
+ return null;
170
+ return { keyExpr, fromExpr, valuesExpr, closeParen: i };
171
+ }
172
+ /** Scan to next top-level `,` or `)` (not inside strings/brackets). */
173
+ function scanArgEnd(source, start) {
174
+ let depthParen = 0;
175
+ let depthBracket = 0;
176
+ let depthBrace = 0;
177
+ let inStr = null;
178
+ let escape = false;
179
+ for (let i = start; i < source.length; i++) {
180
+ const c = source[i];
181
+ if (inStr) {
182
+ if (escape) {
183
+ escape = false;
184
+ continue;
185
+ }
186
+ if (c === '\\' && inStr !== '`') {
187
+ escape = true;
188
+ continue;
189
+ }
190
+ if (c === inStr)
191
+ inStr = null;
192
+ continue;
193
+ }
194
+ if (c === '"' || c === "'" || c === '`') {
195
+ inStr = c;
196
+ continue;
197
+ }
198
+ if (c === '(')
199
+ depthParen++;
200
+ else if (c === ')') {
201
+ if (depthParen === 0 && depthBracket === 0 && depthBrace === 0)
202
+ return i;
203
+ depthParen--;
204
+ }
205
+ else if (c === '[')
206
+ depthBracket++;
207
+ else if (c === ']')
208
+ depthBracket--;
209
+ else if (c === '{')
210
+ depthBrace++;
211
+ else if (c === '}')
212
+ depthBrace--;
213
+ else if (c === ',' &&
214
+ depthParen === 0 &&
215
+ depthBracket === 0 &&
216
+ depthBrace === 0) {
217
+ return i;
218
+ }
219
+ }
220
+ return -1;
221
+ }
222
+ function extractFromProp(optsLiteral) {
223
+ // optsLiteral includes surrounding braces
224
+ const inner = optsLiteral.slice(1, -1);
225
+ const fromIdx = /\bfrom\s*:/.exec(inner);
226
+ if (!fromIdx)
227
+ return null;
228
+ let i = fromIdx.index + fromIdx[0].length;
229
+ while (/\s/.test(inner[i] ?? ''))
230
+ i++;
231
+ if (inner[i] === '[') {
232
+ const arr = takeBalanced(inner, i, '[', ']');
233
+ return arr;
234
+ }
235
+ const ov = /^Object\.values\(\s*[A-Za-z_$][\w$]*\s*\)/.exec(inner.slice(i));
236
+ if (ov)
237
+ return ov[0];
238
+ const id = /^[A-Za-z_$][\w$]*/.exec(inner.slice(i));
239
+ return id ? id[0] : null;
240
+ }
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
+ import { findDynamicCalls } from './dynamic-from.js';
3
4
  const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mdx']);
4
5
  const SKIP_DIRS = new Set([
5
6
  'node_modules',
@@ -152,16 +153,11 @@ export function extractFromSource(source, file) {
152
153
  while ((cm = callRe.exec(code)) !== null) {
153
154
  push(ns, cm[2], cm.index);
154
155
  }
155
- // t.dynamic(any, { from: ['a', 'b'] }) — only `from` keys become refs
156
- const dynRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
157
- let dm;
158
- while ((dm = dynRe.exec(code)) !== null) {
159
- const tail = code.slice(dm.index, dm.index + 400);
160
- const fromMatch = /from\s*:\s*\[([^\]]*)\]/.exec(tail);
161
- if (fromMatch) {
162
- const keys = fromMatch[1].match(/['"]([^'"]+)['"]/g) ?? [];
163
- for (const raw of keys) {
164
- push(ns, raw.slice(1, -1), dm.index);
156
+ // t.dynamic(any, { from }) — only resolvable `from` keys become refs
157
+ for (const call of findDynamicCalls(code, varName)) {
158
+ if (call.keys) {
159
+ for (const key of call.keys) {
160
+ push(ns, key, call.start);
165
161
  }
166
162
  }
167
163
  dynamicNamespaces.add(ns || '_root');
@@ -6,6 +6,9 @@
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 when `from`
10
+ * is an inline array, a same-file const array, or `Object.values(sameFileConst)`.
11
+ *
9
12
  * Escape analysis: if `t` is passed around, falls back to namespace import + warning.
10
13
  */
11
14
  export type LoaderOptions = {
@@ -17,6 +20,14 @@ export type LoaderResult = {
17
20
  warnings: string[];
18
21
  };
19
22
  export declare function transformCallSites(source: string, resourcePath: string, _options?: LoaderOptions): LoaderResult;
23
+ /**
24
+ * Peel leading `"use client"` / `"use server"` (and preceding comments/whitespace)
25
+ * so we can re-emit them before any injected imports.
26
+ */
27
+ export declare function splitLeadingDirectives(source: string): {
28
+ directives: string;
29
+ body: string;
30
+ };
20
31
  /** Webpack/Turbopack loader entry. */
21
32
  export default function tongueLoader(this: {
22
33
  resourcePath: string;
@@ -6,18 +6,23 @@
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 when `from`
10
+ * is an inline array, a same-file const array, or `Object.values(sameFileConst)`.
11
+ *
9
12
  * Escape analysis: if `t` is passed around, falls back to namespace import + warning.
10
13
  */
14
+ import { findDynamicCalls } from '../compiler/dynamic-from.js';
11
15
  const USE_T_DECL = /(?:const|let|var)\s+(\w+)\s*=\s*useT\s*\(\s*(['"])([^'"]*)\2\s*\)/g;
12
16
  export function transformCallSites(source, resourcePath, _options = {}) {
13
17
  const warnings = [];
14
18
  if (!source.includes('useT(')) {
15
19
  return { code: source, warnings };
16
20
  }
21
+ const { directives, body: withoutDirs } = splitLeadingDirectives(source);
17
22
  const translators = new Map(); // varName → namespace
18
23
  let m;
19
24
  const declRe = new RegExp(USE_T_DECL.source, 'g');
20
- while ((m = declRe.exec(source)) !== null) {
25
+ while ((m = declRe.exec(withoutDirs)) !== null) {
21
26
  translators.set(m[1], m[3] || '_root');
22
27
  }
23
28
  if (translators.size === 0) {
@@ -25,21 +30,31 @@ export function transformCallSites(source, resourcePath, _options = {}) {
25
30
  }
26
31
  // Escape analysis: if t is used beyond property calls, warn and skip rewrite
27
32
  for (const [varName, ns] of translators) {
28
- if (escapes(source, varName)) {
33
+ if (escapes(withoutDirs, varName)) {
29
34
  warnings.push(`TONGUE_W_ESCAPE: ${resourcePath}: '${varName}' from useT('${ns}') escapes this file; importing whole namespace`);
30
35
  }
31
36
  }
32
37
  const imports = new Map();
33
- let code = source;
38
+ let code = withoutDirs;
34
39
  for (const [varName, ns] of translators) {
35
- if (escapes(source, varName))
40
+ if (escapes(withoutDirs, varName))
36
41
  continue;
42
+ // Literal t('key') / t.rich('key')
37
43
  const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
38
44
  code = code.replace(callRe, (full, _q, key) => {
39
45
  const ident = safeImportIdent(ns, key);
40
46
  imports.set(`${ns}:${key}`, { ns, key, ident });
41
47
  return full;
42
48
  });
49
+ // t.dynamic(expr, { from }, values?) — collect resolvable keys
50
+ for (const call of findDynamicCalls(withoutDirs, varName)) {
51
+ if (!call.keys)
52
+ continue;
53
+ for (const key of call.keys) {
54
+ const ident = safeImportIdent(ns, key);
55
+ imports.set(`${ns}:${key}`, { ns, key, ident });
56
+ }
57
+ }
43
58
  }
44
59
  if (imports.size === 0) {
45
60
  return { code: source, warnings };
@@ -54,9 +69,31 @@ export function transformCallSites(source, resourcePath, _options = {}) {
54
69
  const localeVar = '__tongue_locale';
55
70
  // Inject locale once after the first useT declaration
56
71
  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
72
+ // Replace t.dynamic(expr, { from }, values?) — rewrite from the end so indices stay valid
73
+ for (const [varName, ns] of translators) {
74
+ if (escapes(withoutDirs, varName))
75
+ continue;
76
+ const calls = findDynamicCalls(code, varName).filter((c) => c.keys && c.keys.length > 0);
77
+ for (let ci = calls.length - 1; ci >= 0; ci--) {
78
+ const call = calls[ci];
79
+ const keys = call.keys;
80
+ const mapEntries = keys
81
+ .map((key) => {
82
+ const ident = safeImportIdent(ns, key);
83
+ imports.set(`${ns}:${key}`, { ns, key, ident });
84
+ return `${JSON.stringify(key)}: ${ident}`;
85
+ })
86
+ .join(', ');
87
+ const valuesArg = call.valuesExpr != null && call.valuesExpr.trim() !== ''
88
+ ? `, ${call.valuesExpr.trim()}`
89
+ : '';
90
+ const replacement = `((__tongue_dyn) => { const __tongue_fn = __tongue_dyn[${call.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} })`;
91
+ code = code.slice(0, call.start) + replacement + code.slice(call.end);
92
+ }
93
+ }
94
+ // Replace t('key', values) / t.rich('key', values) with per-key calls
58
95
  for (const [varName, ns] of translators) {
59
- if (escapes(source, varName))
96
+ if (escapes(withoutDirs, varName))
60
97
  continue;
61
98
  for (const { key, ident } of [...imports.values()].filter((i) => i.ns === ns)) {
62
99
  const richRe = new RegExp(`\\b${escapeRegExp(varName)}\\.rich\\s*\\(\\s*(['"])${escapeRegExp(key)}\\1\\s*(,\\s*([^)]*))?\\)`, 'g');
@@ -85,13 +122,40 @@ export function transformCallSites(source, resourcePath, _options = {}) {
85
122
  preamble += `import { useLocale } from 'next-tongue/client';\n`;
86
123
  }
87
124
  for (const [varName, ns] of translators) {
88
- if (!escapes(source, varName))
125
+ if (!escapes(withoutDirs, varName))
89
126
  continue;
90
127
  preamble +=
91
128
  `process.env.NODE_ENV !== 'production' && globalThis.__tongueDevEscape?.(` +
92
129
  `${JSON.stringify(ns || '_root')}, ${JSON.stringify(resourcePath)});\n`;
93
130
  }
94
- return { code: preamble + code, warnings };
131
+ // "use client" / "use server" MUST be the first statement(s) — never after imports.
132
+ return { code: directives + preamble + code, warnings };
133
+ }
134
+ /**
135
+ * Peel leading `"use client"` / `"use server"` (and preceding comments/whitespace)
136
+ * so we can re-emit them before any injected imports.
137
+ */
138
+ export function splitLeadingDirectives(source) {
139
+ let rest = source.replace(/^\uFEFF/, '');
140
+ const found = [];
141
+ while (rest.length > 0) {
142
+ // leading whitespace / comments
143
+ const lead = rest.match(/^((?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*)/);
144
+ const leadLen = lead?.[0]?.length ?? 0;
145
+ const afterLead = rest.slice(leadLen);
146
+ const dir = afterLead.match(/^['"]use (client|server)['"];?[ \t]*(?:\r?\n)?/);
147
+ if (!dir)
148
+ break;
149
+ found.push(`"use ${dir[1]}";`);
150
+ rest = afterLead.slice(dir[0].length);
151
+ }
152
+ if (found.length === 0) {
153
+ return { directives: '', body: source.replace(/^\uFEFF/, '') };
154
+ }
155
+ return {
156
+ directives: found.join('\n') + '\n\n',
157
+ body: rest,
158
+ };
95
159
  }
96
160
  function escapes(source, varName) {
97
161
  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.7",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",