create-zudo-doc 5.14.0 → 5.15.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to `create-zudo-doc` are documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and release notes are generated from the changelog MDX pages.
6
6
 
7
+ ## [5.15.0] - 2026-09-01
8
+
9
+ ### Features
10
+
11
+ - Added one ordered locale-plan contract across the API, CLI, presets, prompts, scaffold output, and generated guidance, allowing new projects to select and emit any supported set of locales. (`10e34ca0`, `5a990248`, `c9543772`)
12
+
13
+ ### Bug Fixes
14
+
15
+ - Newly generated multi-locale projects now localize generated Claude/Codex resource routes by default instead of keeping them limited to the default locale. (`26ecad11`)
16
+
17
+ ### Other Changes
18
+
19
+ - Updated newly generated projects to use the zfb 2.14.2 package family. (`1c1f8f26`)
20
+
7
21
  ## [5.14.0] - 2026-08-31
8
22
 
9
23
  ### Features
package/README.md CHANGED
@@ -35,13 +35,49 @@ pnpm create zudo-doc my-docs --yes
35
35
  # Fully specified, non-interactive
36
36
  pnpm create zudo-doc my-docs \
37
37
  --lang ja \
38
+ --additional-langs en,de \
38
39
  --scheme "Default Dark" \
39
- --no-i18n \
40
40
  --search \
41
41
  --pm pnpm \
42
42
  --install
43
43
  ```
44
44
 
45
+ ### Locales and translations
46
+
47
+ `--lang` selects the primary locale. Its pages use the unprefixed
48
+ `/docs/...` routes and `src/content/docs/`. Add any number of additional
49
+ locales, in the order shown by the language switcher, with
50
+ `--additional-langs <code,...>`:
51
+
52
+ ```bash
53
+ pnpm create zudo-doc my-docs \
54
+ --lang en \
55
+ --additional-langs ja,de \
56
+ --yes
57
+ ```
58
+
59
+ An omitted or blank list creates a single-locale project. The same rule
60
+ applies to a preset that omits `additionalLangs`; an explicit non-empty list
61
+ is normalized to lowercase, validates each code for safe URL/path use, rejects
62
+ duplicates and the primary code, and creates `src/content/docs-<code>/` plus
63
+ the corresponding `/<code>/docs/...` routes. A legacy preset containing only
64
+ `i18n: true` keeps compatibility inference (`ja` for primary `en`, otherwise
65
+ `en`).
66
+
67
+ CLI locale flags replace the preset list rather than merging with it. An
68
+ explicit `--additional-langs` also enables i18n; if it overrides
69
+ `--no-i18n`, the CLI prints a warning so the precedence is visible.
70
+
71
+ The generated starter uses Japanese prose for `ja` and English placeholder
72
+ prose for every other additional locale. Translate those pages before
73
+ publishing. Labels are configuration-driven: the switcher uses each locale's
74
+ configured `label` and map order, so custom codes and labels are not tied to
75
+ hard-coded `JP` or `JA` links.
76
+
77
+ Built-in UI translations resolve in this order:
78
+
79
+ `requested locale → configured default locale → package English → raw UI-string key`
80
+
45
81
  ## Options
46
82
 
47
83
  ### Project basics
@@ -50,6 +86,7 @@ pnpm create zudo-doc my-docs \
50
86
  |------|-------------|---------|
51
87
  | `[project-name]` | Project name (positional arg or `--name`) | prompted |
52
88
  | `--lang <code>` | Default language: `en`, `ja`, `zh-cn`, `zh-tw`, `ko`, `es`, `fr`, `de`, `pt` | `en` |
89
+ | `--additional-langs <a,b>` | Ordered additional locale codes; implies i18n and replaces a preset list | none |
53
90
  | `--pm <manager>` | Package manager: `pnpm`, `npm`, `yarn`, `bun` | detected |
54
91
  | `--[no-]install` | Install dependencies after scaffolding | prompted |
55
92
  | `-y, --yes` | Use defaults for all unspecified options, skip prompts | — |
@@ -72,7 +109,7 @@ Each feature has a `--[no-]<flag>` form. Passing `--feature` enables it; `--no-f
72
109
 
73
110
  | Flag | Description | Default |
74
111
  |------|-------------|---------|
75
- | `--[no-]i18n` | Multi-language support (adds a secondary locale) | off |
112
+ | `--[no-]i18n` | Legacy multi-language toggle; with no explicit list, infers one additional locale | off |
76
113
  | `--[no-]search` | Full-text search | on |
77
114
  | `--[no-]sidebar-filter` | Real-time sidebar filter | on |
78
115
  | `--[no-]image-enlarge` | Click-to-enlarge for oversized images | on |
