next-tongue 0.9.8 → 0.9.10

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/init.js CHANGED
@@ -83,14 +83,12 @@ export default defineConfig({
83
83
  path: `${appRoot}/[locale]/page.tsx`,
84
84
  kind: 'create',
85
85
  content: `import { getT } from 'next-tongue/server';
86
- import { ensureTongue } from '#tongue/runtime';
87
86
 
88
87
  export default async function Page({
89
88
  params,
90
89
  }: {
91
90
  params: Promise<{ locale: string }>;
92
91
  }) {
93
- ensureTongue();
94
92
  const { locale } = await params;
95
93
  const t = await getT(locale, 'home');
96
94
  return (
@@ -246,10 +244,10 @@ export const config = {
246
244
  function rootLocaleLayout() {
247
245
  return `import { notFound } from 'next/navigation';
248
246
  import { TongueProvider } from 'next-tongue/client';
249
- import { getDir, isLocale, localeParams } from 'next-tongue/server';
250
- import { ensureTongue } from '#tongue/runtime';
247
+ import { getDir, isLocale } from 'next-tongue/server';
248
+ import { localeParams } from '#tongue/runtime';
251
249
 
252
- ensureTongue();
250
+ // Importing #tongue/runtime auto-runs ensureTongue() (withTongue apps).
253
251
  export const generateStaticParams = localeParams;
254
252
 
255
253
  export default async function LocaleLayout({
@@ -278,10 +276,10 @@ export default async function LocaleLayout({
278
276
  function nestedLocaleLayout() {
279
277
  return `import { notFound } from 'next/navigation';
280
278
  import { TongueProvider } from 'next-tongue/client';
281
- import { isLocale, localeParams } from 'next-tongue/server';
282
- import { ensureTongue } from '#tongue/runtime';
279
+ import { isLocale } from 'next-tongue/server';
280
+ import { localeParams } from '#tongue/runtime';
283
281
 
284
- ensureTongue();
282
+ // Importing #tongue/runtime auto-runs ensureTongue() (withTongue apps).
285
283
  export const generateStaticParams = localeParams;
286
284
 
287
285
  export default async function LocaleLayout({
@@ -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 (incl. reserved-key ident sanitization).
117
- const CODEGEN_VERSION = 8;
116
+ // Bump when emit shape changes (reserved idents, auto ensureTongue side-effect).
117
+ const CODEGEN_VERSION = 9;
118
118
  const hash = createHash('sha256')
119
119
  .update(JSON.stringify({
120
120
  codegen: CODEGEN_VERSION,
@@ -337,7 +337,10 @@ export async function buildMessages(options) {
337
337
  ? `export const pseudoLocale = ${JSON.stringify(config.pseudo.locale)};`
338
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
339
339
  ? ` configureFormatMessage({ timeZone, pseudo: { locale: ${JSON.stringify(config.pseudo.locale)}, expand: ${config.pseudo.expand} } });`
340
- : ` configureFormatMessage({ timeZone });`, ` ready = true;`, `}`, ``);
340
+ : ` configureFormatMessage({ timeZone });`, ` ready = true;`, `}`,
341
+ // Side-effect: importing `#tongue/runtime` (or getT's lazy import) wires the
342
+ // server without a per-page ensureTongue() call when using withTongue().
343
+ `ensureTongue();`, ``);
341
344
  await writeFile(join(outDir, 'runtime.js'), runtimeLines.join('\n'), 'utf8');
342
345
  await writeFile(join(outDir, 'runtime.d.ts'), [
343
346
  `/** Generated by next-tongue — do not edit. */`,
@@ -407,7 +410,7 @@ export async function buildMessages(options) {
407
410
  await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
408
411
  await writeFile(join(outDir, '.hash'), hash, 'utf8');
409
412
  // package.json imports helper note — apps map "#tongue/*" themselves / via withTongue
410
- await writeFile(join(outDir, 'README.md'), 'Generated by next-tongue. Do not edit. Map `#tongue/*` → this directory.\nImport `#tongue/runtime` and call `ensureTongue()` once per process.\n', 'utf8');
413
+ await writeFile(join(outDir, 'README.md'), 'Generated by next-tongue. Do not edit. Map `#tongue/*` → this directory.\nImporting `#tongue/runtime` (or calling getT) auto-runs ensureTongue(); an explicit call is optional.\n', 'utf8');
411
414
  await clearOutputDir(publishedOutDir);
412
415
  try {
413
416
  await rename(outDir, publishedOutDir);
@@ -75,6 +75,8 @@ function hasLangHint(src) {
75
75
  /<html[^>]*\blang=["']/.test(src) ||
76
76
  /\blang=\{locale\}/.test(src) ||
77
77
  /document\.documentElement\.lang/.test(src) ||
78
+ /setAttribute\(\s*['"]lang['"]/.test(src) ||
79
+ /\bHtmlLang\b/.test(src) ||
78
80
  /\.lang\s*=/.test(src));
79
81
  }
80
82
  function hasDirHint(src) {
@@ -82,7 +84,9 @@ function hasDirHint(src) {
82
84
  /<html[^>]*\bdir=["']/.test(src) ||
83
85
  /\bdir=\{getDir\(/.test(src) ||
84
86
  /\bdir=\{.*getDir/.test(src) ||
85
- /document\.documentElement\.dir/.test(src));
87
+ /document\.documentElement\.dir/.test(src) ||
88
+ /setAttribute\(\s*['"]dir['"]/.test(src) ||
89
+ /\bHtmlLang\b/.test(src));
86
90
  }
87
91
  async function findLocaleLayouts(cwd) {
88
92
  return findLayoutsUnder(cwd, ['app/[locale]', 'src/app/[locale]']);
@@ -22,3 +22,8 @@ export declare function validateStale(config: ResolvedTongueConfig, cwd: string,
22
22
  }): Promise<CheckIssue[]>;
23
23
  /** Letter + single quote + letter — usually meant as prose apostrophe, not ICU quote. */
24
24
  export declare function likelyUnescapedApostrophe(icu: string): boolean;
25
+ /**
26
+ * Apostrophe-in-words is common in fr/it/…; English `don't` floods CI with noise.
27
+ * Only hint for locales that regularly need ICU `''` in prose.
28
+ */
29
+ export declare function shouldHintApostrophe(locale: string): boolean;
@@ -69,11 +69,12 @@ export async function validateCatalog(config, cwd, opts) {
69
69
  });
70
70
  continue;
71
71
  }
72
- if (likelyUnescapedApostrophe(source)) {
72
+ if (shouldHintApostrophe(config.defaultLocale) &&
73
+ likelyUnescapedApostrophe(source)) {
73
74
  issues.push({
74
75
  check: 'icu',
75
76
  severity: 'warn',
76
- message: `Key "${fullKey}" looks like a natural-language apostrophe (e.g. French). In ICU, double it: "l''été" → l'été`,
77
+ message: `Key "${fullKey}" looks like a natural-language apostrophe. In ICU, double it: "l''été" → l'été`,
77
78
  key: fullKey,
78
79
  locale: config.defaultLocale,
79
80
  file,
@@ -142,6 +143,17 @@ export async function validateCatalog(config, cwd, opts) {
142
143
  });
143
144
  continue;
144
145
  }
146
+ if (shouldHintApostrophe(locale) &&
147
+ likelyUnescapedApostrophe(resolved)) {
148
+ issues.push({
149
+ check: 'icu',
150
+ severity: 'warn',
151
+ message: `Key "${fullKey}" looks like a natural-language apostrophe. In ICU, double it: "l''été" → l'été`,
152
+ key: fullKey,
153
+ locale,
154
+ file: localeFile,
155
+ });
156
+ }
145
157
  const mismatchSev = severityFor(config.strict.mismatch);
146
158
  if (mismatchSev) {
147
159
  const otherArgs = collectArgs(otherAst);
@@ -247,3 +259,10 @@ export function likelyUnescapedApostrophe(icu) {
247
259
  }
248
260
  return false;
249
261
  }
262
+ /**
263
+ * Apostrophe-in-words is common in fr/it/…; English `don't` floods CI with noise.
264
+ * Only hint for locales that regularly need ICU `''` in prose.
265
+ */
266
+ export function shouldHintApostrophe(locale) {
267
+ return /^(fr|it|ca|oc|ht|lb|rm|ga|gd|mt)(-|$)/i.test(locale);
268
+ }
@@ -48,6 +48,7 @@ export declare function createNavigation(config: NavigationConfig): {
48
48
  refresh: () => void;
49
49
  };
50
50
  usePathname: () => string;
51
+ usePathnameWithoutLocale: () => string;
51
52
  getPathname: (args: {
52
53
  href: Href;
53
54
  locale: Locale;
@@ -57,3 +58,10 @@ export declare function createNavigation(config: NavigationConfig): {
57
58
  children: (options: import("./locale-switcher.js").UseLocaleSwitcherResult) => ReactNode;
58
59
  }) => import("react").JSX.Element;
59
60
  };
61
+ /**
62
+ * Strip a leading locale segment from `next/navigation`'s pathname.
63
+ * Prefer `createNavigation(…).usePathname` / `usePathnameWithoutLocale` when
64
+ * you already use tongue navigation; this unbound helper is for apps that keep
65
+ * plain `next/link` + proxy for as-needed paths.
66
+ */
67
+ export declare function usePathnameWithoutLocale(locales: readonly string[]): string;
@@ -77,6 +77,10 @@ export function createNavigation(config) {
77
77
  const locale = useLocale();
78
78
  return toInternalPathname(stripped, locale, bound.pathnames);
79
79
  }
80
+ /** Same as `usePathname` when pathnames are unset — locale prefix stripped. */
81
+ function usePathnameWithoutLocale() {
82
+ return stripLocalePrefix(useNextPathname(), bound.locales);
83
+ }
80
84
  const { useLocaleSwitcher, LocaleSwitcher } = createLocaleSwitcher(config);
81
85
  return {
82
86
  Link,
@@ -84,8 +88,18 @@ export function createNavigation(config) {
84
88
  permanentRedirect,
85
89
  useRouter,
86
90
  usePathname,
91
+ usePathnameWithoutLocale,
87
92
  getPathname: (args) => resolve(args.href, args.locale),
88
93
  useLocaleSwitcher,
89
94
  LocaleSwitcher,
90
95
  };
91
96
  }
97
+ /**
98
+ * Strip a leading locale segment from `next/navigation`'s pathname.
99
+ * Prefer `createNavigation(…).usePathname` / `usePathnameWithoutLocale` when
100
+ * you already use tongue navigation; this unbound helper is for apps that keep
101
+ * plain `next/link` + proxy for as-needed paths.
102
+ */
103
+ export function usePathnameWithoutLocale(locales) {
104
+ return stripLocalePrefix(useNextPathname(), locales);
105
+ }
@@ -6,5 +6,8 @@ export type WithTongueOptions = {
6
6
  /**
7
7
  * Config wrapper (not a bundler plugin): runs codegen when Next loads config,
8
8
  * maps `#tongue/*` → outDir, registers the call-site loader for Turbopack + webpack.
9
+ *
10
+ * Codegen emits `#tongue/runtime` that auto-calls `ensureTongue()` on import;
11
+ * `getT` / `getFormat` also lazy-import that module — no per-page ensureTongue().
9
12
  */
10
13
  export declare function withTongue<T extends object>(nextConfig?: T, options?: WithTongueOptions): T;
@@ -7,6 +7,9 @@ import { startDevWatcher } from './watcher.js';
7
7
  /**
8
8
  * Config wrapper (not a bundler plugin): runs codegen when Next loads config,
9
9
  * maps `#tongue/*` → outDir, registers the call-site loader for Turbopack + webpack.
10
+ *
11
+ * Codegen emits `#tongue/runtime` that auto-calls `ensureTongue()` on import;
12
+ * `getT` / `getFormat` also lazy-import that module — no per-page ensureTongue().
10
13
  */
11
14
  export function withTongue(nextConfig = {}, options = {}) {
12
15
  const cwd = process.cwd();
@@ -48,6 +48,8 @@ export type CreateProxyOptions = {
48
48
  resolveLocale?: (req: TongueProxyRequest) => string | undefined | Promise<string | undefined>;
49
49
  onLocale?: (locale: string, req: TongueProxyRequest) => void;
50
50
  };
51
+ /** Default locale cookie name (`SameSite=Lax`). Use with `requestLocale` in API routes. */
52
+ export declare const LOCALE_COOKIE_NAME = "TONGUE_LOCALE";
51
53
  /**
52
54
  * Next.js 16 `proxy.ts` handler for locale prefix routing.
53
55
  * Returns standard `Response` objects (compatible with Next proxy/middleware).
@@ -2,8 +2,10 @@ import { toInternalPathname } from '../navigation/pathname.js';
2
2
  import { createLocaleMatcher, } from './locale-matcher.js';
3
3
  export { createLocaleMatcher, buildLocaleMatcherTables, emitLocaleMatcherModule, localeParentChain, matchTagAgainstTables, } from './locale-matcher.js';
4
4
  export { verifyLocalePreference, signLocalePreference, timingSafeEqual, DEFAULT_PREF_COOKIE, } from '../runtime/locale-preference.js';
5
+ /** Default locale cookie name (`SameSite=Lax`). Use with `requestLocale` in API routes. */
6
+ export const LOCALE_COOKIE_NAME = 'TONGUE_LOCALE';
5
7
  const DEFAULT_COOKIE = {
6
- name: 'TONGUE_LOCALE',
8
+ name: LOCALE_COOKIE_NAME,
7
9
  maxAge: 60 * 60 * 24 * 365,
8
10
  sameSite: 'lax',
9
11
  path: '/',
@@ -14,8 +14,12 @@ export type TFunction<N extends Namespace = Namespace> = {
14
14
  <K extends MessageKey<N>>(key: K, ...args: [ArgsFor<N, K>] extends [never] ? [values?: Record<string, never>] : Record<string, never> extends ArgsFor<N, K> ? [values?: ArgsFor<N, K>] : [values: ArgsFor<N, K>]): string;
15
15
  rich: <K extends MessageKey<N>>(key: K, ...args: [ArgsFor<N, K>] extends [never] ? [values?: Record<string, never>] : Record<string, never> extends ArgsFor<N, K> ? [values?: ArgsFor<N, K>] : [values: ArgsFor<N, K>]) => ReactNode;
16
16
  has: (key: string) => boolean;
17
- dynamic: (key: string, opts: {
18
- from: readonly MessageKey<N>[];
17
+ /**
18
+ * Non-literal key from a declared set. `key` is narrowed to `From[number]`
19
+ * when `from` is a `as const` / readonly tuple of known keys.
20
+ */
21
+ dynamic: <const From extends readonly MessageKey<N>[]>(key: From[number], opts: {
22
+ from: From;
19
23
  }, values?: Record<string, unknown>) => string;
20
24
  ns: <N2 extends Namespace>(namespace: N2) => TFunction<N2>;
21
25
  };
@@ -29,11 +29,20 @@ export declare const defaultLocale: {
29
29
  export type LocaleProps = {
30
30
  locale: string;
31
31
  };
32
+ /** Map of translators when loading several namespaces in one call. */
33
+ export type MultiT<Ns extends readonly Namespace[]> = {
34
+ [K in Ns[number]]: TFunction<K>;
35
+ };
32
36
  /**
33
37
  * Explicit locale (always available): `getT(locale, ns)` or `getT(locale)` for `_root`.
38
+ * Multi-ns: `getT(locale, ['home', 'common'])` → `{ home, common }` translators.
34
39
  * With `routing.rootParams: true`, `getT(ns)` resolves locale from `next/root-params`
35
40
  * when the single argument is a configured namespace (not a locale tag).
41
+ *
42
+ * `withTongue()` + generated `#tongue/runtime` auto-wires messages; an explicit
43
+ * `ensureTongue()` call is optional.
36
44
  */
45
+ export declare function getT<const Ns extends readonly [Namespace, ...Namespace[]]>(locale: Locale | string, namespaces: Ns): Promise<MultiT<Ns>>;
37
46
  export declare function getT<N extends Namespace>(locale: Locale | string, namespace: N): Promise<TFunction<N>>;
38
47
  export declare function getT(locale: Locale | string): Promise<TFunction<'_root'>>;
39
48
  export declare function getFormat(locale?: string, options?: {
@@ -61,3 +70,5 @@ export type { Format, SanctionedUnit, SimpleUnit, DisplayNameType, } from '../ru
61
70
  export { isSanctionedUnit, SIMPLE_UNITS } from '../runtime/format.js';
62
71
  export { setLocalePreference, } from './locale-preference.js';
63
72
  export type { SetLocalePreferenceOptions, CookieStoreLike, } from './locale-preference.js';
73
+ export { requestLocale, LOCALE_COOKIE_NAME, LOCALE_HEADER_NAME, } from './request-locale.js';
74
+ export type { RequestLocaleOptions, RequestLocaleSource, } from './request-locale.js';
@@ -42,6 +42,18 @@ export const defaultLocale = {
42
42
  };
43
43
  export async function getT(localeOrNs, namespace) {
44
44
  if (namespace !== undefined) {
45
+ if (Array.isArray(namespace)) {
46
+ if (namespace.length === 0) {
47
+ throw new TongueError('TONGUE_E_CONFIG', 'getT(locale, namespaces) requires a non-empty namespace array');
48
+ }
49
+ const locale = String(localeOrNs);
50
+ const out = {};
51
+ // Load sequentially so a single ensureRuntime runs first; namespaces share locale.
52
+ for (const ns of namespace) {
53
+ out[ns] = await getTWithLocale(locale, ns);
54
+ }
55
+ return out;
56
+ }
45
57
  return getTWithLocale(String(localeOrNs), namespace);
46
58
  }
47
59
  const single = String(localeOrNs);
@@ -54,9 +66,10 @@ export async function getT(localeOrNs, namespace) {
54
66
  return getTWithLocale(single, '_root');
55
67
  }
56
68
  async function getTWithLocale(locale, ns) {
69
+ await ensureRuntime();
57
70
  assertLocale(locale);
58
71
  if (!namespaceLoader) {
59
- throw new TongueError('TONGUE_E_TYPES_MISSING', 'Message modules not loaded. Call ensureTongue() from `#tongue/runtime` (or wrap next.config with withTongue()).');
72
+ throw new TongueError('TONGUE_E_TYPES_MISSING', 'Message modules not loaded. Wrap next.config with withTongue() and run `tongue build && next build` so `#tongue/runtime` exists. (ensureTongue() is optional — getT loads it automatically.)');
60
73
  }
61
74
  const messages = await namespaceLoader(ns);
62
75
  return createT({
@@ -67,7 +80,24 @@ async function getTWithLocale(locale, ns) {
67
80
  },
68
81
  });
69
82
  }
83
+ /**
84
+ * Load `#tongue/runtime` and call `ensureTongue()` once when the server runtime
85
+ * is not yet wired (withTongue apps — no per-page ensureTongue boilerplate).
86
+ */
87
+ async function ensureRuntime() {
88
+ if (namespaceLoader)
89
+ return;
90
+ try {
91
+ const importer = new Function('s', 'return import(s)');
92
+ const mod = await importer('#tongue/runtime');
93
+ mod.ensureTongue?.();
94
+ }
95
+ catch {
96
+ /* fall through — getTWithLocale throws a clear error */
97
+ }
98
+ }
70
99
  export async function getFormat(locale, options) {
100
+ await ensureRuntime();
71
101
  const resolved = locale ?? (runtimeRootParams ? await resolveRootLocale() : undefined);
72
102
  if (resolved == null) {
73
103
  throw new TongueError('TONGUE_E_LOCALE', 'getFormat(locale) requires a locale, or routing.rootParams: true');
@@ -141,6 +171,7 @@ export { generateAlternates, createGenerateAlternates, } from './alternates.js';
141
171
  export { translateIssues, defaultFormatPath, } from '../runtime/translate-issues.js';
142
172
  export { isSanctionedUnit, SIMPLE_UNITS } from '../runtime/format.js';
143
173
  export { setLocalePreference, } from './locale-preference.js';
174
+ export { requestLocale, LOCALE_COOKIE_NAME, LOCALE_HEADER_NAME, } from './request-locale.js';
144
175
  function assertLocale(locale) {
145
176
  if (runtimeLocales.length && !runtimeLocales.includes(locale)) {
146
177
  throw new TongueError('TONGUE_E_LOCALE', `Locale "${locale}" is not in configured locales [${runtimeLocales.join(', ')}]`);
@@ -0,0 +1,27 @@
1
+ /** Default cookie name set by `createProxy` (`SameSite=Lax`). */
2
+ export declare const LOCALE_COOKIE_NAME = "TONGUE_LOCALE";
3
+ /** Common header for client → API locale (wizard / fetch). */
4
+ export declare const LOCALE_HEADER_NAME = "x-locale";
5
+ export type RequestLocaleSource = {
6
+ headers: {
7
+ get: (name: string) => string | null;
8
+ };
9
+ cookies?: {
10
+ get: (name: string) => {
11
+ value: string;
12
+ } | undefined;
13
+ };
14
+ };
15
+ export type RequestLocaleOptions = {
16
+ locales: readonly string[];
17
+ defaultLocale: string;
18
+ /** Defaults to `TONGUE_LOCALE`. */
19
+ cookieName?: string;
20
+ /** Defaults to `x-locale`. */
21
+ headerName?: string;
22
+ };
23
+ /**
24
+ * Resolve locale in Route Handlers / Server Actions (no `params.locale`).
25
+ * Order: header → cookie → defaultLocale.
26
+ */
27
+ export declare function requestLocale(req: RequestLocaleSource, opts: RequestLocaleOptions): string;
@@ -0,0 +1,22 @@
1
+ import { isLocale as isLocaleValue } from '../runtime/locale.js';
2
+ /** Default cookie name set by `createProxy` (`SameSite=Lax`). */
3
+ export const LOCALE_COOKIE_NAME = 'TONGUE_LOCALE';
4
+ /** Common header for client → API locale (wizard / fetch). */
5
+ export const LOCALE_HEADER_NAME = 'x-locale';
6
+ /**
7
+ * Resolve locale in Route Handlers / Server Actions (no `params.locale`).
8
+ * Order: header → cookie → defaultLocale.
9
+ */
10
+ export function requestLocale(req, opts) {
11
+ const headerName = opts.headerName ?? LOCALE_HEADER_NAME;
12
+ const cookieName = opts.cookieName ?? LOCALE_COOKIE_NAME;
13
+ const fromHeader = req.headers.get(headerName)?.trim();
14
+ if (fromHeader && isLocaleValue(fromHeader, opts.locales)) {
15
+ return fromHeader;
16
+ }
17
+ const fromCookie = req.cookies?.get(cookieName)?.value?.trim();
18
+ if (fromCookie && isLocaleValue(fromCookie, opts.locales)) {
19
+ return fromCookie;
20
+ }
21
+ return opts.defaultLocale;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",