@iterant/site-runtime 3.6.1 → 3.8.0

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.
@@ -0,0 +1,392 @@
1
+ // @ts-check
2
+ import { createHash } from "node:crypto";
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // Dev-only restart signals, content re-arm and state probe (2026-09-11).
8
+ //
9
+ // Astro restarts its Vite server IN PLACE when a file in `settings.watchFiles`
10
+ // changes, and `core/config/settings.js` seeds that list with the repo's
11
+ // package.json, so a plain `bun add` mid-session replaces the whole dev
12
+ // server. The preset adds src/site-config.ts to the same list, so a font change
13
+ // takes this path too. Vite's restart builds a new chokidar watcher and closes
14
+ // the old one. The content layer is initialised ONCE at boot
15
+ // (`core/dev/dev.js`, `globalContentLayer.init({ watcher })`) and the restart
16
+ // never re-inits it, so the glob loader for the content tree stays bound to a
17
+ // closed watcher for the rest of the process: .tsx/.astro/.css keep
18
+ // hot-reloading while page entries and chrome.json quietly stop. Only a process
19
+ // restart clears it.
20
+ //
21
+ // `astro:server:setup` runs on EVERY server Astro creates, the in-place restart
22
+ // included, and it hands over `refreshContent()`, which resolves the live
23
+ // content layer and syncs it. That is the seam: on a restarted server this
24
+ // integration watches the content tree itself and drives the sync the dead
25
+ // watcher no longer drives. The sync writes .astro/data-store.json, and the new
26
+ // server's own watcher invalidates the content virtual module off that write
27
+ // (`content/vite-plugin-content-virtual-mod.js`), so the next request serves
28
+ // the new entry and the browser reloads.
29
+ //
30
+ // The watched tree is the one the COLLECTIONS read, not a fixed path: a repo
31
+ // that moved its entries passes the same `pagesDir`/`chromeDir` to
32
+ // `createCollections` and to `iterantStarter`, and the preset threads them here.
33
+ //
34
+ // WHAT THE FIRST REFRESH DOES TO THE DEAD WATCHER, measured on the consumer
35
+ // fixture across three restarts (chokidar 3.6 as Vite 8 bundles it). The glob
36
+ // loader ends its load with `watcher.add(base)` on whatever watcher the content
37
+ // layer holds, and chokidar's `add()` opens with `this.closed = false`. So the
38
+ // first refresh this integration drives brings watcher #1 back from the dead:
39
+ //
40
+ // boot #1 alive, 41 dirs / 123 paths, content listeners 10/14/10
41
+ // after restart #1 closed, 0 dirs / 0 paths, 0 listeners; #2 alive 41/123
42
+ // 1st edit after our sync only. #1 comes back: 3 dirs / 6 paths, 2/2/2
43
+ // 2nd edit after `Reloaded data from home.json` AND our sync: BOTH run
44
+ //
45
+ // Three consequences, all of them measured rather than reasoned:
46
+ //
47
+ // The content layer SELF-HEALS from the second edit on. Watcher #1's revived
48
+ // handlers are the layer's own, so it resumes its incremental per-file
49
+ // update. It heals BECAUSE of this arm, not instead of it: nothing else ever
50
+ // calls add(), and the first edit after a restart is carried by us alone.
51
+ //
52
+ // Nothing ACCUMULATES. Only watcher #1 ever revives, because only watcher #1
53
+ // is the one the layer holds; #2 and #3 stayed closed with 0 paths for the
54
+ // rest of the process. At most two live watchers, whatever the restart count.
55
+ //
56
+ // The revived one is NOT a second full watcher. `close()` wipes its watched
57
+ // set, and the loader re-adds only the collection dirs: 3 dirs / 6 paths
58
+ // against the live watcher's 41 / 123. Under inotify that is a handful of
59
+ // descriptors; under CHOKIDAR_USEPOLLING it polls six paths, not the tree.
60
+ //
61
+ // The redundant work is therefore one full sync per content edit alongside the
62
+ // layer's own single-file update: 7.7ms median over the fixture's four entries,
63
+ // growing with entry count. That is the price of an arm that cannot go stale,
64
+ // and it is why narrowing watcher #1 with `unwatch` was NOT done: it would buy
65
+ // back six watched paths and cost the self-heal.
66
+ //
67
+ // STDOUT SIGNALS. One single-line JSON object per event, every one keyed
68
+ // `iterant` so the preview supervisor (which already matches Astro's `ready in`
69
+ // line) can filter them out of the dev log. Every `at`/timestamp field in this
70
+ // integration, on stdout, over HMR and in the dev-state body alike, is an ISO
71
+ // 8601 string in UTC, as `new Date().toISOString()` writes it
72
+ // (`2026-09-11T15:51:32.589Z`), never an epoch number:
73
+ //
74
+ // {"iterant":"dev-server","event":"restart","phase":"config"}
75
+ // {"iterant":"dev-server","event":"restart","phase":"setup"}
76
+ // {"iterant":"dev-server","event":"ready","restart":true}
77
+ // {"iterant":"dev-server","event":"content-synced","restart":true,"paths":[...]}
78
+ // {"iterant":"dev-server","event":"content-sync-failed","restart":true,"paths":[...],"error":"..."}
79
+ //
80
+ // `paths` are root-relative (`src/content/pages/home.json`), which is what a
81
+ // writer wrote and the only key shape that survives a relocated tree. An empty
82
+ // `paths` is the arm-time sync below, which belongs to no single file.
83
+ //
84
+ // The two `restart` lines are emitted only on a restarted server; `ready`
85
+ // carries the flag instead, so a supervisor reading only `ready` still learns
86
+ // which kind of server it got. Exactly one `ready` per server, boot or restart,
87
+ // emitted when its httpServer starts listening.
88
+ //
89
+ // HMR EVENTS, on this server's channel:
90
+ //
91
+ // iterant:server-ready { restart, at } as each client connects
92
+ // iterant:content-synced { paths, at } after each sync
93
+ //
94
+ // There is deliberately no "restarting" event: by the time any hook on the new
95
+ // server runs the old one is gone, so nothing could have sent it to the client
96
+ // that was connected. `iterant:server-ready` is the arrival half of the same
97
+ // signal, and it goes out on connection rather than at startup because a
98
+ // payload sent before a client attaches is dropped.
99
+ //
100
+ // DEV STATE, mounted on the dev server's own middleware stack. Both routes
101
+ // require the header `x-iterant-dev-state: 1`, which the preview supervisor
102
+ // sends; without it the request falls through to Astro and is answered exactly
103
+ // as it would be if this integration were not installed (404 for the GET, and
104
+ // Astro's own cross-site rejection for the POST). The dev server binds to a
105
+ // host the preview tunnel can reach, so a route that answered any caller would
106
+ // be a content-refresh trigger anyone with the URL could pull:
107
+ //
108
+ // GET /__iterant/dev-state -> restart flag, start time, last sync, digests
109
+ // POST /__iterant/refresh -> forces one refreshContent()
110
+ //
111
+ // The digests are computed here, from the files on disk the content layer would
112
+ // load. What the layer currently HOLDS is not reported: the store is reachable
113
+ // only through `astro/dist/content` internals (`globalContentLayer`,
114
+ // `globalDataStore`), which an integration must not import.
115
+
116
+ const REFRESH_DEBOUNCE_MS = 100;
117
+ const STATE_PATH = "/__iterant/dev-state";
118
+ const REFRESH_PATH = "/__iterant/refresh";
119
+ const STATE_HEADER = "x-iterant-dev-state";
120
+
121
+ /**
122
+ * @param {object} options
123
+ * @param {string} options.pagesDir Page entry dir, relative to the project root.
124
+ * @param {string} options.chromeDir Chrome and links dir, same.
125
+ * @returns {import("astro").AstroIntegration}
126
+ */
127
+ export default function devServerSignals({ pagesDir, chromeDir }) {
128
+ /** Dev only. Set in astro:config:setup, read by every later hook. */
129
+ let enabled = false;
130
+ /** Whether THIS server is an in-place restart rather than the boot server. */
131
+ let restart = false;
132
+ /** Project root, no trailing separator: what the reported paths are relative to. */
133
+ let root = "";
134
+ /** Absolute content dirs, each with a trailing separator, nested ones collapsed. */
135
+ let contentDirs = /** @type {string[]} */ ([]);
136
+ let startedAt = "";
137
+ /** @type {string | null} */
138
+ let lastSyncAt = null;
139
+ /** @type {string[]} */
140
+ let lastSyncPaths = [];
141
+
142
+ return {
143
+ name: "dev-server-signals",
144
+ hooks: {
145
+ "astro:config:setup": ({ command, config, isRestart }) => {
146
+ if (command !== "dev") return;
147
+ enabled = true;
148
+ restart = isRestart;
149
+ root = fileURLToPath(config.root).replace(/[\\/]$/, "");
150
+ // resolve(), not join(): a configured dir may carry a trailing slash
151
+ // (`src/content/`), and join keeps it, so the `+ sep` below would make
152
+ // `src/content//` and no content path would ever match the prefix.
153
+ contentDirs = collapseNested(
154
+ [pagesDir, chromeDir].map((dir) => resolve(root, dir) + sep),
155
+ );
156
+ startedAt = new Date().toISOString();
157
+ if (isRestart) signal({ event: "restart", phase: "config" });
158
+ },
159
+
160
+ "astro:server:setup": ({ server, refreshContent }) => {
161
+ if (!enabled) return;
162
+ if (restart) signal({ event: "restart", phase: "setup" });
163
+
164
+ // Astro declares `refreshContent` optional and takes its options
165
+ // object by value; passing none of its keys syncs every loader.
166
+ const sync = () => refreshContent?.({}) ?? Promise.resolve();
167
+
168
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
169
+ let timer;
170
+ /** @type {Set<string>} */
171
+ const pending = new Set();
172
+
173
+ const refresh = async () => {
174
+ const paths = [...pending].sort();
175
+ pending.clear();
176
+ try {
177
+ await sync();
178
+ } catch (error) {
179
+ // A malformed entry rejects the whole loader chain. Report it as
180
+ // its own event rather than letting the rejection go unhandled:
181
+ // the next write retries, and a supervisor that sees this knows
182
+ // the store is behind the digests the state route reports.
183
+ signal({
184
+ event: "content-sync-failed",
185
+ restart,
186
+ paths,
187
+ error: String(error instanceof Error ? error.message : error),
188
+ });
189
+ return;
190
+ }
191
+ lastSyncAt = new Date().toISOString();
192
+ lastSyncPaths = paths;
193
+ signal({ event: "content-synced", restart, paths });
194
+ server.hot.send({
195
+ type: "custom",
196
+ event: "iterant:content-synced",
197
+ data: { paths, at: lastSyncAt },
198
+ });
199
+ };
200
+
201
+ // Coalesce a burst (a multi-entry write, a branch checkout) into one
202
+ // sync: reset the timer per event and fire once the tree settles.
203
+ /** @param {string} [path] */
204
+ const scheduleRefresh = (path) => {
205
+ if (path) pending.add(path);
206
+ clearTimeout(timer);
207
+ timer = setTimeout(() => void refresh(), REFRESH_DEBOUNCE_MS);
208
+ };
209
+
210
+ // Only on a restarted server. The BOOT server's content layer still
211
+ // holds a live watcher and runs its own incremental update per event;
212
+ // arming here too would make every content save run a second full sync
213
+ // on top of it, for the whole session, to fix nothing.
214
+ if (restart) {
215
+ server.watcher.on("all", (event, path) => {
216
+ if (event !== "add" && event !== "change" && event !== "unlink") {
217
+ return;
218
+ }
219
+ if (!contentDirs.some((dir) => path.startsWith(dir))) return;
220
+ scheduleRefresh(contentKey(path, root));
221
+ });
222
+ // One sync for the gap itself. A write that landed between the old
223
+ // watcher's close and this arm produced an event nobody was
224
+ // listening for, and no later event will mention that file, so
225
+ // waiting for one would strand it until the next unrelated edit.
226
+ // It rides the same debounce, so a restart plus a burst of writes
227
+ // is still one sync.
228
+ scheduleRefresh();
229
+ }
230
+
231
+ // An obsolete generation must not fire after its server is gone: Vite's
232
+ // in-place restart calls close() on the old server (which closes this
233
+ // httpServer) before the new one listens, and a timer still pending
234
+ // then would run one sync and one content-synced event on behalf of a
235
+ // server nobody is talking to. The same listener covers shutdown.
236
+ server.httpServer?.once("close", () => clearTimeout(timer));
237
+
238
+ server.middlewares.use((req, res, next) => {
239
+ const path = String(req.url ?? "").split("?")[0];
240
+ if (path !== STATE_PATH && path !== REFRESH_PATH) return next();
241
+ // Absent or wrong header: behave as if these routes did not exist.
242
+ if (req.headers[STATE_HEADER] !== "1") return next();
243
+ if (path === STATE_PATH && req.method === "GET") {
244
+ return json(res, 200, {
245
+ restart,
246
+ startedAt,
247
+ contentSync: {
248
+ armed: restart,
249
+ lastAt: lastSyncAt,
250
+ lastPaths: lastSyncPaths,
251
+ },
252
+ entries: contentDigests(contentDirs, root),
253
+ });
254
+ }
255
+ if (path === REFRESH_PATH && req.method === "POST") {
256
+ void sync().then(
257
+ () => {
258
+ lastSyncAt = new Date().toISOString();
259
+ json(res, 200, { ok: true, at: lastSyncAt });
260
+ },
261
+ (error) => {
262
+ json(res, 500, {
263
+ ok: false,
264
+ error: String(error instanceof Error ? error.message : error),
265
+ });
266
+ },
267
+ );
268
+ return;
269
+ }
270
+ return next();
271
+ });
272
+ // Astro's own dev handler registers earlier in the connect stack and
273
+ // ends the response before later layers run, the same reason
274
+ // preview-error-shell hoists its layer; these two paths belong to no
275
+ // route, so they have to be answered before it.
276
+ const layer = server.middlewares.stack.pop();
277
+ if (layer) server.middlewares.stack.unshift(layer);
278
+
279
+ // The ready line, from the socket rather than from a hook. Astro runs
280
+ // astro:server:start on the BOOT path only, so a restarted server that
281
+ // waited for it would never announce itself; 'listening' is the moment
282
+ // both paths share, and it is also the first moment the routes above
283
+ // can actually be reached.
284
+ server.httpServer?.once("listening", () =>
285
+ signal({ event: "ready", restart }),
286
+ );
287
+
288
+ // The same news over HMR, as clients arrive. It cannot be sent from a
289
+ // hook: an HMR payload with no client attached is dropped, and on a
290
+ // restart the client that was open has not finished reconnecting yet.
291
+ // This states a FACT about the server rather than announcing a moment,
292
+ // so a client connecting later still wants it, and one already attached
293
+ // can take it twice without harm.
294
+ server.ws.on("connection", () =>
295
+ server.hot.send({
296
+ type: "custom",
297
+ event: "iterant:server-ready",
298
+ data: { restart, at: new Date().toISOString() },
299
+ }),
300
+ );
301
+ },
302
+ },
303
+ };
304
+ }
305
+
306
+ /**
307
+ * One signal line on stdout. Written directly rather than through Astro's
308
+ * logger, which wraps and colorizes: a supervisor parses these, so they have to
309
+ * stay one line of plain JSON.
310
+ *
311
+ * @param {Record<string, unknown>} fields
312
+ */
313
+ function signal(fields) {
314
+ process.stdout.write(
315
+ `${JSON.stringify({ iterant: "dev-server", ...fields })}\n`,
316
+ );
317
+ }
318
+
319
+ /**
320
+ * @param {import("node:http").ServerResponse} res
321
+ * @param {number} status
322
+ * @param {unknown} body
323
+ */
324
+ function json(res, status, body) {
325
+ res.writeHead(status, { "content-type": "application/json" });
326
+ res.end(JSON.stringify(body));
327
+ }
328
+
329
+ /**
330
+ * The dirs to walk and watch, with any dir that sits inside another dropped.
331
+ * The default layout nests the two (`src/content` holds `src/content/pages`),
332
+ * and walking both would digest the page entries twice.
333
+ *
334
+ * @param {string[]} dirs
335
+ * @returns {string[]}
336
+ */
337
+ function collapseNested(dirs) {
338
+ const sorted = [...new Set(dirs)].sort();
339
+ return sorted.filter(
340
+ (dir, index) =>
341
+ !sorted.slice(0, index).some((kept) => dir.startsWith(kept)),
342
+ );
343
+ }
344
+
345
+ /**
346
+ * A content file's reported key: its path relative to the project root, with
347
+ * forward slashes. Root-relative rather than dir-relative because two relocated
348
+ * dirs can hold the same file name, and because it is the path a writer wrote.
349
+ *
350
+ * @param {string} path
351
+ * @param {string} root
352
+ */
353
+ function contentKey(path, root) {
354
+ return relative(root, path).split(sep).join("/");
355
+ }
356
+
357
+ /**
358
+ * sha256 of every content entry on disk, keyed by `contentKey`. Every
359
+ * collection this runtime defines loads `.json` out of these dirs, so the walk
360
+ * needs no per-collection knowledge. An absent dir is a real state (a repo
361
+ * before its first entry lands) and reports as no entries.
362
+ *
363
+ * @param {string[]} dirs
364
+ * @param {string} root
365
+ * @returns {Record<string, string>}
366
+ */
367
+ function contentDigests(dirs, root) {
368
+ /** @type {Record<string, string>} */
369
+ const digests = {};
370
+ /** @param {string} current */
371
+ const walk = (current) => {
372
+ /** @type {import("node:fs").Dirent[]} */
373
+ let entries;
374
+ try {
375
+ entries = readdirSync(current, { withFileTypes: true });
376
+ } catch {
377
+ return;
378
+ }
379
+ for (const entry of entries) {
380
+ const path = join(current, entry.name);
381
+ if (entry.isDirectory()) {
382
+ walk(path);
383
+ } else if (entry.name.endsWith(".json")) {
384
+ digests[contentKey(path, root)] = createHash("sha256")
385
+ .update(readFileSync(path))
386
+ .digest("hex");
387
+ }
388
+ }
389
+ };
390
+ for (const dir of dirs) walk(dir);
391
+ return digests;
392
+ }
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+
3
+ // The repo's site config, added to the dev server's restart list (2026-09-11).
4
+ //
5
+ // Astro restarts on its own config file, package.json and the tsconfig sources,
6
+ // and on nothing else: `core/dev/restart.js` compares every add/change/unlink
7
+ // against `settings.watchFiles`, and `addWatchFile` is the only way into that
8
+ // list. src/site-config.ts is not on it by default, yet SITE_CONFIG.fonts is
9
+ // read TWICE from two different places: the preset passes it to Astro's font
10
+ // config (resolved once, at server start) and the layout renders `<Font>` from
11
+ // it per request. Without this, adding a family to that array would render a
12
+ // face the server never resolved, for the rest of the session, and the page
13
+ // would fall back to the system stack with no error anywhere.
14
+ //
15
+ // The restart this causes is Astro's in-place kind, which leaves the content
16
+ // layer bound to the closed watcher; `dev-server-signals` re-arms it. That is
17
+ // what makes watching this file a fix rather than a trade.
18
+ //
19
+ // Not gated on `command`: the hook states that this file feeds the config,
20
+ // which is true in every command, and Astro reads watchFiles in dev alone.
21
+
22
+ /**
23
+ * @param {string} siteConfigPath The repo's site config module, relative to the
24
+ * project root. Resolved against `config.root` rather than `process.cwd()`:
25
+ * the two differ whenever Astro runs with an explicit `--root`, and a watch
26
+ * entry that names a path nothing writes to is a watch that never fires.
27
+ * @returns {import("astro").AstroIntegration}
28
+ */
29
+ export default function siteConfigWatch(siteConfigPath) {
30
+ return {
31
+ name: "site-config-watch",
32
+ hooks: {
33
+ "astro:config:setup": ({ config, addWatchFile }) => {
34
+ addWatchFile(new URL(siteConfigPath, config.root));
35
+ },
36
+ },
37
+ };
38
+ }
@@ -1,9 +1,11 @@
1
1
  ---
