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