@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 +21 -0
- package/README.md +321 -2
- package/dist/assets/form-island.c0f6fac859.js +51 -0
- package/dist/assets/islands.230b20b9c4.js +1 -0
- package/dist/assets/sites-forms.5e6ebf31c3.css +10 -0
- package/dist/assets/sites-forms.css +10 -0
- package/dist/assets/sites.b28b796c6f.css +2 -0
- package/dist/assets/sites.css +2 -0
- package/dist/blocks.d.ts +13 -0
- package/dist/blocks.js +22 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-43XUAO63.js +1255 -0
- package/dist/chunk-43XUAO63.js.map +1 -0
- package/dist/chunk-GA7U3CIN.js +658 -0
- package/dist/chunk-GA7U3CIN.js.map +1 -0
- package/dist/chunk-KEF3V5ZN.js +499 -0
- package/dist/chunk-KEF3V5ZN.js.map +1 -0
- package/dist/index.d.ts +240 -0
- package/dist/index.js +252 -0
- package/dist/index.js.map +1 -0
- package/dist/registry-CdgVymsu.d.ts +764 -0
- package/dist/sections.d.ts +386 -0
- package/dist/sections.js +4 -0
- package/dist/sections.js.map +1 -0
- package/dist/store-BzGw2i5J.d.ts +377 -0
- package/dist/supabase.d.ts +41 -0
- package/dist/supabase.js +174 -0
- package/dist/supabase.js.map +1 -0
- package/dist/web.d.ts +118 -0
- package/dist/web.js +355 -0
- package/dist/web.js.map +1 -0
- package/migrations/0001_genesis_sites.sql +763 -0
- package/migrations/tests/0001_genesis_sites.test.sql +320 -0
- package/package.json +144 -3
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
|
-
#
|
|
1
|
+
# @marktiderman/genesis-sites
|
|
2
2
|
|
|
3
|
-
|
|
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
|