next-tongue 0.9.10 → 0.9.12

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.
@@ -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/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 — compose createProxy({ locales, defaultLocale, … }) yourself (see docs/routing-and-proxy.md).');
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({
@@ -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 side-effect).
117
- const CODEGEN_VERSION = 9;
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,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
+ }
@@ -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;
@@ -67,13 +67,25 @@ export function transformCallSites(source, resourcePath, _options = {}) {
67
67
  importLines.push(`import { ${safeExport(key)} as ${ident} } from '${path}';`);
68
68
  loadedCalls.push(`process.env.NODE_ENV !== 'production' && globalThis.__tongueDevLoaded?.(${JSON.stringify(ns || '_root')}, ${JSON.stringify(key)});`);
69
69
  }
70
- const localeVar = '__tongue_locale';
71
- // Inject locale once after the first useT declaration
72
- code = code.replace(/((?:const|let|var)\s+\w+\s*=\s*useT\s*\([^)]*\))/, `$1\n const ${localeVar} = useLocale();`);
70
+ // Inject locale after every useT — one binding per translator var so each
71
+ // component (and multi-useT in one function) has __tongue_locale_* in scope.
72
+ for (const varName of translators.keys()) {
73
+ if (escapes(withoutDirs, varName))
74
+ continue;
75
+ const localeVar = localeVarFor(varName);
76
+ const declRe = new RegExp(`((?:const|let|var)\\s+${escapeRegExp(varName)}\\s*=\\s*useT\\s*\\([^)]*\\))`, 'g');
77
+ code = code.replace(declRe, (full) => {
78
+ // Idempotent if a prior pass already injected.
79
+ if (full.includes(localeVar))
80
+ return full;
81
+ return `${full}\n const ${localeVar} = useLocale();`;
82
+ });
83
+ }
73
84
  // Replace t.dynamic(expr, { from }, values?) — rewrite from the end so indices stay valid
74
85
  for (const [varName, ns] of translators) {
75
86
  if (escapes(withoutDirs, varName))
76
87
  continue;
88
+ const localeVar = localeVarFor(varName);
77
89
  const calls = findDynamicCalls(code, varName).filter((c) => c.keys && c.keys.length > 0);
78
90
  for (let ci = calls.length - 1; ci >= 0; ci--) {
79
91
  const call = calls[ci];
@@ -96,6 +108,7 @@ export function transformCallSites(source, resourcePath, _options = {}) {
96
108
  for (const [varName, ns] of translators) {
97
109
  if (escapes(withoutDirs, varName))
98
110
  continue;
111
+ const localeVar = localeVarFor(varName);
99
112
  for (const { key, ident } of [...imports.values()].filter((i) => i.ns === ns)) {
100
113
  const richRe = new RegExp(`\\b${escapeRegExp(varName)}\\.rich\\s*\\(\\s*(['"])${escapeRegExp(key)}\\1\\s*(,\\s*([^)]*))?\\)`, 'g');
101
114
  code = code.replace(richRe, (_full, _q, _rest, values) => {
@@ -119,9 +132,9 @@ export function transformCallSites(source, resourcePath, _options = {}) {
119
132
  if (loadedCalls.length > 0) {
120
133
  preamble += loadedCalls.join('\n') + '\n';
121
134
  }
122
- if (!/useLocale/.test(preamble + code) || !/from\s+['"]next-tongue\/client['"]/.test(code)) {
123
- preamble += `import { useLocale } from 'next-tongue/client';\n`;
124
- }
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));
125
138
  for (const [varName, ns] of translators) {
126
139
  if (!escapes(withoutDirs, varName))
127
140
  continue;
@@ -178,6 +191,55 @@ function escapes(source, varName) {
178
191
  function escapeRegExp(s) {
179
192
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
193
  }
194
+ /** Per-useT locale binding so each component function has its own in-scope var. */
195
+ function localeVarFor(varName) {
196
+ return varName === 't' ? '__tongue_locale' : `__tongue_locale_${varName}`;
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
+ }
181
243
  function safeImportIdent(ns, key) {
182
244
  return `__tongue_${(ns + '_' + key).replace(/[^A-Za-z0-9_]/g, '_')}`;
183
245
  }
@@ -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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",