@junejs/server 0.0.22 → 0.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.22",
3
+ "version": "0.0.25",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.22",
34
- "@junejs/db": "0.0.22",
33
+ "@junejs/core": "0.0.25",
34
+ "@junejs/db": "0.0.25",
35
35
  "marked": "^18.0.5",
36
36
  "rolldown": "^1.1.0",
37
37
  "lightningcss": "^1.27.0"
package/src/app.ts CHANGED
@@ -20,6 +20,7 @@ import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
20
20
 
21
21
  import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
22
22
  import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
23
+ import { resolveBoundary } from "./segment";
23
24
  import { memoizeResources } from "./resources";
24
25
  import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
25
26
  import { findGlobalCss, processCssCached, STYLES_URL } from "./css";
@@ -39,15 +40,25 @@ export type JuneApp = {
39
40
  earlyHints(): string[];
40
41
  };
41
42
 
42
- // Import a layout file's default export as a layout component (memoized).
43
- const layoutCache = new Map<string, LayoutComponent>();
44
- async function loadLayout(file: string): Promise<LayoutComponent | null> {
43
+ // Import a layout file's default export as a layout component (memoized), plus
44
+ // its static `segmentBoundary` flag — read from the module export WITHOUT
45
+ // rendering the component, which is what lets the server slice the chain for a
46
+ // segment-scoped fragment without paying the shell's render cost.
47
+ type LoadedLayout = { component: LayoutComponent; boundary: boolean };
48
+ const layoutCache = new Map<string, LoadedLayout | null>();
49
+ async function loadLayout(file: string): Promise<LoadedLayout | null> {
45
50
  const cached = layoutCache.get(file);
46
- if (cached) return cached;
47
- const mod = (await import(pathToFileURL(file).href)) as { default?: LayoutComponent };
48
- if (typeof mod.default !== "function") return null;
49
- layoutCache.set(file, mod.default);
50
- return mod.default;
51
+ if (cached !== undefined) return cached;
52
+ const mod = (await import(pathToFileURL(file).href)) as {
53
+ default?: LayoutComponent;
54
+ segmentBoundary?: unknown;
55
+ };
56
+ const result: LoadedLayout | null =
57
+ typeof mod.default === "function"
58
+ ? { component: mod.default, boundary: mod.segmentBoundary === true }
59
+ : null;
60
+ layoutCache.set(file, result);
61
+ return result;
51
62
  }
52
63
 
53
64
  // The nearest loading.tsx up the segment chain (deepest wins) → the streaming
@@ -64,14 +75,20 @@ async function nearestLoading(segments: SegmentMatch[]): Promise<React.Component
64
75
  return mod.default;
65
76
  }
66
77
 
67
- async function loadChain(segments: SegmentMatch[]): Promise<LayoutComponent[]> {
68
- const chain: LayoutComponent[] = [];
78
+ // The layout chain (root→leaf) plus the segment boundary, via the shared
79
+ // resolver (the one place the deepest-wins rule + shell key live, so dev and the
80
+ // frozen manifest can't drift).
81
+ async function loadChain(
82
+ segments: SegmentMatch[],
83
+ ): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; boundaryKey: string | null }> {
84
+ const items = [];
69
85
  for (const seg of segments) {
70
86
  if (!seg.layout) continue;
71
87
  const L = await loadLayout(seg.layout);
72
- if (L) chain.push(L);
88
+ items.push({ file: seg.layout, entry: L?.component ?? null, boundary: !!L?.boundary });
73
89
  }
74
- return chain;
90
+ const { chain, boundaryIndex, key } = resolveBoundary(items);
91
+ return { chain, boundaryIndex, boundaryKey: key };
75
92
  }
76
93
 
77
94
  export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions): JuneApp {
@@ -149,6 +166,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
149
166
  extra,
150
167
  docConfig,
151
168
  agent,
169
+ i18n: config.i18n,
152
170
  routeList: routePaths,
153
171
  earlyHints: config.earlyHints,
154
172
  resources,
@@ -165,10 +183,13 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
165
183
  }
166
184
  const def = routeFromModule(mod);
167
185
  if (!def) return null;
186
+ const { chain, boundaryIndex, boundaryKey } = await loadChain(match.segments);
168
187
  return {
169
188
  def,
170
189
  params: match.params,
171
- chain: await loadChain(match.segments),
190
+ chain,
191
+ boundaryIndex,
192
+ boundaryKey,
172
193
  loading: await nearestLoading(match.segments),
173
194
  } satisfies Resolved;
174
195
  },
package/src/build.ts CHANGED
@@ -29,9 +29,10 @@ import { buildLinkHeader } from "@junejs/core/discovery";
29
29
  import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
30
30
  import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
31
31
  import type { DocumentConfig } from "@junejs/core/document";
32
- import { collection } from "./content";
32
+ import { generateContentModule } from "./content";
33
33
  import { createWorker, type WorkerManifest } from "./worker";
34
34
  import { findMiddlewareFile } from "./router";
35
+ import { resolveBoundary } from "./segment";
35
36
  import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
36
37
  import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
