Cannabis Freedom Index

Changelog 355dc48tree, with Claude Opus 5.5

Lighter pages, faster builds: shared scripts, search icons on demand

Every page carried about 27 KB of identical inline JavaScript and the 38 search-result icons (19 KB) in a template. The layout's six page scripts (search, hover previews, citation cards, glossary terms, source notes, sticky panes) are now bundled into shared, cached files, and the search icons come from /search-icons.json with the index on first use. A history page drops from 70.8 KB to 29.6 KB and the site from 958 MB to 728 MB, which shortens the Pages upload and deploy. The theme script stays inline (it must run before the first paint). Pages render four at a time (build.concurrency; BUILD_CONCURRENCY overrides): 43 s instead of 55 s for 6,142 pages locally; eight was slower (91 s). Checked in a browser on the built site: search (results and icons), hover previews and citation cards work, with no page errors.

3 files of code, design or docs +32 −13 lines

Code, design and documents 3 files

astro.config.mjs +3 −0
@@ -157,4 +157,7 @@ export default defineConfig({
   // pictures in Markdown get a set of widths, so a phone does not download the 1280-pixel file
   image: { layout: "constrained" },
+  // some 6,500 pages are rendered four at a time instead of one by one (BUILD_CONCURRENCY overrides;
+  // the deploy gives the build an 8 GB heap for it)
+  build: { concurrency: Number(process.env.BUILD_CONCURRENCY) || 4 },
   // A build keeps its own Vite cache. Sharing the dev server's would wipe the dependencies the
   // running dev server has prepared (Fuse, nostr-tools), and its lazy imports would then fail

src/layouts/Base.astro +12 −13
@@ -8,5 +8,5 @@ import fontDisplayItalic from "@fontsource/instrument-serif/files/instrument-ser
 import Leaf from "../components/Leaf.astro";
 import { Icon } from "astro-icon/components";
-import { SEARCH_ICONS, RESULT_KIND_ICONS } from "../lib/search-icons.js";
+import { RESULT_KIND_ICONS } from "../lib/search-icons.js";
 import { build } from "../lib/build-info.js";
 
@@ -120,8 +120,7 @@ const schemas = jsonld ? [jsonld].flat() : [];
     <ol class="search-results" id="searchResults" role="listbox"></ol>
     <p class="search-none" id="searchNone" hidden></p>
-    {/* Every icon the results use (lib/search-icons.js), copied by the script below. */}
-    <template id="searchIcons" data-kinds={JSON.stringify(RESULT_KIND_ICONS)}>
-      {SEARCH_ICONS.map((name) => <Icon name={name} class="ico" aria-hidden="true" data-k={name} />)}
-    </template>
+    {/* The icons the results use (lib/search-icons.js) come from /search-icons.json, fetched with
+        the index on first use rather than carried by every page. */}
+    <template id="searchIcons" data-kinds={JSON.stringify(RESULT_KIND_ICONS)} data-src={`${base}search-icons.json`}></template>
     <p class="search-hint" id="searchHint"><span id="searchCount" aria-live="polite"></span> <span><kbd>↑</kbd><kbd>↓</kbd> to move, <kbd>↵</kbd> to open.</span></p>
   </dialog>
@@ -136,5 +135,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
   </footer>
 
-  <script is:inline>
+  <script>
     // Source notes (SourceTip.astro): a click or tap on the "i" keeps its note open until a click
     // elsewhere. Listens on the document, so it also works in articles loaded into the drawer.
@@ -155,5 +154,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
     window.cfiFuse = () => import("fuse.js").then((m) => m.default);
   </script>
-  <script is:inline>
+  <script>
     // Search dialog. The index and Fuse.js (fuzzy matching, so a typo or a missing accent still
     // finds its row) are loaded on first use. Every word typed must match a row's name or its
@@ -166,9 +165,9 @@ const schemas = jsonld ? [jsonld].flat() : [];
       var KIND = { c: "Country", p: "Page", u: "Culture", a: "Article", h: "History" }, ORDER = { c: 0, p: 1, u: 1, a: 2, h: 3 };
       var icons = document.getElementById("searchIcons"), KIND_ICON = JSON.parse(icons.dataset.kinds), ICON = {};
-      icons.content.querySelectorAll("[data-k]").forEach(function (el) { ICON[el.dataset.k] = el.outerHTML; });
       function fold(s) { return s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); }
       function esc(s) { return String(s).replace(/[&<>"]/g, function (ch) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]; }); }
       function load() {
-        if (!loading) loading = Promise.all([fetch(dialog.dataset.index).then(function (r) { return r.json(); }), window.cfiFuse()]).then(function (got) {
+        if (!loading) loading = Promise.all([fetch(dialog.dataset.index).then(function (r) { return r.json(); }), window.cfiFuse(), fetch(icons.dataset.src).then(function (r) { return r.json(); })]).then(function (got) {
+          ICON = got[2];
           rows = got[0].map(function (r, i) { r.i = i; r.k = fold(r.n); r.h = r.k + " " + fold(r.s) + (r.g ? " " + fold(r.g) : ""); return r; });
           // `k` is the name, `h` the name, second line and hidden words (an entry's tags) together, so words may be spread over them
@@ -326,5 +325,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
     if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
   </script>
-  <script is:inline>
+  <script>
     // Hover previews. A link in the page to a country, a history entry or a culture entry shows a
     // small card with what is behind it. The data is /previews/<kind>.json, fetched the first time a
@@ -417,5 +416,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
     })();
   </script>
-  <script is:inline>
+  <script>
     // The term layer's explanations. Words for cannabis and jargon are marked as the page is built
     // (src/lib/terms.js); a jargon word (.term-g) carries its explanation, shown here in a card in the
@@ -445,5 +444,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
     })();
   </script>
-  <script is:inline>
+  <script>
     // Citation numbers ([3]) show their source in a card in the style of the preview cards, read
     // from the numbered list the number links to, so the list can stay folded away (the country
@@ -494,5 +493,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
     })();
   </script>
-  <script is:inline>
+  <script>
     // Side panes stick beside the text only when the whole pane fits the window; otherwise its end
     // could never be reached until the page ends.

src/pages/search-icons.json.js +17 −0
@@ -0,0 +1,17 @@
+// The icons the search results use (lib/search-icons.js), as SVG markup by name. The search
+// dialog fetches them with its index on first use, instead of every page carrying all of them
+// (some 19 KB a page) in a template.
+import { getIconData, iconToSVG, iconToHTML } from "@iconify/utils";
+import { icons } from "@iconify-json/iconoir";
+import { SEARCH_ICONS } from "../lib/search-icons.js";
+
+export function GET() {
+  const out = {};
+  for (const name of SEARCH_ICONS) {
+    const data = getIconData(icons, name.split(":")[1]);
+    if (!data) throw new Error(`search icon ${name} is not in the iconoir set`);
+    const { attributes, body } = iconToSVG(data);
+    out[name] = iconToHTML(body, { ...attributes, width: "1em", height: "1em", class: "ico", "aria-hidden": "true", "data-k": name });
+  }
+  return new Response(JSON.stringify(out), { headers: { "Content-Type": "application/json" } });
+}

    ↑↓ to move, ↵ to open.