@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.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. 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
+ }