37
38
  import { cssTargets, findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
@@ -112,29 +113,11 @@ export async function scanRoutes(
112
113
  export async function generateContent(appRoot: string): Promise<string[]> {
113
114
  const contentDir = join(appRoot, "content");
114
115
  if (!existsSync(contentDir)) return [];
115
- const dirs = (await readdir(contentDir, { withFileTypes: true })).filter((e) => e.isDirectory());
116
- if (dirs.length === 0) return [];
117
-
118
- let out =
119
- "// AUTO-GENERATED by `june build` — edit content/**/*.md, not this file.\n" +
120
- "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string };\n";
121
- const names: string[] = [];
122
- for (const d of dirs) {
123
- const entries = collection(join(contentDir, d.name)).map(({ slug, data, body, original, html }) => ({
124
- slug,
125
- data,
126
- body,
127
- original,
128
- html,
129
- }));
130
- const constName = d.name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
131
- // Singular finder name: posts → post, otherwise <name>Entry.
132
- const finder = d.name.endsWith("s") ? d.name.slice(0, -1) : `${d.name}Entry`;
133
- out += `export const ${constName}: ContentEntry[] = ${JSON.stringify(entries, null, 2)};\n`;
134
- out += `export const ${finder} = (slug: string): ContentEntry | null => ${constName}.find((p) => p.slug === slug) ?? null;\n`;
135
- names.push(d.name);
136
- }
137
- await writeFile(join(appRoot, "app", "_content.ts"), out);
116
+ // Emission (incl. the per-locale layout) lives in ./content so it's pure and
117
+ // unit-testable; this stays the thin fs wrapper.
118
+ const { code, names } = generateContentModule(contentDir);
119
+ if (names.length === 0) return [];
120
+ await writeFile(join(appRoot, "app", "_content.ts"), code);
138
121
  return names;
139
122
  }
140
123
 
@@ -142,6 +125,7 @@ export async function generateContent(appRoot: string): Promise<string[]> {
142
125
  export async function freezeConfig(appRoot: string): Promise<{
143
126
  document: DocumentConfig;
144
127
  agent: WorkerManifest["agent"];
128
+ i18n: WorkerManifest["i18n"];
145
129
  earlyHints: string[];
146
130
  buildExternal: string[];
147
131
  }> {
@@ -163,14 +147,24 @@ export async function freezeConfig(appRoot: string): Promise<{
163
147
  styles: hasCss ? STYLES_URL : null,
164
148
  },
165
149
  agent: resolveAgent(cfg.agent),
150
+ // Pass i18n through as-is: the in-process buildManifest keeps a resolveLocale
151
+ // hook (parity test), and the codegen JSON.stringify below drops the function
152
+ // (worker hook support is the codegen pass — see the manifest field comment).
153
+ i18n: cfg.i18n,
166
154
  earlyHints: cfg.earlyHints ?? [],
167
155
  buildExternal: cfg.build?.external ?? [],
168
156
  };
169
157
  }
170
158
 
171
- async function importLayout(file: string): Promise<LayoutComponent | null> {
172
- const mod = (await import(pathToFileURL(file).href)) as { default?: LayoutComponent };
173
- return typeof mod.default === "function" ? mod.default : null;
159
+ type ImportedLayout = { component: LayoutComponent; boundary: boolean };
160
+ async function importLayout(file: string): Promise<ImportedLayout | null> {
161
+ const mod = (await import(pathToFileURL(file).href)) as {
162
+ default?: LayoutComponent;
163
+ segmentBoundary?: unknown;
164
+ };
165
+ return typeof mod.default === "function"
166
+ ? { component: mod.default, boundary: mod.segmentBoundary === true }
167
+ : null;
174
168
  }
175
169
 
176
170
  // The FREEZE, in-process: import route modules + layouts, build the manifest a
@@ -181,21 +175,32 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
181
175
  const frozen = await freezeConfig(appRoot);
182
176
  const scanned = (await scanRoutes(appDir)).sort((a, b) => a.path.localeCompare(b.path));
183
177
 
184
- const layoutCache = new Map<string, LayoutComponent | null>();
185
- const componentsFor = async (files: string[]): Promise<LayoutComponent[]> => {
186
- const chain: LayoutComponent[] = [];
178
+ const layoutCache = new Map<string, ImportedLayout | null>();
179
+ const loadCached = async (f: string): Promise<ImportedLayout | null> => {
180
+ if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
181
+ return layoutCache.get(f) ?? null;
182
+ };
183
+ // The chain (root→leaf) + boundary index + shell key, via the SHARED resolver
184
+ // (the one place the deepest-wins rule lives), so the frozen manifest and the
185
+ // dev resolver can't drift — the parity contract.
186
+ const componentsFor = async (
187
+ files: string[],
188
+ ): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; key: string | null }> => {
189
+ const items = [];
187
190
  for (const f of files) {
188
- if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
189
- const c = layoutCache.get(f) ?? null;
190
- if (c) chain.push(c);
191
+ const c = await loadCached(f);
192
+ items.push({ file: f, entry: c?.component ?? null, boundary: !!c?.boundary });
191
193
  }
192
- return chain;
194
+ return resolveBoundary(items);
193
195
  };
194
196
 
195
197
  const loadingCache = new Map<string, LoadingComponent | null>();
196
198
  const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
197
199
  if (!file) return undefined;
198
- if (!loadingCache.has(file)) loadingCache.set(file, await importLayout(file) as LoadingComponent | null);
200
+ if (!loadingCache.has(file)) {
201
+ const loaded = await importLayout(file);
202
+ loadingCache.set(file, loaded ? (loaded.component as LoadingComponent) : null);
203
+ }
199
204
  return loadingCache.get(file) ?? undefined;
200
205
  };
201
206
 
@@ -203,6 +208,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
203
208
  const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
204
209
  const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
205
210
  const layoutChains: Record<string, LayoutComponent[]> = {};
211
+ const layoutBoundaries: Record<string, { index: number; key: string }> = {};
206
212
  const loadings: Record<string, LoadingComponent> = {};
207
213
 
208
214
  for (const r of scanned) {
@@ -215,7 +221,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
215
221
  }
216
222
  const def = routeFromModule(mod);
217
223
  if (!def) continue;
218
- const chain = await componentsFor(r.layouts);
224
+ const { chain, boundaryIndex, key } = await componentsFor(r.layouts);
219
225
  const loading = await loadingFor(r.loading);
220
226
  if (r.dynamic) {
221
227
  dynamicRoutes.push({ pattern: r.path, def });
@@ -224,6 +230,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
224
230
  routes[r.path] = def;
225
231
  layoutChains[r.path] = chain;
226
232
  }
233
+ if (boundaryIndex !== null && key !== null) layoutBoundaries[r.path] = { index: boundaryIndex, key };
227
234
  if (loading) loadings[r.path] = loading;
228
235
  }
229
236
 
@@ -239,9 +246,11 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
239
246
  dynamicRoutes,
240
247
  resourceRoutes,
241
248
  layoutChains,
249
+ layoutBoundaries,
242
250
  loadings,
243
251
  document: frozen.document,
244
252
  agent: frozen.agent,
253
+ i18n: frozen.i18n,
245
254
  earlyHints: frozen.earlyHints,
246
255
  extra,
247
256
  };
@@ -268,6 +277,15 @@ export async function juneBuild(
268
277
  if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
269
278
 
270
279
  const frozen = await freezeConfig(appRoot);
280
+ // The locales table freezes into the worker as data; a resolveLocale hook is a
281
+ // function and won't survive JSON codegen. URL-pinned resolution + the built-in
282
+ // negotiation chain still work in the worker — only the hook is dev-only for now.
283
+ if (frozen.i18n?.resolveLocale) {
284
+ console.warn(
285
+ "[june build] i18n.resolveLocale is not yet wired into the built worker " +
286
+ "(URL-pinned + built-in negotiation work; the hook runs in dev only).",
287
+ );
288
+ }
271
289
  // The deploy adapter packages the portable build for its target (default:
272
290
  // built-in workers()). It contributes the entry's export wrapper + emits the
273
291
  // deploy config.
@@ -360,7 +378,17 @@ export async function juneBuild(
360
378
  }
361
379
  return id;
362
380
  };
381
+ // `segmentBoundary` is a STATIC export, so read each layout module here (no
382
+ // render) to know its boundary flag AND whether it loads at all — codegen then
383
+ // FILTERS null layouts and computes the boundary index/key through the SAME
384
+ // shared resolver the dev/manifest paths use, so all three agree by
385
+ // construction (not by a fragile "indices happen to line up" assumption).
386
+ const layoutInfo = new Map<string, ImportedLayout | null>();
387
+ for (const f of new Set(routes.flatMap((r) => (r.resource ? [] : r.layouts)))) {
388
+ layoutInfo.set(f, await importLayout(f));
389
+ }
363
390
  const chains: string[] = [];