2
+ import { Font, fontData } from "astro:assets";
2
3
  import { getCollection, getEntry } from "astro:content";
3
4
  import type { AstroComponentFactory } from "astro/runtime/server/index.js";
4
5
  import RelatedLinks from "../components/RelatedLinks.astro";
5
6
  import { SEO, type PageType } from "../components/seo";
6
7
  import type { SeoJsonSchema } from "../components/seo-json";
8
+ import { fontCssVariable } from "../fonts/catalog";
7
9
  import type { HreflangAlternate } from "../lib/hreflang";
8
10
  import { DEFAULT_LOCALE, localeFromPath } from "../lib/locales";
9
11
  import { SITE_RUNTIME_VERSION } from "../version";
@@ -14,6 +16,7 @@ import {
14
16
  resolveAbsoluteUrl,
15
17
  resolveStructuredData,
16
18
  routePathFromPathname,
19
+ selectFontFamilies,
17
20
  type LayoutSiteConfig,
18
21
  type SiteShell,
19
22
  } from "./layout-core";
@@ -151,6 +154,41 @@ const shellId = shell ?? pageEntry?.data.shell;
151
154
  const linksEntry = await getEntry("links", "links");
152
155
  const linksSlice = linksEntry?.data.routes[routePath];
153
156
 
