@junejs/server 1.0.0-dev.27 → 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/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 };
package/dist/pipeline.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { iconLetter } from "./icon-letter.js";
2
+ import { webManifest } from "./web-manifest.js";
2
3
  import { SEGMENT_HEADER, TITLE_HEADER, encodeTitle, negotiate } from "./negotiate.js";
3
4
  import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
4
5
  import { LoaderDataContext } from "@junejs/core/route";
@@ -201,6 +202,7 @@ function createPipeline(cfg) {
201
202
  if (out) return out;
202
203
  }
203
204
  if (request.method === "GET" && !docConfig.site.icon && (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico")) return letterFavicon(docConfig.site.name);
205
+ 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
206
  let locale;
205
207
  let routeBase = url.pathname;
206
208
  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.28",
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.48",
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
  };
package/src/pipeline.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  import type { Resources } from "@junejs/core/resources";
51
51
 
52
52
  import { iconLetter } from "./icon-letter";
53
+ import { webManifest } from "./web-manifest";
53
54
  import { negotiate, TITLE_HEADER, SEGMENT_HEADER, encodeTitle } from "./negotiate";
54
55
 
55
56
  // Minimal Cookie-header read for the locale negotiation chain (no dependency on
@@ -543,6 +544,12 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
543
544
  ) {
544
545
  return letterFavicon(docConfig.site.name);
545
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
+ }
546
553
 
547
554
  // --- locale resolution (before routing) ------------------------------
548
555
  // 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
+ }