@junejs/server 1.0.0-dev.27 → 1.0.0-dev.29

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/dist/build.js CHANGED
@@ -342,6 +342,7 @@ ${doClass}`;
342
342
  }
343
343
  for (const [reqPath, file] of [
344
344
  ["/favicon.svg", "favicon.svg"],
345
+ ["/manifest.webmanifest", "manifest.webmanifest"],
345
346
  ["/llms.txt", "llms.txt"],
346
347
  ["/sitemap.xml", "sitemap.xml"]
347
348
  ]) {
package/dist/favicon.d.ts CHANGED
@@ -3,7 +3,7 @@ import { DocumentConfig } from "@junejs/core/document";
3
3
  type Site = DocumentConfig["site"];
4
4
  declare const ICON_BG = "#1d1d1f";
5
5
  declare const ICON_FG = "#fbfbf8";
6
- declare const GENERATED_ICONS: readonly ["apple-touch-icon.png", "icon.png", "favicon.ico"];
6
+ declare const GENERATED_ICONS: readonly ["apple-touch-icon.png", "icon.png", "favicon.ico", "icon-192.png", "icon-512.png"];
7
7
  type GeneratedIconFile = (typeof GENERATED_ICONS)[number];
8
8
  declare function publicFileCheck(publicDir: string): (file: string) => boolean;
9
9
  declare function resolveIcons(site: Site, inPublic: (file: string) => boolean): {
@@ -17,11 +17,15 @@ declare function googleFontFetcher(cacheDir: string, opts?: {
17
17
  fetch?: typeof fetch;
18
18
  timeoutMs?: number;
19
19
  }): FontFetcher;
20
+ declare const INK_H = 28;
21
+ declare const INK_W = 40;
20
22
  declare function pngToIco(png: Uint8Array, size: number): Uint8Array<ArrayBuffer>;
21
23
  type GeneratedIcons = {
22
24
  "apple-touch-icon.png": Uint8Array<ArrayBuffer>;
23
25
  "icon.png": Uint8Array<ArrayBuffer>;
24
26
  "favicon.ico": Uint8Array<ArrayBuffer>;
27
+ "icon-192.png": Uint8Array<ArrayBuffer>;
28
+ "icon-512.png": Uint8Array<ArrayBuffer>;
25
29
  letter: string;
26
30
  family: string | null;
27
31
  };
@@ -31,4 +35,4 @@ declare function renderIcons(opts: {
31
35
  warn?: (msg: string) => void;
32
36
  }): Promise<GeneratedIcons>;
33
37
  //#endregion
34
- export { FontFetcher, GENERATED_ICONS, GeneratedIconFile, GeneratedIcons, ICON_BG, ICON_FG, googleFontFetcher, hanFamily, iconFamily, pngToIco, publicFileCheck, renderIcons, resolveIcons };
38
+ export { FontFetcher, GENERATED_ICONS, GeneratedIconFile, GeneratedIcons, ICON_BG, ICON_FG, INK_H, INK_W, googleFontFetcher, hanFamily, iconFamily, pngToIco, publicFileCheck, renderIcons, resolveIcons };
package/dist/favicon.js CHANGED
@@ -10,7 +10,9 @@ const ICON_FG = "#fbfbf8";
10
10
  const GENERATED_ICONS = [
11
11
  "apple-touch-icon.png",
12
12
  "icon.png",
13
- "favicon.ico"
13
+ "favicon.ico",
14
+ "icon-192.png",
15
+ "icon-512.png"
14
16
  ];
15
17
  const PRIMARY_ICON_FILES = [
16
18
  "favicon.svg",
@@ -20,6 +22,11 @@ const PRIMARY_ICON_FILES = [
20
22
  "favicon.ico"
21
23
  ];
22
24
  const CUSTOM_ICON_FILES = [...PRIMARY_ICON_FILES, "apple-touch-icon.png"];
25
+ const MANIFEST_FILES = [
26
+ "manifest.webmanifest",
27
+ "manifest.json",
28
+ "site.webmanifest"
29
+ ];
23
30
  function publicFileCheck(publicDir) {
24
31
  let real = false;
25
32
  try {
@@ -28,11 +35,14 @@ function publicFileCheck(publicDir) {
28
35
  return (file) => real && existsSync(join(publicDir, file));
29
36
  }
30
37
  function resolveIcons(site, inPublic) {
38
+ const ownManifest = MANIFEST_FILES.find(inPublic);
31
39
  if (!(Boolean(site.icon) || CUSTOM_ICON_FILES.some(inPublic))) return {
32
40
  generate: true,
33
41
  icons: {
34
42
  png: "/icon.png",
35
- appleTouch: "/apple-touch-icon.png"
43
+ appleTouch: "/apple-touch-icon.png",
44
+ manifest: ownManifest ? `/${ownManifest}` : "/manifest.webmanifest",
45
+ generated: !ownManifest
36
46
  }
37
47
  };
38
48
  const primary = PRIMARY_ICON_FILES.find(inPublic);
@@ -41,7 +51,8 @@ function resolveIcons(site, inPublic) {
41
51
  icons: {
42
52
  primary: primary ? `/${primary}` : void 0,
43
53
  png: inPublic("icon.png") ? "/icon.png" : void 0,
44
- appleTouch: inPublic("apple-touch-icon.png") ? "/apple-touch-icon.png" : void 0
54
+ appleTouch: inPublic("apple-touch-icon.png") ? "/apple-touch-icon.png" : void 0,
55
+ manifest: ownManifest ? `/${ownManifest}` : void 0
45
56
  }
46
57
  };
47
58
  }
@@ -163,7 +174,7 @@ function place(resvg, ch, family, font) {
163
174
  if (!bbox) return null;
164
175
  const notdef = inkBox(resvg, NOTDEF_PROBE, family, font);
165
176
  if (notdef && notdef.x === bbox.x && notdef.y === bbox.y && notdef.width === bbox.width && notdef.height === bbox.height) return null;
166
- const scale = Math.min(INK_H / bbox.height, INK_W / bbox.width);
177
+ const scale = Math.min(28 / bbox.height, 40 / bbox.width);
167
178
  const cx = bbox.x + bbox.width / 2 - PROBE / 2;
168
179
  const cy = bbox.y + bbox.height / 2 - PROBE * .7;
169
180
  return {
@@ -227,9 +238,11 @@ async function renderIcons(opts) {
227
238
  "apple-touch-icon.png": render(180, false),
228
239
  "icon.png": icon32,
229
240
  "favicon.ico": pngToIco(icon32, 32),
241
+ "icon-192.png": render(192, false),
242
+ "icon-512.png": render(512, false),
230
243
  letter: ch,
231
244
  family: placement?.family ?? null
232
245
  };
233
246
  }
234
247
  //#endregion
235
- export { GENERATED_ICONS, ICON_BG, ICON_FG, googleFontFetcher, hanFamily, iconFamily, pngToIco, publicFileCheck, renderIcons, resolveIcons };
248
+ export { GENERATED_ICONS, ICON_BG, ICON_FG, INK_H, INK_W, googleFontFetcher, hanFamily, iconFamily, pngToIco, publicFileCheck, renderIcons, resolveIcons };
@@ -0,0 +1,6 @@
1
+ import { u as RouteResolver } from "./pipeline-cCR6MGNK.js";
2
+ import { LlmsLink } from "@junejs/core/discovery";
3
+ //#region src/llms-links.d.ts
4
+ declare function collectLlmsLinks(origin: string, routes: string[], resolve: RouteResolver): Promise<LlmsLink[]>;
5
+ //#endregion
6
+ export { collectLlmsLinks };
@@ -0,0 +1,35 @@
1
+ //#region src/llms-links.ts
2
+ const DEFAULT_SECTION = "Pages";
3
+ const mdPath = (path) => path === "/" ? "/index.md" : `${path.replace(/\/+$/, "")}.md`;
4
+ async function collectLlmsLinks(origin, routes, resolve) {
5
+ const links = [];
6
+ const seen = /* @__PURE__ */ new Set();
7
+ for (const route of routes) {
8
+ const resolved = await resolve(route);
9
+ if (!resolved || !("def" in resolved)) continue;
10
+ const { def } = resolved;
11
+ if (def.llms === false) continue;
12
+ const dynamic = route.includes("[");
13
+ const declared = typeof def.llms === "function" ? await def.llms() : def.llms;
14
+ const entries = declared ? Array.isArray(declared) ? declared : [declared] : dynamic ? [] : [{}];
15
+ const meta = typeof def.metadata === "object" ? def.metadata : void 0;
16
+ for (const entry of entries) {
17
+ const path = entry.path ?? (dynamic ? void 0 : route);
18
+ if (!path) continue;
19
+ const url = origin + (def.md === false ? path : mdPath(path));
20
+ if (seen.has(url)) continue;
21
+ seen.add(url);
22
+ const own = path === route;
23
+ links.push({
24
+ title: entry.title ?? (own ? meta?.title : void 0) ?? path,
25
+ url,
26
+ description: entry.description ?? (own ? meta?.description : void 0),
27
+ section: entry.section ?? DEFAULT_SECTION,
28
+ optional: entry.optional === true
29
+ });
30
+ }
31
+ }
32
+ return links;
33
+ }
34
+ //#endregion
35
+ export { collectLlmsLinks };
package/dist/pipeline.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { iconLetter } from "./icon-letter.js";
2
+ import { collectLlmsLinks } from "./llms-links.js";
3
+ import { webManifest } from "./web-manifest.js";
2
4
  import { SEGMENT_HEADER, TITLE_HEADER, encodeTitle, negotiate } from "./negotiate.js";
3
5
  import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
4
6
  import { LoaderDataContext } from "@junejs/core/route";
@@ -171,7 +173,11 @@ function createPipeline(cfg) {
171
173
  }
172
174
  async function discovery(url) {
173
175
  switch (url.pathname) {
174
- case "/llms.txt": return text(llmsTxt(url.origin, await cfg.routeList(), agent, docConfig.site), "text/markdown; charset=utf-8");
176
+ case "/llms.txt": {
177
+ const routes = await cfg.routeList();
178
+ const links = await collectLlmsLinks(url.origin, routes, cfg.resolve);
179
+ return text(llmsTxt(url.origin, routes, agent, docConfig.site, links), "text/markdown; charset=utf-8");
180
+ }
175
181
  case "/robots.txt": return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
176
182
  case "/sitemap.xml": return text(sitemapXml(url.origin, await cfg.routeList(), cfg.i18n), "application/xml; charset=utf-8");
177
183
  case "/.well-known/api-catalog": return text(JSON.stringify(apiCatalog(url.origin, agent)), "application/linkset+json");
@@ -201,6 +207,7 @@ function createPipeline(cfg) {
201
207
  if (out) return out;
202
208
  }
203
209
  if (request.method === "GET" && !docConfig.site.icon && (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico")) return letterFavicon(docConfig.site.name);
210
+ if (request.method === "GET" && docConfig.icons?.generated && url.pathname === "/manifest.webmanifest") return text(webManifest(docConfig), "application/manifest+json", { headers: { "cache-control": "public, max-age=86400" } });
204
211
  let locale;
205
212
  let routeBase = url.pathname;
206
213
  if (cfg.i18n) {
@@ -0,0 +1,6 @@
1
+ import { DocumentConfig } from "@junejs/core/document";
2
+ //#region src/web-manifest.d.ts
3
+ declare function shortName(site: DocumentConfig["site"]): string | undefined;
4
+ declare function webManifest(config: DocumentConfig): string;
5
+ //#endregion
6
+ export { shortName, webManifest };
@@ -0,0 +1,46 @@
1
+ import { resolveThemeColor } from "@junejs/core/document";
2
+ //#region src/web-manifest.ts
3
+ function shortName(site) {
4
+ if (site.shortName) return site.shortName;
5
+ const name = site.name?.trim();
6
+ if (!name) return void 0;
7
+ return name.split(/\s+[—–|-]\s+|:\s+/)[0].trim() || name;
8
+ }
9
+ function webManifest(config) {
10
+ const base = config.basePath ?? "";
11
+ const theme = resolveThemeColor(config);
12
+ const color = typeof theme === "string" ? theme : void 0;
13
+ const manifest = {
14
+ name: config.site.name,
15
+ short_name: shortName(config.site),
16
+ description: config.site.description,
17
+ start_url: `${base}/`,
18
+ scope: `${base}/`,
19
+ display: "browser",
20
+ ...color ? {
21
+ theme_color: color,
22
+ background_color: color
23
+ } : {},
24
+ icons: [
25
+ {
26
+ src: `${base}/icon-192.png`,
27
+ sizes: "192x192",
28
+ type: "image/png"
29
+ },
30
+ {
31
+ src: `${base}/icon-512.png`,
32
+ sizes: "512x512",
33
+ type: "image/png"
34
+ },
35
+ {
36
+ src: `${base}/icon-512.png`,
37
+ sizes: "512x512",
38
+ type: "image/png",
39
+ purpose: "maskable"
40
+ }
41
+ ]
42
+ };
43
+ return JSON.stringify(manifest, null, 2) + "\n";
44
+ }
45
+ //#endregion
46
+ export { shortName, webManifest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.27",
3
+ "version": "1.0.0-dev.29",
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",
@@ -140,7 +140,7 @@
140
140
  "rolldown": "^1.1.0"
141
141
  },
142
142
  "peerDependencies": {
143
- "@junejs/core": ">=0.2.0-dev.47",
143
+ "@junejs/core": ">=0.2.0-dev.50",
144
144
  "react": "^19.2.0",
145
145
  "react-dom": "^19.2.0",
146
146
  "react-server-dom-webpack": "19.2.7",
package/src/build.ts CHANGED
@@ -631,6 +631,7 @@ ${doClass}`;
631
631
  // skips its file rather than failing the build.
