@unitninety/site-kit 0.0.0-stage → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @unitninety/site-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The library a UnitNinety site is built on. It reads the site's content from UnitNinety, renders its editable parts, and answers the platform at `/api/u90`, so the site's clients can edit it in UnitNinety's dashboard.
4
+
5
+ You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
6
+
7
+ ```bash
8
+ npx --yes @unitninety/u90@1.0.1 --version
9
+ ```
10
+
11
+ UnitNinety imports packages made for that version only, so keep the version the tool names.
12
+
13
+ Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
14
+
15
+ ### Immediate collection-list previews
16
+
17
+ Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
18
+ cards should reorder and disappear immediately in Canvas. Pass the complete value, including
19
+ its draft `$path`, and render one card per entry:
20
+
21
+ ```tsx
22
+ <Entries value={projects} className="project-grid" empty={<p>No projects yet.</p>}>
23
+ {entry => <ProjectCard entry={entry} />}
24
+ </Entries>
25
+ ```
26
+
27
+ The card renderer runs on the server. In draft mode React owns the keyed cards and preserves
28
+ pending order/removal through slow refreshes; rejected saves reconcile to the server result.
29
+ Published output is an ordinary div and server-rendered cards. Newly added entries whose markup
30
+ is not already present appear when the save's server refresh arrives. Query results rendered
31
+ with a plain `.map()` still work, but wait for that refresh to change their order or membership.
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
2
  "name": "@unitninety/site-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.6.2",
