okno 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (58) hide show
  1. package/dist/acorn-CZBJD705.js +3143 -0
  2. package/dist/boot.d.ts +88 -0
  3. package/dist/boot.d.ts.map +1 -0
  4. package/dist/boot.js +98 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +22 -0
  8. package/dist/codegen/generate.d.ts +5 -1
  9. package/dist/codegen/generate.d.ts.map +1 -1
  10. package/dist/codegen/scan-imports.d.ts +9 -0
  11. package/dist/codegen/scan-imports.d.ts.map +1 -0
  12. package/dist/content-encode-DoojdsVU.js +80 -0
  13. package/dist/core/content-encode.d.ts +44 -0
  14. package/dist/core/content-encode.d.ts.map +1 -0
  15. package/dist/core/order.d.ts +18 -0
  16. package/dist/core/order.d.ts.map +1 -0
  17. package/dist/core/parse.d.ts +15 -0
  18. package/dist/core/parse.d.ts.map +1 -0
  19. package/dist/editor/index.js +29645 -15293
  20. package/dist/index-B1PShnsc.js +132 -0
  21. package/dist/index-B2WzdTzI.js +14263 -0
  22. package/dist/index-ButdD9Pf.js +1825 -0
  23. package/dist/index-DKF0siIz.js +5497 -0
  24. package/dist/index-q-7MEoHz.js +47 -0
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/init.d.ts +39 -0
  29. package/dist/init.d.ts.map +1 -0
  30. package/dist/init.js +221 -0
  31. package/dist/mcp.d.ts +4 -0
  32. package/dist/mcp.d.ts.map +1 -0
  33. package/dist/mcp.js +140 -0
  34. package/dist/okno.css +1 -0
  35. package/dist/order-bAkdncZC.js +21 -0
  36. package/dist/plugin-Cy62173B.js +1080 -0
  37. package/dist/runtime/wrap.d.ts +48 -1
  38. package/dist/runtime/wrap.d.ts.map +1 -1
  39. package/dist/runtime/wrap.js +274 -0
  40. package/dist/types/fields.d.ts +54 -6
  41. package/dist/types/fields.d.ts.map +1 -1
  42. package/dist/types/index.d.ts +3 -2
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/dist/types/permissions.d.ts +22 -0
  45. package/dist/types/permissions.d.ts.map +1 -0
  46. package/dist/types/schema.d.ts +41 -6
  47. package/dist/types/schema.d.ts.map +1 -1
  48. package/dist/vite/dev-server.d.ts +8 -0
  49. package/dist/vite/dev-server.d.ts.map +1 -1
  50. package/dist/vite/index.js +1 -1
  51. package/dist/vite/local-engine.d.ts +34 -0
  52. package/dist/vite/local-engine.d.ts.map +1 -0
  53. package/dist/vite/plugin.d.ts.map +1 -1
  54. package/package.json +42 -11
  55. package/skills/connect-okno/SKILL.md +367 -0
  56. package/src/runtime/wrap.test.ts +82 -0
  57. package/src/runtime/wrap.ts +684 -0
  58. package/dist/plugin-Cwwh6E1P.js +0 -440
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Global listener registry for .on() callbacks.
3
+ * Keyed by content path (e.g. "home.title"), values are Sets of callbacks.
4
+ * The editor dispatches "okno:change" CustomEvents, which this module catches
5
+ * and forwards to registered callbacks.
6
+ */
7
+ const listeners = new Map<string, Set<(value: unknown) => void>>()
8
+
9
+ let listening = false
10
+ function ensureListener() {
11
+ const g = globalThis as any
12
+ if (listening || typeof g.document === "undefined") return
13
+ listening = true
14
+ g.document.addEventListener("okno:change", (e: any) => {
15
+ const { path, value } = e.detail
16
+ listeners.get(path)?.forEach((cb) => cb(value))
17
+ })
18
+ }
19
+
20
+ /**
21
+ * Names that should never be humanized — they're typically used as HTML
22
+ * attributes where a humanized fallback would break rendering ("Src", "Alt").
23
+ */
24
+ const TECHNICAL_LEAVES = new Set([
25
+ "src",
26
+ "href",
27
+ "alt",
28
+ "id",
29
+ "class",
30
+ "className",
31
+ "width",
32
+ "height",
33
+ "type",
34
+ "name",
35
+ "value",
36
+ ])
37
+
38
+ function humanize(name: string): string {
39
+ return name
40
+ .replace(/[-_]+/g, " ")
41
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
42
+ .trim()
43
+ .replace(/^./, (c) => c.toUpperCase())
44
+ }
45
+
46
+ /**
47
+ * Resolve raw data to its string representation. When raw is missing,
48
+ * humanize the leaf segment of the path so empty fields render as a
49
+ * readable placeholder ("Title" for `home.title`) instead of empty —
50
+ * makes a fresh page visually scannable before scaffolding fills it in.
51
+ */
52
+ function rawToString(raw: unknown, path = ""): string {
53
+ if (raw === null || raw === undefined) {
54
+ if (!path) return ""
55
+ const leaf = path.split(".").pop() ?? ""
56
+ if (!leaf || TECHNICAL_LEAVES.has(leaf)) return ""
57
+ return humanize(leaf)
58
+ }
59
+ if (typeof raw !== "object") return String(raw)
60
+ // Link object → render its text (so `{home.link}` shows the label).
61
+ if (isLinkObject(raw)) return raw.text ?? ""
62
+ return ""
63
+ }
64
+
65
+ /** Files objects have a `src` key — groups never do. */
66
+ function isMediaObject(raw: unknown): raw is Record<string, unknown> {
67
+ return typeof raw === "object" && raw !== null && !Array.isArray(raw) && "src" in raw
68
+ }
69
+
70
+ /** Link fields always carry exactly `text` + `url` + `external` — distinct from
71
+ * media (`src`) and from generic groups. Lets `<a {...home.link}>{home.link}>`
72
+ * spread `href`/`target`/`rel` and render the text. */
73
+ function isLinkObject(raw: unknown): raw is { text?: string; url?: string; external?: boolean } {
74
+ return (
75
+ typeof raw === "object" &&
76
+ raw !== null &&
77
+ !Array.isArray(raw) &&
78
+ "url" in raw &&
79
+ "text" in raw &&
80
+ "external" in raw
81
+ )
82
+ }
83
+
84
+ /** Deep-merge `override` onto `base` (override leaves win; objects merge,
85
+ * arrays/primitives replace). Used so a locale's leaf-level overrides inherit
86
+ * every untouched field — including siblings inside a group. */
87
+ function deepMerge(base: unknown, override: unknown): unknown {
88
+ if (override === undefined) return base
89
+ if (
90
+ base === null ||
91
+ typeof base !== "object" ||
92
+ Array.isArray(base) ||
93
+ override === null ||
94
+ typeof override !== "object" ||
95
+ Array.isArray(override)
96
+ ) {
97
+ return override
98
+ }
99
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }
100
+ for (const k of Object.keys(override as Record<string, unknown>)) {
101
+ out[k] = deepMerge((base as Record<string, unknown>)[k], (override as Record<string, unknown>)[k])
102
+ }
103
+ return out
104
+ }
105
+
106
+ /**
107
+ * Compose a localized value from the default-locale `base` and a map of
108
+ * per-locale override objects: `{ ...base, <loc>: deepMerge(base, override) }`.
109
+ * The default stays flat at the root; each locale inherits the default then
110
+ * applies its overrides. Generated by the Vite plugin for localized content.
111
+ */
112
+ export function __oknoLocalize(
113
+ base: Record<string, unknown>,
114
+ overrides: Record<string, Record<string, unknown>>,
115
+ ): Record<string, unknown> {
116
+ const out: Record<string, unknown> = { ...base }
117
+ for (const loc of Object.keys(overrides)) {
118
+ out[loc] = deepMerge(base, overrides[loc]) as Record<string, unknown>
119
+ }
120
+ return out
121
+ }
122
+
123
+ /**
124
+ * Wraps content data in a Proxy that:
125
+ * - Returns `data-okno` attribute when spread ({...value})
126
+ * - Returns string value when used in templates ({value})
127
+ * - Forwards native string/number methods (value.length, value.includes(), etc.)
128
+ * - Returns nested proxies for group access (value.child)
129
+ * - Returns empty proxies for non-existing fields
130
+ * - Supports .on(callback) for reactive change subscriptions
131
+ */
132
+ export function __oknoWrap(data: unknown, prefix: string, slot?: string): any {
133
+ const wrapper = { __raw: data, __path: prefix, __slot: slot }
134
+
135
+ return new Proxy(wrapper, {
136
+ get(target, prop) {
137
+ // Spread reads this to get the attribute value
138
+ if (prop === "data-okno") return target.__path
139
+
140
+ // `{...item.slot}` — the array-membership boundary (which list + index),
141
+ // distinct from `data-okno` (content identity). Typed only on array items
142
+ // (so scalar `{...field}` spreads never carry it / collide with HTML attrs);
143
+ // `slot` is a reserved field key so no content field can shadow it. For a
144
+ // reference item the content lives at the collection path but its SLOT is the
145
+ // field array position, so structural edits (reorder/add/remove) hit the right list.
146
+ if (prop === "slot") return { "data-okno-item": target.__slot ?? target.__path }
147
+
148
+ // String coercion for template rendering: {value}
149
+ if (
150
+ prop === Symbol.toPrimitive ||
151
+ prop === "toString" ||
152
+ prop === "valueOf"
153
+ ) {
154
+ return () => rawToString(target.__raw, target.__path)
155
+ }
156
+
157
+ // Symbol.iterator — Astro's renderer iterates children to render them.
158
+ // Yield the string value so {home.title} renders as text.
159
+ if (prop === Symbol.iterator) {
160
+ const str = rawToString(target.__raw, target.__path)
161
+ return function* () {
162
+ yield str
163
+ }
164
+ }
165
+
166
+ // Ignore other symbols
167
+ if (typeof prop === "symbol") return undefined
168
+
169
+ // JSON serialization
170
+ if (prop === "toJSON") return () => target.__raw
171
+
172
+ // .on(callback) — subscribe to live edits from the editor
173
+ if (prop === "on") {
174
+ return (callback: (value: unknown) => void) => {
175
+ ensureListener()
176
+ const path = target.__path
177
+ if (!listeners.has(path)) listeners.set(path, new Set())
178
+ listeners.get(path)!.add(callback)
179
+ // Return unsubscribe function
180
+ return () => {
181
+ listeners.get(path)?.delete(callback)
182
+ if (listeners.get(path)?.size === 0) listeners.delete(path)
183
+ }
184
+ }
185
+ }
186
+
187
+ // .locale(code) — re-root reads at a locale's data. The default
188
+ // locale lives flat at the root; other locales sit under their key
189
+ // (shaped by the build plugin, with default-locale fallback merged
190
+ // in). Returns the flat root when the code is the default, absent,
191
+ // or unset — so `.locale(Astro.currentLocale)` is a safe no-op on
192
+ // un-localized content. The path stays locale-agnostic so live edits
193
+ // resolve against the editor's active locale.
194
+ if (prop === "locale") {
195
+ return (code?: string | null) => {
196
+ const raw = target.__raw
197
+ if (
198
+ code &&
199
+ raw !== null &&
200
+ typeof raw === "object" &&
201
+ !Array.isArray(raw) &&
202
+ typeof (raw as Record<string, unknown>)[code] === "object" &&
203
+ (raw as Record<string, unknown>)[code] !== null
204
+ ) {
205
+ return __oknoWrap(
206
+ (raw as Record<string, unknown>)[code],
207
+ target.__path,
208
+ )
209
+ }
210
+ return __oknoWrap(raw, target.__path)
211
+ }
212
+ }
213
+
214
+ const raw = target.__raw
215
+ const childPath = target.__path
216
+ ? `${target.__path}.${String(prop)}`
217
+ : String(prop)
218
+
219
+ // Link object: expose mapped HTML attributes so `<a {...home.link}>`
220
+ // spreads them (object spread reads values via this Get trap, not via
221
+ // getOwnPropertyDescriptor). The real fields (url/text/external) stay
222
+ // reachable by their own names. `target`/`rel` only when external.
223
+ if (isLinkObject(raw) && typeof prop === "string") {
224
+ if (prop === "href") return raw.url ?? ""
225
+ if (prop === "target" && raw.external) return "_blank"
226
+ if (prop === "rel" && raw.external) return "noopener noreferrer"
227
+ }
228
+
229
+ // For primitive values (string, number, boolean), forward native method/property access
230
+ // This makes home.title.length, home.title.includes("x"), etc. work
231
+ if (raw !== null && raw !== undefined && typeof raw !== "object") {
232
+ const nativeValue = (raw as any)[prop]
233
+ if (nativeValue !== undefined) {
234
+ if (typeof nativeValue === "function") {
235
+ return (...args: unknown[]) =>
236
+ nativeValue.apply(raw, args)
237
+ }
238
+ return nativeValue
239
+ }
240
+ // Property doesn't exist on the primitive — return empty proxy
241
+ return __oknoWrap(null, childPath)
242
+ }
243
+
244
+ // For object values, do nested child access
245
+ if (
246
+ raw !== null &&
247
+ raw !== undefined &&
248
+ typeof raw === "object" &&
249
+ !Array.isArray(raw)
250
+ ) {
251
+ const childValue = (raw as Record<string, unknown>)[String(prop)]
252
+
253
+ if (childValue === undefined || childValue === null) {
254
+ // Non-existing field — return empty proxy
255
+ return __oknoWrap(null, childPath)
256
+ }
257
+
258
+ // Groups, leaves, arrays (→ a real array of wrapped elements so
259
+ // `field.map(...)` works and each item carries its own data-okno
260
+ // path), and resolved-reference markers — see wrapValue.
261
+ return wrapValue(childValue, childPath)
262
+ }
263
+
264
+ // For null/undefined raw (non-existing parent), return empty proxy
265
+ return __oknoWrap(null, childPath)
266
+ },
267
+
268
+ // For spread: expose data-okno, plus all keys for file objects (src, alt, width, height, etc.)
269
+ ownKeys(target) {
270
+ const raw = target.__raw
271
+ if (isMediaObject(raw)) {
272
+ return [...Object.keys(raw), "data-okno"]
273
+ }
274
+ if (isLinkObject(raw)) {
275
+ return raw.external
276
+ ? ["href", "target", "rel", "data-okno"]
277
+ : ["href", "data-okno"]
278
+ }
279
+ return ["data-okno"]
280
+ },
281
+
282
+ getOwnPropertyDescriptor(target, prop) {
283
+ if (prop === "data-okno") {
284
+ return {
285
+ value: target.__path,
286
+ enumerable: true,
287
+ configurable: true,
288
+ }
289
+ }
290
+ const raw = target.__raw
291
+ if (isMediaObject(raw) && typeof prop === "string" && prop in raw) {
292
+ return {
293
+ value: raw[prop],
294
+ enumerable: true,
295
+ configurable: true,
296
+ }
297
+ }
298
+ // Link's virtual attrs (value comes from the Get trap; this just marks
299
+ // them enumerable so the spread picks them up).
300
+ if (isLinkObject(raw) && (prop === "href" || (raw.external && (prop === "target" || prop === "rel")))) {
301
+ return { value: undefined, enumerable: true, configurable: true }
302
+ }
303
+ return undefined
304
+ },
305
+
306
+ has(target, prop) {
307
+ if (prop === "data-okno") return true
308
+ if (prop === Symbol.toPrimitive || prop === "toString") return true
309
+ const raw = target.__raw
310
+ if (typeof raw === "object" && raw !== null) {
311
+ return prop in (raw as object)
312
+ }
313
+ if (raw !== null && raw !== undefined) {
314
+ return prop in Object(raw)
315
+ }
316
+ return false
317
+ },
318
+ })
319
+ }
320
+
321
+ /** A resolved reference: the build plugin replaces a reference id with this
322
+ * marker so the wrapped value's `data-okno` points at the referenced collection
323
+ * item (`<collection>.<id>`), not at the field on the parent record — editing a
324
+ * referenced item edits the item, not the page that references it. */
325
+ interface OknoRef {
326
+ __oknoRef: { path: string; data: unknown }
327
+ }
328
+
329
+ function isOknoRef(value: unknown): value is OknoRef {
330
+ return (
331
+ value !== null &&
332
+ typeof value === "object" &&
333
+ !Array.isArray(value) &&
334
+ "__oknoRef" in (value as object)
335
+ )
336
+ }
337
+
338
+ /**
339
+ * Wrap a child value:
340
+ * - a resolved-reference marker → wrap its data at the referenced item's path,
341
+ * but keep `slot` = the array position (so reorder targets the field's list,
342
+ * not the shared collection item);
343
+ * - an array → a REAL array of wrapped elements (so `field.map(...)` works and
344
+ * every element carries its own `data-okno` path + `slot` boundary);
345
+ * - anything else → a normal nested proxy.
346
+ *
347
+ * `slot` is the array-membership path (`home.featuredArticles.0`); it equals the
348
+ * content path for plain/block arrays but differs for resolved references.
349
+ */
350
+ function wrapValue(value: unknown, fallbackPath: string, slot?: string): any {
351
+ if (isOknoRef(value)) {
352
+ const { path, data } = value.__oknoRef
353
+ return __oknoWrap(data, path, slot ?? path)
354
+ }
355
+ if (Array.isArray(value)) {
356
+ const wrapped = value.map((el, i) => wrapValue(el, `${fallbackPath}.${i}`, `${fallbackPath}.${i}`))
357
+ // Keep the field API on array fields: `.toJSON()` yields the RAW items, the
358
+ // supported way to read a `multiple` block array (e.g. okno.build's homepage
359
+ // `headlines`). The wrapped value is a real array so `.map()`/iteration/spread
360
+ // keep working; `toJSON` is non-enumerable so it never leaks into those.
361
+ // Resolved references unwrap to their data so callers get clean raw items.
362
+ Object.defineProperty(wrapped, "toJSON", {
363
+ value: () => value.map((el) => (isOknoRef(el) ? el.__oknoRef.data : el)),
364
+ enumerable: false,
365
+ })
366
+ return wrapped
367
+ }
368
+ return __oknoWrap(value, fallbackPath, slot)
369
+ }
370
+
371
+ /**
372
+ * Replace reference fields (id or id[]) on a raw record with `__oknoRef` markers
373
+ * carrying the resolved collection item's RAW data + its `<collection>.<id>`
374
+ * path. Driven by the entry's schema `fields` (emitted by the build plugin);
375
+ * `collections` is the `okno:collections` namespace. Non-reference fields pass
376
+ * through untouched. Resolution is one level deep — a referenced item's own
377
+ * references are not re-resolved (avoids cycles).
378
+ */
379
+ export function __oknoResolveRefs(
380
+ raw: unknown,
381
+ fields: Record<string, any> | undefined,
382
+ collections: Record<string, any>,
383
+ ): unknown {
384
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !fields) return raw
385
+
386
+ const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) }
387
+
388
+ for (const key of Object.keys(fields)) {
389
+ const def = fields[key]
390
+ if (!def || def.type !== "reference" || typeof def.collection !== "string") continue
391
+
392
+ const list: any[] =
393
+ collections?.[def.collection] ?? collections?.[def.collection.replace(/-/g, "_")] ?? []
394
+
395
+ const bySlug = new Map<string, any>()
396
+ for (const it of list) {
397
+ const r = typeof it?.toJSON === "function" ? it.toJSON() : it
398
+ if (r && typeof r === "object" && typeof r.slug === "string") bySlug.set(r.slug, it)
399
+ }
400
+ const resolve = (id: unknown): OknoRef | null => {
401
+ if (typeof id !== "string" || !id) return null
402
+ const item = bySlug.get(id)
403
+ if (!item) return null
404
+ const data = typeof item.toJSON === "function" ? item.toJSON() : item
405
+ return { __oknoRef: { path: `${def.collection}.${id}`, data } }
406
+ }
407
+
408
+ const val = (raw as Record<string, unknown>)[key]
409
+ out[key] = Array.isArray(val) ? val.map(resolve).filter(Boolean) : resolve(val)
410
+ }
411
+
412
+ return out
413
+ }
414
+
415
+ /* ─────────────────────────────────────────────────────────────────────────
416
+ * Live STRUCTURAL edits — reorder / remove / insert of array items, reflected
417
+ * on the page with no save and no reload, animated via the View Transitions API.
418
+ *
419
+ * The content channel above (`okno:change`) patches a single field's text/attrs
420
+ * in place by `data-okno`. This channel handles the orthogonal problem: the
421
+ * SHAPE of an array changed. The editor dispatches an `okno:structure` event
422
+ * carrying the list path and the op; we manipulate the served DOM directly.
423
+ *
424
+ * Item boundaries are marked by `data-okno-item="<list>.<index>"` (emitted by
425
+ * `{...item.slot}`). A list may be rendered in several places on one page
426
+ * (header + footer); we treat each parent that holds direct members of the list
427
+ * as an independent render location and apply the op to each.
428
+ * ───────────────────────────────────────────────────────────────────────── */
429
+
430
+ export interface OknoStructureDetail {
431
+ /** The array field path, e.g. "home.block" or "home.featuredArticles". */
432
+ list: string
433
+ op: "reorder" | "remove" | "insert"
434
+ /** reorder */
435
+ from?: number
436
+ to?: number
437
+ /** remove / insert — the affected index (insert position). */
438
+ index?: number
439
+ /** insert (references only): remap the cloned template's CONTENT paths
440
+ * (`data-okno`) from one collection-item prefix to another, since a
441
+ * reference item's content lives at `<collection>.<slug>`, not under the
442
+ * list. Slot boundaries (`data-okno-item`) are always renumbered by list. */
443
+ remap?: { from: string; to: string }
444
+ /** insert: absolute content path → string value, used to fill the cloned
445
+ * item's text leaves (so a new reference shows the linked item's content;
446
+ * block defaults are empty so the clone is just cleared). */
447
+ values?: Record<string, string>
448
+ }
449
+
450
+ const ITEM_ATTR = "data-okno-item"
451
+ const CONTENT_ATTR = "data-okno"
452
+ const PREFIX_ATTRS = [CONTENT_ATTR, ITEM_ATTR]
453
+
454
+ /** A render location: one parent element holding direct members of the list,
455
+ * with those member wrappers sorted by their array index. */
456
+ interface ItemGroup {
457
+ parent: Element
458
+ wrappers: HTMLElement[]
459
+ }
460
+
461
+ /** Rewrite path attributes on `el` and its descendants whose value equals
462
+ * `oldPrefix` or sits under it (`oldPrefix.` …) — swapping that prefix for
463
+ * `newPrefix`. This is the renumbering primitive: it shifts an item's own
464
+ * `data-okno-item` AND every descendant `data-okno`/`data-okno-item` that
465
+ * begins with the same prefix (nested arrays included). For a reference item
466
+ * the content `data-okno` (`articles.<slug>…`) doesn't share the list prefix,
467
+ * so it's correctly left untouched when renumbering positions. */
468
+ function rewritePrefix(
469
+ el: Element,
470
+ oldPrefix: string,
471
+ newPrefix: string,
472
+ attrs: string[] = PREFIX_ATTRS,
473
+ ): void {
474
+ if (oldPrefix === newPrefix) return
475
+ const apply = (node: Element) => {
476
+ for (const attr of attrs) {
477
+ const v = node.getAttribute(attr)
478
+ if (v == null) continue
479
+ if (v === oldPrefix) node.setAttribute(attr, newPrefix)
480
+ else if (v.startsWith(oldPrefix + ".")) node.setAttribute(attr, newPrefix + v.slice(oldPrefix.length))
481
+ }
482
+ }
483
+ apply(el)
484
+ for (const attr of attrs) el.querySelectorAll(`[${attr}]`).forEach(apply)
485
+ }
486
+
487
+ /** Collect the render locations of a list: every element whose `data-okno-item`
488
+ * is a DIRECT member (`<list>.<integer>`, no deeper segment), grouped by parent
489
+ * and sorted by index. Deeper nested members of a different sub-array are
490
+ * excluded by the strict `^<list>\.\d+$` shape. */
491
+ function collectGroups(list: string): ItemGroup[] {
492
+ if (typeof document === "undefined") return []
493
+ const re = new RegExp(`^${list.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.(\\d+)$`)
494
+ const byParent = new Map<Element, HTMLElement[]>()
495
+ document.querySelectorAll(`[${ITEM_ATTR}]`).forEach((el) => {
496
+ const attr = el.getAttribute(ITEM_ATTR)
497
+ if (!attr || !re.test(attr) || !el.parentElement || !(el instanceof HTMLElement)) return
498
+ const arr = byParent.get(el.parentElement) ?? []
499
+ arr.push(el)
500
+ byParent.set(el.parentElement, arr)
501
+ })
502
+ const indexOf = (el: HTMLElement) => Number(el.getAttribute(ITEM_ATTR)!.match(re)![1])
503
+ return [...byParent.entries()].map(([parent, wrappers]) => ({
504
+ parent,
505
+ wrappers: wrappers.sort((a, b) => indexOf(a) - indexOf(b)),
506
+ }))
507
+ }
508
+
509
+ /** Clear visible text in a cloned item's leaf content nodes (a fresh item has
510
+ * no content yet). Only touches `data-okno` leaves with no element children —
511
+ * images / link spreads / richtext are left as-is (best-effort; they settle on
512
+ * the next save+reload). */
513
+ function clearLeaves(root: Element): void {
514
+ root.querySelectorAll(`[${CONTENT_ATTR}]`).forEach((el) => {
515
+ if (el.children.length === 0) el.textContent = ""
516
+ })
517
+ }
518
+
519
+ /** Fill a cloned item's text leaves from absolute-path → value pairs. */
520
+ function fillLeaves(root: Element, values: Record<string, string>): void {
521
+ root.querySelectorAll(`[${CONTENT_ATTR}]`).forEach((el) => {
522
+ const path = el.getAttribute(CONTENT_ATTR)
523
+ if (path && path in values && el.children.length === 0) el.textContent = values[path] ?? ""
524
+ })
525
+ }
526
+
527
+ function prefersReducedMotion(): boolean {
528
+ return (
529
+ typeof window !== "undefined" &&
530
+ typeof window.matchMedia === "function" &&
531
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
532
+ )
533
+ }
534
+
535
+ /** Run a DOM mutation inside a View Transition when supported (so moved/added/
536
+ * removed wrappers tween), falling back to a plain synchronous call. Returns a
537
+ * promise that settles once any animation finishes — used to clear the
538
+ * temporary `view-transition-name`s afterwards. */
539
+ function withTransition(mutate: () => void): Promise<void> {
540
+ const doc = document as any
541
+ if (typeof doc.startViewTransition !== "function" || prefersReducedMotion()) {
542
+ mutate()
543
+ return Promise.resolve()
544
+ }
545
+ return doc.startViewTransition(mutate).finished.catch(() => {})
546
+ }
547
+
548
+ let vtSeq = 0
549
+
550
+ /**
551
+ * Apply a structural op to the live DOM. Attached to `okno:structure` by
552
+ * `__oknoInitStructure()`. The editor calls `updateField` for its optimistic
553
+ * state in the same tick; this keeps the rendered page in sync without a reload.
554
+ */
555
+ export function __oknoApplyStructure(detail: OknoStructureDetail): void {
556
+ if (typeof document === "undefined" || !detail?.list || !detail.op) return
557
+ const { list, op } = detail
558
+ const groups = collectGroups(list)
559
+
560
+ // Insert with no rendered item to clone — an empty list, OR a list that isn't
561
+ // on the page in view (e.g. a block field the current page doesn't render).
562
+ // Nothing to live-insert: no-op. The item is already in the editor's optimistic
563
+ // state and renders once saved + rebuilt. We must NOT reload — the add isn't
564
+ // persisted yet, so a reload would drop it (and needlessly reboot the editor).
565
+ if (op === "insert" && groups.every((g) => g.wrappers.length === 0)) return
566
+ if (groups.length === 0) return
567
+
568
+ // Tag the existing wrappers with a unique view-transition-name BEFORE the
569
+ // transition captures the old snapshot, so moves/removals animate. The clone
570
+ // (added during the mutation) gets its own name inside `mutate`.
571
+ const tagged: HTMLElement[] = []
572
+ const tag = (el: HTMLElement) => {
573
+ el.style.viewTransitionName = `okno-vt-${++vtSeq}`
574
+ tagged.push(el)
575
+ }
576
+ for (const g of groups) for (const w of g.wrappers) tag(w)
577
+
578
+ // Flag the document for the duration so the editor's CSS can scope the
579
+ // view-transition easing to OUR structural transitions only (not any
580
+ // page-nav transitions the host site runs). See `:root.okno-vt-active` in
581
+ // the editor global styles.
582
+ const docEl = document.documentElement
583
+ docEl.classList.add("okno-vt-active")
584
+
585
+ // Keep the editor above the animation. View-transition snapshots render in the
586
+ // browser TOP LAYER (above every z-index), so the moving page items would
587
+ // otherwise sweep OVER the editor window. Give the editor host its own group
588
+ // (`view-transition-name: okno-editor`) so it's captured and — being the last
589
+ // child of <body> — composited on top. The host is normally a 0-size box (its
590
+ // UI is `position:fixed` inside the shadow), which would snapshot empty, so we
591
+ // give it a real viewport box for the transition only and restore it after — no
592
+ // permanent layout/pointer-events change. The editor doesn't move, so its
593
+ // crossfade is suppressed in CSS (`::view-transition-*(okno-editor)`); it shows
594
+ // a frozen snapshot, so the temporary box never actually paints. Tradeoff: its
595
+ // backdrop-filter blur can't sample across snapshot groups, so the glass goes
596
+ // flat for the transition's duration (a View Transitions limitation).
597
+ const editorHost = document.getElementById("okno-shadow-root")
598
+ const prevHostCss = editorHost instanceof HTMLElement ? editorHost.style.cssText : null
599
+ if (editorHost instanceof HTMLElement) {
600
+ editorHost.style.cssText = `${prevHostCss};position:fixed;inset:0;pointer-events:none;view-transition-name:okno-editor`
601
+ }
602
+
603
+ const mutate = () => {
604
+ for (const g of groups) {
605
+ if (op === "reorder") applyReorder(g, list, detail.from!, detail.to!)
606
+ else if (op === "remove") applyRemove(g, list, detail.index!)
607
+ else if (op === "insert") applyInsert(g, list, detail, tag)
608
+ }
609
+ }
610
+
611
+ withTransition(mutate).finally(() => {
612
+ for (const w of tagged) w.style.viewTransitionName = ""
613
+ docEl.classList.remove("okno-vt-active")
614
+ if (editorHost instanceof HTMLElement && prevHostCss !== null) editorHost.style.cssText = prevHostCss
615
+ })
616
+ }
617
+
618
+ function applyReorder(g: ItemGroup, list: string, from: number, to: number): void {
619
+ const w = g.wrappers
620
+ const n = w.length
621
+ if (from < 0 || from >= n || to < 0 || to >= n || from === to) return
622
+ const order = w.slice()
623
+ const [moved] = order.splice(from, 1)
624
+ order.splice(to, 0, moved!)
625
+ // Capture current prefixes, then renumber each wrapper to its new index.
626
+ const olds = order.map((el) => el.getAttribute(ITEM_ATTR)!)
627
+ order.forEach((el, i) => rewritePrefix(el, olds[i]!, `${list}.${i}`))
628
+ // Reinsert in the new order, anchored to whatever followed the list (so the
629
+ // block keeps its place relative to surrounding, non-item siblings).
630
+ const anchor = w[n - 1]!.nextSibling
631
+ for (const el of order) g.parent.insertBefore(el, anchor)
632
+ }
633
+
634
+ function applyRemove(g: ItemGroup, list: string, index: number): void {
635
+ const w = g.wrappers
636
+ if (index < 0 || index >= w.length) return
637
+ w[index]!.remove()
638
+ for (let j = index + 1; j < w.length; j++) rewritePrefix(w[j]!, `${list}.${j}`, `${list}.${j - 1}`)
639
+ }
640
+
641
+ function applyInsert(
642
+ g: ItemGroup,
643
+ list: string,
644
+ detail: OknoStructureDetail,
645
+ tag: (el: HTMLElement) => void,
646
+ ): void {
647
+ const w = g.wrappers
648
+ const n = w.length
649
+ if (n === 0) return // empty handled by the reload guard above
650
+ const at = Math.max(0, Math.min(detail.index ?? n, n))
651
+
652
+ // Clone the item nearest the insertion point as a template (capture its
653
+ // prefix BEFORE we renumber the live siblings).
654
+ const tplIdx = at < n ? at : n - 1
655
+ const tplPrefix = w[tplIdx]!.getAttribute(ITEM_ATTR)!
656
+ const clone = w[tplIdx]!.cloneNode(true) as HTMLElement
657
+
658
+ // Shift existing members at/after the insertion point up by one (descending
659
+ // so a freshly-written prefix is never re-read as a source).
660
+ for (let j = n - 1; j >= at; j--) rewritePrefix(w[j]!, `${list}.${j}`, `${list}.${j + 1}`)
661
+
662
+ // Point the clone at the new index, then (references) remap its content paths.
663
+ rewritePrefix(clone, tplPrefix, `${list}.${at}`)
664
+ if (detail.remap) rewritePrefix(clone, detail.remap.from, detail.remap.to, [CONTENT_ATTR])
665
+
666
+ if (detail.values) fillLeaves(clone, detail.values)
667
+ else clearLeaves(clone)
668
+
669
+ clone.style.viewTransitionName = ""
670
+ tag(clone)
671
+ const anchor = at < n ? w[at]! : w[n - 1]!.nextSibling
672
+ g.parent.insertBefore(clone, anchor)
673
+ }
674
+
675
+ let structureListening = false
676
+
677
+ /** Attach the `okno:structure` listener once. Called by the editor (which is
678
+ * the reliable client-side presence) on scan; safe to call repeatedly. */
679
+ export function __oknoInitStructure(): void {
680
+ const g = globalThis as any
681
+ if (structureListening || typeof g.document === "undefined") return
682
+ structureListening = true
683
+ g.document.addEventListener("okno:structure", (e: any) => __oknoApplyStructure(e.detail))
684
+ }