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.
- package/dist/acorn-CZBJD705.js +3143 -0
- package/dist/boot.d.ts +88 -0
- package/dist/boot.d.ts.map +1 -0
- package/dist/boot.js +98 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +22 -0
- package/dist/codegen/generate.d.ts +5 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/scan-imports.d.ts +9 -0
- package/dist/codegen/scan-imports.d.ts.map +1 -0
- package/dist/content-encode-DoojdsVU.js +80 -0
- package/dist/core/content-encode.d.ts +44 -0
- package/dist/core/content-encode.d.ts.map +1 -0
- package/dist/core/order.d.ts +18 -0
- package/dist/core/order.d.ts.map +1 -0
- package/dist/core/parse.d.ts +15 -0
- package/dist/core/parse.d.ts.map +1 -0
- package/dist/editor/index.js +29645 -15293
- package/dist/index-B1PShnsc.js +132 -0
- package/dist/index-B2WzdTzI.js +14263 -0
- package/dist/index-ButdD9Pf.js +1825 -0
- package/dist/index-DKF0siIz.js +5497 -0
- package/dist/index-q-7MEoHz.js +47 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/init.d.ts +39 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +221 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +140 -0
- package/dist/okno.css +1 -0
- package/dist/order-bAkdncZC.js +21 -0
- package/dist/plugin-Cy62173B.js +1080 -0
- package/dist/runtime/wrap.d.ts +48 -1
- package/dist/runtime/wrap.d.ts.map +1 -1
- package/dist/runtime/wrap.js +274 -0
- package/dist/types/fields.d.ts +54 -6
- package/dist/types/fields.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/permissions.d.ts +22 -0
- package/dist/types/permissions.d.ts.map +1 -0
- package/dist/types/schema.d.ts +41 -6
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/vite/dev-server.d.ts +8 -0
- package/dist/vite/dev-server.d.ts.map +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/vite/local-engine.d.ts +34 -0
- package/dist/vite/local-engine.d.ts.map +1 -0
- package/dist/vite/plugin.d.ts.map +1 -1
- package/package.json +42 -11
- package/skills/connect-okno/SKILL.md +367 -0
- package/src/runtime/wrap.test.ts +82 -0
- package/src/runtime/wrap.ts +684 -0
- 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
|
+
}
|