@unitninety/site-kit 0.0.0-stage → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,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()