@iterant/site-runtime 3.7.0 → 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.
@@ -50,7 +50,7 @@ runtime and says so.
50
50
 
51
51
  <!-- generated: available libraries -->
52
52
 
53
- _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.7.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.8.0._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
@@ -443,7 +443,8 @@ import { SITE_SHELL } from "@/site-shell";
443
443
  ```
444
444
 
445
445
  Every shell keeps importing `../layouts/Layout.astro` unchanged. The core owns
446
- the head (charset, viewport, favicon, generator, version meta, SEO, JSON-LD,
446
+ the head (charset, viewport, favicon, generator, version meta, the web fonts
447
+ named in `SITE_CONFIG.fonts`, SEO, JSON-LD,
447
448
  hreflang, the `head` slot), resolves locale-aware chrome and the page entry, and
448
449
  renders the brand shell around the page slot with the `chrome`, `navbar`,
449
450
  `footer` and `shellId` props. It never imports a stylesheet and never imports chrome
@@ -543,14 +544,87 @@ export default defineConfig({
543
544
  });
544
545
  ```
545
546
 
546
- The preset carries the adapter, the integrations (React, sitemap, and the
547
- dev-only plugin loader, preview error shell and new-file reload), the vite
547
+ The preset carries the adapter, the integrations (React, sitemap, the site-config
548
+ watch, and the dev-only plugin loader, preview error shell, new-file reload and
549
+ dev-server signals), the web fonts, the vite
548
550
  tuning, the CSS browser floor, the dev server port and host, and the toolbar
549
551
  setting. A diverged repo
550
552
  extends rather than ejects: extra integrations and vite plugins append through
551
553
  `overrides`, and any other key it needs it writes in its own `defineConfig`
552
554
  object after the spread.
553
555
 
556
+ ### Web fonts (3.8.0)
557
+
558
+ A site names its font families once, in `SITE_CONFIG.fonts`, and passes the same
559
+ array to the preset:
560
+
561
+ ```js
562
+ import { SITE_CONFIG } from "./src/site-config";
563
+
564
+ export default defineConfig({
565
+ site: "https://example.com",
566
+ ...iterantStarter({ fonts: SITE_CONFIG.fonts }),
567
+ });
568
+ ```
569
+
570
+ The preset turns each name into an Astro font declaration (Google provider,
571
+ weights 400/500/600/700, both slants, subsets latin and latin-ext), and the
572
+ layout renders Astro's `<Font>` for the same families. Names come from the
573
+ catalog the package ships, `@iterant/site-runtime/fonts` (`FONT_CATALOG`,
574
+ `fontCssVariable`, `findFontFamily`); a name the catalog does not carry is
575
+ skipped on both sides rather than failing the render. A family declared as
576
+ `Playfair Display` is set in CSS as `var(--font-playfair-display)`.
577
+
578
+ **The default set is always declared.** `DEFAULT_FONT_FAMILIES` is eleven
579
+ families: one heading default per face class (`DEFAULT_HEADING_FAMILY_BY_CLASS`,
580
+ covering serif, sans, slab, rounded, condensed, display, handwritten and mono)
581
+ plus three body defaults. `SITE_CONFIG.fonts` ADDS to that set, it does not
582
+ replace it, so the family the platform most often picks is already resolved and
583
+ a pick costs no astro.config edit and no restart.
584
+
585
+ The LAYOUT still renders only what a site names, because every family rendered
586
+ inlines its faces into every page. The one exception is a site that names
587
+ nothing: it renders the eleven default faces, with no preload. A repo's
588
+ `globals.css` is written against the variables, so a stylesheet already saying
589
+ `var(--font-fraunces)` on a site with an empty `fonts` array would otherwise get
590
+ the system stack, silently.
591
+
592
+ **Name the families.** The fallback is a safety net, not a resting state, and it
593
+ is not cheap: measured on the fixture's home page, eleven families inline 320
594
+ `@font-face` rules and **93.6K of CSS into the head of every page**, taking a
595
+ 102K page from 8.8K of everything-else. One named family is 32 rules and 9.2K,
596
+ on an 18K page. The faces are only declarations, so the browser downloads a file
597
+ when a glyph needs one, but the bytes are in the HTML either way and no cache
598
+ serves them. The fallback also preloads nothing, because eleven blocking font
599
+ requests on a page that has not said which families it uses is the wrong trade.
600
+ Name the families and the page carries exactly those and preloads them.
601
+
602
+ A family in `SITE_CONFIG.fonts` that `astro.config.mjs` never declared is
603
+ skipped rather than rendered. Astro's `<Font>` throws `FontFamilyNotFound` on a
604
+ CSS variable no family registered, so the layout filters the list through
605
+ `fontData`, Astro's own runtime view of the resolved font config, and warns once
606
+ in dev naming the family. That is the case a repo lands in by upgrading and
607
+ adding `SITE_CONFIG.fonts` without also passing it to `iterantStarter`.
608
+
609
+ Declaring is loading: Astro resolves every declared family at dev boot and
610
+ copies every one of its files into the build output. Measured cold: the eleven
611
+ defaults are 1.2s of resolution and 64 files, 1.6MB, per build; the whole
612
+ 89-family catalog is 6.3s and 403 files, 9.7MB. Warm, off the `.astro/fonts`
613
+ cache, the eleven are 5ms. `fonts: "catalog"` declares all 89 for a repo that
614
+ has a reason to.
615
+
616
+ The layout preloads the upright latin face of each named family and nothing
617
+ else. Every face stays in the `@font-face` block, so the browser still fetches a
618
+ slanted or extended-latin one the moment a glyph needs it.
619
+
620
+ **`src/site-config.ts` is watched.** Astro restarts its dev server on its own
621
+ config file, `package.json` and the tsconfig sources, and on nothing else, so a
622
+ family added to `SITE_CONFIG.fonts` would be rendered by the layout while Astro
623
+ had never resolved it. The preset adds the file to `settings.watchFiles`
624
+ (`site-config-watch`), which makes the change restart the server and reach the
625
+ font declarations. A repo that keeps its site config elsewhere passes
626
+ `iterantStarter({ siteConfigPath })`.
627
+
554
628
  ### The browser floor