157
+ // Web fonts (3.8.0): a `@font-face` block per family the site names in
158
+ // SITE_CONFIG.fonts, and a preload link for each. Never the whole catalog:
159
+ // every family rendered here inlines its faces into EVERY page.
160
+ //
161
+ // `fontData` is Astro's own runtime view of the resolved font config, keyed by
162
+ // CSS variable, so it is what decides whether a family can render. The rule and
163
+ // the fallback live in selectFontFamilies, which is testable without a render.
164
+ const fonts = selectFontFamilies(siteConfig.fonts, (cssVariable) =>
165
+ Object.hasOwn(fontData, cssVariable),
166
+ );
167
+
168
+ // A named family nothing declared is a repo whose astro.config and site config
169
+ // disagree, which no gate catches and no render shows. Said once, in dev, where
170
+ // the author is looking.
171
+ if (import.meta.env.DEV && fonts.undeclared.length > 0) {
172
+ console.warn(
173
+ `[site-runtime] SITE_CONFIG.fonts names ${fonts.undeclared.join(", ")}, which astro.config did not declare. Pass the same list to iterantStarter({ fonts }).`,
174
+ );
175
+ }
176
+
177
+ // The cast is Astro's own type: once a repo configures fonts, `astro sync` narrows
178
+ // CssVariable to the exact literals in that config. Every variable here is one
179
+ // fontData carries, which is the same set, but the compiler cannot see that
180
+ // through a runtime string.
181
+ const fontVariables = fonts.families.map((family) =>
182
+ fontCssVariable(family),
183
+ ) as Array<import("astro:assets").CssVariable>;
184
+
185
+ // Preload the upright latin face of each named family and nothing else. Every
186
+ // face stays in the @font-face block, so the browser still fetches a slanted or
187
+ // extended-latin one the moment a glyph needs it. Preloading every face instead
188
+ // measured 8 files and 436K of blocking requests for two families, on a page
189
+ // that renders four words of plain latin text.
190
+ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false;
191
+
154
192
  // Version identity: the installed package version IS the site's runtime
