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.
@@ -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
  })));
@@ -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 — 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,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 { loadLocaleMessages } from '../load-messages.js';
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 (matchesIgnore(fullKey, ignore))
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
+ }
@@ -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;
@@ -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
- if (!/useLocale/.test(preamble + code) || !/from\s+['"]next-tongue\/client['"]/.test(code)) {
136
- preamble += `import { useLocale } from 'next-tongue/client';\n`;
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
  }
@@ -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.11",
3
+ "version": "0.9.13",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",