@marktiderman/genesis-sites 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Tiderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,322 @@
1
- # Temporary Holding Version
1
+ # @marktiderman/genesis-sites
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sites and landing pages for Genesis apps. A site is a set of pages made of
4
+ sections. It is drawn from your brand, served as cached HTML with no
5
+ hydration, and published as immutable releases you can roll back.
6
+
7
+ - **Core** (`.`): zod 4 documents (`SiteDoc`, `PageDoc`, `SectionNode`, SEO,
8
+ menus, redirects) in Puck's `{ root, content }` shape, the section registry
9
+ (`defineSection`), props migrations, SEO, the publish checks, the theme from
10
+ your brand, templates and the `SiteStore` contract.
11
+ - **Sections** (`/sections`): 13 server-rendered sections on Genesis UI.
12
+ - **Web** (`/web`): `handleSiteRequest`, the renderer and request handler.
13
+ - **Supabase** (`/supabase`): `createSupabaseSiteStore`, over the
14
+ `genesis_sites` schema in `migrations/`.
15
+ - **Block** (`/blocks`): `sitesBlock`, so `genesis add sites` installs the
16
+ migration.
17
+
18
+ Status: beta. The visual editor (Puck) is next; the editor-facing APIs
19
+ (`sectionManifests`, `sectionJsonSchema`, labels, categories, variants and
20
+ presets) are in place for it. The design and its reasoning are in
21
+ [docs/SITES.md](../../docs/SITES.md).
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ genesis add sites # copies the migration and its pgTAP test into supabase/
27
+ pnpm add @marktiderman/genesis-sites zod@^4
28
+ ```
29
+
30
+ Peers: `react` and `react-dom` 19, `zod` 4, `@marktiderman/genesis-ui`,
31
+ `@marktiderman/genesis-design-system`, `@marktiderman/genesis-forms` and
32
+ `-forms-web` (the form embed), `lucide-react`, and `@supabase/supabase-js` for
33
+ the Supabase store.
34
+
35
+ Then:
36
+
37
+ 1. Expose the `genesis_sites` schema to the API: add it to `schemas` under
38
+ `[api]` in `supabase/config.toml`, and to Exposed schemas in the dashboard.
39
+ 2. Decide who edits a site (see [Who may edit](#who-may-edit)).
40
+ 3. Mount `handleSiteRequest` on your server, before your app's routes.
41
+
42
+ ## A landing page in one call
43
+
44
+ ```ts
45
+ import { createMemorySiteStore, landingPage } from "@marktiderman/genesis-sites";
46
+ import { handleSiteRequest } from "@marktiderman/genesis-sites/web";
47
+
48
+ const brand = {
49
+ name: "Riverside Gym",
50
+ colors: { primary: "#0f766e", primaryDark: "#2dd4bf" },
51
+ fonts: { heading: "space-grotesk", body: "inter" },
52
+ };
53
+ const { site, pages } = landingPage({
54
+ brand,
55
+ message: {
56
+ headline: "Train with people who know your name",
57
+ subhead: "Small classes and real coaching by the river.",
58
+ benefits: [{ title: "Coached classes", body: "A coach in every class." }],
59
+ },
60
+ form: { slug: "free-class", heading: "Book a free class" },
61
+ contact: { email: "hello@riverside.example" },
62
+ });
63
+
64
+ const store = createMemorySiteStore();
65
+ await store.createSite({ id: "riverside", site, pages });
66
+ // The template's sample quotes are placeholders: replace them (or hide the
67
+ // section) and publish. A placeholder still showing blocks the publish.
68
+ await store.publish("riverside", { brand });
69
+ ```
70
+
71
+ `starterSite` makes a three-page site (home, about, contact) from the same
72
+ input. `onePageSite({ name, logo, message, colors, fonts })` seeds a site from
73
+ the least a brand can give.
74
+
75
+ ## Serving a site
76
+
77
+ ```ts
78
+ import { DEFAULT_RESERVED_PATHS } from "@marktiderman/genesis-sites";
79
+ import { handleSiteRequest } from "@marktiderman/genesis-sites/web";
80
+
81
+ export async function handle(request: Request) {
82
+ const siteId = await siteForHost(request.headers.get("host")); // your hostname table
83
+ const res = siteId
84
+ ? await handleSiteRequest(request, {
85
+ store, // a store signed in as nobody: getLive is public
86
+ brand: { getBrand: (id) => brandFor(id) },
87
+ siteId,
88
+ origin: "https://riverside.example", // the primary hostname, for canonical URLs
89
+ reserved: [...DEFAULT_RESERVED_PATHS, "/admin"],
90
+ forms: (slug) => formFor(slug), // see Forms
91
+ })
92
+ : null;
93
+ return res ?? app(request); // null: not the site's path, so your app answers
94
+ }
95
+ ```
96
+
97
+ `handleSiteRequest` answers `GET` and `HEAD` and returns `null` for anything it
98
+ doesn't own: another method, a reserved path (`/api`, `/_server`, `/auth`, and
99
+ `/_gs`, where it serves its own assets), an unknown path (or the site's 404 page
100
+ with `unknownPath: "not-found"`), or a site that isn't published. In order, it
101
+ serves:
102
+
103
+ 1. its own content-hashed assets under `/_gs/` (immutable);
104
+ 2. `robots.txt` and `sitemap.xml`;
105
+ 3. the site's redirects, then a 308 to the one address of a page (lowercase, no
106
+ trailing slash);
107
+ 4. the page: streamed HTML from `renderToReadableStream`, with no hydration, an
108
+ inline theme `<style>`, a Content-Security-Policy, `Cache-Control: public,
109
+ max-age=0, s-maxage=31536000, stale-while-revalidate=86400` and
110
+ `Vercel-Cache-Tag: site:<id>` (plus `form:<slug>` for each form the page
111
+ embeds: purge that tag when you change the form, since the page cached the
112
+ form it was rendered with).
113
+
114
+ The page loads one script, the islands loader (2.3 KB), and only when it has a
115
+ phone menu or a form. Pass `basePath` to mount a site under a path.
116
+
117
+ **Publishing and the cache.** Wrap the editor's store with
118
+ `withCacheInvalidation` so publish and rollback purge `site:<id>`. Mind what
119
+ the purge does: on Vercel, `invalidateByTag` serves each cached page once more
120
+ while it regenerates, and `dangerouslyDeleteByTag` drops it. Use the delete
121
+ for a rollback that takes something down:
122
+
123
+ ```ts
124
+ import { dangerouslyDeleteByTag, invalidateByTag } from "@vercel/functions";
125
+
126
+ const editorStore = withCacheInvalidation(store, (tags, { action }) =>
127
+ action === "rollback" ? dangerouslyDeleteByTag(tags) : invalidateByTag(tags),
128
+ );
129
+ ```
130
+
131
+ Archiving a site, or editing its brand, doesn't go through the store: purge
132
+ the tag there too (the database sends a `genesis_sites` notification on every
133
+ publish, rollback and archive, for a listener). A page rendered with a brand
134
+ that changed since the release was checked is served with a 5-minute cache
135
+ and a warning until you publish again. A failed purge is logged and rethrown.
136
+
137
+ A hidden section stays in the draft and is left out of the release, so it is
138
+ never public. A stored page that no longer parses is left out of the site
139
+ (and logged) rather than taking the whole site down.
140
+
141
+ **Preview.** `mode: "preview"` renders the drafts, with `noindex` and
142
+ `Cache-Control: private, no-store`, through a store signed in as the viewer,
143
+ so the database's `can_view` decides who sees them.
144
+
145
+ ## Sections
146
+
147
+ | Type | Variants | Presets |
148
+ |---|---|---|
149
+ | `nav` | simple, centered, minimal | Header with menu; Centred header; Landing page header |
150
+ | `hero` | centered, split, image-bg | Centred hero; Hero with image |
151
+ | `features` | grid, list, alternating | Three features; Feature list |
152
+ | `cta` | banner, card, split | Banner; Card |
153
+ | `testimonials` | grid, single | Quotes; One quote |
154
+ | `faq` | accordion, list | FAQ |
155
+ | `pricing` | cards, compact | Three plans |
156
+ | `logos` | row, grid | Logo row |
157
+ | `stats` | row, cards | Three numbers |
158
+ | `gallery` | grid, masonry | Photo grid |
159
+ | `rich-text` | narrow, wide | Text |
160
+ | `form-embed` | card, split | Contact form; Form card |
161
+ | `footer` | simple, columns, minimal | Footer; Footer with columns |
162
+
163
+ Every section is server-safe React on Genesis UI (`Band`, `Display`,
164
+ `BandLead`, `Button`), sits on a surface (`base`, `muted`, `accent`,
165
+ `inverse`), takes its heading level from the page (one `h1`, from the first
166
+ hero), and passes axe's WCAG 2.2 A and AA rules in the tests. Stored links are
167
+ checked again when they render: only site paths, `#fragments`, `http(s)`,
168
+ `mailto` and `tel` links print; anything else renders as an inert link and is
169
+ logged. Rich text is sanitised by Genesis UI's `RichText`, and JSON-LD is
170
+ escaped so stored text can't close its `<script>`.
171
+
172
+ ### Your own sections
173
+
174
+ ```ts
175
+ import { defineSection, registerSection, Line } from "@marktiderman/genesis-sites";
176
+ import { z } from "zod";
177
+
178
+ registerSection(
179
+ defineSection({
180
+ type: "class-times",
181
+ version: 1,
182
+ label: "Class times",
183
+ category: "content",
184
+ schema: z.object({ heading: Line(90), rows: z.array(z.object({ day: Line(20), time: Line(20) })).max(14) }),
185
+ variants: [{ id: "table", label: "Table" }],
186
+ defaultVariant: "table",
187
+ presets: [{ name: "Weekly times", variant: "table", props: { heading: "Class times", rows: [{ day: "Monday", time: "6 pm" }] } }],
188
+ render: ({ props, ctx }) => <ClassTimes {...props} level={ctx.headingLevel} />,
189
+ }),
190
+ );
191
+ ```
192
+
193
+ `defineSection` checks the definition (every preset against the schema, a
194
+ migration for every version step) and freezes it. A built-in type is replaced
195
+ only with `registerSection(def, { replace: true })`. When props change shape,
196
+ raise `version` and add `migrations[n]`, a pure function from version `n - 1`
197
+ props; `migrateNode` runs the steps on read and a publish writes the result
198
+ into the release. `sectionJsonSchema(type)` gives the props' JSON Schema (for
199
+ editor fields and AI output) and `sectionManifests()` the editor's catalog.
200
+
201
+ ## Publish checks
202
+
203
+ `store.publish` runs `prepareRelease`, which migrates every node and refuses
204
+ to publish (`PublishBlockedError`, with `issues`) when:
205
+
206
+ - a heading would break the outline: the renderer gives every page exactly
207
+ one `h1` (the first hero's title, or the page title, visually hidden), so
208
+ rich text may not hold another `h1` or skip a level, and a page has at most
209
+ one hero;
210
+ - a brand colour pair the sections draw is under 4.5:1 (3:1 for the focus
211
+ ring), in each colour scheme the site uses, or the publish wasn't given the
212
+ brand to check;
213
+ - an image has no alt text and isn't marked decorative;
214
+ - a section is unknown, has invalid props or was saved by newer code;
215
+ - a template placeholder is still visible;
216
+ - a page path is malformed or reserved, or the site has no home page.
217
+
218
+ Warnings (an unknown variant, a page with no hero, a missing description)
219
+ don't block.
220
+ `checkRelease`, `checkPage`, `checkContrast` and `checkHtmlOutline` are
221
+ exported for an editor's lint panel.
222
+
223
+ ## SEO
224
+
225
+ Each page gets a title from the site's template (`%s · Riverside Gym`), a
226
+ description, `robots`, a canonical URL from `origin`, and Open Graph and
227
+ Twitter tags. The home page carries `Organization` and `WebSite` JSON-LD, and
228
+ every page below it a `BreadcrumbList`. `sitemap.xml` lists the indexable
229
+ pages; `robots.txt` points at it, or disallows everything for a `noindex` site
230
+ and in preview. Moving a page (`store.movePage`) leaves a 301 from the old
231
+ path and rewrites older redirects to point at the new one, so there are no
232
+ chains.
233
+
234
+ ## Forms
235
+
236
+ A `form-embed` section names a form by slug. Your `forms` resolver says what
237
+ the slug is:
238
+
239
+ ```ts
240
+ forms: (slug) =>
241
+ slug === "free-class"
242
+ ? { kind: "definition", definition: freeClass.definition, submitUrl: "/api/forms/free-class" }
243
+ : slug === "waitlist"
244
+ ? { kind: "embed", url: "https://forms.example/waitlist", title: "Join the waitlist" }
245
+ : null,
246
+ ```
247
+
248
+ - `submitUrl` is a site path or an https URL.
249
+ - A genesis-forms definition with no logic, pages, recall or hidden inputs
250
+ (most contact and sign-up forms) renders as a plain HTML form. The islands
251
+ loader sends `{ form, answers }` as JSON, with answers keyed by field id as
252
+ genesis-forms' `answers()` gives them. Without script, the browser posts the
253
+ same fields as an ordinary form.
254
+ - A definition that needs the engine renders with forms-web's `<Form>`, and
255
+ the form bundle loads on that page only.
256
+ - `embed` shows an https URL in a sandboxed iframe.
257
+
258
+ Either way, your endpoint checks the answers again (`validateSubmission` from
259
+ genesis-forms). An unknown slug renders nothing and logs.
260
+
261
+ ## Who may edit
262
+
263
+ The migration asks two hooks, which ship as stubs that fail closed:
264
+
265
+ - `genesis_sites.can_edit(site_id uuid)` answers false;
266
+ - `genesis_sites.can_view(site_id uuid)`, for draft and release reads, asks
267
+ `can_edit`.
268
+
269
+ Replace them in a migration of your own, after this one, with the same
270
+ signature:
271
+
272
+ ```sql
273
+ create or replace function genesis_sites.can_edit(site_id uuid) returns boolean
274
+ language sql stable security definer set search_path = '' as $$
275
+ select exists (select 1 from public.brand_members m
276
+ join public.brand_sites b on b.brand_id = m.brand_id
277
+ where b.site_id = can_edit.site_id and m.user_id = auth.uid() and m.role in ('owner', 'editor'))
278
+ $$;
279
+ ```
280
+
281
+ `create or replace` keeps the grants. Name the person (`auth.uid()`) in the
282
+ hook: the write functions are security definer, so your tables' row level
283
+ security doesn't filter inside them. Genesis writes the stubs only through
284
+ `genesis_sites.ensure_hook_stubs()`, over a missing hook or the untouched
285
+ stub, so neither `genesis migrate` nor a later genesis-sites migration ever
286
+ overwrites your version. `genesis_sites.reserved_paths()` works the same way
287
+ for the page paths your app keeps.
288
+
289
+ The accessibility and content checks run in the stores (`prepareRelease`)
290
+ before `publish_site`; the database checks shape, paths and revisions. So the
291
+ checks guide editors but are not a security boundary: an editor calling the
292
+ function directly can skip them (and reports its own `brand_hash`). Access
293
+ rules can't be skipped, and the renderer checks every link again. An archived
294
+ site stays down: an editor can't publish or roll it back.
295
+
296
+ Anon reads only `genesis_sites.live_release(site_id)`: the published snapshot
297
+ of a live site. Drafts, page rows and release notes are never readable by
298
+ anon. Sites are created with the service role (`genesis_sites.create_site`).
299
+ Store uploads under `sites/<site_id>/...` so your storage policies can scope
300
+ by site with `can_edit`; the migration's header has an example policy. Limit
301
+ the bucket's `allowed_mime_types` to images and PDF: never SVG or HTML, which
302
+ a browser runs as a page. `putAsset` refuses other types before it uploads.
303
+
304
+ ## Stores
305
+
306
+ `createSupabaseSiteStore(client)` uses the `genesis_sites` functions: pass a
307
+ client signed in as the editor for drafts and publishing, or an anonymous one
308
+ for `getLive`. `createMemorySiteStore()` keeps the same rules in memory, for
309
+ tests, demos and local previews. Draft saves compare and swap a revision: a
310
+ save from an old revision returns `{ conflict, rev }` with the current
311
+ document instead of overwriting it.
312
+
313
+ ## Hostnames
314
+
315
+ The package keeps no hostname table. Resolve the request's host to a site id
316
+ yourself (strip any inbound tenant header), pass it as `siteId`, and pass the
317
+ site's primary hostname as `origin`. The design note covers custom domains on
318
+ Vercel.
319
+
320
+ ## License
321
+
322
+ MIT