@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,179 @@
1
+ import type { PreviewInstruction } from './protocol'
2
+
3
+ /** A path inside an attribute selector: the two characters a selector string can be broken by. */
4
+ const q = (path: string) => path.replace(/["\\]/g, '\\$&')
5
+
6
+ /** The element a preview addresses: the block or the field marked with that path. */
7
+ const nodeAt = (doc: Document, path: string): HTMLElement | null =>
8
+ doc.querySelector<HTMLElement>(`[data-u90-block="${q(path)}"], [data-u90-field="${q(path)}"]`)
9
+
10
+ /**
11
+ * The element holding the siblings at `path`'s level, found through any one of them rather than
12
+ * through a marker of its own: `page:4f2/blocks/b3`'s container is the parent of whatever matches
13
+ * `page:4f2/blocks/` directly. A block nested inside one of those carries the same prefix, so a match
14
+ * with a further `/` after it is skipped. A zone with no blocks in it yet has nothing to find, so an
15
+ * append into an empty zone is a no-op and the refresh shows it instead.
16
+ */
17
+ const containerFor = (doc: Document, path: string): HTMLElement | null => {
18
+ const cut = path.lastIndexOf('/')
19
+ if (cut < 0) return null
20
+ const prefix = path.slice(0, cut + 1)
21
+ for (const el of doc.querySelectorAll<HTMLElement>(`[data-u90-block^="${q(prefix)}"]`)) {
22
+ const own = el.getAttribute('data-u90-block') ?? ''
23
+ // A direct sibling only: a block nested inside one of these also matches the prefix.
24
+ if (!own.slice(prefix.length).includes('/')) return el.parentElement
25
+ }
26
+ return null
27
+ }
28
+
29
+ /** The first element of a fragment of markup, or null if it holds none. */
30
+ const parse = (doc: Document, html: string): Element | null => {
31
+ const template = doc.createElement('template')
32
+ template.innerHTML = html
33
+ return template.content.firstElementChild
34
+ }
35
+
36
+ /** Puts the page back as it was before one preview. */
37
+ export type Undo = () => void
38
+
39
+ /**
40
+ * Applies one DOM-shaped instruction to markup the page has already rendered, so a Canvas edit
41
+ * shows before the server re-renders it, and returns how to take it back (null if it did nothing).
42
+ *
43
+ * **Why every preview can be undone.** The page is React's, and React writes only what changed in
44
+ * *its* model of the page. A preview changes the document and not that model, so left in place it
45
+ * leaves the two disagreeing, and the refresh that follows the save is no longer authoritative: a
46
+ * node we detached makes React throw when it removes it, a refused move is never put back, and a
47
+ * moved node can be left where neither the preview nor the server put it. So the frame undoes every
48
+ * preview, newest first, immediately before it asks for the refresh (`createBridge`'s journal), and
49
+ * React then reconciles a document that is exactly its own model. Two rules follow: a preview never
50
+ * detaches or replaces a node React owns without keeping it to put back (a remove hides, it does not
51
+ * detach; a text edit keeps the original child nodes, because React updates *those* nodes), and an
52
+ * undo reads state captured *before* the preview mutated.
53
+ *
54
+ * Every branch is a no-op when what it addresses is missing. That is the contract, not defensive
55
+ * coding: `u90:refresh` is what makes the frame correct, and a preview only makes it correct
56
+ * sooner, so a preview that cannot be applied costs the wait it was trying to save and nothing
57
+ * else.
58
+ *
59
+ * `html` arrives over `postMessage` from the platform origin, which `createBridge`'s `onMessage`
60
+ * has already checked — the same trust as every other command the Canvas sends.
61
+ */
62
+ export const applyPreview = (doc: Document, instruction: PreviewInstruction): Undo | null => {
63
+ if (instruction.kind === 'insert') {
64
+ const before = instruction.before ? nodeAt(doc, instruction.before) : null
65
+ // A `before` that was named but is not here means this preview cannot be placed correctly.
66
+ // Showing it in the wrong position is worse than not showing it: the refresh is close behind.
67
+ if (instruction.before && !before) return null
68
+ const parent = before?.parentElement ?? containerFor(doc, instruction.path)
69
+ const node = parse(doc, instruction.html)
70
+ if (!parent || !node) return null
71
+ parent.insertBefore(node, before)
72
+ return () => node.remove()
73
+ }
74
+
75
+ if (instruction.kind === 'link-label') {
76
+ // A responsive header can render the same button twice. Update every label, but keep its
77
+ // anchor and decorations intact and never overwrite a label the person is still typing in.
78
+ const originals: { el: HTMLElement; children: ChildNode[] }[] = []
79
+ for (const el of doc.querySelectorAll<HTMLElement>(`[data-u90-field="${q(instruction.path)}"][data-u90-edit="link-label"]`)) {
80
+ // Capture every copy even when it is actively edited. A later inspector preview may touch
81
+ // that copy too; previews coalesce by path, so the first undo must cover it from the start.
82
+ originals.push({ el, children: [...el.childNodes] })
83
+ if (el.hasAttribute('contenteditable') || el.hasAttribute('data-u90-editing')) continue
84
+ el.textContent = instruction.value
85
+ }
86
+ return originals.length ? () => originals.forEach(({ el, children }) => el.replaceChildren(...children)) : null
87
+ }
88
+
89
+ const el = nodeAt(doc, instruction.path)
90
+ if (!el) return null
91
+
92
+ if (instruction.kind === 'move') {
93
+ const before = instruction.before ? nodeAt(doc, instruction.before) : null
94
+ // As for `insert`: a named but missing `before` is a no-op, not an append.
95
+ if (instruction.before && !before) return null
96
+ const parent = before?.parentElement ?? el.parentElement
97
+ if (!parent) return null
98
+ const home = el.parentNode
99
+ const next = el.nextSibling
100
+ parent.insertBefore(el, before)
101
+ return () => {
102
+ // The tree this node belonged to may have been replaced by something other than our own
103
+ // refresh: a site calling router.push, or fast refresh in dev. Re-attaching it then would
104
+ // graft a node from the old tree into the live document, and nothing would ever remove it.
105
+ if (!el.isConnected) return
106
+ // Restored newest first, so `next` is back where it was; if it is not a child of `home` the
107
+ // journal is stale (a frame that reloaded), and appending is the least wrong place.
108
+ home?.insertBefore(el, next && next.parentNode === home ? next : null)
109
+ }
110
+ }
111
+ if (instruction.kind === 'remove') {
112
+ // Hidden, not detached: React still owns this node, and will remove it itself when the server's
113
+ // render no longer lists it. Detaching it made that removal throw and took the page down.
114
+ const style = (el as HTMLElement).style
115
+ if (!style) return null
116
+ // The whole attribute is kept, not just `display`, so the undo leaves the markup byte for byte as React wrote it.
117
+ const written = el.getAttribute('style')
118
+ style.display = 'none'
119
+ // The attribute carries what to put back, so the frame's last-resort sweep can restore it too
120
+ // without the journal (`createBridge`'s `undoPreviews`): the renderer writes `display:contents`
121
+ // on every block wrapper, and losing that would give the block a layout box.
122
+ el.setAttribute('data-u90-removed', JSON.stringify(written))
123
+ return () => {
124
+ if (written === null) el.removeAttribute('style')
125
+ else el.setAttribute('style', written)
126
+ el.removeAttribute('data-u90-removed')
127
+ }
128
+ }
129
+ if (instruction.kind === 'text' || instruction.kind === 'rich') {
130
+ // The user's own keystrokes are the newest truth in a field they are typing in; an echo of an
131
+ // older value would move their caret to the end of it. v1 marks the field it is editing with
132
+ // `contenteditable` (`createBridge.ts`'s `beginEditing`); `data-u90-editing` is what legacy
133
+ // marked it with, and is kept so a legacy frame is covered too.
134
+ if (el.hasAttribute('data-u90-editing') || el.hasAttribute('contenteditable')) return null
135
+ // Only a field whose stored value IS its rendered text. A string set does not say that: a choice
136
+ // stores its value and renders its label, a date stores an ISO string and renders a formatted one,
137
+ // and writing the stored string into either shows something the page never will. `editable()`
138
+ // sets `data-u90-edit="text"` only for a value that carries text, so that attribute is the test.
139
+ if (el.getAttribute('data-u90-edit') !== instruction.kind) return null
140
+ // The child nodes themselves are kept, not their text: React holds those very nodes and updates
141
+ // them, so an undo that made new ones would leave React writing to nodes no longer on the page.
142
+ const children = [...el.childNodes]
143
+ if (instruction.kind === 'text') el.textContent = instruction.value
144
+ else el.replaceChildren(...instruction.paragraphs.map((text) => {
145
+ const paragraph = doc.createElement('p')
146
+ paragraph.textContent = text
147
+ return paragraph
148
+ }))
149
+ return () => el.replaceChildren(...children)
150
+ }
151
+ if (instruction.kind === 'media') {
152
+ // `tagName`, not `instanceof HTMLImageElement`: the tests build a bare Document with no
153
+ // defaultView, so there is no window to take the constructor from.
154
+ const img = el.tagName === 'IMG' ? (el as HTMLImageElement) : el.querySelector('img')
155
+ if (!img) return null
156
+ const src = img.getAttribute('src')
157
+ const srcset = img.getAttribute('srcset')
158
+ // A srcset left in place outranks the src and would keep showing the old image.
159
+ img.removeAttribute('srcset')
160
+ img.setAttribute('src', instruction.src)
161
+ return () => {
162
+ for (const [name, value] of [['src', src], ['srcset', srcset]] as const) {
163
+ if (value === null) img.removeAttribute(name)
164
+ else img.setAttribute(name, value)
165
+ }
166
+ }
167
+ }
168
+ if (instruction.kind === 'replace') {
169
+ const node = parse(doc, instruction.html)
170
+ if (!node) return null
171
+ el.replaceWith(node)
172
+ return () => {
173
+ // `node` is gone if a later undo (an insert's) already removed it; then `el` has nowhere to go
174
+ // and the page is as it was before either.
175
+ if (node.parentNode) node.replaceWith(el)
176
+ }
177
+ }
178
+ return null
179
+ }
@@ -0,0 +1,271 @@
1
+ import { isDocumentId } from '../elements/ids'
2
+ import { LinkTo } from '../elements/leaves/link'
3
+
4
+ /**
5
+ * Bridge v4: the message protocol between a server-rendered draft page (the site) and the
6
+ * Canvas (the platform). See `docs/content-contracts/bridge-v4.md` and spec §6.
7
+ *
8
+ * The model: the page is server-rendered from draft content. Typing edits the DOM in place and
9
+ * is sent as `edit`; the Canvas saves; after any structural save the Canvas sends `refresh` and
10
+ * the site calls `router.refresh()`. No content push, no client-side merge, no `fields` message.
11
+ */
12
+ export const BRIDGE_VERSION = 4 as const
13
+
14
+ /** The `u90:caret`'s `block`: the nearest block element the caret sits in, if any. */
15
+ type CaretBlock = 'paragraph' | 'h2' | 'h3' | 'h4' | 'list' | 'quote'
16
+ const CARET_BLOCKS = new Set<CaretBlock>(['paragraph', 'h2', 'h3', 'h4', 'list', 'quote'])
17
+
18
+ /** A `{ top, left, width, height }` rect shape, used both for the caret's own rect and `field`. */
19
+ const isRect = (v: unknown): v is { top: number; left: number; width: number; height: number } =>
20
+ Boolean(
21
+ v &&
22
+ typeof v === 'object' &&
23
+ typeof (v as Record<string, unknown>).top === 'number' &&
24
+ typeof (v as Record<string, unknown>).left === 'number' &&
25
+ typeof (v as Record<string, unknown>).width === 'number' &&
26
+ typeof (v as Record<string, unknown>).height === 'number',
27
+ )
28
+
29
+ /**
30
+ * A `to` the bridge accepts from the Canvas: `LinkTo`-shaped, and — since the Canvas only ever
31
+ * sends document ids, never a seed's key, per this plan's global constraint that a stored link
32
+ * target uses document ids — a page or entry target's id must itself be a document id.
33
+ * `LinkTo`'s own `Id` is wider (a positive integer or any non-empty string) because it is shared
34
+ * with legacy and seed data; the bridge has no reason to accept either here.
35
+ */
36
+ const isBridgeLinkTarget = (to: unknown): to is LinkTo => {
37
+ const parsed = LinkTo.safeParse(to)
38
+ if (!parsed.success) return false
39
+ const value = parsed.data
40
+ if ('page' in value) return isDocumentId(value.page)
41
+ if ('entry' in value) return isDocumentId(value.entry.id)
42
+ return true
43
+ }
44
+
45
+ /**
46
+ * A DOM-shaped instruction the Canvas asks a frame to apply to markup it has already rendered, so
47
+ * an edit shows before the server has re-rendered the page.
48
+ *
49
+ * Deliberately presentational. It carries no fields, no sections and no content tree, which is what
50
+ * keeps it from growing back into the client-side merge v4 removed: there is nothing here to merge.
51
+ * Every preview is transient. React owns draft block order and settles it from the next server render.
52
+ * For other previews the frame records an undo and takes them back, newest
53
+ * first, just before it asks for the `u90:refresh` that follows the save (`createBridge`'s journal), so
54
+ * the server's render lands on a page that is exactly as React last rendered it. Being wrong costs a
55
+ * visible settle, never wrong content.
56
+ *
57
+ * `before` names the sibling to insert in front of; `null` appends.
58
+ */
59
+ export type PreviewInstruction =
60
+ | { kind: 'move'; path: string; before: string | null }
61
+ | { kind: 'remove'; path: string }
62
+ | { kind: 'media'; path: string; src: string }
63
+ | { kind: 'text'; path: string; value: string }
64
+ /** The words inside a link; never replace its decorations or destination. */
65
+ | { kind: 'link-label'; path: string; value: string }
66
+ /** Plain paragraph edits from the inspector; formatted/resolved trees still come from the server. */
67
+ | { kind: 'rich'; path: string; paragraphs: string[] }
68
+ | { kind: 'insert'; path: string; before: string | null; html: string }
69
+ | { kind: 'replace'; path: string; html: string }
70
+ /**
71
+ * Asks the frame to fetch the rendered `block` from its own site's fragment page and put it where
72
+ * `path` is marked. The frame does the fetching because only it is same-origin with the site: the
73
+ * Canvas could not read that response, and the frame already carries the draft cookie. It is not
74
+ * applied by `applyPreview`; `createBridge` handles it.
75
+ */
76
+ | { kind: 'fragment'; path: string; block: string }
77
+
78
+ const PREVIEW_KINDS = new Set(['move', 'remove', 'media', 'text', 'link-label', 'rich', 'insert', 'replace', 'fragment'])
79
+ const isBefore = (v: unknown): boolean => v === null || typeof v === 'string'
80
+
81
+ /** Whether a `u90:preview`'s instruction has a known kind and a well-formed payload. */
82
+ const isPreviewInstruction = (value: unknown): value is PreviewInstruction => {
83
+ if (!(value && typeof value === 'object')) return false
84
+ const r = value as Record<string, unknown>
85
+ if (typeof r.kind !== 'string' || !PREVIEW_KINDS.has(r.kind) || typeof r.path !== 'string') return false
86
+ if (r.kind === 'media') return typeof r.src === 'string'
87
+ if (r.kind === 'text' || r.kind === 'link-label') return typeof r.value === 'string'
88
+ if (r.kind === 'rich') return Array.isArray(r.paragraphs) && r.paragraphs.every((paragraph) => typeof paragraph === 'string')
89
+ if (r.kind === 'replace') return typeof r.html === 'string'
90
+ if (r.kind === 'fragment') return typeof r.block === 'string'
91
+ if (r.kind === 'insert') return typeof r.html === 'string' && isBefore(r.before)
92
+ if (r.kind === 'move') return isBefore(r.before)
93
+ return true
94
+ }
95
+
96
+ /**
97
+ * Which page of which screen a message belongs to: the screen's frame key, the language it was
98
+ * loaded in (null for the original) and the count of its address changes. The Canvas sends one on
99
+ * `u90:hello` only when it shows languages, and the site then puts it on every message it sends, so
100
+ * the Canvas can drop what a screen's earlier page said. A message without one is a site on a kit
101
+ * before 1.3.0, or a Canvas that asked for none.
102
+ */
103
+ export type BridgeStamp = { frame: string; locale: string | null; revision: number }
104
+
105
+ /** Every message's own fields: the version, and a stamp, which validates as the three together or none of them. */
106
+ type Base = { version: 4; frame?: string; locale?: string | null; revision?: number }
107
+
108
+ /** Whether a message carries no stamp, a well formed one, or something else in its place. */
109
+ const stampState = (record: Record<string, unknown>): 'none' | 'stamped' | 'malformed' => {
110
+ const { frame, locale, revision } = record
111
+ if (frame === undefined && locale === undefined && revision === undefined) return 'none'
112
+ const wellFormed =
113
+ typeof frame === 'string' &&
114
+ frame !== '' &&
115
+ (locale === null || typeof locale === 'string') &&
116
+ typeof revision === 'number' &&
117
+ Number.isSafeInteger(revision) &&
118
+ revision >= 0
119
+ return wellFormed ? 'stamped' : 'malformed'
120
+ }
121
+
122
+ /** Site → Canvas. */
123
+ export type SiteMessage = Base &
124
+ (
125
+ | { type: 'u90:ready'; path: string }
126
+ | { type: 'u90:path'; path: string }
127
+ | { type: 'u90:select'; path: string | null }
128
+ | { type: 'u90:hover'; path: string | null }
129
+ | { type: 'u90:edit'; path: string; kind: 'text' | 'rich' | 'link-label'; value: unknown }
130
+ | {
131
+ type: 'u90:caret'
132
+ path: string
133
+ top: number
134
+ left: number
135
+ width: number
136
+ height: number
137
+ marks?: string[]
138
+ block?: CaretBlock
139
+ field?: { top: number; left: number; width: number; height: number }
140
+ }
141
+ | { type: 'u90:flushed'; requestId: string }
142
+ | { type: 'u90:height'; height: number }
143
+ | { type: 'u90:scrolled'; top: number; max: number }
144
+ | { type: 'u90:navigate'; path: string }
145
+ | { type: 'u90:zoom'; deltaY: number; x: number; y: number }
146
+ | { type: 'u90:wheel'; deltaX: number; deltaY: number }
147
+ | { type: 'u90:grab'; screenX: number; screenY: number }
148
+ | { type: 'u90:drag'; screenX: number; screenY: number }
149
+ | { type: 'u90:drop' }
150
+ | { type: 'u90:key'; code: string; down: boolean }
151
+ /** Draft, right-to-left pages only: how far the page overflows the screen, and the fields that reach past its edges (spec §8, 09-11 §5). */
152
+ | { type: 'u90:direction'; dir: 'rtl'; overflow: number; offenders: string[] }
153
+ )
154
+
155
+ /** Canvas → Site. */
156
+ export type CanvasMessage = Base &
157
+ (
158
+ | { type: 'u90:refresh' }
159
+ | { type: 'u90:select'; path: string | null }
160
+ | { type: 'u90:flush'; requestId: string }
161
+ | { type: 'u90:format'; action: string; url?: string; to?: LinkTo; newTab?: boolean }
162
+ | { type: 'u90:scroll'; to: 'top' | 'fraction'; fraction?: number }
163
+ | { type: 'u90:viewport'; height: number | null }
164
+ | { type: 'u90:navigation'; enabled: boolean }
165
+ /**
166
+ * Whether this screen is the one the Canvas has selected.
167
+ *
168
+ * Restores the single thing v3's `isActive` was load-bearing for and v4's first table left
169
+ * out. The wheel is the case: an active screen's page scrolls, an inactive one's wheel pans
170
+ * the Canvas instead, and only the site can stop its own page scrolling — `preventDefault`
171
+ * inside the frame is not something the Canvas can do for it. Without this message the site
172
+ * cannot tell the two apart, so it forwarded every wheel AND let the page scroll, and one
173
+ * gesture moved both.
174
+ *
175
+ * Sent to every frame whenever the selection changes, so each knows its own state and no
176
+ * frame is told about the others. A site that does not know it ignores it, as it does any
177
+ * message it does not parse — such a site keeps the behaviour it has today.
178
+ */
179
+ | { type: 'u90:active'; active: boolean }
180
+ /**
181
+ * Asks the page to say it is ready again: answered with `u90:ready` and the path it is on now.
182
+ * The Canvas sends it when it registers a frame whose page had already loaded, since a
183
+ * `u90:ready` sent before the Canvas was listening is lost. A site that does not know it ignores
184
+ * it, as it does any message it does not parse.
185
+ */
186
+ | { type: 'u90:hello' }
187
+ | { type: 'u90:preview'; instruction: PreviewInstruction }
188
+ )
189
+
190
+ const CANVAS_TYPES = new Set([
191
+ 'u90:hello',
192
+ 'u90:preview',
193
+ 'u90:refresh',
194
+ 'u90:select',
195
+ 'u90:flush',
196
+ 'u90:format',
197
+ 'u90:scroll',
198
+ 'u90:viewport',
199
+ 'u90:navigation',
200
+ 'u90:active',
201
+ ])
202
+
203
+ /** Whether a `u90:format` payload's optional `to`/`newTab` are each either absent or well formed. */
204
+ const isValidFormat = (data: Record<string, unknown>): boolean => {
205
+ if ('to' in data && data.to !== undefined && !isBridgeLinkTarget(data.to)) return false
206
+ if ('newTab' in data && data.newTab !== undefined && typeof data.newTab !== 'boolean') return false
207
+ return true
208
+ }
209
+
210
+ /** A message from the Canvas, accepted only at the current version and a known type. */
211
+ export const parseCanvasMessage = (data: unknown): CanvasMessage | null => {
212
+ if (!(data && typeof data === 'object' && (data as { version?: unknown }).version === 4)) return null
213
+ const record = data as Record<string, unknown>
214
+ if (!CANVAS_TYPES.has(String(record.type))) return null
215
+ if (stampState(record) === 'malformed') return null
216
+ if (record.type === 'u90:format' && !isValidFormat(record)) return null
217
+ if (record.type === 'u90:preview' && !isPreviewInstruction(record.instruction)) return null
218
+ return data as CanvasMessage
219
+ }
220
+
221
+ const SITE_TYPES = new Set([
222
+ 'u90:ready',
223
+ 'u90:path',
224
+ 'u90:select',
225
+ 'u90:hover',
226
+ 'u90:edit',
227
+ 'u90:caret',
228
+ 'u90:flushed',
229
+ 'u90:height',
230
+ 'u90:scrolled',
231
+ 'u90:navigate',
232
+ 'u90:zoom',
233
+ 'u90:wheel',
234
+ 'u90:grab',
235
+ 'u90:drag',
236
+ 'u90:drop',
237
+ 'u90:key',
238
+ 'u90:direction',
239
+ ])
240
+
241
+ /** Whether a `u90:caret` payload's optional `marks`/`block`/`field` are each either absent or well formed. */
242
+ const isValidCaret = (data: Record<string, unknown>): boolean => {
243
+ if ('marks' in data && data.marks !== undefined && !(Array.isArray(data.marks) && data.marks.every((m) => typeof m === 'string'))) return false
244
+ if ('block' in data && data.block !== undefined && !CARET_BLOCKS.has(data.block as CaretBlock)) return false
245
+ if ('field' in data && data.field !== undefined && !isRect(data.field)) return false
246
+ return true
247
+ }
248
+
249
+ /** The most fields a right-to-left check names, and the longest field path it may carry. */
250
+ export const MAX_OFFENDERS = 20
251
+ export const MAX_OFFENDER_LENGTH = 512
252
+ /** Whether a `u90:direction` is a right-to-left page's finite, non-negative overflow and at most 20 field paths of at most 512 characters. */
253
+ const isValidDirection = (data: Record<string, unknown>): boolean =>
254
+ data.dir === 'rtl' &&
255
+ typeof data.overflow === 'number' &&
256
+ Number.isFinite(data.overflow) &&
257
+ data.overflow >= 0 &&
258
+ Array.isArray(data.offenders) &&
259
+ data.offenders.length <= MAX_OFFENDERS &&
260
+ data.offenders.every((o) => typeof o === 'string' && o.length <= MAX_OFFENDER_LENGTH)
261
+
262
+ /** A message from a site frame, accepted only at the current version and a known type. */
263
+ export const isSiteMessage = (data: unknown): data is SiteMessage => {
264
+ if (!(data && typeof data === 'object' && (data as { version?: unknown }).version === 4)) return false
265
+ const record = data as Record<string, unknown>
266
+ if (!SITE_TYPES.has(String(record.type))) return false
267
+ if (stampState(record) === 'malformed') return false
268
+ if (record.type === 'u90:caret' && !isValidCaret(record)) return false
269
+ if (record.type === 'u90:direction' && !isValidDirection(record)) return false
270
+ return true
271
+ }
@@ -0,0 +1,27 @@
1
+ import type { Bridge } from './createBridge'
2
+
3
+ /**
4
+ * The one shared, module-level handle onto the live bridge instance and its reveal listeners.
5
+ *
6
+ * No React, no Next — this file exists purely so `useReveal` (in `hooks.tsx`) can register a
7
+ * listener without statically importing `./client` or `./createBridge`. Those two pull in
8
+ * `next/navigation` and the whole DOM-level bridge respectively; `EditingProvider` already keeps
9
+ * that chunk out of a production bundle by fetching it with `lazy(() => import('./client'))`
10
+ * (see `hooks.tsx`), and only when `editing`. A site component that calls `useReveal` (Task 8's
11
+ * header, for one) sits beside that provider in the same client bundle, so if `useReveal` itself
12
+ * imported `./client` or `./createBridge` eagerly, using the hook at all would pull the bridge
13
+ * chunk back in on every render, draft or not — this indirection is what keeps `useReveal` cheap.
14
+ *
15
+ * `current` is set by `createBridge(...).start()` and cleared by `.stop()`. `reveals` is the
16
+ * live set `createBridge`'s `u90:select` handler walks — `useReveal` adds and removes its own
17
+ * `{ prefix, fn }` entry directly, so revealing works whether or not a bridge is mounted yet.
18
+ */
19
+ export const registry: {
20
+ current: Bridge | null
21
+ reveals: Set<{ prefix: string; fn: () => void }>
22
+ blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; move(path: string, before: string | null): boolean }>
23
+ } = {
24
+ current: null,
25
+ reveals: new Set(),
26
+ blockOrders: new Set(),
27
+ }
@@ -0,0 +1,137 @@
1
+ import { EDITABLE_LEAF_SELECTOR, MEDIA, MEDIA_SELECTOR } from './caret'
2
+
3
+ /**
4
+ * The editing affordances injected into a draft page: pointer/I-beam cursors, hover outlines,
5
+ * the selected-element outline, the navigation-mode link/button treatment, and the "no sections
6
+ * yet" placeholder.
7
+ *
8
+ * Extracted from `legacy/bridge.tsx`'s `EditingStyles` (the `<style>` it rendered, from around
9
+ * line 1779 of that file). That component took `selected` and `navigation` as React props and
10
+ * rebuilt the stylesheet string on every render; `createBridge` instead appends this once, as a
11
+ * static stylesheet, and toggles attributes on the elements themselves:
12
+ *
13
+ * - the dynamic `[${FIELD_ATTR}="${escaped}"]` rule (the selected element) and the digit-keyed
14
+ * `[${SECTION_ATTR}="${sectionKey}"]` rule (a selected section) both collapse into one pair of
15
+ * static `[data-u90-selected]` rules — `createBridge`'s `markSelected` sets that attribute on
16
+ * the selected element (field or block) directly, so the stylesheet no longer needs to know
17
+ * which path is selected. The heavier `3px solid` / `8px`-offset treatment legacy gave a
18
+ * selected *section* survives as the more specific `[data-u90-block][data-u90-selected]` rule,
19
+ * below the plain `2px` / `5px` one every other selected element gets.
20
+ * - `[data-u90-hover]` is a highlight driven by attribute rather than by the `:hover`
21
+ * pseudo-class — `createBridge`'s `onPointerOver`/`onPointerOut` set and clear it as the
22
+ * pointer moves across `[data-u90-field]`/`[data-u90-block]` elements, which is also what
23
+ * sends `u90:hover`. The plain mouse-hover affordance below (the dashed outline) is a
24
+ * *separate*, unconditional `:hover` pseudo-class rule, exactly as legacy had it — the two
25
+ * can be visually distinguished (solid highlight vs. dashed hover) because nothing requires
26
+ * them to look the same.
27
+ * - legacy's `navigation` prop — links and buttons either behave like plain links (navigation
28
+ * on) or are outlined as selectable controls with an I-beam over their editable text
29
+ * (navigation off) — cannot be reproduced by a *value* the way legacy's component took it,
30
+ * since `EDITING_CSS` is a static string with no per-render prop to read. Both branches are
31
+ * still here, keyed on `html[data-u90-navigation]` / `html:not([data-u90-navigation])`;
32
+ * `createBridge`'s `u90:navigation` handler sets or removes that attribute on
33
+ * `document.documentElement`, so the CSS switches branch exactly when legacy's component
34
+ * would have re-rendered with a new `navigation` value. Off (`html:not([data-u90-navigation])`)
35
+ * is the state with no `<html>` attribute at all, matching legacy's own `navigation = false`
36
+ * default and its fallback for a site on an older kit that never sends the message.
37
+ * - `[data-u90-rich]`/`[data-u90-section]` selectors become `[data-u90-edit="rich"]`/
38
+ * `[data-u90-block]` throughout, matching the same rename in `caret.ts`.
39
+ *
40
+ * The "no sections yet" placeholder (`main:not(:has(*))`) is copied unchanged.
41
+ */
42
+ export const EDITING_CSS = `
43
+ [data-u90-field] { cursor: pointer; }
44
+ [data-u90-field]:hover {
45
+ outline: 1px dashed #FF5D2E;
46
+ outline-offset: 4px;
47
+ }
48
+
49
+ /* a Single inside editable copy: one chip the caret skips, saved back as its {key} (R38) */
50
+ [data-u90-field] [data-single-key] {
51
+ border-radius: 4px;
52
+ padding: 0 2px;
53
+ background: rgba(255, 93, 46, 0.12);
54
+ }
55
+
56
+ ${MEDIA_SELECTOR} { cursor: pointer; }
57
+ ${MEDIA_SELECTOR}:hover {
58
+ outline: 2px dashed #FF5D2E;
59
+ outline-offset: -2px;
60
+ }
61
+
62
+ [data-u90-block]:hover {
63
+ outline: 2px dashed #FF5D2E;
64
+ outline-offset: 8px;
65
+ }
66
+
67
+ /* navigation on: links and buttons with no marker of their own behave like plain links */
68
+ html[data-u90-navigation] a[href]:not([data-u90-field]),
69
+ html[data-u90-navigation] button:not([data-u90-field]) {
70
+ cursor: pointer;
71
+ }
72
+
73
+ /* navigation off (default): the whole control is outlined, and an editable leaf takes an I-beam */
74
+ html:not([data-u90-navigation]) a[href],
75
+ html:not([data-u90-navigation]) button {
76
+ cursor: pointer;
77
+ }
78
+ html:not([data-u90-navigation]) a[href]:not(:has([data-u90-field])):hover,
79
+ html:not([data-u90-navigation]) button:not(:has([data-u90-field])):hover {
80
+ outline: 1px dashed #FF5D2E;
81
+ outline-offset: 3px;
82
+ }
83
+ html:not([data-u90-navigation]) a[href]:has([data-u90-field]):hover {
84
+ outline: 1px dashed #FF5D2E;
85
+ outline-offset: 3px;
86
+ }
87
+ html:not([data-u90-navigation]) ${EDITABLE_LEAF_SELECTOR} {
88
+ cursor: text;
89
+ }
90
+
91
+ [data-u90-selected] {
92
+ outline: 2px solid #FF5D2E !important;
93
+ outline-offset: 5px;
94
+ }
95
+ [data-u90-selected]:is(${MEDIA}) {
96
+ outline: 3px solid #FF5D2E !important;
97
+ outline-offset: -3px;
98
+ }
99
+ [data-u90-block][data-u90-selected] {
100
+ outline: 3px solid #FF5D2E !important;
101
+ outline-offset: 8px;
102
+ }
103
+
104
+ [data-u90-hover] {
105
+ outline: 1px dashed #FF5D2E;
106
+ outline-offset: 4px;
107
+ }
108
+
109
+ [data-u90-field][data-u90-editing] {
110
+ cursor: text;
111
+ outline: 2px solid #FF5D2E !important;
112
+ outline-offset: 5px;
113
+ }
114
+ [data-u90-field][data-u90-editing]:focus,
115
+ [data-u90-field][data-u90-editing]:focus-visible {
116
+ outline: 2px solid #FF5D2E !important;
117
+ }
118
+
119
+ main:not(:has(*)) {
120
+ min-height: 50vh;
121
+ display: grid;
122
+ place-items: center;
123
+ padding: 48px 24px;
124
+ box-sizing: border-box;
125
+ }
126
+ main:not(:has(*))::after {
127
+ content: 'This page has no sections yet. Add one from the Page panel.';
128
+ display: block;
129
+ max-width: 32ch;
130
+ padding: 28px 32px;
131
+ border: 1px dashed #FF5D2E;
132
+ font: 500 14px/1.5 ui-sans-serif, system-ui, sans-serif;
133
+ letter-spacing: 0.01em;
134
+ text-align: center;
135
+ color: #FF5D2E;
136
+ }
137
+ `
@@ -0,0 +1,9 @@
1
+ import { createElement, type ElementType } from 'react'
2
+ import type { DateRead } from '../elements'
3
+ import { draftAttrs } from './draft'
4
+ export function DateText<T extends ElementType = 'time'>({ value, as, locale, format, ...rest }: { value: DateRead | null | undefined; as?: T; locale?: string; format?: Intl.DateTimeFormatOptions } & Record<string, unknown>) {
5
+ if (!value) return null
6
+ const text = new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...(format ?? { dateStyle: 'medium' }) }).format(new Date(`${value.iso}T00:00:00Z`))
7
+ const attrs = draftAttrs(value)
8
+ return as ? createElement(as, { ...rest, ...attrs }, text) : value.$path ? createElement('span', attrs, text) : text
9
+ }
@@ -0,0 +1,9 @@
1
+ import { createElement, Fragment } from 'react'
2
+ import type { FileRead } from '../elements'
3
+ import { draftAttrs } from './draft'
4
+ export function File({ value, ...rest }: { value: FileRead | null | undefined } & Record<string, unknown>) {
5
+ if (!value || !value.asset) return null
6
+ return createElement(Fragment, null,
7
+ createElement('a', { ...rest, ...draftAttrs(value), href: value.asset.url, ...(value.downloadName ? { download: value.downloadName } : { download: true }) }, value.label || value.asset.filename),
8
+ value.description ? createElement('span', { className: 'u90-file-description' }, value.description) : null)
9
+ }
@@ -0,0 +1,30 @@
1
+ import NextImage, { getImageProps } from 'next/image'
2
+ import { createElement } from 'react'
3
+ import type { ImageRead } from '../elements'
4
+ import { draftAttrs } from './draft'
5
+ import { imageProps, isGrantPath, pictureSources, type ImageProps } from './imageProps'
6
+ /**
7
+ * One declared variant's `<source>`, built with `next/image`'s own `getImageProps` so its `srcSet`
8
+ * carries the candidates a plain `<Image>` would. `getImageProps` refuses `fill` together with a
9
+ * `width` or `height` — it throws outside production, so under `next dev`, which the Canvas preview
10
+ * runs — so a fill image's sources take no size of their own.
11
+ */
12
+ export const sourceProps = (props: ImageProps, s: ReturnType<typeof pictureSources>[number]) => {
13
+ const size = props.fill ? {} : { width: s.width ?? props.width, height: s.height ?? props.height }
14
+ // The variant's own URL decides, in both directions: a grant path is never optimised, whatever the main image is.
15
+ const unoptimized = isGrantPath(s.srcSet)
16
+ const { props: p } = getImageProps({ ...props, src: s.srcSet, ...size, unoptimized, alt: props.alt })
17
+ // An unoptimised image has no generated srcSet, so the raw path is the candidate.
18
+ return { key: s.name, media: s.media, srcSet: p.srcSet ?? p.src, sizes: p.sizes }
19
+ }
20
+ export function Image({ value, sizes, fill, priority, className, figureClassName, variants }: { value: ImageRead | null | undefined; sizes?: string; fill?: boolean; priority?: boolean; className?: string; figureClassName?: string; variants?: Record<string, { media: string }> }) {
21
+ if (!value) return null
22
+ const attrs = draftAttrs(value)
23
+ const props = imageProps(value, { sizes, fill, priority })
24
+ if (!props) return value.$path ? createElement('div', { ...attrs, className, 'data-u90-missing': 'image' }, 'Image missing') : null
25
+ const sources = pictureSources(value, variants)
26
+ const img = sources.length
27
+ ? createElement('picture', null, ...sources.map((s) => createElement('source', sourceProps(props, s))), createElement(NextImage, { ...props, className, ...attrs }))
28
+ : createElement(NextImage, { ...props, className, ...attrs })
29
+ return value.caption ? createElement('figure', { className: figureClassName }, img, createElement('figcaption', null, value.caption)) : img
30
+ }