Skip to content

Commit fff2113

Browse files
TMHSDigitalclaude
andcommitted
Preload every module a page loads, so imports fetch in parallel
PAGE_MODULES in shared.mjs lists the module set of the home, entry, category and 404 pages, and baseHeadHtml emits a modulepreload for each, so search.js, cards.mjs, shared.mjs, views.js and owes.js no longer wait for a second and third round trip. data.js gets fetchpriority=high. A lib test walks the real import graph from each page's entry scripts and fails if a list drifts. Checked in Chromium: no page errors, no module fetched twice. Closes #154 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 56732f4 commit fff2113

6 files changed

Lines changed: 45 additions & 9 deletions

File tree

‎site/build.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { execFileSync } from "node:child_process";
1414
import fs from "node:fs";
1515
import path from "node:path";
1616
import { fileURLToPath } from "node:url";
17-
import { baseHeadHtml, commercialLabel, headAssetsHtml, esc, latestAllowedDate, OG_CARD_NAME, sponsorButtonHtml } from "./lib/shared.mjs";
17+
import { baseHeadHtml, commercialLabel, headAssetsHtml, esc, latestAllowedDate, OG_CARD_NAME, PAGE_MODULES, sponsorButtonHtml } from "./lib/shared.mjs";
1818
import { filmstripHtml, groupedEntriesHtml } from "./lib/cards.mjs";
1919
import { categoryPageHtml } from "./lib/category-page.mjs";
2020
import { filmstripPick, isAttributionLicence, previewCredit, thumbRelPath } from "./lib/thumbs.mjs";
@@ -210,7 +210,7 @@ function guideRowsHtml(guides, repo) {
210210

211211
function headMetaHtml(site, stats, generatedAt, hasCard) {
212212
return [
213-
baseHeadHtml({ site, title: site.title, description: site.tagline, url: site.siteUrl, hasCard, root: "./" }),
213+
baseHeadHtml({ site, title: site.title, description: site.tagline, url: site.siteUrl, hasCard, root: "./", modules: PAGE_MODULES.home }),
214214
`<meta name="generator" content="site/build.mjs ${esc(generatedAt)}" />`,
215215
`<meta name="catalog:entries" content="${stats.total}" />`,
216216
`<script type="application/ld+json">${scriptJson(homeJsonLd({ site, generatedAt, total: stats.total }))}</script>`,
@@ -275,6 +275,7 @@ function notFoundHtml(site) {
275275
</p>
276276
</main>
277277
<script src="${esc(base)}/data.js" defer></script>
278+
${PAGE_MODULES.notFound.filter((m) => m !== "not-found.js").map((m) => `<link rel="modulepreload" href="${esc(base)}/${m}" />`).join("\n ")}
278279
<script type="module" src="${esc(base)}/not-found.js"></script>
279280
</body>
280281
</html>

‎site/lib/category-page.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** One category's page: every entry in it, both views, no JavaScript needed to see everything. Pure. */
22
import { groupedEntriesHtml } from "./cards.mjs";
3-
import { baseHeadHtml, clipText, esc, PREVIEW_CREDIT_LINE, SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, sponsorButtonHtml } from "./shared.mjs";
3+
import { baseHeadHtml, clipText, esc, PREVIEW_CREDIT_LINE, SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, sponsorButtonHtml, PAGE_MODULES } from "./shared.mjs";
44

55
export function categoryPageUrl(site, cat) {
66
return `${String(site.siteUrl).replace(/\/+$/, "")}/category/${cat}/`;
@@ -24,7 +24,7 @@ export function categoryPageHtml({ cat, meta, entries, site, stamp, total, hasCa
2424
<meta name="viewport" content="width=device-width, initial-scale=1" />
2525
<title>${esc(clipText(title, SEO_TITLE_MAX))}</title>
2626
<meta name="description" content="${esc(description)}" />
27-
${baseHeadHtml({ site, title, description, url, hasCard, root: "../../" })}
27+
${baseHeadHtml({ site, title, description, url, hasCard, root: "../../", modules: PAGE_MODULES.category })}
2828
</head>
2929
<body>
3030
<a class="skip-link" href="#entry-grid">Skip to the list</a>

‎site/lib/entry-page.mjs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import {
1212
STATUS_NOTES,
1313
verifiedAge,
1414
sponsorButtonHtml,
15-
PREVIEW_CREDIT_LINE,
16-
} from "./shared.mjs";
15+
PREVIEW_CREDIT_LINE, PAGE_MODULES } from "./shared.mjs";
1716
import { thumbHtml } from "./cards.mjs";
1817
import { previewCredit } from "./thumbs.mjs";
1918

@@ -121,7 +120,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
121120
<title>${esc(seoTitle(entry.name, entry.license))}</title>
122121
<meta name="description" content="${esc(seoDescription(entry))}" />
123122
${deprecated ? '<meta name="robots" content="noindex" />' : ""}
124-
${baseHeadHtml({ site, title, description: desc, url, type: "article", hasCard, root: "../../" })}
123+
${baseHeadHtml({ site, title, description: desc, url, type: "article", hasCard, root: "../../", modules: PAGE_MODULES.entry })}
125124
<script type="application/ld+json">${scriptJson(jsonLd(entry, site))}</script>
126125
</head>
127126
<body>

‎site/lib/lib.test.mjs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,28 @@ has("cards: title level option", entryCardHtml(cardBase, { root: "", repo: "http
10741074

10751075
eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q: "" }, {}).q.length, MAX_QUERY);
10761076

1077+
/* modulepreload lists match the import graph ---------------------------- */
1078+
{
1079+
const { PAGE_MODULES } = await import("./shared.mjs");
1080+
const libDir = new URL("./", import.meta.url);
1081+
const publicDir = new URL("../public/", import.meta.url);
1082+
// dist name -> source file (build.mjs copies these four from site/lib).
1083+
const fromLib = { "search.js": "search.mjs", "owes.js": "owes.mjs", "cards.mjs": "cards.mjs", "shared.mjs": "shared.mjs" };
1084+
const src = (name) => nodeFs.readFileSync(fromLib[name] ? new URL(fromLib[name], libDir) : new URL(name, publicDir), "utf8");
1085+
const importsOf = (name) => [...src(name).matchAll(/^import[^;]*?from "\.\/([^"]+)";/gm)].map((m) => m[1]);
1086+
const closure = (roots) => {
1087+
const seen = new Set();
1088+
const walk = (n) => { if (seen.has(n)) return; seen.add(n); importsOf(n).forEach(walk); };
1089+
roots.forEach(walk);
1090+
return [...seen].sort();
1091+
};
1092+
const roots = { home: ["app.js", "shortlist.js"], entry: ["shortlist.js"], category: ["category.js", "shortlist.js"], notFound: ["not-found.js"] };
1093+
for (const [page, list] of Object.entries(roots)) {
1094+
eq(`modulepreload: ${page} list matches its imports`, [...PAGE_MODULES[page]].sort().join(","), closure(list).join(","));
1095+
}
1096+
has("modulepreload: head emits the links", baseHeadHtml({ site: { siteUrl: "https://s/", title: "T", tagline: "x" }, title: "T", description: "d", url: "https://s/", root: "../../", modules: ["a.js"] }), '<link rel="modulepreload" href="../../a.js" />');
1097+
}
1098+
10771099
/* report (keep last) ------------------------------------------------------ */
10781100
if (failures.length) {
10791101
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/shared.mjs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ export const PREVIEW_CREDIT_LINE = "Preview images belong to their publishers an
198198
/** Self-hosted fonts (site/public/fonts/, OFL). The body face and the mono face are preloaded; the italic is not. */
199199
export const FONT_FILES = ["fonts/public-sans.woff2", "fonts/ibm-plex-mono-400.woff2"];
200200

201+
/**
202+
* Every ES module each page type loads, entry scripts and their imports, as
203+
* served from dist. The head preloads the whole set so the browser fetches
204+
* them in parallel instead of discovering each import one round trip later.
205+
* lib.test.mjs walks the real import graph and fails if a list drifts.
206+
*/
207+
export const PAGE_MODULES = {
208+
home: ["app.js", "search.js", "cards.mjs", "shared.mjs", "views.js", "shortlist.js", "owes.js"],
209+
entry: ["shortlist.js", "owes.js"],
210+
category: ["category.js", "views.js", "shortlist.js", "owes.js"],
211+
notFound: ["not-found.js", "search.js"],
212+
};
213+
201214
/** Icon, font preloads and stylesheet, resolved against `root`. Every page type shares them. */
202215
export function headAssetsHtml(root = "./") {
203216
return [
@@ -218,7 +231,7 @@ export const OG_CARD_NAME = "og-card.png";
218231
* page's path back to the site root ("./", "../", "../../"): the icon, the
219232
* self-hosted font preloads and the stylesheet are resolved against it.
220233
*/
221-
export function baseHeadHtml({ site, title, description, url, type = "website", hasCard, indent = " ", root = "./" }) {
234+
export function baseHeadHtml({ site, title, description, url, type = "website", hasCard, indent = " ", root = "./", modules = [] }) {
222235
const base = String(site.siteUrl).replace(/\/+$/, "");
223236
const card = `${base}/${OG_CARD_NAME}`;
224237
const cardMeta = hasCard
@@ -246,6 +259,7 @@ export function baseHeadHtml({ site, title, description, url, type = "website",
246259
`<meta property="og:description" content="${esc(description)}" />`,
247260
`<meta property="og:url" content="${esc(url)}" />`,
248261
...cardMeta,
262+
...modules.map((m) => `<link rel="modulepreload" href="${esc(root)}${esc(m)}" />`),
249263
`<meta name="twitter:title" content="${esc(title)}" />`,
250264
`<meta name="twitter:description" content="${esc(description)}" />`,
251265
...headAssetsHtml(root),

‎site/public/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ <h2 id="guides-title">Guides</h2>
233233
<span aria-hidden="true">↑</span> Search
234234
</button>
235235

236-
<script src="./data.js" defer></script>
236+
<script src="./data.js" defer fetchpriority="high"></script>
237237
<script type="module" src="./app.js"></script>
238238
<script type="module" src="./shortlist.js"></script>
239239
</body>

0 commit comments

Comments
 (0)