@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.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,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
|
+
// ` ` 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
|
+
}
|