next-tongue 0.9.2 → 0.9.3

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 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
 
@@ -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 = locale === defaultLocale
50
- ? {
51
- home: {
52
- title: 'Welcome, {name}!',
53
- body: 'Scaffolded by tongue init.',
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: 'proxy.ts',
76
+ path: layoutPath,
70
77
  kind: 'create',
71
- content: `import { createProxy } from 'next-tongue/proxy';
72
-
73
- export const proxy = createProxy();
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: 'app/[locale]/layout.tsx',
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);
@@ -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: `[locale]` layout should set `<html lang={locale}>` and
5
- * `dir={getDir(locale)}` (or equivalent) for RTL locales.
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: `[locale]` layout should set `<html lang={locale}>` and
11
- * `dir={getDir(locale)}` (or equivalent) for RTL locales.
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 layouts = await findLocaleLayouts(cwd);
18
- if (layouts.length === 0) {
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 <html lang> / dir',
28
+ message: 'No app/[locale]/layout.* (or src/app/[locale]/layout.*) found — cannot verify lang/dir',
24
29
  },
25
30
  ];
26
31
  }
27
- const issues = [];
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
- for (const file of layouts) {
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
- const hasLang = /<html[^>]*\blang=\{/.test(src) ||
32
- /<html[^>]*\blang=["']/.test(src) ||
33
- /\blang=\{locale\}/.test(src);
34
- const hasDir = /<html[^>]*\bdir=\{/.test(src) ||
35
- /<html[^>]*\bdir=["']/.test(src) ||
36
- /\bdir=\{getDir\(/.test(src) ||
37
- /\bdir=\{.*getDir/.test(src);
38
- if (!hasLang) {
39
- issues.push({
40
- check: 'a11y',
41
- severity: sev,
42
- message: 'Root locale layout <html> is missing lang={locale} (or lang=…)',
43
- file: file.replace(cwd + '/', '').replace(cwd + '\\', ''),
44
- });
45
- }
46
- if (rtlLocales.length > 0 && !hasDir) {
47
- issues.push({
48
- check: 'a11y',
49
- severity: sev,
50
- message: `Root locale layout <html> is missing dir={getDir(locale)} (RTL locales: ${rtlLocales.join(', ')})`,
51
- file: file.replace(cwd + '/', '').replace(cwd + '\\', ''),
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
- const candidates = [
59
- join(cwd, 'app', '[locale]'),
60
- join(cwd, 'src', 'app', '[locale]'),
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 root of candidates) {
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
+ }
@@ -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
- ...(input.turbopack?.rules ?? {}),
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-tongue",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Compiler-first, type-safe i18n for the Next.js 16 App Router",
5
5
  "license": "MIT",
6
6
  "type": "module",