155
193
  // version. `it-astro-starter-version` keeps emitting the same value while the
156
194
  // plugin loader and the platform's page indexer still read that name, and
@@ -176,6 +214,11 @@ const linksSlice = linksEntry?.data.routes[routePath];
176
214
  <meta name="generator" content={Astro.generator} />
177
215
  <meta name="it-site-runtime" content={SITE_RUNTIME_VERSION} />
178
216
  <meta name="it-astro-starter-version" content={SITE_RUNTIME_VERSION} />
217
+ {
218
+ fontVariables.map((cssVariable) => (
219
+ <Font cssVariable={cssVariable} preload={fontPreload} />
220
+ ))
221
+ }
179
222
  <SEO
180
223
  title={title}
181
224
  description={description}
@@ -1,4 +1,5 @@
1
1
  import type { PageType } from "../components/seo";
2
+ import { DEFAULT_FONT_FAMILIES, fontCssVariable } from "../fonts/catalog";
2
3
  import type { ContentProps } from "../lib/content-values";
3
4
 
4
5
  // The pure half of LayoutCore.astro: URL resolution, chrome lookup, and the
@@ -16,6 +17,14 @@ export interface LayoutSiteConfig {
16
17
  description: string;
17
18
  logo?: string;
18
19
  sameAs?: string[];
20
+ /**
21
+ * Font families this site uses, by catalog name (`["Fraunces", "Inter"]`).
22
+ * The head carries a `@font-face` block per family, so this is the list of
23
+ * families the site actually sets somewhere, not everything it may choose
24
+ * from. The repo passes the same array to `iterantStarter({ fonts })`; a
25
+ * family missing there has no CSS variable to render and is skipped.
26
+ */
27
+ fonts?: string[];
19
28
  }
