@junejs/server 1.0.0-dev.25 → 1.0.0-dev.27
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/assets/icon-latin.LICENSE.txt +92 -0
- package/assets/icon-latin.ttf +0 -0
- package/dist/app.js +22 -2
- package/dist/build.js +19 -3
- package/dist/config-freeze.js +5 -2
- package/dist/content.d.ts +16 -2
- package/dist/content.js +43 -3
- package/dist/favicon.d.ts +34 -0
- package/dist/favicon.js +235 -0
- package/dist/icon-letter.d.ts +4 -0
- package/dist/icon-letter.js +8 -0
- package/dist/pipeline.js +16 -3
- package/dist/rsc-runtime/rsc-worker.js +11 -8
- package/package.json +5 -3
- package/src/app.ts +39 -0
- package/src/build.ts +24 -3
- package/src/config-freeze.ts +6 -1
- package/src/content.ts +55 -4
- package/src/favicon.ts +298 -0
- package/src/icon-letter.ts +14 -0
- package/src/pipeline.ts +30 -4
- package/src/rsc-runtime/rsc-worker.tsx +5 -4
package/dist/pipeline.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { iconLetter } from "./icon-letter.js";
|
|
1
2
|
import { SEGMENT_HEADER, TITLE_HEADER, encodeTitle, negotiate } from "./negotiate.js";
|
|
2
3
|
import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
|
|
3
4
|
import { LoaderDataContext } from "@junejs/core/route";
|
|
@@ -34,8 +35,9 @@ function StreamedView({ loadPromise, def, ctx }) {
|
|
|
34
35
|
return provideLoaderData(data, def.view ? def.view(data, ctx) : null);
|
|
35
36
|
}
|
|
36
37
|
function letterFavicon(siteName) {
|
|
37
|
-
return text(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#1d1d1f"/><text x="32" y="32" dy=".36em" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="34" font-weight="600" fill="#fbfbf8">${((siteName
|
|
38
|
+
return text(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#1d1d1f"/><text x="32" y="32" dy=".36em" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="34" font-weight="600" fill="#fbfbf8">${(iconLetter(siteName) || "•").replace(/[<>&"']/g, "") || "•"}</text></svg>\n`, "image/svg+xml", { headers: { "cache-control": "public, max-age=86400" } });
|
|
38
39
|
}
|
|
40
|
+
const routePathOf = /* @__PURE__ */ new WeakMap();
|
|
39
41
|
function createPipeline(cfg) {
|
|
40
42
|
const { docConfig, agent } = cfg;
|
|
41
43
|
const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
|
|
@@ -62,7 +64,15 @@ function createPipeline(cfg) {
|
|
|
62
64
|
protocol: ctx.url.protocol.replace(":", "")
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
|
-
|
|
67
|
+
function pageProps(ctx) {
|
|
68
|
+
const host = ctx.url.hostname.toLowerCase();
|
|
69
|
+
return {
|
|
70
|
+
pageUrl: ctx.url.href,
|
|
71
|
+
isHome: routePathOf.get(ctx) === "/",
|
|
72
|
+
onLocaleDomain: cfg.i18n ? Object.values(cfg.i18n.locales).some((l) => l.domain?.toLowerCase() === host) : false
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function renderDocument(node, metadata, status, chain, locale, boundaryKey, alternates, page) {
|
|
66
76
|
const wrapped = chain.reduceRight((acc, L) => React.createElement(L, null, acc), node);
|
|
67
77
|
const stream = await renderToReadableStream(React.createElement(Document, {
|
|
68
78
|
config: docConfigForRender(),
|
|
@@ -70,6 +80,7 @@ function createPipeline(cfg) {
|
|
|
70
80
|
children: wrapped,
|
|
71
81
|
shellKey: boundaryKey,
|
|
72
82
|
alternates,
|
|
83
|
+
...page,
|
|
73
84
|
...langDir(locale)
|
|
74
85
|
}));
|
|
75
86
|
await stream.allReady;
|
|
@@ -115,6 +126,7 @@ function createPipeline(cfg) {
|
|
|
115
126
|
children: wrapped,
|
|
116
127
|
shellKey: boundaryKey,
|
|
117
128
|
alternates: alternatesFor(ctx),
|
|
129
|
+
...pageProps(ctx),
|
|
118
130
|
...langDir(ctx.locale)
|
|
119
131
|
}), { onError: (e) => console.error("[june] streaming render error:", e) });
|
|
120
132
|
return new Response(stream, {
|
|
@@ -155,7 +167,7 @@ function createPipeline(cfg) {
|
|
|
155
167
|
const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
|
|
156
168
|
const meta = resolveMeta(def, data, ctx);
|
|
157
169
|
if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
|
|
158
|
-
return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx));
|
|
170
|
+
return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx), pageProps(ctx));
|
|
159
171
|
}
|
|
160
172
|
async function discovery(url) {
|
|
161
173
|
switch (url.pathname) {
|
|
@@ -219,6 +231,7 @@ function createPipeline(cfg) {
|
|
|
219
231
|
locale,
|
|
220
232
|
speculative
|
|
221
233
|
};
|
|
234
|
+
routePathOf.set(ctx, pathname);
|
|
222
235
|
if ("handler" in resolved) return resolved.handler(request, ctx);
|
|
223
236
|
if (target === "view" && resolved.loading && typeof resolved.def.metadata !== "function") return renderStreamingDocument(resolved, Promise.resolve(resolved.def.load ? resolved.def.load(ctx) : void 0), ctx);
|
|
224
237
|
let data;
|
|
@@ -21,24 +21,27 @@ async function streamToString(stream) {
|
|
|
21
21
|
out += dec.decode();
|
|
22
22
|
return out;
|
|
23
23
|
}
|
|
24
|
-
async function renderDocument(
|
|
24
|
+
async function renderDocument(url) {
|
|
25
|
+
const path = url.pathname;
|
|
25
26
|
const flight = await (await import(new URL("./server.js", import.meta.url).href)).renderFlight(path);
|
|
26
27
|
if (flight === null) return null;
|
|
27
28
|
const flightStream = new Response(flight).body;
|
|
29
|
+
const tree = createFromReadableStream(flightStream, { serverConsumerManifest: {
|
|
30
|
+
moduleMap: MODULE_MAP,
|
|
31
|
+
serverModuleMap: null,
|
|
32
|
+
moduleLoading: null
|
|
33
|
+
} });
|
|
28
34
|
const html = await renderToReadableStream(/* @__PURE__ */ jsx(Document, {
|
|
29
35
|
config: DOC_CONFIG,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
moduleLoading: null
|
|
34
|
-
} }) })
|
|
36
|
+
pageUrl: url.href,
|
|
37
|
+
isHome: path === "/",
|
|
38
|
+
children: /* @__PURE__ */ jsx(Root, { tree })
|
|
35
39
|
}));
|
|
36
40
|
if (typeof html.allReady?.then === "function") await html.allReady;
|
|
37
41
|
return "<!DOCTYPE html>" + await streamToString(html);
|
|
38
42
|
}
|
|
39
43
|
var rsc_worker_default = { async fetch(request) {
|
|
40
|
-
const
|
|
41
|
-
const body = await renderDocument(path);
|
|
44
|
+
const body = await renderDocument(new URL(request.url));
|
|
42
45
|
if (body === null) return new Response("Not Found", { status: 404 });
|
|
43
46
|
return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
44
47
|
} };
|
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.27",
|
|
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",
|
|
@@ -123,7 +123,8 @@
|
|
|
123
123
|
},
|
|
124
124
|
"files": [
|
|
125
125
|
"src",
|
|
126
|
-
"dist"
|
|
126
|
+
"dist",
|
|
127
|
+
"assets"
|
|
127
128
|
],
|
|
128
129
|
"scripts": {
|
|
129
130
|
"test": "bun test",
|
|
@@ -133,12 +134,13 @@
|
|
|
133
134
|
"dependencies": {
|
|
134
135
|
"@junejs/db": ">=0.1.0-dev.2",
|
|
135
136
|
"@momiji-rs/sparkdown": "^0.0.4",
|
|
137
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
136
138
|
"lightningcss": "^1.27.0",
|
|
137
139
|
"oxc-parser": "^0.137.0",
|
|
138
140
|
"rolldown": "^1.1.0"
|
|
139
141
|
},
|
|
140
142
|
"peerDependencies": {
|
|
141
|
-
"@junejs/core": ">=0.2.0-dev.
|
|
143
|
+
"@junejs/core": ">=0.2.0-dev.47",
|
|
142
144
|
"react": "^19.2.0",
|
|
143
145
|
"react-dom": "^19.2.0",
|
|
144
146
|
"react-server-dom-webpack": "19.2.7",
|
package/src/app.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ComponentType } from "react";
|
|
|
18
18
|
import { routeFromModule } from "@junejs/core/route";
|
|
19
19
|
import {
|
|
20
20
|
resolveAgent,
|
|
21
|
+
deployOrigin,
|
|
21
22
|
resolveClientRouter,
|
|
22
23
|
resolveSpeculationRules,
|
|
23
24
|
type JuneConfig,
|
|
@@ -42,6 +43,15 @@ import { generateIslandRegistry } from "./island-registry";
|
|
|
42
43
|
import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
|
|
43
44
|
import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
|
|
44
45
|
import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
|
|
46
|
+
import {
|
|
47
|
+
GENERATED_ICONS,
|
|
48
|
+
googleFontFetcher,
|
|
49
|
+
publicFileCheck,
|
|
50
|
+
renderIcons,
|
|
51
|
+
resolveIcons,
|
|
52
|
+
type GeneratedIconFile,
|
|
53
|
+
type GeneratedIcons,
|
|
54
|
+
} from "./favicon";
|
|
45
55
|
|
|
46
56
|
export type CreateAppOptions = {
|
|
47
57
|
appDir: string;
|
|
@@ -132,6 +142,19 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
132
142
|
} catch {
|
|
133
143
|
/* no public/ dir → nothing to serve */
|
|
134
144
|
}
|
|
145
|
+
// Default PNG icons: linked by the document, rendered once on first request
|
|
146
|
+
// (a failed render is retried next time rather than memoized).
|
|
147
|
+
const iconPlan = resolveIcons(config.site ?? {}, publicFileCheck(publicDir));
|
|
148
|
+
let iconsPromise: Promise<GeneratedIcons> | undefined;
|
|
149
|
+
const getIcons = () =>
|
|
150
|
+
(iconsPromise ??= renderIcons({
|
|
151
|
+
site: config.site ?? {},
|
|
152
|
+
fetchFont: googleFontFetcher(join(dirname(appDir), "node_modules", ".cache", "june", "fonts")),
|
|
153
|
+
warn: (msg) => console.warn(msg),
|
|
154
|
+
}).catch((err) => {
|
|
155
|
+
iconsPromise = undefined;
|
|
156
|
+
throw err;
|
|
157
|
+
}));
|
|
135
158
|
const agent = resolveAgent(config.agent);
|
|
136
159
|
const speculation = config.speculation;
|
|
137
160
|
// app/_client.* present → the dev document loads /client.js and we serve it
|
|
@@ -143,6 +166,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
143
166
|
const cssEntry = findGlobalCss(appDir);
|
|
144
167
|
const docConfig: DocumentConfig = {
|
|
145
168
|
site: config.site ?? {},
|
|
169
|
+
deployOrigin: deployOrigin(config),
|
|
146
170
|
speculationRules: resolveSpeculationRules(speculation ?? undefined),
|
|
147
171
|
speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
|
|
148
172
|
viewTransitions: config.viewTransitions ?? true,
|
|
@@ -151,6 +175,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
151
175
|
clientRouter: resolveClientRouter(config.clientRouter),
|
|
152
176
|
clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
|
|
153
177
|
styles: cssEntry ? STYLES_URL : null,
|
|
178
|
+
icons: iconPlan.icons,
|
|
154
179
|
};
|
|
155
180
|
|
|
156
181
|
// CSS Modules: glob + transform app/**/*.module.css ONCE (memoized) → the maps
|
|
@@ -401,6 +426,20 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
401
426
|
}
|
|
402
427
|
}
|
|
403
428
|
}
|
|
429
|
+
// June's default PNG icons (the app has none of its own): rendered on first
|
|
430
|
+
// request with the same code `june build` writes them with.
|
|
431
|
+
if (iconPlan.generate && (request.method === "GET" || request.method === "HEAD")) {
|
|
432
|
+
const name = new URL(request.url).pathname.slice(1);
|
|
433
|
+
if ((GENERATED_ICONS as readonly string[]).includes(name)) {
|
|
434
|
+
return getIcons().then(
|
|
435
|
+
(icons) =>
|
|
436
|
+
new Response(request.method === "HEAD" ? null : icons[name as GeneratedIconFile], {
|
|
437
|
+
headers: { "content-type": contentTypeFor(name), "cache-control": "public, max-age=0, must-revalidate" },
|
|
438
|
+
}),
|
|
439
|
+
() => toPipeline(),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
404
443
|
return toPipeline();
|
|
405
444
|
},
|
|
406
445
|
async warmup() {
|
package/src/build.ts
CHANGED
|
@@ -27,12 +27,14 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
|
27
27
|
|
|
28
28
|
import { loadJuneConfig } from "./config-loader";
|
|
29
29
|
import { buildLinkHeader } from "@junejs/core/discovery";
|
|
30
|
+
import { PRERENDER_ORIGIN } from "@junejs/core/document";
|
|
30
31
|
import { localeHref } from "@junejs/core/i18n";
|
|
31
32
|
import type { BrandedRoute } from "@junejs/core/route";
|
|
32
33
|
import type { ResourcePlan } from "./adapter";
|
|
33
34
|
import { generateAgentModule } from "./agent-compile";
|
|
34
35
|
import { freezeConfig, resolveDeployAdapter, workerName } from "./config-freeze";
|
|
35
36
|
import { generateContent } from "./content-freeze";
|
|
37
|
+
import { GENERATED_ICONS, googleFontFetcher, publicFileCheck, renderIcons, resolveIcons } from "./favicon";
|
|
36
38
|
import { createWorker } from "./worker";
|
|
37
39
|
import { buildManifest, importLayout, type ImportedLayout } from "./manifest";
|
|
38
40
|
import { findMiddlewareFile } from "./router";
|
|
@@ -542,6 +544,25 @@ ${doClass}`;
|
|
|
542
544
|
if (publicFiles.length) hasAssets = true;
|
|
543
545
|
}
|
|
544
546
|
|
|
547
|
+
// ---- default PNG icons: apple-touch-icon.png, icon.png, favicon.ico -----
|
|
548
|
+
// Only when the app brings no icon of its own (the frozen document already
|
|
549
|
+
// links them — same resolveIcons call). Listed with publicFiles so adapters
|
|
550
|
+
// with a separate static tier (vercel) place them like any public/ file.
|
|
551
|
+
const site = frozen.document.site;
|
|
552
|
+
if (resolveIcons(site, publicFileCheck(publicDir)).generate) {
|
|
553
|
+
const icons = await renderIcons({
|
|
554
|
+
site,
|
|
555
|
+
fetchFont: googleFontFetcher(join(appRoot, "node_modules", ".cache", "june", "fonts")),
|
|
556
|
+
warn: (msg) => console.warn(msg),
|
|
557
|
+
});
|
|
558
|
+
await mkdir(assetsDir, { recursive: true }); // an app with no public/ has none yet
|
|
559
|
+
for (const file of GENERATED_ICONS) {
|
|
560
|
+
await writeFile(join(assetsDir, file), icons[file]);
|
|
561
|
+
publicFiles.push(file);
|
|
562
|
+
}
|
|
563
|
+
hasAssets = true;
|
|
564
|
+
}
|
|
565
|
+
|
|
545
566
|
// static() target: prerender EVERY route (not just opted-in ones) + enumerate
|
|
546
567
|
// dynamic routes via their staticPaths, and write <stem>/index.html so clean URLs
|
|
547
568
|
// resolve on a dumb file host with no rewrite server. Other targets keep the
|
|
@@ -571,7 +592,7 @@ ${doClass}`;
|
|
|
571
592
|
if (def.md !== false) targets.push([mdReq, mdFile]); // .md/.json stay flat (exact-path negotiation)
|
|
572
593
|
if (typeof def.json === "function") targets.push([jsonReq, jsonFile]);
|
|
573
594
|
for (const [rp, file] of targets) {
|
|
574
|
-
const res = await worker.fetch(new Request(
|
|
595
|
+
const res = await worker.fetch(new Request(`${PRERENDER_ORIGIN}${rp}`));
|
|
575
596
|
if (!res.ok) throw new Error(`prerender ${rp} → ${res.status}`);
|
|
576
597
|
const dest = join(assetsDir, file);
|
|
577
598
|
await mkdir(dirname(dest), { recursive: true });
|
|
@@ -614,14 +635,14 @@ ${doClass}`;
|
|
|
614
635
|
["/sitemap.xml", "sitemap.xml"],
|
|
615
636
|
];
|
|
616
637
|
for (const [reqPath, file] of extra) {
|
|
617
|
-
const res = await worker.fetch(new Request(
|
|
638
|
+
const res = await worker.fetch(new Request(`${PRERENDER_ORIGIN}${reqPath}`));
|
|
618
639
|
if (!res.ok) continue;
|
|
619
640
|
await writeFile(join(assetsDir, file), Buffer.from(await res.arrayBuffer()));
|
|
620
641
|
hasAssets = true;
|
|
621
642
|
}
|
|
622
643
|
// 404.html — GitHub Pages serves it for any unmatched URL. A deliberately-missing
|
|
623
644
|
// path renders June's not-found HTML (body is written regardless of the 404 status).
|
|
624
|
-
const nf = await worker.fetch(new Request(
|
|
645
|
+
const nf = await worker.fetch(new Request(`${PRERENDER_ORIGIN}/__june_not_found__`));
|
|
625
646
|
await writeFile(join(assetsDir, "404.html"), Buffer.from(await nf.arrayBuffer()));
|
|
626
647
|
hasAssets = true;
|
|
627
648
|
}
|
package/src/config-freeze.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
|
-
import { resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
|
|
9
|
+
import { deployOrigin, resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
|
|
10
10
|
import type { JuneConfig } from "@junejs/core/config";
|
|
11
11
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
12
12
|
import { workers, vercel, deno, staticSite, type JuneAdapter } from "./adapter";
|
|
13
13
|
import { findClientEntry, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
14
14
|
import { loadJuneConfig } from "./config-loader";
|
|
15
|
+
import { publicFileCheck, resolveIcons } from "./favicon";
|
|
15
16
|
import { findGlobalCss, globalCssUsesTailwind, STYLES_URL } from "./css";
|
|
16
17
|
import type { WorkerManifest } from "./worker";
|
|
17
18
|
|
|
@@ -56,6 +57,7 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
56
57
|
return {
|
|
57
58
|
document: {
|
|
58
59
|
site: cfg.site ?? {},
|
|
60
|
+
deployOrigin: deployOrigin(cfg),
|
|
59
61
|
speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
|
|
60
62
|
speculationDelivery: "inline",
|
|
61
63
|
viewTransitions: cfg.viewTransitions ?? true,
|
|
@@ -68,6 +70,9 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
68
70
|
// asset URLs; the prerender path re-freezes through buildManifest and gets the
|
|
69
71
|
// same value, keeping static pages' asset links correct under the subpath.
|
|
70
72
|
basePath: normalizeBase(cfg.basePath),
|
|
73
|
+
// PNG icon links: June's generated set unless public/ or site.icon brings one
|
|
74
|
+
// (juneBuild writes the files; resolveIcons makes the same call there).
|
|
75
|
+
icons: resolveIcons(cfg.site ?? {}, publicFileCheck(join(appRoot, "public"))).icons,
|
|
71
76
|
},
|
|
72
77
|
agent: resolveAgent(cfg.agent),
|
|
73
78
|
// Pass i18n through as-is: the in-process buildManifest keeps a resolveLocale
|
package/src/content.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { toHtmlSync, initSync } from "@momiji-rs/sparkdown/gfm";
|
|
15
|
+
import { createSlugger } from "@junejs/core/slug";
|
|
15
16
|
|
|
16
17
|
import type { ContentSource } from "@junejs/core/config";
|
|
17
18
|
|
|
@@ -29,6 +30,48 @@ const ensureWasm = () => {
|
|
|
29
30
|
}
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
export type Heading = { depth: number; text: string; id: string };
|
|
34
|
+
|
|
35
|
+
// &-entities the renderer emits in heading text, decoded so the text (and so the
|
|
36
|
+
// slug) is what a reader sees: "Q&A" slugs as "Q&A" → "qa", as on GitHub.
|
|
37
|
+
const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
38
|
+
const decodeEntities = (s: string) =>
|
|
39
|
+
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) =>
|
|
40
|
+
e[0] === "#"
|
|
41
|
+
? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1)))
|
|
42
|
+
: (NAMED[e.toLowerCase()] ?? m),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Give every heading in rendered HTML a GitHub-compatible id — one slugger per
|
|
46
|
+
// document, in order, so repeats de-duplicate exactly as GitHub's do — and collect
|
|
47
|
+
// them. Markdown headings render bare (`<h2>…</h2>`) and get only an id. Raw HTML an
|
|
48
|
+
// author wrote is preserved by CommonMark, so a heading may arrive with attributes:
|
|
49
|
+
// its attributes are kept, an authored id is REUSED (the author's explicit anchor),
|
|
50
|
+
// and a generated id never repeats any id already in the document — authored ones
|
|
51
|
+
// included — so every heading's anchor is unique.
|
|
52
|
+
const ID_ATTR = /\sid\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
|
|
53
|
+
export function headingIds(html: string): { html: string; headings: Heading[] } {
|
|
54
|
+
const slug = createSlugger();
|
|
55
|
+
const taken = new Set<string>();
|
|
56
|
+
for (const m of html.matchAll(new RegExp(ID_ATTR.source, "gi"))) taken.add((m[1] ?? m[2])!);
|
|
57
|
+
const headings: Heading[] = [];
|
|
58
|
+
const out = html.replace(/<h([1-6])(\s[^>]*)?>([\s\S]*?)<\/h\1>/gi, (_m, depth: string, attrs: string | undefined, inner: string) => {
|
|
59
|
+
const text = decodeEntities(inner.replace(/<[^>]*>/g, "")).trim();
|
|
60
|
+
const own = attrs ? ID_ATTR.exec(attrs) : null;
|
|
61
|
+
const authored = own ? (own[1] ?? own[2]) : undefined;
|
|
62
|
+
let id = authored;
|
|
63
|
+
if (!id) {
|
|
64
|
+
do id = slug(text);
|
|
65
|
+
while (taken.has(id)); // the slugger suffixes each repeat: -1, -2, …
|
|
66
|
+
taken.add(id);
|
|
67
|
+
}
|
|
68
|
+
headings.push({ depth: Number(depth), text, id });
|
|
69
|
+
// an authored-but-empty id="" is no anchor: drop it, so the heading carries exactly one id
|
|
70
|
+
return authored ? _m : `<h${depth}${(attrs ?? "").replace(ID_ATTR, "")} id="${id}">${inner}</h${depth}>`;
|
|
71
|
+
});
|
|
72
|
+
return { html: out, headings };
|
|
73
|
+
}
|
|
74
|
+
|
|
32
75
|
export type ContentEntry = {
|
|
33
76
|
slug: string;
|
|
34
77
|
file: string;
|
|
@@ -38,8 +81,13 @@ export type ContentEntry = {
|
|
|
38
81
|
body: string;
|
|
39
82
|
/** The authored file, verbatim — the agent-facing .md projection. */
|
|
40
83
|
original: string;
|
|
41
|
-
/** The body rendered to HTML (CommonMark + GFM, via @momiji-rs/sparkdown/gfm wasm).
|
|
84
|
+
/** The body rendered to HTML (CommonMark + GFM, via @momiji-rs/sparkdown/gfm wasm).
|
|
85
|
+
* Every heading carries a GitHub-compatible `id` (see @junejs/core/slug) and nothing
|
|
86
|
+
* else — no injected links or classes; how an anchor looks is the app's call. */
|
|
42
87
|
html: string;
|
|
88
|
+
/** The headings of `html`, in document order: their depth (1–6), plain text, and the
|
|
89
|
+
* `id` each carries in `html` — for a table of contents, or a structured projection. */
|
|
90
|
+
headings: Heading[];
|
|
43
91
|
/** The locale this entry was authored in — set for files under a `<locale>/`
|
|
44
92
|
* subdir, undefined for the flat (default-locale) files. */
|
|
45
93
|
locale?: string;
|
|
@@ -96,13 +144,15 @@ function loadEntry(file: string, slug: string, locale?: string): ContentEntry {
|
|
|
96
144
|
if (h1?.[1]) data.title = h1[1];
|
|
97
145
|
}
|
|
98
146
|
ensureWasm(); // sync, idempotent — instantiate the renderer wasm on first use only
|
|
147
|
+
const { html, headings } = headingIds(toHtmlSync(body));
|
|
99
148
|
const entry: ContentEntry = {
|
|
100
149
|
slug,
|
|
101
150
|
file,
|
|
102
151
|
data,
|
|
103
152
|
body,
|
|
104
153
|
original,
|
|
105
|
-
html
|
|
154
|
+
html,
|
|
155
|
+
headings,
|
|
106
156
|
...(locale ? { locale } : {}),
|
|
107
157
|
};
|
|
108
158
|
memo.set(key, { mtime, entry });
|
|
@@ -287,6 +337,7 @@ export function generateContentModule(
|
|
|
287
337
|
body: e.body,
|
|
288
338
|
original: e.original,
|
|
289
339
|
html: e.html,
|
|
340
|
+
headings: e.headings,
|
|
290
341
|
...(e.locale ? { locale: e.locale } : {}),
|
|
291
342
|
});
|
|
292
343
|
// Default scan: each `content/<name>/` is a collection. Guarded — a sources-only app
|
|
@@ -340,8 +391,8 @@ export function generateContentModule(
|
|
|
340
391
|
names.push(name);
|
|
341
392
|
}
|
|
342
393
|
const entryType = anyLocale
|
|
343
|
-
? "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; locale?: string };\n"
|
|
344
|
-
: "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string };\n";
|
|
394
|
+
? "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; headings: Array<{ depth: number; text: string; id: string }>; locale?: string };\n"
|
|
395
|
+
: "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; headings: Array<{ depth: number; text: string; id: string }> };\n";
|
|
345
396
|
const code =
|
|
346
397
|
"// AUTO-GENERATED by `june build` — edit content/**/*.md, not this file.\n" + entryType + body;
|
|
347
398
|
return { code, names };
|