@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.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { createContext, lazy, Suspense, useContext, useEffect, useState, type ReactNode } from 'react'
4
+
5
+ import { registry } from './registry'
6
+
7
+ const LazyBridgeClient = lazy(() => import('./client').then((m) => ({ default: m.BridgeClient })))
8
+
9
+ type EditingContextValue = { editing: boolean; selected: string | null; testPreview: boolean }
10
+
11
+ const Ctx = createContext<EditingContextValue>({ editing: false, selected: null, testPreview: false })
12
+
13
+ /**
14
+ * Mounted once, at the root, by `U90Bridge` — always mounted so `useEditing()` works from
15
+ * anywhere in the tree, but only ever told `editing: true` when `draftMode()` is enabled.
16
+ *
17
+ * Renders `children` unconditionally; the client bridge itself (`LazyBridgeClient`, the lazy
18
+ * `BridgeClient`) is rendered only when `editing`, and only inside its own `Suspense` boundary —
19
+ * `import('./client')` pulls in `next/navigation` and the whole DOM-level bridge, so a production
20
+ * (non-draft) render never fetches that chunk at all.
21
+ */
22
+ export function EditingProvider({ editing, platformOrigin, testPreview = false, children }: { editing: boolean; platformOrigin: string; testPreview?: boolean; children: ReactNode }) {
23
+ const [selected, setSelected] = useState<string | null>(null)
24
+
25
+ return (
26
+ <Ctx.Provider value={{ editing, selected, testPreview }}>
27
+ {children}
28
+ {editing ? (
29
+ <Suspense fallback={null}>
30
+ <LazyBridgeClient platformOrigin={platformOrigin} onSelect={setSelected} />
31
+ </Suspense>
32
+ ) : null}
33
+ </Ctx.Provider>
34
+ )
35
+ }
36
+
37
+ /** `{ editing, selected }` — `selected` is the path last reported by `u90:select`, either way. */
38
+ export const useEditing = (): EditingContextValue => useContext(Ctx)
39
+
40
+ /**
41
+ * Runs `fn` whenever the Canvas selects a path starting with `pathPrefix` — e.g. a header
42
+ * opening its own menu when one of its own fields is selected. Registers directly on the shared
43
+ * `registry`, not on any particular bridge instance, so it works whether the bridge has started
44
+ * yet or not, and needs no context of its own (usable outside `EditingProvider`, where it is
45
+ * simply never triggered). Imports `./registry` only — never `./client` or `./createBridge` — so
46
+ * a component calling this hook never pulls the bridge chunk in just by rendering.
47
+ */
48
+ export function useReveal(pathPrefix: string, fn: () => void): void {
49
+ useEffect(() => {
50
+ const entry = { prefix: pathPrefix, fn }
51
+ registry.reveals.add(entry)
52
+ return () => {
53
+ registry.reveals.delete(entry)
54
+ }
55
+ }, [pathPrefix, fn])
56
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@unitninety/site-kit/bridge`: client-safe only — no `next/headers`, so a `"use client"` site
3
+ * component (e.g. a header calling `useReveal`) can import this subpath without pulling in a
4
+ * server-only module. `U90Bridge` reads `draftMode()` and is exported from `@unitninety/site-kit/server`
5
+ * instead; import it from there.
6
+ */
7
+ export { BridgeClient } from './client'
8
+ export { EditingProvider, useEditing, useReveal } from './hooks'
9
+ export { BRIDGE_VERSION, isSiteMessage, parseCanvasMessage, type BridgeStamp, type CanvasMessage, type PreviewInstruction, type SiteMessage } from './protocol'
@@ -0,0 +1,177 @@
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
+ * Ported from `legacy/lexical.ts`, with one deliberate divergence: the `link` node's `fields`
13
+ * carry v1's `LinkTo` (`elements/leaves/link.ts`) rather than legacy's `{ linkType: 'custom', url }`
14
+ * — the shape the `u90/rich-text` element's stored schema requires (`elements/leaves/rich-text.ts`).
15
+ * Everything else here references no marker attribute, so bridge v4's attribute rename touches
16
+ * nothing else in this file.
17
+ */
18
+ import type { LinkTo } from '../elements/leaves/link'
19
+
20
+ /** Lexical's inline format bitfield. */
21
+ export const FORMAT = {
22
+ bold: 1,
23
+ code: 16,
24
+ italic: 2,
25
+ strikethrough: 4,
26
+ underline: 8,
27
+ } as const
28
+
29
+ export type RichTextNode = {
30
+ detail: number
31
+ format: number
32
+ mode: string
33
+ style: string
34
+ text: string
35
+ type: 'text'
36
+ version: number
37
+ }
38
+
39
+ export type RichLineBreak = { type: 'linebreak'; version: number }
40
+
41
+ export type RichInline = RichLineBreak | RichLink | RichTextNode
42
+
43
+ export type RichLink = {
44
+ children: (RichLineBreak | RichTextNode)[]
45
+ direction: 'ltr'
46
+ fields: { newTab: boolean; to: LinkTo }
47
+ format: ''
48
+ indent: number
49
+ type: 'link'
50
+ version: number
51
+ }
52
+
53
+ export type RichParagraph = {
54
+ children: RichInline[]
55
+ direction: 'ltr'
56
+ format: ''
57
+ indent: number
58
+ textFormat: number
59
+ type: 'paragraph'
60
+ version: number
61
+ }
62
+
63
+ export type RichHeading = {
64
+ children: RichInline[]
65
+ direction: 'ltr'
66
+ format: ''
67
+ indent: number
68
+ tag: 'h1' | 'h2' | 'h3'
69
+ type: 'heading'
70
+ version: number
71
+ }
72
+
73
+ export type RichListItem = {
74
+ children: RichInline[]
75
+ direction: 'ltr'
76
+ format: ''
77
+ indent: number
78
+ type: 'listitem'
79
+ value: number
80
+ version: number
81
+ }
82
+
83
+ export type RichList = {
84
+ children: RichListItem[]
85
+ direction: 'ltr'
86
+ format: ''
87
+ indent: number
88
+ listType: 'bullet' | 'number'
89
+ start: number
90
+ tag: 'ol' | 'ul'
91
+ type: 'list'
92
+ version: number
93
+ }
94
+
95
+ export type RichBlock = RichHeading | RichList | RichParagraph
96
+
97
+ export type RichTextValue = {
98
+ root: {
99
+ children: RichBlock[]
100
+ direction: 'ltr'
101
+ format: ''
102
+ indent: number
103
+ type: 'root'
104
+ version: number
105
+ }
106
+ }
107
+
108
+ export const text = (value: string, format = 0): RichTextNode => ({
109
+ detail: 0,
110
+ format,
111
+ mode: 'normal',
112
+ style: '',
113
+ text: value,
114
+ type: 'text',
115
+ version: 1,
116
+ })
117
+
118
+ export const lineBreak = (): RichLineBreak => ({ type: 'linebreak', version: 1 })
119
+
120
+ export const link = (to: LinkTo, newTab: boolean, ...children: (RichLineBreak | RichTextNode)[]): RichLink => ({
121
+ children,
122
+ direction: 'ltr',
123
+ fields: { newTab, to },
124
+ format: '',
125
+ indent: 0,
126
+ type: 'link',
127
+ version: 1,
128
+ })
129
+
130
+ export const paragraph = (...children: RichInline[]): RichParagraph => ({
131
+ children,
132
+ direction: 'ltr',
133
+ format: '',
134
+ indent: 0,
135
+ textFormat: 0,
136
+ type: 'paragraph',
137
+ version: 1,
138
+ })
139
+
140
+ export const heading = (tag: 'h1' | 'h2' | 'h3', ...children: RichInline[]): RichHeading => ({
141
+ children,
142
+ direction: 'ltr',
143
+ format: '',
144
+ indent: 0,
145
+ tag,
146
+ type: 'heading',
147
+ version: 1,
148
+ })
149
+
150
+ export const listItem = (...children: RichInline[]): RichListItem => ({
151
+ children,
152
+ direction: 'ltr',
153
+ format: '',
154
+ indent: 0,
155
+ type: 'listitem',
156
+ value: 1,
157
+ version: 1,
158
+ })
159
+
160
+ export const list = (
161
+ listType: 'bullet' | 'number',
162
+ ...children: RichListItem[]
163
+ ): RichList => ({
164
+ children: children.map((item, index) => ({ ...item, value: index + 1 })),
165
+ direction: 'ltr',
166
+ format: '',
167
+ indent: 0,
168
+ listType,
169
+ start: 1,
170
+ tag: listType === 'bullet' ? 'ul' : 'ol',
171
+ type: 'list',
172
+ version: 1,
173
+ })
174
+
175
+ export const doc = (...children: RichBlock[]): RichTextValue => ({
176
+ root: { children, direction: 'ltr', format: '', indent: 0, type: 'root', version: 1 },
177
+ })
@@ -0,0 +1,212 @@
1
+ import { LinkTo } from '../elements/leaves/link'
2
+ import {
3
+ doc,
4
+ FORMAT,
5
+ heading,
6
+ lineBreak,
7
+ link,
8
+ list,
9
+ listItem,
10
+ paragraph,
11
+ type RichBlock,
12
+ type RichInline,
13
+ type RichListItem,
14
+ type RichTextValue,
15
+ text,
16
+ } from './lexical'
17
+
18
+ /**
19
+ * Edited markup, back to the document it came from.
20
+ *
21
+ * The inverse of `richText.tsx`, and only of that: it inverts the markup this kit emits, not
22
+ * HTML in general. That is what makes it tractable — the vocabulary is closed and the mark
23
+ * nesting is fixed, so every shape reaching here is one `richText.tsx` produced.
24
+ *
25
+ * **A structural node, not an `Element`.** `dashboard/sitemap/tree.ts` gives the reason: a pure
26
+ * function over hand-written input is one `verify` can exercise, and a serialiser only testable
27
+ * in a browser is one nobody tests. The frame feeds it a thin adapter over real nodes.
28
+ *
29
+ * **Accepted losses (confirmed in review, not bugs to chase).** The vocabulary this file inverts
30
+ * is `richText.tsx`'s, not arbitrary pasted HTML, so a few shapes a paste can introduce are
31
+ * deliberately not preserved:
32
+ * - `<span style="font-weight:700">…</span>` (a Word/Google Docs bold) drops its formatting —
33
+ * there is no inline-style parser, only the closed `MARK_BITS` tag vocabulary below.
34
+ * - a `<ul>`/`<ol>` nested inside an `<li>` flattens into the parent item's inline run, rather
35
+ * than nesting as its own list.
36
+ * - `<div><br></div>` (a lone line break with no text) is dropped rather than producing an
37
+ * empty paragraph.
38
+ *
39
+ * Ported from `legacy/lexicalDom.ts` — `FIELD_ATTR`, `RICH_ATTR` and `SECTION_ATTR` never appear
40
+ * here, so bridge v4's attribute rename touches nothing in this file. One shape does diverge from
41
+ * legacy on purpose: an anchor's link target is read as v1's `LinkTo`, not a bare `url` string —
42
+ * see `linkTarget` below; see `bridge/ports.test.ts` for the copied `toRichText` cases.
43
+ */
44
+ export type DomLike = {
45
+ attributes?: Record<string, string>
46
+ children?: DomLike[]
47
+ tag?: string
48
+ text?: string
49
+ }
50
+
51
+ /**
52
+ * Wrapper tag to the bit it sets. The inverse of `MARKS` in `richText.tsx`.
53
+ *
54
+ * `b`, `i` and `strike` are here although the reader never emits them: in-frame formatting is
55
+ * the browser's native `contentEditable` behaviour, and browsers disagree about which spelling
56
+ * they insert. Accepting both is what stops a client's ⌘B coming back as plain text.
57
+ */
58
+ const MARK_BITS: Record<string, number> = {
59
+ b: FORMAT.bold,
60
+ code: FORMAT.code,
61
+ em: FORMAT.italic,
62
+ i: FORMAT.italic,
63
+ s: FORMAT.strikethrough,
64
+ strike: FORMAT.strikethrough,
65
+ strong: FORMAT.bold,
66
+ u: FORMAT.underline,
67
+ }
68
+
69
+ /** Block-level tags `blocks()` recurses on structurally rather than flattening into a paragraph. */
70
+ const BLOCK_TAGS = new Set(['h1', 'h2', 'h3', 'ol', 'p', 'ul'])
71
+
72
+ /** Tags that signal "this is inline content", the browser's `<a>` plus every mark alias above. */
73
+ const INLINE_TAGS = new Set([...Object.keys(MARK_BITS), 'a'])
74
+
75
+ /**
76
+ * Where an anchor points, in v1's `LinkTo` shape.
77
+ *
78
+ * `RichText.tsx` stamps every link it renders in draft with `data-u90-link`, holding the
79
+ * exact `{ to, newTab }` the field stores — an edit that never touched the link comes back
80
+ * byte-for-byte. A pasted `<a>` carries no such attribute (or one that fails to parse or to
81
+ * validate), so its `href` is classified instead: `mailto:` and `tel:` become the matching
82
+ * `LinkTo` kind, anything else an `{ url }`, always with `newTab: false` — there is no attribute
83
+ * to read a paste's target tab from.
84
+ */
85
+ const stampedTarget = (attributes?: Record<string, string>): { newTab: boolean; to: LinkTo } | null => {
86
+ const stamped = attributes?.['data-u90-link']
87
+ if (!stamped) return null
88
+ try {
89
+ const parsed = JSON.parse(stamped) as { newTab?: unknown; to?: unknown }
90
+ const to = LinkTo.safeParse(parsed.to)
91
+ return to.success ? { newTab: parsed.newTab === true, to: to.data } : null
92
+ } catch {
93
+ return null // Malformed JSON — the caller falls back to what the element itself carries.
94
+ }
95
+ }
96
+
97
+ const linkTarget = (attributes?: Record<string, string>): { newTab: boolean; to: LinkTo } => {
98
+ const stamped = stampedTarget(attributes)
99
+ if (stamped) return stamped
100
+ const href = attributes?.href ?? '#'
101
+ const to: LinkTo = href.startsWith('mailto:')
102
+ ? { email: href.slice('mailto:'.length) }
103
+ : href.startsWith('tel:')
104
+ ? { tel: href.slice('tel:'.length) }
105
+ : { url: href }
106
+ return { newTab: false, to }
107
+ }
108
+
109
+ const inlines = (nodes: DomLike[], format: number): RichInline[] =>
110
+ nodes.flatMap((node): RichInline[] => {
111
+ if (typeof node.text === 'string') {
112
+ // `&nbsp;` is what a browser inserts for a second consecutive space. Storing it means
113
+ // the words on record stop matching the words typed, so it is normalised here — the
114
+ // same thing `readEditedText` does on the plain-text path.
115
+ const value = node.text.replace(/\u00a0/g, ' ')
116
+ return value ? [text(value, format)] : []
117
+ }
118
+
119
+ const singleKey = node.attributes?.['data-single-key']
120
+ if (singleKey && /^[^{}\s]+$/.test(singleKey)) return [text(`{${singleKey}}`, format)]
121
+
122
+ const tag = (node.tag ?? '').toLowerCase()
123
+ if (tag === 'br') return [lineBreak()]
124
+
125
+ /*
126
+ * Any element carrying a valid `data-u90-link` is a link, not only an `<a>`: `RichText.tsx`
127
+ * draws a dead link (no href) as a stamped `<span>`. An `<a>` whose stamp fails falls back to
128
+ * its href; any other element with a bad stamp is only its words — it has no href to fall to.
129
+ */
130
+ const stamped = tag === 'a' ? null : stampedTarget(node.attributes)
131
+ if (tag === 'a' || stamped) {
132
+ const { to, newTab } = stamped ?? linkTarget(node.attributes)
133
+ const children = inlines(node.children ?? [], format).filter(
134
+ (child): child is Exclude<RichInline, ReturnType<typeof link>> => child.type !== 'link',
135
+ )
136
+ return [link(to, newTab, ...children)]
137
+ }
138
+
139
+ const bit = MARK_BITS[tag]
140
+ if (bit !== undefined) return inlines(node.children ?? [], format | bit)
141
+
142
+ return inlines(node.children ?? [], format)
143
+ })
144
+
145
+ /**
146
+ * One block's inline content, with the browser's scaffolding removed.
147
+ *
148
+ * A `contentEditable` puts a filler `<br>` at the end of a block so an empty one stays
149
+ * selectable. It is not something the client typed, and storing it is how a document grows on
150
+ * every save: the reader draws the linebreak back, the browser pads the result again, and the
151
+ * next serialisation keeps both. Observed on a real privacy policy — 34 blocks became 43, with
152
+ * five linebreaks that appear nowhere in the source.
153
+ *
154
+ * Only the *last* one goes. A `<br>` between words is a line break the client asked for.
155
+ */
156
+ const blockInlines = (kids: DomLike[]): RichInline[] => {
157
+ const content = inlines(kids, 0)
158
+ const last = content[content.length - 1]
159
+ return last && last.type === 'linebreak' ? content.slice(0, -1) : content
160
+ }
161
+
162
+
163
+ const blocks = (nodes: DomLike[]): RichBlock[] =>
164
+ nodes.flatMap((node): RichBlock[] => {
165
+ const tag = (node.tag ?? '').toLowerCase()
166
+ const kids = node.children ?? []
167
+ if (node.attributes?.['data-single-key']) return [paragraph(...inlines([node], 0))]
168
+
169
+ /*
170
+ * An empty block is kept. Stripping the filler `<br>` above is what stops the growth loop;
171
+ * dropping the block as well deleted the blank line a client had just made with Enter, and
172
+ * took their caret to the end of the document with it.
173
+ */
174
+ if (tag === 'p') return [paragraph(...blockInlines(kids))]
175
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3') return [heading(tag, ...blockInlines(kids))]
176
+ if (tag === 'ul' || tag === 'ol') {
177
+ const items: RichListItem[] = kids
178
+ .filter((kid) => (kid.tag ?? '').toLowerCase() === 'li')
179
+ .map((kid) => listItem(...blockInlines(kid.children ?? [])))
180
+ return [list(tag === 'ol' ? 'number' : 'bullet', ...items)]
181
+ }
182
+
183
+ /*
184
+ * Anything else contributes its children rather than itself — the wrapper the frame puts
185
+ * around the field, and any element a paste survived.
186
+ *
187
+ * Chrome's `contentEditable` default block separator is a bare `<div>`, and pasted content
188
+ * arrives the same way, so a fallthrough node's own children are not necessarily further
189
+ * blocks — they are as likely to be the inline content of an unwrapped paragraph. If any
190
+ * *direct* child is itself a block tag, keep recursing block-wise (a wrapper around real
191
+ * blocks). Otherwise, if any direct child is text or one of the inline/mark tags, the whole
192
+ * node IS a paragraph's worth of inline content and must be routed through `inlines()` as
193
+ * one paragraph — recursing block-wise here would silently drop every mark and split one
194
+ * sentence into one paragraph per text run, which is exactly the data loss this file exists
195
+ * to prevent.
196
+ */
197
+ if (kids.some((kid) => BLOCK_TAGS.has((kid.tag ?? '').toLowerCase()))) {
198
+ return blocks(kids)
199
+ }
200
+ if (kids.some((kid) => typeof kid.text === 'string' || Boolean(kid.attributes?.['data-single-key']) || Boolean(kid.attributes?.['data-u90-link']) || INLINE_TAGS.has((kid.tag ?? '').toLowerCase()))) {
201
+ return [paragraph(...blockInlines(kids))]
202
+ }
203
+
204
+ // A stray run of text with no block around it becomes a paragraph, because dropping it
205
+ // would lose a client's words.
206
+ if (typeof node.text === 'string') {
207
+ return node.text.trim() ? [paragraph(text(node.text))] : []
208
+ }
209
+ return blocks(kids)
210
+ })
211
+
212
+ export const toRichText = (node: DomLike): RichTextValue => doc(...blocks(node.children ?? []))
@@ -0,0 +1,88 @@
1
+ type AtomicHost = HTMLElement & { moveBefore?: (node: Node, before: Node | null) => void }
2
+ type SheetSnapshot = { element: HTMLStyleElement; sheet: CSSStyleSheet; rules: string[] }
3
+
4
+ /** Includes open shadow roots: embedded widgets commonly put CSSOM-only styles there. */
5
+ function stylesWithin(node: Node): SheetSnapshot[] {
6
+ const found: SheetSnapshot[] = []
7
+ const visit = (root: Node) => {
8
+ if (root.nodeType === 1) {
9
+ const element = root as Element
10
+ if (element.tagName === 'STYLE') {
11
+ const style = element as HTMLStyleElement
12
+ const sheet = style.sheet
13
+ if (sheet) found.push({ element: style, sheet, rules: Array.from(sheet.cssRules, (rule) => rule.cssText) })
14
+ }
15
+ if (element.shadowRoot) visit(element.shadowRoot)
16
+ }
17
+ for (const child of root.childNodes) visit(child)
18
+ }
19
+ visit(node)
20
+ return found
21
+ }
22
+
23
+ /** A browser move can replace a style's stylesheet, including for an atomic move.
24
+ * Restore its rules and forward the widget's cached CSSOM handle to the live sheet.
25
+ * Styled-components, for example, keeps that handle and calls insertRule/deleteRule
26
+ * again when its carousel changes. Copying just the initial rules leaves it broken. */
27
+ function restoreSheets(snapshots: SheetSnapshot[]) {
28
+ for (const { element, sheet, rules } of snapshots) {
29
+ const current = element.sheet
30
+ if (!current || current === sheet) continue
31
+ while (current.cssRules.length) current.deleteRule(current.cssRules.length - 1)
32
+ for (const rule of rules) current.insertRule(rule, current.cssRules.length)
33
+ current.disabled = sheet.disabled
34
+ const originalRules = sheet.cssRules
35
+ const insert = sheet.insertRule.bind(sheet)
36
+ const remove = sheet.deleteRule.bind(sheet)
37
+ sheet.insertRule = (rule, index) => element.sheet && element.sheet !== sheet
38
+ ? element.sheet.insertRule(rule, index)
39
+ : insert(rule, index)
40
+ sheet.deleteRule = (index) => element.sheet && element.sheet !== sheet
41
+ ? element.sheet.deleteRule(index)
42
+ : remove(index)
43
+ Object.defineProperty(sheet, 'cssRules', {
44
+ configurable: true,
45
+ get: () => element.sheet && element.sheet !== sheet ? element.sheet.cssRules : originalRules,
46
+ })
47
+ }
48
+ }
49
+
50
+ /**
51
+ * React 19 uses insertBefore/appendChild to reorder keyed children. Adapt only this
52
+ * draft zone's insertion methods; React still owns its tree and no global DOM
53
+ * prototype changes. Atomic moves keep iframes/focus alive where supported. Both
54
+ * kinds of move need the CSSOM safeguard for styles in an embedded shadow root.
55
+ */
56
+ export function preserveBlockMoves(host: HTMLDivElement | null): void | (() => void) {
57
+ if (!host) return
58
+ const atomic = host as AtomicHost
59
+ const insert = host.insertBefore
60
+ const append = host.appendChild
61
+ const place = <T extends Node>(node: T, before: Node | null, native: () => T): T => {
62
+ if (node.parentNode !== host || !host.isConnected || node.nodeType !== 1) return native()
63
+ const styles = stylesWithin(node)
64
+ if (typeof atomic.moveBefore === 'function') atomic.moveBefore(node, before)
65
+ else native()
66
+ restoreSheets(styles)
67
+ return node
68
+ }
69
+ host.insertBefore = function <T extends Node>(node: T, before: Node | null): T {
70
+ return place(node, before, () => insert.call(this, node, before) as T)
71
+ }
72
+ host.appendChild = function <T extends Node>(node: T): T {
73
+ return place(node, null, () => append.call(this, node) as T)
74
+ }
75
+ return () => {
76
+ host.insertBefore = insert
77
+ host.appendChild = append
78
+ }
79
+ }
80
+
81
+ /** Older browsers cannot atomically move iframes. Leave a zone containing one in
82
+ * place until the saved page loads afresh, instead of resetting a live form mid-drag. */
83
+ export function needsEmbedReload(host: HTMLElement): boolean {
84
+ if (typeof (host as AtomicHost).moveBefore === 'function') return false
85
+ const hasFrame = (root: Element | ShadowRoot): boolean => [...root.querySelectorAll('*')]
86
+ .some((node) => node.tagName === 'IFRAME' || (node.shadowRoot !== null && hasFrame(node.shadowRoot)))
87
+ return hasFrame(host)
88
+ }