@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,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for rendering CMS values, safe to import from a client component.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately free of `next/headers` and of any environment access — the fetcher in
|
|
5
|
+
* `./content` holds the API key and must stay server-side, and mixing the two is how a
|
|
6
|
+
* secret ends up in a browser bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type MediaRef = null | {
|
|
10
|
+
alt?: null | string
|
|
11
|
+
filename?: null | string
|
|
12
|
+
/** Where the subject is, as percentages. Absent means dead centre. */
|
|
13
|
+
focalX?: null | number
|
|
14
|
+
focalY?: null | number
|
|
15
|
+
mimeType?: null | string
|
|
16
|
+
sizes?: Record<string, unknown> | null
|
|
17
|
+
/** Already absolute — the platform resolves it on the way out. */
|
|
18
|
+
url?: null | string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A page reference: an id at depth 0, a document once resolved. */
|
|
22
|
+
export type PageRef = null | number | string | { slug?: null | string; title?: null | string }
|
|
23
|
+
|
|
24
|
+
/** A button, as modelled by `link()` in `defineBlocks`. */
|
|
25
|
+
export type LinkField = null | {
|
|
26
|
+
kind?: 'page' | 'url' | null
|
|
27
|
+
label?: null | string
|
|
28
|
+
newTab?: boolean | null
|
|
29
|
+
page?: PageRef
|
|
30
|
+
url?: null | string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A stored sentence: a link group with the line of copy that surrounds it.
|
|
35
|
+
*
|
|
36
|
+
* One more key than `LinkField` and otherwise identical, because that is what a sentence
|
|
37
|
+
* *is* — see `sentence()` in `defineBlocks.ts`. A site types its blocks with this wherever
|
|
38
|
+
* it declared one.
|
|
39
|
+
*/
|
|
40
|
+
export type SentenceField = null | (NonNullable<LinkField> & { text?: null | string })
|
|
41
|
+
|
|
42
|
+
/** Attribute the dashboard looks for. Value is `${sectionIndex}.${fieldPath}`. */
|
|
43
|
+
export const FIELD_ATTR = 'data-u90-field'
|
|
44
|
+
|
|
45
|
+
/** Marks a page section so Canvas can outline the whole block when it is selected. */
|
|
46
|
+
export const SECTION_ATTR = 'data-u90-section'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Marks a field whose value is a rich text document rather than a run of words.
|
|
50
|
+
*
|
|
51
|
+
* The caret rules key off this. A rich field satisfies every other clause of "editable leaf" —
|
|
52
|
+
* no nested field markers, no media — so without it the frame would make a legal document
|
|
53
|
+
* contentEditable and the bridge would send back `innerText`, flattening every bullet and bold
|
|
54
|
+
* term on the first keystroke.
|
|
55
|
+
*/
|
|
56
|
+
export const RICH_ATTR = 'data-u90-rich'
|
|
57
|
+
|
|
58
|
+
/** Mark a rich text field: editable, but through `u90:edit-rich`, never as plain words. */
|
|
59
|
+
export const richField = (
|
|
60
|
+
base: number | undefined,
|
|
61
|
+
...parts: (number | string)[]
|
|
62
|
+
): Record<string, string> =>
|
|
63
|
+
base === undefined ? {} : { ...field(base, ...parts), [RICH_ATTR]: '' }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where to look for the thing a selection names, in the order the selection means it.
|
|
67
|
+
*
|
|
68
|
+
* Canvas selects containers as readily as leaves — a whole section, a list, a link's group —
|
|
69
|
+
* and only a leaf is ever annotated. `data-u90-field` exists on `7.benefits.0.line`; nothing
|
|
70
|
+
* carries `7.benefits`, and nothing carries `7`. So an exact-match-only lookup found the thing
|
|
71
|
+
* a client clicked exactly when they clicked a field, and nothing the rest of the time: picking
|
|
72
|
+
* a section in the rail outlined it correctly and left the page wherever it already was.
|
|
73
|
+
*
|
|
74
|
+
* Several questions, narrowest first, because they answer different things about the same string:
|
|
75
|
+
*
|
|
76
|
+
* - **The element itself.** A leaf field, and the only case that was ever handled.
|
|
77
|
+
* - **A collection row**, `@projects.42` — any field belonging to that row will do. A detail
|
|
78
|
+
* page has no reason to render the `titleField` a deep link names: jdsi's project page marks
|
|
79
|
+
* `heroTitle` and `overview` and never `name`, because the heading *is* the project. Exact
|
|
80
|
+
* match therefore finds nothing on precisely the screen the link exists to open.
|
|
81
|
+
* - **A layout block**, `~12.0` — the chrome around the page, addressed by layout id and the
|
|
82
|
+
* entry's position in it. Any field of that entry will do, for the reason a row's will.
|
|
83
|
+
* - **A section, by its own marker.** Preferred over the section's first field: the block is
|
|
84
|
+
* what was selected and what gets outlined, so it is also what should be brought into view.
|
|
85
|
+
* - **Anything inside it**, by dotted prefix. This is what carries a list and a group, and a
|
|
86
|
+
* section whose site forgot its marker.
|
|
87
|
+
*
|
|
88
|
+
* Pure and separate from the DOM so the resolution order can be asserted directly — the
|
|
89
|
+
* scrolling it feeds cannot be.
|
|
90
|
+
*/
|
|
91
|
+
export const revealSelectors = (field: string): string[] => {
|
|
92
|
+
// Generated paths: letters, digits, dots, `@`. Escaping quotes is enough, and avoids
|
|
93
|
+
// depending on CSS.escape.
|
|
94
|
+
const quoted = field.replace(/"/g, '\\"')
|
|
95
|
+
const selectors = [`[${FIELD_ATTR}="${quoted}"]`]
|
|
96
|
+
|
|
97
|
+
if (field.startsWith('@')) {
|
|
98
|
+
const [collection, id] = field.slice(1).split('.')
|
|
99
|
+
/*
|
|
100
|
+
* Returns here rather than falling through to the dotted prefix below. A row's fields are
|
|
101
|
+
* already covered by the row prefix, and `@projects.42.name.` would ask for children of a
|
|
102
|
+
* leaf — a selector that can only ever match nothing.
|
|
103
|
+
*/
|
|
104
|
+
if (collection && id) {
|
|
105
|
+
selectors.push(`[${FIELD_ATTR}^="${`@${collection}.${id}.`.replace(/"/g, '\\"')}"]`)
|
|
106
|
+
}
|
|
107
|
+
return selectors
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (field.startsWith('~')) {
|
|
111
|
+
const [layoutId, index] = field.slice(1).split('.')
|
|
112
|
+
/*
|
|
113
|
+
* No `SECTION_ATTR` selector, and that omission is why this branch exists rather than
|
|
114
|
+
* falling through to the one below. The `0` in `~12.0` is a position in the layout's own
|
|
115
|
+
* list of entries; a bare number *is* a page section index and nothing here is one. Asking
|
|
116
|
+
* for `data-u90-section="0"` would find an unrelated block of the page and scroll the
|
|
117
|
+
* client somewhere they did not ask to go — worse than not moving at all.
|
|
118
|
+
*
|
|
119
|
+
* Returns for the reason the `@` branch does: the block prefix already covers every field
|
|
120
|
+
* of the entry, and `~12.0.tagline.` would be children of a leaf.
|
|
121
|
+
*/
|
|
122
|
+
if (layoutId && index) {
|
|
123
|
+
selectors.push(`[${FIELD_ATTR}^="${`~${layoutId}.${index}.`.replace(/"/g, '\\"')}"]`)
|
|
124
|
+
}
|
|
125
|
+
return selectors
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (/^\d+$/.test(field)) selectors.push(`[${SECTION_ATTR}="${quoted}"]`)
|
|
129
|
+
|
|
130
|
+
selectors.push(`[${FIELD_ATTR}^="${quoted}."]`)
|
|
131
|
+
|
|
132
|
+
return selectors
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether the page is already showing this, so scrolling would only move it away.
|
|
137
|
+
*
|
|
138
|
+
* Two conditions, and the second is why this is a function rather than one comparison. "Fully
|
|
139
|
+
* on screen" is **unsatisfiable for anything taller than the viewport**, so applied to a
|
|
140
|
+
* section it answered "no" every time and re-centred a block the client was already reading —
|
|
141
|
+
* sometimes scrolling the part they were looking at off the top.
|
|
142
|
+
*
|
|
143
|
+
* So: a target that fits is settled when all of it is on screen, and a target that does not fit
|
|
144
|
+
* is settled when the screen is full of it. Neither needs a tolerance to be picked, which is
|
|
145
|
+
* the point — a fraction chosen here would be a number nobody could defend later.
|
|
146
|
+
*/
|
|
147
|
+
export const isSettledInView = (
|
|
148
|
+
rect: { bottom: number; height: number; top: number },
|
|
149
|
+
viewport: number,
|
|
150
|
+
): boolean =>
|
|
151
|
+
rect.height > viewport
|
|
152
|
+
? rect.top <= 0 && rect.bottom >= viewport
|
|
153
|
+
: rect.top >= 0 && rect.bottom <= viewport
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Mark an element as editable from the dashboard.
|
|
157
|
+
*
|
|
158
|
+
* <h1 {...field(base, 'slides', i, 'title')}>{slide.title}</h1>
|
|
159
|
+
*
|
|
160
|
+
* Returns nothing when `base` is undefined — i.e. when the section isn't coming from
|
|
161
|
+
* the CMS — so a site with no CMS emits no editor attributes at all.
|
|
162
|
+
*/
|
|
163
|
+
export const field = (
|
|
164
|
+
base: number | undefined,
|
|
165
|
+
...parts: (number | string)[]
|
|
166
|
+
): Record<string, string> =>
|
|
167
|
+
base === undefined ? {} : { [FIELD_ATTR]: [base, ...parts].join('.') }
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Mark the outermost element of a page section.
|
|
171
|
+
*
|
|
172
|
+
* <section {...section(fieldBase)}>…</section>
|
|
173
|
+
*
|
|
174
|
+
* `field` says which words a client is editing; this says which *block* those words are part
|
|
175
|
+
* of. Canvas needs both, because selecting a heading and selecting the block it sits in are
|
|
176
|
+
* two different selections that must both be able to draw the block's box: the outline around
|
|
177
|
+
* the whole section, and the block toolbar that stands at its top-left corner.
|
|
178
|
+
*
|
|
179
|
+
* **This existed as an attribute long before it existed as a helper, and nothing emitted it.**
|
|
180
|
+
* `SECTION_ATTR` was declared here, matched by the selection stylesheet in `bridge.tsx`, and
|
|
181
|
+
* used by `revealSelectors` to decide what to scroll into view — a complete feature with no
|
|
182
|
+
* way to be true. Selecting a section outlined nothing, because `[data-u90-section="0"]` has
|
|
183
|
+
* never matched an element on any site we ship. The rule was written for a mark no one had
|
|
184
|
+
* been given a way to write.
|
|
185
|
+
*
|
|
186
|
+
* One per block, on the block's own root — not on a wrapper the site's section loop adds. A
|
|
187
|
+
* wrapper is a box the design did not ask for, in a `<main>` that is usually a flex column;
|
|
188
|
+
* `display: contents` would avoid the box and take the outline with it, since an element that
|
|
189
|
+
* generates no box cannot be given one. The root is the only element that is reliably the
|
|
190
|
+
* section's own full extent.
|
|
191
|
+
*
|
|
192
|
+
* Undefined `base` returns nothing, exactly like `field`: a site rendering outside the CMS
|
|
193
|
+
* emits no editor attributes at all.
|
|
194
|
+
*/
|
|
195
|
+
export const section = (base: number | undefined): Record<string, string> =>
|
|
196
|
+
base === undefined ? {} : { [SECTION_ATTR]: String(base) }
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Mark a field belonging to a **collection row** rather than to a page section.
|
|
200
|
+
*
|
|
201
|
+
* <span {...entryField('projects', project.id, 'name')}>{project.name}</span>
|
|
202
|
+
*
|
|
203
|
+
* The path is `@projects.42.name`: the `@` says "this lives in a collection, not in
|
|
204
|
+
* this page's sections", the slug names the record type and the id names the row.
|
|
205
|
+
*
|
|
206
|
+
* The id rather than the position, deliberately. A row's position is a property of
|
|
207
|
+
* how the client last sorted them and changes under the editor's feet; the id is what
|
|
208
|
+
* the row *is*, so an edit lands on the project the client clicked even if the list
|
|
209
|
+
* reorders between the click and the save.
|
|
210
|
+
*
|
|
211
|
+
* `undefined` id renders no marker at all, matching `field`'s behaviour for a section
|
|
212
|
+
* that isn't on the page — an unsaved row has nothing for an edit to address yet.
|
|
213
|
+
*/
|
|
214
|
+
export const entryField = (
|
|
215
|
+
collection: string,
|
|
216
|
+
id: number | string | undefined,
|
|
217
|
+
...parts: (number | string)[]
|
|
218
|
+
): Record<string, string> =>
|
|
219
|
+
id === undefined ? {} : { [FIELD_ATTR]: [`@${collection}`, id, ...parts].join('.') }
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Mark a field belonging to the **layout** the page is wrapped in — its header, its footer.
|
|
223
|
+
*
|
|
224
|
+
* <p {...layoutField(layoutId, index, 'tagline')}>{text(entry.tagline, 'Since 1994')}</p>
|
|
225
|
+
*
|
|
226
|
+
* The path is `~12.0.tagline`: the `~` says "this lives in the chrome around the page, not in
|
|
227
|
+
* the page", the number after it names the layout document and the one after that is the
|
|
228
|
+
* entry's position in that layout's list of blocks.
|
|
229
|
+
*
|
|
230
|
+
* The id is carried for the reason `entryField` carries its collection: two frames open side
|
|
231
|
+
* by side can be on pages using different layouts, and "entry 0" is a different block in each,
|
|
232
|
+
* so an index alone cannot say which document an edit belongs to.
|
|
233
|
+
*
|
|
234
|
+
* The position rather than an id of its own, though, because a layout's entries are a list on
|
|
235
|
+
* the layout row and nothing in an entry is stable enough to name. Reordering the chrome does
|
|
236
|
+
* move what these addresses point at — which is right: the client dragged the block, and the
|
|
237
|
+
* block they dragged is the one they meant.
|
|
238
|
+
*
|
|
239
|
+
* `undefined` id renders no marker at all, matching `field` and `entryField` — a site being
|
|
240
|
+
* rendered with no layout behind it has nothing for an edit to address.
|
|
241
|
+
*/
|
|
242
|
+
export const layoutField = (
|
|
243
|
+
layoutId: number | string | undefined,
|
|
244
|
+
index: number,
|
|
245
|
+
...parts: (number | string)[]
|
|
246
|
+
): Record<string, string> =>
|
|
247
|
+
layoutId === undefined ? {} : { [FIELD_ATTR]: [`~${layoutId}`, index, ...parts].join('.') }
|
|
248
|
+
|
|
249
|
+
/** Prefer a CMS string, fall back to what the site ships with. */
|
|
250
|
+
export const text = (incoming: null | string | undefined, fallback: string): string => {
|
|
251
|
+
const trimmed = typeof incoming === 'string' ? incoming.trim() : ''
|
|
252
|
+
return trimmed.length > 0 ? trimmed : fallback
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** URL of an uploaded override, or null to fall back to a committed asset. */
|
|
256
|
+
export const mediaUrl = (media: MediaRef): null | string => media?.url ?? null
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Where to anchor an image that its container crops — a CSS `object-position`.
|
|
260
|
+
*
|
|
261
|
+
* The client picks a point on the photograph in the dashboard; this is how that point survives
|
|
262
|
+
* into a site. It matters most exactly where a site looks best: a full-bleed hero at
|
|
263
|
+
* `object-cover` throws away whatever does not fit, and without this it always throws away the
|
|
264
|
+
* edges, however the picture is composed.
|
|
265
|
+
*
|
|
266
|
+
* `fallback` rather than a hard-coded `center`, because a site may already have tuned a
|
|
267
|
+
* position by hand for a committed image — jdsi's closing banner sits at `center 60%` — and an
|
|
268
|
+
* upload with no focal point set must not quietly undo that.
|
|
269
|
+
*
|
|
270
|
+
* Returns a string for `style={{ objectPosition }}` rather than a Tailwind class, because the
|
|
271
|
+
* values are arbitrary percentages and a class name cannot be built at runtime.
|
|
272
|
+
*/
|
|
273
|
+
export const mediaPosition = (media: MediaRef, fallback = 'center'): string => {
|
|
274
|
+
const x = media?.focalX
|
|
275
|
+
const y = media?.focalY
|
|
276
|
+
if (typeof x !== 'number' || typeof y !== 'number') return fallback
|
|
277
|
+
// Payload stores percentages. Clamped because a hand-edited row could hold anything.
|
|
278
|
+
const clamp = (value: number): number => Math.min(100, Math.max(0, value))
|
|
279
|
+
return `${clamp(x)}% ${clamp(y)}%`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Merge CMS entries over a list of committed defaults, by position.
|
|
284
|
+
*
|
|
285
|
+
* The defaults carry everything the CMS doesn't model — video files, poster variants,
|
|
286
|
+
* responsive hints — so assets stay put while the copy comes from the platform. Null
|
|
287
|
+
* and undefined are dropped so a blank CMS field can't erase a default, and assets
|
|
288
|
+
* cycle if the CMS has more entries than the build ships.
|
|
289
|
+
*/
|
|
290
|
+
export const mergeByIndex = <Default extends object, Incoming extends object>(
|
|
291
|
+
defaults: Default[],
|
|
292
|
+
incoming: Incoming[] | undefined,
|
|
293
|
+
): (Default & Incoming)[] => {
|
|
294
|
+
if (!incoming?.length || defaults.length === 0) return defaults as (Default & Incoming)[]
|
|
295
|
+
|
|
296
|
+
return incoming.map((entry, index) => {
|
|
297
|
+
const fallback = defaults[index % defaults.length] as Default
|
|
298
|
+
const overrides = Object.fromEntries(
|
|
299
|
+
Object.entries(entry).filter(([, value]) => value !== null && value !== undefined),
|
|
300
|
+
) as Incoming
|
|
301
|
+
return { ...fallback, ...overrides }
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Href for a linked page: its slug, with `home` at the root. */
|
|
306
|
+
const pageHref = (page: PageRef | undefined): null | string => {
|
|
307
|
+
if (!page || typeof page !== 'object') return null
|
|
308
|
+
const slug = typeof page.slug === 'string' ? page.slug.trim() : ''
|
|
309
|
+
if (slug.length === 0) return null
|
|
310
|
+
return slug === 'home' ? '/' : `/${slug}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export type ResolvedLink = {
|
|
314
|
+
href: string
|
|
315
|
+
label: string
|
|
316
|
+
newTab: boolean
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Turn a CMS link group into what a button needs.
|
|
321
|
+
*
|
|
322
|
+
* Each part falls back independently, because a client can set a label without touching
|
|
323
|
+
* the destination or the other way round. An untouched group arrives as an object of
|
|
324
|
+
* nulls, so the *presence* of the group is not evidence that anything was set — only a
|
|
325
|
+
* non-empty value is.
|
|
326
|
+
*
|
|
327
|
+
* A page link whose slug can't be resolved falls back to the committed href rather than
|
|
328
|
+
* to `/`: a link that quietly goes to the home page is worse than one that still goes
|
|
329
|
+
* where it used to.
|
|
330
|
+
*/
|
|
331
|
+
export const resolveLink = (
|
|
332
|
+
link: LinkField | undefined,
|
|
333
|
+
fallback: { href: string; label: string },
|
|
334
|
+
): ResolvedLink => {
|
|
335
|
+
const url = typeof link?.url === 'string' ? link.url.trim() : ''
|
|
336
|
+
const chosen = link?.kind === 'url' ? (url.length > 0 ? url : null) : pageHref(link?.page)
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
href: chosen ?? fallback.href,
|
|
340
|
+
label: text(link?.label, fallback.label),
|
|
341
|
+
newTab: link?.newTab === true,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The marker in a sentence's copy that says where its link falls. */
|
|
346
|
+
const SENTENCE_LINK = '{link}'
|
|
347
|
+
|
|
348
|
+
export type ResolvedSentence = ResolvedLink & {
|
|
349
|
+
/** The words after the link. Empty when the link ends the sentence. */
|
|
350
|
+
after: string
|
|
351
|
+
/** The words before the link. Empty when the link opens the sentence. */
|
|
352
|
+
before: string
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Turn a CMS sentence into the three pieces a component renders: words, link, words.
|
|
357
|
+
*
|
|
358
|
+
* **The one place `{link}` is parsed**, so no site reimplements the split. A component
|
|
359
|
+
* writes `{before}<a>{label}</a>{after}` and is done.
|
|
360
|
+
*
|
|
361
|
+
* `before` and `after` are deliberately **not** put through `text()`. That helper trims,
|
|
362
|
+
* which is right for a heading and is exactly what broke the shape this replaces: the space
|
|
363
|
+
* between the copy and the link belongs to neither half, and trimming either one closes it
|
|
364
|
+
* up — `See ourPrivacy Policyfor how we handle your details.` The whole line is trimmed
|
|
365
|
+
* once, at its ends, where trailing space is only ever noise; the interior is the client's.
|
|
366
|
+
*
|
|
367
|
+
* A sentence with no marker still renders its link, after the copy with a space in front of
|
|
368
|
+
* it. A client rewriting the line around the marker is the likely accident, and dropping the
|
|
369
|
+
* link on the floor would take a privacy or terms link off the page silently — worse than a
|
|
370
|
+
* link in a slightly awkward place, which is at least visible and fixable.
|
|
371
|
+
*/
|
|
372
|
+
export const resolveSentence = (
|
|
373
|
+
stored: SentenceField | undefined,
|
|
374
|
+
fallback: { href: string; label: string; text: string },
|
|
375
|
+
): ResolvedSentence => {
|
|
376
|
+
const link = resolveLink(stored, fallback)
|
|
377
|
+
const copy = (
|
|
378
|
+
typeof stored?.text === 'string' && stored.text.trim().length > 0 ? stored.text : fallback.text
|
|
379
|
+
).trim()
|
|
380
|
+
|
|
381
|
+
const at = copy.indexOf(SENTENCE_LINK)
|
|
382
|
+
if (at === -1) return { ...link, after: '', before: copy.length > 0 ? `${copy} ` : '' }
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
...link,
|
|
386
|
+
after: copy.slice(at + SENTENCE_LINK.length),
|
|
387
|
+
before: copy.slice(0, at),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { imagePosition } from './image'
|
|
392
|
+
export type {
|
|
393
|
+
ImageValue,
|
|
394
|
+
ImageReadValue,
|
|
395
|
+
ImageSeedValue,
|
|
396
|
+
ImageCrop,
|
|
397
|
+
ImageFocalPoint,
|
|
398
|
+
ImageReadVariant,
|
|
399
|
+
ImageVariant,
|
|
400
|
+
ImageAsset,
|
|
401
|
+
ImageRendition,
|
|
402
|
+
} from './image'
|
|
403
|
+
|
|
404
|
+
export { videoProps } from './media'
|
|
405
|
+
export type {
|
|
406
|
+
VideoValue,
|
|
407
|
+
VideoReadValue,
|
|
408
|
+
VideoSeedValue,
|
|
409
|
+
FileValue,
|
|
410
|
+
FileReadValue,
|
|
411
|
+
FileSeedValue,
|
|
412
|
+
} from './media'
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { EditorBlock, EditorField } from './contract'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finding the uploads inside a page's sections.
|
|
5
|
+
*
|
|
6
|
+
* Content is opaque JSON, so an upload is a bare id with nothing marking it as one.
|
|
7
|
+
* Three things need to locate them: the platform hydrates them on the way out to a
|
|
8
|
+
* site, guards against deleting a file a page still uses, and forces them back to ids
|
|
9
|
+
* on write; the site resolves them for live editing.
|
|
10
|
+
*
|
|
11
|
+
* **This lives in the shared package so there is exactly one traversal.** Two walkers
|
|
12
|
+
* that disagreed about which values are media would mean ids collected but not
|
|
13
|
+
* replaced — images silently missing, with no error anywhere. That is the failure this
|
|
14
|
+
* placement exists to prevent, and it is why the platform imports this rather than
|
|
15
|
+
* keeping its own copy.
|
|
16
|
+
*
|
|
17
|
+
* Driven by the site's own schema rather than by field names. A convention (`media`,
|
|
18
|
+
* `video`, `image`, …) would be a guess about a schema nobody here owns: the first site
|
|
19
|
+
* to call a field `photo` would lose its images and nothing would report it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A position in the content tree that holds an upload. */
|
|
23
|
+
export type UploadSlot = {
|
|
24
|
+
get: () => unknown
|
|
25
|
+
set: (value: unknown) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isMediaUpload = (field: EditorField): boolean =>
|
|
29
|
+
field.type === 'upload' && (field.relationTo === undefined || field.relationTo === 'media')
|
|
30
|
+
|
|
31
|
+
/** An id as stored: a bare number or string, never an object. */
|
|
32
|
+
export const asMediaId = (value: unknown): null | number | string => {
|
|
33
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
34
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
35
|
+
// Already resolved, or populated by Payload. Recover the id.
|
|
36
|
+
if (value && typeof value === 'object') {
|
|
37
|
+
const id = (value as { id?: unknown }).id
|
|
38
|
+
if (typeof id === 'number' || typeof id === 'string') return id
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The blocks a zone field may hold, from whichever form it used.
|
|
45
|
+
*
|
|
46
|
+
* **This is a deliberate second copy of `childBlocksFor`** in
|
|
47
|
+
* `src/dashboard/canvas/nestedBlocks.ts` on the platform side — that module is the single
|
|
48
|
+
* authority for every consumer that *can* reach it, and this file cannot: the package is
|
|
49
|
+
* vendored into each client's site and must never import from the platform's `src/`. There is
|
|
50
|
+
* a **third** copy too — `childrenOf`/`seedZone` in `packages/site-kit/src/defineBlocks.ts`,
|
|
51
|
+
* this package's own seeding path, which cannot import from this file either without pulling
|
|
52
|
+
* upload-collection code somewhere that has nothing to do with it. Three copies, not a pair:
|
|
53
|
+
* change any one and change the other two; each of the three doc comments names all three
|
|
54
|
+
* files, on purpose, so a fourth copy — or a count that has quietly gone stale — is a
|
|
55
|
+
* three-way disagreement to notice rather than a "the pair" nobody thought to recount.
|
|
56
|
+
* `verify:nested-blocks` checks all three agree, not only `nestedBlocks.ts` and this file.
|
|
57
|
+
*
|
|
58
|
+
* The rule: `field.blocks` (inline definitions) is returned as-is when present, otherwise each
|
|
59
|
+
* entry of `field.childBlocks` (slugs) is looked up in the site's declared blocks, **in the
|
|
60
|
+
* order the site listed its slugs**, skipping any that names nothing. Exactly one form is ever
|
|
61
|
+
* present. The site's order rather than the block list's, because `of: ['navItem',
|
|
62
|
+
* 'productTile']` is the site saying which choice comes first — nothing here reads the order,
|
|
63
|
+
* but the platform's picker does, and a copy that resolved differently would be a copy that
|
|
64
|
+
* disagreed.
|
|
65
|
+
*/
|
|
66
|
+
const childrenOf = (field: EditorField, allBlocks: EditorBlock[]): EditorBlock[] => {
|
|
67
|
+
if (field.blocks !== undefined) return field.blocks
|
|
68
|
+
|
|
69
|
+
const resolved: EditorBlock[] = []
|
|
70
|
+
for (const slug of field.childBlocks ?? []) {
|
|
71
|
+
const block = allBlocks.find((entry) => entry.slug === slug)
|
|
72
|
+
if (block !== undefined) resolved.push(block)
|
|
73
|
+
}
|
|
74
|
+
return resolved
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const collectFrom = (
|
|
78
|
+
fields: EditorField[],
|
|
79
|
+
container: unknown,
|
|
80
|
+
slots: UploadSlot[],
|
|
81
|
+
allBlocks: EditorBlock[],
|
|
82
|
+
): void => {
|
|
83
|
+
if (!container || typeof container !== 'object') return
|
|
84
|
+
const record = container as Record<string, unknown>
|
|
85
|
+
|
|
86
|
+
for (const field of fields) {
|
|
87
|
+
const value = record[field.name]
|
|
88
|
+
|
|
89
|
+
if (isMediaUpload(field)) {
|
|
90
|
+
/*
|
|
91
|
+
* A `hasMany` upload yields one slot per element rather than one for the array.
|
|
92
|
+
*
|
|
93
|
+
* Every caller of this treats a slot as holding a single id — `collectMediaIds`
|
|
94
|
+
* asks `asMediaId` for it, `normaliseUploadsIn` writes one back — and `asMediaId`
|
|
95
|
+
* reads an array as an object with no `id`, so a whole-array slot collected
|
|
96
|
+
* nothing and *normalised the gallery to null on the next save*. Per element, all
|
|
97
|
+
* three walkers keep working unchanged, and the list keeps its order because each
|
|
98
|
+
* slot writes back to the index it came from.
|
|
99
|
+
*/
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
value.forEach((_item, index) => {
|
|
102
|
+
slots.push({
|
|
103
|
+
get: () => (record[field.name] as unknown[])[index],
|
|
104
|
+
set: (next) => {
|
|
105
|
+
;(record[field.name] as unknown[])[index] = next
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
slots.push({
|
|
113
|
+
get: () => record[field.name],
|
|
114
|
+
set: (next) => {
|
|
115
|
+
record[field.name] = next
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (field.type === 'group') {
|
|
122
|
+
collectFrom(field.fields ?? [], value, slots, allBlocks)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (field.type === 'array' && Array.isArray(value)) {
|
|
127
|
+
for (const item of value) collectFrom(field.fields ?? [], item, slots, allBlocks)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (field.type === 'blocks' && Array.isArray(value)) {
|
|
132
|
+
// A nested blocks field either carries its own definitions, which may differ from the
|
|
133
|
+
// page's top-level set, or names them by slug — see `childrenOf`. Reading `field.blocks`
|
|
134
|
+
// alone left a slug zone's allow-list empty, so an upload on a nested child was invisible
|
|
135
|
+
// to every one of this walker's three callers: it was never hydrated on egress, never
|
|
136
|
+
// guarded against deletion, and never normalised back to an id on write.
|
|
137
|
+
const nested = new Map(childrenOf(field, allBlocks).map((block) => [block.slug, block]))
|
|
138
|
+
for (const entry of value) {
|
|
139
|
+
const slug = (entry as { blockType?: unknown } | null)?.blockType
|
|
140
|
+
const block = typeof slug === 'string' ? nested.get(slug) : undefined
|
|
141
|
+
if (block) collectFrom(block.fields, entry, slots, allBlocks)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every upload position in a page's content.
|
|
149
|
+
*
|
|
150
|
+
* A section whose `blockType` isn't in the schema is skipped: nothing can be said about
|
|
151
|
+
* the shape of a block there is no definition for, and guessing is how images get lost.
|
|
152
|
+
*/
|
|
153
|
+
export const uploadSlots = (
|
|
154
|
+
content: unknown,
|
|
155
|
+
blocks: EditorBlock[],
|
|
156
|
+
/**
|
|
157
|
+
* The site's whole declared block list, when it differs from `blocks` — the same split
|
|
158
|
+
* `validateContent` makes on the platform side. They are the same list for a page; they come
|
|
159
|
+
* apart for a collection row, whose `blocks` is the one-block `__entry` wrapper while its
|
|
160
|
+
* zones still name the site's own blocks by slug.
|
|
161
|
+
*/
|
|
162
|
+
allBlocks: EditorBlock[] = blocks,
|
|
163
|
+
): UploadSlot[] => {
|
|
164
|
+
if (!Array.isArray(content)) return []
|
|
165
|
+
|
|
166
|
+
const bySlug = new Map(blocks.map((block) => [block.slug, block]))
|
|
167
|
+
const slots: UploadSlot[] = []
|
|
168
|
+
|
|
169
|
+
for (const section of content) {
|
|
170
|
+
const slug = (section as { blockType?: unknown } | null)?.blockType
|
|
171
|
+
const block = typeof slug === 'string' ? bySlug.get(slug) : undefined
|
|
172
|
+
if (block) collectFrom(block.fields, section, slots, allBlocks)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return slots
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Distinct media ids referenced by a page's content. */
|
|
179
|
+
export const collectMediaIds = (
|
|
180
|
+
content: unknown,
|
|
181
|
+
blocks: EditorBlock[],
|
|
182
|
+
/** See `uploadSlots`. */
|
|
183
|
+
allBlocks: EditorBlock[] = blocks,
|
|
184
|
+
): (number | string)[] => {
|
|
185
|
+
const ids = new Set<number | string>()
|
|
186
|
+
for (const slot of uploadSlots(content, blocks, allBlocks)) {
|
|
187
|
+
const id = asMediaId(slot.get())
|
|
188
|
+
if (id !== null) ids.add(id)
|
|
189
|
+
}
|
|
190
|
+
return [...ids]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Replace ids with resolved values, in place.
|
|
195
|
+
*
|
|
196
|
+
* An id with no match becomes `null` rather than staying a bare number: a site reading
|
|
197
|
+
* `media.url` off a number gets `undefined` and renders nothing, with no indication
|
|
198
|
+
* why. `null` is what a site's own fallback already handles.
|
|
199
|
+
*/
|
|
200
|
+
export const resolveUploadsIn = (
|
|
201
|
+
content: unknown,
|
|
202
|
+
blocks: EditorBlock[],
|
|
203
|
+
byId: Map<string, unknown>,
|
|
204
|
+
/** See `uploadSlots`. */
|
|
205
|
+
allBlocks: EditorBlock[] = blocks,
|
|
206
|
+
): void => {
|
|
207
|
+
for (const slot of uploadSlots(content, blocks, allBlocks)) {
|
|
208
|
+
const id = asMediaId(slot.get())
|
|
209
|
+
if (id === null) continue
|
|
210
|
+
slot.set(byId.get(String(id)) ?? null)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Force every upload position back to a bare id, in place.
|
|
216
|
+
*
|
|
217
|
+
* What makes a mistake elsewhere recoverable rather than destructive: whatever shape an
|
|
218
|
+
* upload arrives in on a write, storage only ever holds an id. An object stored where an
|
|
219
|
+
* id belongs loses the reference permanently on the next save.
|
|
220
|
+
*/
|
|
221
|
+
export const normaliseUploadsIn = (
|
|
222
|
+
content: unknown,
|
|
223
|
+
blocks: EditorBlock[],
|
|
224
|
+
/** See `uploadSlots`. */
|
|
225
|
+
allBlocks: EditorBlock[] = blocks,
|
|
226
|
+
): void => {
|
|
227
|
+
for (const slot of uploadSlots(content, blocks, allBlocks)) {
|
|
228
|
+
const current = slot.get()
|
|
229
|
+
if (current === null || current === undefined) continue
|
|
230
|
+
// An unresolvable value is cleared: storing an object here is what corrupts it.
|
|
231
|
+
slot.set(asMediaId(current))
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Legacy is packed on its own, so this helper must remain inside the legacy package.
|
|
2
|
+
export type VerifyVisitor = (
|
|
3
|
+
request: Request,
|
|
4
|
+
) => Promise<{ ok: true } | { ok: false; retryable: boolean }>
|
|
5
|
+
/** Bound custom adapters as well as network adapters. Proof stays in this server-only request. */
|
|
6
|
+
export async function verifyWithDeadline(
|
|
7
|
+
verifyVisitor: VerifyVisitor,
|
|
8
|
+
url: string,
|
|
9
|
+
proof: string,
|
|
10
|
+
signal?: AbortSignal,
|
|
11
|
+
): Promise<Awaited<ReturnType<VerifyVisitor>>> {
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
const abort = () => controller.abort()
|
|
14
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
15
|
+
if (signal?.aborted) abort()
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
try {
|
|
18
|
+
return await Promise.race([
|
|
19
|
+
new Promise<never>((_resolve, reject) => {
|
|
20
|
+
const fail = () => reject(new Error('Verification unavailable.'))
|
|
21
|
+
controller.signal.addEventListener('abort', fail, { once: true })
|
|
22
|
+
if (controller.signal.aborted) fail()
|
|
23
|
+
timer = setTimeout(() => controller.abort(), 10000)
|
|
24
|
+
}),
|
|
25
|
+
Promise.resolve().then(() => {
|
|
26
|
+
controller.signal.throwIfAborted()
|
|
27
|
+
return verifyVisitor(
|
|
28
|
+
new Request(url, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify({ proof }),
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
}),
|
|
36
|
+
])
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer)
|
|
39
|
+
controller.abort()
|
|
40
|
+
signal?.removeEventListener('abort', abort)
|
|
41
|
+
}
|
|
42
|
+
}
|