632
632
  const extra: Array<[string, string]> = [
633
633
  ["/favicon.svg", "favicon.svg"],
634
+ ["/manifest.webmanifest", "manifest.webmanifest"],
634
635
  ["/llms.txt", "llms.txt"],
635
636
  ["/sitemap.xml", "sitemap.xml"],
636
637
  ];
package/src/favicon.ts CHANGED
@@ -32,12 +32,13 @@ export const ICON_FG = "#fbfbf8";
32
32
 
33
33
  // Filenames the icons are served at (from the app root), and the public/ files
34
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"] as const;
35
+ export const GENERATED_ICONS = ["apple-touch-icon.png", "icon.png", "favicon.ico", "icon-192.png", "icon-512.png"] as const;
36
36
  export type GeneratedIconFile = (typeof GENERATED_ICONS)[number];
37
37
  // The app's own main icon, in preference order: the first one present becomes
38
38
  // the page's favicon link (so none of them is ever silently unreferenced).
39
39
  const PRIMARY_ICON_FILES = ["favicon.svg", "icon.svg", "favicon.png", "icon.png", "favicon.ico"];
40
40
  const CUSTOM_ICON_FILES = [...PRIMARY_ICON_FILES, "apple-touch-icon.png"];
41
+ const MANIFEST_FILES = ["manifest.webmanifest", "manifest.json", "site.webmanifest"];
41
42
 
