@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 +3 -3
- package/src/app.ts +34 -13
- package/src/build.ts +83 -39
- package/src/content.ts +151 -18
- package/src/dev-reload.ts +7 -1
- package/src/index.ts +15 -0
- package/src/negotiate.ts +9 -4
- package/src/pipeline.ts +145 -15
- package/src/segment.ts +60 -0
- package/src/worker.ts +33 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
34
|
-
"@junejs/db": "0.0.
|
|
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
|
-
|
|
44
|
-
|
|
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 {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
+
items.push({ file: seg.layout, entry: L?.component ?? null, boundary: !!L?.boundary });
|
|
73
89
|
}
|
|
74
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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,
|
|
185
|
-
const
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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 =
|
|
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, {
|
|
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
|
-
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
330
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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).
|