next-tongue 0.9.11 → 0.9.13
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/cli/check-types.d.ts +1 -1
- package/dist/cli/check.js +4 -0
- package/dist/cli/extract.js +9 -2
- package/dist/cli/init.js +6 -1
- package/dist/compiler/build.js +6 -4
- package/dist/compiler/unused-meta.d.ts +23 -0
- package/dist/compiler/unused-meta.js +86 -0
- package/dist/compiler/validate/client-loader.d.ts +7 -0
- package/dist/compiler/validate/client-loader.js +79 -0
- package/dist/compiler/validate/usage.js +18 -21
- package/dist/next/loader.d.ts +8 -0
- package/dist/next/loader.js +48 -3
- package/dist/next/with-tongue.js +10 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type CheckKind = 'missing' | 'unused' | 'undefined' | 'dynamic' | 'mismatch' | 'syntax' | 'stale' | 'a11y' | 'bidi' | 'length' | 'duplicate' | 'icu';
|
|
1
|
+
export type CheckKind = 'missing' | 'unused' | 'undefined' | 'dynamic' | 'mismatch' | 'syntax' | 'stale' | 'a11y' | 'bidi' | 'length' | 'duplicate' | 'icu' | 'client-loader';
|
|
2
2
|
export type CheckSeverity = 'error' | 'warn';
|
|
3
3
|
export type CheckIssue = {
|
|
4
4
|
check: CheckKind;
|
package/dist/cli/check.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { loadConfig } from '../config/load-config.js';
|
|
2
2
|
import { validateCatalog, validateStale } from '../compiler/validate/catalog.js';
|
|
3
3
|
import { validateUsage } from '../compiler/validate/usage.js';
|
|
4
|
+
import { validateClientLoader } from '../compiler/validate/client-loader.js';
|
|
4
5
|
import { validateA11y } from '../compiler/validate/a11y.js';
|
|
5
6
|
import { validateBidi } from '../compiler/validate/bidi.js';
|
|
7
|
+
import { DEFAULT_SCAN_ROOTS } from '../compiler/scan-keys.js';
|
|
6
8
|
import { loadLocaleMessages } from '../compiler/load-messages.js';
|
|
7
9
|
import { duplicateSourceKeys, loadAndMergeSources, } from '../compiler/sources.js';
|
|
8
10
|
import { validateLength } from '../compiler/validate/length.js';
|
|
@@ -20,6 +22,8 @@ export async function cmdCheck(opts) {
|
|
|
20
22
|
...(localeFilter ? { locales: localeFilter } : {}),
|
|
21
23
|
})));
|
|
22
24
|
issues.push(...(await validateUsage(config, cwd)));
|
|
25
|
+
const scanRoots = config.scan.include.length > 0 ? config.scan.include : DEFAULT_SCAN_ROOTS;
|
|
26
|
+
issues.push(...(await validateClientLoader(cwd, scanRoots)));
|
|
23
27
|
issues.push(...(await validateStale(config, cwd, {
|
|
24
28
|
...(localeFilter ? { locales: localeFilter } : {}),
|
|
25
29
|
})));
|
package/dist/cli/extract.js
CHANGED
|
@@ -4,6 +4,7 @@ import { loadConfig } from '../config/load-config.js';
|
|
|
4
4
|
import { buildMessages } from '../compiler/build.js';
|
|
5
5
|
import { loadLocaleMessages, resolveMessagePath } from '../compiler/load-messages.js';
|
|
6
6
|
import { DEFAULT_SCAN_ROOTS, scanReferencedKeys, } from '../compiler/scan-keys.js';
|
|
7
|
+
import { collectUnusedIgnores, isUnusedIgnored, matchesUnusedIgnoreGlob, } from '../compiler/unused-meta.js';
|
|
7
8
|
import { TongueError } from '../errors.js';
|
|
8
9
|
export async function cmdExtract(opts) {
|
|
9
10
|
const { config, cwd } = await loadConfig({
|
|
@@ -28,8 +29,6 @@ export async function cmdExtract(opts) {
|
|
|
28
29
|
defined.add(ns === '_root' ? key : `${ns}.${key}`);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
|
-
const missing = [...used].filter((k) => !defined.has(k)).sort();
|
|
32
|
-
const unused = [...defined].filter((k) => !used.has(k)).sort();
|
|
33
32
|
const placeholder = opts.placeholder ?? null; // null → use key as value
|
|
34
33
|
const abs = resolveMessagePath(cwd, config.messages, config.defaultLocale);
|
|
35
34
|
let raw;
|
|
@@ -46,6 +45,14 @@ export async function cmdExtract(opts) {
|
|
|
46
45
|
catch (err) {
|
|
47
46
|
throw new TongueError('TONGUE_E_PARSE', `Invalid JSON: ${err instanceof Error ? err.message : err}`, { file: abs });
|
|
48
47
|
}
|
|
48
|
+
const metaIgnores = collectUnusedIgnores(json);
|
|
49
|
+
const configIgnores = config.strict.unusedIgnore;
|
|
50
|
+
const missing = [...used].filter((k) => !defined.has(k)).sort();
|
|
51
|
+
const unused = [...defined]
|
|
52
|
+
.filter((k) => !used.has(k))
|
|
53
|
+
.filter((k) => !isUnusedIgnored(k, metaIgnores))
|
|
54
|
+
.filter((k) => !matchesUnusedIgnoreGlob(k, configIgnores))
|
|
55
|
+
.sort();
|
|
49
56
|
let added = 0;
|
|
50
57
|
let pruned = 0;
|
|
51
58
|
for (const fullKey of missing) {
|
package/dist/cli/init.js
CHANGED
|
@@ -32,6 +32,9 @@ export async function cmdInit(opts) {
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
const appRoot = await detectAppRoot(cwd);
|
|
35
|
+
if (!opts.quiet) {
|
|
36
|
+
console.log(`tongue init: using ${appRoot}/ for App Router files`);
|
|
37
|
+
}
|
|
35
38
|
const hasRootLayout = await hasFile(cwd, `${appRoot}/layout.tsx`, `${appRoot}/layout.ts`, `${appRoot}/layout.jsx`, `${appRoot}/layout.js`);
|
|
36
39
|
const proxyPath = await detectProxyPath(cwd, appRoot);
|
|
37
40
|
const plans = [];
|
|
@@ -69,7 +72,9 @@ export default defineConfig({
|
|
|
69
72
|
});
|
|
70
73
|
}
|
|
71
74
|
else if (!opts.quiet) {
|
|
72
|
-
console.log('tongue init: existing proxy/middleware detected —
|
|
75
|
+
console.log('tongue init: existing proxy/middleware detected — do not overwrite. Compose like:\n\n' +
|
|
76
|
+
proxyScaffold(locales, defaultLocale, prefix) +
|
|
77
|
+
'\nSee docs/routing-and-proxy.md#composing-with-your-own-proxy-logic / hybrid routing.');
|
|
73
78
|
}
|
|
74
79
|
const layoutPath = `${appRoot}/[locale]/layout.tsx`;
|
|
75
80
|
plans.push({
|
package/dist/compiler/build.js
CHANGED
|
@@ -113,8 +113,8 @@ export async function buildMessages(options) {
|
|
|
113
113
|
const defaultMsgRel = config.messages.replaceAll('{locale}', config.defaultLocale);
|
|
114
114
|
const emitLocales = localesForEmit(config);
|
|
115
115
|
// Content hash for idempotency (bump CODEGEN_VERSION when emit format changes)
|
|
116
|
-
// Bump when emit shape changes (reserved idents, auto ensureTongue
|
|
117
|
-
const CODEGEN_VERSION =
|
|
116
|
+
// Bump when emit shape changes (reserved idents, auto ensureTongue, routing export).
|
|
117
|
+
const CODEGEN_VERSION = 10;
|
|
118
118
|
const hash = createHash('sha256')
|
|
119
119
|
.update(JSON.stringify({
|
|
120
120
|
codegen: CODEGEN_VERSION,
|
|
@@ -333,9 +333,9 @@ export async function buildMessages(options) {
|
|
|
333
333
|
!productionLocales.includes(config.pseudo.locale);
|
|
334
334
|
runtimeLines.push(`export const allLocales = ${JSON.stringify([...emitLocales])};`, `const productionLocales = ${JSON.stringify(productionLocales)};`, pseudoDevOnly
|
|
335
335
|
? `export const locales = process.env.NODE_ENV === 'production' ? productionLocales : allLocales;`
|
|
336
|
-
: `export const locales = allLocales;`, `export const defaultLocale = ${JSON.stringify(config.defaultLocale)};`, `export const timeZone = ${JSON.stringify(config.timeZone)};`, config.pseudo && config.pseudo.enabled !== false
|
|
336
|
+
: `export const locales = allLocales;`, `export const defaultLocale = ${JSON.stringify(config.defaultLocale)};`, `export const prefix = ${JSON.stringify(config.routing.prefix)};`, `export const timeZone = ${JSON.stringify(config.timeZone)};`, config.pseudo && config.pseudo.enabled !== false
|
|
337
337
|
? `export const pseudoLocale = ${JSON.stringify(config.pseudo.locale)};`
|
|
338
|
-
: `export const pseudoLocale = undefined;`, `export function localeParams() {`, ` return locales.map((locale) => ({ locale }));`, `}`, `let ready = false;`, `export function ensureTongue() {`, ` if (ready) return;`, ` __setServerRuntime({`, ` locales: allLocales,`, ` defaultLocale,`, ` timeZone,`, ` rootParams: ${config.routing.rootParams ? 'true' : 'false'},`, ` loadNamespace: async (namespace) => {`, ` const messages = namespaces[namespace];`, ` if (!messages) throw new Error('[next-tongue] Unknown namespace: ' + namespace);`, ` return messages;`, ` },`, ` });`, config.pseudo && config.pseudo.enabled !== false
|
|
338
|
+
: `export const pseudoLocale = undefined;`, `/** Single source of truth for createProxy / createNavigation / TongueShell. */`, `export const routing = { locales, defaultLocale, prefix };`, `export function localeParams() {`, ` return locales.map((locale) => ({ locale }));`, `}`, `let ready = false;`, `export function ensureTongue() {`, ` if (ready) return;`, ` __setServerRuntime({`, ` locales: allLocales,`, ` defaultLocale,`, ` timeZone,`, ` rootParams: ${config.routing.rootParams ? 'true' : 'false'},`, ` loadNamespace: async (namespace) => {`, ` const messages = namespaces[namespace];`, ` if (!messages) throw new Error('[next-tongue] Unknown namespace: ' + namespace);`, ` return messages;`, ` },`, ` });`, config.pseudo && config.pseudo.enabled !== false
|
|
339
339
|
? ` configureFormatMessage({ timeZone, pseudo: { locale: ${JSON.stringify(config.pseudo.locale)}, expand: ${config.pseudo.expand} } });`
|
|
340
340
|
: ` configureFormatMessage({ timeZone });`, ` ready = true;`, `}`,
|
|
341
341
|
// Side-effect: importing `#tongue/runtime` (or getT's lazy import) wires the
|
|
@@ -347,8 +347,10 @@ export async function buildMessages(options) {
|
|
|
347
347
|
`export declare const allLocales: readonly string[];`,
|
|
348
348
|
`export declare const locales: readonly string[];`,
|
|
349
349
|
`export declare const defaultLocale: string;`,
|
|
350
|
+
`export declare const prefix: 'always' | 'as-needed' | 'never';`,
|
|
350
351
|
`export declare const timeZone: string;`,
|
|
351
352
|
`export declare const pseudoLocale: string | undefined;`,
|
|
353
|
+
`export declare const routing: { locales: readonly string[]; defaultLocale: string; prefix: 'always' | 'as-needed' | 'never' };`,
|
|
352
354
|
`export declare function localeParams(): { locale: string }[];`,
|
|
353
355
|
`export declare function ensureTongue(): void;`,
|
|
354
356
|
``,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog `$meta` flags that suppress `unused` (and extract `--prune`).
|
|
3
|
+
*
|
|
4
|
+
* Per key (sibling `key$meta`):
|
|
5
|
+
* { "planned": true }
|
|
6
|
+
* { "ignoreUnused": true }
|
|
7
|
+
* { "@tongue-ignore": true }
|
|
8
|
+
*
|
|
9
|
+
* Per namespace (child `$meta` on the namespace object):
|
|
10
|
+
* "common": { "$meta": { "planned": true }, "seeAll": "…" }
|
|
11
|
+
*/
|
|
12
|
+
export type Nested = Record<string, unknown>;
|
|
13
|
+
export type UnusedIgnoreIndex = {
|
|
14
|
+
/** Exact full keys (e.g. `common.seeAll`). */
|
|
15
|
+
keys: Set<string>;
|
|
16
|
+
/** Namespace prefixes whose unused keys are ignored (`common` → `common.*`). */
|
|
17
|
+
namespaces: Set<string>;
|
|
18
|
+
};
|
|
19
|
+
/** Walk default-locale JSON and collect planned / @tongue-ignore markers. */
|
|
20
|
+
export declare function collectUnusedIgnores(root: Nested): UnusedIgnoreIndex;
|
|
21
|
+
export declare function isUnusedIgnored(fullKey: string, index: UnusedIgnoreIndex): boolean;
|
|
22
|
+
/** Glob-lite for `strict.unusedIgnore` (`common.*`, `*.seeAll`). */
|
|
23
|
+
export declare function matchesUnusedIgnoreGlob(fullKey: string, patterns: readonly string[]): boolean;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog `$meta` flags that suppress `unused` (and extract `--prune`).
|
|
3
|
+
*
|
|
4
|
+
* Per key (sibling `key$meta`):
|
|
5
|
+
* { "planned": true }
|
|
6
|
+
* { "ignoreUnused": true }
|
|
7
|
+
* { "@tongue-ignore": true }
|
|
8
|
+
*
|
|
9
|
+
* Per namespace (child `$meta` on the namespace object):
|
|
10
|
+
* "common": { "$meta": { "planned": true }, "seeAll": "…" }
|
|
11
|
+
*/
|
|
12
|
+
function metaIgnoresUnused(meta) {
|
|
13
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
|
|
14
|
+
return false;
|
|
15
|
+
const m = meta;
|
|
16
|
+
if (m.planned === true)
|
|
17
|
+
return true;
|
|
18
|
+
if (m.ignoreUnused === true)
|
|
19
|
+
return true;
|
|
20
|
+
if (m['@tongue-ignore'] === true)
|
|
21
|
+
return true;
|
|
22
|
+
// Allow string form: "@tongue-ignore" or "planned"
|
|
23
|
+
if (typeof m.unused === 'string') {
|
|
24
|
+
const u = m.unused.toLowerCase();
|
|
25
|
+
if (u === 'ignore' || u === 'planned' || u === '@tongue-ignore')
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/** Walk default-locale JSON and collect planned / @tongue-ignore markers. */
|
|
31
|
+
export function collectUnusedIgnores(root) {
|
|
32
|
+
const keys = new Set();
|
|
33
|
+
const namespaces = new Set();
|
|
34
|
+
walk(root, '', keys, namespaces);
|
|
35
|
+
return { keys, namespaces };
|
|
36
|
+
}
|
|
37
|
+
function walk(node, prefix, keys, namespaces) {
|
|
38
|
+
const nsMeta = node['$meta'];
|
|
39
|
+
if (prefix && metaIgnoresUnused(nsMeta)) {
|
|
40
|
+
namespaces.add(prefix);
|
|
41
|
+
}
|
|
42
|
+
for (const [key, value] of Object.entries(node)) {
|
|
43
|
+
if (key === '$meta' || key.endsWith('$meta') || key.endsWith('$description')) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const full = prefix ? `${prefix}.${key}` : key;
|
|
47
|
+
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
|
48
|
+
walk(value, full, keys, namespaces);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
continue;
|
|
53
|
+
const meta = node[`${key}$meta`];
|
|
54
|
+
if (metaIgnoresUnused(meta)) {
|
|
55
|
+
keys.add(full);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function isUnusedIgnored(fullKey, index) {
|
|
60
|
+
if (index.keys.has(fullKey))
|
|
61
|
+
return true;
|
|
62
|
+
for (const ns of index.namespaces) {
|
|
63
|
+
if (fullKey === ns || fullKey.startsWith(`${ns}.`))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/** Glob-lite for `strict.unusedIgnore` (`common.*`, `*.seeAll`). */
|
|
69
|
+
export function matchesUnusedIgnoreGlob(fullKey, patterns) {
|
|
70
|
+
for (const raw of patterns) {
|
|
71
|
+
const p = raw.trim();
|
|
72
|
+
if (!p)
|
|
73
|
+
continue;
|
|
74
|
+
if (p === fullKey)
|
|
75
|
+
return true;
|
|
76
|
+
if (!p.includes('*'))
|
|
77
|
+
continue;
|
|
78
|
+
const re = new RegExp(`^${p
|
|
79
|
+
.split('*')
|
|
80
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
81
|
+
.join('.*')}$`);
|
|
82
|
+
if (re.test(fullKey))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CheckIssue } from '../../cli/check-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Dry-run the call-site loader on `"use client"` files that call `useT`.
|
|
4
|
+
* Fails when rewritten output calls `useLocale` without importing it
|
|
5
|
+
* (Safari: Can't find variable: useLocale).
|
|
6
|
+
*/
|
|
7
|
+
export declare function validateClientLoader(cwd: string, roots?: readonly string[]): Promise<CheckIssue[]>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { transformCallSites } from '../../next/loader.js';
|
|
4
|
+
import { DEFAULT_SCAN_ROOTS } from '../scan-keys.js';
|
|
5
|
+
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
|
6
|
+
/**
|
|
7
|
+
* Dry-run the call-site loader on `"use client"` files that call `useT`.
|
|
8
|
+
* Fails when rewritten output calls `useLocale` without importing it
|
|
9
|
+
* (Safari: Can't find variable: useLocale).
|
|
10
|
+
*/
|
|
11
|
+
export async function validateClientLoader(cwd, roots = DEFAULT_SCAN_ROOTS) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
const files = await collectSourceFiles(cwd, roots);
|
|
14
|
+
for (const abs of files) {
|
|
15
|
+
let src;
|
|
16
|
+
try {
|
|
17
|
+
src = await readFile(abs, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!/"use client"\s*;?/.test(src) && !/'use client'\s*;?/.test(src)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!/\buseT\s*\(/.test(src))
|
|
26
|
+
continue;
|
|
27
|
+
const rel = relative(cwd, abs);
|
|
28
|
+
const { code } = transformCallSites(src, rel);
|
|
29
|
+
if (!/\buseLocale\s*\(/.test(code))
|
|
30
|
+
continue;
|
|
31
|
+
const hasImport = /import\s*\{[^}]*\buseLocale\b[^}]*\}\s*from\s*['"]next-tongue\/client['"]/.test(code);
|
|
32
|
+
if (!hasImport) {
|
|
33
|
+
issues.push({
|
|
34
|
+
check: 'client-loader',
|
|
35
|
+
severity: 'error',
|
|
36
|
+
message: `Rewritten useT in "${rel}" calls useLocale() without importing it from 'next-tongue/client' (Safari ReferenceError). Upgrade next-tongue / ensure withTongue() applies the call-site loader.`,
|
|
37
|
+
file: rel,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
async function collectSourceFiles(cwd, roots) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const root of roots) {
|
|
46
|
+
const abs = join(cwd, root);
|
|
47
|
+
try {
|
|
48
|
+
if (!(await stat(abs)).isDirectory())
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
await walk(abs, out);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
async function walk(dir, out) {
|
|
59
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
60
|
+
for (const ent of entries) {
|
|
61
|
+
if (ent.name.startsWith('.'))
|
|
62
|
+
continue;
|
|
63
|
+
const abs = join(dir, ent.name);
|
|
64
|
+
if (ent.isDirectory()) {
|
|
65
|
+
if (ent.name === 'node_modules' ||
|
|
66
|
+
ent.name === '.next' ||
|
|
67
|
+
ent.name === 'dist') {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
await walk(abs, out);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const i = ent.name.lastIndexOf('.');
|
|
74
|
+
const ext = i === -1 ? '' : ent.name.slice(i);
|
|
75
|
+
if (SOURCE_EXT.has(ext))
|
|
76
|
+
out.push(abs);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { loadLocaleMessages, resolveMessagePath, } from '../load-messages.js';
|
|
2
3
|
import { DEFAULT_SCAN_ROOTS, scanKeys } from '../scan-keys.js';
|
|
4
|
+
import { collectUnusedIgnores, isUnusedIgnored, matchesUnusedIgnoreGlob, } from '../unused-meta.js';
|
|
3
5
|
function severityFor(level, alwaysError = false) {
|
|
4
6
|
if (alwaysError)
|
|
5
7
|
return 'error';
|
|
@@ -11,25 +13,6 @@ function namespaceOf(fullKey) {
|
|
|
11
13
|
const i = fullKey.indexOf('.');
|
|
12
14
|
return i === -1 ? '_root' : fullKey.slice(0, i);
|
|
13
15
|
}
|
|
14
|
-
/** Glob-lite: `*` matches any substring (e.g. `common.*`, `*.seeAll`). */
|
|
15
|
-
function matchesIgnore(fullKey, patterns) {
|
|
16
|
-
for (const raw of patterns) {
|
|
17
|
-
const p = raw.trim();
|
|
18
|
-
if (!p)
|
|
19
|
-
continue;
|
|
20
|
-
if (p === fullKey)
|
|
21
|
-
return true;
|
|
22
|
-
if (!p.includes('*'))
|
|
23
|
-
continue;
|
|
24
|
-
const re = new RegExp(`^${p
|
|
25
|
-
.split('*')
|
|
26
|
-
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
27
|
-
.join('.*')}$`);
|
|
28
|
-
if (re.test(fullKey))
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
16
|
/** unused + undefined + undeclared client dynamic checks against scanned source. */
|
|
34
17
|
export async function validateUsage(config, cwd) {
|
|
35
18
|
const issues = [];
|
|
@@ -71,10 +54,13 @@ export async function validateUsage(config, cwd) {
|
|
|
71
54
|
const unusedSev = severityFor(config.strict.unused);
|
|
72
55
|
if (unusedSev) {
|
|
73
56
|
const ignore = config.strict.unusedIgnore;
|
|
57
|
+
const metaIgnores = await loadUnusedMetaIgnores(config, cwd);
|
|
74
58
|
for (const fullKey of defined) {
|
|
75
59
|
if (used.has(fullKey))
|
|
76
60
|
continue;
|
|
77
|
-
if (
|
|
61
|
+
if (matchesUnusedIgnoreGlob(fullKey, ignore))
|
|
62
|
+
continue;
|
|
63
|
+
if (isUnusedIgnored(fullKey, metaIgnores))
|
|
78
64
|
continue;
|
|
79
65
|
// Dynamic t(var) / t.ns / t.dynamic — do not false-flag catalog keys as unused
|
|
80
66
|
if (dynamicNamespaces.has(namespaceOf(fullKey)))
|
|
@@ -90,3 +76,14 @@ export async function validateUsage(config, cwd) {
|
|
|
90
76
|
}
|
|
91
77
|
return issues;
|
|
92
78
|
}
|
|
79
|
+
async function loadUnusedMetaIgnores(config, cwd) {
|
|
80
|
+
const abs = resolveMessagePath(cwd, config.messages, config.defaultLocale);
|
|
81
|
+
try {
|
|
82
|
+
const raw = await readFile(abs, 'utf8');
|
|
83
|
+
const json = JSON.parse(raw);
|
|
84
|
+
return collectUnusedIgnores(json);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return collectUnusedIgnores({});
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/next/loader.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export declare function splitLeadingDirectives(source: string): {
|
|
|
28
28
|
directives: string;
|
|
29
29
|
body: string;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Ensure `useLocale` is a real import. Looking for the identifier in the body is
|
|
33
|
+
* not enough — the loader injects `useLocale()` calls before this runs.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ensureUseLocaleImport(code: string, preamble: string): {
|
|
36
|
+
code: string;
|
|
37
|
+
preamble: string;
|
|
38
|
+
};
|
|
31
39
|
/** Webpack/Turbopack loader entry. */
|
|
32
40
|
export default function tongueLoader(this: {
|
|
33
41
|
resourcePath: string;
|
package/dist/next/loader.js
CHANGED
|
@@ -132,9 +132,9 @@ export function transformCallSites(source, resourcePath, _options = {}) {
|
|
|
132
132
|
if (loadedCalls.length > 0) {
|
|
133
133
|
preamble += loadedCalls.join('\n') + '\n';
|
|
134
134
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
135
|
+
// Injected `useLocale()` calls do NOT count as an import — merge into the
|
|
136
|
+
// existing next-tongue/client import or add one (Safari ReferenceError otherwise).
|
|
137
|
+
({ code, preamble } = ensureUseLocaleImport(code, preamble));
|
|
138
138
|
for (const [varName, ns] of translators) {
|
|
139
139
|
if (!escapes(withoutDirs, varName))
|
|
140
140
|
continue;
|
|
@@ -195,6 +195,51 @@ function escapeRegExp(s) {
|
|
|
195
195
|
function localeVarFor(varName) {
|
|
196
196
|
return varName === 't' ? '__tongue_locale' : `__tongue_locale_${varName}`;
|
|
197
197
|
}
|
|
198
|
+
const USE_LOCALE_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]next-tongue\/client['"]/;
|
|
199
|
+
/**
|
|
200
|
+
* Ensure `useLocale` is a real import. Looking for the identifier in the body is
|
|
201
|
+
* not enough — the loader injects `useLocale()` calls before this runs.
|
|
202
|
+
*/
|
|
203
|
+
export function ensureUseLocaleImport(code, preamble) {
|
|
204
|
+
const combined = preamble + code;
|
|
205
|
+
if (USE_LOCALE_IMPORT.test(combined) && /\buseLocale\b/.test(combined.match(USE_LOCALE_IMPORT)?.[1] ?? '')) {
|
|
206
|
+
return { code, preamble };
|
|
207
|
+
}
|
|
208
|
+
if (USE_LOCALE_IMPORT.test(code)) {
|
|
209
|
+
return {
|
|
210
|
+
code: code.replace(USE_LOCALE_IMPORT, (full, specs) => {
|
|
211
|
+
if (/\buseLocale\b/.test(specs))
|
|
212
|
+
return full;
|
|
213
|
+
const parts = specs
|
|
214
|
+
.split(',')
|
|
215
|
+
.map((s) => s.trim())
|
|
216
|
+
.filter(Boolean);
|
|
217
|
+
parts.push('useLocale');
|
|
218
|
+
return full.replace(specs, ` ${parts.join(', ')} `);
|
|
219
|
+
}),
|
|
220
|
+
preamble,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (USE_LOCALE_IMPORT.test(preamble)) {
|
|
224
|
+
return {
|
|
225
|
+
code,
|
|
226
|
+
preamble: preamble.replace(USE_LOCALE_IMPORT, (full, specs) => {
|
|
227
|
+
if (/\buseLocale\b/.test(specs))
|
|
228
|
+
return full;
|
|
229
|
+
const parts = specs
|
|
230
|
+
.split(',')
|
|
231
|
+
.map((s) => s.trim())
|
|
232
|
+
.filter(Boolean);
|
|
233
|
+
parts.push('useLocale');
|
|
234
|
+
return full.replace(specs, ` ${parts.join(', ')} `);
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
code,
|
|
240
|
+
preamble: preamble + `import { useLocale } from 'next-tongue/client';\n`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
198
243
|
function safeImportIdent(ns, key) {
|
|
199
244
|
return `__tongue_${(ns + '_' + key).replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
200
245
|
}
|
package/dist/next/with-tongue.js
CHANGED
|
@@ -18,6 +18,7 @@ export function withTongue(nextConfig = {}, options = {}) {
|
|
|
18
18
|
const input = nextConfig;
|
|
19
19
|
if (!options.skipBuild) {
|
|
20
20
|
runCodegenSync(cwd, options.config);
|
|
21
|
+
warnMissingTongueRuntime(cwd);
|
|
21
22
|
void loadConfig({
|
|
22
23
|
cwd,
|
|
23
24
|
...(options.config != null ? { configPath: options.config } : {}),
|
|
@@ -33,6 +34,9 @@ export function withTongue(nextConfig = {}, options = {}) {
|
|
|
33
34
|
console.warn('[next-tongue] Could not start watcher:', err instanceof Error ? err.message : err);
|
|
34
35
|
});
|
|
35
36
|
}
|
|
37
|
+
else {
|
|
38
|
+
warnMissingTongueRuntime(cwd);
|
|
39
|
+
}
|
|
36
40
|
// Next 16: content must be RegExp; omit `as` when returning the same module type
|
|
37
41
|
// (using `as: '*.tsx'` previously doubled extensions → `file.tsx.tsx`).
|
|
38
42
|
// Do NOT gate on `browser` — client components still SSR, and useT must be
|
|
@@ -171,6 +175,12 @@ function resolveLoaderPath() {
|
|
|
171
175
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
172
176
|
return resolve(here, 'loader.js');
|
|
173
177
|
}
|
|
178
|
+
function warnMissingTongueRuntime(cwd) {
|
|
179
|
+
const runtime = join(cwd, '.tongue', 'runtime.js');
|
|
180
|
+
if (!existsSync(runtime)) {
|
|
181
|
+
console.warn('[next-tongue] `.tongue/runtime.js` is missing. Run `npx tongue build` (or use `"build": "tongue build && next build"`). `next dev` without a recent build leaves getT/useT unwired.');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
174
184
|
function runCodegenSync(cwd, configPath) {
|
|
175
185
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
176
186
|
const bin = resolve(here, '../../bin/tongue.js');
|