555
629
 
556
630
  The preset declares the browsers every brand site's CSS is compiled for, to
@@ -567,6 +641,85 @@ conversation: lowering the floor to Safari 15 does restore `min-width` breakpoin
567
641
  and costs 7.5K of fallbacks on the template's own sheet, for engines that still
568
642
  cannot render its `@property` and `color-mix()` values.
569
643
 
644
+ ### The dev server's signals (3.8.0)
645
+
646
+ Astro restarts its Vite server IN PLACE when a file it watches changes, and the
647
+ repo's `package.json` is one of them: a `bun add` mid-session replaces the whole
648
+ dev server. The content layer is initialised once, at boot, against the first
649
+ server's file watcher, and the restart never re-inits it. Page entries and
650
+ `chrome.json` therefore stop reloading after a restart while `.tsx`, `.astro`
651
+ and `.css` edits keep hot-reloading, and only a process restart clears it.
652
+
653
+ That restart is not rare: the preset watches `src/site-config.ts` too, so every
654
+ change to a site's font list takes this path.
655
+
656
+ The `dev-server-signals` integration closes that gap and reports what happened.
657
+ On a RESTARTED server it watches `src/content/` itself and drives Astro's own
658
+ `refreshContent()`, debounced 100ms so a burst of entry writes syncs once. On the
659
+ boot server it arms nothing: that server's content layer is live and runs its own
660
+ incremental update, and a second full sync per save would buy nothing.
661
+
662
+ It also runs one sync the moment it arms, because a write that landed between
663
+ the old watcher's close and the new one's arm produced an event nobody heard and
664
+ no later event names that file.
665
+
666
+ That first sync has a side effect worth knowing about. Astro's glob loader ends
667
+ its load by calling `add()` on whatever file watcher the content layer holds,
668
+ and chokidar's `add()` reopens a closed watcher, so the refresh brings the dead
669
+ watcher back and the content layer resumes its own per-file updates from the
670
+ second edit onward. Measured on the consumer fixture across three restarts: the
671
+ revived watcher comes back holding 3 dirs and 6 paths, the collection dirs only,
672
+ against the live watcher's 41 and 123; only that one watcher ever revives, so
673
+ there are at most two alive whatever the restart count; and every content edit
674
+ after the first then costs one extra full sync, 7.7ms median over four entries,
675
+ alongside the layer's own single-file update. The arm stays as it is: the layer
676
+ heals BECAUSE of it (nothing else calls `add()`, and the first edit after a
677
+ restart is carried by the arm alone), and the duplicate is a few milliseconds
678
+ rather than a second watcher over the whole tree.
679
+
680
+ It writes one line of JSON per event on stdout, every one keyed `iterant` so a
681
+ supervisor reading the dev log can filter them:
682
+
683
+ ```
684
+ {"iterant":"dev-server","event":"restart","phase":"config"}
685
+ {"iterant":"dev-server","event":"restart","phase":"setup"}
686
+ {"iterant":"dev-server","event":"ready","restart":true}
687
+ {"iterant":"dev-server","event":"content-synced","restart":true,"paths":["src/content/pages/home.json"]}
688
+ {"iterant":"dev-server","event":"content-sync-failed","restart":true,"paths":[...],"error":"..."}
689
+ ```
690
+
691
+ `ready` goes out when the server's socket starts listening, which is the first
692
+ moment those routes can be reached. The same news reaches clients over Vite's
693
+ HMR channel as `iterant:server-ready` (`{ restart, at }`), sent as each client
694
+ connects rather than at startup, because a payload sent before a client attaches
695
+ is dropped; `iterant:content-synced` (`{ paths, at }`) follows each sync. There
696
+ is no "restarting" event: the old server is gone before any hook on the new one
697
+ runs, so the arrival is the only half that can be sent.
698
+
699
+ Two dev-only routes report and force the state. They exist on the dev server and
700
+ in no build:
701
+
702
+ | Route | Answers |
703
+ | -------------------------- | ---------------------------------------------------------------------------- |
704
+ | `GET /__iterant/dev-state` | `{ restart, startedAt, contentSync: { armed, lastAt, lastPaths }, entries }` |
705
+ | `POST /__iterant/refresh` | `{ ok: true, at }`, or 500 `{ ok: false, error }` if the sync rejects |
706
+
707
+ Both require the request header `x-iterant-dev-state: 1`, which the preview
708
+ supervisor sends. Without it the request falls through to Astro and is answered
709
+ exactly as it would be if the integration were not installed: 404 for the GET,
710
+ Astro's own cross-site rejection for the POST. The dev server binds to a host
711
+ the preview tunnel can reach, so a route that answered any caller would be a
712
+ content-refresh trigger anyone with the URL could pull.
713
+
714
+ `entries` is a sha256 per `.json` file in the content dirs the collections read
715
+ (`pagesDir` and `chromeDir`, whatever a repo set them to), keyed by path from the
716
+ project root (`src/content/pages/home.json`). It is the files on DISK, not what
717
+ the content layer holds: the store is reachable only through Astro internals an
718
+ integration must not import. A writer compares the digest it expects against the
719
+ one reported, and calls `POST /__iterant/refresh` if the layer is behind. Every
720
+ timestamp in these payloads, and in the signal lines, is an ISO 8601 string in
721
+ UTC, never an epoch number.
722
+
570
723
  ## Verify: the gate
