@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,1602 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeVideoSeed,
|
|
3
|
+
normalizeFileSeed,
|
|
4
|
+
videoSeedForImport,
|
|
5
|
+
fileSeedForImport,
|
|
6
|
+
type VideoSeedValue,
|
|
7
|
+
type FileSeedValue,
|
|
8
|
+
} from './media'
|
|
9
|
+
import {
|
|
10
|
+
imageSeedForImport,
|
|
11
|
+
normalizeImageSeed,
|
|
12
|
+
validateImageDeclaration,
|
|
13
|
+
ImageValidationError,
|
|
14
|
+
type ImageSeedValue,
|
|
15
|
+
type ImageConstraints,
|
|
16
|
+
type ImageVariantDeclaration,
|
|
17
|
+
} from './image'
|
|
18
|
+
import {
|
|
19
|
+
BLOCK_ENTRY,
|
|
20
|
+
BRIDGE_VERSION,
|
|
21
|
+
type BlockEntry,
|
|
22
|
+
type CollectionRoutes,
|
|
23
|
+
type EditorBlock,
|
|
24
|
+
type EditorField,
|
|
25
|
+
type ManifestCollection,
|
|
26
|
+
type PageDeclaration,
|
|
27
|
+
type SeedSection,
|
|
28
|
+
type SiteManifest,
|
|
29
|
+
type Spec,
|
|
30
|
+
type SpecKey,
|
|
31
|
+
} from './contract'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a site declares its own content schema.
|
|
35
|
+
*
|
|
36
|
+
* This is the piece that makes onboarding a client free. The site author writes the
|
|
37
|
+
* blocks their design actually has; the platform renders an editor from them. No
|
|
38
|
+
* migration, no deploy, no code in the platform repo.
|
|
39
|
+
*
|
|
40
|
+
* export const blocks = defineBlocks({
|
|
41
|
+
* hero: {
|
|
42
|
+
* label: 'Hero',
|
|
43
|
+
* fields: {
|
|
44
|
+
* headline: text({ required: true }),
|
|
45
|
+
* slides: array({ fields: { title: text(), photo: upload() } }),
|
|
46
|
+
* },
|
|
47
|
+
* },
|
|
48
|
+
* })
|
|
49
|
+
*
|
|
50
|
+
* Blocks and fields are keyed by object rather than listed in arrays, which is not
|
|
51
|
+
* cosmetic: object keys are unique by construction, so two blocks cannot share a slug
|
|
52
|
+
* and two fields cannot share a name. Both of those are rejected by the platform's
|
|
53
|
+
* validator, and this way they are rejected by the type system first — a duplicate
|
|
54
|
+
* slug makes stored content unresolvable, and a duplicate name makes a dotted path
|
|
55
|
+
* ambiguous.
|
|
56
|
+
*
|
|
57
|
+
* Labels default to a humanised version of the key, because the common case is a label
|
|
58
|
+
* that just reads the key back.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** Everything a field helper accepts. Each helper narrows it. */
|
|
62
|
+
type Common = {
|
|
63
|
+
/**
|
|
64
|
+
* What this field currently says on the site, as the *platform* will store it.
|
|
65
|
+
*
|
|
66
|
+
* Carried into the manifest's `seed` so a tenant's first pair populates its CMS with the
|
|
67
|
+
* site's real copy instead of blanks. Never reaches the editor as schema — the platform
|
|
68
|
+
* stores it once, at pair time, and from then on the stored content is the only truth.
|
|
69
|
+
*/
|
|
70
|
+
default?: unknown
|
|
71
|
+
description?: string
|
|
72
|
+
label?: string
|
|
73
|
+
readOnly?: boolean
|
|
74
|
+
required?: boolean
|
|
75
|
+
/**
|
|
76
|
+
* What this field currently says on the site, as the *site* renders it.
|
|
77
|
+
*
|
|
78
|
+
* Identical to `default` for almost every field. The two diverge exactly where the stored
|
|
79
|
+
* form and the rendered form differ: `link()` renders `{ href, label }` and stores
|
|
80
|
+
* `{ kind: 'url', url, … }`. `upload()` carries the same repo path on both, but they mean
|
|
81
|
+
* different things about it — rendered, it is the asset; seeded, it is the key the
|
|
82
|
+
* platform's media ingestion relinks to an id.
|
|
83
|
+
*
|
|
84
|
+
* This is what `defineSite` collects into `site.values` — the object every component
|
|
85
|
+
* imports as `brand`.
|
|
86
|
+
*/
|
|
87
|
+
value?: unknown
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Available only on copy-bearing scalar helpers. */
|
|
91
|
+
type LocalizableCommon = Common & {
|
|
92
|
+
/** Eligible copy is localized by default. Set false for identifiers and textual facts. */
|
|
93
|
+
localized?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A field before its name is known — the name comes from its object key.
|
|
98
|
+
*
|
|
99
|
+
* `V` is the type of the value this field commits, carried phantom-wise through `Spec` so
|
|
100
|
+
* that `defineSite` can read `site.values` off the helpers a site wrote. It is a property of
|
|
101
|
+
* the *helper*, not of its argument: widening the argument to `Common | V` is what would let
|
|
102
|
+
* an options object be mistaken for a bare value, which `opts` would then classify as options
|
|
103
|
+
* and `base` would spread to `{}` — the value gone, with no error anywhere.
|
|
104
|
+
*/
|
|
105
|
+
type Unnamed<V = unknown> = AnyField & Spec<V>
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A field of unstated value type — the shape `FieldSpec` is keyed by.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately *without* `Spec`, and that is load-bearing rather than tidy. A helper's value
|
|
111
|
+
* type is inferred, and a contextual return type is an inference site like any other: were
|
|
112
|
+
* `FieldSpec` a map of `Unnamed<unknown>`, then `block({ address: group({ … }) })` would
|
|
113
|
+
* contextually type the `group` call as returning `Spec<unknown>`, `V` would infer as
|
|
114
|
+
* `unknown` from that alone, and the aggregate default would never be reached — every nested
|
|
115
|
+
* group in every site would come out `unknown`. Carrying no `VALUE` at all leaves nothing to
|
|
116
|
+
* infer from, so each helper's own reasoning stands.
|
|
117
|
+
*/
|
|
118
|
+
type AnyField = Omit<EditorField, 'name'> & { name?: never }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The value a field spec commits, or `never` for one that carries nothing.
|
|
122
|
+
*
|
|
123
|
+
* `Spec`'s phantom property is optional, so every object matches `Spec<infer V>` — the
|
|
124
|
+
* `SpecKey extends keyof T` test is what tells a real carrier apart from an object that
|
|
125
|
+
* simply has no opinion. See `SpecKey`.
|
|
126
|
+
*/
|
|
127
|
+
type Committed<T> = SpecKey extends keyof T ? (T extends Spec<infer V> ? V : never) : never
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A non-schema marker `link()` stamps onto the field spec it returns, so that later code
|
|
131
|
+
* can recognise a link field by how it was *built* rather than guessing from its shape.
|
|
132
|
+
*
|
|
133
|
+
* `seedItem` uses this to decide whether to translate an array item's field into the
|
|
134
|
+
* platform's stored link shape. Guessing instead — e.g. "does this object have an `href`"
|
|
135
|
+
* — would misfire on a plain `group` field whose committed value happens to have an `href`
|
|
136
|
+
* alongside other keys, a realistic shape for a card with a title and a link.
|
|
137
|
+
*
|
|
138
|
+
* **A symbol, and the one non-schema key `namedFields` does not strip.** `seedItem` recurses,
|
|
139
|
+
* so it has to recognise a link at *any* depth inside an array item — and a link nested inside
|
|
140
|
+
* a group has already been through `namedFields` when that group was constructed. A string
|
|
141
|
+
* marker would have been stripped there, before anything could read it, and the item's
|
|
142
|
+
* committed `{ href }` would then be pruned as a key the schema does not declare: the button's
|
|
143
|
+
* URL gone, silently, which is worse than not pruning at all. A symbol key survives an object
|
|
144
|
+
* rest spread, and is invisible to `JSON.stringify` and `Object.keys` — so it reaches
|
|
145
|
+
* `seedItem` without reaching the platform, which receives this schema as JSON.
|
|
146
|
+
*
|
|
147
|
+
* `Symbol.for`, not `Symbol()`: the registry is keyed by string, so two *instances* of this
|
|
148
|
+
* package in one bundle — a vendored tarball beside a workspace link, which is exactly how the
|
|
149
|
+
* first site to use this consumes it — still agree on the marker. With `Symbol()` they would
|
|
150
|
+
* not, and the failure is silent and is the one this marker exists to prevent: a link built by
|
|
151
|
+
* one instance is unrecognisable to the other, its committed `href` is pruned as a key the
|
|
152
|
+
* schema does not declare, and the button is stored with no address.
|
|
153
|
+
*/
|
|
154
|
+
const LINK_FIELD_MARKER: unique symbol = Symbol.for('u90.linkField')
|
|
155
|
+
|
|
156
|
+
/** A plain object — not null, not an array. The shape a group's committed value has. */
|
|
157
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
158
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
159
|
+
|
|
160
|
+
const humanise = (key: string): string =>
|
|
161
|
+
key
|
|
162
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
163
|
+
.replace(/[-_]+/g, ' ')
|
|
164
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* `default` is *kept* on the spec object and stripped later, in `namedFields`.
|
|
168
|
+
*
|
|
169
|
+
* It has to survive here: `seedFrom` reads the same spec the author wrote, so a value
|
|
170
|
+
* dropped at this point is gone before the seed is collected — which is exactly the
|
|
171
|
+
* bug the round-trip check caught, seed sections arriving with a `blockType` and no
|
|
172
|
+
* values at all. It must equally never reach the schema, where a field carrying its
|
|
173
|
+
* default would let a stale site config overwrite what a client typed. So: kept on the
|
|
174
|
+
* spec, removed on the way into `EditorField[]`.
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Let a scalar helper take its copy directly: `text('Building WA')`.
|
|
179
|
+
*
|
|
180
|
+
* The rule is one sentence: **a non-object argument is the value; an object is options.**
|
|
181
|
+
* It applies only to the scalar helpers, whose values are never objects. `array`, `group`,
|
|
182
|
+
* `link`, `relationship` and `blocks` keep the object-options form, because theirs are.
|
|
183
|
+
*/
|
|
184
|
+
const opts = (arg: unknown): Common =>
|
|
185
|
+
isRecord(arg) ? (arg as Common) : { default: arg, value: arg }
|
|
186
|
+
|
|
187
|
+
const base = <V = unknown>(
|
|
188
|
+
type: EditorField['type'],
|
|
189
|
+
raw: Common & { localized?: boolean },
|
|
190
|
+
): Unnamed<V> => {
|
|
191
|
+
const options: Common & { localized?: boolean } = {
|
|
192
|
+
...raw,
|
|
193
|
+
value: 'value' in raw ? raw.value : raw.default,
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
...(options.default === undefined ? {} : { default: options.default }),
|
|
197
|
+
...(options.value === undefined ? {} : { value: options.value }),
|
|
198
|
+
...(options.description === undefined ? {} : { description: options.description }),
|
|
199
|
+
...(options.localized === undefined ? {} : { localized: options.localized }),
|
|
200
|
+
...(options.readOnly === undefined ? {} : { readOnly: options.readOnly }),
|
|
201
|
+
...(options.required === undefined ? {} : { required: options.required }),
|
|
202
|
+
label: options.label ?? '',
|
|
203
|
+
type,
|
|
204
|
+
} as Unnamed<V>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* What a scalar helper commits, over `B` — the type its bare form takes.
|
|
209
|
+
*
|
|
210
|
+
* Four signatures, resolved in the order `base` itself resolves the carriers: an explicit
|
|
211
|
+
* `value`, else an explicit `default`, else a bare argument, else **nothing**.
|
|
212
|
+
*
|
|
213
|
+
* That last one is the whole point of writing these as overloads. A helper handed no carrier
|
|
214
|
+
* commits no carrier: `blockValues` skips it, and `brand.pageBody.heading` is `undefined` at
|
|
215
|
+
* runtime. A signature that answered `string` there would be a type that compiles
|
|
216
|
+
* `brand.pageBody.heading.trim()` into a `TypeError` — and it would do it at exactly the
|
|
217
|
+
* fallback-render guarantee this document exists to hold up, in the direction that crashes
|
|
218
|
+
* rather than renders blank. A hand-written `Brand` said `heading?: string`; this says
|
|
219
|
+
* `undefined`, which is stricter and equally honest.
|
|
220
|
+
*
|
|
221
|
+
* The carrier signatures read the committed type back off what was written rather than
|
|
222
|
+
* pinning it, because `default` and `value` legitimately hold different shapes — richText
|
|
223
|
+
* stores a Lexical document and renders copy, an upload's committed value may be an explicit
|
|
224
|
+
* `null`. What is *not* being done is widening the argument to `Common | V`: each object
|
|
225
|
+
* signature demands a **named** carrier, so an options object stays options and
|
|
226
|
+
* `text({ label: 'Headline' })` matches the last signature rather than being read as a value
|
|
227
|
+
* that `opts` would then classify as options and drop on the floor.
|
|
228
|
+
*/
|
|
229
|
+
type Scalar<B, O extends Common = Common> = {
|
|
230
|
+
<V>(arg: O & { value: V }): Unnamed<V>
|
|
231
|
+
<V>(arg: O & { default: V }): Unnamed<V>
|
|
232
|
+
(arg: B): Unnamed<B>
|
|
233
|
+
(arg?: O): Unnamed<undefined>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const text: Scalar<string, LocalizableCommon> = (arg: LocalizableCommon | string = {}) =>
|
|
237
|
+
base<never>('text', opts(arg) as LocalizableCommon)
|
|
238
|
+
export const textarea: Scalar<string, LocalizableCommon> = (arg: LocalizableCommon | string = {}) =>
|
|
239
|
+
base<never>('textarea', opts(arg))
|
|
240
|
+
export const number: Scalar<number> = (arg: Common | number = {}) =>
|
|
241
|
+
base<never>('number', opts(arg))
|
|
242
|
+
export const checkbox: Scalar<boolean> = (arg: Common | boolean = {}) =>
|
|
243
|
+
base<never>('checkbox', opts(arg))
|
|
244
|
+
export const email: Scalar<string> = (arg: Common | string = {}) => base<never>('email', opts(arg))
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A script URL, an embed snippet, or anything else that is code rather than prose.
|
|
248
|
+
*
|
|
249
|
+
* Identical to `textarea` in what it stores and what it commits; the type is what tells the
|
|
250
|
+
* editor to draw a monospace control with spellcheck off and to badge the row `E`. Nothing
|
|
251
|
+
* about rendering changes — the site does whatever it already did with the string.
|
|
252
|
+
*
|
|
253
|
+
* The distinction earns itself the moment a client opens the rail: `pbir`'s reviews block
|
|
254
|
+
* holds a heading, a lead, two button captions *and* the elfsight script URL, and every one
|
|
255
|
+
* of them read as plain text. A field a client should almost never touch looked exactly like
|
|
256
|
+
* the four they edit constantly.
|
|
257
|
+
*/
|
|
258
|
+
export const code: Scalar<string> = (arg: Common | string = {}) => base<never>('code', opts(arg))
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Long-form copy, in whichever of its forms the *site* renders.
|
|
262
|
+
*
|
|
263
|
+
* The field that made `Scalar` read its answer off the carrier rather than pin it: the
|
|
264
|
+
* platform stores a Lexical document while a site may render either plain copy or a document
|
|
265
|
+
* of its own, so `string` would be wrong for every site that renders a document and `unknown`
|
|
266
|
+
* would put a cast at every reader of a field that is a string almost every time.
|
|
267
|
+
*/
|
|
268
|
+
export const richText: Scalar<string, LocalizableCommon> = (arg: LocalizableCommon | string = {}) =>
|
|
269
|
+
base<never>('richText', opts(arg))
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A calendar day — a post's published date, an event, a completion date.
|
|
273
|
+
*
|
|
274
|
+
* Not localizable, unlike `text` and `richText`: a date is the same day in every language,
|
|
275
|
+
* and `validate.ts` restricts `localized` to the three copy types for that reason.
|
|
276
|
+
*/
|
|
277
|
+
export const date: Scalar<string> = (arg: Common | string = {}) => base<never>('date', opts(arg))
|
|
278
|
+
|
|
279
|
+
/** The choices a `select` offers: a `{ label, value }` pair, or a value to humanise. */
|
|
280
|
+
type SelectOptions = Common & { options: ({ label: string; value: string } | string)[] }
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One of a fixed set of choices.
|
|
284
|
+
*
|
|
285
|
+
* The same four cases as `Scalar`, except that the bare form needs the choices alongside it —
|
|
286
|
+
* a select with no `options` is a control with nothing in it. `select({ options })` on its
|
|
287
|
+
* own declares the choices and commits none of them, which is `undefined`, not the first
|
|
288
|
+
* choice: the platform holds no defaults, and a field the site never committed renders as
|
|
289
|
+
* whatever the component does with nothing.
|
|
290
|
+
*/
|
|
291
|
+
export const select: {
|
|
292
|
+
<V>(arg: SelectOptions & { value: V }): Unnamed<V>
|
|
293
|
+
<V>(arg: SelectOptions & { default: V }): Unnamed<V>
|
|
294
|
+
(arg: string, extra: SelectOptions): Unnamed<string>
|
|
295
|
+
(arg: SelectOptions): Unnamed<undefined>
|
|
296
|
+
} = (arg: SelectOptions | string, extra?: SelectOptions) => {
|
|
297
|
+
const options = (extra ? { ...extra, default: arg, value: arg } : arg) as SelectOptions
|
|
298
|
+
return {
|
|
299
|
+
...base<never>('select', options),
|
|
300
|
+
options: options.options.map((option) =>
|
|
301
|
+
typeof option === 'string' ? { label: humanise(option), value: option } : option,
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* An image, video or document from the client's own media library.
|
|
308
|
+
*
|
|
309
|
+
* The argument is the **committed** asset the component falls back to — a path in the site
|
|
310
|
+
* repo. It is carried on both carriers: `value` is what the component renders when the
|
|
311
|
+
* platform is unreachable, and `default` is the path the platform's media ingestion relinks
|
|
312
|
+
* to the id of the media document now holding those bytes.
|
|
313
|
+
*
|
|
314
|
+
* **This used to seed nothing**, on the reasoning that a tenant's media library is empty
|
|
315
|
+
* until someone uploads to it, so a repo path stored as a media id would resolve to nothing
|
|
316
|
+
* and show a broken thumbnail on the day the client paired. That reasoning was right and its
|
|
317
|
+
* premise has expired: `importArchiveAssets` now uploads every committed path a site names,
|
|
318
|
+
* and `relinkSeedAssets` swaps the path for the id it was uploaded as. With no path in the
|
|
319
|
+
* seed there was nothing to swap, so a client paired onto a library full of their own
|
|
320
|
+
* photographs and an editor whose every image slot read "Use the site's own image".
|
|
321
|
+
*
|
|
322
|
+
* The platform is what keeps this honest, and it has to be, because a path is not an id: a
|
|
323
|
+
* seeded upload value that ingestion did not relink is **dropped** before it is stored — see
|
|
324
|
+
* `dropUnlinkedUploads` in the platform's `manifest/seed.ts`. A pair with no ingestion behind
|
|
325
|
+
* it therefore lands exactly where it landed before: an empty slot, and the committed asset
|
|
326
|
+
* still rendering underneath it.
|
|
327
|
+
*
|
|
328
|
+
* `upload()` with no argument commits nothing and is typed `undefined` — a slot the client
|
|
329
|
+
* fills, with no repo asset underneath it. Reading the carrier back rather than pinning
|
|
330
|
+
* `string` is also what keeps `upload({ value: null })` honest: an explicit null is a
|
|
331
|
+
* deliberate "no asset", and it is not a path.
|
|
332
|
+
*/
|
|
333
|
+
export type ImageOptions = Omit<Common, 'default' | 'value' | 'label'> & {
|
|
334
|
+
label: string
|
|
335
|
+
default?: ImageSeedValue
|
|
336
|
+
constraints?: ImageConstraints
|
|
337
|
+
variants?: Record<string, ImageVariantDeclaration>
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** One atomic Image placement, with upload children retained for media traversal. */
|
|
341
|
+
export const image = (options: ImageOptions): Unnamed<ImageSeedValue> => {
|
|
342
|
+
const issues = validateImageDeclaration(options)
|
|
343
|
+
if (issues.length) throw new ImageValidationError(issues)
|
|
344
|
+
const normalized = normalizeImageSeed(options.default)
|
|
345
|
+
for (const key of Object.keys(normalized?.variants ?? {})) {
|
|
346
|
+
if (!Object.hasOwn(options.variants ?? {}, key))
|
|
347
|
+
throw new ImageValidationError([
|
|
348
|
+
{ path: `variants.${key}`, message: 'Variant is not declared by this field.' },
|
|
349
|
+
])
|
|
350
|
+
}
|
|
351
|
+
const structural = (): EditorField[] => [
|
|
352
|
+
{ name: 'asset', label: 'Asset', type: 'upload', relationTo: 'media', localized: false },
|
|
353
|
+
{
|
|
354
|
+
name: 'focalPoint',
|
|
355
|
+
label: 'Focal point',
|
|
356
|
+
type: 'group',
|
|
357
|
+
localized: false,
|
|
358
|
+
fields: ['x', 'y'].map((name) => ({ name, label: name, type: 'number' })),
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'crop',
|
|
362
|
+
label: 'Crop',
|
|
363
|
+
type: 'group',
|
|
364
|
+
localized: false,
|
|
365
|
+
fields: ['x', 'y', 'width', 'height'].map((name) => ({ name, label: name, type: 'number' })),
|
|
366
|
+
},
|
|
367
|
+
]
|
|
368
|
+
return {
|
|
369
|
+
...base<ImageSeedValue>('group', {
|
|
370
|
+
...options,
|
|
371
|
+
default: imageSeedForImport(normalized),
|
|
372
|
+
value: normalized,
|
|
373
|
+
}),
|
|
374
|
+
element: 'image',
|
|
375
|
+
contractVersion: 1,
|
|
376
|
+
...(options.constraints ? { constraints: options.constraints } : {}),
|
|
377
|
+
...(options.variants ? { variants: options.variants } : {}),
|
|
378
|
+
fields: [
|
|
379
|
+
...structural(),
|
|
380
|
+
{ name: 'alt', label: 'Alternative text', type: 'text' },
|
|
381
|
+
{ name: 'decorative', label: 'Decorative', type: 'checkbox', localized: false },
|
|
382
|
+
{ name: 'caption', label: 'Caption', type: 'text' },
|
|
383
|
+
{
|
|
384
|
+
name: 'variants',
|
|
385
|
+
label: 'Variants',
|
|
386
|
+
type: 'group',
|
|
387
|
+
localized: false,
|
|
388
|
+
fields: Object.keys(options.variants ?? {}).map((name) => ({
|
|
389
|
+
name,
|
|
390
|
+
label: name,
|
|
391
|
+
type: 'group',
|
|
392
|
+
fields: structural(),
|
|
393
|
+
})),
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export type VideoOptions = Omit<Common, 'default' | 'value' | 'label'> & {
|
|
400
|
+
label: string
|
|
401
|
+
default?: VideoSeedValue
|
|
402
|
+
poster?: Pick<ImageOptions, 'label' | 'constraints' | 'variants'>
|
|
403
|
+
}
|
|
404
|
+
export type FileOptions = Omit<Common, 'default' | 'value' | 'label'> & {
|
|
405
|
+
label: string
|
|
406
|
+
default?: FileSeedValue
|
|
407
|
+
}
|
|
408
|
+
export const video = (options: VideoOptions): Unnamed<VideoSeedValue> => {
|
|
409
|
+
const poster = options.poster ?? { label: 'Poster' }
|
|
410
|
+
const posterField = image(poster)
|
|
411
|
+
const normalized = normalizeVideoSeed(options.default, { posterVariants: poster.variants ?? {} })
|
|
412
|
+
return {
|
|
413
|
+
...base<VideoSeedValue>('group', {
|
|
414
|
+
...options,
|
|
415
|
+
default: videoSeedForImport(normalized),
|
|
416
|
+
value: normalized,
|
|
417
|
+
}),
|
|
418
|
+
element: 'video',
|
|
419
|
+
contractVersion: 1,
|
|
420
|
+
poster: {
|
|
421
|
+
label: poster.label,
|
|
422
|
+
...(poster.constraints ? { constraints: poster.constraints } : {}),
|
|
423
|
+
...(poster.variants ? { variants: poster.variants } : {}),
|
|
424
|
+
},
|
|
425
|
+
fields: [
|
|
426
|
+
{ name: 'asset', label: 'Asset', type: 'upload', relationTo: 'media', localized: false },
|
|
427
|
+
{ ...posterField, name: 'poster' },
|
|
428
|
+
...['posterTime', 'startTime', 'endTime'].map((name) => ({
|
|
429
|
+
name,
|
|
430
|
+
label: name,
|
|
431
|
+
type: 'number' as const,
|
|
432
|
+
localized: false,
|
|
433
|
+
})),
|
|
434
|
+
...['autoplay', 'muted', 'loop', 'controls', 'playsInline'].map((name) => ({
|
|
435
|
+
name,
|
|
436
|
+
label: name,
|
|
437
|
+
type: 'checkbox' as const,
|
|
438
|
+
localized: false,
|
|
439
|
+
})),
|
|
440
|
+
{
|
|
441
|
+
name: 'preload',
|
|
442
|
+
label: 'Preload',
|
|
443
|
+
type: 'select',
|
|
444
|
+
localized: false,
|
|
445
|
+
options: ['none', 'metadata', 'auto'].map((value) => ({ label: value, value })),
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
export const file = (options: FileOptions): Unnamed<FileSeedValue> => {
|
|
451
|
+
const normalized = normalizeFileSeed(options.default)
|
|
452
|
+
return {
|
|
453
|
+
...base<FileSeedValue>('group', {
|
|
454
|
+
...options,
|
|
455
|
+
default: fileSeedForImport(normalized),
|
|
456
|
+
value: normalized,
|
|
457
|
+
}),
|
|
458
|
+
element: 'file',
|
|
459
|
+
contractVersion: 1,
|
|
460
|
+
fields: [
|
|
461
|
+
{ name: 'asset', label: 'Asset', type: 'upload', relationTo: 'media', localized: false },
|
|
462
|
+
...['label', 'description', 'downloadName'].map((name) => ({
|
|
463
|
+
name,
|
|
464
|
+
label: name,
|
|
465
|
+
type: 'text' as const,
|
|
466
|
+
})),
|
|
467
|
+
],
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export const upload: Scalar<string> = (arg: Common | string = {}) => {
|
|
472
|
+
const options = opts(arg)
|
|
473
|
+
const committed = 'value' in options ? options.value : options.default
|
|
474
|
+
return {
|
|
475
|
+
...base<never>('upload', { ...options, default: committed, value: committed }),
|
|
476
|
+
relationTo: 'media',
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Everything `relationship()` accepts beyond the common carriers. */
|
|
481
|
+
type RelationshipOptions = Common & {
|
|
482
|
+
hasMany?: boolean
|
|
483
|
+
inverse?: { label: string; name: string }
|
|
484
|
+
relationTo: string
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* A reference to another record — one of this site's pages, or rows of one of its own
|
|
489
|
+
* collections.
|
|
490
|
+
*
|
|
491
|
+
* `relationTo: 'pages'` is the page link `link()` uses and is reserved. Anything else must
|
|
492
|
+
* name a collection this site declares in `defineCollections`, or the platform rejects the
|
|
493
|
+
* whole manifest rather than render a picker with nothing in it.
|
|
494
|
+
*
|
|
495
|
+
* `hasMany` makes it an ordered list of references; the order is the client's and is stored.
|
|
496
|
+
* `inverse` asks the target collection to show this edge from its own side — see `EditorField`.
|
|
497
|
+
*
|
|
498
|
+
* Answers `undefined` when nothing was committed, the same as every other helper. It used to
|
|
499
|
+
* answer `unknown`, on the reasoning that `unknown` refuses every dereference and so cannot
|
|
500
|
+
* crash. It can: `unknown` is not `undefined`, so a *group* holding one uncommitted reference
|
|
501
|
+
* read as present — `Aggregate` saw a member that was not `undefined` and typed the whole
|
|
502
|
+
* group as an object. The crash then lands one level up, at the group, where the members
|
|
503
|
+
* never get the chance to refuse anything. A page reference beside a caption the client fills
|
|
504
|
+
* in is an ordinary block, so this has to be exact rather than merely cautious.
|
|
505
|
+
*/
|
|
506
|
+
export const relationship: {
|
|
507
|
+
<V>(options: RelationshipOptions & { value: V }): Unnamed<V>
|
|
508
|
+
<V>(options: RelationshipOptions & { default: V }): Unnamed<V>
|
|
509
|
+
(options: RelationshipOptions): Unnamed<undefined>
|
|
510
|
+
} = (options: RelationshipOptions) => ({
|
|
511
|
+
...base<never>('relationship', options),
|
|
512
|
+
...(options.hasMany === undefined ? {} : { hasMany: options.hasMany }),
|
|
513
|
+
...(options.inverse === undefined ? {} : { inverse: options.inverse }),
|
|
514
|
+
relationTo: options.relationTo,
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* A field whose value lives once, in the client's Singles, and is pointed at from here.
|
|
519
|
+
*
|
|
520
|
+
* `reference('phone')` names the **default** key. The block stores nothing until a client
|
|
521
|
+
* overrides it, so a field that has never been touched still resolves — see the design note
|
|
522
|
+
* on why an unwired state is not allowed to exist.
|
|
523
|
+
*
|
|
524
|
+
* The platform resolves this on egress, so a site reads it exactly as it reads a text field
|
|
525
|
+
* and needs no concept of a Single at all.
|
|
526
|
+
*
|
|
527
|
+
* Not `relationTo`, deliberately: see `EditorFieldType`'s `'reference'` case in
|
|
528
|
+
* `contract.ts` for why sharing it with `relationship()` would put `_singles` where a
|
|
529
|
+
* client's own collections belong.
|
|
530
|
+
*/
|
|
531
|
+
export const reference = (
|
|
532
|
+
defaultKey: string,
|
|
533
|
+
options: { description?: string; label?: string } = {},
|
|
534
|
+
) => ({ ...base<never>('reference', options), referenceKey: defaultKey })
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The committed value of every child that carries one, keyed by name.
|
|
538
|
+
*
|
|
539
|
+
* Read at construction time, before `namedFields` strips `value` from each child on its way
|
|
540
|
+
* into the group's own schema `fields`. By the time a group is walked from the outside —
|
|
541
|
+
* `defineSite`'s `blockValues`, say — its children are schema-only and carry nothing;
|
|
542
|
+
* aggregating here, once, is what lets a bare `group()` still contribute its children's
|
|
543
|
+
* committed copy. A nested group's own aggregate was already computed when *it* was built,
|
|
544
|
+
* so reading its top-level `value` here is enough — no recursion required.
|
|
545
|
+
*/
|
|
546
|
+
const aggregateValues = (fields: FieldSpec): Record<string, unknown> | undefined => {
|
|
547
|
+
const out: Record<string, unknown> = {}
|
|
548
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
549
|
+
const value = (field as { value?: unknown }).value
|
|
550
|
+
if (value !== undefined) out[name] = value
|
|
551
|
+
}
|
|
552
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* What a group's children commit between them — or `undefined` when that is nothing at all.
|
|
557
|
+
*
|
|
558
|
+
* Mirrors `aggregateValues`, which returns `undefined` rather than an empty object when no
|
|
559
|
+
* child carries a value. A group of fields a client fills in and the site commits none of is
|
|
560
|
+
* absent from `site.values` entirely, so the type has to be absent too; saying `{ open:
|
|
561
|
+
* undefined; close: undefined }` would let `brand.contact.hours.open` compile against a
|
|
562
|
+
* `hours` that is itself `undefined`.
|
|
563
|
+
*
|
|
564
|
+
* **This rests on every helper answering `undefined` — not `unknown` — when it was handed no
|
|
565
|
+
* carrier**, and it is why `relationship` and `blocks` had to stop answering `unknown`. One
|
|
566
|
+
* member that is merely *unreadable* rather than *absent* fails the test below, and the whole
|
|
567
|
+
* group then types as present. Any new helper has to keep that bargain or this silently
|
|
568
|
+
* over-promises again, one level above where the wrong type was written.
|
|
569
|
+
*/
|
|
570
|
+
type Aggregate<F> =
|
|
571
|
+
{ [K in keyof F]: Committed<F[K]> } extends Record<string, undefined>
|
|
572
|
+
? undefined
|
|
573
|
+
: { [K in keyof F]: Committed<F[K]> }
|
|
574
|
+
|
|
575
|
+
/** The seed default of every child that carries one, keyed by name. See `aggregateValues`. */
|
|
576
|
+
const aggregateDefaults = (fields: FieldSpec): Record<string, unknown> | undefined => {
|
|
577
|
+
const out: Record<string, unknown> = {}
|
|
578
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
579
|
+
const value = (field as { default?: unknown }).default
|
|
580
|
+
if (value !== undefined) out[name] = value
|
|
581
|
+
}
|
|
582
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* A named group of fields, edited together as one panel.
|
|
587
|
+
*
|
|
588
|
+
* Carries its own `default`/`value` the way every other helper does — aggregated from
|
|
589
|
+
* whichever children carry one, at construction time, before `namedFields` strips those
|
|
590
|
+
* carriers from the children on their way into this group's own schema `fields`. An
|
|
591
|
+
* explicit `default`/`value` passed to `group()` itself wins over the aggregate: `link()`
|
|
592
|
+
* hands `group()` its own translated `default`/`value`, and those must not be overwritten
|
|
593
|
+
* by an aggregate over its five schema-only children.
|
|
594
|
+
*
|
|
595
|
+
* The type follows the runtime's precedence exactly, which is why `default` and `value` are
|
|
596
|
+
* lifted out of `Common` and given parameters of their own: an explicit `value` wins, else
|
|
597
|
+
* the children's aggregate, else an explicit `default`. **A group none of whose children
|
|
598
|
+
* committed anything aggregates to nothing, and `aggregateValues` returns `undefined` rather
|
|
599
|
+
* than `{}`** — so `Aggregate` collapses to `undefined` in that case, and a reader is made to
|
|
600
|
+
* check before dereferencing something that is genuinely not there.
|
|
601
|
+
*
|
|
602
|
+
* The aggregate is computed from the *spec map* the caller wrote, never from `fields` on the
|
|
603
|
+
* returned descriptor — `namedFields` has already turned that into an `EditorField[]` with
|
|
604
|
+
* the carriers stripped, so a reader walking it from the outside would find nothing at all.
|
|
605
|
+
*/
|
|
606
|
+
export const group = <F extends FieldSpec, V = never, D = undefined>(
|
|
607
|
+
options: Omit<Common, 'default' | 'value'> & { default?: D; fields: F; value?: V },
|
|
608
|
+
): Unnamed<[V] extends [never] ? (Aggregate<F> extends undefined ? D : Aggregate<F>) : V> => {
|
|
609
|
+
const aggregatedDefault = aggregateDefaults(options.fields)
|
|
610
|
+
const aggregatedValue = aggregateValues(options.fields)
|
|
611
|
+
const groupDefault = options.default !== undefined ? options.default : aggregatedDefault
|
|
612
|
+
const groupValue =
|
|
613
|
+
options.value !== undefined
|
|
614
|
+
? options.value
|
|
615
|
+
: aggregatedValue !== undefined
|
|
616
|
+
? aggregatedValue
|
|
617
|
+
: groupDefault
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
...base<never>('group', {
|
|
621
|
+
...options,
|
|
622
|
+
...(groupDefault === undefined ? {} : { default: groupDefault }),
|
|
623
|
+
...(groupValue === undefined ? {} : { value: groupValue }),
|
|
624
|
+
}),
|
|
625
|
+
fields: namedFields(options.fields),
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* A committed button in the shape the platform stores.
|
|
631
|
+
*
|
|
632
|
+
* The site writes what it renders — `{ href, label }`, what `resolveLink(cms, fallback)`
|
|
633
|
+
* takes — while the inspector edits a group of `{ kind, url, newTab, … }`. One translation,
|
|
634
|
+
* read by `link()` for its own seed and by `seedItem` for a link inside an array item; they
|
|
635
|
+
* were two copies of the same six lines, which is one copy too many for a shape the platform
|
|
636
|
+
* stores permanently.
|
|
637
|
+
*/
|
|
638
|
+
const storedLink = (committed: LinkValue & { text?: string }): Record<string, unknown> => ({
|
|
639
|
+
/*
|
|
640
|
+
* A sentence's copy rides through the same translation as the link inside it, because a
|
|
641
|
+
* sentence *is* a link group with one field more — see `sentence()`. Conditional rather
|
|
642
|
+
* than always present, so an ordinary button stores exactly the four keys it always did.
|
|
643
|
+
*/
|
|
644
|
+
...(committed.text === undefined ? {} : { text: committed.text }),
|
|
645
|
+
...(committed.label === undefined ? {} : { label: committed.label }),
|
|
646
|
+
kind: 'url',
|
|
647
|
+
newTab: committed.newTab ?? false,
|
|
648
|
+
url: committed.href,
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* A link `default` already in the shape the platform stores, rather than the shape a
|
|
653
|
+
* component renders. `link()` must accept either: before this file translated `{ href }`
|
|
654
|
+
* into `{ kind, url }`, a `default` passed straight through, so a site whose own helper
|
|
655
|
+
* already builds the stored shape — as jdsi's `seedLink()` does — got it stored correctly.
|
|
656
|
+
* Making `link()` translate unconditionally silently broke that site on upgrade, with no
|
|
657
|
+
* error: `committed.href` read `undefined` off an object that never had an `href`, and the
|
|
658
|
+
* button lost its address.
|
|
659
|
+
*
|
|
660
|
+
* Discriminated by a key only the stored shape carries — `kind` or `url` — never by the
|
|
661
|
+
* *absence* of `href`. A committed link legitimately written as `{ href: undefined }` for
|
|
662
|
+
* some placeholder case would otherwise be misread as already-stored.
|
|
663
|
+
*/
|
|
664
|
+
const isStoredLinkValue = (value: unknown): value is Record<string, unknown> =>
|
|
665
|
+
isRecord(value) && ('kind' in value || 'url' in value)
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The rendered equivalent of an already-stored link — the inverse of `storedLink`, read only
|
|
669
|
+
* to populate `value`.
|
|
670
|
+
*
|
|
671
|
+
* `value` is what `site.values` carries into the component that actually renders this link,
|
|
672
|
+
* and a component reads `{ href, label }` — see `LinkValue`. Leaving `value` empty when
|
|
673
|
+
* `default` arrives pre-stored would mean `brand.*.cta.href` silently disappears at the one
|
|
674
|
+
* carrier a component reads, even though the field's stored `url` is intact. `href` falls
|
|
675
|
+
* back to `''` rather than `undefined` for a `kind: 'page'` default with no `url` of its own
|
|
676
|
+
* — there is no resolved page address available at spec-building time, and `''` keeps
|
|
677
|
+
* `LinkValue.href` the required string a component's `resolveLink` expects rather than
|
|
678
|
+
* widening it to optional for a case no site actually writes today.
|
|
679
|
+
*/
|
|
680
|
+
const valueFromStored = (stored: Record<string, unknown>): LinkValue & { text?: string } => ({
|
|
681
|
+
...(stored.text === undefined ? {} : { text: stored.text as string }),
|
|
682
|
+
...(stored.label === undefined ? {} : { label: stored.label as string }),
|
|
683
|
+
href: (stored.url as string | undefined) ?? '',
|
|
684
|
+
...(stored.newTab === undefined ? {} : { newTab: stored.newTab as boolean }),
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The blocks a zone field may hold, from whichever form it used.
|
|
689
|
+
*
|
|
690
|
+
* **This is the third copy of this exact resolution rule**, not the second. `childBlockFor` /
|
|
691
|
+
* `childBlocksFor` in `src/dashboard/canvas/nestedBlocks.ts` is the platform's own authority;
|
|
692
|
+
* `childrenOf` in `uploads.ts` (this package) duplicates it because uploads.ts is vendored into
|
|
693
|
+
* every client's site and cannot import from the platform's `src/`. This is a third copy for
|
|
694
|
+
* the same reason — `defineBlocks.ts` cannot import from `uploads.ts` either without pulling
|
|
695
|
+
* upload-collection code into a seeding path that has nothing to do with it, and the two
|
|
696
|
+
* modules within this package are otherwise independent. Three copies, named by file in all
|
|
697
|
+
* three doc comments — say the number and every path, not "the pair" or "the only duplicate",
|
|
698
|
+
* or the count is the next thing to go stale. `verify:nested-blocks` checks all three agree,
|
|
699
|
+
* not only `nestedBlocks.ts` and `uploads.ts`; this function is not exported, so that check
|
|
700
|
+
* exercises it through observable behaviour rather than calling it directly — see the check
|
|
701
|
+
* itself for why.
|
|
702
|
+
*
|
|
703
|
+
* `field.blocks` (inline definitions) is returned as-is when present; otherwise each entry of
|
|
704
|
+
* `field.childBlocks` (slugs) is looked up in `allBlocks` — the site's full declared block
|
|
705
|
+
* library — skipping any slug that resolves to nothing.
|
|
706
|
+
*/
|
|
707
|
+
const childrenOf = (field: EditorField, allBlocks: EditorBlock[]): EditorBlock[] => {
|
|
708
|
+
if (field.blocks !== undefined) return field.blocks
|
|
709
|
+
|
|
710
|
+
const resolved: EditorBlock[] = []
|
|
711
|
+
for (const slug of field.childBlocks ?? []) {
|
|
712
|
+
const block = allBlocks.find((entry) => entry.slug === slug)
|
|
713
|
+
if (block !== undefined) resolved.push(block)
|
|
714
|
+
}
|
|
715
|
+
return resolved
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* One zone's committed items, each translated against the block it names.
|
|
720
|
+
*
|
|
721
|
+
* A zone's children are heterogeneous — every entry names its own shape by `blockType` — so
|
|
722
|
+
* unlike `group`/`array` there is no single `fields` to walk them against. Each entry is
|
|
723
|
+
* looked up in `allBlocks` by that name and walked with `seedItem` against *its own* declared
|
|
724
|
+
* fields; an entry naming a block `allBlocks` does not carry (a slug that resolves to nothing,
|
|
725
|
+
* or simply not a record) is passed through untouched, the same "prune, do not correct" rule
|
|
726
|
+
* `seedItem` applies to a value of the wrong shape.
|
|
727
|
+
*
|
|
728
|
+
* `blockType` itself is not a declared field of the child block, so `seedItem`'s own loop —
|
|
729
|
+
* which only ever copies keys present in the `fields` map it is given — would drop it. It is
|
|
730
|
+
* added back explicitly, from the resolved block's own slug rather than the entry's, for the
|
|
731
|
+
* reason `publishedBlock` refuses a slugless zone block at declaration time: the stored name
|
|
732
|
+
* and the declared name must be the same string or a later read would not find the block this
|
|
733
|
+
* write resolved against.
|
|
734
|
+
*/
|
|
735
|
+
const seedZone = (field: EditorField, raw: unknown[], allBlocks: EditorBlock[]): unknown[] => {
|
|
736
|
+
const nested = new Map(childrenOf(field, allBlocks).map((block) => [block.slug, block]))
|
|
737
|
+
return raw.map((entry) => {
|
|
738
|
+
if (!isRecord(entry)) return entry
|
|
739
|
+
const blockType = (entry as { blockType?: unknown }).blockType
|
|
740
|
+
const child = typeof blockType === 'string' ? nested.get(blockType) : undefined
|
|
741
|
+
if (!child) return entry
|
|
742
|
+
return { ...seedItem(namedFieldSpec(child.fields), entry, allBlocks), blockType: child.slug }
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* One array item, reduced to what the platform should store.
|
|
748
|
+
*
|
|
749
|
+
* Undeclared keys are dropped and a nested `link` is translated — the same rules the field
|
|
750
|
+
* helpers apply one level up, applied per item because an array's values arrive as whole
|
|
751
|
+
* objects rather than through the helpers.
|
|
752
|
+
*
|
|
753
|
+
* **Uploads used to be dropped here too**, and now ride along as the committed repo path.
|
|
754
|
+
* See `upload()` for why that flipped: the path is the only thing that tells the platform's
|
|
755
|
+
* media ingestion which uploaded file belongs in which slot, and a row's portrait is written
|
|
756
|
+
* per row — so pruning it here left `relinkSeedAssets` with nothing to relink and every
|
|
757
|
+
* seeded row imageless. The platform drops any path ingestion did not relink, which is what
|
|
758
|
+
* keeps a repo path from ever being stored as a media id.
|
|
759
|
+
*
|
|
760
|
+
* A field is translated as a link only when it carries `LINK_FIELD_MARKER` — i.e. it was
|
|
761
|
+
* built by `link()` — never by guessing from shape. A plain `group` whose committed value
|
|
762
|
+
* happens to have an `href` alongside other keys (a card with a title and a link, say) is
|
|
763
|
+
* not a link and must survive with every one of its keys intact.
|
|
764
|
+
*
|
|
765
|
+
* **`group`, `array` and `blocks` all recurse, because the rule is the field type and not the
|
|
766
|
+
* nesting level** — this used to say only `group` and `array` did, which was true right up
|
|
767
|
+
* until a site declared a zone with committed children and the missing case went unnoticed
|
|
768
|
+
* until PBIR's nav did exactly that. A `group` or `array` *inside* an item is walked against
|
|
769
|
+
* its own declared fields, which is what carries a nested `upload()`'s path through to the
|
|
770
|
+
* platform and what prunes an undeclared key a one-level walk would let ride along. A `blocks`
|
|
771
|
+
* zone recurses differently — see `seedZone` — because its children do not share one shape to
|
|
772
|
+
* walk them against; each is resolved against the block its own `blockType` names, out of
|
|
773
|
+
* `allBlocks`, the full library this call was handed.
|
|
774
|
+
*
|
|
775
|
+
* **`allBlocks` is only ever non-empty when this walk starts from `startersFrom`.** The other
|
|
776
|
+
* two callers — `array()`'s own `default` translation and `defineCollections`' row seeding —
|
|
777
|
+
* run while a site's module is still being evaluated, before `defineSite` has finished
|
|
778
|
+
* collecting every declared block into one list, so a zone nested inside an array item or a
|
|
779
|
+
* collection row cannot resolve a slug-named child (`of: [...]`) at that point and is passed
|
|
780
|
+
* through unresolved — not a regression, the same as before this case existed at all. Only a
|
|
781
|
+
* zone reached through a block's own top-level field, or nested inside another such zone's own
|
|
782
|
+
* items, resolves — which is every zone this plan's PBIR case needed.
|
|
783
|
+
*
|
|
784
|
+
* Those nested fields are an `EditorField[]` rather than the map the author wrote —
|
|
785
|
+
* `group()` and `array()` call `namedFields` on their children when they are *constructed*,
|
|
786
|
+
* long before anything seeds — so `namedFieldSpec` puts the names back on before the
|
|
787
|
+
* recursion, which is the same normalisation `defineSite`'s `validate` needs for the same
|
|
788
|
+
* reason. A committed value of the wrong shape for its field (a string where a group is
|
|
789
|
+
* declared, `null` where an array is) is passed through untouched rather than forced: this
|
|
790
|
+
* prunes, it does not correct.
|
|
791
|
+
*/
|
|
792
|
+
const seedItem = (
|
|
793
|
+
fields: FieldSpec,
|
|
794
|
+
item: Record<string, unknown>,
|
|
795
|
+
allBlocks: EditorBlock[],
|
|
796
|
+
): Record<string, unknown> => {
|
|
797
|
+
const out: Record<string, unknown> = {}
|
|
798
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
799
|
+
if (!(name in item)) continue
|
|
800
|
+
const descriptor = field as unknown as EditorField & {
|
|
801
|
+
fields?: unknown
|
|
802
|
+
[LINK_FIELD_MARKER]?: true
|
|
803
|
+
}
|
|
804
|
+
const raw = item[name]
|
|
805
|
+
if (descriptor.element === 'video' || descriptor.element === 'file') {
|
|
806
|
+
out[name] =
|
|
807
|
+
descriptor.element === 'video'
|
|
808
|
+
? videoSeedForImport(raw, { posterVariants: descriptor.poster?.variants ?? {} })
|
|
809
|
+
: fileSeedForImport(raw)
|
|
810
|
+
continue
|
|
811
|
+
}
|
|
812
|
+
if (descriptor.element === 'image') {
|
|
813
|
+
const normalized = normalizeImageSeed(raw)
|
|
814
|
+
for (const key of Object.keys(normalized?.variants ?? {}))
|
|
815
|
+
if (!Object.hasOwn(descriptor.variants ?? {}, key))
|
|
816
|
+
throw new ImageValidationError([
|
|
817
|
+
{ path: `${name}.variants.${key}`, message: 'Variant is not declared by this field.' },
|
|
818
|
+
])
|
|
819
|
+
out[name] = imageSeedForImport(normalized)
|
|
820
|
+
continue
|
|
821
|
+
}
|
|
822
|
+
if (descriptor[LINK_FIELD_MARKER]) {
|
|
823
|
+
// Either shape, for the reason `link()` accepts either — see `isStoredLinkValue`. A
|
|
824
|
+
// row or item whose helper already built the stored form must not be translated twice,
|
|
825
|
+
// which reads `href` off an object that never had one and drops the address.
|
|
826
|
+
out[name] = isStoredLinkValue(raw) ? raw : storedLink(raw as LinkValue)
|
|
827
|
+
continue
|
|
828
|
+
}
|
|
829
|
+
if (descriptor.type === 'group' && isRecord(raw)) {
|
|
830
|
+
out[name] = seedItem(namedFieldSpec(descriptor.fields), raw, allBlocks)
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
if (descriptor.type === 'array' && Array.isArray(raw)) {
|
|
834
|
+
const nested = namedFieldSpec(descriptor.fields)
|
|
835
|
+
out[name] = (raw as unknown[]).map((entry) =>
|
|
836
|
+
isRecord(entry) ? seedItem(nested, entry, allBlocks) : entry,
|
|
837
|
+
)
|
|
838
|
+
continue
|
|
839
|
+
}
|
|
840
|
+
if (descriptor.type === 'blocks' && Array.isArray(raw)) {
|
|
841
|
+
out[name] = seedZone(descriptor, raw as unknown[], allBlocks)
|
|
842
|
+
continue
|
|
843
|
+
}
|
|
844
|
+
out[name] = raw
|
|
845
|
+
}
|
|
846
|
+
return out
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* A field descriptor built by one of the helpers above, as opposed to a `FieldSpec` map.
|
|
851
|
+
*
|
|
852
|
+
* `base` always stamps a string `type` and a string `label` onto whatever it returns, so a
|
|
853
|
+
* descriptor is recognisable by that pair and a fields-map is not — a map's own `.type`
|
|
854
|
+
* property only exists when one of its fields happens to be named `type`, and that field's
|
|
855
|
+
* value is itself a descriptor object, not a string. This is what lets `array` tell its
|
|
856
|
+
* legacy form apart from a new-form field-spec that happens to declare a field named `fields`.
|
|
857
|
+
*/
|
|
858
|
+
const isFieldDescriptor = (value: unknown): boolean =>
|
|
859
|
+
value !== null &&
|
|
860
|
+
typeof value === 'object' &&
|
|
861
|
+
typeof (value as Record<string, unknown>).type === 'string' &&
|
|
862
|
+
typeof (value as Record<string, unknown>).label === 'string'
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* A repeating set of items that all share one shape.
|
|
866
|
+
*
|
|
867
|
+
* `array(fields, items)` — the fields first, the committed items second. The items are the
|
|
868
|
+
* value the component falls back to and may carry keys the fields do not declare: a slide's
|
|
869
|
+
* `video`, a poster's `posterFallback`. Those ride along in `site.values` and are pruned out
|
|
870
|
+
* of the seed, because a key with no field is a key no client can edit and the platform has
|
|
871
|
+
* no business storing it.
|
|
872
|
+
*
|
|
873
|
+
* The legacy `array({ fields, … })` form is still accepted, so specs written for
|
|
874
|
+
* `defineBlocks` keep compiling. It is distinguished by carrying a `fields` key whose value
|
|
875
|
+
* is *not itself a field descriptor* — see `isFieldDescriptor`. The legacy branch is passed
|
|
876
|
+
* straight to `base` untouched: no `seedItem` pruning runs over it, because its `default` was
|
|
877
|
+
* already written as whole stored rows by the site author, not derived from committed items.
|
|
878
|
+
*
|
|
879
|
+
* The committed items are also where the *type* comes from: an item is a whole object, so
|
|
880
|
+
* there is nothing to read it off except the items themselves. Reading it off `fields`
|
|
881
|
+
* instead would give the wrong answer even in principle — `namedFields` runs on those at
|
|
882
|
+
* construction time, so by the time anything can look, the shape is an `EditorField[]` with
|
|
883
|
+
* neither the carriers nor the author's key names left on it.
|
|
884
|
+
*
|
|
885
|
+
* **`array(fields)` with no items commits nothing, and is typed `undefined` rather than an
|
|
886
|
+
* empty list.** This is the sharpest case of the four `Scalar` covers, because the reader is
|
|
887
|
+
* a `.map()` rather than a property access: a type saying `Record<string, unknown>[]` would
|
|
888
|
+
* compile `brand.hero.gallery.map(…)` straight into a `TypeError`. It is *not* fixed by
|
|
889
|
+
* writing `[]` at runtime — a declared-but-uncommitted array genuinely has nothing in it, and
|
|
890
|
+
* a site that means an empty list writes `array(fields, [])` and gets one. (A collection
|
|
891
|
+
* differs: `defineSite` writes `rows ?? []` there, so it reads as `[]` in both the type and
|
|
892
|
+
* at runtime.)
|
|
893
|
+
*/
|
|
894
|
+
export const array: {
|
|
895
|
+
<I extends Record<string, unknown>>(first: FieldSpec, items: I[], extra?: Common): Unnamed<I[]>
|
|
896
|
+
<V>(first: Common & { fields: FieldSpec; value: V }): Unnamed<V>
|
|
897
|
+
<V>(first: Common & { fields: FieldSpec; default: V }): Unnamed<V>
|
|
898
|
+
(first: Common & { fields: FieldSpec }): Unnamed<undefined>
|
|
899
|
+
(first: FieldSpec): Unnamed<undefined>
|
|
900
|
+
} = (
|
|
901
|
+
first: (Common & { fields: FieldSpec }) | FieldSpec,
|
|
902
|
+
items?: Record<string, unknown>[],
|
|
903
|
+
extra: Common = {},
|
|
904
|
+
) => {
|
|
905
|
+
const legacy = 'fields' in first && !isFieldDescriptor((first as { fields: unknown }).fields)
|
|
906
|
+
|
|
907
|
+
if (legacy) {
|
|
908
|
+
const options = first as Common & { fields: FieldSpec }
|
|
909
|
+
return {
|
|
910
|
+
...base<never>('array', options),
|
|
911
|
+
fields: namedFields(options.fields),
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const fields = first as FieldSpec
|
|
916
|
+
const options: Common = { ...extra, ...(items === undefined ? {} : { value: items }) }
|
|
917
|
+
const value = ('value' in options ? options.value : options.default) as
|
|
918
|
+
Record<string, unknown>[] | undefined
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
...base<never>('array', {
|
|
922
|
+
...options,
|
|
923
|
+
/*
|
|
924
|
+
* `allBlocks: []`, not the site's full library — this runs while the site's module is
|
|
925
|
+
* still being evaluated, before `defineSite` has finished collecting every declared
|
|
926
|
+
* block into one list. A zone nested inside one of these items and named by slug
|
|
927
|
+
* (`of: [...]`) cannot resolve here for exactly that reason; see the note on
|
|
928
|
+
* `seedItem`. An inline zone (`blocks: [...]`) is unaffected — `childrenOf` reads that
|
|
929
|
+
* form straight off the field, no registry needed.
|
|
930
|
+
*/
|
|
931
|
+
...(value === undefined ? {} : { default: value.map((item) => seedItem(fields, item, [])) }),
|
|
932
|
+
...(value === undefined ? {} : { value }),
|
|
933
|
+
}),
|
|
934
|
+
fields: namedFields(fields),
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* A dynamic zone: an ordered list of sections whose shapes differ from one another.
|
|
940
|
+
*
|
|
941
|
+
* The distinction from `array` is *shape*, not ownership. An array's items all have the
|
|
942
|
+
* same fields, so one field list describes every one of them. A zone's sections each name
|
|
943
|
+
* their own block in `blockType`, and are read against that block alone — which is what
|
|
944
|
+
* lets one project lead with a gallery and the next with a quote.
|
|
945
|
+
*
|
|
946
|
+
* The client adds, removes and reorders these in Canvas, and that is the point: on a row,
|
|
947
|
+
* arrangement *is* the content. Give it the blocks the section may be — usually a subset
|
|
948
|
+
* of the same `defineBlocks` call the rest of the site uses, since a block is worth
|
|
949
|
+
* writing once:
|
|
950
|
+
*
|
|
951
|
+
* body: blocks({ label: 'Body', blocks: [gallery, quote] })
|
|
952
|
+
*
|
|
953
|
+
* Two things worth knowing before you declare one. A section is created empty — the
|
|
954
|
+
* platform holds no defaults, deliberately — so a block with a `required` field cannot be
|
|
955
|
+
* added by a client at all until they fill it, and one with no editable fields at all
|
|
956
|
+
* renders in the rail with nothing under it. Both are legal; neither is much use.
|
|
957
|
+
*
|
|
958
|
+
* A zone is created empty, so the uncommitted form is the *normal* one here rather than the
|
|
959
|
+
* exception — all the more reason for it to answer `undefined`. See `relationship` for why
|
|
960
|
+
* `unknown` was not safe.
|
|
961
|
+
*
|
|
962
|
+
* **Either form of block is accepted**: a `defineBlocks` result, or the `block()` entries a
|
|
963
|
+
* brand document writes. Without the second, a site using `defineSite` would have to keep a
|
|
964
|
+
* `defineBlocks` call beside it purely to describe its zones, which is the duplication the
|
|
965
|
+
* one-document rule exists to remove. A zone block seeds nothing whichever way it is written
|
|
966
|
+
* — only a top-level entry seeds — so `block()`'s `seed` is not read here.
|
|
967
|
+
*
|
|
968
|
+
* **A zone's children can be named two ways.** `blocks: [gallery, quote]` above embeds their
|
|
969
|
+
* definitions, which is what every zone has done until now. `of: ['navItem']` instead names
|
|
970
|
+
* slugs, resolved against the site's declared blocks when read — the only form that can
|
|
971
|
+
* express a block containing itself, since an embedded definition would have to serialise its
|
|
972
|
+
* own expansion forever. Exactly one of the two is meaningful on any call, and this constructor
|
|
973
|
+
* throws if a caller supplies neither or both — a typed call can't reach either case, but an
|
|
974
|
+
* agent-authored declaration built from a spread object or an `any`-typed value can, and a loud
|
|
975
|
+
* failure at declaration time beats an empty zone silently rendering as a section with nothing
|
|
976
|
+
* in it. The platform's manifest validator (`src/manifest/validate.ts`) enforces the same rule
|
|
977
|
+
* again on the way in, for a declaration that arrives some other way.
|
|
978
|
+
*
|
|
979
|
+
* **A zone may commit its children whichever way it names them.** `value`/`default` resolve the
|
|
980
|
+
* same for `of: [...]` as they do for `blocks: [...]` — a nav item with sub-items needs the
|
|
981
|
+
* slug form to express itself at all, and needs a starting menu just as much as any other zone
|
|
982
|
+
* does, so the carrier overloads exist for both forms rather than only the older one.
|
|
983
|
+
*/
|
|
984
|
+
export const blocks: {
|
|
985
|
+
<V>(options: Common & { blocks: ZoneBlock[]; value: V }): Unnamed<V>
|
|
986
|
+
<V>(options: Common & { blocks: ZoneBlock[]; default: V }): Unnamed<V>
|
|
987
|
+
(options: Common & { blocks: ZoneBlock[] }): Unnamed<undefined>
|
|
988
|
+
<V>(options: Common & { of: string[]; value: V }): Unnamed<V>
|
|
989
|
+
<V>(options: Common & { of: string[]; default: V }): Unnamed<V>
|
|
990
|
+
(options: Common & { of: string[] }): Unnamed<undefined>
|
|
991
|
+
} = (options: Common & { blocks?: ZoneBlock[]; of?: string[] }) => {
|
|
992
|
+
if (options.blocks === undefined && options.of === undefined) {
|
|
993
|
+
throw new Error(
|
|
994
|
+
`A zone names no children. Give it blocks: [...] to embed their definitions, or of: [...] to name their slugs — a zone with neither is a field with nothing to render.`,
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
if (options.blocks !== undefined && options.of !== undefined) {
|
|
998
|
+
throw new Error(
|
|
999
|
+
`A zone names its children two ways at once. blocks: [...] embeds definitions and of: [...] names slugs; the two would disagree the moment a block was renamed, so exactly one is required, not both.`,
|
|
1000
|
+
)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return {
|
|
1004
|
+
...base<never>('blocks', options),
|
|
1005
|
+
/*
|
|
1006
|
+
* One form or the other, never both — enforced above, so `options.blocks` is defined in
|
|
1007
|
+
* exactly the branch that reads it.
|
|
1008
|
+
*/
|
|
1009
|
+
...(options.of === undefined
|
|
1010
|
+
? { blocks: options.blocks!.map(publishedBlock) }
|
|
1011
|
+
: { childBlocks: options.of }),
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** A section a zone may hold: already published, or still as the site wrote it. */
|
|
1016
|
+
export type ZoneBlock = BlockEntry<FieldSpec> | EditorBlock
|
|
1017
|
+
|
|
1018
|
+
/** Guarded like `isBlock`/`isCollection`: `in` on a `null` from a JS caller is a TypeError. */
|
|
1019
|
+
const isBlockEntry = (value: ZoneBlock): value is BlockEntry<FieldSpec> =>
|
|
1020
|
+
value !== null && typeof value === 'object' && BLOCK_ENTRY in value
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* A zone's block in the published form, from whichever form the site wrote.
|
|
1024
|
+
*
|
|
1025
|
+
* The conversion is `defineBlocks`'s, key for key, because the same block has to arrive at
|
|
1026
|
+
* the platform the same way no matter which call built it — the schema is what stored content
|
|
1027
|
+
* is read back against.
|
|
1028
|
+
*
|
|
1029
|
+
* A zone block has no document key to take a slug from, so it states its own. Refusing here
|
|
1030
|
+
* is the whole reason this throws rather than inventing one: a slug keys stored content
|
|
1031
|
+
* permanently, and a guess made at publish time would be a different guess after any edit.
|
|
1032
|
+
*/
|
|
1033
|
+
const publishedBlock = (entry: ZoneBlock): EditorBlock => {
|
|
1034
|
+
if (!isBlockEntry(entry)) return entry
|
|
1035
|
+
const { slug } = entry
|
|
1036
|
+
if (!slug) {
|
|
1037
|
+
throw new Error(
|
|
1038
|
+
`A block in this zone declares no slug. A zone's blocks are not keyed by a document key, so each states its own: block({ … }, { slug: 'gallery' }). Stored sections name their block by slug, so one without a slug could never be read back.`,
|
|
1039
|
+
)
|
|
1040
|
+
}
|
|
1041
|
+
return { fields: namedFields(entry.fields), label: entry.label ?? humanise(slug), slug }
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* A button as the *site* renders it — what `resolveLink(cms, fallback)` takes.
|
|
1046
|
+
*
|
|
1047
|
+
* Deliberately not the shape the platform stores; see `link()` for the translation between
|
|
1048
|
+
* the two, and `Common.value` for why a helper may commit one shape and seed another.
|
|
1049
|
+
*/
|
|
1050
|
+
export type LinkValue = { href: string; label?: string; newTab?: boolean }
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* A button: its words, where it goes, and whether it opens a new tab.
|
|
1054
|
+
*
|
|
1055
|
+
* The group it returns carries `button: true` into the published schema, and that flag —
|
|
1056
|
+
* not the key the site happened to file it under — is what makes the inspector edit the
|
|
1057
|
+
* whole thing as one panel instead of five separate fields. It used to be the name: a
|
|
1058
|
+
* group called exactly `link` got the panel and anything else got five loose controls,
|
|
1059
|
+
* which spent a block's one good name on its first button and left a second one with
|
|
1060
|
+
* nowhere to go. Writing `cta: link({ label: 'Submit Button' })` beside `link: link({ … })`
|
|
1061
|
+
* now gives two buttons, each named for what it does.
|
|
1062
|
+
*
|
|
1063
|
+
* `withLabel: false` is for a tile or row whose entire surface is the link, so no button
|
|
1064
|
+
* text is rendered and modelling one would put a dead field in the client's editor.
|
|
1065
|
+
*
|
|
1066
|
+
* `link()` with no committed button commits nothing: all five of its children are declared
|
|
1067
|
+
* without carriers, so the group aggregates to `undefined`. The committed type is read back
|
|
1068
|
+
* off what was written, so `brand.hero.cta.label` is a `string` when a label was written and
|
|
1069
|
+
* a compile error when the site never wrote one — rather than a `TypeError` in a component
|
|
1070
|
+
* rendering a button that does not exist.
|
|
1071
|
+
*/
|
|
1072
|
+
/**
|
|
1073
|
+
* The five fields a destination is made of, built once because two helpers need them.
|
|
1074
|
+
*
|
|
1075
|
+
* `link()` and `sentence()` publish the same button underneath — the inspector's panel edits
|
|
1076
|
+
* `label`, `kind`, `page`, `url` and `newTab` whichever of the two declared them, and a
|
|
1077
|
+
* second copy of the list here would be a second copy of the shape the platform stores.
|
|
1078
|
+
*/
|
|
1079
|
+
const linkFields = ({
|
|
1080
|
+
pageCollection,
|
|
1081
|
+
withLabel,
|
|
1082
|
+
}: {
|
|
1083
|
+
pageCollection: string
|
|
1084
|
+
withLabel: boolean
|
|
1085
|
+
}): FieldSpec => ({
|
|
1086
|
+
...(withLabel ? { label: text({ description: 'The words on the button.' }) } : {}),
|
|
1087
|
+
kind: select({
|
|
1088
|
+
description: 'Where the button goes.',
|
|
1089
|
+
options: [
|
|
1090
|
+
{ label: 'A page on this site', value: 'page' },
|
|
1091
|
+
{ label: 'A web address', value: 'url' },
|
|
1092
|
+
],
|
|
1093
|
+
}),
|
|
1094
|
+
page: relationship({ description: 'Pick one of your pages.', relationTo: pageCollection }),
|
|
1095
|
+
url: text({
|
|
1096
|
+
description: 'A full address, a path on this site, or a mailto:/tel: link.',
|
|
1097
|
+
localized: false,
|
|
1098
|
+
}),
|
|
1099
|
+
newTab: checkbox({ label: 'Open in a new tab' }),
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
export const link: {
|
|
1103
|
+
<V extends LinkValue>(
|
|
1104
|
+
options: Common & { pageCollection?: string; value: V; withLabel?: boolean },
|
|
1105
|
+
): Unnamed<V>
|
|
1106
|
+
<V extends LinkValue>(
|
|
1107
|
+
options: Common & { default: V; pageCollection?: string; withLabel?: boolean },
|
|
1108
|
+
): Unnamed<V>
|
|
1109
|
+
(options?: Common & { pageCollection?: string; withLabel?: boolean }): Unnamed<undefined>
|
|
1110
|
+
} = (options: Common & { pageCollection?: string; withLabel?: boolean } = {}) => {
|
|
1111
|
+
const { pageCollection = 'pages', withLabel = true } = options
|
|
1112
|
+
const raw = options.default as undefined | LinkValue | Record<string, unknown>
|
|
1113
|
+
|
|
1114
|
+
/*
|
|
1115
|
+
* A committed button is written the way the site renders it — `{ href, label }`, what
|
|
1116
|
+
* `resolveLink(cms, fallback)` takes. The platform stores the group the inspector edits,
|
|
1117
|
+
* which is a different shape. Translating here rather than asking each site to write
|
|
1118
|
+
* `{ kind: 'url', url }` by hand is what removes jdsi's `seedLink` helper — but a site
|
|
1119
|
+
* that already writes the stored shape (jdsi's own `seedLink()` did, until it was
|
|
1120
|
+
* removed) must still be accepted: see `isStoredLinkValue`.
|
|
1121
|
+
*/
|
|
1122
|
+
const alreadyStored = raw !== undefined && isStoredLinkValue(raw)
|
|
1123
|
+
const stored = raw === undefined ? undefined : alreadyStored ? raw : storedLink(raw as LinkValue)
|
|
1124
|
+
const committed =
|
|
1125
|
+
raw === undefined ? undefined : alreadyStored ? valueFromStored(raw) : (raw as LinkValue)
|
|
1126
|
+
|
|
1127
|
+
return {
|
|
1128
|
+
/*
|
|
1129
|
+
* Schema, unlike `LINK_FIELD_MARKER` beneath it: this one is meant to be published.
|
|
1130
|
+
* `namedFields` spreads whatever the descriptor carries into the field it emits, so
|
|
1131
|
+
* it reaches the platform as JSON and the inspector reads it there.
|
|
1132
|
+
*/
|
|
1133
|
+
element: 'button' as const,
|
|
1134
|
+
...group({
|
|
1135
|
+
...options,
|
|
1136
|
+
...(stored === undefined ? {} : { default: stored }),
|
|
1137
|
+
...(committed === undefined ? {} : { value: committed }),
|
|
1138
|
+
fields: linkFields({ pageCollection, withLabel }),
|
|
1139
|
+
}),
|
|
1140
|
+
[LINK_FIELD_MARKER]: true,
|
|
1141
|
+
} as Unnamed<never>
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** The marker that says where the link falls inside a sentence. See `sentence()`. */
|
|
1145
|
+
export const SENTENCE_LINK = '{link}'
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* A sentence as the *site* renders it: the copy, and the link inside it.
|
|
1149
|
+
*
|
|
1150
|
+
* `text` holds the whole line with `{link}` standing where the link goes. One string, not
|
|
1151
|
+
* two, and that is the entire point — see `sentence()`.
|
|
1152
|
+
*/
|
|
1153
|
+
export type SentenceValue = LinkValue & { text: string }
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* A line of copy with one link inside it, edited as one thing.
|
|
1157
|
+
*
|
|
1158
|
+
* privacyNote: sentence({
|
|
1159
|
+
* label: 'Privacy Note',
|
|
1160
|
+
* default: {
|
|
1161
|
+
* text: 'See our {link} for how we handle your details.',
|
|
1162
|
+
* href: '/privacy',
|
|
1163
|
+
* label: 'Privacy Policy',
|
|
1164
|
+
* },
|
|
1165
|
+
* })
|
|
1166
|
+
*
|
|
1167
|
+
* **Why this exists rather than three fields.** pbir's privacy line was `before`, `link`,
|
|
1168
|
+
* `after` — a text field, a button and another text field, three rows in the rail for one
|
|
1169
|
+
* sentence. It read badly and it rendered wrongly: `text()` in `shape.ts` trims what it is
|
|
1170
|
+
* given, so `'See our '` came back without the space that separated it from the link, and
|
|
1171
|
+
* the page said `See ourPrivacy Policyfor how we handle your details.` **Whitespace between
|
|
1172
|
+
* two fields belongs to neither of them**, and no amount of care in either field can hold
|
|
1173
|
+
* it. One string can, because the spaces are interior to it.
|
|
1174
|
+
*
|
|
1175
|
+
* The link keeps everything a button has: its words, a page on this site or a web address,
|
|
1176
|
+
* and whether it opens a new tab. What it does not keep is a row of its own — the inspector
|
|
1177
|
+
* draws the sentence and its destination in one panel, which `element: 'sentence'` is what
|
|
1178
|
+
* asks for.
|
|
1179
|
+
*
|
|
1180
|
+
* `{link}` is a marker in the copy, not markup: a client types a normal sentence and the
|
|
1181
|
+
* marker says where the link falls. A sentence that has lost its marker still renders its
|
|
1182
|
+
* link, appended — see `resolveSentence` in `shape.ts`. A legal link that vanishes because
|
|
1183
|
+
* somebody rewrote the sentence around it is a worse failure than one in an awkward place.
|
|
1184
|
+
*/
|
|
1185
|
+
export const sentence: {
|
|
1186
|
+
<V extends SentenceValue>(options: Common & { default: V; pageCollection?: string }): Unnamed<V>
|
|
1187
|
+
<V extends SentenceValue>(options: Common & { pageCollection?: string; value: V }): Unnamed<V>
|
|
1188
|
+
(options?: Common & { pageCollection?: string }): Unnamed<undefined>
|
|
1189
|
+
} = (options: Common & { pageCollection?: string } = {}) => {
|
|
1190
|
+
const { pageCollection = 'pages' } = options
|
|
1191
|
+
const raw = options.default as undefined | Record<string, unknown> | SentenceValue
|
|
1192
|
+
|
|
1193
|
+
/*
|
|
1194
|
+
* Translated exactly as `link()` translates its own default, and accepting either shape
|
|
1195
|
+
* for the same reason: a sentence stores `{ text, label, kind, url, newTab }` and renders
|
|
1196
|
+
* `{ text, href, label }`, and `storedLink`/`valueFromStored` are the one pair that knows
|
|
1197
|
+
* how those two correspond. `text` rides through both.
|
|
1198
|
+
*/
|
|
1199
|
+
const alreadyStored = raw !== undefined && isStoredLinkValue(raw)
|
|
1200
|
+
const stored =
|
|
1201
|
+
raw === undefined ? undefined : alreadyStored ? raw : storedLink(raw as SentenceValue)
|
|
1202
|
+
const committed =
|
|
1203
|
+
raw === undefined ? undefined : alreadyStored ? valueFromStored(raw) : (raw as SentenceValue)
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
element: 'sentence' as const,
|
|
1207
|
+
...group({
|
|
1208
|
+
...options,
|
|
1209
|
+
...(stored === undefined ? {} : { default: stored }),
|
|
1210
|
+
...(committed === undefined ? {} : { value: committed }),
|
|
1211
|
+
fields: {
|
|
1212
|
+
/*
|
|
1213
|
+
* `textarea`, not `text`: a sentence wraps, and a client editing one wants to see all
|
|
1214
|
+
* of it. It is declared first so the inspector's panel reads in the order the client
|
|
1215
|
+
* thinks in — the words, then which words are the link, then where it goes.
|
|
1216
|
+
*/
|
|
1217
|
+
text: textarea({
|
|
1218
|
+
description: `The line of copy. Put ${SENTENCE_LINK} where the link should fall.`,
|
|
1219
|
+
}),
|
|
1220
|
+
...linkFields({ pageCollection, withLabel: true }),
|
|
1221
|
+
},
|
|
1222
|
+
}),
|
|
1223
|
+
/*
|
|
1224
|
+
* The same marker a button carries, because `seedItem` has the same job to do with a
|
|
1225
|
+
* sentence inside an array item: translate the committed shape into the stored one. A
|
|
1226
|
+
* sentence is a `group`, so without this it would take `seedItem`'s group branch, which
|
|
1227
|
+
* copies only the keys the schema declares — `href` is not one of them, and the link
|
|
1228
|
+
* would be seeded with no address at all.
|
|
1229
|
+
*/
|
|
1230
|
+
[LINK_FIELD_MARKER]: true,
|
|
1231
|
+
} as Unnamed<never>
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
export type FieldSpec = Record<string, AnyField>
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* The tag that tells `defineSite` this entry is a layout.
|
|
1238
|
+
*
|
|
1239
|
+
* A symbol for the same reason `BLOCK_ENTRY` is one: a site spec is a flat object of the
|
|
1240
|
+
* author's own keys, and a carrier has to be recognisable without reserving a key name that
|
|
1241
|
+
* a site might legitimately want for something else.
|
|
1242
|
+
*/
|
|
1243
|
+
export const LAYOUT = Symbol('u90.layout')
|
|
1244
|
+
|
|
1245
|
+
/** The reserved blockType marking where a page's own sections go. See `defineSite`. */
|
|
1246
|
+
export const SLOT_BLOCK = '_slot'
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* The one entry of a layout that is not a block: the hole the page renders into.
|
|
1250
|
+
*
|
|
1251
|
+
* A frozen literal rather than a factory, because there is nothing to configure and two
|
|
1252
|
+
* calls must be indistinguishable — the one-slot rule below counts them.
|
|
1253
|
+
*/
|
|
1254
|
+
export const SLOT = Object.freeze({ blockType: SLOT_BLOCK }) as { blockType: string }
|
|
1255
|
+
|
|
1256
|
+
export type LayoutMeta = { default?: boolean; label: string }
|
|
1257
|
+
|
|
1258
|
+
export type LayoutEntry = {
|
|
1259
|
+
[LAYOUT]: true
|
|
1260
|
+
entries: (Record<string, unknown> & { blockType: string })[]
|
|
1261
|
+
meta: LayoutMeta
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* A named arrangement of blocks with one hole in it.
|
|
1266
|
+
*
|
|
1267
|
+
* The entries are the same shape a page's sections are and a block's seed is — one object
|
|
1268
|
+
* per entry, keyed by `blockType` — so the manifest validator, the media walker and the
|
|
1269
|
+
* seed-upload relinker all work on them unchanged. That reuse is the whole reason a layout
|
|
1270
|
+
* is expressible at all without a second content pipeline.
|
|
1271
|
+
*
|
|
1272
|
+
* No slug: the declaration key names it, and that name is what re-seeding is keyed on.
|
|
1273
|
+
*/
|
|
1274
|
+
export const layout = (
|
|
1275
|
+
meta: LayoutMeta,
|
|
1276
|
+
entries: (Record<string, unknown> & { blockType: string })[],
|
|
1277
|
+
): LayoutEntry => ({ [LAYOUT]: true, entries, meta })
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The tag that tells `defineSite` this entry declares Singles.
|
|
1281
|
+
*
|
|
1282
|
+
* A symbol beside `LAYOUT`, for the reason that one is a symbol: a site spec is a flat
|
|
1283
|
+
* object of the author's own keys, and a carrier has to be recognisable without reserving a
|
|
1284
|
+
* key name a site might legitimately want.
|
|
1285
|
+
*/
|
|
1286
|
+
export const SINGLES = Symbol('u90.singles')
|
|
1287
|
+
|
|
1288
|
+
/** What a site writes for one entry of `singles()`: the type, the client's name for it, and
|
|
1289
|
+
* the value the site ships with. The key comes from the map. */
|
|
1290
|
+
export type SingleSpec = { label: string; type: 'link' | 'text'; value: string }
|
|
1291
|
+
|
|
1292
|
+
export type SinglesEntry = {
|
|
1293
|
+
[SINGLES]: true
|
|
1294
|
+
declared: Record<string, SingleSpec>
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* The shared values this site starts with, keyed by the identifier blocks point at.
|
|
1299
|
+
*
|
|
1300
|
+
* A Single is one named scalar the client keeps once — a phone number, an ABN — and every
|
|
1301
|
+
* block that prints it declares `reference('phone')` and reads it as an ordinary text field.
|
|
1302
|
+
* A site could already *point at* one and had no way to *declare* one, so a freshly paired
|
|
1303
|
+
* client opened the Singles tab to nothing: the site rendered its committed fallbacks
|
|
1304
|
+
* perfectly and the values behind them were undiscoverable and uneditable, which is a worse
|
|
1305
|
+
* state than a visible mistake because nothing signals it.
|
|
1306
|
+
*
|
|
1307
|
+
* **This is not `business()` returning.** That was a category the platform invented, with a
|
|
1308
|
+
* fixed identity — a logo, a phone, an ABN, socials — so it only ever fitted the sites it
|
|
1309
|
+
* had been written for, and the platform had opinions about what each field meant. This says
|
|
1310
|
+
* only "the shared values this site starts with". The keys are the site's, the labels are
|
|
1311
|
+
* the client's, and what is seeded becomes rows the client wholly owns: theirs to edit,
|
|
1312
|
+
* rename, empty or delete, with no second opinion from here afterwards.
|
|
1313
|
+
*
|
|
1314
|
+
* **The declared value seeds the row; it does not replace the component's fallback.** A
|
|
1315
|
+
* component still renders `text(content.phone, COMMITTED.phone)`, because a site has to draw
|
|
1316
|
+
* itself when the platform is unreachable. Both read the same constant, so there is still one
|
|
1317
|
+
* literal per fact:
|
|
1318
|
+
*
|
|
1319
|
+
* singles({ phone: { label: 'Phone', type: 'text', value: COMMITTED.phone } })
|
|
1320
|
+
* text(content.phone, COMMITTED.phone)
|
|
1321
|
+
*
|
|
1322
|
+
* No slug and no meta: the map keys name the values, and that name is what re-seeding is
|
|
1323
|
+
* keyed on — the same bargain `layout()` strikes with its declaration key.
|
|
1324
|
+
*/
|
|
1325
|
+
export const singles = (declared: Record<string, SingleSpec>): SinglesEntry => ({
|
|
1326
|
+
[SINGLES]: true,
|
|
1327
|
+
declared,
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
export type BlockSpec = Record<
|
|
1331
|
+
string,
|
|
1332
|
+
{ fields: FieldSpec; label?: string; layoutOnly?: boolean; renders?: string[] }
|
|
1333
|
+
>
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Attach each field's name from its key, fill in any missing label, and drop `default`.
|
|
1337
|
+
*
|
|
1338
|
+
* This is the boundary: everything from here on is schema, published to the platform
|
|
1339
|
+
* and rendered into a client's editor. A default is seed data and stops here.
|
|
1340
|
+
*
|
|
1341
|
+
* `LINK_FIELD_MARKER` is the one thing that rides through, and only because it is a symbol:
|
|
1342
|
+
* the rest spread copies it, `JSON.stringify` drops it, and `seedItem` needs it to recognise
|
|
1343
|
+
* a link nested inside a group. See the marker's own note.
|
|
1344
|
+
*/
|
|
1345
|
+
export const namedFields = (spec: FieldSpec): EditorField[] =>
|
|
1346
|
+
Object.entries(spec).map(([name, field]) => {
|
|
1347
|
+
const {
|
|
1348
|
+
default: _seed,
|
|
1349
|
+
value: _committed,
|
|
1350
|
+
...schema
|
|
1351
|
+
} = field as Unnamed & {
|
|
1352
|
+
default?: unknown
|
|
1353
|
+
value?: unknown
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
...schema,
|
|
1357
|
+
label: field.label || humanise(name),
|
|
1358
|
+
name,
|
|
1359
|
+
}
|
|
1360
|
+
}) as EditorField[]
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* A nested `fields` value, reduced to the `FieldSpec` shape a recursive walk needs.
|
|
1364
|
+
*
|
|
1365
|
+
* A top-level entry's `fields` is the author-written map — untouched, keyed by name. A
|
|
1366
|
+
* `group()` or `array()` field's own `fields` is not: those helpers call `namedFields` on
|
|
1367
|
+
* their children synchronously, at construction time, before any walk begins — so by the time
|
|
1368
|
+
* `seedItem` or `defineSite`'s `validate` reaches them they have already become an
|
|
1369
|
+
* `EditorField[]`, each element carrying its name as a `.name` property rather than as an
|
|
1370
|
+
* object key. Walking that array with `Object.entries` would read the array's *indices*
|
|
1371
|
+
* ("0", "1", …) as if they were field names, which rejects every nested group and array a
|
|
1372
|
+
* site declares and prunes every nested value out of the seed. Rebuilding the map from each
|
|
1373
|
+
* element's own `.name` is what lets one recursive walk pass through either shape.
|
|
1374
|
+
*/
|
|
1375
|
+
export const namedFieldSpec = (fields: unknown): FieldSpec =>
|
|
1376
|
+
Array.isArray(fields)
|
|
1377
|
+
? (Object.fromEntries(
|
|
1378
|
+
(fields as { name: string }[]).map((field) => [field.name, field]),
|
|
1379
|
+
) as unknown as FieldSpec)
|
|
1380
|
+
: (fields as FieldSpec)
|
|
1381
|
+
|
|
1382
|
+
export const defineBlocks = (spec: BlockSpec): EditorBlock[] =>
|
|
1383
|
+
Object.entries(spec).map(([slug, block]) => ({
|
|
1384
|
+
fields: namedFields(block.fields),
|
|
1385
|
+
label: block.label ?? humanise(slug),
|
|
1386
|
+
...(block.layoutOnly === undefined ? {} : { layoutOnly: block.layoutOnly }),
|
|
1387
|
+
...(block.renders === undefined ? {} : { renders: block.renders }),
|
|
1388
|
+
slug,
|
|
1389
|
+
}))
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* What a fresh instance of each block contains, keyed by slug. See `SiteManifest.starters`.
|
|
1393
|
+
*
|
|
1394
|
+
* Reads both carriers, and it has to. `default` is defined as what the platform stores, so it
|
|
1395
|
+
* is preferred; but a site may say the same thing positionally — `text('Building WA.')` sets
|
|
1396
|
+
* `value`, what the component renders — and a client adding that block should get those words
|
|
1397
|
+
* rather than an empty box. `seedFrom` reads only `default` because a *seed* is written once
|
|
1398
|
+
* at pair time against content the site already serves; a starter has no such history.
|
|
1399
|
+
*
|
|
1400
|
+
* Blocks contributing nothing are omitted rather than published empty, so the dashboard can
|
|
1401
|
+
* tell "arrives ready to edit" from "arrives blank" and say so before the client commits.
|
|
1402
|
+
*
|
|
1403
|
+
* **`allBlocks` is why this takes a second argument `array()` and `group()` never needed.**
|
|
1404
|
+
* Every other field helper translates its own `default`/`value` into the stored button shape
|
|
1405
|
+
* the moment the site calls it — `link()` does this itself, and `array()` does it by calling
|
|
1406
|
+
* `seedItem` on each item during its own construction. `blocksField` cannot: a zone named by
|
|
1407
|
+
* slug (`of: [...]`) only says which block its children *may* be, not what that block's fields
|
|
1408
|
+
* are, and the block it names may not even have been declared yet in the site's own module —
|
|
1409
|
+
* see `navItem` in PBIR's `site.ts`, which names itself. The block library only exists whole
|
|
1410
|
+
* once `defineSite` finishes collecting every entry, which is this function's caller and the
|
|
1411
|
+
* one place that can resolve a slug zone's own committed children. A `blocks`-typed field's
|
|
1412
|
+
* chosen value is therefore routed through `seedZone` here, on the way into the starter — the
|
|
1413
|
+
* one translation `array`'s own construction-time pass could never have done for it.
|
|
1414
|
+
*/
|
|
1415
|
+
export const startersFrom = (
|
|
1416
|
+
spec: BlockSpec,
|
|
1417
|
+
allBlocks: EditorBlock[],
|
|
1418
|
+
): Record<string, Record<string, unknown>> => {
|
|
1419
|
+
const out: Record<string, Record<string, unknown>> = {}
|
|
1420
|
+
|
|
1421
|
+
for (const [slug, block] of Object.entries(spec)) {
|
|
1422
|
+
const fields = namedFieldSpec(block.fields)
|
|
1423
|
+
const starter: Record<string, unknown> = {}
|
|
1424
|
+
|
|
1425
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
1426
|
+
const descriptor = field as unknown as EditorField
|
|
1427
|
+
const carrier = field as { default?: unknown; value?: unknown }
|
|
1428
|
+
const chosen = carrier.default !== undefined ? carrier.default : carrier.value
|
|
1429
|
+
if (chosen === undefined) continue
|
|
1430
|
+
starter[name] =
|
|
1431
|
+
descriptor.type === 'blocks' && Array.isArray(chosen)
|
|
1432
|
+
? seedZone(descriptor, chosen, allBlocks)
|
|
1433
|
+
: chosen
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/*
|
|
1437
|
+
* A zone block states its own slug and a keyed one takes the document key. Read through
|
|
1438
|
+
* an optional so both forms land under the slug stored content actually names.
|
|
1439
|
+
*/
|
|
1440
|
+
const key = (block as { slug?: string }).slug ?? slug
|
|
1441
|
+
if (Object.keys(starter).length > 0) out[key] = starter
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
return out
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
export type CollectionSpec = Record<
|
|
1448
|
+
string,
|
|
1449
|
+
{
|
|
1450
|
+
/** Where these rows render on the site. See `CollectionRoutes`. */
|
|
1451
|
+
appearsOn?: CollectionRoutes
|
|
1452
|
+
description?: string
|
|
1453
|
+
detailField?: string
|
|
1454
|
+
fields: FieldSpec
|
|
1455
|
+
itemNoun?: string
|
|
1456
|
+
label?: string
|
|
1457
|
+
/** One object per committed row, in the order the site renders them. */
|
|
1458
|
+
seed?: Record<string, unknown>[]
|
|
1459
|
+
titleField: string
|
|
1460
|
+
}
|
|
1461
|
+
>
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* How a site declares its repeatable record types.
|
|
1465
|
+
*
|
|
1466
|
+
* export const collections = defineCollections({
|
|
1467
|
+
* projects: {
|
|
1468
|
+
* titleField: 'name',
|
|
1469
|
+
* detailField: 'sector',
|
|
1470
|
+
* fields: { name: text(), sector: text(), photo: upload() },
|
|
1471
|
+
* seed: brand.projects.map(({ name, sector }) => ({ name, sector })),
|
|
1472
|
+
* },
|
|
1473
|
+
* })
|
|
1474
|
+
*
|
|
1475
|
+
* Keyed by object for the same reason blocks are: the key *is* the slug, so two
|
|
1476
|
+
* collections cannot collide, and a collision here would make stored rows ambiguous
|
|
1477
|
+
* about which record type they belong to.
|
|
1478
|
+
*
|
|
1479
|
+
* Unlike a block, the seed is written as whole rows rather than collected from
|
|
1480
|
+
* per-field `default`s — a collection's seed is a *list*, and there is no sensible
|
|
1481
|
+
* way to spell "six rows" as a default on each field.
|
|
1482
|
+
*
|
|
1483
|
+
* **A row is pruned exactly as an array item is**, through the same `seedItem`: undeclared
|
|
1484
|
+
* keys dropped, marked links translated, uploads carried through as their committed path, at
|
|
1485
|
+
* every depth `seedItem` can resolve. The rule is the field's type, never the container it
|
|
1486
|
+
* sits in — a row and an item are the same problem one level apart. Left unpruned, a committed
|
|
1487
|
+
* `{ href }` reached the platform as a button with no address; jdsi's migration hit that and
|
|
1488
|
+
* it is what this exists to stop. A zone nested inside a row and named by slug does not
|
|
1489
|
+
* resolve here — see the note on the `seedItem` call below and on `seedItem` itself.
|
|
1490
|
+
*/
|
|
1491
|
+
export const defineCollections = (spec: CollectionSpec): ManifestCollection[] =>
|
|
1492
|
+
Object.entries(spec).map(([slug, entry]) => ({
|
|
1493
|
+
...(entry.appearsOn === undefined ? {} : { appearsOn: entry.appearsOn }),
|
|
1494
|
+
...(entry.description === undefined ? {} : { description: entry.description }),
|
|
1495
|
+
...(entry.detailField === undefined ? {} : { detailField: entry.detailField }),
|
|
1496
|
+
...(entry.itemNoun === undefined ? {} : { itemNoun: entry.itemNoun }),
|
|
1497
|
+
/*
|
|
1498
|
+
* `allBlocks: []` for the same reason `array()`'s own default carries it: a collection's
|
|
1499
|
+
* rows are pruned while the site's module is still being evaluated, and `defineCollections`
|
|
1500
|
+
* is a public export a site may call standalone, without ever going through `defineSite` at
|
|
1501
|
+
* all — there is no site-wide block list to hand it even when one exists. A zone nested
|
|
1502
|
+
* inside a row and named by slug (`of: [...]`) does not resolve here; see `seedItem`.
|
|
1503
|
+
*/
|
|
1504
|
+
...(entry.seed === undefined
|
|
1505
|
+
? {}
|
|
1506
|
+
: { seed: entry.seed.map((row) => seedItem(entry.fields, row, [])) }),
|
|
1507
|
+
fields: namedFields(entry.fields),
|
|
1508
|
+
label: entry.label ?? humanise(slug),
|
|
1509
|
+
slug,
|
|
1510
|
+
titleField: entry.titleField,
|
|
1511
|
+
}))
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Build the manifest this site publishes.
|
|
1515
|
+
*
|
|
1516
|
+
* `generatedAt` is stamped at call time rather than at build time, so an operator
|
|
1517
|
+
* looking at a stale schema in the dashboard sees when the *running* site last
|
|
1518
|
+
* reported it, not when the bundle was compiled.
|
|
1519
|
+
*/
|
|
1520
|
+
export const createManifest = ({
|
|
1521
|
+
blocks,
|
|
1522
|
+
collections,
|
|
1523
|
+
newPage,
|
|
1524
|
+
pages = [],
|
|
1525
|
+
routesAllPages,
|
|
1526
|
+
seed,
|
|
1527
|
+
}: {
|
|
1528
|
+
blocks: EditorBlock[]
|
|
1529
|
+
/** Repeatable record types, from `defineCollections`. Their seeds ride along. */
|
|
1530
|
+
collections?: ManifestCollection[]
|
|
1531
|
+
/**
|
|
1532
|
+
* The sections a client-added page starts with. See `SiteManifest.newPage`.
|
|
1533
|
+
*
|
|
1534
|
+
* A literal `SeedSection[]`, **not** a `BlockSpec` like `seed` — there is no `seedFrom`
|
|
1535
|
+
* pass over it. `seedFrom` emits one section per declared block in spec order, which is a
|
|
1536
|
+
* layout for one particular page (the home page) and wrong for every other. A starting
|
|
1537
|
+
* stack is a deliberate short list, so the site writes it out.
|
|
1538
|
+
*/
|
|
1539
|
+
newPage?: SeedSection[]
|
|
1540
|
+
pages?: PageDeclaration[]
|
|
1541
|
+
/** True when a catch-all route serves every stored slug. See `SiteManifest.routesAllPages`. */
|
|
1542
|
+
routesAllPages?: boolean
|
|
1543
|
+
/**
|
|
1544
|
+
* Seed content. Pass the same spec you gave `defineBlocks` and the defaults are
|
|
1545
|
+
* collected automatically; pass nothing and no seed is published.
|
|
1546
|
+
*/
|
|
1547
|
+
seed?: BlockSpec
|
|
1548
|
+
}): SiteManifest => ({
|
|
1549
|
+
blocks,
|
|
1550
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
1551
|
+
...(collections ? { collections } : {}),
|
|
1552
|
+
generatedAt: new Date().toISOString(),
|
|
1553
|
+
...(newPage ? { newPage } : {}),
|
|
1554
|
+
pages,
|
|
1555
|
+
...(routesAllPages ? { routesAllPages } : {}),
|
|
1556
|
+
...(seed ? { seed: seedFrom(seed) } : {}),
|
|
1557
|
+
/*
|
|
1558
|
+
* Derived from `seed`, which is the only spec this form receives — `blocks` arrives
|
|
1559
|
+
* already published as schema, with the carriers stripped. That is the honest limit of the
|
|
1560
|
+
* legacy shape: a caller who passes the spec gets starters, one who passes only published
|
|
1561
|
+
* blocks does not, and `defineSite` (which holds the real spec) has no such gap.
|
|
1562
|
+
*/
|
|
1563
|
+
...(() => {
|
|
1564
|
+
if (!seed) return {}
|
|
1565
|
+
const starters = startersFrom(seed, blocks)
|
|
1566
|
+
return Object.keys(starters).length > 0 ? { starters } : {}
|
|
1567
|
+
})(),
|
|
1568
|
+
})
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Collect every `default` in a block spec into one seed section per block.
|
|
1572
|
+
*
|
|
1573
|
+
* Blocks are emitted in spec order, which is the order the page renders them — the
|
|
1574
|
+
* platform indexes sections by position, so a seed in the wrong order would address
|
|
1575
|
+
* every field to the wrong section.
|
|
1576
|
+
*
|
|
1577
|
+
* A block whose fields have no defaults still gets a section. That is deliberate: the
|
|
1578
|
+
* section's *presence* is what gives the site's bridge a `fieldBase` to hang editable
|
|
1579
|
+
* markers on, and without it that block is invisible to the editor even though its
|
|
1580
|
+
* schema is known.
|
|
1581
|
+
*
|
|
1582
|
+
* Exported for `defineSite`, which seeds the blocks it was told to seed and needs the
|
|
1583
|
+
* *same* function rather than a copy of it: the two forms of the same site are asserted to
|
|
1584
|
+
* publish identical output, so a second implementation drifting from this one would break
|
|
1585
|
+
* that identity silently. `BlockSpec` is the wider shape — a label it never reads costs it
|
|
1586
|
+
* nothing — so one signature serves both callers.
|
|
1587
|
+
*/
|
|
1588
|
+
export const seedFrom = (spec: BlockSpec): SeedSection[] =>
|
|
1589
|
+
Object.entries(spec).map(([slug, block]) => ({
|
|
1590
|
+
blockType: slug,
|
|
1591
|
+
...seedFields(block.fields),
|
|
1592
|
+
}))
|
|
1593
|
+
|
|
1594
|
+
/** The seed default of every field in a block, keyed by field name. See `seedFrom`. */
|
|
1595
|
+
const seedFields = (spec: FieldSpec): Record<string, unknown> => {
|
|
1596
|
+
const out: Record<string, unknown> = {}
|
|
1597
|
+
for (const [name, field] of Object.entries(spec)) {
|
|
1598
|
+
const value = (field as { default?: unknown }).default
|
|
1599
|
+
if (value !== undefined) out[name] = value
|
|
1600
|
+
}
|
|
1601
|
+
return out
|
|
1602
|
+
}
|