next-tongue 0.9.4 → 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.
@@ -13,15 +13,28 @@ function store() {
13
13
  rendered: [],
14
14
  listeners: new Set(),
15
15
  snapshot: null,
16
+ notifyScheduled: false,
16
17
  };
17
18
  }
18
19
  return g.__nextTongueDev;
19
20
  }
21
+ /**
22
+ * Invalidate snapshot and notify subscribers **after** the current render.
23
+ * Synchronous notify from inside `t()` (render) caused
24
+ * "Cannot update DevOverlay while rendering Header".
25
+ */
20
26
  function notify() {
21
27
  const s = store();
22
28
  s.snapshot = null;
23
- for (const l of s.listeners)
24
- l();
29
+ if (s.notifyScheduled)
30
+ return;
31
+ s.notifyScheduled = true;
32
+ queueMicrotask(() => {
33
+ const st = store();
34
+ st.notifyScheduled = false;
35
+ for (const l of st.listeners)
36
+ l();
37
+ });
25
38
  }
26
39
  export function tongueDevLoaded(namespace, key) {
27
40
  const fullKey = namespace === '_root' || namespace === '' ? key : `${namespace}.${key}`;
@@ -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;
@@ -8,8 +8,8 @@ export type ReferencedKey = {
8
8
  export type ScanResult = {
9
9
  refs: ReferencedKey[];
10
10
  /**
11
- * Namespaces where `t(variable)` / `t.ns(...)` was seen — unused-key warnings
12
- * for these namespaces are suppressed (dynamic / whole-ns usage).
11
+ * Namespaces where `t(variable)` / `t.ns(...)` / `t.dynamic` was seen —
12
+ * unused-key warnings for these namespaces are suppressed.
13
13
  */
14
14
  dynamicNamespaces: Set<string>;
15
15
  };
@@ -19,4 +19,6 @@ export type ScanResult = {
19
19
  */
20
20
  export declare function scanReferencedKeys(cwd: string, roots?: string[]): Promise<ReferencedKey[]>;
21
21
  export declare function scanKeys(cwd: string, roots?: string[]): Promise<ScanResult>;
22
+ /** Strip // and /* *\/ comments; preserve newlines for line numbers. */
23
+ export declare function stripComments(source: string): string;
22
24
  export declare function extractFromSource(source: string, file: string): ScanResult;
@@ -62,14 +62,77 @@ function extname(name) {
62
62
  const i = name.lastIndexOf('.');
63
63
  return i === -1 ? '' : name.slice(i);
64
64
  }
65
+ /** Strip // and /* *\/ comments; preserve newlines for line numbers. */
66
+ export function stripComments(source) {
67
+ let out = '';
68
+ let i = 0;
69
+ while (i < source.length) {
70
+ const ch = source[i];
71
+ const next = source[i + 1];
72
+ // string / template — copy verbatim so // inside strings stay
73
+ if (ch === '"' || ch === "'" || ch === '`') {
74
+ const quote = ch;
75
+ out += ch;
76
+ i++;
77
+ while (i < source.length) {
78
+ const c = source[i];
79
+ out += c;
80
+ if (c === '\\' && i + 1 < source.length) {
81
+ out += source[i + 1];
82
+ i += 2;
83
+ continue;
84
+ }
85
+ if (c === quote) {
86
+ i++;
87
+ break;
88
+ }
89
+ // template ${ } — shallow skip (good enough for comment strip)
90
+ if (quote === '`' && c === '$' && source[i + 1] === '{') {
91
+ out += '{';
92
+ i += 2;
93
+ let depth = 1;
94
+ while (i < source.length && depth > 0) {
95
+ if (source[i] === '{')
96
+ depth++;
97
+ else if (source[i] === '}')
98
+ depth--;
99
+ out += source[i];
100
+ i++;
101
+ }
102
+ continue;
103
+ }
104
+ i++;
105
+ }
106
+ continue;
107
+ }
108
+ if (ch === '/' && next === '/') {
109
+ while (i < source.length && source[i] !== '\n')
110
+ i++;
111
+ continue;
112
+ }
113
+ if (ch === '/' && next === '*') {
114
+ i += 2;
115
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
116
+ out += source[i] === '\n' ? '\n' : ' ';
117
+ i++;
118
+ }
119
+ i += 2;
120
+ continue;
121
+ }
122
+ out += ch;
123
+ i++;
124
+ }
125
+ return out;
126
+ }
65
127
  export function extractFromSource(source, file) {
128
+ const code = stripComments(source);
66
129
  const out = [];
67
130
  const dynamicNamespaces = new Set();
68
131
  const translators = new Map(); // var → ns
69
132
  // useT('ns') | getT('ns') | getT(locale, 'ns')
70
133
  const declRe = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useT|getT)\s*\(\s*(?:[^'"\n,]+,\s*)?(['"])([^'"]*)\2\s*\)/g;
71
134
  let m;
72
- while ((m = declRe.exec(source)) !== null) {
135
+ while ((m = declRe.exec(code)) !== null) {
73
136
  translators.set(m[1], m[3] || '_root');
74
137
  }
75
138
  const push = (ns, key, index) => {
@@ -79,27 +142,26 @@ export function extractFromSource(source, file) {
79
142
  namespace: ns || '_root',
80
143
  key,
81
144
  file,
82
- line: lineAt(source, index),
145
+ line: lineAt(code, index),
83
146
  });
84
147
  };
85
148
  for (const [varName, ns] of translators) {
86
149
  // Literal t('key') / t.rich('key')
87
150
  const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
88
151
  let cm;
89
- while ((cm = callRe.exec(source)) !== null) {
152
+ while ((cm = callRe.exec(code)) !== null) {
90
153
  push(ns, cm[2], cm.index);
91
154
  }
92
- // t.dynamic(any, { from: ['a', 'b'] })
155
+ // t.dynamic(any, { from: ['a', 'b'] }) — only `from` keys become refs
93
156
  const dynRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
94
157
  let dm;
95
- while ((dm = dynRe.exec(source)) !== null) {
96
- const tail = source.slice(dm.index, dm.index + 400);
158
+ while ((dm = dynRe.exec(code)) !== null) {
159
+ const tail = code.slice(dm.index, dm.index + 400);
97
160
  const fromMatch = /from\s*:\s*\[([^\]]*)\]/.exec(tail);
98
161
  if (fromMatch) {
99
162
  const keys = fromMatch[1].match(/['"]([^'"]+)['"]/g) ?? [];
100
163
  for (const raw of keys) {
101
- const key = raw.slice(1, -1);
102
- push(ns, key, dm.index);
164
+ push(ns, raw.slice(1, -1), dm.index);
103
165
  }
104
166
  }
105
167
  dynamicNamespaces.add(ns || '_root');
@@ -107,28 +169,15 @@ export function extractFromSource(source, file) {
107
169
  // t.ns('other') — whole namespace marked dynamic
108
170
  const nsRe = new RegExp(`\\b${escapeRegExp(varName)}\\.ns\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
109
171
  let nm;
110
- while ((nm = nsRe.exec(source)) !== null) {
172
+ while ((nm = nsRe.exec(code)) !== null) {
111
173
  dynamicNamespaces.add(nm[2] || '_root');
112
174
  }
113
- // Non-literal t(var) / t.rich(var) — mark namespace dynamic + harvest nearby string arrays
175
+ // Non-literal t(var) — suppress unused for the namespace only.
176
+ // Do NOT harvest unrelated string arrays (cache tags, sort keys, …).
114
177
  const nonLitRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(?!['"])([A-Za-z_$][\\w$]*)`, 'g');
115
- let nl;
116
- let sawNonLit = false;
117
- while ((nl = nonLitRe.exec(source)) !== null) {
118
- sawNonLit = true;
178
+ if (nonLitRe.test(code)) {
119
179
  dynamicNamespaces.add(ns || '_root');
120
180
  }
121
- if (sawNonLit) {
122
- // Keys listed in string arrays in this file (e.g. KEYS.map((k) => t(k)))
123
- const arrayLitRe = /\[\s*((?:['"][^'"]+['"]\s*,?\s*)+)\]/g;
124
- let am;
125
- while ((am = arrayLitRe.exec(source)) !== null) {
126
- const keys = am[1].match(/['"]([^'"]+)['"]/g) ?? [];
127
- for (const raw of keys) {
128
- push(ns, raw.slice(1, -1), am.index);
129
- }
130
- }
131
- }
132
181
  }
133
182
  return { refs: out, dynamicNamespaces };
134
183
  }
@@ -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);
@@ -32,9 +32,11 @@ export function withTongue(nextConfig = {}, options = {}) {
32
32
  }
33
33
  // Next 16: content must be RegExp; omit `as` when returning the same module type
34
34
  // (using `as: '*.tsx'` previously doubled extensions → `file.tsx.tsx`).
35
+ // Do NOT gate on `browser` — client components still SSR, and useT must be
36
+ // rewritten on the server graph too (otherwise SSR throws without loadNamespace).
35
37
  const tongueLoaderRule = {
36
38
  condition: {
37
- all: [{ not: 'foreign' }, 'browser', { content: /useT\s*\(/ }],
39
+ all: [{ not: 'foreign' }, { content: /useT\s*\(/ }],
38
40
  },
39
41
  loaders: [
40
42
  {
@@ -65,18 +67,17 @@ export function withTongue(nextConfig = {}, options = {}) {
65
67
  ...(config.resolve.alias ?? {}),
66
68
  '#tongue': outDir,
67
69
  };
68
- if (!ctx.isServer) {
69
- config.module ??= {};
70
- config.module.rules = [
71
- ...(config.module.rules ?? []),
72
- {
73
- test: /\.[jt]sx?$/,
74
- exclude: /node_modules/,
75
- enforce: 'pre',
76
- use: [{ loader: loaderPath, options: { outDir: '.tongue' } }],
77
- },
78
- ];
79
- }
70
+ // Client components SSR under webpack too — apply on both graphs.
71
+ config.module ??= {};
72
+ config.module.rules = [
73
+ ...(config.module.rules ?? []),
74
+ {
75
+ test: /\.[jt]sx?$/,
76
+ exclude: /node_modules/,
77
+ enforce: 'pre',
78
+ use: [{ loader: loaderPath, options: { outDir: '.tongue' } }],
79
+ },
80
+ ];
80
81
  return userWebpack ? userWebpack(config, ctx) : config;
81
82
  };
82
83
  const enableRootParams = peekRootParamsFlag(cwd, options.config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.4",
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",