20
29
 
21
30
  /**
@@ -141,3 +150,50 @@ export function resolveStructuredData(
141
150
  }
142
151
 
143
152
  export { LAYOUT_CONTRACT } from "./layout-contract";
153
+
154
+ /** What the head renders for web fonts, decided off the site's list. */
155
+ export interface FontSelection {
156
+ /** Families to render a `<Font>` for, in order, deduped. */
157
+ families: string[];
158
+ /** Whether the families are the site's own list rather than the fallback. */
159
+ named: boolean;
160
+ /** Families the site named that no font config declared. */
161
+ undeclared: string[];
162
+ }
163
+
164
+ /**
165
+ * The families the head renders, and whether to preload them.
166
+ *
167
+ * `isDeclared` is the authority, not the catalog: Astro's `<Font>` THROWS
168
+ * (`FontFamilyNotFound`) on a CSS variable no family registered, so a repo
169
+ * whose astro.config never got the site's list would render an error page
170
+ * rather than a missing face. Filtering through what Astro actually resolved
171
+ * makes that structurally impossible, and it also lets a diverged repo declare
172
+ * a family of its own and have it render.
173
+ *
174
+ * A site that names nothing falls back to the default set: a repo's globals.css
175
+ * is written against the CSS variables, so a stylesheet already saying
176
+ * `var(--font-fraunces)` would otherwise get the system stack, silently. The
177
+ * fallback is reported through `named`, because a page that has not said which
178
+ * families it uses must not spend a preload on each of eleven guesses.
179
+ */
180
+ export function selectFontFamilies(
181
+ fonts: readonly string[] | undefined,
182
+ isDeclared: (cssVariable: string) => boolean,
183
+ ): FontSelection {
184
+ const wanted = [...new Set(fonts ?? [])];
185
+ const declared = wanted.filter((family) =>
186
+ isDeclared(fontCssVariable(family)),
187
+ );
188
+ const undeclared = wanted.filter((family) => !declared.includes(family));
189
+ if (declared.length > 0) {
190
+ return { families: declared, named: true, undeclared };
191
+ }
192
+ return {
193
+ families: DEFAULT_FONT_FAMILIES.filter((family) =>
194
+ isDeclared(fontCssVariable(family)),
195
+ ),
196
+ named: false,
197
+ undeclared,
198
+ };
199
+ }
@@ -1,4 +1,5 @@
1
1
  import { micromark } from "micromark";
