@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,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,273 @@
|
|
|
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: 'entries'; path: string; order: string[]; exclude: string[]; pin: string[] }
|
|
61
|
+
| { kind: 'move'; path: string; before: string | null }
|
|
62
|
+
| { kind: 'remove'; path: string }
|
|
63
|
+
| { kind: 'media'; path: string; src: string }
|
|
64
|
+
| { kind: 'text'; path: string; value: string }
|
|
65
|
+
/** The words inside a link; never replace its decorations or destination. */
|
|
66
|
+
| { kind: 'link-label'; path: string; value: string }
|
|
67
|
+
/** Plain paragraph edits from the inspector; formatted/resolved trees still come from the server. */
|
|
68
|
+
| { kind: 'rich'; path: string; paragraphs: string[] }
|
|
69
|
+
| { kind: 'insert'; path: string; before: string | null; html: string }
|
|
70
|
+
| { kind: 'replace'; path: string; html: string }
|
|
71
|
+
/**
|
|
72
|
+
* Asks the frame to fetch the rendered `block` from its own site's fragment page and put it where
|
|
73
|
+
* `path` is marked. The frame does the fetching because only it is same-origin with the site: the
|
|
74
|
+
* Canvas could not read that response, and the frame already carries the draft cookie. It is not
|
|
75
|
+
* applied by `applyPreview`; `createBridge` handles it.
|
|
76
|
+
*/
|
|
77
|
+
| { kind: 'fragment'; path: string; block: string }
|
|
78
|
+
|
|
79
|
+
const PREVIEW_KINDS = new Set(['entries', 'move', 'remove', 'media', 'text', 'link-label', 'rich', 'insert', 'replace', 'fragment'])
|
|
80
|
+
const isBefore = (v: unknown): boolean => v === null || typeof v === 'string'
|
|
81
|
+
|
|
82
|
+
/** Whether a `u90:preview`'s instruction has a known kind and a well-formed payload. */
|
|
83
|
+
const isPreviewInstruction = (value: unknown): value is PreviewInstruction => {
|
|
84
|
+
if (!(value && typeof value === 'object')) return false
|
|
85
|
+
const r = value as Record<string, unknown>
|
|
86
|
+
if (typeof r.kind !== 'string' || !PREVIEW_KINDS.has(r.kind) || typeof r.path !== 'string') return false
|
|
87
|
+
if (r.kind === 'entries') return [r.order, r.exclude, r.pin].every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))
|
|
88
|
+
if (r.kind === 'media') return typeof r.src === 'string'
|
|
89
|
+
if (r.kind === 'text' || r.kind === 'link-label') return typeof r.value === 'string'
|
|
90
|
+
if (r.kind === 'rich') return Array.isArray(r.paragraphs) && r.paragraphs.every((paragraph) => typeof paragraph === 'string')
|
|
91
|
+
if (r.kind === 'replace') return typeof r.html === 'string'
|
|
92
|
+
if (r.kind === 'fragment') return typeof r.block === 'string'
|
|
93
|
+
if (r.kind === 'insert') return typeof r.html === 'string' && isBefore(r.before)
|
|
94
|
+
if (r.kind === 'move') return isBefore(r.before)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Which page of which screen a message belongs to: the screen's frame key, the language it was
|
|
100
|
+
* loaded in (null for the original) and the count of its address changes. The Canvas sends one on
|
|
101
|
+
* `u90:hello` only when it shows languages, and the site then puts it on every message it sends, so
|
|
102
|
+
* the Canvas can drop what a screen's earlier page said. A message without one is a site on a kit
|
|
103
|
+
* before 1.3.0, or a Canvas that asked for none.
|
|
104
|
+
*/
|
|
105
|
+
export type BridgeStamp = { frame: string; locale: string | null; revision: number }
|
|
106
|
+
|
|
107
|
+
/** Every message's own fields: the version, and a stamp, which validates as the three together or none of them. */
|
|
108
|
+
type Base = { version: 4; frame?: string; locale?: string | null; revision?: number }
|
|
109
|
+
|
|
110
|
+
/** Whether a message carries no stamp, a well formed one, or something else in its place. */
|
|
111
|
+
const stampState = (record: Record<string, unknown>): 'none' | 'stamped' | 'malformed' => {
|
|
112
|
+
const { frame, locale, revision } = record
|
|
113
|
+
if (frame === undefined && locale === undefined && revision === undefined) return 'none'
|
|
114
|
+
const wellFormed =
|
|
115
|
+
typeof frame === 'string' &&
|
|
116
|
+
frame !== '' &&
|
|
117
|
+
(locale === null || typeof locale === 'string') &&
|
|
118
|
+
typeof revision === 'number' &&
|
|
119
|
+
Number.isSafeInteger(revision) &&
|
|
120
|
+
revision >= 0
|
|
121
|
+
return wellFormed ? 'stamped' : 'malformed'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Site → Canvas. */
|
|
125
|
+
export type SiteMessage = Base &
|
|
126
|
+
(
|
|
127
|
+
| { type: 'u90:ready'; path: string }
|
|
128
|
+
| { type: 'u90:path'; path: string }
|
|
129
|
+
| { type: 'u90:select'; path: string | null }
|
|
130
|
+
| { type: 'u90:hover'; path: string | null }
|
|
131
|
+
| { type: 'u90:edit'; path: string; kind: 'text' | 'rich' | 'link-label'; value: unknown }
|
|
132
|
+
| {
|
|
133
|
+
type: 'u90:caret'
|
|
134
|
+
path: string
|
|
135
|
+
top: number
|
|
136
|
+
left: number
|
|
137
|
+
width: number
|
|
138
|
+
height: number
|
|
139
|
+
marks?: string[]
|
|
140
|
+
block?: CaretBlock
|
|
141
|
+
field?: { top: number; left: number; width: number; height: number }
|
|
142
|
+
}
|
|
143
|
+
| { type: 'u90:flushed'; requestId: string }
|
|
144
|
+
| { type: 'u90:height'; height: number }
|
|
145
|
+
| { type: 'u90:scrolled'; top: number; max: number }
|
|
146
|
+
| { type: 'u90:navigate'; path: string }
|
|
147
|
+
| { type: 'u90:zoom'; deltaY: number; x: number; y: number }
|
|
148
|
+
| { type: 'u90:wheel'; deltaX: number; deltaY: number }
|
|
149
|
+
| { type: 'u90:grab'; screenX: number; screenY: number }
|
|
150
|
+
| { type: 'u90:drag'; screenX: number; screenY: number }
|
|
151
|
+
| { type: 'u90:drop' }
|
|
152
|
+
| { type: 'u90:key'; code: string; down: boolean }
|
|
153
|
+
/** 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). */
|
|
154
|
+
| { type: 'u90:direction'; dir: 'rtl'; overflow: number; offenders: string[] }
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
/** Canvas → Site. */
|
|
158
|
+
export type CanvasMessage = Base &
|
|
159
|
+
(
|
|
160
|
+
| { type: 'u90:refresh' }
|
|
161
|
+
| { type: 'u90:select'; path: string | null }
|
|
162
|
+
| { type: 'u90:flush'; requestId: string }
|
|
163
|
+
| { type: 'u90:format'; action: string; url?: string; to?: LinkTo; newTab?: boolean }
|
|
164
|
+
| { type: 'u90:scroll'; to: 'top' | 'fraction'; fraction?: number }
|
|
165
|
+
| { type: 'u90:viewport'; height: number | null }
|
|
166
|
+
| { type: 'u90:navigation'; enabled: boolean }
|
|
167
|
+
/**
|
|
168
|
+
* Whether this screen is the one the Canvas has selected.
|
|
169
|
+
*
|
|
170
|
+
* Restores the single thing v3's `isActive` was load-bearing for and v4's first table left
|
|
171
|
+
* out. The wheel is the case: an active screen's page scrolls, an inactive one's wheel pans
|
|
172
|
+
* the Canvas instead, and only the site can stop its own page scrolling — `preventDefault`
|
|
173
|
+
* inside the frame is not something the Canvas can do for it. Without this message the site
|
|
174
|
+
* cannot tell the two apart, so it forwarded every wheel AND let the page scroll, and one
|
|
175
|
+
* gesture moved both.
|
|
176
|
+
*
|
|
177
|
+
* Sent to every frame whenever the selection changes, so each knows its own state and no
|
|
178
|
+
* frame is told about the others. A site that does not know it ignores it, as it does any
|
|
179
|
+
* message it does not parse — such a site keeps the behaviour it has today.
|
|
180
|
+
*/
|
|
181
|
+
| { type: 'u90:active'; active: boolean }
|
|
182
|
+
/**
|
|
183
|
+
* Asks the page to say it is ready again: answered with `u90:ready` and the path it is on now.
|
|
184
|
+
* The Canvas sends it when it registers a frame whose page had already loaded, since a
|
|
185
|
+
* `u90:ready` sent before the Canvas was listening is lost. A site that does not know it ignores
|
|
186
|
+
* it, as it does any message it does not parse.
|
|
187
|
+
*/
|
|
188
|
+
| { type: 'u90:hello' }
|
|
189
|
+
| { type: 'u90:preview'; instruction: PreviewInstruction }
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const CANVAS_TYPES = new Set([
|
|
193
|
+
'u90:hello',
|
|
194
|
+
'u90:preview',
|
|
195
|
+
'u90:refresh',
|
|
196
|
+
'u90:select',
|
|
197
|
+
'u90:flush',
|
|
198
|
+
'u90:format',
|
|
199
|
+
'u90:scroll',
|
|
200
|
+
'u90:viewport',
|
|
201
|
+
'u90:navigation',
|
|
202
|
+
'u90:active',
|
|
203
|
+
])
|
|
204
|
+
|
|
205
|
+
/** Whether a `u90:format` payload's optional `to`/`newTab` are each either absent or well formed. */
|
|
206
|
+
const isValidFormat = (data: Record<string, unknown>): boolean => {
|
|
207
|
+
if ('to' in data && data.to !== undefined && !isBridgeLinkTarget(data.to)) return false
|
|
208
|
+
if ('newTab' in data && data.newTab !== undefined && typeof data.newTab !== 'boolean') return false
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A message from the Canvas, accepted only at the current version and a known type. */
|
|
213
|
+
export const parseCanvasMessage = (data: unknown): CanvasMessage | null => {
|
|
214
|
+
if (!(data && typeof data === 'object' && (data as { version?: unknown }).version === 4)) return null
|
|
215
|
+
const record = data as Record<string, unknown>
|
|
216
|
+
if (!CANVAS_TYPES.has(String(record.type))) return null
|
|
217
|
+
if (stampState(record) === 'malformed') return null
|
|
218
|
+
if (record.type === 'u90:format' && !isValidFormat(record)) return null
|
|
219
|
+
if (record.type === 'u90:preview' && !isPreviewInstruction(record.instruction)) return null
|
|
220
|
+
return data as CanvasMessage
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const SITE_TYPES = new Set([
|
|
224
|
+
'u90:ready',
|
|
225
|
+
'u90:path',
|
|
226
|
+
'u90:select',
|
|
227
|
+
'u90:hover',
|
|
228
|
+
'u90:edit',
|
|
229
|
+
'u90:caret',
|
|
230
|
+
'u90:flushed',
|
|
231
|
+
'u90:height',
|
|
232
|
+
'u90:scrolled',
|
|
233
|
+
'u90:navigate',
|
|
234
|
+
'u90:zoom',
|
|
235
|
+
'u90:wheel',
|
|
236
|
+
'u90:grab',
|
|
237
|
+
'u90:drag',
|
|
238
|
+
'u90:drop',
|
|
239
|
+
'u90:key',
|
|
240
|
+
'u90:direction',
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
/** Whether a `u90:caret` payload's optional `marks`/`block`/`field` are each either absent or well formed. */
|
|
244
|
+
const isValidCaret = (data: Record<string, unknown>): boolean => {
|
|
245
|
+
if ('marks' in data && data.marks !== undefined && !(Array.isArray(data.marks) && data.marks.every((m) => typeof m === 'string'))) return false
|
|
246
|
+
if ('block' in data && data.block !== undefined && !CARET_BLOCKS.has(data.block as CaretBlock)) return false
|
|
247
|
+
if ('field' in data && data.field !== undefined && !isRect(data.field)) return false
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The most fields a right-to-left check names, and the longest field path it may carry. */
|
|
252
|
+
export const MAX_OFFENDERS = 20
|
|
253
|
+
export const MAX_OFFENDER_LENGTH = 512
|
|
254
|
+
/** 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. */
|
|
255
|
+
const isValidDirection = (data: Record<string, unknown>): boolean =>
|
|
256
|
+
data.dir === 'rtl' &&
|
|
257
|
+
typeof data.overflow === 'number' &&
|
|
258
|
+
Number.isFinite(data.overflow) &&
|
|
259
|
+
data.overflow >= 0 &&
|
|
260
|
+
Array.isArray(data.offenders) &&
|
|
261
|
+
data.offenders.length <= MAX_OFFENDERS &&
|
|
262
|
+
data.offenders.every((o) => typeof o === 'string' && o.length <= MAX_OFFENDER_LENGTH)
|
|
263
|
+
|
|
264
|
+
/** A message from a site frame, accepted only at the current version and a known type. */
|
|
265
|
+
export const isSiteMessage = (data: unknown): data is SiteMessage => {
|
|
266
|
+
if (!(data && typeof data === 'object' && (data as { version?: unknown }).version === 4)) return false
|
|
267
|
+
const record = data as Record<string, unknown>
|
|
268
|
+
if (!SITE_TYPES.has(String(record.type))) return false
|
|
269
|
+
if (stampState(record) === 'malformed') return false
|
|
270
|
+
if (record.type === 'u90:caret' && !isValidCaret(record)) return false
|
|
271
|
+
if (record.type === 'u90:direction' && !isValidDirection(record)) return false
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EntryPicks } from './DraftEntries'
|
|
2
|
+
import type { Bridge } from './createBridge'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one shared, module-level handle onto the live bridge instance and its reveal listeners.
|
|
6
|
+
*
|
|
7
|
+
* No React, no Next — this file exists purely so `useReveal` (in `hooks.tsx`) can register a
|
|
8
|
+
* listener without statically importing `./client` or `./createBridge`. Those two pull in
|
|
9
|
+
* `next/navigation` and the whole DOM-level bridge respectively; `EditingProvider` already keeps
|
|
10
|
+
* that chunk out of a production bundle by fetching it with `lazy(() => import('./client'))`
|
|
11
|
+
* (see `hooks.tsx`), and only when `editing`. A site component that calls `useReveal` (Task 8's
|
|
12
|
+
* header, for one) sits beside that provider in the same client bundle, so if `useReveal` itself
|
|
13
|
+
* imported `./client` or `./createBridge` eagerly, using the hook at all would pull the bridge
|
|
14
|
+
* chunk back in on every render, draft or not — this indirection is what keeps `useReveal` cheap.
|
|
15
|
+
*
|
|
16
|
+
* `current` is set by `createBridge(...).start()` and cleared by `.stop()`. `reveals` is the
|
|
17
|
+
* live set `createBridge`'s `u90:select` handler walks — `useReveal` adds and removes its own
|
|
18
|
+
* `{ prefix, fn }` entry directly, so revealing works whether or not a bridge is mounted yet.
|
|
19
|
+
*/
|
|
20
|
+
export const registry: {
|
|
21
|
+
current: Bridge | null
|
|
22
|
+
reveals: Set<{ prefix: string; fn: () => void }>
|
|
23
|
+
entryLists: Set<{ document: Document; beforeRefresh(): void; pick(path: string, picks: EntryPicks): boolean }>
|
|
24
|
+
blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; move(path: string, before: string | null): boolean }>
|
|
25
|
+
} = {
|
|
26
|
+
current: null,
|
|
27
|
+
reveals: new Set(),
|
|
28
|
+
blockOrders: new Set(),
|
|
29
|
+
entryLists: new Set(),
|
|
30
|
+
}
|
|
@@ -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,16 @@
|
|
|
1
|
+
import { Fragment, type HTMLAttributes, type ReactNode } from 'react'
|
|
2
|
+
import type { EntryRead } from '../elements/define'
|
|
3
|
+
import type { EntriesRead } from '../elements/leaves/selection'
|
|
4
|
+
import { DraftEntries } from '../bridge/DraftEntries'
|
|
5
|
+
|
|
6
|
+
/** A placement-aware list of server-rendered cards. Draft picks update immediately in Canvas;
|
|
7
|
+
* published lists remain ordinary server markup. The renderer never crosses the client boundary. */
|
|
8
|
+
export function Entries({ value, children, empty, ...props }: Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
9
|
+
value: EntriesRead
|
|
10
|
+
children: (entry: EntryRead) => ReactNode
|
|
11
|
+
empty?: ReactNode
|
|
12
|
+
}) {
|
|
13
|
+
const items = value.entries.map(entry => ({ id: String(entry.id), content: children(entry) }))
|
|
14
|
+
if (value.$path) return <DraftEntries key={value.$path} path={value.$path} items={items} pins={value.$pins ?? []} empty={empty} {...props} />
|
|
15
|
+
return <div {...props}>{items.length ? items.map(item => <Fragment key={item.id}>{item.content}</Fragment>) : empty}</div>
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|