@junejs/server 1.0.0-dev.25 → 1.0.0-dev.28

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/src/favicon.ts ADDED
@@ -0,0 +1,325 @@
1
+ // The default PNG icons: apple-touch-icon.png (180), icon.png (32), and a real
2
+ // favicon.ico — the site name's first character on the same dark square as the
3
+ // SVG favicon the pipeline serves. Build + dev host only (resvg-wasm, node:fs);
4
+ // the worker never imports this, it serves the files `june build` wrote.
5
+ //
6
+ // A rasterizer has no browser fonts, so each icon picks a font for the script
7
+ // of its character:
8
+ // Latin / Greek / Cyrillic caps, digits → Inter caps, bundled (offline, deterministic)
9
+ // Han → the Noto Sans CJK face for site.lang, via CLDR likely subtags:
10
+ // zh → SC, zh-TW / zh-Hant → TC, zh-HK / zh-MO / yue → HK, ja → JP, ko → KR;
11
+ // a lang with no Han signal (en) → SC, CLDR's default for Han
12
+ // kana → Noto Sans JP · Hangul → Noto Sans KR · emoji → Noto Emoji
13
+ // Arabic, Devanagari, Thai, … → that script's Noto Sans face
14
+ // Non-bundled faces come from Google Fonts subset to that ONE character (a few
15
+ // KB), cached on disk, so only the first build touches the network. Any miss
16
+ // (offline, unknown script, a glyph the font lacks) draws the square without a
17
+ // character, and never fails the build.
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, lstatSync } from "node:fs";
20
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
21
+ import { createRequire } from "node:module";
22
+ import { join } from "node:path";
23
+
24
+ import type { DocumentConfig } from "@junejs/core/document";
25
+
26
+ import { iconLetter } from "./icon-letter";
27
+
28
+ type Site = DocumentConfig["site"];
29
+
30
+ export const ICON_BG = "#1d1d1f";
31
+ export const ICON_FG = "#fbfbf8";
32
+
33
+ // Filenames the icons are served at (from the app root), and the public/ files
34
+ // that mean the app brings its own icon — then June generates none.
35
+ export const GENERATED_ICONS = ["apple-touch-icon.png", "icon.png", "favicon.ico", "icon-192.png", "icon-512.png"] as const;
36
+ export type GeneratedIconFile = (typeof GENERATED_ICONS)[number];
37
+ // The app's own main icon, in preference order: the first one present becomes
38
+ // the page's favicon link (so none of them is ever silently unreferenced).
39
+ const PRIMARY_ICON_FILES = ["favicon.svg", "icon.svg", "favicon.png", "icon.png", "favicon.ico"];
40
+ const CUSTOM_ICON_FILES = [...PRIMARY_ICON_FILES, "apple-touch-icon.png"];
41
+ const MANIFEST_FILES = ["manifest.webmanifest", "manifest.json", "site.webmanifest"];
42
+
43
+ // `inPublic` for resolveIcons over an app's public/ dir. A symlinked public/ is
44
+ // ignored, as the build and dev server ignore it.
45
+ export function publicFileCheck(publicDir: string): (file: string) => boolean {
46
+ let real = false;
47
+ try {
48
+ real = lstatSync(publicDir).isDirectory();
49
+ } catch {
50
+ /* no public/ */
51
+ }
52
+ return (file) => real && existsSync(join(publicDir, file));
53
+ }
54
+
55
+ // Which icons the document links, and whether June generates them. `inPublic`
56
+ // answers whether public/<file> exists. Generated when the app has no icon of
57
+ // its own. Otherwise every icon file it has is linked: the first of
58
+ // PRIMARY_ICON_FILES as the favicon (site.icon still wins in the document),
59
+ // plus icon.png and apple-touch-icon.png when present.
60
+ //
61
+ // The web manifest: the app's own public/manifest.webmanifest (or
62
+ // manifest.json) is linked whenever present. Otherwise June generates one only
63
+ // alongside its own icons — an app with custom icons may have no 192/512 PNGs
64
+ // for a manifest to point at.
65
+ export function resolveIcons(
66
+ site: Site,
67
+ inPublic: (file: string) => boolean,
68
+ ): { generate: boolean; icons: NonNullable<DocumentConfig["icons"]> } {
69
+ const ownManifest = MANIFEST_FILES.find(inPublic);
70
+ const custom = Boolean(site.icon) || CUSTOM_ICON_FILES.some(inPublic);
71
+ if (!custom) {
72
+ return {
73
+ generate: true,
74
+ icons: {
75
+ png: "/icon.png",
76
+ appleTouch: "/apple-touch-icon.png",
77
+ manifest: ownManifest ? `/${ownManifest}` : "/manifest.webmanifest",
78
+ generated: !ownManifest,
79
+ },
80
+ };
81
+ }
82
+ const primary = PRIMARY_ICON_FILES.find(inPublic);
83
+ return {
84
+ generate: false,
85
+ icons: {
86
+ primary: primary ? `/${primary}` : undefined,
87
+ png: inPublic("icon.png") ? "/icon.png" : undefined,
88
+ appleTouch: inPublic("apple-touch-icon.png") ? "/apple-touch-icon.png" : undefined,
89
+ manifest: ownManifest ? `/${ownManifest}` : undefined,
90
+ },
91
+ };
92
+ }
93
+
94
+ const is = (re: RegExp) => (ch: string) => re.test(ch);
95
+ const isBundled = is(/^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}0-9•]/u);
96
+ const isEmoji = is(/^\p{Extended_Pictographic}/u);
97
+ const isKana = is(/^[\p{Script=Hiragana}\p{Script=Katakana}]/u);
98
+ const isHangul = is(/^\p{Script=Hangul}/u);
99
+ const isHan = is(/^\p{Script=Han}/u);
100
+
101
+ // Scripts with a Noto Sans face on Google Fonts, by Unicode script name.
102
+ const NOTO_SCRIPTS = [
103
+ "Arabic", "Armenian", "Bengali", "Devanagari", "Ethiopic", "Georgian", "Gujarati", "Gurmukhi",
104
+ "Hebrew", "Kannada", "Khmer", "Lao", "Malayalam", "Myanmar", "Sinhala", "Tamil", "Telugu", "Thai",
105
+ ] as const;
106
+ const NOTO_SCRIPT_RES = NOTO_SCRIPTS.map((s) => [s, new RegExp(`^\\p{Script=${s}}`, "u")] as const);
107
+
108
+ // The Noto Sans CJK face whose Han glyph forms match `lang`. Resolved through
109
+ // CLDR likely subtags (Intl.Locale#maximize) instead of a hand-picked region.
110
+ export function hanFamily(lang: string | undefined): string {
111
+ let loc: Intl.Locale;
112
+ try {
113
+ loc = new Intl.Locale(lang || "und").maximize();
114
+ } catch {
115
+ return "Noto Sans SC";
116
+ }
117
+ if (loc.script === "Jpan") return "Noto Sans JP";
118
+ if (loc.script === "Kore") return "Noto Sans KR";
119
+ if (loc.script === "Hant") return loc.region === "HK" || loc.region === "MO" ? "Noto Sans HK" : "Noto Sans TC";
120
+ return "Noto Sans SC"; // Hans, or a language with no Han signal — CLDR's und-Hani is zh-Hans
121
+ }
122
+
123
+ // The Google Fonts family for a character; null = the bundled Inter caps;
124
+ // undefined = no known face (draw the square alone).
125
+ export function iconFamily(ch: string, lang: string | undefined): string | null | undefined {
126
+ if (!ch) return undefined;
127
+ if (isBundled(ch)) return null;
128
+ if (isEmoji(ch)) return "Noto Emoji";
129
+ if (isKana(ch)) return "Noto Sans JP";
130
+ if (isHangul(ch)) return "Noto Sans KR";
131
+ if (isHan(ch)) return hanFamily(lang);
132
+ const script = NOTO_SCRIPT_RES.find(([, re]) => re.test(ch))?.[0];
133
+ return script ? `Noto Sans ${script}` : undefined;
134
+ }
135
+
136
+ // Fetches a font subset: resolves to the TTF bytes, or null on any failure.
137
+ export type FontFetcher = (family: string, text: string) => Promise<Uint8Array | null>;
138
+
139
+ // A legacy Safari UA makes Google Fonts answer with TTF (resvg reads TTF/OTF,
140
+ // not woff2) — the same trick @junejs/og uses.
141
+ const LEGACY_UA =
142
+ "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1";
143
+
144
+ // Google Fonts, subset to `text`, cached under cacheDir by request URL so a
145
+ // rebuild never refetches. timeoutMs bounds each request (offline builds fall
146
+ // back quickly instead of hanging).
147
+ export function googleFontFetcher(
148
+ cacheDir: string,
149
+ opts: { fetch?: typeof fetch; timeoutMs?: number } = {},
150
+ ): FontFetcher {
151
+ const doFetch = opts.fetch ?? fetch;
152
+ const timeoutMs = opts.timeoutMs ?? 3000;
153
+ return async (family, text) => {
154
+ const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@600&text=${encodeURIComponent(text)}`;
155
+ const file = join(cacheDir, createHash("sha256").update(cssUrl).digest("hex").slice(0, 32) + ".ttf");
156
+ try {
157
+ return new Uint8Array(await readFile(file));
158
+ } catch {
159
+ /* not cached */
160
+ }
161
+ try {
162
+ const signal = AbortSignal.timeout(timeoutMs);
163
+ const cssRes = await doFetch(cssUrl, { headers: { "User-Agent": LEGACY_UA }, signal });
164
+ if (!cssRes.ok) return null;
165
+ const url = (await cssRes.text()).match(/src: url\((.+?)\)/)?.[1];
166
+ if (!url) return null;
167
+ const fontRes = await doFetch(url, { signal });
168
+ if (!fontRes.ok) return null;
169
+ const bytes = new Uint8Array(await fontRes.arrayBuffer());
170
+ await mkdir(cacheDir, { recursive: true });
171
+ await writeFile(file, bytes);
172
+ return bytes;
173
+ } catch {
174
+ return null;
175
+ }
176
+ };
177
+ }
178
+
179
+ type ResvgModule = typeof import("@resvg/resvg-wasm");
180
+ let resvgReady: Promise<ResvgModule> | undefined;
181
+ function loadResvg(): Promise<ResvgModule> {
182
+ return (resvgReady ??= (async () => {
183
+ const mod = await import("@resvg/resvg-wasm");
184
+ const wasmPath = createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm");
185
+ await mod.initWasm(await readFile(wasmPath));
186
+ return mod;
187
+ })());
188
+ }
189
+
190
+ let bundledFont: Promise<Uint8Array> | undefined;
191
+ const loadBundledFont = () =>
192
+ (bundledFont ??= readFile(new URL("../assets/icon-latin.ttf", import.meta.url)).then((b) => new Uint8Array(b)));
193
+ const BUNDLED_FAMILY = "Inter";
194
+
195
+ const escapeXml = (s: string) => s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
196
+
197
+ // Where the glyph goes on the 64×64 square: font size + origin, from its INK
198
+ // box — not its em box — so every script sits optically centred at one size.
199
+ // A probe render measures the ink; cap height and a CJK ideograph both come out
200
+ // INK_H tall, and a wide glyph is held to INK_W. The ink box's corners stay
201
+ // within 0.4 of the size from the centre, which is the maskable-icon safe zone,
202
+ // so the manifest can mark the full-bleed art "maskable" (pinned in a test).
203
+ const PROBE = 1000;
204
+ export const INK_H = 28;
205
+ export const INK_W = 40;
206
+ type Placement = { family: string; font: Uint8Array; size: number; x: number; y: number };
207
+
208
+ function inkBox(resvg: ResvgModule, ch: string, family: string, font: Uint8Array) {
209
+ const probe =
210
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${PROBE}" height="${PROBE}">` +
211
+ `<text x="${PROBE / 2}" y="${PROBE * 0.7}" font-family="${family}" font-size="${PROBE * 0.6}">${escapeXml(ch)}</text></svg>`;
212
+ const r = new resvg.Resvg(probe, { font: { fontBuffers: [font], loadSystemFonts: false, defaultFontFamily: family } });
213
+ const bbox = r.getBBox();
214
+ const box = bbox && bbox.width > 0 && bbox.height > 0 ? { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height } : null;
215
+ bbox?.free();
216
+ r.free();
217
+ return box;
218
+ }
219
+
220
+ // A private-use code point no subset font maps: whatever it draws is .notdef.
221
+ const NOTDEF_PROBE = "";
222
+
223
+ function place(resvg: ResvgModule, ch: string, family: string, font: Uint8Array): Placement | null {
224
+ const bbox = inkBox(resvg, ch, family, font);
225
+ if (!bbox) return null;
226
+ // A font without the glyph draws .notdef (tofu), which HAS ink — so compare
227
+ // with the font's own .notdef box. The same box means a missing glyph.
228
+ const notdef = inkBox(resvg, NOTDEF_PROBE, family, font);
229
+ if (notdef && notdef.x === bbox.x && notdef.y === bbox.y && notdef.width === bbox.width && notdef.height === bbox.height) {
230
+ return null;
231
+ }
232
+ const scale = Math.min(INK_H / bbox.height, INK_W / bbox.width);
233
+ const cx = bbox.x + bbox.width / 2 - PROBE / 2;
234
+ const cy = bbox.y + bbox.height / 2 - PROBE * 0.7;
235
+ return { family, font, size: PROBE * 0.6 * scale, x: 32 - cx * scale, y: 32 - cy * scale };
236
+ }
237
+
238
+ // The icon as SVG on a 64 grid. rounded=false for apple-touch-icon: iOS masks
239
+ // the corners itself and wants a full-bleed square.
240
+ function iconSvg(ch: string, p: Placement | null, rounded: boolean): string {
241
+ const glyph = p
242
+ ? `<text x="${p.x.toFixed(2)}" y="${p.y.toFixed(2)}" font-family="${p.family}" font-size="${p.size.toFixed(2)}" fill="${ICON_FG}">${escapeXml(ch)}</text>`
243
+ : "";
244
+ return (
245
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">` +
246
+ `<rect width="64" height="64"${rounded ? ` rx="12"` : ""} fill="${ICON_BG}"/>${glyph}</svg>`
247
+ );
248
+ }
249
+
250
+ // A single-image .ico whose entry is a PNG (valid since Windows Vista; every
251
+ // current browser reads it).
252
+ export function pngToIco(png: Uint8Array, size: number): Uint8Array<ArrayBuffer> {
253
+ const out = new Uint8Array(22 + png.length);
254
+ const v = new DataView(out.buffer);
255
+ v.setUint16(2, 1, true); // type: icon
256
+ v.setUint16(4, 1, true); // one image
257
+ out[6] = size >= 256 ? 0 : size; // width
258
+ out[7] = size >= 256 ? 0 : size; // height
259
+ v.setUint16(10, 1, true); // colour planes
260
+ v.setUint16(12, 32, true); // bits per pixel
261
+ v.setUint32(14, png.length, true);
262
+ v.setUint32(18, 22, true); // image data offset
263
+ out.set(png, 22);
264
+ return out;
265
+ }
266
+
267
+ export type GeneratedIcons = {
268
+ "apple-touch-icon.png": Uint8Array<ArrayBuffer>;
269
+ "icon.png": Uint8Array<ArrayBuffer>;
270
+ "favicon.ico": Uint8Array<ArrayBuffer>;
271
+ // The web manifest's icons (Android home screen). Full-bleed like the apple
272
+ // icon: the glyph's ink stays inside the maskable safe zone (a centred circle
273
+ // of radius 0.4), so one image serves "any" and "maskable".
274
+ "icon-192.png": Uint8Array<ArrayBuffer>;
275
+ "icon-512.png": Uint8Array<ArrayBuffer>;
276
+ // What was drawn, for the build log and tests: the character and its font
277
+ // family, or family null when the square went out without one.
278
+ letter: string;
279
+ family: string | null;
280
+ };
281
+
282
+ export async function renderIcons(opts: {
283
+ site: Site;
284
+ fetchFont: FontFetcher;
285
+ warn?: (msg: string) => void;
286
+ }): Promise<GeneratedIcons> {
287
+ const resvg = await loadResvg();
288
+ const ch = iconLetter(opts.site.name);
289
+ const family = iconFamily(ch, opts.site.lang);
290
+ let placement: Placement | null = null;
291
+ if (family === null) {
292
+ placement = place(resvg, ch, BUNDLED_FAMILY, await loadBundledFont());
293
+ } else if (family !== undefined) {
294
+ const font = await opts.fetchFont(family, ch);
295
+ if (font) placement = place(resvg, ch, family, font);
296
+ }
297
+ if (ch && !placement) {
298
+ opts.warn?.(
299
+ `[june] default icon: no font for "${ch}"${family ? ` (${family} unavailable — offline?)` : ""}; ` +
300
+ `drew the square without it. Set site.icon or add public/icon.png to use your own.`,
301
+ );
302
+ }
303
+ const fonts = placement ? [placement.font] : [];
304
+ const render = (size: number, rounded: boolean) => {
305
+ const r = new resvg.Resvg(iconSvg(ch, placement, rounded), {
306
+ fitTo: { mode: "width", value: size },
307
+ font: { fontBuffers: fonts, loadSystemFonts: false, defaultFontFamily: placement?.family ?? BUNDLED_FAMILY },
308
+ });
309
+ const image = r.render();
310
+ const png = image.asPng() as Uint8Array<ArrayBuffer>; // a JS-owned copy, not a view into wasm memory
311
+ image.free();
312
+ r.free();
313
+ return png;
314
+ };
315
+ const icon32 = render(32, true);
316
+ return {
317
+ "apple-touch-icon.png": render(180, false),
318
+ "icon.png": icon32,
319
+ "favicon.ico": pngToIco(icon32, 32),
320
+ "icon-192.png": render(192, false),
321
+ "icon-512.png": render(512, false),
322
+ letter: ch,
323
+ family: placement?.family ?? null,
324
+ };
325
+ }
@@ -0,0 +1,14 @@
1
+ // The default icon's character — shared by the worker's SVG favicon
2
+ // (pipeline.ts) and the build's PNG icons (favicon.ts), so both draw the same
3
+ // one. Worker-safe: no node:* imports.
4
+
5
+ // The name's first grapheme, uppercased. A grapheme — not charAt(0) — so an
6
+ // emoji, a flag, or a character outside the BMP stays whole instead of
7
+ // becoming half a surrogate pair. "" when the name has none.
8
+ export function iconLetter(name: string | undefined): string {
9
+ const trimmed = (name ?? "").trim();
10
+ if (!trimmed) return "";
11
+ const first = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(trimmed)[Symbol.iterator]().next()
12
+ .value?.segment;
13
+ return (first ?? "").toUpperCase();
14
+ }
package/src/pipeline.ts CHANGED
@@ -49,6 +49,8 @@ import {
49
49
  } from "@junejs/core/i18n";
50
50
  import type { Resources } from "@junejs/core/resources";
51
51
 
52
+ import { iconLetter } from "./icon-letter";
53
+ import { webManifest } from "./web-manifest";
52
54
  import { negotiate, TITLE_HEADER, SEGMENT_HEADER, encodeTitle } from "./negotiate";
53
55
 
54
56
  // Minimal Cookie-header read for the locale negotiation chain (no dependency on
@@ -200,10 +202,11 @@ function StreamedView({
200
202
  // The default favicon: the site name's first character in a rounded square —
201
203
  // a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
202
204
  // names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
203
- // (browsers respect the svg content-type), so no June app 404s its icon.
205
+ // (browsers respect the svg content-type), so no June app 404s its icon. A built
206
+ // app also ships real PNG/ICO files (favicon.ts), which the asset layer answers
207
+ // first, so this /favicon.ico is only the fallback.
204
208
  function letterFavicon(siteName: string | undefined): Response {
205
- const first = (siteName ?? "").trim().charAt(0) || "•";
206
- const letter = first.toUpperCase().replace(/[<>&"']/g, "");
209
+ const letter = (iconLetter(siteName) || "•").replace(/[<>&"']/g, "");
207
210
  return text(
208
211
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
209
212
  `<rect width="64" height="64" rx="12" fill="#1d1d1f"/>` +
@@ -214,6 +217,12 @@ function letterFavicon(siteName: string | undefined): Response {
214
217
  );
215
218
  }
216
219
 
220
+ type PageProps = { pageUrl: string; isHome: boolean; onLocaleDomain: boolean };
221
+
222
+ // A request's matched route pathname (locale stripped, /index → /), keyed by its
223
+ // ctx — RouteContext is public API, so the pipeline tracks it on the side.
224
+ const routePathOf = new WeakMap<RouteContext, string>();
225
+
217
226
  export function createPipeline(cfg: PipelineConfig): Pipeline {
218
227
  const { docConfig, agent } = cfg;
219
228
  const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
@@ -249,6 +258,20 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
249
258
  });
250
259
  }
251
260
 
261
+ // The document's page-identity props: the URL for og:url/canonical, whether the
262
+ // MATCHED route is home (so /de and /index count, per negotiate's normalized
263
+ // pathname), and whether the host is a locale's own domain (its public origin).
264
+ function pageProps(ctx: RouteContext): PageProps {
265
+ const host = ctx.url.hostname.toLowerCase();
266
+ return {
267
+ pageUrl: ctx.url.href,
268
+ isHome: routePathOf.get(ctx) === "/",
269
+ onLocaleDomain: cfg.i18n
270
+ ? Object.values(cfg.i18n.locales).some((l) => l.domain?.toLowerCase() === host)
271
+ : false,
272
+ };
273
+ }
274
+
252
275
  async function renderDocument(
253
276
  node: React.ReactNode,
254
277
  metadata: Metadata | undefined,
@@ -257,6 +280,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
257
280
  locale?: string,
258
281
  boundaryKey?: string | null,
259
282
  alternates?: LocaleAlternate[],
283
+ page?: PageProps,
260
284
  ): Promise<Response> {
261
285
  // Wrap root→leaf: chain[0] is outermost.
262
286
  const wrapped = chain.reduceRight<React.ReactNode>(
@@ -270,6 +294,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
270
294
  children: wrapped,
271
295
  shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
272
296
  alternates,
297
+ ...page,
273
298
  ...langDir(locale),
274
299
  }),
275
300
  );
@@ -357,6 +382,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
357
382
  children: wrapped,
358
383
  shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
359
384
  alternates: alternatesFor(ctx),
385
+ ...pageProps(ctx),
360
386
  ...langDir(ctx.locale),
361
387
  }),
362
388
  { onError: (e: unknown) => console.error("[june] streaming render error:", e) },
@@ -449,7 +475,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
449
475
  // renders the whole chain (a hard load of the URL is never segment-scoped) —
450
476
  // but it stamps the shell key so the client knows which shell is mounted.
451
477
  if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
452
- return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx));
478
+ return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx), pageProps(ctx));
453
479
  }
