@junejs/server 0.0.24 → 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/build.ts +6 -24
- package/src/content.ts +151 -18
- package/src/pipeline.ts +24 -2
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/build.ts
CHANGED
|
@@ -29,7 +29,7 @@ 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
35
|
import { resolveBoundary } from "./segment";
|
|
@@ -113,29 +113,11 @@ export async function scanRoutes(
|
|
|
113
113
|
export async function generateContent(appRoot: string): Promise<string[]> {
|
|
114
114
|
const contentDir = join(appRoot, "content");
|
|
115
115
|
if (!existsSync(contentDir)) return [];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string };\n";
|
|
122
|
-
const names: string[] = [];
|
|
123
|
-
for (const d of dirs) {
|
|
124
|
-
const entries = collection(join(contentDir, d.name)).map(({ slug, data, body, original, html }) => ({
|
|
125
|
-
slug,
|
|
126
|
-
data,
|
|
127
|
-
body,
|
|
128
|
-
original,
|
|
129
|
-
html,
|
|
130
|
-
}));
|
|
131
|
-
const constName = d.name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
132
|
-
// Singular finder name: posts → post, otherwise <name>Entry.
|
|
133
|
-
const finder = d.name.endsWith("s") ? d.name.slice(0, -1) : `${d.name}Entry`;
|
|
134
|
-
out += `export const ${constName}: ContentEntry[] = ${JSON.stringify(entries, null, 2)};\n`;
|
|
135
|
-
out += `export const ${finder} = (slug: string): ContentEntry | null => ${constName}.find((p) => p.slug === slug) ?? null;\n`;
|
|
136
|
-
names.push(d.name);
|
|
137
|
-
}
|
|
138
|
-
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);
|
|
139
121
|
return names;
|
|
140
122
|
}
|
|
141
123
|
|
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/pipeline.ts
CHANGED
|
@@ -39,10 +39,12 @@ import { ensureScope, runInScope } from "@junejs/db";
|
|
|
39
39
|
import type { AgentConfig } from "@junejs/core/config";
|
|
40
40
|
import {
|
|
41
41
|
LOCALE_COOKIE,
|
|
42
|
+
localeAlternates,
|
|
42
43
|
localeDir,
|
|
43
44
|
matchPinnedLocale,
|
|
44
45
|
negotiateLocale,
|
|
45
46
|
type I18nConfig,
|
|
47
|
+
type LocaleAlternate,
|
|
46
48
|
} from "@junejs/core/i18n";
|
|
47
49
|
import type { Resources } from "@junejs/core/resources";
|
|
48
50
|
|
|
@@ -214,6 +216,20 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
214
216
|
return { lang, dir: localeDir(lang, cfg.i18n) };
|
|
215
217
|
}
|
|
216
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
|
+
|
|
217
233
|
async function renderDocument(
|
|
218
234
|
node: React.ReactNode,
|
|
219
235
|
metadata: Metadata | undefined,
|
|
@@ -221,6 +237,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
221
237
|
chain: LayoutComponent[],
|
|
222
238
|
locale?: string,
|
|
223
239
|
boundaryKey?: string | null,
|
|
240
|
+
alternates?: LocaleAlternate[],
|
|
224
241
|
): Promise<Response> {
|
|
225
242
|
// Wrap root→leaf: chain[0] is outermost.
|
|
226
243
|
const wrapped = chain.reduceRight<React.ReactNode>(
|
|
@@ -233,6 +250,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
233
250
|
metadata,
|
|
234
251
|
children: wrapped,
|
|
235
252
|
shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
|
|
253
|
+
alternates,
|
|
236
254
|
...langDir(locale),
|
|
237
255
|
}),
|
|
238
256
|
);
|
|
@@ -317,6 +335,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
317
335
|
metadata,
|
|
318
336
|
children: wrapped,
|
|
319
337
|
shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
|
|
338
|
+
alternates: alternatesFor(ctx),
|
|
320
339
|
...langDir(ctx.locale),
|
|
321
340
|
}),
|
|
322
341
|
{ onError: (e: unknown) => console.error("[june] streaming render error:", e) },
|
|
@@ -409,7 +428,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
409
428
|
// renders the whole chain (a hard load of the URL is never segment-scoped) —
|
|
410
429
|
// but it stamps the shell key so the client knows which shell is mounted.
|
|
411
430
|
if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
|
|
412
|
-
return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey);
|
|
431
|
+
return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx));
|
|
413
432
|
}
|
|
414
433
|
|
|
415
434
|
async function discovery(url: URL): Promise<Response | null> {
|
|
@@ -422,7 +441,10 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
422
441
|
case "/robots.txt":
|
|
423
442
|
return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
|
|
424
443
|
case "/sitemap.xml":
|
|
425
|
-
return text(
|
|
444
|
+
return text(
|
|
445
|
+
sitemapXml(url.origin, await cfg.routeList(), cfg.i18n),
|
|
446
|
+
"application/xml; charset=utf-8",
|
|
447
|
+
);
|
|
426
448
|
case "/.well-known/api-catalog":
|
|
427
449
|
return text(JSON.stringify(apiCatalog(url.origin, agent)), "application/linkset+json");
|
|
428
450
|
case "/.well-known/mcp/server-card.json":
|