@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.
- package/docs/runtime-contract.md +200 -4
- package/package.json +8 -2
- package/src/components/forms.tsx +155 -0
- package/src/config/preset.ts +108 -2
- package/src/fonts/catalog.json +361 -0
- package/src/fonts/catalog.ts +88 -0
- package/src/index.ts +1 -0
- package/src/integrations/dev-server-signals.mjs +392 -0
- package/src/integrations/site-config-watch.mjs +38 -0
- package/src/layouts/LayoutCore.astro +43 -0
- package/src/layouts/layout-core.ts +56 -0
- package/src/lib/markdown.ts +10 -1
- package/src/lib/submit-form.ts +123 -0
package/docs/runtime-contract.md
CHANGED
|
@@ -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.
|
|
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
|
|
|
@@ -81,6 +81,7 @@ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.6.1._
|
|
|
81
81
|
| `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
|
|
82
82
|
| `github-slugger` | 2.0.0 | heading and anchor slugs |
|
|
83
83
|
| `micromark` | 4.0.2 | the CommonMark compiler markdown wrappers render with |
|
|
84
|
+
| `micromark-extension-gfm` | 3.0.0 | tables, task lists and strikethrough on top of it |
|
|
84
85
|
|
|
85
86
|
<!-- /generated -->
|
|
86
87
|
|
|
@@ -351,6 +352,48 @@ is what puts the component in the client bundle siblings hydrate from. The
|
|
|
351
352
|
bespoke-sibling gate enforces both on every non-draft bespoke page, after the
|
|
352
353
|
build.
|
|
353
354
|
|
|
355
|
+
## Forms (3.7.0)
|
|
356
|
+
|
|
357
|
+
A capturing form posts to the platform, never to a backend of its own. The
|
|
358
|
+
authored convention (an action-less `<form data-iterant-form="<key>">`, the
|
|
359
|
+
`_hp` honeypot, a hidden `data-iterant-form-success` sibling) is what the
|
|
360
|
+
injected runtime and the edge's native fallback act on, and `IterantForm`
|
|
361
|
+
renders exactly that markup so a page stops hand-writing it. Rendered
|
|
362
|
+
statically it behaves as the authored markup does: the runtime intercepts the
|
|
363
|
+
submit and reveals the success element. Hydrated (`client:load`, or inside a
|
|
364
|
+
bespoke page) it owns the submit: it marks the form `data-iterant-form-managed`
|
|
365
|
+
on mount, which is how the runtime knows to stand down, posts the same body to
|
|
366
|
+
the same endpoint, disables the submit button with `aria-busy` while in flight,
|
|
367
|
+
shows the success or the error message, resets the fields on success and never
|
|
368
|
+
navigates. `name` is required and is the form key the submissions dashboard
|
|
369
|
+
aggregates on. `successMessage` and `errorMessage` take a wrapped text value or
|
|
370
|
+
a plain string, so a section passes its entry copy straight through, and an
|
|
371
|
+
absent value falls back to the package default. It ships no utility classes:
|
|
372
|
+
the page styles the form through `className`, the state through
|
|
373
|
+
`data-iterant-form-state` (`idle`, `pending`, `success`, `error`) and the two
|
|
374
|
+
message elements through their attributes.
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
import { readText } from "@iterant/site-runtime/content-values";
|
|
378
|
+
import { IterantForm } from "@iterant/site-runtime/forms";
|
|
379
|
+
|
|
380
|
+
<IterantForm name={readText(data.formKey)} successMessage={data.successCopy}>
|
|
381
|
+
<label htmlFor="email">{readText(data.emailLabel)}</label>
|
|
382
|
+
<input id="email" name="email" type="email" required />
|
|
383
|
+
<button type="submit">{readText(data.submitLabel)}</button>
|
|
384
|
+
</IterantForm>;
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`submitForm(name, fields)` is the same post without the component, for a
|
|
388
|
+
multi-step form or a custom handler. It resolves to `{ ok: true }` or
|
|
389
|
+
`{ ok: false, error }` and never throws, whether the platform accepted,
|
|
390
|
+
refused (`rate_limited`, `turnstile_failed`) or was unreachable (`network`).
|
|
391
|
+
Values are strings only: a `File` is refused as `unsupported_field:<name>`, and
|
|
392
|
+
`readFormFields` turns a form's `FormData` into the record it takes. Pass one
|
|
393
|
+
`idempotencyKey` across retries of a submission and the platform collapses them
|
|
394
|
+
to one row. Submissions are captured on a published site; the dev server has no
|
|
395
|
+
endpoint, so a preview submit settles as an error and captures nothing.
|
|
396
|
+
|
|
354
397
|
## Structured data is derived, never authored
|
|
355
398
|
|
|
356
399
|
Every indexable page emits a schema.org graph (Organization, WebSite, the page
|
|
@@ -400,7 +443,8 @@ import { SITE_SHELL } from "@/site-shell";
|
|
|
400
443
|
```
|
|
401
444
|
|
|
402
445
|
Every shell keeps importing `../layouts/Layout.astro` unchanged. The core owns
|
|
403
|
-
the head (charset, viewport, favicon, generator, version meta,
|
|
446
|
+
the head (charset, viewport, favicon, generator, version meta, the web fonts
|
|
447
|
+
named in `SITE_CONFIG.fonts`, SEO, JSON-LD,
|
|
404
448
|
hreflang, the `head` slot), resolves locale-aware chrome and the page entry, and
|
|
405
449
|
renders the brand shell around the page slot with the `chrome`, `navbar`,
|
|
406
450
|
`footer` and `shellId` props. It never imports a stylesheet and never imports chrome
|
|
@@ -500,14 +544,87 @@ export default defineConfig({
|
|
|
500
544
|
});
|
|
501
545
|
```
|
|
502
546
|
|
|
503
|
-
The preset carries the adapter, the integrations (React, sitemap,
|
|
504
|
-
dev-only plugin loader, preview error shell
|
|
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
|
|
505
550
|
tuning, the CSS browser floor, the dev server port and host, and the toolbar
|
|
506
551
|
setting. A diverged repo
|
|
507
552
|
extends rather than ejects: extra integrations and vite plugins append through
|
|
508
553
|
`overrides`, and any other key it needs it writes in its own `defineConfig`
|
|
509
554
|
object after the spread.
|
|
510
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
|
+
|
|
511
628
|
### The browser floor
|
|
512
629
|
|
|
513
630
|
The preset declares the browsers every brand site's CSS is compiled for, to
|
|
@@ -524,6 +641,85 @@ conversation: lowering the floor to Safari 15 does restore `min-width` breakpoin
|
|
|
524
641
|
and costs 7.5K of fallbacks on the template's own sheet, for engines that still
|
|
525
642
|
cannot render its `@property` and `color-mix()` values.
|
|
526
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
|
+
|
|
527
723
|
## Verify: the gate
|
|
528
724
|
|
|
529
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.
|
|
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,11 +44,14 @@
|
|
|
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",
|
|
50
52
|
"./seo": "./src/components/seo.tsx",
|
|
51
53
|
"./seo-json": "./src/components/seo-json.tsx",
|
|
54
|
+
"./forms": "./src/components/forms.tsx",
|
|
52
55
|
"./layout": "./src/layouts/LayoutCore.astro",
|
|
53
56
|
"./layout-core": "./src/layouts/layout-core.ts",
|
|
54
57
|
"./layout-contract": "./src/layouts/layout-contract.ts",
|
|
@@ -81,7 +84,8 @@
|
|
|
81
84
|
"class-variance-authority",
|
|
82
85
|
"tw-animate-css",
|
|
83
86
|
"github-slugger",
|
|
84
|
-
"micromark"
|
|
87
|
+
"micromark",
|
|
88
|
+
"micromark-extension-gfm"
|
|
85
89
|
]
|
|
86
90
|
},
|
|
87
91
|
"dependencies": {
|
|
@@ -98,6 +102,7 @@
|
|
|
98
102
|
"github-slugger": "2.0.0",
|
|
99
103
|
"lucide-react": "1.31.0",
|
|
100
104
|
"micromark": "4.0.2",
|
|
105
|
+
"micromark-extension-gfm": "3.0.0",
|
|
101
106
|
"motion": "13.0.0",
|
|
102
107
|
"radix-ui": "1.6.7",
|
|
103
108
|
"react": "19.2.8",
|
|
@@ -114,6 +119,7 @@
|
|
|
114
119
|
"@types/node": "24.3.1",
|
|
115
120
|
"@types/react": "19.2.14",
|
|
116
121
|
"@types/react-dom": "19.2.3",
|
|
122
|
+
"jsdom": "^26.0.0",
|
|
117
123
|
"typescript": "5.9.2",
|
|
118
124
|
"vitest": "^4.0.13",
|
|
119
125
|
"wrangler": "^4.107.0"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FormEvent,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
import { type ContentLeaf, readText } from "../lib/content-values";
|
|
10
|
+
import {
|
|
11
|
+
FORM_HONEYPOT_FIELD,
|
|
12
|
+
mintIdempotencyKey,
|
|
13
|
+
readFormFields,
|
|
14
|
+
submitForm,
|
|
15
|
+
} from "../lib/submit-form";
|
|
16
|
+
|
|
17
|
+
export { readFormFields, submitForm } from "../lib/submit-form";
|
|
18
|
+
export type { SubmitFormOptions, SubmitFormResult } from "../lib/submit-form";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SUCCESS_MESSAGE = "Thanks for your submission!";
|
|
21
|
+
export const DEFAULT_ERROR_MESSAGE = "Something went wrong. Please try again.";
|
|
22
|
+
|
|
23
|
+
export interface IterantFormProps {
|
|
24
|
+
/** The form key: `data-iterant-form`, and what the submissions dashboard
|
|
25
|
+
* aggregates on. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** A wrapped text value or a plain string; anything else, or an empty one,
|
|
28
|
+
* falls back to the default. */
|
|
29
|
+
successMessage?: ContentLeaf;
|
|
30
|
+
errorMessage?: ContentLeaf;
|
|
31
|
+
className?: string;
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
onSuccess?(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type FormState = "idle" | "pending" | "success" | "error";
|
|
37
|
+
|
|
38
|
+
// The capturing form of the forms convention (an action-less
|
|
39
|
+
// `data-iterant-form`, the `_hp` honeypot, a hidden success sibling), rendered
|
|
40
|
+
// once so pages stop hand-writing it. Rendered statically it IS that markup and
|
|
41
|
+
// the injected runtime submits it. Hydrated, it owns the submit: same body,
|
|
42
|
+
// same endpoint, its own pending, success and error states, no navigation.
|
|
43
|
+
export function IterantForm({
|
|
44
|
+
name,
|
|
45
|
+
successMessage,
|
|
46
|
+
errorMessage,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
onSuccess,
|
|
50
|
+
}: IterantFormProps) {
|
|
51
|
+
const [state, setState] = useState<FormState>("idle");
|
|
52
|
+
// Set after mount, never in the static render: the mark is what tells the
|
|
53
|
+
// injected runtime this form's submit has an owner and to stand down.
|
|
54
|
+
const [managed, setManaged] = useState(false);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
setManaged(true);
|
|
57
|
+
}, []);
|
|
58
|
+
const inFlight = useRef(false);
|
|
59
|
+
// One key per submission, reused on retry so the platform collapses a
|
|
60
|
+
// retried post to one row, and dropped once the submission is accepted.
|
|
61
|
+
const idempotencyKey = useRef<string | null>(null);
|
|
62
|
+
|
|
63
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
64
|
+
// A runtime from before the managed mark took this submit in its
|
|
65
|
+
// capture-phase listener and posts it itself.
|
|
66
|
+
if (event.isDefaultPrevented()) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
if (inFlight.current) return;
|
|
69
|
+
const form = event.currentTarget;
|
|
70
|
+
// A runtime from before the managed mark may have posted this form while
|
|
71
|
+
// the page was still static and be waiting on the edge: its in-flight mark
|
|
72
|
+
// stands down this submit, and its key is the one a retry reuses, so the
|
|
73
|
+
// platform collapses both posts to one row.
|
|
74
|
+
if (form.dataset.iterantSubmitting === "1") return;
|
|
75
|
+
inFlight.current = true;
|
|
76
|
+
const release = markBusy(form);
|
|
77
|
+
setState("pending");
|
|
78
|
+
idempotencyKey.current ??= form.dataset.iterantIk || mintIdempotencyKey();
|
|
79
|
+
const result = await submitForm(name, readFormFields(form), {
|
|
80
|
+
idempotencyKey: idempotencyKey.current,
|
|
81
|
+
});
|
|
82
|
+
release();
|
|
83
|
+
inFlight.current = false;
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
setState("error");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
idempotencyKey.current = null;
|
|
89
|
+
form.reset();
|
|
90
|
+
setState("success");
|
|
91
|
+
onSuccess?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<form
|
|
97
|
+
data-iterant-form={name}
|
|
98
|
+
data-iterant-form-managed={managed ? "" : undefined}
|
|
99
|
+
data-iterant-form-state={state}
|
|
100
|
+
className={className}
|
|
101
|
+
onSubmit={handleSubmit}
|
|
102
|
+
>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
name={FORM_HONEYPOT_FIELD}
|
|
106
|
+
tabIndex={-1}
|
|
107
|
+
autoComplete="off"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
style={{ position: "absolute", left: "-9999px" }}
|
|
110
|
+
/>
|
|
111
|
+
{children}
|
|
112
|
+
</form>
|
|
113
|
+
<p
|
|
114
|
+
data-iterant-form-success=""
|
|
115
|
+
role="status"
|
|
116
|
+
hidden={state !== "success"}
|
|
117
|
+
>
|
|
118
|
+
{readText(successMessage) || DEFAULT_SUCCESS_MESSAGE}
|
|
119
|
+
</p>
|
|
120
|
+
<p data-iterant-form-error="" role="alert" hidden={state !== "error"}>
|
|
121
|
+
{readText(errorMessage) || DEFAULT_ERROR_MESSAGE}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The submit controls are the caller's children, so the pending state goes on
|
|
128
|
+
// the live elements: `aria-busy` for assistive tech and the page's styles,
|
|
129
|
+
// `disabled` so a second click cannot start a second submission.
|
|
130
|
+
function markBusy(form: HTMLFormElement): () => void {
|
|
131
|
+
const restore = Array.from(form.elements)
|
|
132
|
+
.filter(isSubmitControl)
|
|
133
|
+
.map((control) => {
|
|
134
|
+
const wasDisabled = control.disabled;
|
|
135
|
+
control.disabled = true;
|
|
136
|
+
control.setAttribute("aria-busy", "true");
|
|
137
|
+
return () => {
|
|
138
|
+
control.disabled = wasDisabled;
|
|
139
|
+
control.removeAttribute("aria-busy");
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
return () => {
|
|
143
|
+
for (const fn of restore) fn();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isSubmitControl(
|
|
148
|
+
element: Element,
|
|
149
|
+
): element is HTMLButtonElement | HTMLInputElement {
|
|
150
|
+
return (
|
|
151
|
+
(element instanceof HTMLButtonElement ||
|
|
152
|
+
element instanceof HTMLInputElement) &&
|
|
153
|
+
element.type === "submit"
|
|
154
|
+
);
|
|
155
|
+
}
|
package/src/config/preset.ts
CHANGED
|
@@ -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
|