42
43
  // `inPublic` for resolveIcons over an app's public/ dir. A symlinked public/ is
43
44
  // ignored, as the build and dev server ignore it.
@@ -56,12 +57,28 @@ export function publicFileCheck(publicDir: string): (file: string) => boolean {
56
57
  // its own. Otherwise every icon file it has is linked: the first of
57
58
  // PRIMARY_ICON_FILES as the favicon (site.icon still wins in the document),
58
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.
59
65
  export function resolveIcons(
60
66
  site: Site,
61
67
  inPublic: (file: string) => boolean,
62
68
  ): { generate: boolean; icons: NonNullable<DocumentConfig["icons"]> } {
69
+ const ownManifest = MANIFEST_FILES.find(inPublic);
63
70
  const custom = Boolean(site.icon) || CUSTOM_ICON_FILES.some(inPublic);
64
- if (!custom) return { generate: true, icons: { png: "/icon.png", appleTouch: "/apple-touch-icon.png" } };
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
+ }
65
82
  const primary = PRIMARY_ICON_FILES.find(inPublic);
66
83
  return {
67
84
  generate: false,
@@ -69,6 +86,7 @@ export function resolveIcons(
69
86
  primary: primary ? `/${primary}` : undefined,
70
87
  png: inPublic("icon.png") ? "/icon.png" : undefined,
71
88
  appleTouch: inPublic("apple-touch-icon.png") ? "/apple-touch-icon.png" : undefined,
89
+ manifest: ownManifest ? `/${ownManifest}` : undefined,
72
90
  },
73
91
  };
74
92
  }