391
+ const boundaries: string[] = [];
364
392
  const loadings: string[] = [];
365
393
  const resources: string[] = [];
366
394
  routes.forEach((r, i) => {
@@ -374,12 +402,28 @@ export async function juneBuild(
374
402
  imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
375
403
  if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
376
404
  else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
377
- chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
405
+ // entry = the emitted layout id (null layouts filtered out, so layoutId — and
406
+ // its import — is only emitted for real layouts), matching the runtime chain.
407
+ const { chain, boundaryIndex, key } = resolveBoundary(
408
+ r.layouts.map((f) => {
409
+ const info = layoutInfo.get(f) ?? null;
410
+ return { file: f, entry: info ? layoutId(f) : null, boundary: !!info?.boundary };
411
+ }),
412
+ );
413
+ chains.push(` ${JSON.stringify(r.path)}: [${chain.join(", ")}],`);
414
+ if (boundaryIndex !== null && key !== null) {
415
+ boundaries.push(` ${JSON.stringify(r.path)}: { index: ${boundaryIndex}, key: ${JSON.stringify(key)} },`);
416
+ }
378
417
  if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
379
418
  });
380
419
  const resourceRoutesField = resources.length
381
420
  ? `\n resourceRoutes: [\n${resources.join("\n")}\n ],`
382
421
  : "";
422
+ // Only emitted when some route declares a boundary, so boundary-less bundles
423
+ // stay byte-identical (additive manifest field, like resources).
424
+ const layoutBoundariesField = boundaries.length
425
+ ? `\n layoutBoundaries: {\n${boundaries.join("\n")}\n },`
426
+ : "";
383
427
 
384
428
  const builtExtraFile = findMiddlewareFile(appDir);
385
429
  if (builtExtraFile) {
@@ -444,12 +488,12 @@ ${dynamics.join("\n")}
444
488
  ],${resourceRoutesField}
445
489
  layoutChains: {
446
490
  ${chains.join("\n")}
447
- },
491
+ },${layoutBoundariesField}
448
492
  loadings: {
449
493
  ${loadings.join("\n")}
450
494
  },
451
495
  document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
452
- agent: ${JSON.stringify(frozen.agent)},
496
+ agent: ${JSON.stringify(frozen.agent)},${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
453
497
  earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
454
498
  });
455
499
 
package/src/content.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // The last line is the differentiator: other frameworks' "markdown output" is
10
10
  // a lossy HTML→md conversion; June's .md projection IS the authored source.
11
11
  // Agents read exactly what the author wrote (frontmatter included).
12
- import { readdirSync, readFileSync, statSync } from "node:fs";
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { marked } from "marked";
15
15
 
@@ -24,6 +24,9 @@ export type ContentEntry = {
24
24
  original: string;
25
25
  /** The body rendered to HTML (marked). */
26
26
  html: string;
27
+ /** The locale this entry was authored in — set for files under a `<locale>/`
28
+ * subdir, undefined for the flat (default-locale) files. */
29
+ locale?: string;
27
30
  };
28
31
 
29
32
  // Minimal frontmatter: `key: value` lines between --- fences; `[a, b]` lists
