@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -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 +1010 -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 +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -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 +12 -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,246 @@
|
|
|
1
|
+
import { FIELD_ATTR, RICH_ATTR } from './shape'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which marked elements take a caret, and where the caret goes.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `bridge.tsx` for two reasons. It is the only part of the editing behaviour
|
|
7
|
+
* that is pure DOM — no React, no message protocol, no closure over the bridge's state — so
|
|
8
|
+
* it can be loaded and driven on its own, which is what `verify:caret` does: it runs these
|
|
9
|
+
* exact functions in a real browser against a real layout, rather than a transcription of
|
|
10
|
+
* them that could drift. And `bridge.tsx` was over a thousand lines.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What an annotated element must not contain if it is to be typed into.
|
|
15
|
+
*
|
|
16
|
+
* A marked element holding an image or a video is a media field or a card, not words: its
|
|
17
|
+
* value is an upload id, and the caret has no business there. A nested control or link is
|
|
18
|
+
* excluded for a different reason — its own text is a separate field, or markup a plain
|
|
19
|
+
* string cannot round-trip, so editing the parent as one blob would flatten it.
|
|
20
|
+
*
|
|
21
|
+
* **`svg` is deliberately absent.** Every button on these sites is `<a>Contact Us<svg/></a>`
|
|
22
|
+
* — the label and its chevron — and excluding icons would have made the most obviously
|
|
23
|
+
* editable thing on the page the one thing you could not edit. `innerText` ignores an svg's
|
|
24
|
+
* contents, so the value is the words alone; and if a client deletes the icon while typing,
|
|
25
|
+
* the re-render on blur puts it straight back, because the icon was never theirs to store.
|
|
26
|
+
*
|
|
27
|
+
* The check is on *descendants* — an `<a>` that is itself the marked element is fine.
|
|
28
|
+
*/
|
|
29
|
+
export const NON_TEXT = 'img, picture, video, canvas, iframe, input, textarea, select, button, a'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The tags that are a picture rather than words — a deliberate *subset* of `NON_TEXT`.
|
|
33
|
+
*
|
|
34
|
+
* Two lists rather than one, and the difference is `a` and `button`. `NON_TEXT` answers "may
|
|
35
|
+
* this element *contain* something untypable", and a marked button label is both a control and
|
|
36
|
+
* a text leaf: with navigation off it takes a caret, which is how a client edits a button's
|
|
37
|
+
* words at all. Excluding every non-text tag from editability would therefore make every
|
|
38
|
+
* caption on every button uneditable in place — a far worse bug than the one this fixes.
|
|
39
|
+
*
|
|
40
|
+
* This list answers a different question: "*is* this element a picture". `NON_TEXT` could never
|
|
41
|
+
* answer it, because it is only ever asked about descendants — `querySelector`, `:has()` — and
|
|
42
|
+
* an `<img>` contains no `img`. So a marked image satisfied every editable-leaf test and
|
|
43
|
+
* offered an I-beam over a photograph.
|
|
44
|
+
*/
|
|
45
|
+
export const MEDIA = 'img, picture, video, canvas, iframe'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A marked element that is itself a picture.
|
|
49
|
+
*
|
|
50
|
+
* Keyed on the element's own tag rather than on what it contains, because the two spellings a
|
|
51
|
+
* site uses want different treatment: a marker *on* the image needs its affordance drawn inside
|
|
52
|
+
* itself, while a marker on a wrapper is normally the clipping frame and its outline is already
|
|
53
|
+
* visible outside it.
|
|
54
|
+
*/
|
|
55
|
+
export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether this annotated element is a leaf holding text, and so directly editable.
|
|
59
|
+
*
|
|
60
|
+
* Leaf means it contains no other annotated element. A container — a card marked as a
|
|
61
|
+
* whole, a section wrapping four marked children — would otherwise become one editable
|
|
62
|
+
* blob whose text belongs to several fields at once, and the first keystroke would write
|
|
63
|
+
* all of them into one.
|
|
64
|
+
*
|
|
65
|
+
* **A rich field is accepted here early**, ahead of the nested-marker and `NON_TEXT`
|
|
66
|
+
* clauses below, and that ordering is the point: a real document contains `<a>` and `<ul>`,
|
|
67
|
+
* and both of those clauses exist to stop an *ordinary* field being edited as one blob —
|
|
68
|
+
* applied to a rich field they would reject it for exactly the structure it is allowed to
|
|
69
|
+
* hold. The requirement was never "a rich field must not be edited in place"; a client
|
|
70
|
+
* choosing to type into their own heading is the same client choosing to type into their
|
|
71
|
+
* own paragraph. The requirement is "a rich field must never be read back as plain words",
|
|
72
|
+
* and that guarantee is the bridge's job — see `flushEdit`'s branch on `RICH_ATTR` — not
|
|
73
|
+
* this function's.
|
|
74
|
+
*
|
|
75
|
+
* The `MEDIA` clause still runs unconditionally, ahead of the rich check: a marker on an
|
|
76
|
+
* image is a picture whatever else it carries, and the caret has no business there.
|
|
77
|
+
*/
|
|
78
|
+
export const isEditableLeaf = (element: HTMLElement): boolean => {
|
|
79
|
+
if (element.matches(MEDIA)) return false
|
|
80
|
+
if (element.hasAttribute(RICH_ATTR)) return Boolean(element.textContent?.trim())
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
!element.querySelector(`[${FIELD_ATTR}]`) &&
|
|
84
|
+
!element.querySelector(NON_TEXT) &&
|
|
85
|
+
Boolean(element.textContent?.trim())
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The CSS spelling of `isEditableLeaf`, for the hover affordance.
|
|
91
|
+
*
|
|
92
|
+
* Built from the same constants so the I-beam and the behaviour cannot disagree about what
|
|
93
|
+
* is typable. `:is([${RICH_ATTR}], …)` is the OR that mirrors the early return above: a
|
|
94
|
+
* rich field takes the I-beam without also having to clear the nested-marker and `NON_TEXT`
|
|
95
|
+
* clauses, which exist to protect an ordinary field and would otherwise disqualify a real
|
|
96
|
+
* document for the very links and lists it is allowed to contain.
|
|
97
|
+
*
|
|
98
|
+
* `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
|
|
99
|
+
* a picture, rich or not, and the caret has no business there either.
|
|
100
|
+
*
|
|
101
|
+
* The one clause CSS still cannot express is "and it has some text in it" — an empty marked
|
|
102
|
+
* element therefore offers an I-beam and does nothing when clicked, which is the least
|
|
103
|
+
* interesting way for the two to fall out of step and beats making every site component
|
|
104
|
+
* carry a class.
|
|
105
|
+
*/
|
|
106
|
+
export const EDITABLE_LEAF_SELECTOR = `[${FIELD_ATTR}]:not(:is(${MEDIA})):is([${RICH_ATTR}], :not(:has([${FIELD_ATTR}])):not(:has(${NON_TEXT})))`
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A collapsed range at a viewport point, across both spellings of the same API.
|
|
110
|
+
*
|
|
111
|
+
* `caretPositionFromPoint` is the standard one and what Firefox has always had;
|
|
112
|
+
* `caretRangeFromPoint` is WebKit's older name for it, still what Safari ships. Chromium
|
|
113
|
+
* answers to both. Neither is guaranteed, hence the null.
|
|
114
|
+
*/
|
|
115
|
+
export const caretFromPoint = (x: number, y: number): null | Range => {
|
|
116
|
+
const doc = document as Document & {
|
|
117
|
+
caretPositionFromPoint?: (x: number, y: number) => null | { offset: number; offsetNode: Node }
|
|
118
|
+
caretRangeFromPoint?: (x: number, y: number) => null | Range
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof doc.caretPositionFromPoint === 'function') {
|
|
122
|
+
const position = doc.caretPositionFromPoint(x, y)
|
|
123
|
+
if (!position) return null
|
|
124
|
+
const range = document.createRange()
|
|
125
|
+
range.setStart(position.offsetNode, position.offset)
|
|
126
|
+
range.collapse(true)
|
|
127
|
+
return range
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof doc.caretRangeFromPoint === 'function') {
|
|
131
|
+
const range = doc.caretRangeFromPoint(x, y)
|
|
132
|
+
range?.collapse(true)
|
|
133
|
+
return range
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where the caret should go for a click at this point — resolved, not yet applied.
|
|
141
|
+
*
|
|
142
|
+
* ## Call this *before* making the element editable
|
|
143
|
+
*
|
|
144
|
+
* The split into resolve-then-apply is the whole point of this pair, and it is not
|
|
145
|
+
* stylistic. Both inputs it reads are changed by `contenteditable`:
|
|
146
|
+
*
|
|
147
|
+
* - The point lookup. Measured on Chromium against `<a>Contact Us<svg/></a>` laid out as
|
|
148
|
+
* `inline-flex`: with the element inert, x-positions across the label resolve to text
|
|
149
|
+
* offsets 9, 12, 13 as you would expect. Set `contenteditable` first and every one of
|
|
150
|
+
* them comes back as offset 0 instead. The caret then lands at the front of the label
|
|
151
|
+
* however carefully the client aimed — which was the original bug wearing a new hat.
|
|
152
|
+
* - Any selection the browser has already made. `focus()` on a freshly editable element
|
|
153
|
+
* can collapse it, and a double-click's word selection is exactly that.
|
|
154
|
+
*
|
|
155
|
+
* So: resolve here, `beginEditing`, then `applyCaret`. Both call sites in `bridge.tsx` do
|
|
156
|
+
* that in that order, and it is why they hold a `Range` across the two.
|
|
157
|
+
*
|
|
158
|
+
* An existing run of selected text wins over the point. A press-and-drag across three words
|
|
159
|
+
* *is* a selection and the click ending it must not collapse it to a caret; the same clause
|
|
160
|
+
* is what keeps a double-click meaning "replace this word".
|
|
161
|
+
*/
|
|
162
|
+
export const caretTargetIn = (element: HTMLElement, x: number, y: number): Range => {
|
|
163
|
+
const selection = window.getSelection()
|
|
164
|
+
|
|
165
|
+
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
|
|
166
|
+
const existing = selection.getRangeAt(0)
|
|
167
|
+
// Cloned: the live range would be mutated by the focus that happens in between.
|
|
168
|
+
if (element.contains(existing.commonAncestorContainer)) return existing.cloneRange()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fromPoint = caretFromPoint(x, y)
|
|
172
|
+
return fromPoint && landsInText(element, fromPoint) ? fromPoint : endOf(element)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Apply a range resolved by `caretTargetIn`, after the element has been made editable. */
|
|
176
|
+
export const applyCaret = (range: Range): void => {
|
|
177
|
+
const selection = window.getSelection()
|
|
178
|
+
if (!selection) return
|
|
179
|
+
selection.removeAllRanges()
|
|
180
|
+
selection.addRange(range)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether a point lookup found somewhere a caret can actually sit.
|
|
185
|
+
*
|
|
186
|
+
* Three things have to hold, and the third is why this is a function rather than a
|
|
187
|
+
* `contains` call. Every button on these sites is `<a>Contact Us<svg/></a>`, and clicking
|
|
188
|
+
* the chevron resolves to a `path` node — which *is* inside the marked element, so a
|
|
189
|
+
* containment check alone accepts it and the caret is placed inside the icon. Typing then
|
|
190
|
+
* goes nowhere the client can see. The svg check is belt-and-braces on top of the text-node
|
|
191
|
+
* one: an `<svg><text>` would satisfy both otherwise.
|
|
192
|
+
*
|
|
193
|
+
* Anything rejected falls back to the end of the words, which for a button label clicked on
|
|
194
|
+
* its icon is where you wanted the caret anyway.
|
|
195
|
+
*/
|
|
196
|
+
const landsInText = (element: HTMLElement, range: Range): boolean => {
|
|
197
|
+
const container = range.startContainer
|
|
198
|
+
if (container.nodeType !== Node.TEXT_NODE) return false
|
|
199
|
+
if (!element.contains(container)) return false
|
|
200
|
+
return !container.parentElement?.closest('svg')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A collapsed range after the element's last visible character.
|
|
205
|
+
*
|
|
206
|
+
* **Not `selectNodeContents` + `collapse(false)`**, which is the obvious spelling and is
|
|
207
|
+
* wrong here. That produces a position anchored to the *element* just past its last child,
|
|
208
|
+
* and for `<a>Contact Us<svg/> </a>` the last child is collapsible whitespace after an icon
|
|
209
|
+
* — no visible caret position at all. Chromium normalises it, and measurably normalises it
|
|
210
|
+
* to the *front* of the element: typing after clicking the chevron inserted at the start of
|
|
211
|
+
* the label, which is the exact failure the fallback exists to avoid.
|
|
212
|
+
*
|
|
213
|
+
* So it anchors to the last text node that actually holds words, at the end of those words
|
|
214
|
+
* rather than the end of the node — trailing whitespace inside a text node is no better a
|
|
215
|
+
* caret position than trailing whitespace between them.
|
|
216
|
+
*/
|
|
217
|
+
const endOf = (element: HTMLElement): Range => {
|
|
218
|
+
const range = document.createRange()
|
|
219
|
+
const last = lastWordsIn(element)
|
|
220
|
+
|
|
221
|
+
if (!last) {
|
|
222
|
+
// No text at all. `isEditableLeaf` rules this out for the click path; a direct caller
|
|
223
|
+
// gets the old behaviour, which is the only thing available.
|
|
224
|
+
range.selectNodeContents(element)
|
|
225
|
+
range.collapse(false)
|
|
226
|
+
return range
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
range.setStart(last, (last.textContent ?? '').replace(/\s+$/, '').length)
|
|
230
|
+
range.collapse(true)
|
|
231
|
+
return range
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The last text node holding something other than whitespace, icons excluded. */
|
|
235
|
+
const lastWordsIn = (element: HTMLElement): null | Text => {
|
|
236
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
|
|
237
|
+
acceptNode: (node) =>
|
|
238
|
+
node.textContent?.trim() && !node.parentElement?.closest('svg')
|
|
239
|
+
? NodeFilter.FILTER_ACCEPT
|
|
240
|
+
: NodeFilter.FILTER_REJECT,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
let last: null | Text = null
|
|
244
|
+
while (walker.nextNode()) last = walker.currentNode as Text
|
|
245
|
+
return last
|
|
246
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `npx --yes @unitninety/u90@1.0.0 pair <code>` — the one command an operator hands over. The u90 tool
|
|
6
|
+
* runs this file for `u90 pair`. The command is scoped and pinned because an unscoped name, run in a
|
|
7
|
+
* repository with no local bin, fetches whatever package npm holds under that name.
|
|
8
|
+
*
|
|
9
|
+
* Claims this site's credentials, writes them to `.env.local`, then tells the platform
|
|
10
|
+
* where the site lives. Two calls rather than one, because the platform *fetches* the
|
|
11
|
+
* manifest instead of accepting one — and it can only authenticate that fetch once this
|
|
12
|
+
* site holds the secret.
|
|
13
|
+
*
|
|
14
|
+
* Nothing is printed that shouldn't be: the API key and revalidate secret go to the file
|
|
15
|
+
* and never to stdout, because a terminal scrollback is exactly the kind of place
|
|
16
|
+
* secrets survive in.
|
|
17
|
+
*
|
|
18
|
+
* **Plain `.mjs`, and dependency-free — `node:` builtins only.** Both on purpose. This
|
|
19
|
+
* is the first thing that runs in a fresh repo, so it cannot need an install step or a
|
|
20
|
+
* build step to work. `.ts` would have been fine on Node 22.18+, where type stripping is
|
|
21
|
+
* unflagged, and would have failed with `Unknown file extension ".ts"` on the Node 20
|
|
22
|
+
* that Next still supports — a bootstrap tool that only starts on new runtimes is a
|
|
23
|
+
* bootstrap tool with a bootstrap problem. The one type it used (`PairedCredentials`,
|
|
24
|
+
* in `contract.ts`) was erased at runtime anyway.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const ENV_FILE = '.env.local'
|
|
28
|
+
|
|
29
|
+
/** @returns {never} */
|
|
30
|
+
const fail = (message) => {
|
|
31
|
+
console.error(`\n ${message}\n`)
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Which platform to pair against. The code doesn't carry it. */
|
|
36
|
+
const platformFromArgs = (args) => {
|
|
37
|
+
const flag = args.findIndex((arg) => arg === '--platform')
|
|
38
|
+
if (flag !== -1 && args[flag + 1]) return args[flag + 1].replace(/\/$/, '')
|
|
39
|
+
if (process.env.PLATFORM_URL) return process.env.PLATFORM_URL.replace(/\/$/, '')
|
|
40
|
+
return 'https://run.unitninety.com'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** This site's public origin, which the platform has to be able to reach. */
|
|
44
|
+
const siteUrlFromArgs = (args) => {
|
|
45
|
+
const flag = args.findIndex((arg) => arg === '--site')
|
|
46
|
+
if (flag !== -1 && args[flag + 1]) return args[flag + 1].replace(/\/$/, '')
|
|
47
|
+
if (process.env.SITE_URL) return process.env.SITE_URL.replace(/\/$/, '')
|
|
48
|
+
return fail(
|
|
49
|
+
"This site's public URL is needed, so the platform can fetch its content schema.\n" +
|
|
50
|
+
' Pass --site https://example.com (or set SITE_URL).',
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The four keys pairing owns. Everything else in the file is left untouched. */
|
|
55
|
+
const PAIRED_KEYS = ['PLATFORM_URL', 'PAYLOAD_API_KEY', 'REVALIDATE_SECRET', 'SITE_URL']
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write the paired values, replacing any this command set before.
|
|
59
|
+
*
|
|
60
|
+
* **Superseded, not deleted.** This used to append and leave the old block in place,
|
|
61
|
+
* reasoning that silently editing someone's env file is how a working deployment breaks at
|
|
62
|
+
* 5pm — dotenv takes the last value, so appending was both effective and reversible.
|
|
63
|
+
*
|
|
64
|
+
* True, and it cost an hour anyway. Pair a site twice and every key appears twice with
|
|
65
|
+
* different values, the dead block reads exactly like the live one, and the first thing
|
|
66
|
+
* anyone debugging a 401 does is read the file from the top. That is a worse trap than the
|
|
67
|
+
* one the appending avoided, because it is silent and it looks fine.
|
|
68
|
+
*
|
|
69
|
+
* So old values are commented out where they stand, with the date that retired them, and
|
|
70
|
+
* the new block goes on the end. One live value per key, nothing destroyed, and going back
|
|
71
|
+
* is uncommenting a line.
|
|
72
|
+
*/
|
|
73
|
+
const writeEnv = async (credentials, siteUrl, platform) => {
|
|
74
|
+
const existing = await readFile(ENV_FILE, 'utf8').catch(() => '')
|
|
75
|
+
const stamp = new Date().toISOString()
|
|
76
|
+
|
|
77
|
+
const superseded = []
|
|
78
|
+
|
|
79
|
+
const retired = existing
|
|
80
|
+
.split('\n')
|
|
81
|
+
.map((line) => {
|
|
82
|
+
const key = PAIRED_KEYS.find((candidate) => line.startsWith(`${candidate}=`))
|
|
83
|
+
if (!key) return line
|
|
84
|
+
superseded.push(key)
|
|
85
|
+
// Kept in full, including the value: this is a gitignored local file, and a
|
|
86
|
+
// recoverable old key is the whole point of not simply dropping the line.
|
|
87
|
+
return `# superseded by \`u90 pair\` on ${stamp}: ${line}`
|
|
88
|
+
})
|
|
89
|
+
.join('\n')
|
|
90
|
+
|
|
91
|
+
if (superseded.length > 0) {
|
|
92
|
+
console.log(
|
|
93
|
+
`\n Note: ${ENV_FILE} already set ${[...new Set(superseded)].join(', ')}.\n` +
|
|
94
|
+
' Those lines are now commented out with today’s date, and the new values are below them.',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await writeFile(
|
|
99
|
+
ENV_FILE,
|
|
100
|
+
[
|
|
101
|
+
retired.replace(/\n+$/, ''),
|
|
102
|
+
'',
|
|
103
|
+
`# Added by \`u90 pair\` on ${stamp}`,
|
|
104
|
+
`PLATFORM_URL=${credentials.platformUrl || platform}`,
|
|
105
|
+
`PAYLOAD_API_KEY=${credentials.apiKey}`,
|
|
106
|
+
`REVALIDATE_SECRET=${credentials.revalidateSecret}`,
|
|
107
|
+
`SITE_URL=${siteUrl}`,
|
|
108
|
+
'',
|
|
109
|
+
].join('\n'),
|
|
110
|
+
'utf8',
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const run = async () => {
|
|
115
|
+
const args = process.argv.slice(2)
|
|
116
|
+
|
|
117
|
+
if (args[0] !== 'pair' || !args[1]) {
|
|
118
|
+
fail('Usage: npx --yes @unitninety/u90@1.0.0 pair <code> --site https://example.com [--platform https://cms…]')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const code = args[1]
|
|
122
|
+
const platform = platformFromArgs(args)
|
|
123
|
+
const siteUrl = siteUrlFromArgs(args)
|
|
124
|
+
|
|
125
|
+
console.log(`\n Pairing with ${platform}…`)
|
|
126
|
+
|
|
127
|
+
const paired = await fetch(`${platform}/api/u90/pair`, {
|
|
128
|
+
body: JSON.stringify({ code }),
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
method: 'POST',
|
|
131
|
+
}).catch(() => fail(`Could not reach ${platform}.`))
|
|
132
|
+
|
|
133
|
+
if (!paired.ok) {
|
|
134
|
+
const body = await paired.json().catch(() => ({}))
|
|
135
|
+
fail(
|
|
136
|
+
body.error ??
|
|
137
|
+
`The platform refused the pairing code (HTTP ${paired.status}). Codes are single use and expire after 15 minutes — ask for a new one.`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const credentials = await paired.json()
|
|
142
|
+
|
|
143
|
+
await writeEnv(credentials, siteUrl, platform)
|
|
144
|
+
console.log(` Credentials written to ${ENV_FILE}.`)
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* Registration happens *after* the env is written, on purpose. The platform fetches
|
|
148
|
+
* this site's manifest during registration, and that fetch is authenticated with the
|
|
149
|
+
* secret we have only just received — so a site that hasn't stored it yet cannot be
|
|
150
|
+
* verified.
|
|
151
|
+
*/
|
|
152
|
+
console.log(` Registering ${siteUrl} and publishing the content schema…`)
|
|
153
|
+
|
|
154
|
+
const registered = await fetch(`${platform}/api/u90/register`, {
|
|
155
|
+
body: JSON.stringify({ siteUrl }),
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: `Bearer ${credentials.revalidateSecret}`,
|
|
158
|
+
'Content-Type': 'application/json',
|
|
159
|
+
},
|
|
160
|
+
method: 'POST',
|
|
161
|
+
}).catch(() => fail(`Could not reach ${platform} to register.`))
|
|
162
|
+
|
|
163
|
+
const result = await registered.json().catch(() => ({}))
|
|
164
|
+
|
|
165
|
+
if (!registered.ok) {
|
|
166
|
+
fail(
|
|
167
|
+
`${result.error ?? `Registration failed (HTTP ${registered.status}).`}\n\n` +
|
|
168
|
+
' The credentials are saved, so once the site is reachable at that URL you can\n' +
|
|
169
|
+
` finish with: curl -X POST ${platform}/api/u90/register \\\n` +
|
|
170
|
+
' -H "Authorization: Bearer $REVALIDATE_SECRET" \\\n' +
|
|
171
|
+
` -H 'Content-Type: application/json' -d '{"siteUrl":"${siteUrl}"}'`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log(`
|
|
176
|
+
────────────────────────────────────────────────────────────────────────
|
|
177
|
+
${credentials.tenantSlug} is connected.
|
|
178
|
+
|
|
179
|
+
${result.blocks ?? 0} block(s) published. The client's dashboard now renders
|
|
180
|
+
from this repo's own schema — change a block here and it appears there on
|
|
181
|
+
the next deploy.
|
|
182
|
+
|
|
183
|
+
${ENV_FILE} holds the credentials. Add the same three values to your
|
|
184
|
+
hosting environment; they are not printed here on purpose.
|
|
185
|
+
────────────────────────────────────────────────────────────────────────
|
|
186
|
+
`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await run()
|