@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 +1 -0
- package/dist/favicon.d.ts +6 -2
- package/dist/favicon.js +18 -5
- package/dist/llms-links.d.ts +6 -0
- package/dist/llms-links.js +35 -0
- package/dist/pipeline.js +8 -1
- package/dist/web-manifest.d.ts +6 -0
- package/dist/web-manifest.js +46 -0
- package/package.json +2 -2
- package/src/build.ts +1 -0
- package/src/favicon.ts +32 -5
- package/src/llms-links.ts +51 -0
- package/src/pipeline.ts +14 -5
- package/src/web-manifest.ts +42 -0
package/dist/build.js
CHANGED
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(
|
|
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":
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
}
|