@unitninety/site-kit 0.0.0-stage → 1.6.1
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 +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -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 +1010 -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 +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -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 +12 -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,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
|
+
// ` ` 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
|
+
}
|