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.
- package/dist/client/dev-registry.js +22 -3
- package/dist/client/index.js +22 -1
- package/dist/compiler/scan-keys.d.ts +13 -2
- package/dist/compiler/scan-keys.js +116 -17
- package/dist/compiler/validate/usage.js +9 -2
- package/dist/next/with-tongue.js +14 -13
- package/package.json +1 -1
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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() {
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
|
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,
|
package/dist/next/with-tongue.js
CHANGED
|
@@ -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' },
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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);
|