@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.24",
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.24",
34
- "@junejs/db": "0.0.24",
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 { collection } from "./content";
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
- const dirs = (await readdir(contentDir, { withFileTypes: true })).filter((e) => e.isDirectory());
117
- if (dirs.length === 0) return [];
118
-
119
- let out =
120
- "// AUTO-GENERATED by `june build` — edit content/**/*.md, not this file.\n" +
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
- /** 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/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(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
+ );
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":