next-tongue 0.9.2 → 0.9.4
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/README.md +6 -2
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +144 -53
- package/dist/client/dev-registry.js +8 -2
- package/dist/client/index.js +22 -1
- package/dist/compiler/scan-keys.d.ts +11 -2
- package/dist/compiler/scan-keys.js +65 -15
- package/dist/compiler/validate/a11y.d.ts +7 -2
- package/dist/compiler/validate/a11y.js +99 -35
- package/dist/compiler/validate/usage.js +9 -2
- package/dist/next/with-tongue.js +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,10 +37,14 @@ export default withTongue({ cacheComponents: true });
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
|
-
// proxy.ts
|
|
40
|
+
// proxy.ts — locales + defaultLocale are required
|
|
41
41
|
import { createProxy } from 'next-tongue/proxy';
|
|
42
42
|
|
|
43
|
-
export const proxy = createProxy(
|
|
43
|
+
export const proxy = createProxy({
|
|
44
|
+
locales: ['en', 'de'],
|
|
45
|
+
defaultLocale: 'en',
|
|
46
|
+
prefix: 'as-needed',
|
|
47
|
+
});
|
|
44
48
|
export const config = { matcher: ['/((?!api|_next|.*\\..*).*)'] };
|
|
45
49
|
```
|
|
46
50
|
|
package/dist/cli/init.d.ts
CHANGED
|
@@ -10,3 +10,10 @@ export type InitCliOptions = {
|
|
|
10
10
|
quiet?: boolean;
|
|
11
11
|
};
|
|
12
12
|
export declare function cmdInit(opts: InitCliOptions): Promise<void>;
|
|
13
|
+
/** Prefer src/app when present; otherwise app/. */
|
|
14
|
+
export declare function detectAppRoot(cwd: string): Promise<'app' | 'src/app'>;
|
|
15
|
+
/**
|
|
16
|
+
* Where to write proxy.ts. Returns null when the project already has proxy/middleware
|
|
17
|
+
* (caller should compose manually).
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectProxyPath(cwd: string, appRoot: 'app' | 'src/app'): Promise<string | null>;
|
package/dist/cli/init.js
CHANGED
|
@@ -31,6 +31,9 @@ export async function cmdInit(opts) {
|
|
|
31
31
|
process.exitCode = 2;
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
+
const appRoot = await detectAppRoot(cwd);
|
|
35
|
+
const hasRootLayout = await hasFile(cwd, `${appRoot}/layout.tsx`, `${appRoot}/layout.ts`, `${appRoot}/layout.jsx`, `${appRoot}/layout.js`);
|
|
36
|
+
const proxyPath = await detectProxyPath(cwd, appRoot);
|
|
34
37
|
const plans = [];
|
|
35
38
|
plans.push({
|
|
36
39
|
path: 'tongue.config.ts',
|
|
@@ -46,68 +49,38 @@ export default defineConfig({
|
|
|
46
49
|
`,
|
|
47
50
|
});
|
|
48
51
|
for (const locale of locales) {
|
|
49
|
-
const sample =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
: {
|
|
57
|
-
home: {
|
|
58
|
-
title: 'Welcome, {name}!',
|
|
59
|
-
body: 'Scaffolded by tongue init.',
|
|
60
|
-
},
|
|
61
|
-
};
|
|
52
|
+
const sample = {
|
|
53
|
+
home: {
|
|
54
|
+
title: 'Welcome, {name}!',
|
|
55
|
+
body: 'Scaffolded by tongue init.',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
62
58
|
plans.push({
|
|
63
59
|
path: `messages/${locale}.json`,
|
|
64
60
|
kind: 'create',
|
|
65
61
|
content: `${JSON.stringify(sample, null, 2)}\n`,
|
|
66
62
|
});
|
|
67
63
|
}
|
|
64
|
+
if (proxyPath) {
|
|
65
|
+
plans.push({
|
|
66
|
+
path: proxyPath,
|
|
67
|
+
kind: 'create',
|
|
68
|
+
content: proxyScaffold(locales, defaultLocale, prefix),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else if (!opts.quiet) {
|
|
72
|
+
console.log('tongue init: existing proxy/middleware detected — compose createProxy({ locales, defaultLocale, … }) yourself (see docs/routing-and-proxy.md).');
|
|
73
|
+
}
|
|
74
|
+
const layoutPath = `${appRoot}/[locale]/layout.tsx`;
|
|
68
75
|
plans.push({
|
|
69
|
-
path:
|
|
76
|
+
path: layoutPath,
|
|
70
77
|
kind: 'create',
|
|
71
|
-
content:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
export const config = {
|
|
75
|
-
matcher: ['/((?!api|_next|.*\\\\..*).*)'],
|
|
76
|
-
};
|
|
77
|
-
`,
|
|
78
|
+
content: hasRootLayout
|
|
79
|
+
? nestedLocaleLayout()
|
|
80
|
+
: rootLocaleLayout(),
|
|
78
81
|
});
|
|
79
82
|
plans.push({
|
|
80
|
-
path:
|
|
81
|
-
kind: 'create',
|
|
82
|
-
content: `import { notFound } from 'next/navigation';
|
|
83
|
-
import { TongueProvider } from 'next-tongue/client';
|
|
84
|
-
import { getDir, isLocale, localeParams } from 'next-tongue/server';
|
|
85
|
-
import { ensureTongue } from '#tongue/runtime';
|
|
86
|
-
|
|
87
|
-
ensureTongue();
|
|
88
|
-
export const generateStaticParams = localeParams;
|
|
89
|
-
|
|
90
|
-
export default async function RootLayout({
|
|
91
|
-
children,
|
|
92
|
-
params,
|
|
93
|
-
}: {
|
|
94
|
-
children: React.ReactNode;
|
|
95
|
-
params: Promise<{ locale: string }>;
|
|
96
|
-
}) {
|
|
97
|
-
const { locale } = await params;
|
|
98
|
-
if (!isLocale(locale)) notFound();
|
|
99
|
-
return (
|
|
100
|
-
<html lang={locale} dir={getDir(locale)}>
|
|
101
|
-
<body>
|
|
102
|
-
<TongueProvider locale={locale}>{children}</TongueProvider>
|
|
103
|
-
</body>
|
|
104
|
-
</html>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
`,
|
|
108
|
-
});
|
|
109
|
-
plans.push({
|
|
110
|
-
path: 'app/[locale]/page.tsx',
|
|
83
|
+
path: `${appRoot}/[locale]/page.tsx`,
|
|
111
84
|
kind: 'create',
|
|
112
85
|
content: `import { getT } from 'next-tongue/server';
|
|
113
86
|
import { ensureTongue } from '#tongue/runtime';
|
|
@@ -160,7 +133,7 @@ export default withTongue(nextConfig);
|
|
|
160
133
|
`,
|
|
161
134
|
});
|
|
162
135
|
}
|
|
163
|
-
// tsconfig include
|
|
136
|
+
// tsconfig: include + paths for #tongue/* (tsc does not resolve package.json imports)
|
|
164
137
|
if (await exists(join(cwd, 'tsconfig.json'))) {
|
|
165
138
|
const abs = join(cwd, 'tsconfig.json');
|
|
166
139
|
const raw = await readFile(abs, 'utf8');
|
|
@@ -169,6 +142,12 @@ export default withTongue(nextConfig);
|
|
|
169
142
|
const include = new Set(ts.include ?? ['**/*.ts', '**/*.tsx']);
|
|
170
143
|
include.add('.tongue/types.d.ts');
|
|
171
144
|
ts.include = [...include];
|
|
145
|
+
ts.compilerOptions = ts.compilerOptions ?? {};
|
|
146
|
+
const paths = { ...(ts.compilerOptions.paths ?? {}) };
|
|
147
|
+
if (!paths['#tongue/*']) {
|
|
148
|
+
paths['#tongue/*'] = ['./.tongue/*'];
|
|
149
|
+
}
|
|
150
|
+
ts.compilerOptions.paths = paths;
|
|
172
151
|
const next = `${JSON.stringify(ts, null, 2)}\n`;
|
|
173
152
|
if (next !== raw) {
|
|
174
153
|
plans.push({ path: 'tsconfig.json', kind: 'merge', content: next });
|
|
@@ -242,9 +221,114 @@ export default withTongue(nextConfig);
|
|
|
242
221
|
}
|
|
243
222
|
}
|
|
244
223
|
if (!opts.quiet) {
|
|
224
|
+
if (hasRootLayout) {
|
|
225
|
+
console.log('tongue init: existing root layout detected — set <html lang={locale} dir={…}> on the root layout (or a client HtmlLang), not under [locale].');
|
|
226
|
+
}
|
|
245
227
|
console.log('tongue init: done. Run `npx tongue build` (or start next) so `.tongue/` is generated.');
|
|
246
228
|
}
|
|
247
229
|
}
|
|
230
|
+
function proxyScaffold(locales, defaultLocale, prefix) {
|
|
231
|
+
return `import { createProxy } from 'next-tongue/proxy';
|
|
232
|
+
|
|
233
|
+
// locales/defaultLocale must match tongue.config.ts (or import from '#tongue/runtime' after \`tongue build\`).
|
|
234
|
+
export const proxy = createProxy({
|
|
235
|
+
locales: ${JSON.stringify(locales)},
|
|
236
|
+
defaultLocale: ${JSON.stringify(defaultLocale)},
|
|
237
|
+
prefix: ${JSON.stringify(prefix)},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export const config = {
|
|
241
|
+
matcher: ['/((?!api|_next|.*\\\\..*).*)'],
|
|
242
|
+
};
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
/** Greenfield: [locale] is the root layout and owns <html>. */
|
|
246
|
+
function rootLocaleLayout() {
|
|
247
|
+
return `import { notFound } from 'next/navigation';
|
|
248
|
+
import { TongueProvider } from 'next-tongue/client';
|
|
249
|
+
import { getDir, isLocale, localeParams } from 'next-tongue/server';
|
|
250
|
+
import { ensureTongue } from '#tongue/runtime';
|
|
251
|
+
|
|
252
|
+
ensureTongue();
|
|
253
|
+
export const generateStaticParams = localeParams;
|
|
254
|
+
|
|
255
|
+
export default async function LocaleLayout({
|
|
256
|
+
children,
|
|
257
|
+
params,
|
|
258
|
+
}: {
|
|
259
|
+
children: React.ReactNode;
|
|
260
|
+
params: Promise<{ locale: string }>;
|
|
261
|
+
}) {
|
|
262
|
+
const { locale } = await params;
|
|
263
|
+
if (!isLocale(locale)) notFound();
|
|
264
|
+
return (
|
|
265
|
+
<html lang={locale} dir={getDir(locale)}>
|
|
266
|
+
<body>
|
|
267
|
+
<TongueProvider locale={locale}>{children}</TongueProvider>
|
|
268
|
+
</body>
|
|
269
|
+
</html>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* App already has app/layout.tsx — Next only allows <html> there.
|
|
276
|
+
* [locale] layout wraps children only.
|
|
277
|
+
*/
|
|
278
|
+
function nestedLocaleLayout() {
|
|
279
|
+
return `import { notFound } from 'next/navigation';
|
|
280
|
+
import { TongueProvider } from 'next-tongue/client';
|
|
281
|
+
import { isLocale, localeParams } from 'next-tongue/server';
|
|
282
|
+
import { ensureTongue } from '#tongue/runtime';
|
|
283
|
+
|
|
284
|
+
ensureTongue();
|
|
285
|
+
export const generateStaticParams = localeParams;
|
|
286
|
+
|
|
287
|
+
export default async function LocaleLayout({
|
|
288
|
+
children,
|
|
289
|
+
params,
|
|
290
|
+
}: {
|
|
291
|
+
children: React.ReactNode;
|
|
292
|
+
params: Promise<{ locale: string }>;
|
|
293
|
+
}) {
|
|
294
|
+
const { locale } = await params;
|
|
295
|
+
if (!isLocale(locale)) notFound();
|
|
296
|
+
// Keep html lang/dir on the root layout (or sync via a client helper).
|
|
297
|
+
return <TongueProvider locale={locale}>{children}</TongueProvider>;
|
|
298
|
+
}
|
|
299
|
+
`;
|
|
300
|
+
}
|
|
301
|
+
/** Prefer src/app when present; otherwise app/. */
|
|
302
|
+
export async function detectAppRoot(cwd) {
|
|
303
|
+
if (await exists(join(cwd, 'src', 'app')))
|
|
304
|
+
return 'src/app';
|
|
305
|
+
if (await exists(join(cwd, 'app')))
|
|
306
|
+
return 'app';
|
|
307
|
+
if (await exists(join(cwd, 'src')))
|
|
308
|
+
return 'src/app';
|
|
309
|
+
return 'app';
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Where to write proxy.ts. Returns null when the project already has proxy/middleware
|
|
313
|
+
* (caller should compose manually).
|
|
314
|
+
*/
|
|
315
|
+
export async function detectProxyPath(cwd, appRoot) {
|
|
316
|
+
const existing = [
|
|
317
|
+
'proxy.ts',
|
|
318
|
+
'proxy.js',
|
|
319
|
+
'src/proxy.ts',
|
|
320
|
+
'src/proxy.js',
|
|
321
|
+
'middleware.ts',
|
|
322
|
+
'middleware.js',
|
|
323
|
+
'src/middleware.ts',
|
|
324
|
+
'src/middleware.js',
|
|
325
|
+
];
|
|
326
|
+
for (const p of existing) {
|
|
327
|
+
if (await exists(join(cwd, p)))
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return appRoot.startsWith('src/') ? 'src/proxy.ts' : 'proxy.ts';
|
|
331
|
+
}
|
|
248
332
|
function wrapNextConfig(src, staticExport) {
|
|
249
333
|
let out = src;
|
|
250
334
|
if (!/withTongue/.test(out)) {
|
|
@@ -258,6 +342,13 @@ function wrapNextConfig(src, staticExport) {
|
|
|
258
342
|
}
|
|
259
343
|
return out;
|
|
260
344
|
}
|
|
345
|
+
async function hasFile(cwd, ...rels) {
|
|
346
|
+
for (const rel of rels) {
|
|
347
|
+
if (await exists(join(cwd, rel)))
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
261
352
|
async function exists(path) {
|
|
262
353
|
try {
|
|
263
354
|
await access(path);
|
|
@@ -12,12 +12,15 @@ function store() {
|
|
|
12
12
|
escapes: new Map(),
|
|
13
13
|
rendered: [],
|
|
14
14
|
listeners: new Set(),
|
|
15
|
+
snapshot: null,
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
18
|
return g.__nextTongueDev;
|
|
18
19
|
}
|
|
19
20
|
function notify() {
|
|
20
|
-
|
|
21
|
+
const s = store();
|
|
22
|
+
s.snapshot = null;
|
|
23
|
+
for (const l of s.listeners)
|
|
21
24
|
l();
|
|
22
25
|
}
|
|
23
26
|
export function tongueDevLoaded(namespace, key) {
|
|
@@ -74,13 +77,16 @@ export function subscribeTongueDev(listener) {
|
|
|
74
77
|
}
|
|
75
78
|
export function getTongueDevSnapshot() {
|
|
76
79
|
const s = store();
|
|
77
|
-
|
|
80
|
+
if (s.snapshot)
|
|
81
|
+
return s.snapshot;
|
|
82
|
+
s.snapshot = {
|
|
78
83
|
loaded: [...s.loaded.values()],
|
|
79
84
|
missing: [...s.missing.values()],
|
|
80
85
|
fallbacks: [...s.fallbacks.values()],
|
|
81
86
|
escapes: [...s.escapes.values()],
|
|
82
87
|
rendered: [...s.rendered],
|
|
83
88
|
};
|
|
89
|
+
return s.snapshot;
|
|
84
90
|
}
|
|
85
91
|
/** @internal test helper */
|
|
86
92
|
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,18 @@ 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(...)` was seen — unused-key warnings
|
|
12
|
+
* for these namespaces are suppressed (dynamic / whole-ns usage).
|
|
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
|
+
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 });
|
|
@@ -57,6 +64,7 @@ function extname(name) {
|
|
|
57
64
|
}
|
|
58
65
|
export function extractFromSource(source, file) {
|
|
59
66
|
const out = [];
|
|
67
|
+
const dynamicNamespaces = new Set();
|
|
60
68
|
const translators = new Map(); // var → ns
|
|
61
69
|
// useT('ns') | getT('ns') | getT(locale, 'ns')
|
|
62
70
|
const declRe = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useT|getT)\s*\(\s*(?:[^'"\n,]+,\s*)?(['"])([^'"]*)\2\s*\)/g;
|
|
@@ -64,23 +72,65 @@ export function extractFromSource(source, file) {
|
|
|
64
72
|
while ((m = declRe.exec(source)) !== null) {
|
|
65
73
|
translators.set(m[1], m[3] || '_root');
|
|
66
74
|
}
|
|
75
|
+
const push = (ns, key, index) => {
|
|
76
|
+
const fullKey = ns === '_root' || ns === '' ? key : `${ns}.${key}`;
|
|
77
|
+
out.push({
|
|
78
|
+
fullKey,
|
|
79
|
+
namespace: ns || '_root',
|
|
80
|
+
key,
|
|
81
|
+
file,
|
|
82
|
+
line: lineAt(source, index),
|
|
83
|
+
});
|
|
84
|
+
};
|
|
67
85
|
for (const [varName, ns] of translators) {
|
|
86
|
+
// Literal t('key') / t.rich('key')
|
|
68
87
|
const callRe = new RegExp(`\\b${escapeRegExp(varName)}(?:\\.rich)?\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
|
|
69
88
|
let cm;
|
|
70
89
|
while ((cm = callRe.exec(source)) !== null) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
push(ns, cm[2], cm.index);
|
|
91
|
+
}
|
|
92
|
+
// t.dynamic(any, { from: ['a', 'b'] })
|
|
93
|
+
const dynRe = new RegExp(`\\b${escapeRegExp(varName)}\\.dynamic\\s*\\(`, 'g');
|
|
94
|
+
let dm;
|
|
95
|
+
while ((dm = dynRe.exec(source)) !== null) {
|
|
96
|
+
const tail = source.slice(dm.index, dm.index + 400);
|
|
97
|
+
const fromMatch = /from\s*:\s*\[([^\]]*)\]/.exec(tail);
|
|
98
|
+
if (fromMatch) {
|
|
99
|
+
const keys = fromMatch[1].match(/['"]([^'"]+)['"]/g) ?? [];
|
|
100
|
+
for (const raw of keys) {
|
|
101
|
+
const key = raw.slice(1, -1);
|
|
102
|
+
push(ns, key, dm.index);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
dynamicNamespaces.add(ns || '_root');
|
|
106
|
+
}
|
|
107
|
+
// t.ns('other') — whole namespace marked dynamic
|
|
108
|
+
const nsRe = new RegExp(`\\b${escapeRegExp(varName)}\\.ns\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g');
|
|
109
|
+
let nm;
|
|
110
|
+
while ((nm = nsRe.exec(source)) !== null) {
|
|
111
|
+
dynamicNamespaces.add(nm[2] || '_root');
|
|
112
|
+
}
|
|
113
|
+
// Non-literal t(var) / t.rich(var) — mark namespace dynamic + harvest nearby string arrays
|
|
114
|
+
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;
|
|
119
|
+
dynamicNamespaces.add(ns || '_root');
|
|
120
|
+
}
|
|
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
|
+
}
|
|
81
131
|
}
|
|
82
132
|
}
|
|
83
|
-
return out;
|
|
133
|
+
return { refs: out, dynamicNamespaces };
|
|
84
134
|
}
|
|
85
135
|
function lineAt(source, index) {
|
|
86
136
|
let line = 1;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { ResolvedTongueConfig } from '../../config/types.js';
|
|
2
2
|
import type { CheckIssue } from '../../cli/check-types.js';
|
|
3
3
|
/**
|
|
4
|
-
* Static analysis:
|
|
5
|
-
* `dir={getDir(locale)}`
|
|
4
|
+
* Static analysis: some layout in the tree should set `<html lang={locale}>`
|
|
5
|
+
* and `dir={getDir(locale)}` for RTL.
|
|
6
|
+
*
|
|
7
|
+
* Next.js only allows `<html>` on the **root** layout. When `app/layout` (or
|
|
8
|
+
* `src/app/layout`) exists beside `app/[locale]`, check the root layout for
|
|
9
|
+
* lang/dir (or any client sync of `document.documentElement.lang`). Otherwise
|
|
10
|
+
* check the `[locale]` layout (greenfield pattern).
|
|
6
11
|
*/
|
|
7
12
|
export declare function validateA11y(config: ResolvedTongueConfig, cwd: string): Promise<CheckIssue[]>;
|
|
@@ -7,60 +7,116 @@ function severityFor(level) {
|
|
|
7
7
|
return level === 'error' ? 'error' : 'warn';
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
|
-
* Static analysis:
|
|
11
|
-
* `dir={getDir(locale)}`
|
|
10
|
+
* Static analysis: some layout in the tree should set `<html lang={locale}>`
|
|
11
|
+
* and `dir={getDir(locale)}` for RTL.
|
|
12
|
+
*
|
|
13
|
+
* Next.js only allows `<html>` on the **root** layout. When `app/layout` (or
|
|
14
|
+
* `src/app/layout`) exists beside `app/[locale]`, check the root layout for
|
|
15
|
+
* lang/dir (or any client sync of `document.documentElement.lang`). Otherwise
|
|
16
|
+
* check the `[locale]` layout (greenfield pattern).
|
|
12
17
|
*/
|
|
13
18
|
export async function validateA11y(config, cwd) {
|
|
14
19
|
const sev = severityFor(config.strict.a11y);
|
|
15
20
|
if (!sev)
|
|
16
21
|
return [];
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
22
|
+
const localeLayouts = await findLocaleLayouts(cwd);
|
|
23
|
+
if (localeLayouts.length === 0) {
|
|
19
24
|
return [
|
|
20
25
|
{
|
|
21
26
|
check: 'a11y',
|
|
22
27
|
severity: sev,
|
|
23
|
-
message: 'No app/[locale]/layout.* found — cannot verify
|
|
28
|
+
message: 'No app/[locale]/layout.* (or src/app/[locale]/layout.*) found — cannot verify lang/dir',
|
|
24
29
|
},
|
|
25
30
|
];
|
|
26
31
|
}
|
|
27
|
-
const
|
|
32
|
+
const rootLayouts = await findRootLayouts(cwd);
|
|
33
|
+
const checkFiles = rootLayouts.length > 0
|
|
34
|
+
? [...rootLayouts, ...localeLayouts]
|
|
35
|
+
: localeLayouts;
|
|
28
36
|
const rtlLocales = config.locales.filter((l) => getDir(l) === 'rtl');
|
|
29
|
-
|
|
37
|
+
let anyLang = false;
|
|
38
|
+
let anyDir = false;
|
|
39
|
+
const sources = [];
|
|
40
|
+
for (const file of checkFiles) {
|
|
30
41
|
const src = await readFile(file, 'utf8');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
sources.push(src);
|
|
43
|
+
if (hasLangHint(src))
|
|
44
|
+
anyLang = true;
|
|
45
|
+
if (hasDirHint(src))
|
|
46
|
+
anyDir = true;
|
|
47
|
+
}
|
|
48
|
+
const issues = [];
|
|
49
|
+
const rel = (rootLayouts[0] ?? localeLayouts[0] ?? '').replace(cwd + '/', '').replace(cwd + '\\', '') ||
|
|
50
|
+
'layout';
|
|
51
|
+
if (!anyLang) {
|
|
52
|
+
issues.push({
|
|
53
|
+
check: 'a11y',
|
|
54
|
+
severity: sev,
|
|
55
|
+
message: rootLayouts.length
|
|
56
|
+
? 'Set <html lang={locale}> on the root layout (Next disallows <html> under [locale]), or sync document.documentElement.lang from the locale'
|
|
57
|
+
: 'Root locale layout <html> is missing lang={locale} (or lang=…)',
|
|
58
|
+
file: rel,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (rtlLocales.length > 0 && !anyDir) {
|
|
62
|
+
issues.push({
|
|
63
|
+
check: 'a11y',
|
|
64
|
+
severity: sev,
|
|
65
|
+
message: rootLayouts.length
|
|
66
|
+
? `Set <html dir={…}> on the root layout for RTL locales (${rtlLocales.join(', ')}), or sync document.documentElement.dir`
|
|
67
|
+
: `Root locale layout <html> is missing dir={getDir(locale)} (RTL locales: ${rtlLocales.join(', ')})`,
|
|
68
|
+
file: rel,
|
|
69
|
+
});
|
|
54
70
|
}
|
|
55
71
|
return issues;
|
|
56
72
|
}
|
|
73
|
+
function hasLangHint(src) {
|
|
74
|
+
return (/<html[^>]*\blang=\{/.test(src) ||
|
|
75
|
+
/<html[^>]*\blang=["']/.test(src) ||
|
|
76
|
+
/\blang=\{locale\}/.test(src) ||
|
|
77
|
+
/document\.documentElement\.lang/.test(src) ||
|
|
78
|
+
/\.lang\s*=/.test(src));
|
|
79
|
+
}
|
|
80
|
+
function hasDirHint(src) {
|
|
81
|
+
return (/<html[^>]*\bdir=\{/.test(src) ||
|
|
82
|
+
/<html[^>]*\bdir=["']/.test(src) ||
|
|
83
|
+
/\bdir=\{getDir\(/.test(src) ||
|
|
84
|
+
/\bdir=\{.*getDir/.test(src) ||
|
|
85
|
+
/document\.documentElement\.dir/.test(src));
|
|
86
|
+
}
|
|
57
87
|
async function findLocaleLayouts(cwd) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
return findLayoutsUnder(cwd, ['app/[locale]', 'src/app/[locale]']);
|
|
89
|
+
}
|
|
90
|
+
/** Root layouts that are siblings of [locale], not under it. */
|
|
91
|
+
async function findRootLayouts(cwd) {
|
|
62
92
|
const out = [];
|
|
63
|
-
for (const
|
|
93
|
+
for (const dir of ['app', 'src/app']) {
|
|
94
|
+
const abs = join(cwd, dir);
|
|
95
|
+
try {
|
|
96
|
+
if (!(await stat(abs)).isDirectory())
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Only treat as "existing root" when [locale] also lives under the same app dir
|
|
103
|
+
// (otherwise greenfield [locale]-as-root still owns <html>).
|
|
104
|
+
const hasLocale = await existsDir(join(abs, '[locale]'));
|
|
105
|
+
if (!hasLocale)
|
|
106
|
+
continue;
|
|
107
|
+
const entries = await readdir(abs);
|
|
108
|
+
for (const name of entries) {
|
|
109
|
+
if (/^layout\.(tsx|ts|jsx|js)$/.test(name)) {
|
|
110
|
+
out.push(join(abs, name));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
async function findLayoutsUnder(cwd, roots) {
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const rel of roots) {
|
|
119
|
+
const root = join(cwd, ...rel.split('/'));
|
|
64
120
|
try {
|
|
65
121
|
if (!(await stat(root)).isDirectory())
|
|
66
122
|
continue;
|
|
@@ -77,3 +133,11 @@ async function findLocaleLayouts(cwd) {
|
|
|
77
133
|
}
|
|
78
134
|
return out;
|
|
79
135
|
}
|
|
136
|
+
async function existsDir(path) {
|
|
137
|
+
try {
|
|
138
|
+
return (await stat(path)).isDirectory();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -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
|
@@ -43,16 +43,19 @@ export function withTongue(nextConfig = {}, options = {}) {
|
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
45
|
};
|
|
46
|
+
const existingRules = input.turbopack?.rules ?? {};
|
|
46
47
|
const turbopack = {
|
|
47
48
|
...(input.turbopack ?? {}),
|
|
48
49
|
resolveAlias: {
|
|
49
50
|
...(input.turbopack?.resolveAlias ?? {}),
|
|
50
51
|
'#tongue': outDir,
|
|
51
52
|
},
|
|
53
|
+
// Appends the call-site loader onto any existing `*.tsx` / `*.jsx` rules
|
|
54
|
+
// (Next accepts a rule or an array of rules per glob).
|
|
52
55
|
rules: {
|
|
53
|
-
...
|
|
54
|
-
'*.tsx': tongueLoaderRule,
|
|
55
|
-
'*.jsx': tongueLoaderRule,
|
|
56
|
+
...existingRules,
|
|
57
|
+
'*.tsx': appendTurbopackRule(existingRules['*.tsx'], tongueLoaderRule),
|
|
58
|
+
'*.jsx': appendTurbopackRule(existingRules['*.jsx'], tongueLoaderRule),
|
|
56
59
|
},
|
|
57
60
|
};
|
|
58
61
|
const userWebpack = input.webpack;
|
|
@@ -90,6 +93,12 @@ export function withTongue(nextConfig = {}, options = {}) {
|
|
|
90
93
|
webpack,
|
|
91
94
|
};
|
|
92
95
|
}
|
|
96
|
+
/** Merge a tongue loader rule with any pre-existing turbopack rule for the same glob. */
|
|
97
|
+
function appendTurbopackRule(existing, rule) {
|
|
98
|
+
if (existing == null)
|
|
99
|
+
return rule;
|
|
100
|
+
return Array.isArray(existing) ? [...existing, rule] : [existing, rule];
|
|
101
|
+
}
|
|
93
102
|
/** Sync peek: `routing.rootParams: true` in tongue.config (ADR 0007 / #24). */
|
|
94
103
|
function peekRootParamsFlag(cwd, configPath) {
|
|
95
104
|
const candidates = configPath
|