next-tongue 0.9.3 → 0.9.5

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.
@@ -12,13 +12,29 @@ function store() {
12
12
  escapes: new Map(),
13
13
  rendered: [],
14
14
  listeners: new Set(),
15
+ snapshot: null,
16
+ notifyScheduled: false,
15
17
  };
16
18
  }
17
19
  return g.__nextTongueDev;
18
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
+ */
19
26
  function notify() {
20
- for (const l of store().listeners)
21
- l();
27
+ const s = store();
28
+ s.snapshot = null;
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
+ });
22
38
  }
23
39
  export function tongueDevLoaded(namespace, key) {
24
40
  const fullKey = namespace === '_root' || namespace === '' ? key : `${namespace}.${key}`;
@@ -74,13 +90,16 @@ export function subscribeTongueDev(listener) {
74
90
  }
75
91
  export function getTongueDevSnapshot() {
76
92
  const s = store();
77
- return {
93
+ if (s.snapshot)
94
+ return s.snapshot;
95
+ s.snapshot = {
78
96
  loaded: [...s.loaded.values()],
79
97
  missing: [...s.missing.values()],
80
98
  fallbacks: [...s.fallbacks.values()],
81
99
  escapes: [...s.escapes.values()],
82
100
  rendered: [...s.rendered],
83
101
  };
102
+ return s.snapshot;
84
103
  }
85
104
  /** @internal test helper */
86
105
  export function __resetTongueDev() {
@@ -68,8 +68,11 @@ export function useFormat() {
68
68
  export function useT(namespace) {
69
69
  const ns = (namespace ?? '_root');
70
70
  const { locale, loadNamespace } = useTongue();
71
+ // Literal `t('key')` is rewritten by the call-site loader to per-key imports;
72
+ // `useT()` itself must still be safe during SSR without loadNamespace.
73
+ // Invoking the returned `t` without a rewrite (dynamic/escaped) throws below.
71
74
  if (!loadNamespace) {
72
- throw new TongueError('TONGUE_E_NO_PROVIDER', `useT('${String(ns)}') needs the call-site loader (or pass loadNamespace to TongueProvider). Prefer translating on the server with getT and passing strings.`);
75
+ return createLoaderStubT(locale, String(ns));
73
76
  }
74
77
  const messages = loadNamespace(ns);
75
78
  return createT({
@@ -78,6 +81,24 @@ export function useT(namespace) {
78
81
  loadNamespace,
79
82
  });
80
83
  }
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 })`.
88
+ */
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: [...] }).`);
92
+ };
93
+ const t = Object.assign((..._args) => fail(), {
94
+ rich: fail,
95
+ has: () => false,
96
+ dynamic: fail,
97
+ ns: fail,
98
+ });
99
+ void locale;
100
+ return t;
101
+ }
81
102
  export function getClientLocales() {
82
103
  return clientLocales;
83
104
  }
@@ -5,9 +5,20 @@ export type ReferencedKey = {
5
5
  file: string;
6
6
  line: number;
7
7
  };
8
+ export type ScanResult = {
9
+ refs: ReferencedKey[];
10
+ /**
11
+ * Namespaces where `t(variable)` / `t.ns(...)` / `t.dynamic` was seen —
12
+ * unused-key warnings for these namespaces are suppressed.
13
+ */
14
+ dynamicNamespaces: Set<string>;
15
+ };
8
16
  /**
9
17
  * Find `t('key')` / `t.rich('key')` under a known `useT`/`getT` namespace,
10
- * plus bare `useT('ns')` / `getT('ns')` for namespace awareness.
18
+ * plus `t.dynamic(..., { from })` declared keys and dynamic `t(var)` markers.
11
19
  */
12
20
  export declare function scanReferencedKeys(cwd: string, roots?: string[]): Promise<ReferencedKey[]>;
13
- export declare function extractFromSource(source: string, file: string): ReferencedKey[];
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;
24
+ export declare function extractFromSource(source: string, file: string): ScanResult;
@@ -12,9 +12,12 @@ const SKIP_DIRS = new Set([
12
12
  ]);
13
13
  /**
14
14
  * Find `t('key')` / `t.rich('key')` under a known `useT`/`getT` namespace,
15
- * plus bare `useT('ns')` / `getT('ns')` for namespace awareness.
15
+ * plus `t.dynamic(..., { from })` declared keys and dynamic `t(var)` markers.
16
16
  */