454
480
 
455
481
  async function discovery(url: URL): Promise<Response | null> {
@@ -518,6 +544,12 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
518
544
  ) {
519
545
  return letterFavicon(docConfig.site.name);
520
546
  }
547
+ // --- generated web manifest (only beside June's generated icons) ------
548
+ if (request.method === "GET" && docConfig.icons?.generated && url.pathname === "/manifest.webmanifest") {
549
+ return text(webManifest(docConfig), "application/manifest+json", {
550
+ headers: { "cache-control": "public, max-age=86400" },
551
+ });
552
+ }
521
553
 
522
554
  // --- locale resolution (before routing) ------------------------------
523
555
  // host/path → locale, stripping the locale prefix off the front so the
@@ -562,6 +594,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
562
594
  locale,
563
595
  speculative,
564
596
  };
597
+ routePathOf.set(ctx, pathname);
565
598
 
566
599
  // Resource route (route.*): a raw-Response handler — no projection, no doc
567
600
  // shell. It's matched like any route, so it can't shadow a page.
@@ -36,7 +36,8 @@ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<strin
36
36
  return out;
37
37
  }
38
38
 
39
- async function renderDocument(path: string): Promise<string | null> {
39
+ async function renderDocument(url: URL): Promise<string | null> {
40
+ const path = url.pathname;
40
41
  // Non-literal specifier → rolldown leaves it as a runtime import, so the
41
42
  // react-server server bundle is loaded (not re-bundled) from beside this file.
42
43
  const serverUrl = new URL("./server.js", import.meta.url).href;
@@ -50,7 +51,8 @@ async function renderDocument(path: string): Promise<string | null> {
50
51
  }) as unknown as Promise<React.ReactNode>;
51
52
 
52
53
  const html = await renderToReadableStream(
53
- <Document config={DOC_CONFIG}>
54
+ // RSC routes are exact paths (no locale prefix), so the home route IS "/".
55
+ <Document config={DOC_CONFIG} pageUrl={url.href} isHome={path === "/"}>
54
56
  <Root tree={tree} />
55
57
  </Document>,
56
58
  );
@@ -62,8 +64,7 @@ async function renderDocument(path: string): Promise<string | null> {
62
64
 
63
65
  export default {
64
66
  async fetch(request: Request): Promise<Response> {
65
- const path = new URL(request.url).pathname;
66
- const body = await renderDocument(path);
67
+ const body = await renderDocument(new URL(request.url));
67
68
  if (body === null) return new Response("Not Found", { status: 404 });
68
69
  return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
69
70
  },
@@ -0,0 +1,42 @@
1
+ // The generated web app manifest (/manifest.webmanifest) — Android's "Add to
2
+ // Home Screen" name, icons, and colours, built from the same config the
3
+ // document renders with. Worker-safe (no node:*): the pipeline serves it like
4
+ // the SVG favicon, so dev, the worker, and the static build all agree.
5
+ import { resolveThemeColor, type DocumentConfig } from "@junejs/core/document";
6
+
7
+ // The home-screen label: site.shortName, else the site name's leading segment
8
+ // ("June — build agents into real apps" → "June"). Launchers truncate past
9
+ // ~12 characters, and a tagline is never the label anyone wants.
10
+ export function shortName(site: DocumentConfig["site"]): string | undefined {
11
+ if (site.shortName) return site.shortName;
12
+ const name = site.name?.trim();
13
+ if (!name) return undefined;
14
+ return name.split(/\s+[—–|-]\s+|:\s+/)[0]!.trim() || name;
15
+ }
16
+
17
+ export function webManifest(config: DocumentConfig): string {
18
+ const base = config.basePath ?? "";
19
+ const theme = resolveThemeColor(config);
20
+ // A manifest has ONE theme colour; a { light, dark } pair has no honest single
21
+ // value, so both colour fields are left to the browser then.
22
+ const color = typeof theme === "string" ? theme : undefined;
23
+ const manifest = {
24
+ name: config.site.name,
25
+ short_name: shortName(config.site),
26
+ description: config.site.description,
27
+ start_url: `${base}/`,
28
+ scope: `${base}/`,
29
+ // "browser": a home-screen shortcut that opens a normal tab. standalone
30
+ // (app-like, no URL bar) changes how a site behaves, so it stays opt-in via
31
+ // your own public/manifest.webmanifest.
32
+ display: "browser",
33
+ ...(color ? { theme_color: color, background_color: color } : {}),
34
+ icons: [
35
+ { src: `${base}/icon-192.png`, sizes: "192x192", type: "image/png" },
36
+ { src: `${base}/icon-512.png`, sizes: "512x512", type: "image/png" },
37
+ // The same full-bleed art: its glyph sits inside the maskable safe zone.
38
+ { src: `${base}/icon-512.png`, sizes: "512x512", type: "image/png", purpose: "maskable" },
39
+ ],
40
+ };
41
+ return JSON.stringify(manifest, null, 2) + "\n";
42
+ }