next-tongue 0.9.6 → 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.
@@ -88,7 +88,7 @@ export function useT(namespace) {
88
88
  */
89
89
  function createLoaderStubT(locale, ns) {
90
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.`);
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
93
  const t = Object.assign((..._args) => fail('t()'), {
94
94
  rich: () => fail('t.rich()'),
@@ -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,7 +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.
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)`.
10
11
  *
11
12
  * Escape analysis: if `t` is passed around, falls back to namespace import + warning.
12
13
  */
@@ -6,10 +6,12 @@
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.
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)`.
10
11
  *
11
12
  * Escape analysis: if `t` is passed around, falls back to namespace import + warning.
12
13
  */
14
+ import { findDynamicCalls } from '../compiler/dynamic-from.js';
13
15
  const USE_T_DECL = /(?:const|let|var)\s+(\w+)\s*=\s*useT\s*\(\s*(['"])([^'"]*)\2\s*\)/g;
14
16
  export function transformCallSites(source, resourcePath, _options = {}) {
15
17
  const warnings = [];
@@ -44,16 +46,11 @@ export function transformCallSites(source, resourcePath, _options = {}) {
44
46
  imports.set(`${ns}:${key}`, { ns, key, ident });
45
47
  return full;
46
48
  });
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)
49
+ // t.dynamic(expr, { from }, values?) — collect resolvable keys
50
+ for (const call of findDynamicCalls(withoutDirs, varName)) {
51
+ if (!call.keys)
54
52
  continue;
55
- const keys = [...fromMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
56
- for (const key of keys) {
53
+ for (const key of call.keys) {
57
54
  const ident = safeImportIdent(ns, key);
58
55
  imports.set(`${ns}:${key}`, { ns, key, ident });
59
56
  }
@@ -72,13 +69,14 @@ export function transformCallSites(source, resourcePath, _options = {}) {
72
69
  const localeVar = '__tongue_locale';
73
70
  // Inject locale once after the first useT declaration
74
71
  code = code.replace(/((?:const|let|var)\s+\w+\s*=\s*useT\s*\([^)]*\))/, `$1\n const ${localeVar} = useLocale();`);
75
- // Replace t.dynamic(expr, { from: [...] }, values?)
72
+ // Replace t.dynamic(expr, { from }, values?) — rewrite from the end so indices stay valid
76
73
  for (const [varName, ns] of translators) {
77
74
  if (escapes(withoutDirs, varName))
78
75
  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]);
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;
82
80
  const mapEntries = keys
83
81
  .map((key) => {
84
82
  const ident = safeImportIdent(ns, key);
@@ -86,11 +84,12 @@ export function transformCallSites(source, resourcePath, _options = {}) {
86
84
  return `${JSON.stringify(key)}: ${ident}`;
87
85
  })
88
86
  .join(', ');
89
- const valuesArg = values != null && String(values).trim() !== ''
90
- ? `, ${String(values).trim()}`
87
+ const valuesArg = call.valuesExpr != null && call.valuesExpr.trim() !== ''
88
+ ? `, ${call.valuesExpr.trim()}`
91
89
  : '';
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
- });
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
+ }
94
93
  }
95
94
  // Replace t('key', values) / t.rich('key', values) with per-key calls
96
95
  for (const [varName, ns] of translators) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.6",
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",