@unitninety/site-kit 0.0.0-stage → 1.6.2
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/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineBlocks,
|
|
3
|
+
startersFrom,
|
|
4
|
+
defineCollections,
|
|
5
|
+
LAYOUT,
|
|
6
|
+
namedFieldSpec,
|
|
7
|
+
seedFrom,
|
|
8
|
+
SINGLES,
|
|
9
|
+
SLOT_BLOCK,
|
|
10
|
+
type CollectionSpec,
|
|
11
|
+
type FieldSpec,
|
|
12
|
+
type LayoutEntry,
|
|
13
|
+
type SinglesEntry,
|
|
14
|
+
} from './defineBlocks'
|
|
15
|
+
import { BLOCK_ENTRY, BRIDGE_VERSION } from './contract'
|
|
16
|
+
import type {
|
|
17
|
+
BlockEntry,
|
|
18
|
+
CollectionRoutes,
|
|
19
|
+
EditorBlock,
|
|
20
|
+
FormDeclaration,
|
|
21
|
+
FormFieldDeclaration,
|
|
22
|
+
LayoutDeclaration,
|
|
23
|
+
ManifestCollection,
|
|
24
|
+
PageDeclaration,
|
|
25
|
+
SeedSection,
|
|
26
|
+
SingleDeclaration,
|
|
27
|
+
SiteManifest,
|
|
28
|
+
Spec,
|
|
29
|
+
SpecKey,
|
|
30
|
+
} from './contract'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How a site states its content once.
|
|
34
|
+
*
|
|
35
|
+
* `brand.ts` holds what the site says; `blocks.ts` used to hold how a client may change it;
|
|
36
|
+
* a hand-written `Brand` type held the shape a third time. This collapses the three into one
|
|
37
|
+
* document, on one rule:
|
|
38
|
+
*
|
|
39
|
+
* **A wrapped entry is modelled. A bare entry is committed and never modelled.**
|
|
40
|
+
*
|
|
41
|
+
* By default every homepage section is a `block()`, and a repeating record the
|
|
42
|
+
* page names (people, projects, stories) is a `collection()` plus a
|
|
43
|
+
* `relationship` on the block that renders it (`renders: ['people']`). Header,
|
|
44
|
+
* nav and footer stay bare chrome.
|
|
45
|
+
*
|
|
46
|
+
* export const site = defineSite({
|
|
47
|
+
* hero: block({ headline: text('Building Western Australia.') }),
|
|
48
|
+
* logo: { src: '/brand/logo-mark.png', ratio: 241 / 105 },
|
|
49
|
+
* })
|
|
50
|
+
*
|
|
51
|
+
* export const brand = site.values // every component imports this, unchanged
|
|
52
|
+
*
|
|
53
|
+
* `brand.ts`'s values are **not** seed. Components render `text(cms, committed)` — CMS over
|
|
54
|
+
* committed — and the contract guarantees that an unreachable platform renders the site
|
|
55
|
+
* exactly as it does with no CMS at all. The values stay compiled into the bundle forever.
|
|
56
|
+
* The database is the truth for edits; this is the floor underneath them.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** `Symbol.for`, so two instances of this package agree on it. See `BLOCK_ENTRY`. */
|
|
60
|
+
const COLLECTION: unique symbol = Symbol.for('u90.collection')
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* `BLOCK_ENTRY` and `BlockEntry` live in `contract.ts` rather than here, because
|
|
64
|
+
* `blocksField()` has to recognise a `block()` — a zone's blocks are written with it and held
|
|
65
|
+
* in a plain const — and `defineBlocks.ts` importing this module would make the two circular.
|
|
66
|
+
* Re-exported so a site still reads the type from where it reads `block()`.
|
|
67
|
+
*/
|
|
68
|
+
export type { BlockEntry } from './contract'
|
|
69
|
+
|
|
70
|
+
export type CollectionOptions<R extends Record<string, unknown>[] = Record<string, unknown>[]> = {
|
|
71
|
+
appearsOn?: CollectionRoutes
|
|
72
|
+
description?: string
|
|
73
|
+
detailField?: string
|
|
74
|
+
itemNoun?: string
|
|
75
|
+
label?: string
|
|
76
|
+
/** One object per committed row, in the order the site renders them. */
|
|
77
|
+
rows?: R
|
|
78
|
+
/**
|
|
79
|
+
* Stored slug, when it must differ from the document key. Mirrors `block()`'s override and
|
|
80
|
+
* exists for the same reason, one level along.
|
|
81
|
+
*
|
|
82
|
+
* A collection's rows and a site's *committed* array of the same records are routinely not
|
|
83
|
+
* the same shape: jdsi's `brand.projects` carries the photograph and the href its
|
|
84
|
+
* components render, while the `projects` collection's rows carry the write-up, the
|
|
85
|
+
* standfirst and the page slug. Neither derives from the other, both must exist, and only
|
|
86
|
+
* one can own the document key. **A slug keys stored rows, so it is permanent** — move the
|
|
87
|
+
* key and pin the slug.
|
|
88
|
+
*/
|
|
89
|
+
slug?: string
|
|
90
|
+
titleField: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CollectionEntry<
|
|
94
|
+
F extends FieldSpec = FieldSpec,
|
|
95
|
+
R extends Record<string, unknown>[] = Record<string, unknown>[],
|
|
96
|
+
> = CollectionOptions<R> & {
|
|
97
|
+
[COLLECTION]: true
|
|
98
|
+
fields: F
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A section of a page.
|
|
103
|
+
*
|
|
104
|
+
* Declaration order is the home page's section order: the platform indexes stored sections
|
|
105
|
+
* by position, so the order these are written in is the order `page.tsx` renders them.
|
|
106
|
+
*
|
|
107
|
+
* `{ seed: false }` publishes the block without putting it on the home page — `pageHeader`
|
|
108
|
+
* and `pageBody` are available to every page and seeded onto none. That is also what makes
|
|
109
|
+
* them usable as `newPage`: a block absent from the home seed is what a *new* page starts as.
|
|
110
|
+
*
|
|
111
|
+
* `{ layoutOnly: true }` keeps the block out of the page picker too, which `seed: false`
|
|
112
|
+
* alone does not — a client could still add a chrome block, like a header, as an ordinary
|
|
113
|
+
* page section and end up with two. See `EditorBlock.layoutOnly`.
|
|
114
|
+
*
|
|
115
|
+
* `{ slug }` overrides the stored slug when it must differ from the document key. Two
|
|
116
|
+
* entries cannot share a key in one object, but a site may legitimately have a *block* and a
|
|
117
|
+
* *collection* of the same name — jdsi's home page has an empty `projects` section and a
|
|
118
|
+
* `projects` collection of records. **A slug keys stored content, so it is permanent**; the
|
|
119
|
+
* key is only how the brand document addresses the entry. When they must diverge, move the
|
|
120
|
+
* key and pin the slug.
|
|
121
|
+
*/
|
|
122
|
+
export const block = <F extends FieldSpec>(
|
|
123
|
+
fields: F,
|
|
124
|
+
options: {
|
|
125
|
+
label?: string
|
|
126
|
+
layoutOnly?: boolean
|
|
127
|
+
renders?: string[]
|
|
128
|
+
seed?: boolean
|
|
129
|
+
slug?: string
|
|
130
|
+
} = {},
|
|
131
|
+
): BlockEntry<F> => ({
|
|
132
|
+
[BLOCK_ENTRY]: true,
|
|
133
|
+
fields,
|
|
134
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
135
|
+
...(options.layoutOnly === undefined ? {} : { layoutOnly: options.layoutOnly }),
|
|
136
|
+
...(options.renders === undefined ? {} : { renders: options.renders }),
|
|
137
|
+
seed: options.seed ?? true,
|
|
138
|
+
...(options.slug === undefined ? {} : { slug: options.slug }),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A repeatable record type — projects, team, sectors.
|
|
143
|
+
*
|
|
144
|
+
* Rows are a list, so they attach here rather than to any one field; there is no sensible
|
|
145
|
+
* way to spell "six rows" as a value on each field. Rows are frequently derived from data a
|
|
146
|
+
* block also uses, which inside one `defineSite` call would be circular — hoist the shared
|
|
147
|
+
* array to a const above the call and read it from both places.
|
|
148
|
+
*/
|
|
149
|
+
export const collection = <
|
|
150
|
+
F extends FieldSpec,
|
|
151
|
+
R extends Record<string, unknown>[] = Record<string, unknown>[],
|
|
152
|
+
>(
|
|
153
|
+
fields: F,
|
|
154
|
+
options: CollectionOptions<R>,
|
|
155
|
+
): CollectionEntry<F, R> => ({
|
|
156
|
+
[COLLECTION]: true,
|
|
157
|
+
fields,
|
|
158
|
+
...options,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
export type FormEntry = {
|
|
162
|
+
__kind: 'form'
|
|
163
|
+
fields: FormFieldDeclaration[]
|
|
164
|
+
label: string
|
|
165
|
+
recipients?: string[]
|
|
166
|
+
slug?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A form this site collects — a contact form, a careers application.
|
|
171
|
+
*
|
|
172
|
+
* Unlike `block()` and `collection()`, a form commits no copy to `site.values`: there is
|
|
173
|
+
* nothing for a component to render, only a field schema the platform validates submissions
|
|
174
|
+
* against and a mailing list it seeds once. Marked with a plain `__kind` rather than a
|
|
175
|
+
* shared symbol like `BLOCK_ENTRY` — nothing outside this module ever needs to recognise a
|
|
176
|
+
* `form()` entry the way `blocksField()` needs to recognise a `block()` inside a zone, so
|
|
177
|
+
* there is no circular-import reason to hoist the tag into `contract.ts`.
|
|
178
|
+
*/
|
|
179
|
+
export const form = (declaration: {
|
|
180
|
+
fields: FormFieldDeclaration[]
|
|
181
|
+
label: string
|
|
182
|
+
recipients?: string[]
|
|
183
|
+
slug?: string
|
|
184
|
+
}): FormEntry => ({ ...declaration, __kind: 'form' as const })
|
|
185
|
+
|
|
186
|
+
export type SiteSpec = Record<string, unknown>
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The committed value of one entry — what it contributes to `site.values`.
|
|
190
|
+
*
|
|
191
|
+
* A block resolves to an object of its fields' values. A collection resolves to its rows,
|
|
192
|
+
* and to `[]` when it has none, matching `values[key] = rows ?? []` below. A field helper
|
|
193
|
+
* resolves to the value it carries. Anything else — a bare entry — is carried through
|
|
194
|
+
* exactly as written, which is what makes `mark.ratio` a `number` rather than something the
|
|
195
|
+
* CMS has an opinion about.
|
|
196
|
+
*
|
|
197
|
+
* That a carrier keeps its field map is not decoration: this recovers a carrier's committed
|
|
198
|
+
* shape by inferring the parameter, so a carrier that had thrown its map away would resolve
|
|
199
|
+
* `site.values.hero` to the carrier itself. A site reading one of its own fields would then
|
|
200
|
+
* get `TS2339` on the field and a clean typecheck on `.fields`, which is `undefined` at
|
|
201
|
+
* runtime — the wrong half of the pair being the one that compiles.
|
|
202
|
+
*
|
|
203
|
+
* A layout resolves to `never`, and so does a form, for the same reason: the loop in
|
|
204
|
+
* `defineSite` diverts a layout entry into `layouts` and a form entry into `forms`, and
|
|
205
|
+
* never writes `values[key]` for either, so there is no committed value to name a type for.
|
|
206
|
+
* `never` alone does not make `site.values.standard` a compile error — TypeScript happily reads
|
|
207
|
+
* a property typed `never` — so `SiteValues` below drops the key entirely rather than leaning
|
|
208
|
+
* on this branch to do that work.
|
|
209
|
+
*
|
|
210
|
+
* **It reads carriers, never nested `fields`.** A block's own `fields` is still the map the
|
|
211
|
+
* author wrote, so that one is walked. A `group()`'s or `array()`'s is not: both call
|
|
212
|
+
* `namedFields` on their children at construction time, so what survives on the descriptor
|
|
213
|
+
* is an `EditorField[]` — no carriers, and the author's key names demoted to a `name`
|
|
214
|
+
* property. Those helpers state the type they commit on the way out, and this reads it from
|
|
215
|
+
* there. Recursing into a nested `fields` would infer the shape of the editor's schema
|
|
216
|
+
* instead of the shape of the site's content.
|
|
217
|
+
*/
|
|
218
|
+
export type Value<S> =
|
|
219
|
+
S extends BlockEntry<infer F>
|
|
220
|
+
? { [K in keyof F]: Value<F[K]> }
|
|
221
|
+
: S extends CollectionEntry<FieldSpec, infer R>
|
|
222
|
+
? R
|
|
223
|
+
: S extends LayoutEntry | SinglesEntry | FormEntry
|
|
224
|
+
? never
|
|
225
|
+
: SpecKey extends keyof S
|
|
226
|
+
? S extends Spec<infer V>
|
|
227
|
+
? V
|
|
228
|
+
: never
|
|
229
|
+
: S
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every entry of a site spec, resolved to its committed value. See `Value`.
|
|
233
|
+
*
|
|
234
|
+
* The `as` clause drops a layout or form key rather than leaving it mapped to `Value`'s
|
|
235
|
+
* `never`: a property typed `never` still typechecks to read (`{ x: never }`'s `x` is legal
|
|
236
|
+
* to access, just impossible to produce), so keeping the key would leave `site.values.careers`
|
|
237
|
+
* compiling clean and then reading `undefined` at runtime — the exact failure `Value`'s
|
|
238
|
+
* carrier branches exist to avoid, reintroduced for a form key. Removing the key instead
|
|
239
|
+
* makes that access `TS2339: Property does not exist`, which is what `defineSite`'s loop
|
|
240
|
+
* actually does: a form entry is diverted into `forms` and `values[key]` is never written.
|
|
241
|
+
*/
|
|
242
|
+
export type SiteValues<S> = {
|
|
243
|
+
[K in keyof S as S[K] extends LayoutEntry | SinglesEntry | FormEntry ? never : K]: Value<S[K]>
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type Site<V = Record<string, unknown>> = {
|
|
247
|
+
blocks: EditorBlock[]
|
|
248
|
+
collections: ManifestCollection[]
|
|
249
|
+
/** Forms this site collects. Absent on a site that declares none. */
|
|
250
|
+
forms?: FormDeclaration[]
|
|
251
|
+
/** Named arrangements of blocks with one content slot. Absent on a site that declares none. */
|
|
252
|
+
layouts?: LayoutDeclaration[]
|
|
253
|
+
seed: SeedSection[]
|
|
254
|
+
/** The shared values this site starts with. Absent on a site that declares none. */
|
|
255
|
+
singles?: SingleDeclaration[]
|
|
256
|
+
/** What one fresh instance of each block contains. See `SiteManifest.starters`. */
|
|
257
|
+
starters: Record<string, Record<string, unknown>>
|
|
258
|
+
values: V
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/*
|
|
262
|
+
* `LayoutDeclaration` lives in `contract.ts` rather than here, for the same reason
|
|
263
|
+
* `BlockEntry` does: it belongs to `SiteManifest`, the platform's own half of the contract,
|
|
264
|
+
* not just to `Site`. Re-exported so a site still reads the type from where it reads
|
|
265
|
+
* `layout()`.
|
|
266
|
+
*/
|
|
267
|
+
export type {
|
|
268
|
+
FormDeclaration,
|
|
269
|
+
FormFieldDeclaration,
|
|
270
|
+
LayoutDeclaration,
|
|
271
|
+
SingleDeclaration,
|
|
272
|
+
} from './contract'
|
|
273
|
+
|
|
274
|
+
/** `FieldSpec` restated, because `contract.ts` cannot name it. See `BlockEntry`. */
|
|
275
|
+
const isBlock = (entry: unknown): entry is BlockEntry<FieldSpec> =>
|
|
276
|
+
entry !== null && typeof entry === 'object' && BLOCK_ENTRY in entry
|
|
277
|
+
|
|
278
|
+
const isCollection = (entry: unknown): entry is CollectionEntry =>
|
|
279
|
+
entry !== null && typeof entry === 'object' && COLLECTION in entry
|
|
280
|
+
|
|
281
|
+
const isForm = (entry: unknown): entry is FormEntry =>
|
|
282
|
+
entry !== null && typeof entry === 'object' && (entry as { __kind?: unknown }).__kind === 'form'
|
|
283
|
+
|
|
284
|
+
export const isLayout = (entry: unknown): entry is LayoutEntry =>
|
|
285
|
+
entry !== null && typeof entry === 'object' && LAYOUT in entry
|
|
286
|
+
|
|
287
|
+
export const isSingles = (entry: unknown): entry is SinglesEntry =>
|
|
288
|
+
entry !== null && typeof entry === 'object' && SINGLES in entry
|
|
289
|
+
|
|
290
|
+
/** The two a Single may be. Restated here rather than imported for the reason
|
|
291
|
+
* `dashboard/singles/store.ts` restates it: this polices what a *site* declares. */
|
|
292
|
+
const SINGLE_TYPES = ['link', 'text']
|
|
293
|
+
|
|
294
|
+
const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Refuse to build rather than serve a manifest the platform will reject.
|
|
298
|
+
*
|
|
299
|
+
* A site that fails to build is recoverable in minutes. A site that builds and serves a
|
|
300
|
+
* manifest the platform rejects shows the client an empty editor, which they read as "my
|
|
301
|
+
* content has vanished" — the failure the platform's own schema notes single out as the one
|
|
302
|
+
* worth any amount of noise to avoid.
|
|
303
|
+
*
|
|
304
|
+
* Every rule here is re-checked by `src/manifest/validate.ts` on the platform. This is not
|
|
305
|
+
* a substitute for that; it moves the same failure from a client's screen to a build log.
|
|
306
|
+
*/
|
|
307
|
+
const validate = (spec: SiteSpec, collections: CollectionSpec): void => {
|
|
308
|
+
const declared = new Set(Object.keys(collections))
|
|
309
|
+
|
|
310
|
+
const walkFields = (fields: FieldSpec, where: string): void => {
|
|
311
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
312
|
+
if (!IDENTIFIER.test(name)) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`${where}: field name "${name}" is not an identifier. A name becomes part of a dotted path (\`0.slides.1.title\`), so a name containing a "." addresses a different field than it claims to.`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
const nested = (field as { fields?: unknown }).fields
|
|
318
|
+
const relationTo = (field as { relationTo?: string }).relationTo
|
|
319
|
+
if (
|
|
320
|
+
relationTo !== undefined &&
|
|
321
|
+
relationTo !== 'pages' &&
|
|
322
|
+
relationTo !== 'media' &&
|
|
323
|
+
!declared.has(relationTo)
|
|
324
|
+
) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`${where}.${name}: relationTo "${relationTo}" names no collection this site declares. Declare it with collection(), or the platform rejects the whole manifest rather than render a picker with nothing in it.`,
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
if (nested !== undefined) walkFields(namedFieldSpec(nested), `${where}.${name}`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const [key, entry] of Object.entries(spec)) {
|
|
334
|
+
if (isBlock(entry)) walkFields(entry.fields, key)
|
|
335
|
+
if (isCollection(entry)) {
|
|
336
|
+
walkFields(entry.fields, key)
|
|
337
|
+
if (!(entry.titleField in entry.fields)) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`${key}: titleField "${entry.titleField}" names no field on this collection. The CMS list would render a blank first column and no row would be nameable.`,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const [key, entry] of Object.entries(spec)) {
|
|
346
|
+
if (!isBlock(entry)) continue
|
|
347
|
+
|
|
348
|
+
for (const slug of entry.renders ?? []) {
|
|
349
|
+
if (!declared.has(slug)) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`${key}: renders "${slug}", which names no collection this site declares. The rail would show nothing under this block, which reads to a client as their content having gone.`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export const defineSite = <S extends SiteSpec>(spec: S): Site<SiteValues<S>> => {
|
|
359
|
+
const blockSpec: Record<
|
|
360
|
+
string,
|
|
361
|
+
{ fields: FieldSpec; label?: string; layoutOnly?: boolean; renders?: string[] }
|
|
362
|
+
> = {}
|
|
363
|
+
const seedSpec: Record<
|
|
364
|
+
string,
|
|
365
|
+
{ fields: FieldSpec; label?: string; layoutOnly?: boolean; renders?: string[] }
|
|
366
|
+
> = {}
|
|
367
|
+
const collectionSpec: CollectionSpec = {}
|
|
368
|
+
const forms: FormDeclaration[] = []
|
|
369
|
+
const values: Record<string, unknown> = {}
|
|
370
|
+
const layouts: LayoutDeclaration[] = []
|
|
371
|
+
const singles: SingleDeclaration[] = []
|
|
372
|
+
const singleKeys = new Set<string>()
|
|
373
|
+
|
|
374
|
+
for (const [key, entry] of Object.entries(spec)) {
|
|
375
|
+
if (isBlock(entry)) {
|
|
376
|
+
const declared = {
|
|
377
|
+
fields: entry.fields,
|
|
378
|
+
...(entry.label === undefined ? {} : { label: entry.label }),
|
|
379
|
+
...(entry.layoutOnly === undefined ? {} : { layoutOnly: entry.layoutOnly }),
|
|
380
|
+
...(entry.renders === undefined ? {} : { renders: entry.renders }),
|
|
381
|
+
}
|
|
382
|
+
// The slug keys stored content; the document key is only how `brand` addresses it.
|
|
383
|
+
const slug = entry.slug ?? key
|
|
384
|
+
if (slug in blockSpec) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Two blocks resolve to the slug "${slug}". A slug keys stored content, so two sections sharing one would be unresolvable.`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
blockSpec[slug] = declared
|
|
390
|
+
if (entry.seed) seedSpec[slug] = declared
|
|
391
|
+
values[key] = blockValues(entry.fields)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (isCollection(entry)) {
|
|
396
|
+
const { fields, rows, slug: override, ...rest } = entry
|
|
397
|
+
// The slug keys stored rows; the document key is only how `brand` addresses them.
|
|
398
|
+
const slug = override ?? key
|
|
399
|
+
if (slug in collectionSpec) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Two collections resolve to the slug "${slug}". A slug keys stored rows, so two record types sharing one would be unresolvable.`,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
collectionSpec[slug] = {
|
|
405
|
+
...rest,
|
|
406
|
+
fields,
|
|
407
|
+
...(rows === undefined ? {} : { seed: rows }),
|
|
408
|
+
}
|
|
409
|
+
values[key] = rows ?? []
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (isForm(entry)) {
|
|
414
|
+
const { fields, label, recipients, slug: override } = entry
|
|
415
|
+
// The slug keys stored submissions; the document key is only how `brand` addresses it.
|
|
416
|
+
const slug = override ?? key
|
|
417
|
+
// Match the platform manifest identifier grammar before it could discard a declaration.
|
|
418
|
+
const identifier = /^[a-zA-Z][a-zA-Z0-9_]*$/
|
|
419
|
+
if (typeof slug !== 'string' || !identifier.test(slug)) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Invalid form slug "${slug}" for "${key}". Use a letter followed by letters, digits or underscores.`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
for (const field of fields) {
|
|
425
|
+
if (typeof field.name !== 'string' || !identifier.test(field.name)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`Invalid field name "${field.name}" in form "${slug}". Use a letter followed by letters, digits or underscores.`,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (forms.some((declared) => declared.slug === slug)) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Two forms resolve to the slug "${slug}". A slug keys stored submissions, so two forms sharing one would be unresolvable.`,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
forms.push({ fields, label, slug, ...(recipients === undefined ? {} : { recipients }) })
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (isLayout(entry)) {
|
|
441
|
+
const slots = entry.entries.filter((item) => item.blockType === SLOT_BLOCK).length
|
|
442
|
+
if (slots === 0) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Layout "${key}" has no content slot. Put SLOT where the page's own sections go — without one the layout renders the chrome and none of the page, which is silent at runtime.`,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
if (slots > 1) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`Layout "${key}" has two content slots. A page's sections are one list, so there is no answer to which slot they go in until multi-slot exists.`,
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
layouts.push({ ...entry.meta, entries: entry.entries, name: key })
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (isSingles(entry)) {
|
|
457
|
+
/*
|
|
458
|
+
* Refused at build time rather than left for the platform, for the reason `validate`
|
|
459
|
+
* below gives: a site that fails to build is recoverable in minutes, and a site that
|
|
460
|
+
* builds and publishes a declaration the platform drops leaves a client with a value
|
|
461
|
+
* they cannot see and no line anywhere saying so. Each of these mirrors a rule
|
|
462
|
+
* `dashboard/singles/store.ts` enforces on a key a client types, because a seeded row
|
|
463
|
+
* and a typed one are the same row.
|
|
464
|
+
*/
|
|
465
|
+
for (const [key, spec] of Object.entries(entry.declared)) {
|
|
466
|
+
if (!IDENTIFIER.test(key)) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`singles: "${key}" is not a name a block can point at. A key is what a reference() names, so it starts with a letter and holds only letters, numbers and underscores.`,
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
if (singleKeys.has(key)) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`Two declarations resolve to the single "${key}". Two rows answering one key would leave it undecided which one a block is showing, and the client editing one would be shown the other.`,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
if (!SINGLE_TYPES.includes((spec as { type?: string } | undefined)?.type ?? '')) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`singles.${key}: type "${(spec as { type?: string } | undefined)?.type}" is neither "text" nor "link". An image is not a Single — it lives in the media library, pointed at by an upload field on the block that draws it.`,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
if (typeof spec.label !== 'string' || spec.label.trim() === '') {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`singles.${key}: no label. The label is what the client recognises this value by in a list of them, and a row without one is unnameable.`,
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
singleKeys.add(key)
|
|
487
|
+
singles.push({
|
|
488
|
+
key,
|
|
489
|
+
label: spec.label.trim(),
|
|
490
|
+
type: spec.type,
|
|
491
|
+
value: typeof spec.value === 'string' ? spec.value : '',
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
values[key] = entry
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
validate(spec, collectionSpec)
|
|
501
|
+
|
|
502
|
+
/*
|
|
503
|
+
* Computed once, ahead of the return, because `startersFrom` needs the same full block
|
|
504
|
+
* library `blocks` below publishes — a slug zone (`of: [...]`) only names which block its
|
|
505
|
+
* children may be, and resolving that name to the block's own fields needs every block this
|
|
506
|
+
* site declared, not just the one the zone sits on. `blockSpec` alone is not enough for
|
|
507
|
+
* that: it is this function's own author-facing map, still keyed by field maps rather than
|
|
508
|
+
* the published `EditorField[]` shape `seedZone` walks.
|
|
509
|
+
*/
|
|
510
|
+
const allBlocks = defineBlocks(blockSpec)
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
blocks: allBlocks,
|
|
514
|
+
collections: defineCollections(collectionSpec),
|
|
515
|
+
...(forms.length === 0 ? {} : { forms }),
|
|
516
|
+
...(layouts.length === 0 ? {} : { layouts }),
|
|
517
|
+
// `defineBlocks`'s own seed collector, not a copy of it: it emits one section per block
|
|
518
|
+
// in spec order, which is what `seedSpec`'s insertion order carries, and the two ways of
|
|
519
|
+
// writing one site are asserted to seed identically. Two implementations of that could
|
|
520
|
+
// only drift.
|
|
521
|
+
seed: seedFrom(seedSpec),
|
|
522
|
+
...(singles.length === 0 ? {} : { singles }),
|
|
523
|
+
/*
|
|
524
|
+
* `blockSpec`, not `seedSpec`: a starter belongs to every block a client can add, and
|
|
525
|
+
* `seedSpec` holds only the ones that seed — `block({…}, { seed: false })` is excluded
|
|
526
|
+
* there and must not be excluded here. `allBlocks` alongside it is what lets a zone
|
|
527
|
+
* field's own committed children (a slug-named one included) come out translated into
|
|
528
|
+
* the shape the platform stores rather than passed through as the site's own literal.
|
|
529
|
+
*/
|
|
530
|
+
starters: startersFrom(blockSpec, allBlocks),
|
|
531
|
+
// Every branch above writes exactly what `Value` says it writes; the loop is keyed by a
|
|
532
|
+
// runtime tag and there is no way to say that to the checker.
|
|
533
|
+
values: values as SiteValues<S>,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Build the published manifest from a site, plus the things a site knows and a spec cannot.
|
|
539
|
+
*
|
|
540
|
+
* `pages` comes from `pagesFromNav(brand.nav)`, which reads `site.values` — so it cannot be
|
|
541
|
+
* an entry in the spec without being circular. `newPage` is a deliberate short list of
|
|
542
|
+
* starting sections rather than anything derivable: the seed emits one section per block in
|
|
543
|
+
* declaration order, which is the home page's layout and wrong for every other page.
|
|
544
|
+
*/
|
|
545
|
+
export const manifestFrom = ({
|
|
546
|
+
newPage,
|
|
547
|
+
pages = [],
|
|
548
|
+
routesAllPages,
|
|
549
|
+
site,
|
|
550
|
+
}: {
|
|
551
|
+
newPage?: SeedSection[]
|
|
552
|
+
pages?: PageDeclaration[]
|
|
553
|
+
routesAllPages?: boolean
|
|
554
|
+
site: Site<unknown>
|
|
555
|
+
}): SiteManifest => {
|
|
556
|
+
const declared = new Set(site.blocks.map((entry) => entry.slug))
|
|
557
|
+
for (const section of newPage ?? []) {
|
|
558
|
+
if (!declared.has(section.blockType)) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
`newPage names block "${section.blockType}", which this site does not declare. A page created from it renders as nothing at all.`,
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/*
|
|
566
|
+
* The same guard for a page that commits its own content, and it matters more here.
|
|
567
|
+
*
|
|
568
|
+
* `newPage` is wrong in front of the client the first time they add a page, which is at
|
|
569
|
+
* least loud. A page seed is written once, on a tenant's first pair, and the platform drops
|
|
570
|
+
* a section it cannot validate rather than refusing the page — so a `blockType` this site
|
|
571
|
+
* does not declare would arrive as a page that is simply missing its words, months after
|
|
572
|
+
* anyone looked at this file. Raised at module load, where the call is.
|
|
573
|
+
*/
|
|
574
|
+
for (const page of pages) {
|
|
575
|
+
for (const section of page.seed ?? []) {
|
|
576
|
+
if (!declared.has(section.blockType)) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
`page "${page.slug}" seeds block "${section.blockType}", which this site does not declare. It would be dropped on pairing and the page would arrive empty.`,
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
blocks: site.blocks,
|
|
586
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
587
|
+
...(site.collections.length > 0 ? { collections: site.collections } : {}),
|
|
588
|
+
...(site.forms === undefined ? {} : { forms: site.forms }),
|
|
589
|
+
generatedAt: new Date().toISOString(),
|
|
590
|
+
...(site.layouts === undefined ? {} : { layouts: site.layouts }),
|
|
591
|
+
...(newPage ? { newPage } : {}),
|
|
592
|
+
pages,
|
|
593
|
+
...(routesAllPages ? { routesAllPages } : {}),
|
|
594
|
+
...(site.seed.length > 0 ? { seed: site.seed } : {}),
|
|
595
|
+
...(site.singles === undefined ? {} : { singles: site.singles }),
|
|
596
|
+
...(Object.keys(site.starters).length > 0 ? { starters: site.starters } : {}),
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The committed value of every field in a block, keyed by field name.
|
|
602
|
+
*
|
|
603
|
+
* Reads exactly one carrier — `value` — and nothing else. A group carries its own `value`
|
|
604
|
+
* already: `group()` aggregates it from its children at construction time, before
|
|
605
|
+
* `namedFields` strips `value` from those children on their way into the group's schema. By
|
|
606
|
+
* the time this walks a group's `fields`, they are schema-only, so there is nothing left
|
|
607
|
+
* there to recurse into.
|
|
608
|
+
*
|
|
609
|
+
* **Near-identical to `defineBlocks`'s `seedFields`, and deliberately not the same function.**
|
|
610
|
+
* They read different carriers, and that difference is the whole design: `value` is what the
|
|
611
|
+
* component renders when the platform is unreachable, `default` is what the platform stores
|
|
612
|
+
* once at pair time. `upload()` and `link()` exist precisely because the two diverge. Folding
|
|
613
|
+
* them into one function taking a carrier name would put the two halves of that distinction
|
|
614
|
+
* behind one argument, and the next reader would have to guess which call meant which.
|
|
615
|
+
*/
|
|
616
|
+
const blockValues = (fields: FieldSpec): Record<string, unknown> => {
|
|
617
|
+
const out: Record<string, unknown> = {}
|
|
618
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
619
|
+
const value = (field as { value?: unknown }).value
|
|
620
|
+
if (value !== undefined) out[name] = value
|
|
621
|
+
}
|
|
622
|
+
return out
|
|
623
|
+
}
|