@@ -140,6 +177,7 @@ import { createZudoDoc } from "create-zudo-doc";
140
177
  await createZudoDoc({
141
178
  projectName: "my-docs",
142
179
  defaultLang: "en",
180
+ additionalLangs: ["ja", "de"],
143
181
  colorSchemeMode: "single",
144
182
  singleScheme: "Default Dark",
145
183
  features: ["search", "sidebarFilter", "tagGovernance"],
package/dist/api.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { PresetHeaderRightItem, PresetMetaTagsConfig } from "./preset.js";
2
+ export { resolveLocalePlan, type LocalePlan, type LocalePlanInput, } from "./locale-plan.js";
2
3
  export type { UserChoices } from "./prompts.js";
3
4
  export interface CreateOptions {
4
5
  projectName: string;
5
6
  /** Default language code (default: "en") */
6
7
  defaultLang?: string;
8
+ /** Ordered additional locale codes. A non-empty list implies i18n. */
9
+ additionalLangs?: string[];
7
10
  colorSchemeMode: "single" | "light-dark";
8
11
  singleScheme?: string;
9
12
  lightScheme?: string;
package/dist/api.js CHANGED
@@ -3,6 +3,8 @@ import { SINGLE_SCHEMES, THEME_PACKS } from "./constants.js";
3
3
  import { parseChangelogPackages, validateChangelogPackages, validateHeaderRightItems, validateMetaTags, } from "./preset.js";
4
4
  import { scaffold } from "./scaffold.js";
5
5
  import { initGitRepo, installDependencies, validateProjectName } from "./utils.js";
6
+ import { resolveLocalePlan } from "./locale-plan.js";
7
+ export { resolveLocalePlan, } from "./locale-plan.js";
6
8
  export async function createZudoDoc(options) {
7
9
  const { install = false, git = false, ...rest } = options;
8
10
  const nameError = validateProjectName(rest.projectName);
@@ -52,9 +54,18 @@ export async function createZudoDoc(options) {
52
54
  throw new Error(err);
53
55
  changelogPackages = parseChangelogPackages(changelogPackages);
54
56
  }
57
+ const localePlan = resolveLocalePlan({
58
+ defaultLang: rest.defaultLang ?? "en",
59
+ additionalLangs: rest.additionalLangs,
60
+ i18n: rest.features.includes("i18n"),
61
+ });
55
62
  const choices = {
56
63
  ...rest,
57
- defaultLang: rest.defaultLang ?? "en",
64
+ defaultLang: localePlan.defaultLang,
65
+ additionalLangs: rest.additionalLangs === undefined ? undefined : localePlan.additionalLangs,
66
+ features: localePlan.i18n
67
+ ? [...new Set([...rest.features, "i18n"])]
68
+ : rest.features.filter((feature) => feature !== "i18n"),
58
69
  changelogPackages,
59
70
  };
60
71
  await scaffold(choices);
@@ -1,4 +1,5 @@
1
1
  import type { UserChoices } from "./prompts.js";
2
+ import type { LocalePlan } from "./locale-plan.js";
2
3
  /**
3
4
  * Generate the per-project `CLAUDE.md` (minimal-scaffold shape, epic
4
5
  * zudolab/zudo-doc#2651, Wave 6 #2660). Rewritten from scratch — the old
@@ -7,4 +8,4 @@ import type { UserChoices } from "./prompts.js";
7
8
  * longer exists. The scaffolded project is now ~13 files; almost
8
9
  * everything referenced here lives in `node_modules/@takazudo/zudo-doc`.
9
10
  */
10
- export declare function generateCLAUDEFile(choices: UserChoices): string;
11
+ export declare function generateCLAUDEFile(choices: UserChoices, localePlan?: LocalePlan): string;
@@ -1,4 +1,5 @@
1
- import { capitalize, pmRunCommand } from "./utils.js";
1
+ import { resolveLocalePlan } from "./locale-plan.js";
2
+ import { capitalize, getLangLabel, pmRunCommand } from "./utils.js";
2
3
  /**
3
4
  * Generate the per-project `CLAUDE.md` (minimal-scaffold shape, epic
4
5
  * zudolab/zudo-doc#2651, Wave 6 #2660). Rewritten from scratch — the old
@@ -7,9 +8,30 @@ import { capitalize, pmRunCommand } from "./utils.js";
7
8
  * longer exists. The scaffolded project is now ~13 files; almost
8
9
  * everything referenced here lives in `node_modules/@takazudo/zudo-doc`.
9
10
  */
10
- export function generateCLAUDEFile(choices) {
11
+ export function generateCLAUDEFile(choices, localePlan = resolveLocalePlan({
12
+ defaultLang: choices.defaultLang,
13
+ additionalLangs: choices.additionalLangs,
14
+ i18n: choices.features.includes("i18n"),
15
+ i18nExplicitlyDisabled: choices.explicitlyDisabledFeatures?.includes("i18n"),
16
+ })) {
11
17
  const siteName = capitalize(choices.projectName.replace(/-/g, " "));
12
18
  const lines = [];
19
+ const localeDisplayName = (locale) => {
20
+ if (locale === "en")
21
+ return "English";
22
+ if (locale === "ja")
23
+ return "Japanese";
24
+ return getLangLabel(locale);
25
+ };
26
+ const localeStarterNote = (locale) => {
27
+ if (locale === "ja")
28
+ return "Japanese starter prose";
29
+ if (locale === "en")
30
+ return "English starter prose";
31
+ return "English placeholder prose pending translation";
32
+ };
33
+ const defaultLocaleLabel = localeDisplayName(localePlan.defaultLang);
34
+ const additionalLocales = localePlan.i18n ? localePlan.additionalLangs : [];
13
35
  lines.push(`# ${siteName}`);
14
36
  lines.push(``);
15
37
  lines.push(`Documentation site built with [zudo-doc](https://github.com/zudolab/zudo-doc) — a zfb-based documentation framework with MDX, Tailwind CSS v4, and Preact islands. This project is intentionally minimal: one config file (\`zfb.config.ts\`) plus markdown content — layout, chrome, and islands all ship from \`@takazudo/zudo-doc\` in \`node_modules\`.`);
@@ -49,16 +71,17 @@ export function generateCLAUDEFile(choices) {
49
71
  lines.push(`pages/`);
50
72
  lines.push(`├── index.tsx # 1-line re-export of the package home route`);
51
73
  lines.push(`└── docs/[[...slug]].tsx # self-contained doc-route stub (required for \`${pm} dev\`)`);
52
- if (choices.features.includes("i18n")) {
74
+ if (localePlan.i18n) {
53
75
  lines.push(` [locale]/docs/[[...slug]].tsx # same, for non-default locales`);
54
76
  }
55
77
  lines.push(`src/`);
56
78
  lines.push(`├── chrome-bindings.tsx # optional typed primary chrome / named header / MDX bindings`);
57
79
  lines.push(`├── content/`);
58
- lines.push(`│ └── docs/ # MDX content (this project's showcase docs)`);
59
- if (choices.features.includes("i18n")) {
60
- const secondaryLang = choices.defaultLang === "ja" ? "en" : "ja";
61
- lines.push(`│ └── docs-${secondaryLang}/ # ${secondaryLang === "ja" ? "Japanese" : "English"} MDX content (mirrors docs/)`);
80
+ const defaultBranch = additionalLocales.length === 0 ? "└──" : "├──";
81
+ lines.push(`│ ${defaultBranch} docs/ # ${defaultLocaleLabel} (default) MDX content (routes at /docs/; ${localeStarterNote(localePlan.defaultLang)})`);
82
+ for (const [index, locale] of additionalLocales.entries()) {
83
+ const branch = index === additionalLocales.length - 1 ? "└──" : "├──";
84
+ lines.push(`│ ${branch} docs-${locale}/ # ${localeDisplayName(locale)} MDX content (routes at /${locale}/docs/; ${localeStarterNote(locale)})`);
62
85
  }
63
86
  lines.push(`└── styles/`);
64
87
  lines.push(` └── global.css # @import chain + a token-override slot — that's it`);
@@ -105,15 +128,15 @@ export function generateCLAUDEFile(choices) {
105
128
  lines.push(`Admonitions (above), tabbed content (\`<Tabs>\` / \`<TabItem>\`, \`<CodeGroup>\`), and block math (\`<MathBlock>\`) work the same way — no import. Full reference: https://zudo-doc.takazudomodular.com/docs/components/`);
106
129
  lines.push(``);
107
130
  // i18n section
108
- if (choices.features.includes("i18n")) {
109
- const secondaryLang = choices.defaultLang === "ja" ? "en" : "ja";
110
- const defaultLabel = choices.defaultLang === "ja" ? "Japanese" : "English";
111
- const secondaryLabel = secondaryLang === "ja" ? "Japanese" : "English";
131
+ if (localePlan.i18n) {
112
132
  lines.push(`## i18n`);
113
133
  lines.push(``);
114
- lines.push(`- ${defaultLabel} (default): \`/docs/...\` — content in \`src/content/docs/\``);
115
- lines.push(`- ${secondaryLabel}: \`/${secondaryLang}/docs/...\` content in \`src/content/docs-${secondaryLang}/\``);
116
- lines.push(`- ${secondaryLabel} docs should mirror the ${defaultLabel} directory structure`);
134
+ lines.push(`- ${defaultLocaleLabel} (default, \`${localePlan.defaultLang}\`): \`/docs/...\` — content in \`src/content/docs/\` (${localeStarterNote(localePlan.defaultLang)})`);
135
+ for (const locale of additionalLocales) {
136
+ lines.push(`- ${localeDisplayName(locale)} (\`${locale}\`): \`/${locale}/docs/...\` content in \`src/content/docs-${locale}/\` (${localeStarterNote(locale)})`);
137
+ }
138
+ lines.push(`- Every additional-locale directory should mirror the default directory structure`);
139
+ lines.push(`- The \`ja\` locale, when configured, receives Japanese starter prose and uses Japanese translation conventions. Other non-EN locale directories currently receive English placeholder prose pending translation; do not assume they are already translated.`);
117
140
  lines.push(`- Both \`pages/docs/[[...slug]].tsx\` and \`pages/[locale]/docs/[[...slug]].tsx\` are self-contained doc-route stubs shipped by the generator as explicit host-owned seams. zfb 2.13.1 also renders package-injected dynamic routes in dev; keep these files so the generated project retains route ownership and customization.`);
118
141
  lines.push(``);
119
142
  }
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface CliArgs {
2
2
  name?: string;
3
3
  lang?: string;
4
+ additionalLangs?: string[];
4
5
  colorSchemeMode?: "single" | "light-dark";
5
6
  scheme?: string;
6
7
  lightScheme?: string;
@@ -43,6 +44,8 @@ export interface CliArgs {
43
44
  yes?: boolean;
44
45
  help?: boolean;
45
46
  }
47
+ /** Presets are the base layer; an explicitly supplied CLI list replaces it. */
48
+ export declare function layerAdditionalLangs(presetValue: string[] | undefined, cliValue: string[] | undefined): string[] | undefined;
46
49
  export declare function parseArgs(argv?: string[]): CliArgs;
47
50
  export declare function printHelp(): void;
48
51
  export declare function validateArgs(args: CliArgs): string | null;
package/dist/cli.js CHANGED
@@ -3,11 +3,17 @@ import pc from "picocolors";
3
3
  import { FEATURES, SINGLE_SCHEMES, SUPPORTED_LANGS, THEME_PACKS } from "./constants.js";
4
4
  import { parseChangelogPackages, validateChangelogPackages, } from "./preset.js";
5
5
  import { validateProjectName } from "./utils.js";
6
+ import { resolveLocalePlan } from "./locale-plan.js";
7
+ /** Presets are the base layer; an explicitly supplied CLI list replaces it. */
8
+ export function layerAdditionalLangs(presetValue, cliValue) {
9
+ return cliValue === undefined ? presetValue : cliValue;
10
+ }
6
11
  export function parseArgs(argv = process.argv.slice(2)) {
7
12
  const raw = minimist(argv, {
8
13
  string: [
9
14
  "name",
10
15
  "lang",
16
+ "additional-langs",
11
17
  "color-scheme-mode",
12
18
  "scheme",
13
19
  "light-scheme",
@@ -42,6 +48,10 @@ export function parseArgs(argv = process.argv.slice(2)) {
42
48
  }
43
49
  if (raw.lang)
44
50
  args.lang = raw.lang;
51
+ if (raw["additional-langs"] !== undefined) {
52
+ const value = raw["additional-langs"];
53
+ args.additionalLangs = (typeof value === "string" ? value : String(value)).split(",");
54
+ }
45
55
  if (raw["color-scheme-mode"])
46
56
  args.colorSchemeMode = raw["color-scheme-mode"];
47
57
  if (raw.scheme)
@@ -96,6 +106,7 @@ ${pc.bold("Options:")}
96
106
  --name <name> Project name (or first positional arg)
97
107
  --lang <code> Default language (${langList})
98
108
  Default: en
109
+ --additional-langs <a,b> Additional locale codes (ordered; implies i18n)
99
110
  --color-scheme-mode <mode> single | light-dark
100
111
  --scheme <name> Color scheme (single mode)
101
112
  --light-scheme <name> Light scheme (light-dark mode)
@@ -137,6 +148,22 @@ export function validateArgs(args) {
137
148
  return `Invalid language "${args.lang}". Supported: ${validLangs.join(", ")}`;
138
149
  }
139
150
  }
151
+ // A preset may supply the primary locale, so defer cross-field validation
152
+ // until the layered choices reach runPrompts() when --lang is omitted.
153
+ if (args.additionalLangs !== undefined &&
154
+ (args.preset === undefined || args.lang !== undefined)) {
155
+ try {
156
+ resolveLocalePlan({
157
+ defaultLang: args.lang ?? "en",
158
+ additionalLangs: args.additionalLangs,
159
+ i18n: args.i18n ?? false,
160
+ i18nExplicitlyDisabled: args.i18n === false,
161
+ });
162
+ }
163
+ catch (error) {
164
+ return error instanceof Error ? error.message : String(error);
165
+ }
166
+ }
140
167
  if (args.colorSchemeMode && !["single", "light-dark"].includes(args.colorSchemeMode)) {
141
168
  return `Invalid color-scheme-mode "${args.colorSchemeMode}". Must be "single" or "light-dark"`;
142
169
  }
package/dist/compose.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { UserChoices } from "./prompts.js";
2
+ import type { LocalePlan } from "./locale-plan.js";
2
3
  /** A single injection into a shared file at an anchor point. */
3
4
  export interface Injection {
4
5
  /** Target file path relative to project root */
@@ -29,13 +30,13 @@ export interface FeatureDefinition {
29
30
  * Post-processing hook for complex transformations that cannot be expressed
30
31
  * as simple file copies or anchor injections (e.g. i18n page patching).
31
32
  */
32
- postProcess?: (targetDir: string, choices: UserChoices) => Promise<void>;
33
+ postProcess?: (targetDir: string, choices: UserChoices, localePlan?: LocalePlan) => Promise<void>;
33
34
  }
34
35
  /**
35
36
  * A function that returns a FeatureDefinition based on user choices.
36
37
  * This allows injections to be conditional on other choices.
37
38
  */
38
- export type FeatureModule = (choices: UserChoices) => FeatureDefinition;
39
+ export type FeatureModule = (choices: UserChoices, localePlan?: LocalePlan) => FeatureDefinition;
39
40
  /**
40
41
  * Apply a list of injections to files in the target directory.
41
42
  *
@@ -62,7 +63,7 @@ export declare function copyFeatureFiles(featureFilesDir: string, targetDir: str
62
63
  * Resolve which features are selected based on UserChoices.
63
64
  * Handles special cases like the footer pseudo-feature.
64
65
  */
65
- export declare function resolveSelectedFeatures(choices: UserChoices, featureModules: Record<string, FeatureModule>): FeatureDefinition[];
66
+ export declare function resolveSelectedFeatures(choices: UserChoices, featureModules: Record<string, FeatureModule>, localePlan?: LocalePlan): FeatureDefinition[];
66
67
  /**
67
68
  * Validate that all feature dependencies are satisfied.
68
69
  * Throws if a selected feature depends on one that isn't selected.
@@ -96,4 +97,4 @@ export declare const ANCHOR_FILES: string[];
96
97
  * 5. Run post-processing hooks
97
98
  * 6. Clean up unused anchors
98
99
  */
99
- export declare function composeFeatures(targetDir: string, choices: UserChoices, featureModules: Record<string, FeatureModule>, featuresDir: string): Promise<void>;
100
+ export declare function composeFeatures(targetDir: string, choices: UserChoices, featureModules: Record<string, FeatureModule>, featuresDir: string, localePlan?: LocalePlan): Promise<void>;
package/dist/compose.js CHANGED
@@ -120,7 +120,7 @@ export async function copyFeatureFiles(featureFilesDir, targetDir) {
120
120
  * Resolve which features are selected based on UserChoices.
121
121
  * Handles special cases like the footer pseudo-feature.
122
122
  */
123
- export function resolveSelectedFeatures(choices, featureModules) {
123
+ export function resolveSelectedFeatures(choices, featureModules, localePlan) {
124
124
  const selected = [];
125
125
  for (const [name, moduleFn] of Object.entries(featureModules)) {
126
126
  // Special case: footer is activated by footerNavGroup, footerCopyright,
@@ -129,12 +129,12 @@ export function resolveSelectedFeatures(choices, featureModules) {
129
129
  if (choices.features.includes("footerNavGroup") ||
130
130
  choices.features.includes("footerCopyright") ||
131
131
  choices.features.includes("footerTaglist")) {
132
- selected.push(moduleFn(choices));
132
+ selected.push(moduleFn(choices, localePlan));
133
133
  }
134
134
  continue;
135
135
  }
136
136
  if (choices.features.includes(name)) {
137
- selected.push(moduleFn(choices));
137
+ selected.push(moduleFn(choices, localePlan));
138
138
  }
139
139
  }
140
140
  return selected;
@@ -182,9 +182,9 @@ export const ANCHOR_FILES = [];
182
182
  * 5. Run post-processing hooks
183
183
  * 6. Clean up unused anchors
184
184
  */
185
- export async function composeFeatures(targetDir, choices, featureModules, featuresDir) {
185
+ export async function composeFeatures(targetDir, choices, featureModules, featuresDir, localePlan) {
186
186
  // 1. Resolve
187
- const features = resolveSelectedFeatures(choices, featureModules);
187
+ const features = resolveSelectedFeatures(choices, featureModules, localePlan);
188
188
  const selectedNames = new Set(features.map((f) => f.name));
189
189
  // 2. Validate
190
190
  validateDependencies(features, selectedNames);
@@ -202,7 +202,7 @@ export async function composeFeatures(targetDir, choices, featureModules, featur
202
202
  // 5. Post-processing
203
203
  for (const feature of features) {
204
204
  if (feature.postProcess) {
205
- await feature.postProcess(targetDir, choices);
205
+ await feature.postProcess(targetDir, choices, localePlan);
206
206
  }
207
207
  }
208
208
  // 6. Clean up unused anchors
package/dist/constants.js CHANGED
@@ -187,7 +187,7 @@ export const FEATURES = [
187
187
  {
188
188
  value: "i18n",
189
189
  label: "i18n (multi-language)",
190
- hint: "Add a secondary language",
190
+ hint: "Configure additional locales",
191
191
  default: false,
192
192
  cliFlag: "i18n",
193
193
  },
@@ -5,7 +5,8 @@ import type { FeatureModule } from "../compose.js";
5
5
  * Fully plugin-owned (`@takazudo/zudo-doc/plugins/claude-resources`,
6
6
  * `zudoDocPreset()` wires it whenever `settings.claudeResources` is
7
7
  * truthy). Generation is package-owned. This feature's touch points are now
8
- * just: `claudeResources` + `defaultLocaleOnlyPrefixes` fields
9
- * (`zfb-config-gen.ts`).
8
+ * just the `claudeResources` field (`zfb-config-gen.ts`). Resource routes are
9
+ * localized by default; projects can opt selected paths out through the
10
+ * general-purpose `defaultLocaleOnlyPrefixes` setting.
10
11
  */
11
12
  export declare const claudeResourcesFeature: FeatureModule;
@@ -4,8 +4,9 @@
4
4
  * Fully plugin-owned (`@takazudo/zudo-doc/plugins/claude-resources`,
5
5
  * `zudoDocPreset()` wires it whenever `settings.claudeResources` is
6
6
  * truthy). Generation is package-owned. This feature's touch points are now
7
- * just: `claudeResources` + `defaultLocaleOnlyPrefixes` fields
8
- * (`zfb-config-gen.ts`).
7
+ * just the `claudeResources` field (`zfb-config-gen.ts`). Resource routes are
8
+ * localized by default; projects can opt selected paths out through the
9
+ * general-purpose `defaultLocaleOnlyPrefixes` setting.
9
10
  */
10
11
  export const claudeResourcesFeature = () => ({
11
12
  name: "claudeResources",
@@ -5,7 +5,8 @@ import type { FeatureModule } from "../compose.js";
5
5
  * Fully plugin-owned (`@takazudo/zudo-doc/plugins/codex-resources`,
6
6
  * `zudoDocPreset()` wires it whenever `settings.codexResources` is
7
7
  * truthy). Generation is package-owned. This feature's touch points are now
8
- * just: `codexResources` + `defaultLocaleOnlyPrefixes` fields
9
- * (`zfb-config-gen.ts`).
8
+ * just the `codexResources` field (`zfb-config-gen.ts`). Resource routes are
9
+ * localized by default; projects can opt selected paths out through the
10
+ * general-purpose `defaultLocaleOnlyPrefixes` setting.
10
11
  */
11
12
  export declare const codexResourcesFeature: FeatureModule;
@@ -4,8 +4,9 @@
4
4
  * Fully plugin-owned (`@takazudo/zudo-doc/plugins/codex-resources`,
5
5
  * `zudoDocPreset()` wires it whenever `settings.codexResources` is
6
6
  * truthy). Generation is package-owned. This feature's touch points are now
7
- * just: `codexResources` + `defaultLocaleOnlyPrefixes` fields
8
- * (`zfb-config-gen.ts`).
7
+ * just the `codexResources` field (`zfb-config-gen.ts`). Resource routes are
8
+ * localized by default; projects can opt selected paths out through the
9
+ * general-purpose `defaultLocaleOnlyPrefixes` setting.
9
10
  */
10
11
  export const codexResourcesFeature = () => ({
11
12
  name: "codexResources",
@@ -3,7 +3,7 @@ import type { FeatureModule } from "../compose.js";
3
3
  * i18n feature — gates the locale-prefixed page set.
4
4
  *
5
5
  * Locked manifest (#2653 Decision 4, i18n addendum): "i18n ON adds
6
- * `pages/[locale]/docs/[[...slug]].tsx` (a second doc stub, locale variant)
6
+ * `pages/[locale]/docs/[[...slug]].tsx` (the additional-locale doc route stub)
7
7
  * … No other pages." That ONE self-contained stub — retained as an explicit
8
8
  * host-owned route seam; zfb 2.13.1 also serves injected dynamic routes in dev —
9
9
  * is shipped under `templates/features/i18n/files/pages/[locale]/docs/`
@@ -15,7 +15,7 @@ import type { FeatureModule } from "../compose.js";
15
15
  *
16
16
  * No injections: the stub iterates `settings.locales` at build/request
17
17
  * time, so no postProcess regex patching is required for non-default
18
- * languages. Secondary-language content mirrors under
18
+ * locales. Additional-locale content mirrors under
19
19
  * `src/content/docs-<lang>/` are seeded by `scaffold.ts`.
20
20
  *
21
21
  * Loud-failure check: per spec-lock Decision 8 (#1737), abort scaffolding
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
5
5
  * i18n feature — gates the locale-prefixed page set.
6
6
  *
7
7
  * Locked manifest (#2653 Decision 4, i18n addendum): "i18n ON adds
8
- * `pages/[locale]/docs/[[...slug]].tsx` (a second doc stub, locale variant)
8
+ * `pages/[locale]/docs/[[...slug]].tsx` (the additional-locale doc route stub)
9
9
  * … No other pages." That ONE self-contained stub — retained as an explicit
10
10
  * host-owned route seam; zfb 2.13.1 also serves injected dynamic routes in dev —
11
11
  * is shipped under `templates/features/i18n/files/pages/[locale]/docs/`
@@ -17,7 +17,7 @@ import { fileURLToPath } from "url";
17
17
  *
18
18
  * No injections: the stub iterates `settings.locales` at build/request
19
19
  * time, so no postProcess regex patching is required for non-default
20
- * languages. Secondary-language content mirrors under
20
+ * locales. Additional-locale content mirrors under
21
21
  * `src/content/docs-<lang>/` are seeded by `scaffold.ts`.
22
22
  *
23
23
  * Loud-failure check: per spec-lock Decision 8 (#1737), abort scaffolding
@@ -1,6 +1,6 @@
1
1
  import fs from "fs-extra";
2
2
  import path from "path";
3
- import { getSecondaryLang } from "../utils.js";
3
+ import { resolveLocalePlan } from "../locale-plan.js";
4
4
  /**
5
5
  * Tag governance feature.
6
6
  *
@@ -8,18 +8,27 @@ import { getSecondaryLang } from "../utils.js";
8
8
  * feeds zfb while its default TagCliConfig export feeds both package-owned
9
9
  * bins through an explicit `--config` package-script argument.
10
10
  */
11
- export const tagGovernanceFeature = (choices) => ({
11
+ export const tagGovernanceFeature = (choices, localePlan) => ({
12
12
  name: "tagGovernance",
13
13
  injections: [],
14
14
  postProcess: async (targetDir) => {
15
15
  const vocabPath = path.join(targetDir, "src/config/tag-vocabulary.ts");
16
16
  if (!(await fs.pathExists(vocabPath))) {
17
- const contentDirs = choices.features.includes("i18n")
18
- ? `[
19
- "src/content/docs",
20
- "src/content/docs-${getSecondaryLang(choices.defaultLang)}",
21
- ]`
22
- : `["src/content/docs"]`;
17
+ // `scaffold()` resolves the locale plan once and threads it through
18
+ // feature composition. Keep the fallback for direct FeatureModule
19
+ // callers (e.g. package consumers/tests) while ensuring normalization
20
+ // and legacy inference still have one canonical implementation.
21
+ const plan = localePlan ??
22
+ resolveLocalePlan({
23
+ defaultLang: choices.defaultLang,
24
+ additionalLangs: choices.additionalLangs,
25
+ i18n: choices.features.includes("i18n"),
26
+ i18nExplicitlyDisabled: choices.explicitlyDisabledFeatures?.includes("i18n"),
27
+ });
28
+ const contentDirs = [
29
+ "src/content/docs",
30
+ ...plan.additionalLangs.map((locale) => `src/content/docs-${locale}`),
31
+ ];
23
32
  await fs.outputFile(vocabPath, `import type { TagCliConfig } from "@takazudo/zudo-doc/tags-audit";
24
33
  import type { TagVocabularyEntry } from "@takazudo/zudo-doc/settings";
25
34
 
@@ -31,7 +40,7 @@ export const tagVocabulary: TagVocabularyEntry[] = [];
31
40
  // Package scripts pass this module to the package-owned audit/suggest bins.
32
41
  // Paths are resolved from the project root.
33
42
  const tagCliConfig = {
34
- contentDirs: ${contentDirs},
43
+ contentDirs: ${JSON.stringify(contentDirs, null, 2)},
35
44
  vocabulary: tagVocabulary,
36
45
  governance: "warn",
37
46
  vocabularyActive: true,
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "path";
2
2
  import * as p from "@clack/prompts";
3
3
  import pc from "picocolors";
4
- import { parseArgs, printHelp, validateArgs } from "./cli.js";
4
+ import { layerAdditionalLangs, parseArgs, printHelp, validateArgs, } from "./cli.js";
5
5
  import { FEATURES } from "./constants.js";
6
6
  import { loadPreset } from "./preset.js";
7
7
  import { runPrompts } from "./prompts.js";
@@ -40,6 +40,7 @@ async function main() {
40
40
  prefilled.projectName = args.name;
41
41
  if (args.lang)
42
42
  prefilled.defaultLang = args.lang;
43
+ prefilled.additionalLangs = layerAdditionalLangs(prefilled.additionalLangs, args.additionalLangs);
43
44
  if (args.colorSchemeMode)
44
45
  prefilled.colorSchemeMode = args.colorSchemeMode;
45
46
  if (args.scheme) {
@@ -0,0 +1,20 @@
1
+ export interface LocalePlanInput {
2
+ defaultLang: string;
3
+ additionalLangs?: readonly string[];
4
+ i18n: boolean;
5
+ /** Whether the original input explicitly contained `--no-i18n`. */
6
+ i18nExplicitlyDisabled?: boolean;
7
+ }
8
+ export interface LocalePlan {
9
+ defaultLang: string;
10
+ additionalLangs: string[];
11
+ i18n: boolean;
12
+ /** A non-empty explicit list won over an explicit `--no-i18n`. */
13
+ overridesExplicitDisable: boolean;
14
+ }
15
+ /**
16
+ * Resolve every locale input path to one canonical, filesystem-safe plan.
17
+ * This function is pure and is the sole owner of locale normalization,
18
+ * validation, legacy inference, and explicit-list precedence.
19
+ */
20
+ export declare function resolveLocalePlan(input: LocalePlanInput): LocalePlan;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A locale code is also used as a directory and URL-path segment. Keep this
3
+ * grammar deliberately smaller than the full BCP 47 grammar: an ASCII
4
+ * language followed by optional ASCII alphanumeric subtags.
5
+ */
6
+ const LOCALE_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/;
7
+ function normalizeLocale(value, field) {
8
+ if (typeof value !== "string") {
9
+ throw new Error(`${field} must be a string`);
10
+ }
11
+ const normalized = value.trim().toLowerCase();
12
+ if (!LOCALE_RE.test(normalized)) {
13
+ throw new Error(`Invalid ${field} ${JSON.stringify(value)}. Locale codes must match /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/`);
14
+ }
15
+ return normalized;
16
+ }
17
+ /**
18
+ * Resolve every locale input path to one canonical, filesystem-safe plan.
19
+ * This function is pure and is the sole owner of locale normalization,
20
+ * validation, legacy inference, and explicit-list precedence.
21
+ */
22
+ export function resolveLocalePlan(input) {
23
+ const defaultLang = normalizeLocale(input.defaultLang, "defaultLang");
24
+ if (input.additionalLangs === undefined) {
25
+ const additionalLangs = input.i18n
26
+ ? [defaultLang === "en" ? "ja" : "en"]
27
+ : [];
28
+ return {
29
+ defaultLang,
30
+ additionalLangs,
31
+ i18n: input.i18n,
32
+ overridesExplicitDisable: false,
33
+ };
34
+ }
35
+ if (!Array.isArray(input.additionalLangs)) {
36
+ throw new Error("additionalLangs must be an array");
37
+ }
38
+ if (input.additionalLangs.length === 0) {
39
+ throw new Error("additionalLangs must contain at least one locale");
40
+ }
41
+ const additionalLangs = input.additionalLangs.map((locale, index) => normalizeLocale(locale, `additionalLangs[${index}]`));
42
+ const seen = new Set();
43
+ for (const locale of additionalLangs) {
44
+ if (locale === defaultLang) {
45
+ throw new Error(`additionalLangs must not include defaultLang ${JSON.stringify(defaultLang)}`);
46
+ }
47
+ if (seen.has(locale)) {
48
+ throw new Error(`Duplicate locale ${JSON.stringify(locale)} in additionalLangs`);
49
+ }
50
+ seen.add(locale);
51
+ }
52
+ return {
53
+ defaultLang,
54
+ additionalLangs,
55
+ i18n: true,
56
+ overridesExplicitDisable: input.i18nExplicitlyDisabled === true,
57
+ };
58
+ }