4
+ "description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/site/index.ts",
8
+ "./server": "./src/server/index.ts",
9
+ "./features/server": "./src/features/server/index.ts",
10
+ "./features/contracts": "./src/features/contracts/index.ts",
11
+ "./preview-lock": "./src/preview-lock/index.ts",
12
+ "./preview-lock/server": "./src/preview-lock/server.ts",
13
+ "./preview-lock/screen": "./src/preview-lock/screen.tsx",
14
+ "./components": "./src/components/index.ts",
15
+ "./bridge": "./src/bridge/index.ts",
16
+ "./testing": "./src/testing/index.ts",
17
+ "./redirects": "./src/redirects/index.ts",
18
+ "./redirects/config": {
19
+ "types": "./src/redirects/config.d.mts",
20
+ "default": "./src/redirects/config.mjs"
21
+ },
22
+ "./elements": "./src/elements/index.ts",
23
+ "./languages": "./src/languages/index.ts",
24
+ "./languages/config": {
25
+ "types": "./src/languages/config.d.mts",
26
+ "default": "./src/languages/config.mjs"
27
+ },
28
+ "./legacy": "./src/legacy/index.ts",
29
+ "./legacy/bridge": "./src/legacy/bridge.tsx",
30
+ "./legacy/cli": "./src/legacy/cli.mjs",
31
+ "./legacy/draft": "./src/legacy/draft.tsx",
32
+ "./legacy/shape": "./src/legacy/shape.ts",
33
+ "./legacy/contract": "./src/legacy/contract.ts",
34
+ "./legacy/redirects": "./src/legacy/redirects.ts",
35
+ "./legacy/language-switcher": "./src/legacy/language-switcher.tsx",
36
+ "./features": "./src/features/client/index.ts",
37
+ "./features/styles.css": "./src/features/client/styles.css",
38
+ "./test-preview/server": "./src/test-preview/server.ts"
39
+ },
40
+ "files": [
41
+ "src",
42
+ "!src/**/*.test.ts",
43
+ "!src/**/*.test.tsx",
44
+ "!src/**/*.test-d.ts",
45
+ "!src/**/*.test.mjs",
46
+ "!src/elements/fixtures",
47
+ "!src/elements/harness.ts",
48
+ "!src/site/fixtures",
49
+ "!src/test-preview/fixtures.ts",
50
+ "!src/features/consent/test-fixtures.ts"
51
+ ],
52
+ "license": "UNLICENSED",
53
+ "homepage": "https://unitninety.com",
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/UnitNinety/unitninety-run.git",
57
+ "directory": "packages/site-kit"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "dependencies": {
63
+ "zod": "4.4.3"
64
+ },
65
+ "peerDependencies": {
66
+ "react": ">=19",
67
+ "next": ">=16.3.8 <17"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "react": {
71
+ "optional": true
72
+ },
73
+ "next": {
74
+ "optional": true
75
+ }
76
+ },
77
+ "scripts": {
78
+ "typecheck": "tsc --noEmit",
79
+ "test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx' src/test-preview/protocol.test.mjs"
80
+ }
81
+ }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ import { Fragment, useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
4
+
5
+ import { registry } from './registry'
6
+ import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
7
+ import { PreviewCommitBoundary } from './PreviewCommitBoundary'
8
+
9
+ type Item = { id: string; path?: string; content: ReactNode }
10
+ type Move = { path: string; before: string | null }
11
+
12
+ const moved = (items: Item[], { path, before }: Move): Item[] => {
13
+ const item = items.find((entry) => entry.path === path)
14
+ if (!item || before === path) return items
15
+ const next = items.filter((entry) => entry !== item)
16
+ const at = before === null ? next.length : next.findIndex((entry) => entry.path === before)
17
+ if (at < 0) return items
18
+ next.splice(at, 0, item)
19
+ return next
20
+ }
21
+
22
+ /**
23
+ * React owns draft block order, including the optimistic order. Moving its DOM nodes outside
24
+ * React required undoing the drag before a refresh, visibly restoring the old order while the
25
+ * server answered. The next server render reconciles these same keyed children instead.
26
+ * Only draft-marked zones use this component; published pages keep their server-only renderer.
27
+ */
28
+ export function DraftBlocks({ items }: { items: Item[] }) {
29
+ const host = useRef<HTMLDivElement | null>(null)
30
+ const reload = useRef(false)
31
+ // A stable ref keeps the adapter installed through React's mutation phase.
32
+ const attachHost = useCallback((node: HTMLDivElement | null) => {
33
+ host.current = node
34
+ return preserveBlockMoves(node)
35
+ }, [])
36
+ const [shown, setShown] = useState({ source: items, items, order: null as string[] | null })
37
+ // Keep the desired order, not a log to replay: the server response may already
38
+ // contain later moves, and applying a sequence twice is not generally idempotent.
39
+ const ranks = new Map(shown.order?.map((id, index) => [id, index]))
40
+ const known = items.filter((item) => ranks.has(item.id)).sort((a, b) => ranks.get(a.id)! - ranks.get(b.id)!)
41
+ let knownIndex = 0
42
+ const current = shown.source === items ? shown.items : shown.order === null ? items
43
+ : items.map((item) => ranks.has(item.id) ? known[knownIndex++]! : item)
44
+ if (shown.source !== items) setShown({ ...shown, source: items, items: current })
45
+
46
+ useLayoutEffect(() => {
47
+ const handler = {
48
+ document,
49
+ needsReload: () => reload.current,
50
+ beforeRefresh: () => setShown((value) => ({ ...value, order: null })),
51
+ move: (path: string, before: string | null) => {
52
+ if (!current.some((item) => item.path === path)) return false
53
+ if (before !== null && !current.some((item) => item.path === before)) return false
54
+ if (host.current && needsEmbedReload(host.current)) {
55
+ reload.current = true
56
+ return true
57
+ }
58
+ setShown((value) => {
59
+ const next = moved(value.items, { path, before })
60
+ return { ...value, items: next, order: next.map((item) => item.id) }
61
+ })
62
+ return true
63
+ },
64
+ }
65
+ registry.blockOrders.add(handler)
66
+ return () => { registry.blockOrders.delete(handler) }
67
+ }, [current])
68
+
69
+ return <PreviewCommitBoundary source={items}><div data-u90-draft-zone="" style={{ display: 'contents' }} ref={attachHost}>{current.map((item) => <Fragment key={item.id}>{item.content}</Fragment>)}</div></PreviewCommitBoundary>
70
+ }
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import { Fragment, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
4
+ import { PreviewCommitBoundary } from './PreviewCommitBoundary'
5
+ import { registry } from './registry'
6
+
7
+ export type EntryItem = { id: string; content: ReactNode }
8
+ export type EntryPicks = { order: string[]; exclude: string[]; pin: string[] }
9
+
10
+ function picked(items: EntryItem[], picks: EntryPicks, pins: readonly string[]): EntryItem[] {
11
+ const excluded = new Set([...picks.exclude, ...pins.filter(id => !picks.pin.includes(id))])
12
+ const ranks = new Map(picks.order.map((id, index) => [id, index]))
13
+ return items.filter(item => !excluded.has(item.id)).sort((a, b) =>
14
+ (ranks.get(a.id) ?? Infinity) - (ranks.get(b.id) ?? Infinity))
15
+ }
16
+
17
+ /** React owns the cards so pending picks survive a slow server refresh without a DOM undo flash. */
18
+ export function DraftEntries({ path, items, pins, empty, ...props }: HTMLAttributes<HTMLDivElement> & { path: string; items: EntryItem[]; pins: string[]; empty?: ReactNode }) {
19
+ const host = useRef<HTMLDivElement>(null)
20
+ const [shown, setShown] = useState({ source: items, pins, items, picks: null as EntryPicks | null })
21
+ const current = shown.source === items ? shown.items : shown.picks === null ? items : picked(items, shown.picks, pins)
22
+ if (shown.source !== items) setShown({ ...shown, source: items, pins, items: current })
23
+
24
+ useLayoutEffect(() => {
25
+ const handler = {
26
+ document: host.current!.ownerDocument,
27
+ // Clear only the picks included when this refresh starts, retaining the visible cards
28
+ // until its response arrives. A subsequent edit remains active across that older response.
29
+ beforeRefresh: () => setShown(value => ({ ...value, picks: null })),
30
+ pick: (at: string, picks: EntryPicks) => {
31
+ if (at !== path) return false
32
+ setShown(value => ({ ...value, items: picked(value.source, picks, value.pins), picks }))
33
+ return true
34
+ },
35
+ }
36
+ registry.entryLists.add(handler)
37
+ return () => { registry.entryLists.delete(handler) }
38
+ }, [path])
39
+
40
+ return <PreviewCommitBoundary source={items}><div {...props} data-u90-field={path} ref={host}>{current.length ? current.map(item => <Fragment key={item.id}>{item.content}</Fragment>) : empty}</div></PreviewCommitBoundary>
41
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import { Component, type ReactNode } from 'react'
4
+ import { registry } from './registry'
5
+
6
+ /** Rebase imperative field previews around the actual React mutation, not around
7
+ * the network request. A persistent Next layout need not render when its routed
8
+ * subtree changes, so each draft zone places this boundary around its server items. */
9
+ export class PreviewCommitBoundary extends Component<{ source: unknown; children: ReactNode }> {
10
+ getSnapshotBeforeUpdate(previous: Readonly<{ source: unknown; children: ReactNode }>) {
11
+ return previous.source === this.props.source ? null : registry.current?.prepareCommit() ?? null
12
+ }
13
+
14
+ componentDidUpdate(_previous: unknown, _state: unknown, replay: null | (() => void)) {
15
+ replay?.()
16
+ }
17
+
18
+ render() { return this.props.children }
19
+ }
@@ -0,0 +1,54 @@
1
+ import { nextAdapters, type NextAdapters } from '../server/adapters'
2
+ import { createTestPreviewRuntime, type TestPreviewContext } from '../test-preview/runtime'
3
+ import { resolveBridgePreview } from '../test-preview/bridge'
4
+ import { Suspense, type ReactNode } from 'react'
5
+
6
+ import { EditingProvider } from './hooks'
7
+
8
+ /**
9
+ * `draftMode()` is a request-time API: under Cache Components, reading it must happen behind a
10
+ * `Suspense` boundary rather than at a layout's top level (a top-level `await` there would hold
11
+ * `children` — the whole segment — behind the request on every render). `Gate` is that boundary's
12
+ * one child, so the read is pushed down into the smallest component that needs it, per Next's
13
+ * documented pattern for authentication/session reads under Cache Components.
14
+ *
15
+ * `EditingProvider` renders `children` itself (see `hooks.tsx`) — it has to, since a Context
16
+ * provider must be an ancestor of the components calling `useEditing()`/`useReveal()`, which
17
+ * means `children` sits inside this Suspense boundary too. In practice this costs nothing visible:
18
+ * `draftMode()` reads a cookie already on the request, no I/O, so `Gate` resolves in the same tick
19
+ * Ordinary visitors perform no I/O here. Test previews additionally verify their session with the
20
+ * source trust response; pass `u90.testPreview` to share the connection's bounded trust cache.
21
+ */
22
+ type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; testPreview?: () => Promise<TestPreviewContext | null> }
23
+ async function Gate({ platformOrigin, children, draft, testPreview }: BridgeProps) {
24
+ const runtime = createTestPreviewRuntime({ env: { PLATFORM_URL: process.env.PLATFORM_URL, PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY, SITE_URL: process.env.SITE_URL } })
25
+ const props = await resolveBridgePreview({ platformOrigin, draft, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
26
+ return <EditingProvider {...props}>{children}</EditingProvider>
27
+ }
28
+
29
+ /**
30
+ * Wraps `{children}` in `app/layout.tsx` (`<U90Bridge>{children}</U90Bridge>`): outside draft, or
31
+ * with no `platformOrigin` configured, this renders `children` through the provider but through the async
32
+ * `Gate` and its `EditingProvider` — `useEditing()` still works everywhere, always returning
33
+ * `{ editing: false, selected: null }` in that case. Only when `draftMode()` is enabled and a
34
+ * `platformOrigin` is known does the client bridge (`BridgeClient`, lazily imported from
35
+ * `EditingProvider`) actually mount.
36
+ *
37
+ * `platformOrigin` defaults to `PLATFORM_URL`'s origin (empty when that env var is unset, which
38
+ * `Gate` reads as "never enable the bridge" — a draft page with no platform to talk to is just a
39
+ * normal render).
40
+ */
41
+ export function U90Bridge({
42
+ children,
43
+ draft = nextAdapters.draft,
44
+ testPreview,
45
+ platformOrigin = process.env.PLATFORM_URL ? new URL(process.env.PLATFORM_URL).origin : '',
46
+ }: {
47
+ children: ReactNode
48
+ platformOrigin?: string
49
+ draft?: NextAdapters['draft']
50
+ /** Pass u90.testPreview to share its configured connection and injected adapters. */
51
+ testPreview?: () => Promise<TestPreviewContext | null>
52
+ }) {
53
+ return <Suspense fallback={null}><Gate platformOrigin={platformOrigin} draft={draft} testPreview={testPreview}>{children}</Gate></Suspense>
54
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Which marked elements take a caret, and where the caret goes.
3
+ *
4
+ * Split out of `bridge.tsx` for two reasons. It is the only part of the editing behaviour
5
+ * that is pure DOM — no React, no message protocol, no closure over the bridge's state — so
6
+ * it can be loaded and driven on its own, which is what `verify:caret` does: it runs these
7
+ * exact functions in a real browser against a real layout, rather than a transcription of
8
+ * them that could drift. And `bridge.tsx` was over a thousand lines.
9
+ *
10
+ * Ported from `legacy/caret.ts`, with one change throughout: v4 has no standalone `RICH_ATTR`
11
+ * boolean attribute or `SECTION_ATTR` — a field is `data-u90-field`, its edit kind (when it takes
12
+ * a caret at all) is `data-u90-edit="text" | "rich"`, and a block wrapper is `data-u90-block`
13
+ * rather than a digit-keyed `data-u90-section`. `FIELD_ATTR` is inlined below rather than
14
+ * imported from `./shape` (this v1 package has no `shape.ts`); every other line — the exported
15
+ * function bodies, the ordering, the comments — is unchanged from the legacy source.
16
+ */
17
+
18
+ /** The v4 spelling of the legacy `FIELD_ATTR` (`'data-u90-field'`), inlined rather than imported. */
19
+ const FIELD_ATTR = 'data-u90-field'
20
+
21
+ /**
22
+ * What an annotated element must not contain if it is to be typed into.
23
+ *
24
+ * A marked element holding an image or a video is a media field or a card, not words: its
25
+ * value is an upload id, and the caret has no business there. A nested control or link is
26
+ * excluded for a different reason — its own text is a separate field, or markup a plain
27
+ * string cannot round-trip, so editing the parent as one blob would flatten it.
28
+ *
29
+ * **`svg` is deliberately absent.** Every button on these sites is `<a>Contact Us<svg/></a>`
30
+ * — the label and its chevron — and excluding icons would have made the most obviously
31
+ * editable thing on the page the one thing you could not edit. `innerText` ignores an svg's
32
+ * contents, so the value is the words alone; and if a client deletes the icon while typing,
33
+ * the re-render on blur puts it straight back, because the icon was never theirs to store.
34
+ *
35
+ * The check is on *descendants* — an `<a>` that is itself the marked element is fine.
36
+ */
37
+ export const NON_TEXT = 'img, picture, video, canvas, iframe, input, textarea, select, button, a'
38
+
39
+ /**
40
+ * The tags that are a picture rather than words — a deliberate *subset* of `NON_TEXT`.
41
+ *
42
+ * Two lists rather than one, and the difference is `a` and `button`. `NON_TEXT` answers "may
43
+ * this element *contain* something untypable", and a marked button label is both a control and
44
+ * a text leaf: with navigation off it takes a caret, which is how a client edits a button's
45
+ * words at all. Excluding every non-text tag from editability would therefore make every
46
+ * caption on every button uneditable in place — a far worse bug than the one this fixes.
47
+ *
48
+ * This list answers a different question: "*is* this element a picture". `NON_TEXT` could never
49
+ * answer it, because it is only ever asked about descendants — `querySelector`, `:has()` — and
50
+ * an `<img>` contains no `img`. So a marked image satisfied every editable-leaf test and
51
+ * offered an I-beam over a photograph.
52
+ */
53
+ export const MEDIA = 'img, picture, video, canvas, iframe'
54
+
55
+ /**
56
+ * A marked element that is itself a picture.
57
+ *
58
+ * Keyed on the element's own tag rather than on what it contains, because the two spellings a
59
+ * site uses want different treatment: a marker *on* the image needs its affordance drawn inside
60
+ * itself, while a marker on a wrapper is normally the clipping frame and its outline is already
61
+ * visible outside it.
62
+ */
63
+ export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
64
+
65
+ /**
66
+ * Whether this annotated element is a leaf holding text, and so directly editable.
67
+ *
68
+ * Leaf means it contains no other annotated element. A container — a card marked as a
69
+ * whole, a section wrapping four marked children — would otherwise become one editable
70
+ * blob whose text belongs to several fields at once, and the first keystroke would write
71
+ * all of them into one.
72
+ *
73
+ * **A rich field is accepted here early**, ahead of the nested-marker and `NON_TEXT`
74
+ * clauses below, and that ordering is the point: a real document contains `<a>` and `<ul>`,
75
+ * and both of those clauses exist to stop an *ordinary* field being edited as one blob —
76
+ * applied to a rich field they would reject it for exactly the structure it is allowed to
77
+ * hold. The requirement was never "a rich field must not be edited in place"; a client
78
+ * choosing to type into their own heading is the same client choosing to type into their
79
+ * own paragraph. The requirement is "a rich field must never be read back as plain words",
80
+ * and that guarantee is the bridge's job — see `createBridge`'s `valueOf`, keyed on the same
81
+ * `data-u90-edit="rich"` attribute — not this function's.
82
+ *
83
+ * The `MEDIA` clause still runs unconditionally, ahead of the rich check: a marker on an
84
+ * image is a picture whatever else it carries, and the caret has no business there.
85
+ *
86
+ * v4 change: the legacy `element.hasAttribute(RICH_ATTR)` (a standalone boolean attribute)
87
+ * becomes `element.getAttribute('data-u90-edit') === 'rich'`, since v4 has one edit-kind
88
+ * attribute rather than a separate rich marker.
89
+ */
90
+ export const isEditableLeaf = (element: HTMLElement): boolean => {
91
+ if (element.matches(MEDIA)) return false
92
+ if (element.getAttribute('data-u90-edit') === 'rich') return Boolean(element.textContent?.trim())
93
+
94
+ return (
95
+ !element.querySelector(`[${FIELD_ATTR}]`) &&
96
+ !element.querySelector(NON_TEXT) &&
97
+ Boolean(element.textContent?.trim())
98
+ )
99
+ }
100
+
101
+ /**
102
+ * The CSS spelling of `isEditableLeaf`, for the hover affordance.
103
+ *
104
+ * Built from the same constants so the I-beam and the behaviour cannot disagree about what
105
+ * is typable. `:is([data-u90-edit="rich"], …)` is the OR that mirrors the early return above: a
106
+ * rich field takes the I-beam without also having to clear the nested-marker and `NON_TEXT`
107
+ * clauses, which exist to protect an ordinary field and would otherwise disqualify a real
108
+ * document for the very links and lists it is allowed to contain.
109
+ *
110
+ * `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
111
+ * a picture, rich or not, and the caret has no business there either.
112
+ *
113
+ * The one clause CSS still cannot express is "and it has some text in it" — an empty marked
114
+ * element therefore offers an I-beam and does nothing when clicked, which is the least
115
+ * interesting way for the two to fall out of step and beats making every site component
116
+ * carry a class.
117
+ *
118
+ * v4 change: `[${RICH_ATTR}]` (legacy's presence selector for the standalone rich attribute)
119
+ * becomes the value selector `[data-u90-edit="rich"]`.
120
+ */
121
+ export const EDITABLE_LEAF_SELECTOR = `[${FIELD_ATTR}]:not(:is(${MEDIA})):is([data-u90-edit="rich"], :not(:has([${FIELD_ATTR}])):not(:has(${NON_TEXT})))`
122
+
123
+ /**
124
+ * A collapsed range at a viewport point, across both spellings of the same API.
125
+ *
126
+ * `caretPositionFromPoint` is the standard one and what Firefox has always had;
127
+ * `caretRangeFromPoint` is WebKit's older name for it, still what Safari ships. Chromium
128
+ * answers to both. Neither is guaranteed, hence the null.
129
+ */
130
+ export const caretFromPoint = (x: number, y: number): null | Range => {
131
+ const doc = document as Document & {
132
+ caretPositionFromPoint?: (x: number, y: number) => null | { offset: number; offsetNode: Node }
133
+ caretRangeFromPoint?: (x: number, y: number) => null | Range
134
+ }
135
+
136
+ if (typeof doc.caretPositionFromPoint === 'function') {
137
+ const position = doc.caretPositionFromPoint(x, y)
138
+ if (!position) return null
139
+ const range = document.createRange()
140
+ range.setStart(position.offsetNode, position.offset)
141
+ range.collapse(true)
142
+ return range
143
+ }
144
+
145
+ if (typeof doc.caretRangeFromPoint === 'function') {
146
+ const range = doc.caretRangeFromPoint(x, y)
147
+ range?.collapse(true)
148
+ return range
149
+ }
150
+
151
+ return null
152
+ }
153
+
154
+ /**
155
+ * Where the caret should go for a click at this point — resolved, not yet applied.
156
+ *
157
+ * ## Call this *before* making the element editable
158
+ *
159
+ * The split into resolve-then-apply is the whole point of this pair, and it is not
160
+ * stylistic. Both inputs it reads are changed by `contenteditable`:
161
+ *
162
+ * - The point lookup. Measured on Chromium against `<a>Contact Us<svg/></a>` laid out as
163
+ * `inline-flex`: with the element inert, x-positions across the label resolve to text
164
+ * offsets 9, 12, 13 as you would expect. Set `contenteditable` first and every one of
165
+ * them comes back as offset 0 instead. The caret then lands at the front of the label
166
+ * however carefully the client aimed — which was the original bug wearing a new hat.
167
+ * - Any selection the browser has already made. `focus()` on a freshly editable element
168
+ * can collapse it, and a double-click's word selection is exactly that.
169
+ *
170
+ * So: resolve here, make the element editable, then `applyCaret`. Both call sites in
171
+ * `createBridge`'s `onClick` do that in that order, and it is why they hold a `Range` across
172
+ * the two.
173
+ *
174
+ * An existing run of selected text wins over the point. A press-and-drag across three words
175
+ * *is* a selection and the click ending it must not collapse it to a caret; the same clause
176
+ * is what keeps a double-click meaning "replace this word".
177
+ */
178
+ export const caretTargetIn = (element: HTMLElement, x: number, y: number): Range => {
179
+ const selection = window.getSelection()
180
+
181
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
182
+ const existing = selection.getRangeAt(0)
183
+ // Cloned: the live range would be mutated by the focus that happens in between.
184
+ if (element.contains(existing.commonAncestorContainer)) return existing.cloneRange()
185
+ }
186
+
187
+ const fromPoint = caretFromPoint(x, y)
188
+ return fromPoint && landsInText(element, fromPoint) ? fromPoint : endOf(element)
189
+ }
190
+
191
+ /** Apply a range resolved by `caretTargetIn`, after the element has been made editable. */
192
+ export const applyCaret = (range: Range): void => {
193
+ const selection = window.getSelection()
194
+ if (!selection) return
195
+ selection.removeAllRanges()
196
+ selection.addRange(range)
197
+ }
198
+
199
+ /**
200
+ * Whether a point lookup found somewhere a caret can actually sit.
201
+ *
202
+ * Three things have to hold, and the third is why this is a function rather than a
203
+ * `contains` call. Every button on these sites is `<a>Contact Us<svg/></a>`, and clicking
204
+ * the chevron resolves to a `path` node — which *is* inside the marked element, so a
205
+ * containment check alone accepts it and the caret is placed inside the icon. Typing then
206
+ * goes nowhere the client can see. The svg check is belt-and-braces on top of the text-node
207
+ * one: an `<svg><text>` would satisfy both otherwise.
208
+ *
209
+ * Anything rejected falls back to the end of the words, which for a button label clicked on
210
+ * its icon is where you wanted the caret anyway.
211
+ */
212
+ const landsInText = (element: HTMLElement, range: Range): boolean => {
213
+ const container = range.startContainer
214
+ if (container.nodeType !== Node.TEXT_NODE) return false
215
+ if (!element.contains(container)) return false
216
+ return !container.parentElement?.closest('svg')
217
+ }
218
+
219
+ /**
220
+ * A collapsed range after the element's last visible character.
221
+ *
222
+ * **Not `selectNodeContents` + `collapse(false)`**, which is the obvious spelling and is
223
+ * wrong here. That produces a position anchored to the *element* just past its last child,
224
+ * and for `<a>Contact Us<svg/> </a>` the last child is collapsible whitespace after an icon
225
+ * — no visible caret position at all. Chromium normalises it, and measurably normalises it
226
+ * to the *front* of the element: typing after clicking the chevron inserted at the start of
227
+ * the label, which is the exact failure the fallback exists to avoid.
228
+ *
229
+ * So it anchors to the last text node that actually holds words, at the end of those words
230
+ * rather than the end of the node — trailing whitespace inside a text node is no better a
231
+ * caret position than trailing whitespace between them.
232
+ */
233
+ const endOf = (element: HTMLElement): Range => {
234
+ const range = document.createRange()
235
+ const last = lastWordsIn(element)
236
+
237
+ if (!last) {
238
+ // No text at all. `isEditableLeaf` rules this out for the click path; a direct caller
239
+ // gets the old behaviour, which is the only thing available.
240
+ range.selectNodeContents(element)
241
+ range.collapse(false)
242
+ return range
243
+ }
244
+
245
+ range.setStart(last, (last.textContent ?? '').replace(/\s+$/, '').length)
246
+ range.collapse(true)
247
+ return range
248
+ }
249
+
250
+ /** The last text node holding something other than whitespace, icons excluded. */
251
+ const lastWordsIn = (element: HTMLElement): null | Text => {
252
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
253
+ acceptNode: (node) =>
254
+ node.textContent?.trim() && !node.parentElement?.closest('svg')
255
+ ? NodeFilter.FILTER_ACCEPT
256
+ : NodeFilter.FILTER_REJECT,
257
+ })
258
+
259
+ let last: null | Text = null
260
+ while (walker.nextNode()) last = walker.currentNode as Text
261
+ return last
262
+ }
263
+
264
+ /**
265
+ * Formatting tag → the mark name `u90:caret`'s `marks` reports it as (`protocol.ts`). Named to
266
+ * match `lexicalDom.ts`'s own `MARK_BITS` naming (`bold`, `italic`, `underline`, `strikethrough`,
267
+ * `code`) rather than the tags themselves, since both spellings a browser inserts (`strong`/`b`,
268
+ * `em`/`i`) collapse to the one mark the Canvas's toolbar highlights; `a` reports as `link`.
269
+ */
270
+ const MARK_TAGS: Record<string, string> = {
271
+ a: 'link',
272
+ b: 'bold',
273
+ code: 'code',
274
+ em: 'italic',
275
+ i: 'italic',
276
+ s: 'strikethrough',
277
+ strong: 'bold',
278
+ u: 'underline',
279
+ }
280
+
281
+ /** Block tag → the `u90:caret`'s `block` name (`protocol.ts`). */
282
+ const BLOCK_TAGS: Record<string, 'paragraph' | 'h2' | 'h3' | 'h4' | 'list' | 'quote'> = {
283
+ blockquote: 'quote',
284
+ h2: 'h2',
285
+ h3: 'h3',
286
+ h4: 'h4',
287
+ li: 'list',
288
+ p: 'paragraph',
289
+ }
290
+
291
+ /**
292
+ * The formatting elements between `node` (the selection's anchor) and `root` (the field element
293
+ * `u90:format` applies to, exclusive) — the caret's `marks`. Walking ancestors rather than
294
+ * inspecting Lexical state: there is none here, only the live DOM a `contenteditable` selection
295
+ * sits in, so this reads the same tags `lexicalDom.ts`'s `MARK_BITS` inverts back out of it.
296
+ */
297
+ export const activeMarks = (node: Node, root: HTMLElement): string[] => {
298
+ const marks = new Set<string>()
299
+ let el: Element | null = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
300
+ while (el && el !== root && root.contains(el)) {
301
+ const mark = MARK_TAGS[el.tagName.toLowerCase()]
302
+ if (mark) marks.add(mark)
303
+ el = el.parentElement
304
+ }
305
+ return [...marks]
306
+ }
307
+
308
+ /** The nearest block element between `node` and `root` (exclusive) — the caret's `block`. */
309
+ export const activeBlock = (node: Node, root: HTMLElement): 'paragraph' | 'h2' | 'h3' | 'h4' | 'list' | 'quote' | undefined => {
310
+ let el: Element | null = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
311
+ while (el && el !== root && root.contains(el)) {
312
+ const block = BLOCK_TAGS[el.tagName.toLowerCase()]
313
+ if (block) return block
314
+ el = el.parentElement
315
+ }
316
+ return undefined
317
+ }