17
17
  export async function scanReferencedKeys(cwd, roots = ['app', 'src', 'components']) {
18
+ return (await scanKeys(cwd, roots)).refs;
19
+ }
20
+ export async function scanKeys(cwd, roots = ['app', 'src', 'components']) {
18
21
  const files = [];
19
22
  for (const root of roots) {
20
23
  const abs = join(cwd, root);
@@ -27,13 +30,17 @@ export async function scanReferencedKeys(cwd, roots = ['app', 'src', 'components
27
30
  }
28
31
  await walk(abs, files);
29
32
  }
30
- const out = [];
33
+ const refs = [];
34
+ const dynamicNamespaces = new Set();
31
35
  for (const file of files) {
32
36
  const src = await readFile(file, 'utf8');
33
37
  const rel = relative(cwd, file);
34
- out.push(...extractFromSource(src, rel));
38
+ const part = extractFromSource(src, rel);
39
+ refs.push(...part.refs);
40
+ for (const ns of part.dynamicNamespaces)
41
+ dynamicNamespaces.add(ns);
35
42
  }
36
- return out;
43
+ return { refs, dynamicNamespaces };
37
44
  }
38
45
  async function walk(dir, out) {
39
46
  const entries = await readdir(dir, { withFileTypes: true });
@@ -55,32 +62,124 @@ function extname(name) {
55
62
  const i = name.lastIndexOf('.');
56
63
  return i === -1 ? '' : name.slice(i);
57
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
+ }
58
127
  export function extractFromSource(source, file) {
128
+ const code = stripComments(source);
59
129
  const out = [];
130
+ const dynamicNamespaces = new Set();
60
131
  const translators = new Map(); // var → ns
61
132
  // useT('ns') | getT('ns') | getT(locale, 'ns')
62
133
  const declRe = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useT|getT)\s*\(\s*(?:[^'"\n,]+,\s*)?(['"])([^'"]*)\2\s*\)/g;
63
134
  let m;
64
- while ((m = declRe.exec(source)) !== null) {
135
+ while ((m = declRe.exec(code)) !== null) {
65
136
  translators.set(m[1], m[3] || '_root');
66
137
  }
138
+ const push = (ns, key, index) => {
139
+ const fullKey = ns === '_root' || ns === '' ? key : `${ns}.${key}`;
140
+ out.push({
141
+ fullKey,
142
+ namespace: ns || '_root',
143
+ key,
144
+ file,
145
+ line: lineAt(code, index),
146
+ });
147
+ };
67
148
  for (const [varName, ns] of translators) {
149
+ // Literal t('key') / t.rich('key')
68
150
  const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
69
151
  let cm;
70
- while ((cm = callRe.exec(source)) !== null) {
71
- const key = cm[2];
72
- const fullKey = ns === '_root' || ns === '' ? key : `${ns}.${key}`;
73
- const line = lineAt(source, cm.index);
74
- out.push({
75
- fullKey,
76
- namespace: ns || '_root',
77
- key,
78
- file,
79
- line,
80
- });
152
+ while ((cm = callRe.exec(code)) !== null) {
153
+ push(ns, cm[2], cm.index);
154
+ }
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);
165
+ }
166
+ }
167
+ dynamicNamespaces.add(ns || '_root');
168
+ }
169
+ // t.ns('other') — whole namespace marked dynamic
170
+ const nsRe = new RegExp(`\\b${escapeRegExp(varName)}\\.ns\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
171
+ let nm;
172
+ while ((nm = nsRe.exec(code)) !== null) {
173
+ dynamicNamespaces.add(nm[2] || '_root');
174
+ }
175
+ // Non-literal t(var) — suppress unused for the namespace only.
176
+ // Do NOT harvest unrelated string arrays (cache tags, sort keys, …).
177
+ const nonLitRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(?!['"])([A-Za-z_$][\\w$]*)`, 'g');
178
+ if (nonLitRe.test(code)) {
179
+ dynamicNamespaces.add(ns || '_root');
81
180
  }
82
181
  }
83
- return out;
182
+ return { refs: out, dynamicNamespaces };
84
183
  }
85
184
  function lineAt(source, index) {
86
185
  let line = 1;
@@ -1,5 +1,5 @@
1
1
  import { loadLocaleMessages } from '../load-messages.js';
2
- import { scanReferencedKeys } from '../scan-keys.js';
2
+ import { scanKeys } from '../scan-keys.js';
3
3
  function severityFor(level, alwaysError = false) {
4
4
  if (alwaysError)
5
5
  return 'error';
@@ -7,6 +7,10 @@ function severityFor(level, alwaysError = false) {
7
7
  return null;
8
8
  return level === 'error' ? 'error' : 'warn';
9
9
  }
10
+ function namespaceOf(fullKey) {
11
+ const i = fullKey.indexOf('.');
12
+ return i === -1 ? '_root' : fullKey.slice(0, i);
13
+ }
10
14
  /** unused + undefined checks against scanned source references. */
11
15
  export async function validateUsage(config, cwd) {
12
16
  const issues = [];
@@ -20,7 +24,7 @@ export async function validateUsage(config, cwd) {
20
24
  defined.add(ns === '_root' ? key : `${ns}.${key}`);
21
25
  }
22
26
  }
23
- const refs = await scanReferencedKeys(cwd);
27
+ const { refs, dynamicNamespaces } = await scanKeys(cwd);
24
28
  const used = new Set();
25
29
  for (const ref of refs) {
26
30
  used.add(ref.fullKey);
@@ -40,6 +44,9 @@ export async function validateUsage(config, cwd) {
40
44
  for (const fullKey of defined) {
41
45
  if (used.has(fullKey))
42
46
  continue;
47
+ // Dynamic t(var) / t.ns / t.dynamic — do not false-flag catalog keys as unused
48
+ if (dynamicNamespaces.has(namespaceOf(fullKey)))
49
+ continue;
43
50
  issues.push({
44
51
  check: 'unused',
45
52
  severity: unusedSev,
@@ -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.3",
3
+ "version": "0.9.5",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",