571
724
 
572
725
  `bun run verify` maps to `site-runtime verify` and is the gate. It is silent on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
6
6
  "scripts": {
@@ -44,6 +44,8 @@
44
44
  "./locales": "./src/lib/locales.ts",
45
45
  "./hreflang": "./src/lib/hreflang.ts",
46
46
  "./chrome": "./src/lib/chrome.ts",
47
+ "./fonts": "./src/fonts/catalog.ts",
48
+ "./fonts/catalog.json": "./src/fonts/catalog.json",
47
49
  "./chrome-schemas": "./src/lib/chrome-schemas.ts",
48
50
  "./bespoke-pages": "./src/lib/bespoke-pages.ts",
49
51
  "./sitemap": "./src/lib/sitemap/index.ts",
@@ -4,11 +4,20 @@ import cloudflare from "@astrojs/cloudflare";
4
4
  import react from "@astrojs/react";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
6
  import type { AstroUserConfig } from "astro";
7
- import { sessionDrivers } from "astro/config";
7
+ import { fontProviders, sessionDrivers } from "astro/config";
8
8
 
9
+ import {
10
+ DEFAULT_FONT_FAMILIES,
11
+ FONT_CATALOG,
12
+ fontCssVariable,
13
+ findFontFamily,
14
+ } from "../fonts/catalog";
15
+ import { DEFAULT_CHROME_DIR, DEFAULT_PAGES_DIR } from "../lib/content-paths";
16
+ import devServerSignals from "../integrations/dev-server-signals.mjs";
9
17
  import iterantPlugins from "../integrations/iterant-plugins.mjs";
10
18
  import newFileReload from "../integrations/new-file-reload.mjs";
11
19
  import previewErrorShell from "../integrations/preview-error-shell.mjs";
20
+ import siteConfigWatch from "../integrations/site-config-watch.mjs";
12
21
  import { sitemapWithCustomPages } from "../lib/sitemap";
13
22
 
14
23
  /**
@@ -37,6 +46,71 @@ import { sitemapWithCustomPages } from "../lib/sitemap";
37
46
  */
38
47
  const CSS_TARGET = ["safari16.4", "chrome111", "firefox128", "edge111"];
39
48
 
49
+ /** Astro's font schema takes the three below as non-empty tuples, not arrays. */
50
+ type NonEmpty<T> = [T, ...T[]];
51
+
52
+ /**
53
+ * The weights and styles every catalog family is requested at.
54
+ *
55
+ * FOUR EXPLICIT WEIGHTS, not a variable range. Unifont's google provider decides
56
+ * variable-versus-static per family (`prepareWeights`, on the family's own
57
+ * `wght` axis), and it forwards a range straight into the css2 URL: `100 900`
58
+ * against a family whose axis is narrower (EB Garamond is 400..800) comes back
59
+ * 400 Bad Request, and `throwOnError: false` turns that into a family that
60
+ * silently resolves to zero faces. Measured on the whole catalog: the range
61
+ * lost 37 of 89 families, these four weights resolve all 89. Where the family
62
+ * IS variable, Google still serves one variable file per subset, so asking for
63
+ * four weights costs four faces and one download, not four.
64
+ *
65
+ * Astro's own defaults are one weight and both slants; both are restated here
66
+ * because the catalog's value is that any family in it renders the same way.
67
+ */
68
+ const FONT_WEIGHTS: NonEmpty<string> = ["400", "500", "600", "700"];
69
+ const FONT_STYLES: NonEmpty<"normal" | "italic"> = ["normal", "italic"];
70
+ const FONT_SUBSETS: NonEmpty<string> = ["latin", "latin-ext"];
71
+
72
+ /**
73
+ * The default set plus whatever the site names, as Astro font declarations.
74
+ *
75
+ * DECLARING IS LOADING, which is why this is a set and not the whole catalog.
76
+ * Astro's fonts plugin resolves every declared family in `buildStart`,
77
+ * sequentially, once per dev server and once per build, and `buildEnd` writes
78
+ * every file of every declared family into the client output. Measured cold:
79
+ * the eleven defaults cost 1.2s at dev boot (829ms of resolution, ~75ms a
80
+ * family, plus 338ms of provider init) and 64 font files, 1.6MB, into `dist/`
81
+ * per build. The whole 89-family catalog costs 6.3s and 403 files, 9.7MB.
82
+ * Warm (the `.astro/fonts` cache, or `node_modules/.astro/fonts` for a build)
83
+ * eleven families are 5ms, but a preview sandbox clones the repo fresh, so it
84
+ * pays the cold number on its first boot.
85
+ *
86
+ * The defaults are declared unconditionally because the alternative is worse
87
+ * than a second of boot: the platform picks a family from the catalog, writes
88
+ * it into SITE_CONFIG.fonts, and until the dev server restarts the layout would
89
+ * render a face Astro never resolved. `site-config-watch` makes that restart
90
+ * happen, and these eleven make it unnecessary for the common pick.
91
+ *
92
+ * A name the catalog does not carry is skipped rather than declared: Astro's
93
+ * `<Font>` throws on a CSS variable no family registered, and the layout reads
94
+ * the same catalog to decide what to render.
95
+ */
96
+ function catalogFontFamilies(fonts: readonly string[] | "catalog") {
97
+ const names =
98
+ fonts === "catalog"
99
+ ? FONT_CATALOG.map((entry) => entry.family)
100
+ : [...DEFAULT_FONT_FAMILIES, ...fonts];
101
+ const families = [...new Set(names)]
102
+ .map((family) => findFontFamily(family))
103
+ .filter((entry) => entry !== undefined);
104
+ return families.map((entry) => ({
105
+ name: entry.family,
106
+ cssVariable: fontCssVariable(entry.family),
107
+ provider: fontProviders.google(),
108
+ weights: FONT_WEIGHTS,
109
+ styles: FONT_STYLES,
110
+ subsets: FONT_SUBSETS,
111
+ }));
112
+ }
113
+
40
114
  // The Astro configuration every brand site runs on, as one spreadable fragment:
41
115
  //
42
116
  // // astro.config.mjs
@@ -72,6 +146,28 @@ export interface IterantStarterOptions {
72
146
  * knob, so the sitemap and the collection can never read different dirs.
73
147
  */
74
148
  pagesDir?: string;
149
+ /**
150
+ * Directory holding chrome.json, its locale siblings and links.json, if a
151
+ * repo moved it. Same rule as `pagesDir`: `createCollections` takes the same
152
+ * value, and the dev server's content re-arm watches whichever pair is set,
153
+ * so a relocated tree is repaired and reported like any other.
154
+ */
155
+ chromeDir?: string;
156
+ /**
157
+ * Font families this site uses, by catalog name (`["Fraunces", "Inter"]`).
158
+ * The same list the layout receives as `siteConfig.fonts`, so a repo passes
159
+ * its `SITE_CONFIG.fonts` here and the two cannot name different families.
160
+ * ADDED to the default set rather than replacing it; omitted or empty leaves
161
+ * the defaults alone. `"catalog"` declares all 89, at the cost
162
+ * `catalogFontFamilies` measures.
163
+ */
164
+ fonts?: readonly string[] | "catalog";
165
+ /**
166
+ * The repo's site config module, relative to the project root. Watched, so a
167
+ * change to `SITE_CONFIG.fonts` restarts the dev server and reaches the font
168
+ * declarations; the preset does not import the file, it only names it.
169
+ */
170
+ siteConfigPath?: string;
75
171
  /**
76
172
  * Extensions for a diverged repo. These are the two keys a spread cannot
77
173
  * express: extra `integrations` APPEND after the platform's, and `vite`
@@ -93,7 +189,10 @@ export interface IterantStarterOptions {
93
189
  export function iterantStarter({
94
190
  site,
95
191
  allowedHosts = true,
96
- pagesDir,
192
+ pagesDir = DEFAULT_PAGES_DIR,
193
+ chromeDir = DEFAULT_CHROME_DIR,
194
+ fonts = [],
195
+ siteConfigPath = "src/site-config.ts",
97
196
  overrides,
98
197
  }: IterantStarterOptions = {}) {
99
198
  // A repo's own wrangler config, when it has one. The template checks in a
@@ -125,8 +224,14 @@ export function iterantStarter({
125
224
  previewErrorShell(),
126
225
  // Dev-only: broadcast a reload when a new src/ file matches no module.
127
226
  newFileReload(),
227
+ // Dev-only: restart signals, the content re-arm, and the dev-state probe.
228
+ devServerSignals({ pagesDir, chromeDir }),
229
+ // Restart the dev server when the site config the fonts come from changes.
230
+ siteConfigWatch(siteConfigPath),
128
231
  ];
129
232
 
233
+ const fontFamilies = catalogFontFamilies(fonts);
234
+
130
235
  const vite: NonNullable<AstroUserConfig["vite"]> = {
131
236
  cacheDir: viteCacheDir,
132
237
  plugins: [tailwindcss()],
@@ -215,6 +320,7 @@ export function iterantStarter({
215
320
  // allow a package MINOR to make; the runtime therefore keeps the old
216
321
  // behavior and a later MAJOR can adopt "jsx" deliberately.
217
322
  compressHTML: true,
323
+ fonts: fontFamilies,
218
324
  integrations,
219
325
  vite,
220
326
  // Port 4321 (Astro's default), NOT 3000: inside a Cloudflare Sandbox port