@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,170 @@
1
+ /**
2
+ * The rich text vocabulary, and the only place it is spelled out.
3
+ *
4
+ * Lexical's serialised shape, narrowed to the nodes this kit renders and can serialise back.
5
+ * Closed on purpose: the DOM-to-Lexical inverse in `lexicalDom.ts` only has to invert what
6
+ * `richText.tsx` emits, and that argument holds only while both read from this list.
7
+ *
8
+ * **No `lexical` import.** This package is vendored into every client site, so a dependency
9
+ * here is a dependency in seven repositories. A builder for a documented JSON tree does not
10
+ * need the library that produced it.
11
+ */
12
+
13
+ /** Lexical's inline format bitfield. */
14
+ export const FORMAT = {
15
+ bold: 1,
16
+ code: 16,
17
+ italic: 2,
18
+ strikethrough: 4,
19
+ underline: 8,
20
+ } as const
21
+
22
+ export type RichTextNode = {
23
+ detail: number
24
+ format: number
25
+ mode: string
26
+ style: string
27
+ text: string
28
+ type: 'text'
29
+ version: number
30
+ }
31
+
32
+ export type RichLineBreak = { type: 'linebreak'; version: number }
33
+
34
+ export type RichInline = RichLineBreak | RichLink | RichTextNode
35
+
36
+ export type RichLink = {
37
+ children: (RichLineBreak | RichTextNode)[]
38
+ direction: 'ltr'
39
+ fields: { linkType: 'custom'; newTab: boolean; url: string }
40
+ format: ''
41
+ indent: number
42
+ type: 'link'
43
+ version: number
44
+ }
45
+
46
+ export type RichParagraph = {
47
+ children: RichInline[]
48
+ direction: 'ltr'
49
+ format: ''
50
+ indent: number
51
+ textFormat: number
52
+ type: 'paragraph'
53
+ version: number
54
+ }
55
+
56
+ export type RichHeading = {
57
+ children: RichInline[]
58
+ direction: 'ltr'
59
+ format: ''
60
+ indent: number
61
+ tag: 'h1' | 'h2' | 'h3'
62
+ type: 'heading'
63
+ version: number
64
+ }
65
+
66
+ export type RichListItem = {
67
+ children: RichInline[]
68
+ direction: 'ltr'
69
+ format: ''
70
+ indent: number
71
+ type: 'listitem'
72
+ value: number
73
+ version: number
74
+ }
75
+
76
+ export type RichList = {
77
+ children: RichListItem[]
78
+ direction: 'ltr'
79
+ format: ''
80
+ indent: number
81
+ listType: 'bullet' | 'number'
82
+ start: number
83
+ tag: 'ol' | 'ul'
84
+ type: 'list'
85
+ version: number
86
+ }
87
+
88
+ export type RichBlock = RichHeading | RichList | RichParagraph
89
+
90
+ export type RichTextValue = {
91
+ root: {
92
+ children: RichBlock[]
93
+ direction: 'ltr'
94
+ format: ''
95
+ indent: number
96
+ type: 'root'
97
+ version: number
98
+ }
99
+ }
100
+
101
+ export const text = (value: string, format = 0): RichTextNode => ({
102
+ detail: 0,
103
+ format,
104
+ mode: 'normal',
105
+ style: '',
106
+ text: value,
107
+ type: 'text',
108
+ version: 1,
109
+ })
110
+
111
+ export const lineBreak = (): RichLineBreak => ({ type: 'linebreak', version: 1 })
112
+
113
+ export const link = (url: string, ...children: (RichLineBreak | RichTextNode)[]): RichLink => ({
114
+ children,
115
+ direction: 'ltr',
116
+ fields: { linkType: 'custom', newTab: false, url },
117
+ format: '',
118
+ indent: 0,
119
+ type: 'link',
120
+ version: 1,
121
+ })
122
+
123
+ export const paragraph = (...children: RichInline[]): RichParagraph => ({
124
+ children,
125
+ direction: 'ltr',
126
+ format: '',
127
+ indent: 0,
128
+ textFormat: 0,
129
+ type: 'paragraph',
130
+ version: 1,
131
+ })
132
+
133
+ export const heading = (tag: 'h1' | 'h2' | 'h3', ...children: RichInline[]): RichHeading => ({
134
+ children,
135
+ direction: 'ltr',
136
+ format: '',
137
+ indent: 0,
138
+ tag,
139
+ type: 'heading',
140
+ version: 1,
141
+ })
142
+
143
+ export const listItem = (...children: RichInline[]): RichListItem => ({
144
+ children,
145
+ direction: 'ltr',
146
+ format: '',
147
+ indent: 0,
148
+ type: 'listitem',
149
+ value: 1,
150
+ version: 1,
151
+ })
152
+
153
+ export const list = (
154
+ listType: 'bullet' | 'number',
155
+ ...children: RichListItem[]
156
+ ): RichList => ({
157
+ children: children.map((item, index) => ({ ...item, value: index + 1 })),
158
+ direction: 'ltr',
159
+ format: '',
160
+ indent: 0,
161
+ listType,
162
+ start: 1,
163
+ tag: listType === 'bullet' ? 'ul' : 'ol',
164
+ type: 'list',
165
+ version: 1,
166
+ })
167
+
168
+ export const doc = (...children: RichBlock[]): RichTextValue => ({
169
+ root: { children, direction: 'ltr', format: '', indent: 0, type: 'root', version: 1 },
170
+ })
@@ -0,0 +1,166 @@
1
+ import {
2
+ doc,
3
+ FORMAT,
4
+ heading,
5
+ lineBreak,
6
+ link,
7
+ list,
8
+ listItem,
9
+ paragraph,
10
+ type RichBlock,
11
+ type RichInline,
12
+ type RichListItem,
13
+ type RichTextValue,
14
+ text,
15
+ } from './lexical'
16
+
17
+ /**
18
+ * Edited markup, back to the document it came from.
19
+ *
20
+ * The inverse of `richText.tsx`, and only of that: it inverts the markup this kit emits, not
21
+ * HTML in general. That is what makes it tractable — the vocabulary is closed and the mark
22
+ * nesting is fixed, so every shape reaching here is one `richText.tsx` produced.
23
+ *
24
+ * **A structural node, not an `Element`.** `dashboard/sitemap/tree.ts` gives the reason: a pure
25
+ * function over hand-written input is one `verify` can exercise, and a serialiser only testable
26
+ * in a browser is one nobody tests. The frame feeds it a thin adapter over real nodes.
27
+ *
28
+ * **Accepted losses (confirmed in review, not bugs to chase).** The vocabulary this file inverts
29
+ * is `richText.tsx`'s, not arbitrary pasted HTML, so a few shapes a paste can introduce are
30
+ * deliberately not preserved:
31
+ * - `<span style="font-weight:700">…</span>` (a Word/Google Docs bold) drops its formatting —
32
+ * there is no inline-style parser, only the closed `MARK_BITS` tag vocabulary below.
33
+ * - a `<ul>`/`<ol>` nested inside an `<li>` flattens into the parent item's inline run, rather
34
+ * than nesting as its own list.
35
+ * - `<div><br></div>` (a lone line break with no text) is dropped rather than producing an
36
+ * empty paragraph.
37
+ */
38
+ export type DomLike = {
39
+ attributes?: Record<string, string>
40
+ children?: DomLike[]
41
+ tag?: string
42
+ text?: string
43
+ }
44
+
45
+ /**
46
+ * Wrapper tag to the bit it sets. The inverse of `MARKS` in `richText.tsx`.
47
+ *
48
+ * `b`, `i` and `strike` are here although the reader never emits them: in-frame formatting is
49
+ * the browser's native `contentEditable` behaviour, and browsers disagree about which spelling
50
+ * they insert. Accepting both is what stops a client's ⌘B coming back as plain text.
51
+ */
52
+ const MARK_BITS: Record<string, number> = {
53
+ b: FORMAT.bold,
54
+ code: FORMAT.code,
55
+ em: FORMAT.italic,
56
+ i: FORMAT.italic,
57
+ s: FORMAT.strikethrough,
58
+ strike: FORMAT.strikethrough,
59
+ strong: FORMAT.bold,
60
+ u: FORMAT.underline,
61
+ }
62
+
63
+ /** Block-level tags `blocks()` recurses on structurally rather than flattening into a paragraph. */
64
+ const BLOCK_TAGS = new Set(['h1', 'h2', 'h3', 'ol', 'p', 'ul'])
65
+
66
+ /** Tags that signal "this is inline content", the browser's `<a>` plus every mark alias above. */
67
+ const INLINE_TAGS = new Set([...Object.keys(MARK_BITS), 'a'])
68
+
69
+ const inlines = (nodes: DomLike[], format: number): RichInline[] =>
70
+ nodes.flatMap((node): RichInline[] => {
71
+ if (typeof node.text === 'string') {
72
+ // `&nbsp;` is what a browser inserts for a second consecutive space. Storing it means
73
+ // the words on record stop matching the words typed, so it is normalised here — the
74
+ // same thing `readEditedText` does on the plain-text path.
75
+ const value = node.text.replace(/\u00a0/g, ' ')
76
+ return value ? [text(value, format)] : []
77
+ }
78
+
79
+ const singleKey = node.attributes?.['data-single-key']
80
+ if (singleKey && /^[^{}\s]+$/.test(singleKey)) return [text(`{${singleKey}}`, format)]
81
+
82
+ const tag = (node.tag ?? '').toLowerCase()
83
+ if (tag === 'br') return [lineBreak()]
84
+
85
+ if (tag === 'a') {
86
+ const url = node.attributes?.href ?? '#'
87
+ const children = inlines(node.children ?? [], format).filter(
88
+ (child): child is Exclude<RichInline, ReturnType<typeof link>> => child.type !== 'link',
89
+ )
90
+ return [link(url, ...children)]
91
+ }
92
+
93
+ const bit = MARK_BITS[tag]
94
+ if (bit !== undefined) return inlines(node.children ?? [], format | bit)
95
+
96
+ return inlines(node.children ?? [], format)
97
+ })
98
+
99
+ /**
100
+ * One block's inline content, with the browser's scaffolding removed.
101
+ *
102
+ * A `contentEditable` puts a filler `<br>` at the end of a block so an empty one stays
103
+ * selectable. It is not something the client typed, and storing it is how a document grows on
104
+ * every save: the reader draws the linebreak back, the browser pads the result again, and the
105
+ * next serialisation keeps both. Observed on a real privacy policy — 34 blocks became 43, with
106
+ * five linebreaks that appear nowhere in the source.
107
+ *
108
+ * Only the *last* one goes. A `<br>` between words is a line break the client asked for.
109
+ */
110
+ const blockInlines = (kids: DomLike[]): RichInline[] => {
111
+ const content = inlines(kids, 0)
112
+ const last = content[content.length - 1]
113
+ return last && last.type === 'linebreak' ? content.slice(0, -1) : content
114
+ }
115
+
116
+
117
+ const blocks = (nodes: DomLike[]): RichBlock[] =>
118
+ nodes.flatMap((node): RichBlock[] => {
119
+ const tag = (node.tag ?? '').toLowerCase()
120
+ const kids = node.children ?? []
121
+ if (node.attributes?.['data-single-key']) return [paragraph(...inlines([node], 0))]
122
+
123
+ /*
124
+ * An empty block is kept. Stripping the filler `<br>` above is what stops the growth loop;
125
+ * dropping the block as well deleted the blank line a client had just made with Enter, and
126
+ * took their caret to the end of the document with it.
127
+ */
128
+ if (tag === 'p') return [paragraph(...blockInlines(kids))]
129
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3') return [heading(tag, ...blockInlines(kids))]
130
+ if (tag === 'ul' || tag === 'ol') {
131
+ const items: RichListItem[] = kids
132
+ .filter((kid) => (kid.tag ?? '').toLowerCase() === 'li')
133
+ .map((kid) => listItem(...blockInlines(kid.children ?? [])))
134
+ return [list(tag === 'ol' ? 'number' : 'bullet', ...items)]
135
+ }
136
+
137
+ /*
138
+ * Anything else contributes its children rather than itself — the wrapper the frame puts
139
+ * around the field, and any element a paste survived.
140
+ *
141
+ * Chrome's `contentEditable` default block separator is a bare `<div>`, and pasted content
142
+ * arrives the same way, so a fallthrough node's own children are not necessarily further
143
+ * blocks — they are as likely to be the inline content of an unwrapped paragraph. If any
144
+ * *direct* child is itself a block tag, keep recursing block-wise (a wrapper around real
145
+ * blocks). Otherwise, if any direct child is text or one of the inline/mark tags, the whole
146
+ * node IS a paragraph's worth of inline content and must be routed through `inlines()` as
147
+ * one paragraph — recursing block-wise here would silently drop every mark and split one
148
+ * sentence into one paragraph per text run, which is exactly the data loss this file exists
149
+ * to prevent.
150
+ */
151
+ if (kids.some((kid) => BLOCK_TAGS.has((kid.tag ?? '').toLowerCase()))) {
152
+ return blocks(kids)
153
+ }
154
+ if (kids.some((kid) => typeof kid.text === 'string' || Boolean(kid.attributes?.['data-single-key']) || INLINE_TAGS.has((kid.tag ?? '').toLowerCase()))) {
155
+ return [paragraph(...blockInlines(kids))]
156
+ }
157
+
158
+ // A stray run of text with no block around it becomes a paragraph, because dropping it
159
+ // would lose a client's words.
160
+ if (typeof node.text === 'string') {
161
+ return node.text.trim() ? [paragraph(text(node.text))] : []
162
+ }
163
+ return blocks(kids)
164
+ })
165
+
166
+ export const toRichText = (node: DomLike): RichTextValue => doc(...blocks(node.children ?? []))
@@ -0,0 +1,272 @@
1
+ import type { EditorBlock, EditorField } from './contract'
2
+ import type { ImageSlot } from './image'
3
+ import {
4
+ normalizeImageSeed,
5
+ imageSeedForImport,
6
+ validateImageValue,
7
+ type ImageValue,
8
+ type ImageSeedValue,
9
+ type ImageReadValue,
10
+ type ImageVariantDeclaration,
11
+ type MediaId,
12
+ } from './image'
13
+ import type { MediaRef } from './shape'
14
+
15
+ export type MediaValidationIssue = { path: string; message: string }
16
+ export type MediaValidationOptions = {
17
+ path?: string
18
+ mode: 'draft' | 'publish'
19
+ required?: boolean
20
+ duration?: number | null
21
+ posterVariants?: Record<string, ImageVariantDeclaration>
22
+ }
23
+ export type VideoSettings = {
24
+ posterTime: number
25
+ startTime: number
26
+ endTime: number | null
27
+ autoplay: boolean
28
+ muted: boolean
29
+ loop: boolean
30
+ controls: boolean
31
+ playsInline: boolean
32
+ preload: 'none' | 'metadata' | 'auto'
33
+ }
34
+ export type VideoValue = null | (VideoSettings & { asset: MediaId; poster: ImageValue })
35
+ export type VideoSeedValue =
36
+ null | (Partial<VideoSettings> & { asset: MediaId | { source: string }; poster?: ImageSeedValue })
37
+ export type VideoReadValue = null | (VideoSettings & { asset: MediaRef; poster: ImageReadValue })
38
+ export type FileValue = null | {
39
+ asset: MediaId
40
+ label: string
41
+ description: string | null
42
+ downloadName: string | null
43
+ }
44
+ export type FileSeedValue = null | {
45
+ asset: MediaId | { source: string }
46
+ label?: string
47
+ description?: string | null
48
+ downloadName?: string | null
49
+ }
50
+ export type FileReadValue = null | (Omit<NonNullable<FileValue>, 'asset'> & { asset: MediaRef })
51
+ const record = (v: unknown): v is Record<string, unknown> =>
52
+ !!v && typeof v === 'object' && !Array.isArray(v)
53
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
54
+ const id = (v: unknown) =>
55
+ (finite(v) && Number.isInteger(v) && v > 0) || (typeof v === 'string' && !!v.trim())
56
+ const at = (p: string, k: string) => (p ? `${p}.${k}` : k)
57
+ const defaults: VideoSettings = {
58
+ posterTime: 0,
59
+ startTime: 0,
60
+ endTime: null,
61
+ autoplay: false,
62
+ muted: true,
63
+ loop: false,
64
+ controls: true,
65
+ playsInline: true,
66
+ preload: 'metadata',
67
+ }
68
+ export class MediaValidationError extends Error {
69
+ constructor(public issues: MediaValidationIssue[]) {
70
+ super(issues.map((i) => `${i.path}: ${i.message}`).join('; '))
71
+ this.name = 'MediaValidationError'
72
+ }
73
+ }
74
+ function validate(
75
+ value: unknown,
76
+ options: MediaValidationOptions,
77
+ kind: 'Video' | 'File',
78
+ ): MediaValidationIssue[] {
79
+ const path = options.path ?? '',
80
+ issues: MediaValidationIssue[] = []
81
+ const issue = (key: string, message: string) => issues.push({ path: at(path, key), message })
82
+ if (value == null)
83
+ return options.required && options.mode === 'publish'
84
+ ? [{ path, message: `A ${kind} is required for publication.` }]
85
+ : []
86
+ if (!record(value)) return [{ path, message: `Expected a ${kind} object or null.` }]
87
+ if (!id(value.asset))
88
+ issue('asset', 'Expected a media ID; expanded assets and seed sources are not write values.')
89
+ const keys =
90
+ kind === 'Video'
91
+ ? ['asset', 'poster', ...Object.keys(defaults)]
92
+ : ['asset', 'label', 'description', 'downloadName']
93
+ for (const key of Object.keys(value))
94
+ if (!keys.includes(key)) issue(key, `Unknown ${kind} property.`)
95
+ if (kind === 'File') {
96
+ if (typeof value.label !== 'string') issue('label', 'Label must be text.')
97
+ for (const key of ['description', 'downloadName'])
98
+ if (value[key] !== null && typeof value[key] !== 'string')
99
+ issue(key, 'Expected text or null.')
100
+ } else {
101
+ for (const key of ['posterTime', 'startTime', 'endTime']) {
102
+ const n = value[key]
103
+ if (key === 'endTime' && n === null) continue
104
+ if (!finite(n) || n < 0) issue(key, 'Time must be finite nonnegative seconds.')
105
+ else if (finite(options.duration) && n > options.duration)
106
+ issue(key, 'Time exceeds the video duration.')
107
+ }
108
+ if (finite(value.startTime) && finite(value.endTime) && value.endTime <= value.startTime)
109
+ issue('endTime', 'End time must be after start time.')
110
+ for (const key of ['autoplay', 'muted', 'loop', 'controls', 'playsInline'])
111
+ if (typeof value[key] !== 'boolean') issue(key, 'Expected a boolean.')
112
+ if (value.autoplay === true && value.muted !== true)
113
+ issue('muted', 'Autoplay requires muted playback.')
114
+ if (!['none', 'metadata', 'auto'].includes(value.preload as string))
115
+ issue('preload', 'Expected none, metadata or auto.')
116
+ issues.push(
117
+ ...validateImageValue(value.poster, {
118
+ ...options,
119
+ required: false,
120
+ path: at(path, 'poster'),
121
+ variants: options.posterVariants,
122
+ }),
123
+ )
124
+ }
125
+ return issues
126
+ }
127
+ export const validateVideoValue = (value: unknown, options: MediaValidationOptions) =>
128
+ validate(value, options, 'Video')
129
+ export const validateFileValue = (value: unknown, options: MediaValidationOptions) =>
130
+ validate(value, options, 'File')
131
+ function normalize(
132
+ value: unknown,
133
+ kind: 'Video' | 'File',
134
+ options: Pick<MediaValidationOptions, 'posterVariants'> = {},
135
+ ) {
136
+ if (value == null) return null
137
+ if (!record(value))
138
+ throw new MediaValidationError([
139
+ { path: '', message: `Expected a ${kind} seed object or null.` },
140
+ ])
141
+ const out: Record<string, unknown> = { ...value }
142
+ const defs =
143
+ kind === 'Video'
144
+ ? { ...defaults, poster: null }
145
+ : { label: '', description: null, downloadName: null }
146
+ for (const [key, v] of Object.entries(defs)) if (out[key] === undefined) out[key] = v
147
+ if (kind === 'Video') out.poster = normalizeImageSeed(out.poster)
148
+ const check = { ...out }
149
+ if (
150
+ record(check.asset) &&
151
+ Object.keys(check.asset).length === 1 &&
152
+ typeof check.asset.source === 'string' &&
153
+ check.asset.source.trim()
154
+ )
155
+ check.asset = check.asset.source
156
+ // Poster seed sources have already been validated by the Image normalizer.
157
+ if (kind === 'Video') check.poster = null
158
+ const issues = validate(check, { mode: 'draft', ...options }, kind)
159
+ if (
160
+ kind === 'Video' &&
161
+ record(out.poster) &&
162
+ record(out.poster.variants) &&
163
+ options.posterVariants
164
+ )
165
+ for (const key of Object.keys(out.poster.variants))
166
+ if (!Object.hasOwn(options.posterVariants, key))
167
+ issues.push({
168
+ path: `poster.variants.${key}`,
169
+ message: 'Variant is not declared by this field.',
170
+ })
171
+ if (issues.length) throw new MediaValidationError(issues)
172
+ return out
173
+ }
174
+ export const normalizeVideoSeed = (
175
+ value: unknown,
176
+ options: Pick<MediaValidationOptions, 'posterVariants'> = {},
177
+ ): VideoSeedValue => normalize(value, 'Video', options) as VideoSeedValue
178
+ export const normalizeFileSeed = (value: unknown): FileSeedValue =>
179
+ normalize(value, 'File') as FileSeedValue
180
+ export function videoSeedForImport(
181
+ value: unknown,
182
+ options: Pick<MediaValidationOptions, 'posterVariants'> = {},
183
+ ): VideoSeedValue {
184
+ const v = normalizeVideoSeed(value, options)
185
+ return v
186
+ ? {
187
+ ...v,
188
+ asset: typeof v.asset === 'object' ? v.asset.source : v.asset,
189
+ poster: imageSeedForImport(v.poster),
190
+ }
191
+ : null
192
+ }
193
+ export function fileSeedForImport(value: unknown): FileSeedValue {
194
+ const v = normalizeFileSeed(value)
195
+ return v ? { ...v, asset: typeof v.asset === 'object' ? v.asset.source : v.asset } : null
196
+ }
197
+ export type VideoHTMLProps = {
198
+ src?: string
199
+ poster?: string
200
+ autoPlay: boolean
201
+ muted: boolean
202
+ loop: boolean
203
+ controls: boolean
204
+ playsInline: boolean
205
+ preload: VideoSettings['preload']
206
+ }
207
+ /** Native attributes only. Components own seeking and bounded playback events. */
208
+ export function videoProps(
209
+ value: VideoReadValue | undefined,
210
+ fallback: Pick<VideoHTMLProps, 'src' | 'poster'> = {},
211
+ ): VideoHTMLProps {
212
+ const v = value ?? defaults
213
+ return {
214
+ src: value?.asset?.url ?? fallback.src,
215
+ poster:
216
+ (value?.poster?.status === 'ready' ? value.poster.rendition?.url : undefined) ??
217
+ fallback.poster,
218
+ autoPlay: v.autoplay,
219
+ muted: v.muted,
220
+ loop: v.loop,
221
+ controls: v.controls,
222
+ playsInline: v.playsInline,
223
+ preload: v.preload,
224
+ }
225
+ }
226
+
227
+ export type MediaSlot = ImageSlot
228
+ /** Atomic Video and File placements, including nested groups, arrays and block zones. */
229
+ export function mediaSlots(
230
+ content: unknown,
231
+ blocks: EditorBlock[],
232
+ allBlocks: EditorBlock[] = blocks,
233
+ ): MediaSlot[] {
234
+ const slots: MediaSlot[] = []
235
+ function fields(schema: EditorField[], container: unknown, path: string) {
236
+ if (!record(container)) return
237
+ for (const field of schema) {
238
+ const p = at(path, field.name),
239
+ value = container[field.name]
240
+ if (field.type === 'group' && (field.element === 'video' || field.element === 'file')) {
241
+ slots.push({
242
+ field,
243
+ path: p,
244
+ get: () => container[field.name],
245
+ set: (v) => {
246
+ container[field.name] = v
247
+ },
248
+ })
249
+ continue
250
+ }
251
+ if (field.type === 'group') fields(field.fields ?? [], value, p)
252
+ if (field.type === 'array' && Array.isArray(value))
253
+ value.forEach((v, i) => fields(field.fields ?? [], v, at(p, String(i))))
254
+ if (field.type === 'blocks')
255
+ rows(
256
+ value,
257
+ field.blocks ??
258
+ (field.childBlocks ?? []).flatMap((slug) => allBlocks.filter((b) => b.slug === slug)),
259
+ p,
260
+ )
261
+ }
262
+ }
263
+ function rows(value: unknown, schema: EditorBlock[], path: string) {
264
+ if (!Array.isArray(value)) return
265
+ value.forEach((v, i) => {
266
+ const block = record(v) ? schema.find((b) => b.slug === v.blockType) : undefined
267
+ if (block) fields(block.fields, v, at(path, String(i)))
268
+ })
269
+ }
270
+ rows(content, blocks, '')
271
+ return slots
272
+ }