@@ -56,7 +59,7 @@ function parseFrontmatter(raw: string): { data: ContentEntry["data"]; body: stri
56
59
  // mtime-keyed memo: correct under dev edits, free in production.
57
60
  const memo = new Map<string, { mtime: number; entry: ContentEntry }>();
58
61
 
59
- function loadEntry(file: string, slug: string): ContentEntry {
62
+ function loadEntry(file: string, slug: string, locale?: string): ContentEntry {
60
63
  const mtime = statSync(file).mtimeMs;
61
64
  const hit = memo.get(file);
62
65
  if (hit && hit.mtime === mtime) return hit.entry;
@@ -69,34 +72,164 @@ function loadEntry(file: string, slug: string): ContentEntry {
69
72
  body,
70
73
  original,
71
74
  html: marked.parse(body, { async: false }) as string,
75
+ ...(locale ? { locale } : {}),
72
76
  };
73
77
  memo.set(file, { mtime, entry });
74
78
  return entry;
75
79
  }
76
80
 
77
- /** All entries in a content directory, newest-first by `date` (then slug). */
78
- export function collection(dir: string): ContentEntry[] {
79
- const entries = readdirSync(dir)
80
- .filter((f) => f.endsWith(".md") || f.endsWith(".mdx"))
81
- .map((f) => loadEntry(join(dir, f), f.replace(/\.(md|mdx)$/, "")));
82
- return entries.sort((a, b) => {
83
- const da = String(a.data.date ?? "");
84
- const db = String(b.data.date ?? "");
85
- return db.localeCompare(da) || a.slug.localeCompare(b.slug);
86
- });
81
+ const isMd = (f: string) => f.endsWith(".md") || f.endsWith(".mdx");
82
+ const slugOf = (f: string) => f.replace(/\.(md|mdx)$/, "");
83
+ const byDate = (a: ContentEntry, b: ContentEntry) =>
84
+ String(b.data.date ?? "").localeCompare(String(a.data.date ?? "")) ||
85
+ a.slug.localeCompare(b.slug);
86
+
87
+ // Per-locale content layout: flat `<dir>/*.md` are the DEFAULT-locale entries
88
+ // (today's behavior, zero migration); each `<dir>/<locale>/*.md` subdir holds
89
+ // that locale's variants. Isomorphic to the routing default (unprefixed) vs
90
+ // locale (prefixed) split. A single-locale collection has no subdirs → `byLocale`
91
+ // is empty and everything below collapses to the flat path.
92
+ export type ScannedCollection = {
93
+ default: ContentEntry[];
94
+ byLocale: Record<string, ContentEntry[]>;
95
+ };
96
+
97
+ // BCP-47-shaped tag (language + optional script/region/variant subtags). Used to
98
+ // decide whether a subdir is a locale bucket WITHOUT the i18n config: it accepts
99
+ // `de`, `fr`, `zh-TW`, `pt-BR` and rejects `images`, `drafts`, `assets` — so a
100
+ // stray folder can't become a phantom locale. Pass `knownLocales` for exact,
101
+ // config-driven validation (the stronger check once the locale set is threaded in).
102
+ const LOCALE_DIR = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
103
+ function isLocaleDir(name: string, known?: readonly string[]): boolean {
104
+ return known ? known.includes(name) : LOCALE_DIR.test(name);
87
105
  }
88
106
 
89
- /** One entry by slug, or null. */
90
- export function entry(dir: string, slug: string): ContentEntry | null {
91
- // Guard the slug — it comes from the URL.
92
- if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
107
+ export function scanCollection(dir: string, knownLocales?: readonly string[]): ScannedCollection {
108
+ const def: ContentEntry[] = [];
109
+ const byLocale: Record<string, ContentEntry[]> = {};
110
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
111
+ if (ent.isDirectory()) {
112
+ // Only a configured locale (or, without a list, a BCP-47-shaped name) is a
113
+ // locale bucket — a stray content/<col>/images/ is NOT a phantom "images".
114
+ if (!isLocaleDir(ent.name, knownLocales)) continue;
115
+ const locale = ent.name;
116
+ const sub = join(dir, locale);
117
+ byLocale[locale] = readdirSync(sub)
118
+ .filter(isMd)
119
+ .map((f) => loadEntry(join(sub, f), slugOf(f), locale))
120
+ .sort(byDate);
121
+ } else if (isMd(ent.name)) {
122
+ def.push(loadEntry(join(dir, ent.name), slugOf(ent.name)));
123
+ }
124
+ }
125
+ def.sort(byDate);
126
+ return { default: def, byLocale };
127
+ }
128
+
129
+ /** All entries in a content directory, newest-first by `date` (then slug). With
130
+ * a `locale`, each entry is its locale variant when present, else the default
131
+ * (so a reader sees the whole collection, localized where available). */
132
+ export function collection(dir: string, locale?: string): ContentEntry[] {
133
+ const { default: def, byLocale } = scanCollection(dir);
134
+ if (!locale || !byLocale[locale]) return def;
135
+ const variants = new Map(byLocale[locale].map((e) => [e.slug, e]));
136
+ return def.map((e) => variants.get(e.slug) ?? e).sort(byDate);
137
+ }
138
+
139
+ // Probe `<dir>/<slug>.{md,mdx}`, tagging the result with `locale`. null if absent.
140
+ function probe(dir: string, slug: string, locale?: string): ContentEntry | null {
93
141
  for (const ext of [".md", ".mdx"]) {
94
- const file = join(dir, slug + ext);
95
142
  try {
96
- return loadEntry(file, slug);
143
+ return loadEntry(join(dir, slug + ext), slug, locale);
97
144
  } catch {
98
145
  /* try next ext */
99
146
  }
100
147
  }
101
148
  return null;
102
149
  }
150
+
151
+ /** One entry by slug, or null. With a `locale`, prefer `<dir>/<locale>/<slug>`,
152
+ * falling back to the flat default (a dev-warn flags a partial translation).
153
+ * `fallback: false` is STRICT — a missing variant returns null (so a route can
154
+ * 404 rather than serve default-language content). */
155
+ export function entry(
156
+ dir: string,
157
+ slug: string,
158
+ locale?: string,
159
+ opts?: { fallback?: boolean },
160
+ ): ContentEntry | null {
161
+ // Guard the slug — it comes from the URL.
162
+ if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
163
+ if (!locale) return probe(dir, slug);
164
+ const variant = probe(join(dir, locale), slug, locale);
165
+ if (variant) return variant;
166
+ if (opts?.fallback === false) return null; // strict — no default-language bleed
167
+ const fallback = probe(dir, slug);
168
+ // The locale is otherwise translated here, but not this slug → a gap worth a
169
+ // dev signal (silent in production builds, where NODE_ENV is baked).
170
+ if (fallback && existsSync(join(dir, locale))) {
171
+ console.warn(`[june content] ${slug}: no "${locale}" variant — served default`);
172
+ }
173
+ return fallback;
174
+ }
175
+
176
+ // Generate the frozen `app/_content.ts` module text from `content/<collection>/`.
177
+ // The FREEZE that removes node:fs from the worker graph: routes import frozen
178
+ // entries + finders instead of reading the filesystem at request time.
179
+ //
180
+ // Off by absence: a collection with no `<locale>/` subdirs emits EXACTLY today's
181
+ // shape (`POSTS` array + `post(slug)` finder) — single-locale apps are unchanged.
182
+ // A collection WITH locale subdirs additionally emits a slug→locale→entry map, a
183
+ // locale-aware `post(slug, locale?)` finder (variant → flat default, dev-warn on a
184
+ // partial-translation miss, tree-shaken in production), and a locale-merged
185
+ // `posts(locale?)` lister.
186
+ export function generateContentModule(
187
+ contentDir: string,
188
+ knownLocales?: readonly string[],
189
+ ): { code: string; names: string[] } {
190
+ const strip = (e: ContentEntry) => ({
191
+ slug: e.slug,
192
+ data: e.data,
193
+ body: e.body,
194
+ original: e.original,
195
+ html: e.html,
196
+ ...(e.locale ? { locale: e.locale } : {}),
197
+ });
198
+ const dirs = readdirSync(contentDir, { withFileTypes: true }).filter((e) => e.isDirectory());
199
+ const names: string[] = [];
200
+ let anyLocale = false;
201
+ let body = "";
202
+ for (const d of dirs) {
203
+ const { default: def, byLocale } = scanCollection(join(contentDir, d.name), knownLocales);
204
+ const CONST = d.name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
205
+ // Singular finder name: posts → post, otherwise <name>Entry.
206
+ const finder = d.name.endsWith("s") ? d.name.slice(0, -1) : `${d.name}Entry`;
207
+ body += `export const ${CONST}: ContentEntry[] = ${JSON.stringify(def.map(strip), null, 2)};\n`;
208
+ if (Object.keys(byLocale).length === 0) {
209
+ body += `export const ${finder} = (slug: string): ContentEntry | null => ${CONST}.find((p) => p.slug === slug) ?? null;\n`;
210
+ } else {
211
+ anyLocale = true;
212
+ const map: Record<string, Record<string, unknown>> = {};
213
+ for (const [loc, entries] of Object.entries(byLocale)) {
214
+ for (const e of entries) (map[loc] ??= {})[e.slug] = strip(e);
215
+ }
216
+ body += `const ${CONST}_L: Record<string, Record<string, ContentEntry>> = ${JSON.stringify(map, null, 2)};\n`;
217
+ body +=
218
+ `export const ${finder} = (slug: string, locale?: string, opts?: { fallback?: boolean }): ContentEntry | null => {\n` +
219
+ ` const v = locale ? ${CONST}_L[locale]?.[slug] : undefined;\n` +
220
+ ` if (v) return v;\n` +
221
+ ` if (opts?.fallback === false) return null;\n` +
222
+ ` const d = ${CONST}.find((p) => p.slug === slug) ?? null;\n` +
223
+ ` if (process.env.NODE_ENV !== "production" && locale && ${CONST}_L[locale] && d) console.warn(\`[june content] ${d.name}/\${slug}: no "\${locale}" variant — served default\`);\n` +
224
+ ` return d;\n};\n`;
225
+ body += `export const ${d.name} = (locale?: string): ContentEntry[] => locale && ${CONST}_L[locale] ? ${CONST}.map((p) => ${CONST}_L[locale]![p.slug] ?? p) : ${CONST};\n`;
226
+ }
227
+ names.push(d.name);
228
+ }
229
+ const entryType = anyLocale
230
+ ? "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; locale?: string };\n"
231
+ : "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string };\n";
232
+ const code =
233
+ "// AUTO-GENERATED by `june build` — edit content/**/*.md, not this file.\n" + entryType + body;
234
+ return { code, names };
235
+ }
package/src/dev-reload.ts CHANGED
@@ -18,6 +18,7 @@ const SCRIPT_PATH = "/__june/reload.js";
18
18
  const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "css" event → hot-swap
19
19
  (() => {
20
20
  let dropped = false;
21
+ let es;
21
22
  const swapCss = () => {
22
23
  document.querySelectorAll('link[rel="stylesheet"]').forEach((old) => {
23
24
  const u = new URL(old.href);
@@ -33,7 +34,7 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
33
34
  });
34
35
  };
35
36
  const connect = () => {
36
- const es = new EventSource(${JSON.stringify(EVENTS_PATH)});
37
+ es = new EventSource(${JSON.stringify(EVENTS_PATH)});
37
38
  es.addEventListener("open", () => {
38
39
  if (dropped) {
39
40
  // push-HMR: morph the freshly-restarted page in place (state survives);
@@ -58,6 +59,11 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
58
59
  }
59
60
  });
60
61
  };
62
+ // Release the SSE on navigation. In MPA mode every click is a full reload that
63
+ // opens a fresh EventSource; without this the old connection lingers and, since
64
+ // each held SSE consumes one of the browser's ~6 HTTP/1.1 slots per host, rapid
65
+ // navigation saturates them — further page loads stall (pending) / 503.
66
+ addEventListener("pagehide", () => { try { es && es.close(); } catch (e) {} });
61
67
  connect();
62
68
  })();
63
69
  `;
package/src/index.ts CHANGED
@@ -50,6 +50,21 @@ export {
50
50
  } from "./router";
51
51
  export { installAsyncContext } from "./instrumentation";
52
52
  export { negotiate, type Negotiated } from "./negotiate";
53
+ // Locale routing (Layer 1). The pure host-resolution layer lives in @junejs/core;
54
+ // re-exported here so apps build locale links with `localeHref` off the ONE handle.
55
+ export {
56
+ localeHref,
57
+ resolveRequestLocale,
58
+ matchPinnedLocale,
59
+ negotiateLocale,
60
+ parseAcceptLanguage,
61
+ localeDir,
62
+ dirForLang,
63
+ LOCALE_COOKIE,
64
+ type I18nConfig,
65
+ type LocaleConfig,
66
+ type LocaleMatch,
67
+ } from "@junejs/core/i18n";
53
68
  export {
54
69
  createPipeline,
55
70
  type Pipeline,
package/src/negotiate.ts CHANGED
@@ -11,7 +11,7 @@ import type { RenderTarget } from "@junejs/core/route";
11
11
  // The fragment media type + title header are the client-router wire protocol —
12
12
  // defined once in @junejs/core so the browser router and this negotiator share
13
13
  // the exact strings. Re-exported so existing server-side importers are unaffected.
14
- export { FRAGMENT_ACCEPT, TITLE_HEADER } from "@junejs/core/nav-protocol";
14
+ export { FRAGMENT_ACCEPT, TITLE_HEADER, SEGMENT_HEADER } from "@junejs/core/nav-protocol";
15
15
 
16
16
  const EXT_TARGET: Record<string, RenderTarget> = {
17
17
  ".json": "json",
@@ -31,8 +31,13 @@ export type Negotiated = {
31
31
  speculative: boolean;
32
32
  };
33
33
 
34
- export function negotiate(url: URL, request: Request): Negotiated {
35
- let pathname = url.pathname;
34
+ // `basePath` overrides the pathname to negotiate (defaults to url.pathname). The
35
+ // i18n step strips the locale prefix off the FRONT of url.pathname first and
36
+ // passes the remainder here, so extension/Accept negotiation runs on the route
37
+ // path, not the locale-prefixed one. url.pathname stays the raw request path.
38
+ export function negotiate(url: URL, request: Request, basePath?: string): Negotiated {
39
+ const original = basePath ?? url.pathname;
40
+ let pathname = original;
36
41
  let target: RenderTarget | null = null;
37
42
 
38
43
  for (const [ext, t] of Object.entries(EXT_TARGET)) {
@@ -53,7 +58,7 @@ export function negotiate(url: URL, request: Request): Negotiated {
53
58
  // A bare projection on the root — `/.md`, `/.json` — is NOT a real URL (the
54
59
  // home surface is `/index.md`). Stripping the extension collapses it to "/",
55
60
  // so steer it back to the literal path: no route matches → 404.
56
- pathname = url.pathname;
61
+ pathname = original;
57
62
  }
58
63
 
59
64
  if (!target) {
package/src/pipeline.ts CHANGED
@@ -37,9 +37,31 @@ import { mcpHandler, mcpTools } from "@junejs/core/mcp";
37
37
 
38
38
  import { ensureScope, runInScope } from "@junejs/db";
39
39
  import type { AgentConfig } from "@junejs/core/config";
40
+ import {
41
+ LOCALE_COOKIE,
42
+ localeAlternates,
43
+ localeDir,
44
+ matchPinnedLocale,
45
+ negotiateLocale,
46
+ type I18nConfig,
47
+ type LocaleAlternate,
48
+ } from "@junejs/core/i18n";
40
49
  import type { Resources } from "@junejs/core/resources";
41
50
 
42
- import { negotiate, TITLE_HEADER } from "./negotiate";
51
+ import { negotiate, TITLE_HEADER, SEGMENT_HEADER } from "./negotiate";
52
+
53
+ // Minimal Cookie-header read for the locale negotiation chain (no dependency on
54
+ // an auth/cookie integration — i18n must stand alone).
55
+ function readCookie(request: Request, name: string): string | undefined {
56
+ const header = request.headers.get("cookie");
57
+ if (!header) return undefined;
58
+ for (const part of header.split(";")) {
59
+ const eq = part.indexOf("=");
60
+ if (eq === -1) continue;
61
+ if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
62
+ }
63
+ return undefined;
64
+ }
43
65
 
44
66
  export type LayoutComponent = React.ComponentType<{ children: React.ReactNode }>;
45
67
  export type LoadingComponent = React.ComponentType;
@@ -54,6 +76,17 @@ export type Resolved = {
54
76
  // into streaming Suspense: the shell + this fallback flush before load()
55
77
  // resolves, then the view streams in.
56
78
  loading?: React.ComponentType;
79
+ // Segment-scoped fragments: the index in `chain` of the boundary layout (the
80
+ // one exporting `segmentBoundary` and rendering <JuneOutlet>), deepest wins.
81
+ // A soft-nav fragment then renders only chain.slice(boundaryIndex + 1) — the
82
+ // boundary layout's own markup (the persistent shell) is NOT re-rendered.
83
+ // null/undefined → whole-chain fragment (the default). Full documents always
84
+ // render the whole chain regardless, so a hard load is unaffected.
85
+ boundaryIndex?: number | null;
86
+ // The shell's identity key (which boundary layout owns it). Stamped on
87
+ // [data-june-root] (full load) and sent as the SEGMENT_HEADER (fragment) so the
88
+ // client morphs a fragment only into the shell it belongs to. Set iff boundaryIndex is.
89
+ boundaryKey?: string | null;
57
90
  };
58
91
 
59
92
  // A RESOURCE route (app/**/route.*): a handler returning a raw Response — binary,
@@ -73,6 +106,9 @@ export type RouteResolver = (pathname: string) => Promise<Resolved | ResolvedRes
73
106
  export type PipelineConfig = {
74
107
  docConfig: DocumentConfig;
75
108
  agent: AgentConfig;
109
+ // Locale routing config (june.config.ts `i18n`). Absent → no locale handling:
110
+ // the resolution step below never runs and ctx.locale stays undefined.
111
+ i18n?: I18nConfig;
76
112
  // The route list for discovery surfaces (sitemap / llms.txt). Async so dev can
77
113
  // re-scan the filesystem; the worker returns a frozen array.
78
114
  routeList: () => Promise<string[]> | string[];
@@ -171,11 +207,37 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
171
207
  return headers;
172
208
  }
173
209
 
210
+ // The document language + writing direction for one render. lang = the resolved
211
+ // locale (ctx.locale) when i18n is on, else the site.lang floor, else "en"; dir
212
+ // derives from it (a LocaleConfig.dir override wins). LTR → dir undefined, so
213
+ // single-locale pages stay byte-identical.
214
+ function langDir(locale: string | undefined): { lang: string; dir?: "ltr" | "rtl" } {
215
+ const lang = locale ?? docConfig.site.lang ?? "en";
216
+ return { lang, dir: localeDir(lang, cfg.i18n) };
217
+ }
218
+
219
+ // rel="alternate" hreflang links for this page's locale variants. Built from the
220
+ // locale-STRIPPED route path (so every locale's URL is generated correctly) and
221
+ // the current host (cross-origin locales come back absolute). undefined when
222
+ // i18n is off → single-locale pages emit no hreflang.
223
+ function alternatesFor(ctx: RouteContext): LocaleAlternate[] | undefined {
224
+ if (!cfg.i18n) return undefined;
225
+ const pinned = matchPinnedLocale(cfg.i18n, ctx.url.host, ctx.url.pathname);
226
+ const routePath = pinned ? pinned.pathname : ctx.url.pathname;
227
+ return localeAlternates(cfg.i18n, routePath, {
228
+ currentHost: ctx.url.host,
229
+ protocol: ctx.url.protocol.replace(":", ""),
230
+ });
231
+ }
232
+
174
233
  async function renderDocument(
175
234
  node: React.ReactNode,
176
235
  metadata: Metadata | undefined,
177
236
  status: number,
178
237
  chain: LayoutComponent[],
238
+ locale?: string,
239
+ boundaryKey?: string | null,
240
+ alternates?: LocaleAlternate[],
179
241
  ): Promise<Response> {
180
242
  // Wrap root→leaf: chain[0] is outermost.
181
243
  const wrapped = chain.reduceRight<React.ReactNode>(
@@ -183,7 +245,14 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
183
245
  node,
184
246
  );
185
247
  const stream = await renderToReadableStream(
186
- React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
248
+ React.createElement(Document, {
249
+ config: docConfigForRender(),
250
+ metadata,
251
+ children: wrapped,
252
+ shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
253
+ alternates,
254
+ ...langDir(locale),
255
+ }),
187
256
  );
188
257
  await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
189
258
  // React 19 emits <!DOCTYPE html> itself for an <html> root — don't prepend a
@@ -201,8 +270,20 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
201
270
  node: React.ReactNode,
202
271
  metadata: Metadata | undefined,
203
272
  chain: LayoutComponent[],
273
+ boundaryIndex?: number | null,
274
+ boundaryKey?: string | null,
204
275
  ): Promise<Response> {
205
- const wrapped = chain.reduceRight<React.ReactNode>(
276
+ // ONE gate: a fragment is segment-scoped iff it has both a boundary index AND
277
+ // a shell key. They always travel together (resolveBoundary returns both or
278
+ // neither), but tying the content-slice and the header to a single flag means
279
+ // a content-only body can never go out without its key (which would wipe the
280
+ // shell) nor a key without a content-only body.
281
+ const segmented = typeof boundaryIndex === "number" && boundaryKey != null;
282
+ // Segment-scoped renders ONLY the chain below the boundary layout (its
283
+ // children = the <JuneOutlet> contents on a full load), so the boundary
284
+ // layout's shell markup is never produced. Whole-chain wraps the entire chain.
285
+ const inside = segmented ? chain.slice(boundaryIndex! + 1) : chain;
286
+ const wrapped = inside.reduceRight<React.ReactNode>(
206
287
  (acc, L) => React.createElement(L, null, acc),
207
288
  node,
208
289
  );
@@ -212,6 +293,9 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
212
293
  const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
213
294
  const title = typeof metadata?.title === "string" ? metadata.title : undefined;
214
295
  if (title) headers.set(TITLE_HEADER, title);
296
+ // The shell key tells the client which shell this content-only fragment is
297
+ // for; it morphs the outlet only when that matches the mounted shell.
298
+ if (segmented) headers.set(SEGMENT_HEADER, boundaryKey!);
215
299
  return new Response(html, { status: 200, headers });
216
300
  }
217
301
 
@@ -233,7 +317,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
233
317
  loadPromise: Promise<unknown>,
234
318
  ctx: RouteContext,
235
319
  ): Promise<Response> {
236
- const { def, chain, loading: Loading } = resolved;
320
+ const { def, chain, loading: Loading, boundaryKey } = resolved;
237
321
  const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
238
322
  const boundary = React.createElement(
239
323
  Suspense,
@@ -246,7 +330,14 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
246
330
  );
247
331
  const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
248
332
  const stream = await renderToReadableStream(
249
- React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
333
+ React.createElement(Document, {
334
+ config: docConfigForRender(),
335
+ metadata,
336
+ children: wrapped,
337
+ shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
338
+ alternates: alternatesFor(ctx),
339
+ ...langDir(ctx.locale),
340
+ }),
250
341
  { onError: (e: unknown) => console.error("[june] streaming render error:", e) },
251
342
  );
252
343
  // NO allReady — return the live stream (shell first). React 19 streams the
@@ -254,7 +345,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
254
345
  return new Response(stream, { status: 200, headers: htmlHeaders() });
255
346
  }
256
347
 
257
- function notFoundResponse(target: RenderTarget, pathname: string): Promise<Response> | Response {
348
+ function notFoundResponse(
349
+ target: RenderTarget,
350
+ pathname: string,
351
+ locale?: string,
352
+ ): Promise<Response> | Response {
258
353
  // Data clients get a JSON 404; humans get the rendered NotFound document.
259
354
  return target !== "view"
260
355
  ? Response.json({ error: "Not Found", path: pathname }, { status: 404 })
@@ -263,6 +358,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
263
358
  { title: "Not found", robots: "noindex" },
264
359
  404,
265
360
  [],
361
+ locale,
266
362
  );
267
363
  }
268
364
 
@@ -293,15 +389,15 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
293
389
  data: unknown,
294
390
  ctx: RouteContext,
295
391
  ): Promise<Response> {
296
- const { def, chain } = resolved;
392
+ const { def, chain, boundaryIndex, boundaryKey } = resolved;
297
393
  // A projection declared `false` is disabled → 404 (and absent from discovery).
298
394
  // "fragment" isn't a declarable projection (it's the view rendered without the
299
395
  // shell), so it's never disabled — exclude it from the check.
300
396
  if (target !== "fragment" && def[target] === false) {
301
- return notFoundResponse(target, ctx.url.pathname);
397
+ return notFoundResponse(target, ctx.url.pathname, ctx.locale);
302
398
  }
303
399
 
304
- const res = await renderTarget(target, def, data, ctx, chain);
400
+ const res = await renderTarget(target, def, data, ctx, chain, boundaryIndex, boundaryKey);
305
401
  // Every projection here is chosen by the Accept header at the SAME url (a full
306
402
  // page, its fragment, its .md and .json all live at the clean path). Without
307
403
  // Vary, a shared/browser cache can hand a soft-nav fragment to a real page
@@ -317,6 +413,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
317
413
  data: unknown,
318
414
  ctx: RouteContext,
319
415
  chain: LayoutComponent[],
416
+ boundaryIndex?: number | null,
417
+ boundaryKey?: string | null,
320
418
  ): Promise<Response> {
321
419
  if (target === "md") return renderMarkdown(def, data, ctx);
322
420
  if (target === "json") {
@@ -326,8 +424,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
326
424
  }
327
425
  const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
328
426
  const meta = resolveMeta(def, data, ctx);
329
- if (target === "fragment") return renderFragment(node, meta, chain);
330
- return renderDocument(node, meta, 200, chain);
427
+ // Only the fragment projection narrows to a segment; the full document always
428
+ // renders the whole chain (a hard load of the URL is never segment-scoped) —
429
+ // but it stamps the shell key so the client knows which shell is mounted.
430
+ if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
431
+ return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx));
331
432
  }
332
433
 
333
434
  async function discovery(url: URL): Promise<Response | null> {
@@ -340,7 +441,10 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
340
441
  case "/robots.txt":
341
442
  return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
342
443
  case "/sitemap.xml":
343
- return text(sitemapXml(url.origin, await cfg.routeList()), "application/xml; charset=utf-8");
444
+ return text(
445
+ sitemapXml(url.origin, await cfg.routeList(), cfg.i18n),
446
+ "application/xml; charset=utf-8",
447
+ );
344
448
  case "/.well-known/api-catalog":
345
449
  return text(JSON.stringify(apiCatalog(url.origin, agent)), "application/linkset+json");
346
450
  case "/.well-known/mcp/server-card.json":
@@ -383,10 +487,35 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
383
487
  return letterFavicon(docConfig.site.name);
384
488
  }
385
489
 
490
+ // --- locale resolution (before routing) ------------------------------
491
+ // host/path → locale, stripping the locale prefix off the front so the
492
+ // router matches the bare route path. Only runs when `i18n` is configured.
493
+ let locale: string | undefined;
494
+ let routeBase = url.pathname;
495
+ if (cfg.i18n) {
496
+ const pinned = matchPinnedLocale(cfg.i18n, url.host, url.pathname);
497
+ if (pinned) {
498
+ locale = pinned.locale;
499
+ routeBase = pinned.pathname;
500
+ } else {
501
+ // Ambiguous (bare path on the default origin): the resolveLocale hook
502
+ // gets first say, then the built-in chain. The hook runs ONLY here —
503
+ // never when the URL already pinned the locale.
504
+ const override = cfg.i18n.resolveLocale?.({ url, headers: request.headers });
505
+ locale =
506
+ override && cfg.i18n.locales[override]
507
+ ? override
508
+ : negotiateLocale(cfg.i18n, {
509
+ acceptLanguage: request.headers.get("accept-language"),
510
+ cookie: readCookie(request, LOCALE_COOKIE),
511
+ });
512
+ }
513
+ }
514
+
386
515
  // --- routes ----------------------------------------------------------
387
- const { target, pathname, speculative } = negotiate(url, request);
516
+ const { target, pathname, speculative } = negotiate(url, request, routeBase);
388
517
  const resolved = await cfg.resolve(pathname);
389
- if (!resolved) return notFoundResponse(target, pathname);
518
+ if (!resolved) return notFoundResponse(target, pathname, locale);
390
519
 
391
520
  // ctx is identity/request only; db/kv/blob are ambient (read from the
392
521
  // request scope this whole handler runs inside — see runInScope below).
@@ -395,6 +524,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
395
524
  url,
396
525
  params: resolved.params,
397
526
  target,
527
+ locale,
398
528
  speculative,
399
529
  };
400
530
 
@@ -416,7 +546,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
416
546
  data = resolved.def.load ? await resolved.def.load(ctx) : undefined;
417
547
  } catch {
418
548
  // unknown slug etc. → 404 (segment error boundaries are a later milestone)
419
- return notFoundResponse(target, pathname);
549
+ return notFoundResponse(target, pathname, locale);
420
550
  }
421
551
  return renderProjection(resolved, target, data, ctx);
422
552
  }
package/src/segment.ts ADDED
@@ -0,0 +1,60 @@
1
+ // Segment-boundary resolution — the ONE place the "deepest segmentBoundary wins"
2
+ // rule lives, shared by the dev resolver (app.ts), the build manifest, and the
3
+ // codegen (build.ts). Single-sourcing it keeps dev / frozen-manifest / deployed
4
+ // worker from drifting on the exact invariant the feature depends on.
5
+ //
6
+ // HOST-ONLY (uses node:crypto): runs in the dev server and at build time, never
7
+ // in the worker request path — the worker reads the frozen key from the manifest.
8
+ import { createHash } from "node:crypto";
9
+
10
+ export const boundaryWarning = (file: string): string =>
11
+ `[june] multiple segmentBoundary layouts in one chain (${file}); using the deepest.`;
12
+
13
+ // A stable key identifying a boundary layout (its persistent shell). Same file →
14
+ // same key (a shell shared across routes); different file → different key (a
15
+ // distinct shell), so the client can tell whether a soft-nav fragment belongs to
16
+ // the shell currently mounted and hard-navigate when it doesn't. Consistency is
17
+ // only needed WITHIN one running instance (dev server OR deployed worker), since
18
+ // the key is compared between an outlet's host marker and a response header both
19
+ // produced by that same instance — dev and prod need not agree.
20
+ //
21
+ // Memoized: the dev resolver runs per request, and the key is a pure function of
22
+ // the path. 16 hex chars (64 bits) makes a same-instance collision between two
23
+ // boundary layouts — which would morph one shell's content into another — beyond
24
+ // reach without inflating the wire marker.
25
+ const keyCache = new Map<string, string>();
26
+ export function boundaryKey(file: string): string {
27
+ let key = keyCache.get(file);
28
+ if (key === undefined) {
29
+ key = createHash("sha256").update(file).digest("hex").slice(0, 16);
30
+ keyCache.set(file, key);
31
+ }
32
+ return key;
33
+ }
34
+
35
+ // Build the layout chain (root→leaf, null entries filtered) and locate the
36
+ // boundary: the DEEPEST entry flagged `boundary`. The index is into the FILTERED
37
+ // chain — every consumer filters identically, so the index always matches the
38
+ // chain the worker actually renders. Generic over the entry type so codegen can
39
+ // pass layout-id strings where dev/build pass component functions.
40
+ export function resolveBoundary<C>(
41
+ items: Array<{ file: string; entry: C | null; boundary: boolean }>,
42
+ ): { chain: C[]; boundaryIndex: number | null; key: string | null } {
43
+ const chain: C[] = [];
44
+ let boundaryIndex: number | null = null;
45
+ let boundaryFile: string | null = null;
46
+ for (const it of items) {
47
+ if (it.entry == null) continue; // dropped layout — filtered consistently everywhere
48
+ if (it.boundary) {
49
+ if (boundaryIndex !== null) console.warn(boundaryWarning(it.file));
50
+ boundaryIndex = chain.length;
51
+ boundaryFile = it.file;
52
+ }
53
+ chain.push(it.entry);
54
+ }
55
+ return {
56
+ chain,
57
+ boundaryIndex,
58
+ key: boundaryFile === null ? null : boundaryKey(boundaryFile),
59
+ };
60
+ }
package/src/worker.ts CHANGED
@@ -14,6 +14,7 @@ import type React from "react";
14
14
  import type { BrandedRoute } from "@junejs/core/route";
15
15
  import type { AgentConfig } from "@junejs/core/config";
16
16
  import type { DocumentConfig } from "@junejs/core/document";
17
+ import type { I18nConfig } from "@junejs/core/i18n";
17
18
  import type { Resources } from "@junejs/core/resources";
18
19
 
19
20
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
@@ -29,10 +30,19 @@ export type WorkerManifest = {
29
30
  // Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
30
31
  // freezes the same chain the dev server loads from app/layout.* files.
31
32
  layoutChains?: Record<string, LayoutComponent[]>;
33
+ // Segment boundary per route: the chain index of the deepest layout exporting
34
+ // `segmentBoundary` (renders <JuneOutlet>) plus the shell's identity `key`.
35
+ // Routes without one are absent → whole-chain fragment (the default). Frozen
36
+ // statically by the build.
37
+ layoutBoundaries?: Record<string, { index: number; key: string }>;
32
38
  // Nearest loading.tsx per route path → streaming Suspense fallback.
33
39
  loadings?: Record<string, LoadingComponent>;
34
40
  document: DocumentConfig;
35
41
  agent: AgentConfig;
42
+ // Locale routing config, frozen from june.config.ts `i18n`. The locales table
43
+ // is plain data; a `resolveLocale` hook survives the in-process freeze (parity
44
+ // test) but not JSON codegen — worker hook support lands with the codegen pass.
45
+ i18n?: I18nConfig;
36
46
  // Preload Link values (config earlyHints + auto font hints), frozen at build.
37
47
  earlyHints?: string[];
38
48
  htmlCacheControl?: string;
@@ -108,6 +118,8 @@ export function createWorker(
108
118
 
109
119
  const chainFor = (key: string): LayoutComponent[] => manifest.layoutChains?.[key] ?? [];
110
120
  const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
121
+ const boundaryFor = (key: string): { index: number; key: string } | null =>
122
+ manifest.layoutBoundaries?.[key] ?? null;
111
123
 
112
124
  // The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
113
125
  // requests in an isolate; we capture the latest and hand it to the env-aware
@@ -118,6 +130,7 @@ export function createWorker(
118
130
  const pipeline = createPipeline({
119
131
  docConfig: manifest.document,
120
132
  agent: manifest.agent,
133
+ i18n: manifest.i18n,
121
134
  routeList: () => routeList,
122
135
  earlyHints: manifest.earlyHints,
123
136
  htmlCacheControl: manifest.htmlCacheControl,
@@ -126,8 +139,17 @@ export function createWorker(
126
139
  resources: provider ? () => provider(currentEnv) : undefined,
127
140
  resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
128
141
  const staticDef = manifest.routes[pathname];
129
- if (staticDef)
130
- return { def: staticDef, params: {}, chain: chainFor(pathname), loading: loadingFor(pathname) };
142
+ if (staticDef) {
143
+ const b = boundaryFor(pathname);
144
+ return {
145
+ def: staticDef,
146
+ params: {},
147
+ chain: chainFor(pathname),
148
+ boundaryIndex: b?.index ?? null,
149
+ boundaryKey: b?.key ?? null,
150
+ loading: loadingFor(pathname),
151
+ };
152
+ }
131
153
  for (const d of dynamic) {
132
154
  const m = pathname.match(d.regex);
133
155
  if (m) {
@@ -139,7 +161,15 @@ export function createWorker(
139
161
  return v === undefined ? [] : [[n, decodeURIComponent(v)]];
140
162
  }),
141
163
  );
142
- return { def: d.def, params, chain: chainFor(d.pattern), loading: loadingFor(d.pattern) };
164
+ const b = boundaryFor(d.pattern);
165
+ return {
166
+ def: d.def,
167
+ params,
168
+ chain: chainFor(d.pattern),
169
+ boundaryIndex: b?.index ?? null,
170
+ boundaryKey: b?.key ?? null,
171
+ loading: loadingFor(d.pattern),
172
+ };
143
173
  }
144
174
  }
145
175
  // Resource routes after pages (so a page is never shadowed by one).