@@ -179,10 +197,12 @@ const escapeXml = (s: string) => s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(
179
197
  // Where the glyph goes on the 64×64 square: font size + origin, from its INK
180
198
  // box — not its em box — so every script sits optically centred at one size.
181
199
  // A probe render measures the ink; cap height and a CJK ideograph both come out
182
- // INK_H tall, and a wide glyph is held to INK_W.
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).
183
203
  const PROBE = 1000;
184
- const INK_H = 28;
185
- const INK_W = 40;
204
+ export const INK_H = 28;
205
+ export const INK_W = 40;
186
206
  type Placement = { family: string; font: Uint8Array; size: number; x: number; y: number };
187
207
 
188
208
  function inkBox(resvg: ResvgModule, ch: string, family: string, font: Uint8Array) {
@@ -248,6 +268,11 @@ export type GeneratedIcons = {
248
268
  "apple-touch-icon.png": Uint8Array<ArrayBuffer>;
249
269
  "icon.png": Uint8Array<ArrayBuffer>;
250
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>;
251
276
  // What was drawn, for the build log and tests: the character and its font
252
277
  // family, or family null when the square went out without one.
253
278
  letter: string;
@@ -292,6 +317,8 @@ export async function renderIcons(opts: {
292
317
  "apple-touch-icon.png": render(180, false),
293
318
  "icon.png": icon32,
294
319
  "favicon.ico": pngToIco(icon32, 32),
320
+ "icon-192.png": render(192, false),
321
+ "icon-512.png": render(512, false),
295
322
  letter: ch,
296
323
  family: placement?.family ?? null,
297
324
  };
@@ -0,0 +1,51 @@
1
+ // /llms.txt links, collected from the routes themselves. Each route may export
2
+ // `llms` (see LlmsEntry in @junejs/core/route): a static route defaults to one link
3
+ // under "Pages" built from its static metadata; a dynamic route — a [param]
4
+ // template an agent can't fetch — lists each real page it serves, or is skipped.
5
+ // Links point at the markdown projection (<path>.md) unless the route turned md off,
6
+ // so an agent reads the page rather than its HTML (llmstxt.org's recommendation).
7
+ import type { LlmsLink } from "@junejs/core/discovery";
8
+ import type { LlmsEntry } from "@junejs/core/route";
9
+
10
+ import type { RouteResolver } from "./pipeline";
11
+
12
+ const DEFAULT_SECTION = "Pages";
13
+
14
+ const mdPath = (path: string) => (path === "/" ? "/index.md" : `${path.replace(/\/+$/, "")}.md`);
15
+
16
+ export async function collectLlmsLinks(origin: string, routes: string[], resolve: RouteResolver): Promise<LlmsLink[]> {
17
+ const links: LlmsLink[] = [];
18
+ const seen = new Set<string>();
19
+ for (const route of routes) {
20
+ // A dynamic template resolves against its own pattern ("/docs/[slug]" matches
21
+ // /docs/:slug), which is all we need: the route definition, not its data.
22
+ const resolved = await resolve(route);
23
+ if (!resolved || !("def" in resolved)) continue; // resource routes have no page to list
24
+ const { def } = resolved;
25
+ if (def.llms === false) continue;
26
+
27
+ const dynamic = route.includes("[");
28
+ const declared = typeof def.llms === "function" ? await def.llms() : def.llms;
29
+ const entries: LlmsEntry[] = declared ? (Array.isArray(declared) ? declared : [declared]) : dynamic ? [] : [{}];
30
+ // Static metadata only: a metadata FUNCTION needs loader data we don't run here.
31
+ const meta = typeof def.metadata === "object" ? def.metadata : undefined;
32
+
33
+ for (const entry of entries) {
34
+ const path = entry.path ?? (dynamic ? undefined : route);
35
+ if (!path) continue; // a dynamic entry must name its page
36
+ const url = origin + (def.md === false ? path : mdPath(path));
37
+ // dedupe on the FINAL url: "/a" and "/a/" both render /a.md, so they're one page
38
+ if (seen.has(url)) continue;
39
+ seen.add(url);
40
+ const own = path === route; // the route's own page → its metadata describes it
41
+ links.push({
42
+ title: entry.title ?? (own ? meta?.title : undefined) ?? path,
43
+ url,
44
+ description: entry.description ?? (own ? meta?.description : undefined),
45
+ section: entry.section ?? DEFAULT_SECTION,
46
+ optional: entry.optional === true,
47
+ });
48
+ }
49
+ }
50
+ return links;
51
+ }
package/src/pipeline.ts CHANGED
@@ -50,6 +50,8 @@ import {
50
50
  import type { Resources } from "@junejs/core/resources";
51
51
 
52
52
  import { iconLetter } from "./icon-letter";
53
+ import { collectLlmsLinks } from "./llms-links";
54
+ import { webManifest } from "./web-manifest";
53
55
  import { negotiate, TITLE_HEADER, SEGMENT_HEADER, encodeTitle } from "./negotiate";
54
56
 
55
57
  // Minimal Cookie-header read for the locale negotiation chain (no dependency on
@@ -479,11 +481,12 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
479
481
 
480
482
  async function discovery(url: URL): Promise<Response | null> {
481
483
  switch (url.pathname) {
482
- case "/llms.txt":
483
- return text(
484
- llmsTxt(url.origin, await cfg.routeList(), agent, docConfig.site),
485
- "text/markdown; charset=utf-8",
486
- );
484
+ case "/llms.txt": {
485
+ const routes = await cfg.routeList();
486
+ // sections, descriptions, and Optional come from each route's `llms` export
487
+ const links = await collectLlmsLinks(url.origin, routes, cfg.resolve);
488
+ return text(llmsTxt(url.origin, routes, agent, docConfig.site, links), "text/markdown; charset=utf-8");
489
+ }
487
490
  case "/robots.txt":
488
491
  return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
489
492
  case "/sitemap.xml":
@@ -543,6 +546,12 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
543
546
  ) {
544
547
  return letterFavicon(docConfig.site.name);
545
548
  }
549
+ // --- generated web manifest (only beside June's generated icons) ------
550
+ if (request.method === "GET" && docConfig.icons?.generated && url.pathname === "/manifest.webmanifest") {
551
+ return text(webManifest(docConfig), "application/manifest+json", {
552
+ headers: { "cache-control": "public, max-age=86400" },
553
+ });
554
+ }
546
555
 
547
556
  // --- locale resolution (before routing) ------------------------------
548
557
  // host/path → locale, stripping the locale prefix off the front so the
@@ -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
+ }