2
+ import { gfm, gfmHtml } from "micromark-extension-gfm";
2
3
 
3
4
  // The one compiler for {type:"markdown"} content values (content-values.ts).
4
5
  // Runs at build time inside Astro's static render, so the browser ships HTML,
@@ -8,10 +9,18 @@ import { micromark } from "micromark";
8
9
  // tags up front, and this default is the backstop for anything that predates
9
10
  // or evades the schema. Do not pass allowDangerousHtml here, ever.
10
11
  //
12
+ // GFM is on because the dashboard's body toolbar emits it: tables and task
13
+ // lists are buttons a customer can press, and CommonMark alone would render
14
+ // what they wrote as pipe soup. It adds syntax only; the escaping contract
15
+ // above is untouched, and gfmHtml writes task checkboxes disabled.
16
+ //
11
17
  // Headings: a markdown body renders inside a section, below the page's own
12
18
  // h1, so authors start at `##`. The compiler does not rewrite heading levels;
13
19
  // a body that opens with `#` is an authoring error the review pass catches,
14
20
  // not something to silently demote.
15
21
  export function renderMarkdown(value: string): string {
16
- return micromark(value);
22
+ return micromark(value, {
23
+ extensions: [gfm()],
24
+ htmlExtensions: [gfmHtml()],
25
+ });
17
26
  }