@xtrable-ltd/nanoesis 0.1.9 → 0.1.11

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 (32) hide show
  1. package/dist/adapter-azure-blob.d.ts +7 -1
  2. package/dist/adapter-azure-blob.js +2 -2
  3. package/dist/{chunk-J6VYOB47.js → chunk-XO3CT6GL.js} +1699 -1167
  4. package/dist/editor-api.d.ts +39 -2
  5. package/dist/editor-api.js +396 -6
  6. package/dist/index.d.ts +491 -222
  7. package/dist/index.js +25 -1
  8. package/editor/assets/MigrationsPane-BAHPPSXP.css +1 -0
  9. package/editor/assets/MigrationsPane-BYGqWBAA.js +4 -0
  10. package/editor/assets/{TemplatesPane-CHzfB00-.js → TemplatesPane-B5hn_v0Z.js} +208 -202
  11. package/editor/assets/{TemplatesPane-B4_sg2u5.css → TemplatesPane-D0gGehUt.css} +1 -1
  12. package/editor/assets/{cssMode-BahdJh1A.js → cssMode-BbIf5k6I.js} +1 -1
  13. package/editor/assets/{freemarker2-2FC3twUE.js → freemarker2-DoW0pSYV.js} +1 -1
  14. package/editor/assets/{handlebars-pMjPHNx1.js → handlebars-DLlET-qc.js} +1 -1
  15. package/editor/assets/{html-KTToTG0n.js → html-4khbqrhe.js} +1 -1
  16. package/editor/assets/{htmlMode-ufik94dZ.js → htmlMode-DblHkZ-k.js} +1 -1
  17. package/editor/assets/index-CkESQLMV.css +7 -0
  18. package/editor/assets/index-Do1drqEQ.js +138 -0
  19. package/editor/assets/{javascript-CD4kAZXr.js → javascript-CgPO2Hmj.js} +1 -1
  20. package/editor/assets/{jsonMode-ClHucayn.js → jsonMode-BrWh2436.js} +1 -1
  21. package/editor/assets/{liquid-B-uYib60.js → liquid-BsQJXwPT.js} +1 -1
  22. package/editor/assets/{mdx-BOc9oMkZ.js → mdx-AO8t67gx.js} +1 -1
  23. package/editor/assets/{python-BipLFHGs.js → python-3w4sZj5c.js} +1 -1
  24. package/editor/assets/{razor-C0di_gwM.js → razor-BFsvo06w.js} +1 -1
  25. package/editor/assets/{tsMode-B7fenrcD.js → tsMode-QrC4ERjp.js} +1 -1
  26. package/editor/assets/{typescript-CDg7c2A-.js → typescript-BXJ3QLad.js} +1 -1
  27. package/editor/assets/{xml-DTAdn5Pw.js → xml-CxKYn1FP.js} +1 -1
  28. package/editor/assets/{yaml-B9-OjY0Z.js → yaml-BmWLvF7Q.js} +1 -1
  29. package/editor/index.html +2 -2
  30. package/package.json +1 -1
  31. package/editor/assets/index-BsRGVHEP.css +0 -7
  32. package/editor/assets/index-CPKtfzWD.js +0 -134
@@ -154,785 +154,1029 @@ function copy(bytes) {
154
154
  return bytes.slice();
155
155
  }
156
156
 
157
- // ../engine/src/store/content-index.ts
158
- var RESERVED_PREFIX = ".nanoesis/";
159
- var INDEX_KEY = `${RESERVED_PREFIX}index.json`;
160
- var BACKUP_RING_SIZE = 3;
161
- function backupKey(slot) {
162
- return `${RESERVED_PREFIX}index.bak.${slot}`;
157
+ // ../engine/src/html/dom.ts
158
+ import { parse, parseFragment, serialize } from "parse5";
159
+ function isElement(node) {
160
+ return "tagName" in node;
163
161
  }
164
- function emptyIndex() {
165
- return freezeIndex(0, []);
162
+ function isTextNode(node) {
163
+ return node.nodeName === "#text";
166
164
  }
167
- async function loadIndex(store) {
168
- const live = parseIndex(await store.get(INDEX_KEY));
169
- if (live !== void 0) return live;
170
- let best;
171
- for (let slot = 0; slot < BACKUP_RING_SIZE; slot += 1) {
172
- const candidate = parseIndex(await store.get(backupKey(slot)));
173
- if (candidate !== void 0 && (best === void 0 || candidate.version > best.version)) {
174
- best = candidate;
175
- }
176
- }
177
- return best ?? emptyIndex();
165
+ function getAttribute(el, name) {
166
+ return el.attrs.find((attr) => attr.name === name)?.value;
178
167
  }
179
- async function saveIndex(store, prev, nextKeys) {
180
- await store.put(backupKey(prev.version % BACKUP_RING_SIZE), serialize(prev));
181
- const next = freezeIndex(prev.version + 1, nextKeys);
182
- await store.put(INDEX_KEY, serialize(next));
183
- return next;
168
+ function hasAttribute(el, name) {
169
+ return el.attrs.some((attr) => attr.name === name);
184
170
  }
185
- async function reconcileIndex(store, actualKeys) {
186
- const prev = await loadIndex(store);
187
- const keys = [...new Set(actualKeys.filter((key) => !key.startsWith(RESERVED_PREFIX)))].sort();
188
- const prevKeys = new Set(prev.keys);
189
- const nextKeys = new Set(keys);
190
- const added = keys.filter((key) => !prevKeys.has(key));
191
- const removed = prev.keys.filter((key) => !nextKeys.has(key));
192
- if (added.length === 0 && removed.length === 0) return { index: prev, added, removed };
193
- return { index: await saveIndex(store, prev, keys), added, removed };
171
+ function setAttribute(el, name, value) {
172
+ const existing = el.attrs.find((attr) => attr.name === name);
173
+ if (existing) existing.value = value;
174
+ else el.attrs.push({ name, value });
194
175
  }
195
- function freezeIndex(version, keys) {
196
- const sorted = [...new Set(keys)].sort();
197
- return { version, keys: sorted, checksum: checksumOf(version, sorted) };
176
+ function removeAttribute(el, name) {
177
+ el.attrs = el.attrs.filter((attr) => attr.name !== name);
198
178
  }
199
- function serialize(index) {
200
- return new TextEncoder().encode(`${JSON.stringify(index, null, 2)}
201
- `);
179
+ function parseFragmentNodes(html) {
180
+ return parseFragment(html).childNodes;
202
181
  }
203
- function parseIndex(bytes) {
204
- if (bytes === void 0) return void 0;
205
- let raw;
206
- try {
207
- raw = JSON.parse(new TextDecoder().decode(bytes));
208
- } catch {
209
- return void 0;
210
- }
211
- if (typeof raw !== "object" || raw === null) return void 0;
212
- const { version, keys, checksum } = raw;
213
- if (typeof version !== "number" || typeof checksum !== "string" || !Array.isArray(keys)) {
214
- return void 0;
215
- }
216
- const stringKeys = keys.filter((key) => typeof key === "string");
217
- if (stringKeys.length !== keys.length) return void 0;
218
- const rebuilt = freezeIndex(version, stringKeys);
219
- return rebuilt.checksum === checksum ? rebuilt : void 0;
182
+ function isFullDocument(html) {
183
+ const start = html.trimStart().toLowerCase();
184
+ return start.startsWith("<!doctype") || start.startsWith("<html");
220
185
  }
221
- function checksumOf(version, sortedKeys) {
222
- const text = `${version}
223
- ${sortedKeys.join("\n")}`;
224
- let hash = 2166136261;
225
- for (let i = 0; i < text.length; i += 1) {
226
- hash ^= text.charCodeAt(i);
227
- hash = Math.imul(hash, 16777619);
228
- }
229
- return (hash >>> 0).toString(16).padStart(8, "0");
186
+ function parseNodes(html) {
187
+ return isFullDocument(html) ? parse(html).childNodes : parseFragment(html).childNodes;
188
+ }
189
+ function serializeNodes(nodes) {
190
+ const fragment = parseFragment("");
191
+ fragment.childNodes = nodes;
192
+ return serialize(fragment);
230
193
  }
231
194
 
232
- // ../engine/src/store/indexed-store.ts
233
- var IndexedStore = class {
234
- constructor(store) {
235
- this.store = store;
236
- }
237
- store;
238
- index;
239
- loading;
240
- /**
241
- * Per-instance mutex (DESIGN §11d): every mutation (`write`/`delete`/`rename`/`reconcile`)
242
- * runs through {@link serializeMutation}, which chains onto the previous mutation's
243
- * completion. Without it, two concurrent mutations both read the cached `this.index`,
244
- * each compute their "next" key set from that stale snapshot, and the second
245
- * `saveIndex` clobbers the first the file writes land in blob, but the index keeps
246
- * stale references or loses fresh ones (the "I deleted it and it's still there" or
247
- * "I added it and it didn't appear" bugs surfaced dogfooding the marketing site,
248
- * 2026-05-28). Reads (`list`/`exists`/`readBytes`) are *not* serialised; an
249
- * in-flight mutation simply means a reader sees the pre-mutation index, eventually
250
- * consistent and safe. Crashes inside `work()` release the lock so a single failure
251
- * does not deadlock subsequent operations.
252
- */
253
- mutationLock = Promise.resolve();
254
- /** The index, loaded once on first need and cached (mutations replace the cached copy). */
255
- async loaded() {
256
- if (this.index !== void 0) return this.index;
257
- this.loading ??= loadIndex(this.store);
258
- this.index = await this.loading;
259
- return this.index;
260
- }
261
- serializeMutation(work) {
262
- const previous = this.mutationLock;
263
- let release;
264
- this.mutationLock = new Promise((resolve) => {
265
- release = resolve;
266
- });
267
- return (async () => {
268
- try {
269
- await previous;
270
- return await work();
271
- } finally {
272
- release();
273
- }
274
- })();
275
- }
276
- async list(dir) {
277
- return childrenOf((await this.loaded()).keys, dir);
278
- }
279
- async readText(path) {
280
- return new TextDecoder().decode(await this.readBytes(path));
281
- }
282
- async readBytes(path) {
283
- const bytes = await this.store.get(path);
284
- if (bytes === void 0) throw new Error(`No such file in content source: ${path}`);
285
- return bytes;
286
- }
287
- async exists(path) {
288
- return pathExists((await this.loaded()).keys, path);
289
- }
290
- /**
291
- * Create or overwrite `key`. The index is rewritten only when `key` is new (an
292
- * overwrite leaves the key set unchanged, so editing an item is a single `put`).
293
- */
294
- write(key, bytes) {
295
- return this.serializeMutation(async () => {
296
- const target = guarded(normalize(key));
297
- await this.store.put(target, bytes);
298
- const index = await this.loaded();
299
- if (!index.keys.includes(target)) {
300
- this.index = await saveIndex(this.store, index, [...index.keys, target]);
301
- }
302
- });
303
- }
304
- /**
305
- * Delete a file, or a whole directory subtree (every key under `key/`). Idempotent: a
306
- * path the index does not know is still deleted from the store (clearing an orphan),
307
- * and deleting nothing is a no-op.
308
- */
309
- delete(key) {
310
- return this.serializeMutation(async () => {
311
- const target = guarded(normalize(key));
312
- const index = await this.loaded();
313
- const prefix = `${target}/`;
314
- const removed = index.keys.filter((k) => k === target || k.startsWith(prefix));
315
- if (removed.length === 0) {
316
- await this.store.delete(target);
317
- return;
318
- }
319
- await Promise.all(removed.map((k) => this.store.delete(k)));
320
- const remaining = index.keys.filter((k) => k !== target && !k.startsWith(prefix));
321
- this.index = await saveIndex(this.store, index, remaining);
322
- });
323
- }
324
- /**
325
- * Move/rename a file, or a whole directory subtree (every key under `from/` is remapped
326
- * under `to/`). Clobbering an existing destination, or renaming a missing path, are
327
- * returned as data (`ok: false`), not thrown (CLAUDE §2), the same contract the host's
328
- * `/api/rename` enforces. (Mutating the reserved namespace is a programmer error and
329
- * still throws.)
330
- */
331
- rename(from, to) {
332
- return this.serializeMutation(async () => {
333
- const source = guarded(normalize(from));
334
- const dest = guarded(normalize(to));
335
- if (source === dest) return { ok: true };
336
- const index = await this.loaded();
337
- const sourcePrefix = `${source}/`;
338
- const affected = index.keys.filter((k) => k === source || k.startsWith(sourcePrefix));
339
- if (affected.length === 0) return { ok: false, reason: "missing" };
340
- const destPrefix = `${dest}/`;
341
- if (index.keys.some((k) => k === dest || k.startsWith(destPrefix))) {
342
- return { ok: false, reason: "exists" };
343
- }
344
- const moves = affected.map((k) => ({
345
- from: k,
346
- to: k === source ? dest : dest + k.slice(source.length)
347
- }));
348
- for (const move of moves) {
349
- const bytes = await this.store.get(move.from);
350
- if (bytes === void 0) continue;
351
- await this.store.put(move.to, bytes);
352
- await this.store.delete(move.from);
353
- }
354
- const movedFrom = new Set(moves.map((move) => move.from));
355
- const next = index.keys.filter((k) => !movedFrom.has(k)).concat(moves.map((m) => m.to));
356
- this.index = await saveIndex(this.store, index, next);
357
- return { ok: true };
358
- });
359
- }
360
- /**
361
- * Rebuild this store's index from the *actual* keys the underlying store holds (DESIGN
362
- * §11d), recovering files that arrived by a path that bypassed the index. A
363
- * {@link BlobStore} cannot enumerate itself, so the caller supplies the real key set
364
- * from an adapter that can (e.g. `BlobContainer.list`). Unlike calling
365
- * {@link reconcileIndex} on a fresh store, this also refreshes the cached in-memory
366
- * index, so this live instance sees the recovered files immediately.
367
- */
368
- reconcile(actualKeys) {
369
- return this.serializeMutation(async () => {
370
- const result = await reconcileIndex(this.store, actualKeys);
371
- this.index = result.index;
372
- return result;
373
- });
374
- }
195
+ // ../engine/src/template/registry.ts
196
+ var FIELD_TYPES = {
197
+ image: { type: "image", valueKind: "asset", control: "image", multiline: false },
198
+ file: { type: "file", valueKind: "asset", control: "file", multiline: false },
199
+ url: { type: "url", valueKind: "url", control: "url", multiline: false },
200
+ email: { type: "email", valueKind: "text", control: "email", multiline: false },
201
+ phone: { type: "phone", valueKind: "text", control: "phone", multiline: false },
202
+ date: { type: "date", valueKind: "text", control: "date", multiline: false },
203
+ time: { type: "time", valueKind: "text", control: "time", multiline: false },
204
+ code: { type: "code", valueKind: "text", control: "code", multiline: true },
205
+ richtext: { type: "richtext", valueKind: "html", control: "richtext", multiline: true },
206
+ authors: { type: "authors", valueKind: "authors", control: "authors", multiline: false },
207
+ text: { type: "text", valueKind: "text", control: "text", multiline: true },
208
+ shorttext: { type: "shorttext", valueKind: "text", control: "shorttext", multiline: false }
375
209
  };
376
- function guarded(key) {
377
- if (key === "" || key.startsWith(RESERVED_PREFIX)) {
378
- throw new Error(`Refusing to mutate a reserved key: ${key === "" ? "(root)" : key}`);
379
- }
380
- return key;
210
+ function isFieldType(value) {
211
+ return Object.prototype.hasOwnProperty.call(FIELD_TYPES, value);
381
212
  }
382
- function normalize(path) {
383
- return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
384
- }
385
- function childrenOf(keys, dir) {
386
- const base = normalize(dir);
387
- const prefix = base === "" ? "" : `${base}/`;
388
- const entries = /* @__PURE__ */ new Map();
389
- for (const key of keys) {
390
- if (prefix !== "" && !key.startsWith(prefix)) continue;
391
- const rest = key.slice(prefix.length);
392
- if (rest === "") continue;
393
- const slash = rest.indexOf("/");
394
- if (slash === -1) entries.set(rest, "file");
395
- else entries.set(rest.slice(0, slash), "dir");
396
- }
397
- return [...entries].map(([name, kind]) => ({ name, kind }));
398
- }
399
- function pathExists(keys, path) {
400
- const target = normalize(path);
401
- if (target === "") return true;
402
- if (keys.includes(target)) return true;
403
- const prefix = `${target}/`;
404
- return keys.some((key) => key.startsWith(prefix));
213
+ function valueKindOf(type) {
214
+ return FIELD_TYPES[type].valueKind;
405
215
  }
406
216
 
407
- // ../engine/src/content/source.ts
408
- function normalizePath(path) {
409
- return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
410
- }
411
- var InMemoryContentSource = class {
412
- files;
413
- constructor(files) {
414
- this.files = new Map(Object.entries(files).map(([key, value]) => [normalizePath(key), value]));
415
- }
416
- async readText(path) {
417
- const value = this.files.get(normalizePath(path));
418
- if (value === void 0) throw new Error(`No such file in content source: ${path}`);
419
- return typeof value === "string" ? value : new TextDecoder().decode(value);
420
- }
421
- async readBytes(path) {
422
- const value = this.files.get(normalizePath(path));
423
- if (value === void 0) throw new Error(`No such file in content source: ${path}`);
424
- return typeof value === "string" ? new TextEncoder().encode(value) : value;
217
+ // ../engine/src/template/inference.ts
218
+ var SHORT_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
219
+ "h1",
220
+ "h2",
221
+ "h3",
222
+ "h4",
223
+ "h5",
224
+ "h6",
225
+ "title",
226
+ "label",
227
+ "th",
228
+ "caption",
229
+ "figcaption",
230
+ "legend",
231
+ "summary",
232
+ "option",
233
+ "dt",
234
+ "button"
235
+ ]);
236
+ var CODE_ELEMENTS = /* @__PURE__ */ new Set(["code", "pre", "kbd", "samp"]);
237
+ var PLAIN_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
238
+ "p",
239
+ "span",
240
+ "li",
241
+ "td",
242
+ "dd",
243
+ "strong",
244
+ "em",
245
+ "b",
246
+ "i",
247
+ "small",
248
+ "time",
249
+ "a",
250
+ "figcaption",
251
+ "div",
252
+ "article",
253
+ "section",
254
+ "main",
255
+ "aside",
256
+ "blockquote"
257
+ ]);
258
+ function inferControl(ctx) {
259
+ if (ctx.annotation !== void 0 && isFieldType(ctx.annotation)) {
260
+ return ctx.annotation;
425
261
  }
426
- async exists(path) {
427
- const target = normalizePath(path);
428
- if (target === "") return true;
429
- if (this.files.has(target)) return true;
430
- const prefix = `${target}/`;
431
- for (const key of this.files.keys()) {
432
- if (key.startsWith(prefix)) return true;
262
+ if (ctx.attribute !== void 0) {
263
+ const attr = ctx.attribute;
264
+ const prefix = (ctx.valuePrefix ?? "").toLowerCase();
265
+ if (attr === "href") {
266
+ if (prefix.endsWith("mailto:")) return "email";
267
+ if (prefix.endsWith("tel:")) return "phone";
268
+ if (ctx.wholeValue) return ctx.download === true ? "file" : "url";
433
269
  }
434
- return false;
435
- }
436
- async list(dir) {
437
- const base = normalizePath(dir);
438
- const prefix = base === "" ? "" : `${base}/`;
439
- const entries = /* @__PURE__ */ new Map();
440
- for (const key of this.files.keys()) {
441
- if (prefix !== "" && !key.startsWith(prefix)) continue;
442
- const rest = key.slice(prefix.length);
443
- if (rest === "") continue;
444
- const slash = rest.indexOf("/");
445
- if (slash === -1) entries.set(rest, "file");
446
- else entries.set(rest.slice(0, slash), "dir");
270
+ if (ctx.wholeValue && attr === "src") {
271
+ if (ctx.tag === "img") return "image";
272
+ return "file";
447
273
  }
448
- return [...entries].map(([name, kind]) => ({ name, kind }));
449
- }
450
- };
451
-
452
- // ../engine/src/url/redirects.ts
453
- var DEFAULT_STATUS = 301;
454
- var OUTPUT_PATH = "_redirects";
455
- function isRule(value) {
456
- if (typeof value !== "object" || value === null) return false;
457
- const record = value;
458
- return typeof record.from === "string" && typeof record.to === "string";
459
- }
460
- function parseRedirects(raw) {
461
- if (!Array.isArray(raw)) return [];
462
- const rules = [];
463
- for (const entry of raw) {
464
- if (!isRule(entry)) continue;
465
- const status = entry.status;
466
- rules.push(
467
- typeof status === "number" && Number.isInteger(status) ? { from: entry.from, to: entry.to, status } : { from: entry.from, to: entry.to }
468
- );
469
- }
470
- return rules;
471
- }
472
- function resolveTarget(from, exact) {
473
- const seen = /* @__PURE__ */ new Set([from]);
474
- let target = exact.get(from).to;
475
- while (exact.has(target) && !seen.has(target)) {
476
- seen.add(target);
477
- target = exact.get(target).to;
274
+ if (ctx.wholeValue && ctx.tag === "source" && attr === "srcset") return "file";
275
+ if (attr === "alt" || attr === "title") return "shorttext";
276
+ return "shorttext";
478
277
  }
479
- return target;
278
+ if (CODE_ELEMENTS.has(ctx.tag)) return "code";
279
+ if (SHORT_TEXT_ELEMENTS.has(ctx.tag)) return "shorttext";
280
+ if (PLAIN_TEXT_ELEMENTS.has(ctx.tag)) return "text";
281
+ return "shorttext";
480
282
  }
481
- function buildRedirects(rules, liveUrls) {
482
- const exact = /* @__PURE__ */ new Map();
483
- const wildcard = /* @__PURE__ */ new Map();
484
- for (const rule of rules) {
485
- (rule.from.includes("*") ? wildcard : exact).set(rule.from, rule);
486
- }
487
- const resolved = [];
488
- for (const [from, rule] of exact) {
489
- if (liveUrls.has(from)) continue;
490
- const to = resolveTarget(from, exact);
491
- if (to === from) continue;
492
- resolved.push(rule.status !== void 0 ? { from, to, status: rule.status } : { from, to });
283
+
284
+ // ../engine/src/template/token.ts
285
+ var TOKEN = /\{([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/g;
286
+ function findTokens(input) {
287
+ const out = [];
288
+ for (const match of input.matchAll(TOKEN)) {
289
+ const expr = match[1] ?? "";
290
+ const path = expr.split(".");
291
+ out.push({ raw: match[0], name: path[0] ?? "", path, dotted: path.length > 1 });
493
292
  }
494
- resolved.push(...wildcard.values());
495
- if (resolved.length === 0) return void 0;
496
- resolved.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
497
- const contents = resolved.map((rule) => `${rule.from} ${rule.to} ${rule.status ?? DEFAULT_STATUS}
498
- `).join("");
499
- return { path: OUTPUT_PATH, contents };
293
+ return out;
500
294
  }
501
-
502
- // ../engine/src/content/loader.ts
503
- var SORT_FILE = "_sort.json";
504
- var REDIRECTS_FILE = "_redirects.json";
505
- var SITE_CONFIG_FILE = "_site.json";
506
- var ASSETS_DIR = "assets";
507
- var ITEM_EXT = ".json";
508
- var DEFAULT_DIRS = {
509
- content: "content",
510
- templates: "templates",
511
- components: "components",
512
- /** Static passthrough, copied verbatim to the published root (DESIGN §8). The
513
- * engine never reads it; named here so hosts/editor share one source of truth. */
514
- public: "public"
515
- };
516
- function join(...parts) {
517
- return parts.filter((part) => part !== "").join("/");
295
+ function wholeValueToken(input) {
296
+ const trimmed = input.trim();
297
+ const tokens = findTokens(trimmed);
298
+ if (tokens.length === 1 && tokens[0]?.raw === trimmed) return tokens[0];
299
+ return null;
518
300
  }
519
- function stripBom(text) {
520
- return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
301
+ function substituteTokens(input, resolve) {
302
+ return input.replace(/\{([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/g, (raw, expr) => {
303
+ const path = expr.split(".");
304
+ return resolve({ raw, name: path[0] ?? "", path, dotted: path.length > 1 });
305
+ });
521
306
  }
522
- async function loadContentTree(source, contentDir = DEFAULT_DIRS.content) {
523
- return loadDir(source, contentDir, "", "");
307
+ function literalPrefix(input) {
308
+ const brace = input.indexOf("{");
309
+ return (brace === -1 ? input : input.slice(0, brace)).toLowerCase();
524
310
  }
525
- async function loadDir(source, dirPath, slug, treePath) {
526
- const [entries, sort] = await Promise.all([source.list(dirPath), readSort(source, dirPath)]);
527
- const childMap = /* @__PURE__ */ new Map();
528
- for (const entry of entries) {
529
- if (entry.kind === "file") {
530
- if (entry.name === SORT_FILE || entry.name === REDIRECTS_FILE || entry.name === SITE_CONFIG_FILE || !entry.name.endsWith(ITEM_EXT))
531
- continue;
532
- const childSlug = entry.name.slice(0, -ITEM_EXT.length);
533
- childMap.set(
534
- childSlug,
535
- await loadItem(source, join(dirPath, entry.name), childSlug, treePath)
536
- );
537
- } else {
538
- if (entry.name === ASSETS_DIR) continue;
539
- const childPath = join(treePath, entry.name);
540
- childMap.set(
541
- entry.name,
542
- await loadDir(source, join(dirPath, entry.name), entry.name, childPath)
543
- );
544
- }
545
- }
546
- const name = sort.name ?? (slug === "" ? "" : humanize(slug));
311
+
312
+ // ../engine/src/template/fields.ts
313
+ function lengthConstraints(el) {
314
+ const min = parseLength(getAttribute(el, "data-minlength"));
315
+ const max = parseLength(getAttribute(el, "data-maxlength"));
547
316
  return {
548
- kind: "dir",
549
- slug,
550
- name,
551
- path: treePath,
552
- children: orderChildren(childMap, sort.order),
553
- ...sort.collection !== void 0 && { collection: sort.collection },
554
- ...sort.defaultTemplate !== void 0 && { defaultTemplate: sort.defaultTemplate }
317
+ ...min !== void 0 && { minLength: min },
318
+ ...max !== void 0 && { maxLength: max }
555
319
  };
556
320
  }
557
- async function loadItem(source, filePath, slug, parentPath2) {
558
- const raw = await source.readText(filePath);
559
- try {
560
- const item = parseContentItem(JSON.parse(raw));
561
- return { kind: "item", slug, path: join(parentPath2, slug), item };
562
- } catch (error) {
563
- throw new Error(`Failed to load ${filePath}: ${error.message}`);
564
- }
565
- }
566
- async function readSort(source, dirPath) {
567
- const path = join(dirPath, SORT_FILE);
568
- if (!await source.exists(path)) return { order: [] };
569
- try {
570
- return parseSortFile(JSON.parse(await source.readText(path)));
571
- } catch {
572
- return { order: [] };
573
- }
321
+ function parseLength(raw) {
322
+ if (raw === void 0) return void 0;
323
+ const value = Number(raw);
324
+ return Number.isInteger(value) && value >= 0 ? value : void 0;
574
325
  }
575
- async function loadRedirects(source, contentDir = DEFAULT_DIRS.content) {
576
- const path = join(contentDir, REDIRECTS_FILE);
577
- if (!await source.exists(path)) return [];
578
- try {
579
- return parseRedirects(JSON.parse(stripBom(await source.readText(path))));
580
- } catch {
581
- return [];
582
- }
326
+ function deriveFields(template, components = /* @__PURE__ */ new Map()) {
327
+ return deriveFromSource(template, components, /* @__PURE__ */ new Set());
583
328
  }
584
- async function loadSiteConfig(source, contentDir = DEFAULT_DIRS.content) {
585
- const path = join(contentDir, SITE_CONFIG_FILE);
586
- if (!await source.exists(path)) return {};
587
- try {
588
- const raw = JSON.parse(stripBom(await source.readText(path)));
589
- const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
590
- return baseUrl !== "" ? { baseUrl } : {};
591
- } catch {
592
- return {};
329
+ function componentPropTypes(tag, components, seen) {
330
+ if (seen.has(tag)) return /* @__PURE__ */ new Map();
331
+ const source = components.get(tag);
332
+ if (source === void 0) return /* @__PURE__ */ new Map();
333
+ const fields = deriveFromSource(source, components, new Set(seen).add(tag));
334
+ return new Map(fields.map((field) => [field.name, field.type]));
335
+ }
336
+ function deriveFromSource(source, components, seen) {
337
+ const order = [];
338
+ const fields = /* @__PURE__ */ new Map();
339
+ const ensure = (name) => {
340
+ const existing = fields.get(name);
341
+ if (existing !== void 0) return existing;
342
+ const created = {
343
+ name,
344
+ type: "shorttext",
345
+ label: humanize(name),
346
+ required: false,
347
+ multiline: false
348
+ };
349
+ fields.set(name, created);
350
+ order.push(name);
351
+ return created;
352
+ };
353
+ const record = (name, type) => {
354
+ if (fields.has(name)) return;
355
+ const field = ensure(name);
356
+ field.type = type;
357
+ field.multiline = FIELD_TYPES[type].multiline;
358
+ };
359
+ const applyAnnotations = (el) => {
360
+ const named = getAttribute(el, "data-field") ?? firstTokenName(el);
361
+ if (named === void 0) return;
362
+ const field = ensure(named);
363
+ if (hasAttribute(el, "data-required")) field.required = true;
364
+ const help = getAttribute(el, "data-help");
365
+ if (help !== void 0) field.help = help;
366
+ const label = getAttribute(el, "data-label");
367
+ if (label !== void 0) field.label = label;
368
+ const { minLength, maxLength } = lengthConstraints(el);
369
+ if (minLength !== void 0) field.minLength = minLength;
370
+ if (maxLength !== void 0) field.maxLength = maxLength;
371
+ };
372
+ const recordFreeText = (text) => {
373
+ const whole = wholeValueToken(text);
374
+ for (const token of findTokens(text)) {
375
+ if (token.dotted) continue;
376
+ record(
377
+ token.name,
378
+ inferControl({
379
+ tag: "",
380
+ wholeValue: whole?.name === token.name
381
+ })
382
+ );
383
+ }
384
+ };
385
+ const visit = (nodes) => {
386
+ for (const node of nodes) {
387
+ if (!isElement(node)) {
388
+ if (isTextNode(node)) recordFreeText(node.value);
389
+ continue;
390
+ }
391
+ const eachField = getAttribute(node, "data-each");
392
+ if (eachField !== void 0) {
393
+ const field = ensure(eachField);
394
+ field.type = "url";
395
+ field.multiple = true;
396
+ const pickFrom = getAttribute(node, "data-pick-from");
397
+ if (pickFrom !== void 0) field.pickFrom = pickFrom;
398
+ applyAnnotations(node);
399
+ continue;
400
+ }
401
+ if (hasAttribute(node, "data-each-in")) continue;
402
+ const annotation = getAttribute(node, "data-type");
403
+ const propTypes = components.has(node.tagName) ? componentPropTypes(node.tagName, components, seen) : void 0;
404
+ for (const attr of node.attrs) {
405
+ const whole = wholeValueToken(attr.value);
406
+ for (const token of findTokens(attr.value)) {
407
+ if (token.dotted) continue;
408
+ const isWhole = whole?.name === token.name;
409
+ const propType = isWhole ? propTypes?.get(attr.name) : void 0;
410
+ record(
411
+ token.name,
412
+ propType ?? inferControl({
413
+ tag: node.tagName,
414
+ attribute: attr.name,
415
+ wholeValue: isWhole,
416
+ valuePrefix: literalPrefix(attr.value),
417
+ download: hasAttribute(node, "download"),
418
+ ...annotation !== void 0 && { annotation }
419
+ })
420
+ );
421
+ }
422
+ }
423
+ for (const child of node.childNodes) {
424
+ if (!isTextNode(child)) continue;
425
+ const whole = wholeValueToken(child.value);
426
+ for (const token of findTokens(child.value)) {
427
+ if (token.dotted) continue;
428
+ record(
429
+ token.name,
430
+ inferControl({
431
+ tag: node.tagName,
432
+ wholeValue: whole?.name === token.name,
433
+ ...annotation !== void 0 && { annotation }
434
+ })
435
+ );
436
+ }
437
+ }
438
+ applyAnnotations(node);
439
+ visit(node.childNodes);
440
+ }
441
+ };
442
+ visit(parseNodes(source));
443
+ return order.map((name) => freeze(fields.get(name)));
444
+ }
445
+ function firstTokenName(el) {
446
+ for (const attr of el.attrs) {
447
+ const token = findTokens(attr.value).find((candidate) => !candidate.dotted);
448
+ if (token !== void 0) return token.name;
449
+ }
450
+ for (const child of el.childNodes) {
451
+ if (isTextNode(child)) {
452
+ const token = findTokens(child.value).find((candidate) => !candidate.dotted);
453
+ if (token !== void 0) return token.name;
454
+ }
593
455
  }
456
+ return void 0;
594
457
  }
595
- function orderChildren(map, order) {
596
- const out = [];
597
- const used = /* @__PURE__ */ new Set();
598
- for (const slug of order) {
599
- const node = map.get(slug);
600
- if (node !== void 0) {
601
- out.push(node);
602
- used.add(slug);
458
+ function freeze(field) {
459
+ return {
460
+ name: field.name,
461
+ type: field.type,
462
+ label: field.label,
463
+ required: field.required,
464
+ multiline: field.multiline,
465
+ ...field.help !== void 0 && { help: field.help },
466
+ ...field.minLength !== void 0 && { minLength: field.minLength },
467
+ ...field.maxLength !== void 0 && { maxLength: field.maxLength },
468
+ ...field.multiple !== void 0 && { multiple: field.multiple },
469
+ ...field.pickFrom !== void 0 && { pickFrom: field.pickFrom }
470
+ };
471
+ }
472
+
473
+ // ../engine/src/template/stamp.ts
474
+ function isDestructiveTypeChange(from, to) {
475
+ if (from === to) return false;
476
+ const fromKind = valueKindOf(from);
477
+ const toKind = valueKindOf(to);
478
+ if (toKind === "html") return false;
479
+ if (fromKind === "html") return true;
480
+ if (from === "date" && to === "time" || from === "time" && to === "date") return true;
481
+ const isFreeText = (t) => t === "shorttext" || t === "text" || t === "code" || t === "email" || t === "phone";
482
+ if (isFreeText(from) && (to === "date" || to === "time")) return true;
483
+ if (fromKind === toKind) return false;
484
+ if (toKind === "text") return false;
485
+ return true;
486
+ }
487
+ function computeSchemaDelta(oldFields, newFields) {
488
+ const oldByName = new Map(oldFields.map((f) => [f.name, f]));
489
+ const newByName = new Map(newFields.map((f) => [f.name, f]));
490
+ const added = [];
491
+ const removed = [];
492
+ const typeChanged = [];
493
+ for (const [name, oldField] of oldByName) {
494
+ const newField = newByName.get(name);
495
+ if (newField === void 0) {
496
+ removed.push({ name, type: oldField.type });
497
+ } else if (newField.type !== oldField.type) {
498
+ typeChanged.push({
499
+ name,
500
+ from: oldField.type,
501
+ to: newField.type,
502
+ destructive: isDestructiveTypeChange(oldField.type, newField.type)
503
+ });
603
504
  }
604
505
  }
605
- for (const [slug, node] of map) {
606
- if (!used.has(slug)) out.push(node);
506
+ for (const [name, newField] of newByName) {
507
+ if (!oldByName.has(name)) added.push({ name, type: newField.type });
607
508
  }
608
- return out;
509
+ return {
510
+ added: added.sort((a, b) => a.name.localeCompare(b.name)),
511
+ removed: removed.sort((a, b) => a.name.localeCompare(b.name)),
512
+ typeChanged: typeChanged.sort((a, b) => a.name.localeCompare(b.name))
513
+ };
609
514
  }
610
- function loadComponents(source, componentsDir = DEFAULT_DIRS.components) {
611
- return loadComponentFiles(source, componentsDir, "html");
515
+ function detectStamp(oldSource, newSource, components = /* @__PURE__ */ new Map()) {
516
+ const oldFields = deriveFields(oldSource, components);
517
+ const newFields = deriveFields(newSource, components);
518
+ const delta = computeSchemaDelta(oldFields, newFields);
519
+ const destructive = delta.removed.length > 0 || delta.typeChanged.some((change) => change.destructive);
520
+ return {
521
+ destructive,
522
+ removedTokens: delta.removed.map((r) => r.name),
523
+ addedTokens: delta.added.map((a) => a.name),
524
+ typeChanged: delta.typeChanged
525
+ };
612
526
  }
613
- function loadComponentStyles(source, componentsDir = DEFAULT_DIRS.components) {
614
- return loadComponentFiles(source, componentsDir, "css");
527
+
528
+ // ../engine/src/template/versions.ts
529
+ var VERSION_PATTERN = /^(.+)@v([1-9]\d*)$/;
530
+ function parseVersionedName(name) {
531
+ const match = VERSION_PATTERN.exec(name);
532
+ if (!match) return null;
533
+ const base = match[1];
534
+ if (VERSION_PATTERN.test(base)) return null;
535
+ return { base, version: Number.parseInt(match[2], 10) };
536
+ }
537
+ function isVersionedTemplateName(name) {
538
+ return parseVersionedName(name) !== null;
539
+ }
540
+ function baseTemplateName(name) {
541
+ const parsed = parseVersionedName(name);
542
+ return parsed ? parsed.base : name;
543
+ }
544
+ function versionNumber(name) {
545
+ const parsed = parseVersionedName(name);
546
+ return parsed ? parsed.version : null;
547
+ }
548
+ function snapshotName(base, version) {
549
+ if (!Number.isInteger(version) || version < 1) {
550
+ throw new Error(`Snapshot version must be a positive integer, got ${version}`);
551
+ }
552
+ if (isVersionedTemplateName(base)) {
553
+ throw new Error(`snapshotName base "${base}" is already versioned`);
554
+ }
555
+ return `${base}@v${version}`;
556
+ }
557
+ function nextVersionNumber(existingNames) {
558
+ let max = 0;
559
+ for (const name of existingNames) {
560
+ const n = versionNumber(name);
561
+ if (n !== null && n > max) max = n;
562
+ }
563
+ return max + 1;
564
+ }
565
+ function isReservedVersionedPath(path) {
566
+ if (!path.endsWith(".html")) return false;
567
+ if (!(path.startsWith("templates/") || path.startsWith("components/"))) return false;
568
+ const stem = path.slice(0, -".html".length);
569
+ const lastSlash = stem.lastIndexOf("/");
570
+ const baseName = stem.slice(lastSlash + 1);
571
+ return isVersionedTemplateName(baseName);
615
572
  }
616
- function loadComponentScripts(source, componentsDir = DEFAULT_DIRS.components) {
617
- return loadComponentFiles(source, componentsDir, "js");
573
+
574
+ // ../engine/src/store/content-index.ts
575
+ var RESERVED_PREFIX = ".nanoesis/";
576
+ var INDEX_KEY = `${RESERVED_PREFIX}index.json`;
577
+ var BACKUP_RING_SIZE = 3;
578
+ function backupKey(slot) {
579
+ return `${RESERVED_PREFIX}index.bak.${slot}`;
618
580
  }
619
- async function loadComponentFiles(source, componentsDir, extension) {
620
- const suffix = `.${extension}`;
621
- const map = /* @__PURE__ */ new Map();
622
- const walk = async (dir) => {
623
- if (!await source.exists(dir)) return;
624
- for (const entry of await source.list(dir)) {
625
- const full = join(dir, entry.name);
626
- if (entry.kind === "dir") {
627
- await walk(full);
628
- } else if (entry.name.endsWith(suffix)) {
629
- const key = entry.name.slice(0, -suffix.length).toLowerCase();
630
- map.set(key, await source.readText(full));
631
- }
581
+ function emptyIndex() {
582
+ return freezeIndex(0, []);
583
+ }
584
+ async function loadIndex(store) {
585
+ const live = parseIndex(await store.get(INDEX_KEY));
586
+ if (live !== void 0) return live;
587
+ let best;
588
+ for (let slot = 0; slot < BACKUP_RING_SIZE; slot += 1) {
589
+ const candidate = parseIndex(await store.get(backupKey(slot)));
590
+ if (candidate !== void 0 && (best === void 0 || candidate.version > best.version)) {
591
+ best = candidate;
632
592
  }
633
- };
634
- await walk(componentsDir);
635
- return map;
593
+ }
594
+ return best ?? emptyIndex();
636
595
  }
637
- async function loadTemplate(source, name, templatesDir = DEFAULT_DIRS.templates) {
638
- return source.readText(join(templatesDir, `${name}.html`));
596
+ async function saveIndex(store, prev, nextKeys) {
597
+ await store.put(backupKey(prev.version % BACKUP_RING_SIZE), serialize2(prev));
598
+ const next = freezeIndex(prev.version + 1, nextKeys);
599
+ await store.put(INDEX_KEY, serialize2(next));
600
+ return next;
639
601
  }
640
- function loadTemplateStyle(source, name, templatesDir = DEFAULT_DIRS.templates) {
641
- return tryReadText(source, join(templatesDir, `${name}.css`));
602
+ async function reconcileIndex(store, actualKeys) {
603
+ const prev = await loadIndex(store);
604
+ const keys = [...new Set(actualKeys.filter((key) => !key.startsWith(RESERVED_PREFIX)))].sort();
605
+ const prevKeys = new Set(prev.keys);
606
+ const nextKeys = new Set(keys);
607
+ const added = keys.filter((key) => !prevKeys.has(key));
608
+ const removed = prev.keys.filter((key) => !nextKeys.has(key));
609
+ if (added.length === 0 && removed.length === 0) return { index: prev, added, removed };
610
+ return { index: await saveIndex(store, prev, keys), added, removed };
642
611
  }
643
- function loadTemplateScript(source, name, templatesDir = DEFAULT_DIRS.templates) {
644
- return tryReadText(source, join(templatesDir, `${name}.js`));
612
+ function freezeIndex(version, keys) {
613
+ const sorted = [...new Set(keys)].sort();
614
+ return { version, keys: sorted, checksum: checksumOf(version, sorted) };
645
615
  }
646
- var DOCUMENT_SHELL = "document";
647
- function loadDocumentShell(source, templatesDir = DEFAULT_DIRS.templates) {
648
- return tryReadText(source, join(templatesDir, `${DOCUMENT_SHELL}.html`));
616
+ function serialize2(index) {
617
+ return new TextEncoder().encode(`${JSON.stringify(index, null, 2)}
618
+ `);
649
619
  }
650
- async function tryReadText(source, path) {
620
+ function parseIndex(bytes) {
621
+ if (bytes === void 0) return void 0;
622
+ let raw;
651
623
  try {
652
- return await source.readText(path);
624
+ raw = JSON.parse(new TextDecoder().decode(bytes));
653
625
  } catch {
654
626
  return void 0;
655
627
  }
628
+ if (typeof raw !== "object" || raw === null) return void 0;
629
+ const { version, keys, checksum } = raw;
630
+ if (typeof version !== "number" || typeof checksum !== "string" || !Array.isArray(keys)) {
631
+ return void 0;
632
+ }
633
+ const stringKeys = keys.filter((key) => typeof key === "string");
634
+ if (stringKeys.length !== keys.length) return void 0;
635
+ const rebuilt = freezeIndex(version, stringKeys);
636
+ return rebuilt.checksum === checksum ? rebuilt : void 0;
656
637
  }
657
-
658
- // ../engine/src/template/registry.ts
659
- var FIELD_TYPES = {
660
- image: { type: "image", valueKind: "asset", control: "image", multiline: false },
661
- file: { type: "file", valueKind: "asset", control: "file", multiline: false },
662
- url: { type: "url", valueKind: "url", control: "url", multiline: false },
663
- email: { type: "email", valueKind: "text", control: "email", multiline: false },
664
- phone: { type: "phone", valueKind: "text", control: "phone", multiline: false },
665
- date: { type: "date", valueKind: "text", control: "date", multiline: false },
666
- time: { type: "time", valueKind: "text", control: "time", multiline: false },
667
- code: { type: "code", valueKind: "text", control: "code", multiline: true },
668
- richtext: { type: "richtext", valueKind: "html", control: "richtext", multiline: true },
669
- authors: { type: "authors", valueKind: "authors", control: "authors", multiline: false },
670
- text: { type: "text", valueKind: "text", control: "text", multiline: true },
671
- shorttext: { type: "shorttext", valueKind: "text", control: "shorttext", multiline: false }
672
- };
673
- function isFieldType(value) {
674
- return Object.prototype.hasOwnProperty.call(FIELD_TYPES, value);
675
- }
676
- function valueKindOf(type) {
677
- return FIELD_TYPES[type].valueKind;
638
+ function checksumOf(version, sortedKeys) {
639
+ const text = `${version}
640
+ ${sortedKeys.join("\n")}`;
641
+ let hash = 2166136261;
642
+ for (let i = 0; i < text.length; i += 1) {
643
+ hash ^= text.charCodeAt(i);
644
+ hash = Math.imul(hash, 16777619);
645
+ }
646
+ return (hash >>> 0).toString(16).padStart(8, "0");
678
647
  }
679
648
 
680
- // ../engine/src/template/inference.ts
681
- var SHORT_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
682
- "h1",
683
- "h2",
684
- "h3",
685
- "h4",
686
- "h5",
687
- "h6",
688
- "title",
689
- "label",
690
- "th",
691
- "caption",
692
- "figcaption",
693
- "legend",
694
- "summary",
695
- "option",
696
- "dt",
697
- "button"
698
- ]);
699
- var CODE_ELEMENTS = /* @__PURE__ */ new Set(["code", "pre", "kbd", "samp"]);
700
- var PLAIN_TEXT_ELEMENTS = /* @__PURE__ */ new Set([
701
- "p",
702
- "span",
703
- "li",
704
- "td",
705
- "dd",
706
- "strong",
707
- "em",
708
- "b",
709
- "i",
710
- "small",
711
- "time",
712
- "a",
713
- "figcaption",
714
- "div",
715
- "article",
716
- "section",
717
- "main",
718
- "aside",
719
- "blockquote"
720
- ]);
721
- function inferControl(ctx) {
722
- if (ctx.annotation !== void 0 && isFieldType(ctx.annotation)) {
723
- return ctx.annotation;
649
+ // ../engine/src/store/indexed-store.ts
650
+ var IndexedStore = class {
651
+ constructor(store) {
652
+ this.store = store;
724
653
  }
725
- if (ctx.attribute !== void 0) {
726
- const attr = ctx.attribute;
727
- const prefix = (ctx.valuePrefix ?? "").toLowerCase();
728
- if (attr === "href") {
729
- if (prefix.endsWith("mailto:")) return "email";
730
- if (prefix.endsWith("tel:")) return "phone";
731
- if (ctx.wholeValue) return ctx.download === true ? "file" : "url";
732
- }
733
- if (ctx.wholeValue && attr === "src") {
734
- if (ctx.tag === "img") return "image";
735
- return "file";
736
- }
737
- if (ctx.wholeValue && ctx.tag === "source" && attr === "srcset") return "file";
738
- if (attr === "alt" || attr === "title") return "shorttext";
739
- return "shorttext";
654
+ store;
655
+ index;
656
+ loading;
657
+ /**
658
+ * Per-instance mutex (DESIGN §11d): every mutation (`write`/`delete`/`rename`/`reconcile`)
659
+ * runs through {@link serializeMutation}, which chains onto the previous mutation's
660
+ * completion. Without it, two concurrent mutations both read the cached `this.index`,
661
+ * each compute their "next" key set from that stale snapshot, and the second
662
+ * `saveIndex` clobbers the first the file writes land in blob, but the index keeps
663
+ * stale references or loses fresh ones (the "I deleted it and it's still there" or
664
+ * "I added it and it didn't appear" bugs surfaced dogfooding the marketing site,
665
+ * 2026-05-28). Reads (`list`/`exists`/`readBytes`) are *not* serialised; an
666
+ * in-flight mutation simply means a reader sees the pre-mutation index, eventually
667
+ * consistent and safe. Crashes inside `work()` release the lock so a single failure
668
+ * does not deadlock subsequent operations.
669
+ */
670
+ mutationLock = Promise.resolve();
671
+ /** The index, loaded once on first need and cached (mutations replace the cached copy). */
672
+ async loaded() {
673
+ if (this.index !== void 0) return this.index;
674
+ this.loading ??= loadIndex(this.store);
675
+ this.index = await this.loading;
676
+ return this.index;
740
677
  }
741
- if (CODE_ELEMENTS.has(ctx.tag)) return "code";
742
- if (SHORT_TEXT_ELEMENTS.has(ctx.tag)) return "shorttext";
743
- if (PLAIN_TEXT_ELEMENTS.has(ctx.tag)) return "text";
744
- return "shorttext";
745
- }
746
-
747
- // ../engine/src/template/token.ts
748
- var TOKEN = /\{([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/g;
749
- function findTokens(input) {
750
- const out = [];
751
- for (const match of input.matchAll(TOKEN)) {
752
- const expr = match[1] ?? "";
753
- const path = expr.split(".");
754
- out.push({ raw: match[0], name: path[0] ?? "", path, dotted: path.length > 1 });
678
+ serializeMutation(work) {
679
+ const previous = this.mutationLock;
680
+ let release;
681
+ this.mutationLock = new Promise((resolve) => {
682
+ release = resolve;
683
+ });
684
+ return (async () => {
685
+ try {
686
+ await previous;
687
+ return await work();
688
+ } finally {
689
+ release();
690
+ }
691
+ })();
755
692
  }
756
- return out;
757
- }
758
- function wholeValueToken(input) {
759
- const trimmed = input.trim();
760
- const tokens = findTokens(trimmed);
761
- if (tokens.length === 1 && tokens[0]?.raw === trimmed) return tokens[0];
762
- return null;
763
- }
764
- function substituteTokens(input, resolve) {
765
- return input.replace(/\{([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/g, (raw, expr) => {
766
- const path = expr.split(".");
767
- return resolve({ raw, name: path[0] ?? "", path, dotted: path.length > 1 });
768
- });
769
- }
770
- function literalPrefix(input) {
771
- const brace = input.indexOf("{");
772
- return (brace === -1 ? input : input.slice(0, brace)).toLowerCase();
773
- }
774
-
775
- // ../engine/src/html/escape.ts
776
- function escapeHtmlText(value) {
777
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
778
- }
779
- function escapeHtmlAttribute(value) {
780
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
781
- }
782
- function escapeJsonStringContent(value) {
783
- const literal = JSON.stringify(value);
784
- return literal.slice(1, -1);
785
- }
786
- function sanitizeUrl(url) {
787
- let cleaned = "";
788
- for (const ch of url) {
789
- const code = ch.codePointAt(0);
790
- if (code !== void 0 && code > 32) cleaned += ch;
693
+ async list(dir) {
694
+ return childrenOf((await this.loaded()).keys, dir);
791
695
  }
792
- const scheme = cleaned.toLowerCase();
793
- if (scheme.startsWith("javascript:") || scheme.startsWith("vbscript:")) {
794
- return "#";
696
+ async readText(path) {
697
+ return new TextDecoder().decode(await this.readBytes(path));
795
698
  }
796
- return url;
797
- }
798
-
799
- // ../engine/src/authors/authors.ts
800
- function toAuthorRefs(value) {
801
- if (!Array.isArray(value)) return [];
802
- const refs = [];
803
- for (const entry of value) {
804
- if (entry === null || typeof entry !== "object") continue;
805
- const record = entry;
806
- const name = typeof record.name === "string" ? record.name : "";
807
- if (name.trim() === "") continue;
808
- refs.push({
809
- name,
810
- ...typeof record.user === "string" && record.user !== "" && { user: record.user },
811
- ...typeof record.href === "string" && record.href !== "" && { href: record.href }
699
+ async readBytes(path) {
700
+ const bytes = await this.store.get(path);
701
+ if (bytes === void 0) throw new Error(`No such file in content source: ${path}`);
702
+ return bytes;
703
+ }
704
+ async exists(path) {
705
+ return pathExists((await this.loaded()).keys, path);
706
+ }
707
+ /**
708
+ * Create or overwrite `key`. The index is rewritten only when `key` is new (an
709
+ * overwrite leaves the key set unchanged, so editing an item is a single `put`).
710
+ *
711
+ * **Auto-stamp** (PROPOSAL §4.1): a destructive write to a template or component
712
+ * path (one that removes a token from the existing source) snapshots the prior
713
+ * bytes to `<base>@v<N+1>.html` before the new bytes land. Order is
714
+ * **current first, snapshot second** (§16.3): if the snapshot copy fails after
715
+ * the current write succeeded, the site stays consistent — only the migration
716
+ * UX's left-pane comparison is unavailable for items affected by this stamp,
717
+ * surfaced as `templates.stamp-incomplete`.
718
+ *
719
+ * Direct writes to a reserved-version path are refused (snapshots are
720
+ * immutable; the user retires them via `delete`, not by overwriting them).
721
+ */
722
+ write(key, bytes) {
723
+ return this.serializeMutation(async () => {
724
+ const target = guarded(normalize(key));
725
+ if (isReservedVersionedPath(target)) {
726
+ throw new Error(
727
+ `Refusing to write to a reserved-version path: ${target} (snapshots are immutable; retire via delete)`
728
+ );
729
+ }
730
+ const stampCandidate = stampTargetOf(target);
731
+ const previousBytes = stampCandidate !== void 0 ? await this.store.get(target) : void 0;
732
+ let stampDecision;
733
+ let schemaDelta;
734
+ if (stampCandidate !== void 0 && previousBytes !== void 0) {
735
+ const oldSource = new TextDecoder().decode(previousBytes);
736
+ const newSource = new TextDecoder().decode(bytes);
737
+ const oldFields = deriveFields(oldSource);
738
+ const newFields = deriveFields(newSource);
739
+ schemaDelta = computeSchemaDelta(oldFields, newFields);
740
+ const destructive = schemaDelta.removed.length > 0 || schemaDelta.typeChanged.some((change) => change.destructive);
741
+ if (destructive) {
742
+ const index2 = await this.loaded();
743
+ const siblings = index2.keys.filter((k) => k.startsWith(`${stampCandidate.dir}/`) && k.endsWith(".html")).map((k) => k.slice(stampCandidate.dir.length + 1, -".html".length));
744
+ const version = nextVersionNumber(siblings);
745
+ stampDecision = {
746
+ snapshotPath: `${stampCandidate.dir}/${stampCandidate.name}@v${version}.html`,
747
+ bytes: previousBytes,
748
+ version
749
+ };
750
+ }
751
+ }
752
+ await this.store.put(target, bytes);
753
+ const index = await this.loaded();
754
+ let nextIndex = index;
755
+ if (!nextIndex.keys.includes(target)) {
756
+ nextIndex = await saveIndex(this.store, nextIndex, [...nextIndex.keys, target]);
757
+ }
758
+ let stamped;
759
+ let stampIncomplete;
760
+ if (stampDecision !== void 0) {
761
+ try {
762
+ await this.store.put(stampDecision.snapshotPath, stampDecision.bytes);
763
+ if (!nextIndex.keys.includes(stampDecision.snapshotPath)) {
764
+ nextIndex = await saveIndex(this.store, nextIndex, [
765
+ ...nextIndex.keys,
766
+ stampDecision.snapshotPath
767
+ ]);
768
+ }
769
+ stamped = {
770
+ name: stampCandidate.name,
771
+ version: stampDecision.version,
772
+ snapshotPath: stampDecision.snapshotPath
773
+ };
774
+ } catch {
775
+ stampIncomplete = true;
776
+ }
777
+ }
778
+ this.index = nextIndex;
779
+ return {
780
+ ...stamped !== void 0 && { stamped },
781
+ ...stampIncomplete === true && { stampIncomplete: true },
782
+ ...schemaDelta !== void 0 && { schemaDelta }
783
+ };
812
784
  });
813
785
  }
814
- return refs;
815
- }
816
- function joinAuthors(names) {
817
- if (names.length === 0) return "";
818
- if (names.length === 1) return names[0] ?? "";
819
- const last = names[names.length - 1] ?? "";
820
- const head = names.slice(0, -1).join(", ");
821
- return `${head} and ${last}`;
822
- }
823
- function renderAuthors(value, directory) {
824
- const names = toAuthorRefs(value).map((ref) => {
825
- const resolved = ref.user !== void 0 ? directory?.(ref.user) : void 0;
826
- return escapeHtmlText(resolved?.displayName ?? ref.name);
827
- });
828
- return joinAuthors(names);
829
- }
830
-
831
- // ../engine/src/html/dom.ts
832
- import { parse, parseFragment, serialize as serialize2 } from "parse5";
833
- function isElement(node) {
834
- return "tagName" in node;
835
- }
836
- function isTextNode(node) {
837
- return node.nodeName === "#text";
786
+ /**
787
+ * Manually stamp a snapshot of the current `<kind>/<name>.html` to the next available
788
+ * `<name>@v<N+1>.html` slot. Uses the same internal `this.store.put` pathway the
789
+ * auto-stamp uses (so the public `write`'s reserved-version refusal doesn't apply),
790
+ * and runs under the mutation mutex so a concurrent destructive auto-stamp can't
791
+ * race with a manual stamp.
792
+ */
793
+ stamp(name, kind) {
794
+ return this.serializeMutation(async () => {
795
+ if (isVersionedTemplateName(name)) {
796
+ throw new Error(`stamp: cannot stamp an already-versioned name (${name})`);
797
+ }
798
+ const dir = kind === "template" ? "templates" : "components";
799
+ const currentPath = `${dir}/${name}.html`;
800
+ const bytes = await this.store.get(currentPath);
801
+ if (bytes === void 0) {
802
+ throw new Error(`stamp: ${currentPath} does not exist (nothing to snapshot)`);
803
+ }
804
+ const index = await this.loaded();
805
+ const siblings = index.keys.filter((k) => k.startsWith(`${dir}/`) && k.endsWith(".html")).map((k) => k.slice(dir.length + 1, -".html".length));
806
+ const version = nextVersionNumber(siblings);
807
+ const snapshotPath = `${dir}/${name}@v${version}.html`;
808
+ await this.store.put(snapshotPath, bytes);
809
+ if (!index.keys.includes(snapshotPath)) {
810
+ this.index = await saveIndex(this.store, index, [...index.keys, snapshotPath]);
811
+ } else {
812
+ this.index = index;
813
+ }
814
+ return { name, version, snapshotPath };
815
+ });
816
+ }
817
+ /**
818
+ * Delete a file, or a whole directory subtree (every key under `key/`). Idempotent: a
819
+ * path the index does not know is still deleted from the store (clearing an orphan),
820
+ * and deleting nothing is a no-op.
821
+ */
822
+ delete(key) {
823
+ return this.serializeMutation(async () => {
824
+ const target = guarded(normalize(key));
825
+ const index = await this.loaded();
826
+ const prefix = `${target}/`;
827
+ const removed = index.keys.filter((k) => k === target || k.startsWith(prefix));
828
+ if (removed.length === 0) {
829
+ await this.store.delete(target);
830
+ return;
831
+ }
832
+ await Promise.all(removed.map((k) => this.store.delete(k)));
833
+ const remaining = index.keys.filter((k) => k !== target && !k.startsWith(prefix));
834
+ this.index = await saveIndex(this.store, index, remaining);
835
+ });
836
+ }
837
+ /**
838
+ * Move/rename a file, or a whole directory subtree (every key under `from/` is remapped
839
+ * under `to/`). Clobbering an existing destination, or renaming a missing path, are
840
+ * returned as data (`ok: false`), not thrown (CLAUDE §2), the same contract the host's
841
+ * `/api/rename` enforces. (Mutating the reserved namespace is a programmer error and
842
+ * still throws.)
843
+ */
844
+ rename(from, to) {
845
+ return this.serializeMutation(async () => {
846
+ const source = guarded(normalize(from));
847
+ const dest = guarded(normalize(to));
848
+ if (source === dest) return { ok: true };
849
+ if (isReservedVersionedPath(source) || isReservedVersionedPath(dest)) {
850
+ return { ok: false, reason: "reserved-version" };
851
+ }
852
+ const index = await this.loaded();
853
+ const sourcePrefix = `${source}/`;
854
+ const affected = index.keys.filter((k) => k === source || k.startsWith(sourcePrefix));
855
+ if (affected.length === 0) return { ok: false, reason: "missing" };
856
+ const destPrefix = `${dest}/`;
857
+ if (index.keys.some((k) => k === dest || k.startsWith(destPrefix))) {
858
+ return { ok: false, reason: "exists" };
859
+ }
860
+ const moves = affected.map((k) => ({
861
+ from: k,
862
+ to: k === source ? dest : dest + k.slice(source.length)
863
+ }));
864
+ for (const move of moves) {
865
+ const bytes = await this.store.get(move.from);
866
+ if (bytes === void 0) continue;
867
+ await this.store.put(move.to, bytes);
868
+ await this.store.delete(move.from);
869
+ }
870
+ const movedFrom = new Set(moves.map((move) => move.from));
871
+ const next = index.keys.filter((k) => !movedFrom.has(k)).concat(moves.map((m) => m.to));
872
+ this.index = await saveIndex(this.store, index, next);
873
+ return { ok: true };
874
+ });
875
+ }
876
+ /**
877
+ * Rebuild this store's index from the *actual* keys the underlying store holds (DESIGN
878
+ * §11d), recovering files that arrived by a path that bypassed the index. A
879
+ * {@link BlobStore} cannot enumerate itself, so the caller supplies the real key set
880
+ * from an adapter that can (e.g. `BlobContainer.list`). Unlike calling
881
+ * {@link reconcileIndex} on a fresh store, this also refreshes the cached in-memory
882
+ * index, so this live instance sees the recovered files immediately.
883
+ */
884
+ reconcile(actualKeys) {
885
+ return this.serializeMutation(async () => {
886
+ const result = await reconcileIndex(this.store, actualKeys);
887
+ this.index = result.index;
888
+ return result;
889
+ });
890
+ }
891
+ };
892
+ function stampTargetOf(target) {
893
+ if (!target.endsWith(".html")) return void 0;
894
+ const dir = target.startsWith("templates/") ? "templates" : target.startsWith("components/") ? "components" : void 0;
895
+ if (dir === void 0) return void 0;
896
+ const stem = target.slice(dir.length + 1, -".html".length);
897
+ if (stem === "" || stem.includes("@v")) return void 0;
898
+ return { dir, name: stem };
838
899
  }
839
- function getAttribute(el, name) {
840
- return el.attrs.find((attr) => attr.name === name)?.value;
900
+ function guarded(key) {
901
+ if (key === "" || key.startsWith(RESERVED_PREFIX)) {
902
+ throw new Error(`Refusing to mutate a reserved key: ${key === "" ? "(root)" : key}`);
903
+ }
904
+ return key;
841
905
  }
842
- function hasAttribute(el, name) {
843
- return el.attrs.some((attr) => attr.name === name);
906
+ function normalize(path) {
907
+ return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
844
908
  }
845
- function setAttribute(el, name, value) {
846
- const existing = el.attrs.find((attr) => attr.name === name);
847
- if (existing) existing.value = value;
848
- else el.attrs.push({ name, value });
909
+ function childrenOf(keys, dir) {
910
+ const base = normalize(dir);
911
+ const prefix = base === "" ? "" : `${base}/`;
912
+ const entries = /* @__PURE__ */ new Map();
913
+ for (const key of keys) {
914
+ if (prefix !== "" && !key.startsWith(prefix)) continue;
915
+ const rest = key.slice(prefix.length);
916
+ if (rest === "") continue;
917
+ const slash = rest.indexOf("/");
918
+ if (slash === -1) entries.set(rest, "file");
919
+ else entries.set(rest.slice(0, slash), "dir");
920
+ }
921
+ return [...entries].map(([name, kind]) => ({ name, kind })).sort((a, b) => a.name.localeCompare(b.name));
849
922
  }
850
- function removeAttribute(el, name) {
851
- el.attrs = el.attrs.filter((attr) => attr.name !== name);
923
+ function pathExists(keys, path) {
924
+ const target = normalize(path);
925
+ if (target === "") return true;
926
+ if (keys.includes(target)) return true;
927
+ const prefix = `${target}/`;
928
+ return keys.some((key) => key.startsWith(prefix));
852
929
  }
853
- function parseFragmentNodes(html) {
854
- return parseFragment(html).childNodes;
930
+
931
+ // ../engine/src/content/source.ts
932
+ function normalizePath(path) {
933
+ return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
855
934
  }
856
- function isFullDocument(html) {
857
- const start = html.trimStart().toLowerCase();
858
- return start.startsWith("<!doctype") || start.startsWith("<html");
935
+ var InMemoryContentSource = class {
936
+ files;
937
+ constructor(files) {
938
+ this.files = new Map(Object.entries(files).map(([key, value]) => [normalizePath(key), value]));
939
+ }
940
+ async readText(path) {
941
+ const value = this.files.get(normalizePath(path));
942
+ if (value === void 0) throw new Error(`No such file in content source: ${path}`);
943
+ return typeof value === "string" ? value : new TextDecoder().decode(value);
944
+ }
945
+ async readBytes(path) {
946
+ const value = this.files.get(normalizePath(path));
947
+ if (value === void 0) throw new Error(`No such file in content source: ${path}`);
948
+ return typeof value === "string" ? new TextEncoder().encode(value) : value;
949
+ }
950
+ async exists(path) {
951
+ const target = normalizePath(path);
952
+ if (target === "") return true;
953
+ if (this.files.has(target)) return true;
954
+ const prefix = `${target}/`;
955
+ for (const key of this.files.keys()) {
956
+ if (key.startsWith(prefix)) return true;
957
+ }
958
+ return false;
959
+ }
960
+ async list(dir) {
961
+ const base = normalizePath(dir);
962
+ const prefix = base === "" ? "" : `${base}/`;
963
+ const entries = /* @__PURE__ */ new Map();
964
+ for (const key of this.files.keys()) {
965
+ if (prefix !== "" && !key.startsWith(prefix)) continue;
966
+ const rest = key.slice(prefix.length);
967
+ if (rest === "") continue;
968
+ const slash = rest.indexOf("/");
969
+ if (slash === -1) entries.set(rest, "file");
970
+ else entries.set(rest.slice(0, slash), "dir");
971
+ }
972
+ return [...entries].map(([name, kind]) => ({ name, kind })).sort((a, b) => a.name.localeCompare(b.name));
973
+ }
974
+ };
975
+
976
+ // ../engine/src/url/redirects.ts
977
+ var DEFAULT_STATUS = 301;
978
+ var OUTPUT_PATH = "_redirects";
979
+ function isRule(value) {
980
+ if (typeof value !== "object" || value === null) return false;
981
+ const record = value;
982
+ return typeof record.from === "string" && typeof record.to === "string";
859
983
  }
860
- function parseNodes(html) {
861
- return isFullDocument(html) ? parse(html).childNodes : parseFragment(html).childNodes;
984
+ function parseRedirects(raw) {
985
+ if (!Array.isArray(raw)) return [];
986
+ const rules = [];
987
+ for (const entry of raw) {
988
+ if (!isRule(entry)) continue;
989
+ const status = entry.status;
990
+ rules.push(
991
+ typeof status === "number" && Number.isInteger(status) ? { from: entry.from, to: entry.to, status } : { from: entry.from, to: entry.to }
992
+ );
993
+ }
994
+ return rules;
862
995
  }
863
- function serializeNodes(nodes) {
864
- const fragment = parseFragment("");
865
- fragment.childNodes = nodes;
866
- return serialize2(fragment);
996
+ function resolveTarget(from, exact) {
997
+ const seen = /* @__PURE__ */ new Set([from]);
998
+ let target = exact.get(from).to;
999
+ while (exact.has(target) && !seen.has(target)) {
1000
+ seen.add(target);
1001
+ target = exact.get(target).to;
1002
+ }
1003
+ return target;
1004
+ }
1005
+ function buildRedirects(rules, liveUrls) {
1006
+ const exact = /* @__PURE__ */ new Map();
1007
+ const wildcard = /* @__PURE__ */ new Map();
1008
+ for (const rule of rules) {
1009
+ (rule.from.includes("*") ? wildcard : exact).set(rule.from, rule);
1010
+ }
1011
+ const resolved = [];
1012
+ for (const [from, rule] of exact) {
1013
+ if (liveUrls.has(from)) continue;
1014
+ const to = resolveTarget(from, exact);
1015
+ if (to === from) continue;
1016
+ resolved.push(rule.status !== void 0 ? { from, to, status: rule.status } : { from, to });
1017
+ }
1018
+ resolved.push(...wildcard.values());
1019
+ if (resolved.length === 0) return void 0;
1020
+ resolved.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
1021
+ const contents = resolved.map((rule) => `${rule.from} ${rule.to} ${rule.status ?? DEFAULT_STATUS}
1022
+ `).join("");
1023
+ return { path: OUTPUT_PATH, contents };
867
1024
  }
868
1025
 
869
- // ../engine/src/media/media.ts
870
- var DEFAULT_WIDTHS = [400, 800, 1200, 1600];
871
- var MIME = {
872
- avif: "image/avif",
873
- webp: "image/webp",
874
- jpeg: "image/jpeg",
875
- png: "image/png"
1026
+ // ../engine/src/content/loader.ts
1027
+ var SORT_FILE = "_sort.json";
1028
+ var REDIRECTS_FILE = "_redirects.json";
1029
+ var SITE_CONFIG_FILE = "_site.json";
1030
+ var ASSETS_DIR = "assets";
1031
+ var ITEM_EXT = ".json";
1032
+ var DEFAULT_DIRS = {
1033
+ content: "content",
1034
+ templates: "templates",
1035
+ components: "components",
1036
+ /** Static passthrough, copied verbatim to the published root (DESIGN §8). The
1037
+ * engine never reads it; named here so hosts/editor share one source of truth. */
1038
+ public: "public"
876
1039
  };
877
- function extOf(format) {
878
- return format === "jpeg" ? "jpg" : format;
1040
+ function join(...parts) {
1041
+ return parts.filter((part) => part !== "").join("/");
879
1042
  }
880
- function fallbackFormatFor(assetPath) {
881
- const ext = assetPath.slice(assetPath.lastIndexOf(".") + 1).toLowerCase();
882
- if (ext === "png") return "png";
883
- if (ext === "webp") return "webp";
884
- return "jpeg";
1043
+ function stripBom(text) {
1044
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
885
1045
  }
886
- function contentHash(bytes) {
887
- let hash = 2166136261;
888
- for (let i = 0; i < bytes.length; i += 1) {
889
- hash ^= bytes[i] ?? 0;
890
- hash = Math.imul(hash, 16777619);
891
- }
892
- return (hash >>> 0).toString(36);
1046
+ async function loadContentTree(source, contentDir = DEFAULT_DIRS.content) {
1047
+ return loadDir(source, contentDir, "", "");
893
1048
  }
894
- async function processImage(input, assetPath, encoder) {
895
- const fallbackFormat = fallbackFormatFor(assetPath);
896
- const formats = [.../* @__PURE__ */ new Set(["avif", "webp", fallbackFormat])];
897
- const encoded = await encoder.encode(input, { widths: DEFAULT_WIDTHS, formats });
898
- const hash = contentHash(input);
899
- const base = assetPath.slice(
900
- 0,
901
- assetPath.lastIndexOf(".") >= 0 ? assetPath.lastIndexOf(".") : assetPath.length
902
- );
903
- const artifacts = [];
904
- const urlOf = (variant) => {
905
- const path = `${base}.${hash}-${variant.width}.${extOf(variant.format)}`;
906
- artifacts.push({ path, contents: variant.bytes });
907
- return `/${path}`;
1049
+ async function loadDir(source, dirPath, slug, treePath) {
1050
+ const [entries, sort] = await Promise.all([source.list(dirPath), readSort(source, dirPath)]);
1051
+ const childMap = /* @__PURE__ */ new Map();
1052
+ for (const entry of entries) {
1053
+ if (entry.kind === "file") {
1054
+ if (entry.name === SORT_FILE || entry.name === REDIRECTS_FILE || entry.name === SITE_CONFIG_FILE || !entry.name.endsWith(ITEM_EXT))
1055
+ continue;
1056
+ const childSlug = entry.name.slice(0, -ITEM_EXT.length);
1057
+ childMap.set(
1058
+ childSlug,
1059
+ await loadItem(source, join(dirPath, entry.name), childSlug, treePath)
1060
+ );
1061
+ } else {
1062
+ if (entry.name === ASSETS_DIR) continue;
1063
+ const childPath = join(treePath, entry.name);
1064
+ childMap.set(
1065
+ entry.name,
1066
+ await loadDir(source, join(dirPath, entry.name), entry.name, childPath)
1067
+ );
1068
+ }
1069
+ }
1070
+ const name = sort.name ?? (slug === "" ? "" : humanize(slug));
1071
+ return {
1072
+ kind: "dir",
1073
+ slug,
1074
+ name,
1075
+ path: treePath,
1076
+ children: orderChildren(childMap, sort.order),
1077
+ ...sort.collection !== void 0 && { collection: sort.collection },
1078
+ ...sort.defaultTemplate !== void 0 && { defaultTemplate: sort.defaultTemplate }
908
1079
  };
909
- const byFormat = /* @__PURE__ */ new Map();
910
- for (const variant of [...encoded.variants].sort((a, b) => a.width - b.width)) {
911
- const url = urlOf(variant);
912
- const list = byFormat.get(variant.format) ?? [];
913
- list.push({ url, width: variant.width });
914
- byFormat.set(variant.format, list);
1080
+ }
1081
+ async function loadItem(source, filePath, slug, parentPath2) {
1082
+ const raw = await source.readText(filePath);
1083
+ try {
1084
+ const item = parseContentItem(JSON.parse(raw));
1085
+ return { kind: "item", slug, path: join(parentPath2, slug), item };
1086
+ } catch (error) {
1087
+ throw new Error(`Failed to load ${filePath}: ${error.message}`);
915
1088
  }
916
- const srcsetOf = (format) => (byFormat.get(format) ?? []).map((entry) => `${entry.url} ${entry.width}w`).join(", ");
917
- const sources = ["avif", "webp"].filter((format) => byFormat.has(format)).map((format) => ({ format, mime: MIME[format], srcset: srcsetOf(format) }));
918
- const fallbackList = byFormat.get(fallbackFormat) ?? [];
919
- const fallbackSrc = fallbackList[fallbackList.length - 1]?.url ?? `/${assetPath}`;
920
- const info = {
921
- width: encoded.sourceWidth,
922
- height: encoded.sourceHeight,
923
- sources,
924
- fallbackSrc,
925
- ...encoded.blurDataUri !== void 0 && { blurDataUri: encoded.blurDataUri }
1089
+ }
1090
+ async function readSort(source, dirPath) {
1091
+ const path = join(dirPath, SORT_FILE);
1092
+ if (!await source.exists(path)) return { order: [] };
1093
+ try {
1094
+ return parseSortFile(JSON.parse(await source.readText(path)));
1095
+ } catch {
1096
+ return { order: [] };
1097
+ }
1098
+ }
1099
+ async function loadRedirects(source, contentDir = DEFAULT_DIRS.content) {
1100
+ const path = join(contentDir, REDIRECTS_FILE);
1101
+ if (!await source.exists(path)) return [];
1102
+ try {
1103
+ return parseRedirects(JSON.parse(stripBom(await source.readText(path))));
1104
+ } catch {
1105
+ return [];
1106
+ }
1107
+ }
1108
+ async function loadSiteConfig(source, contentDir = DEFAULT_DIRS.content) {
1109
+ const path = join(contentDir, SITE_CONFIG_FILE);
1110
+ if (!await source.exists(path)) return {};
1111
+ try {
1112
+ const raw = JSON.parse(stripBom(await source.readText(path)));
1113
+ const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
1114
+ return baseUrl !== "" ? { baseUrl } : {};
1115
+ } catch {
1116
+ return {};
1117
+ }
1118
+ }
1119
+ function orderChildren(map, order) {
1120
+ const out = [];
1121
+ const used = /* @__PURE__ */ new Set();
1122
+ for (const slug of order) {
1123
+ const node = map.get(slug);
1124
+ if (node !== void 0) {
1125
+ out.push(node);
1126
+ used.add(slug);
1127
+ }
1128
+ }
1129
+ for (const [slug, node] of map) {
1130
+ if (!used.has(slug)) out.push(node);
1131
+ }
1132
+ return out;
1133
+ }
1134
+ function loadComponents(source, componentsDir = DEFAULT_DIRS.components) {
1135
+ return loadComponentFiles(source, componentsDir, "html");
1136
+ }
1137
+ function loadComponentStyles(source, componentsDir = DEFAULT_DIRS.components) {
1138
+ return loadComponentFiles(source, componentsDir, "css");
1139
+ }
1140
+ function loadComponentScripts(source, componentsDir = DEFAULT_DIRS.components) {
1141
+ return loadComponentFiles(source, componentsDir, "js");
1142
+ }
1143
+ async function loadComponentFiles(source, componentsDir, extension) {
1144
+ const suffix = `.${extension}`;
1145
+ const map = /* @__PURE__ */ new Map();
1146
+ const walk = async (dir) => {
1147
+ if (!await source.exists(dir)) return;
1148
+ for (const entry of await source.list(dir)) {
1149
+ const full = join(dir, entry.name);
1150
+ if (entry.kind === "dir") {
1151
+ await walk(full);
1152
+ } else if (entry.name.endsWith(suffix)) {
1153
+ const key = entry.name.slice(0, -suffix.length).toLowerCase();
1154
+ map.set(key, await source.readText(full));
1155
+ }
1156
+ }
926
1157
  };
927
- return { artifacts, info };
1158
+ await walk(componentsDir);
1159
+ return map;
928
1160
  }
929
- function buildPictureMarkup(info, extraAttrs) {
930
- const sources = info.sources.map(
931
- (source) => `<source type="${source.mime}" srcset="${escapeHtmlAttribute(source.srcset)}">`
932
- ).join("");
933
- const carried = extraAttrs.filter(([name]) => name !== "src" && name !== "width" && name !== "height").map(([name, value]) => ` ${name}="${escapeHtmlAttribute(value)}"`).join("");
934
- const img = `<img src="${escapeHtmlAttribute(info.fallbackSrc)}" width="${info.width}" height="${info.height}" loading="lazy" decoding="async"${carried}>`;
935
- return `<picture>${sources}${img}</picture>`;
1161
+ async function loadTemplate(source, name, templatesDir = DEFAULT_DIRS.templates) {
1162
+ return source.readText(join(templatesDir, `${name}.html`));
1163
+ }
1164
+ function loadTemplateStyle(source, name, templatesDir = DEFAULT_DIRS.templates) {
1165
+ return tryReadText(source, join(templatesDir, `${name}.css`));
1166
+ }
1167
+ function loadTemplateScript(source, name, templatesDir = DEFAULT_DIRS.templates) {
1168
+ return tryReadText(source, join(templatesDir, `${name}.js`));
1169
+ }
1170
+ var DOCUMENT_SHELL = "document";
1171
+ function loadDocumentShell(source, templatesDir = DEFAULT_DIRS.templates) {
1172
+ return tryReadText(source, join(templatesDir, `${DOCUMENT_SHELL}.html`));
1173
+ }
1174
+ async function tryReadText(source, path) {
1175
+ try {
1176
+ return await source.readText(path);
1177
+ } catch {
1178
+ return void 0;
1179
+ }
936
1180
  }
937
1181
 
938
1182
  // ../engine/src/url/references.ts
@@ -956,35 +1200,418 @@ function collectHtmlReferences(html) {
956
1200
  return [...found].sort();
957
1201
  }
958
1202
 
959
- // ../engine/src/compile/compiler.ts
960
- var LOOP_ATTRS = ["data-each-in", "data-each", "data-sort", "data-limit", "data-pick-from"];
961
- var AUTHORING_ATTRS = ["data-type", "data-field", "data-required", "data-help", "data-label"];
962
- function compileTemplate(input) {
963
- const ctx = {
964
- components: input.components,
965
- usedComponents: /* @__PURE__ */ new Set(),
966
- ...input.context !== void 0 && { context: input.context },
967
- ...input.media !== void 0 && { media: input.media },
968
- ...input.authorDirectory !== void 0 && { authorDirectory: input.authorDirectory },
969
- ...input.componentStyles !== void 0 && { componentStyles: input.componentStyles }
1203
+ // ../engine/src/template/analyze.ts
1204
+ function analyzeTemplate(source, components = /* @__PURE__ */ new Map()) {
1205
+ const requiredFields = /* @__PURE__ */ new Set();
1206
+ const queryLoopPaths = [];
1207
+ const curatedLoopFields = [];
1208
+ const imageFields = /* @__PURE__ */ new Set();
1209
+ const fileFields = /* @__PURE__ */ new Set();
1210
+ const richTextFields = /* @__PURE__ */ new Set();
1211
+ const constraints = /* @__PURE__ */ new Map();
1212
+ const references = /* @__PURE__ */ new Set();
1213
+ const visit = (nodes) => {
1214
+ for (const node of nodes) {
1215
+ if (!isElement(node)) continue;
1216
+ const richText = richTextFieldName(node);
1217
+ if (richText !== void 0) richTextFields.add(richText);
1218
+ const eachIn = getAttribute(node, "data-each-in");
1219
+ if (eachIn !== void 0) {
1220
+ queryLoopPaths.push(eachIn);
1221
+ continue;
1222
+ }
1223
+ const each = getAttribute(node, "data-each");
1224
+ if (each !== void 0) curatedLoopFields.push(each);
1225
+ if (hasAttribute(node, "data-required")) {
1226
+ const field = annotatedFieldName(node);
1227
+ if (field !== void 0) requiredFields.add(field);
1228
+ }
1229
+ const bounds = lengthConstraints(node);
1230
+ if (bounds.minLength !== void 0 || bounds.maxLength !== void 0) {
1231
+ const field = annotatedFieldName(node);
1232
+ if (field !== void 0) constraints.set(field, bounds);
1233
+ }
1234
+ const annotation = getAttribute(node, "data-type");
1235
+ const propTypes = components.has(node.tagName) ? componentPropTypes(node.tagName, components, /* @__PURE__ */ new Set()) : void 0;
1236
+ for (const attr of node.attrs) {
1237
+ const reference = referenceTarget(attr.value);
1238
+ if (reference !== void 0) references.add(reference);
1239
+ const token = wholeValueToken(attr.value);
1240
+ if (token === null || token.dotted) continue;
1241
+ const type = propTypes?.get(attr.name) ?? inferControl({
1242
+ tag: node.tagName,
1243
+ attribute: attr.name,
1244
+ wholeValue: true,
1245
+ valuePrefix: literalPrefix(attr.value),
1246
+ download: hasAttribute(node, "download"),
1247
+ ...annotation !== void 0 && { annotation }
1248
+ });
1249
+ if (type === "image") imageFields.add(token.name);
1250
+ else if (type === "file") fileFields.add(token.name);
1251
+ else if (type === "richtext") richTextFields.add(token.name);
1252
+ }
1253
+ visit(node.childNodes);
1254
+ }
1255
+ };
1256
+ visit(parseNodes(source));
1257
+ return {
1258
+ requiredFields: [...requiredFields],
1259
+ queryLoopPaths,
1260
+ curatedLoopFields,
1261
+ imageFields: [...imageFields],
1262
+ fileFields: [...fileFields],
1263
+ richTextFields: [...richTextFields],
1264
+ constraints,
1265
+ references: [...references].sort()
970
1266
  };
971
- const body = transformNodes(parseNodes(input.template), input.scope, ctx);
972
- const out = input.document === void 0 ? body : fillSlots(transformNodes(parseNodes(input.document), input.scope, ctx), body);
973
- injectPageAssets(out, input, ctx);
974
- return serializeNodes(out);
975
1267
  }
976
- function injectPageAssets(nodes, input, ctx) {
977
- const style = input.templateStyle?.trim();
978
- if (style) {
979
- const styleNode = parseFragmentNodes(`<style>
980
- ${style}
981
- </style>`);
982
- const head = findElement(nodes, "head");
983
- if (head) head.childNodes.push(...styleNode);
984
- else nodes.unshift(...styleNode);
985
- }
986
- const scripts = [];
987
- const templateScript = input.templateScript?.trim();
1268
+ function richTextFieldName(el) {
1269
+ const meaningful = el.childNodes.filter(
1270
+ (child) => !(isTextNode(child) && child.value.trim() === "")
1271
+ );
1272
+ const only = meaningful[0];
1273
+ if (meaningful.length !== 1 || only === void 0 || !isTextNode(only)) return void 0;
1274
+ const token = wholeValueToken(only.value);
1275
+ if (token === null || token.dotted) return void 0;
1276
+ const annotation = getAttribute(el, "data-type");
1277
+ const type = inferControl({
1278
+ tag: el.tagName,
1279
+ wholeValue: true,
1280
+ ...annotation !== void 0 && { annotation }
1281
+ });
1282
+ return valueKindOf(type) === "html" ? token.name : void 0;
1283
+ }
1284
+ function annotatedFieldName(el) {
1285
+ const explicit = getAttribute(el, "data-field");
1286
+ if (explicit !== void 0) return explicit;
1287
+ for (const attr of el.attrs) {
1288
+ const token = findTokens(attr.value).find((candidate) => !candidate.dotted);
1289
+ if (token !== void 0) return token.name;
1290
+ }
1291
+ for (const child of el.childNodes) {
1292
+ if (isTextNode(child)) {
1293
+ const token = findTokens(child.value).find((candidate) => !candidate.dotted);
1294
+ if (token !== void 0) return token.name;
1295
+ }
1296
+ }
1297
+ return void 0;
1298
+ }
1299
+
1300
+ // ../engine/src/migrations/migrations.ts
1301
+ async function pendingMigrations(store) {
1302
+ const components = await loadComponents(store).catch(() => /* @__PURE__ */ new Map());
1303
+ const items = await collectItems(store);
1304
+ const templateFieldsCache = /* @__PURE__ */ new Map();
1305
+ const requiredFieldsCache = /* @__PURE__ */ new Map();
1306
+ const snapshotFieldsCache = /* @__PURE__ */ new Map();
1307
+ const fieldsFor = async (templateName) => {
1308
+ const cached = templateFieldsCache.get(templateName);
1309
+ if (cached !== void 0) return cached;
1310
+ const html = await readTemplateOrNull(store, templateName);
1311
+ const fields = new Set(html === null ? [] : deriveFields(html, components).map((f) => f.name));
1312
+ templateFieldsCache.set(templateName, fields);
1313
+ return fields;
1314
+ };
1315
+ const requiredFor = async (templateName) => {
1316
+ const cached = requiredFieldsCache.get(templateName);
1317
+ if (cached !== void 0) return cached;
1318
+ const html = await readTemplateOrNull(store, templateName);
1319
+ let required = [];
1320
+ if (html !== null) {
1321
+ try {
1322
+ required = analyzeTemplate(html, components).requiredFields;
1323
+ } catch {
1324
+ required = [];
1325
+ }
1326
+ }
1327
+ requiredFieldsCache.set(templateName, required);
1328
+ return required;
1329
+ };
1330
+ const out = [];
1331
+ const byTemplate = /* @__PURE__ */ new Map();
1332
+ for (const item of items) {
1333
+ const expected = await fieldsFor(item.template);
1334
+ const itemFieldNames = Object.keys(item.fields);
1335
+ const orphans = itemFieldNames.filter((name) => !expected.has(name));
1336
+ const required = await requiredFor(item.template);
1337
+ const missing = required.filter((name) => {
1338
+ const value = item.fields[name];
1339
+ return value === void 0 || value === null || value === "";
1340
+ });
1341
+ if (orphans.length === 0 && missing.length === 0) continue;
1342
+ const base = baseTemplateName(item.template);
1343
+ const bestFit = isVersionedTemplateName(item.template) ? null : await pickBestFitSnapshot(store, base, itemFieldNames, snapshotFieldsCache, components);
1344
+ const finding2 = {
1345
+ path: item.path,
1346
+ boundTemplate: item.template,
1347
+ currentTemplate: base,
1348
+ orphanFields: orphans.sort(),
1349
+ missingRequiredFields: [...missing].sort(),
1350
+ bestFitSnapshot: bestFit
1351
+ };
1352
+ out.push(finding2);
1353
+ const bucket = byTemplate.get(base);
1354
+ if (bucket === void 0) byTemplate.set(base, [finding2]);
1355
+ else bucket.push(finding2);
1356
+ }
1357
+ return { items: out, byTemplate };
1358
+ }
1359
+ async function bestFitSnapshot(store, base, itemFieldNames) {
1360
+ const components = await loadComponents(store).catch(() => /* @__PURE__ */ new Map());
1361
+ return pickBestFitSnapshot(store, base, itemFieldNames, /* @__PURE__ */ new Map(), components);
1362
+ }
1363
+ async function applyMigration(store, itemPath, resolution) {
1364
+ const text = await store.readText(itemPath);
1365
+ const raw = JSON.parse(text);
1366
+ if (typeof raw !== "object" || raw === null) {
1367
+ throw new Error(`applyMigration: ${itemPath} is not a JSON object`);
1368
+ }
1369
+ const obj = raw;
1370
+ const fields = typeof obj["fields"] === "object" && obj["fields"] !== null ? { ...obj["fields"] } : {};
1371
+ for (const [from, to] of Object.entries(resolution.rename)) {
1372
+ if (Object.prototype.hasOwnProperty.call(fields, from)) {
1373
+ fields[to] = fields[from];
1374
+ delete fields[from];
1375
+ }
1376
+ }
1377
+ for (const name of resolution.drop) delete fields[name];
1378
+ for (const [name, value] of Object.entries(resolution.fill)) {
1379
+ if (value !== "") fields[name] = value;
1380
+ }
1381
+ obj["fields"] = Object.fromEntries(Object.entries(fields).sort(([a], [b]) => a.localeCompare(b)));
1382
+ await store.write(itemPath, new TextEncoder().encode(`${JSON.stringify(obj, null, 2)}
1383
+ `));
1384
+ }
1385
+ async function collectItems(store, dir = "content") {
1386
+ const out = [];
1387
+ let entries;
1388
+ try {
1389
+ entries = await store.list(dir);
1390
+ } catch {
1391
+ return out;
1392
+ }
1393
+ for (const entry of entries) {
1394
+ const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
1395
+ if (entry.kind === "dir") {
1396
+ out.push(...await collectItems(store, path));
1397
+ continue;
1398
+ }
1399
+ if (!entry.name.endsWith(".json") || entry.name.startsWith("_")) continue;
1400
+ let data;
1401
+ try {
1402
+ data = JSON.parse(await store.readText(path));
1403
+ } catch {
1404
+ continue;
1405
+ }
1406
+ if (typeof data !== "object" || data === null) continue;
1407
+ const obj = data;
1408
+ const template = obj["template"];
1409
+ if (typeof template !== "string") continue;
1410
+ const fields = obj["fields"];
1411
+ out.push({
1412
+ path,
1413
+ template,
1414
+ fields: typeof fields === "object" && fields !== null ? fields : {}
1415
+ });
1416
+ }
1417
+ return out;
1418
+ }
1419
+ async function readTemplateOrNull(store, templateName) {
1420
+ try {
1421
+ return await store.readText(`templates/${templateName}.html`);
1422
+ } catch {
1423
+ return null;
1424
+ }
1425
+ }
1426
+ async function pickBestFitSnapshot(store, base, itemFieldNames, cache, components) {
1427
+ let entries;
1428
+ try {
1429
+ entries = await store.list("templates");
1430
+ } catch {
1431
+ return null;
1432
+ }
1433
+ const itemSet = new Set(itemFieldNames);
1434
+ let bestVersion = 0;
1435
+ let bestName = null;
1436
+ for (const entry of entries) {
1437
+ if (entry.kind !== "file" || !entry.name.endsWith(".html")) continue;
1438
+ const stem = entry.name.slice(0, -".html".length);
1439
+ if (baseTemplateName(stem) !== base || !isVersionedTemplateName(stem)) continue;
1440
+ const version = versionNumber(stem);
1441
+ if (version === null) continue;
1442
+ let tokens = cache.get(stem);
1443
+ if (tokens === void 0) {
1444
+ try {
1445
+ const html = await store.readText(`templates/${entry.name}`);
1446
+ tokens = new Set(deriveFields(html, components).map((f) => f.name));
1447
+ } catch {
1448
+ tokens = /* @__PURE__ */ new Set();
1449
+ }
1450
+ cache.set(stem, tokens);
1451
+ }
1452
+ const covers = [...itemSet].every((name) => tokens.has(name));
1453
+ if (covers && version > bestVersion) {
1454
+ bestVersion = version;
1455
+ bestName = stem;
1456
+ }
1457
+ }
1458
+ return bestName;
1459
+ }
1460
+
1461
+ // ../engine/src/html/escape.ts
1462
+ function escapeHtmlText(value) {
1463
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1464
+ }
1465
+ function escapeHtmlAttribute(value) {
1466
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1467
+ }
1468
+ function escapeJsonStringContent(value) {
1469
+ const literal = JSON.stringify(value);
1470
+ return literal.slice(1, -1);
1471
+ }
1472
+ function sanitizeUrl(url) {
1473
+ let cleaned = "";
1474
+ for (const ch of url) {
1475
+ const code = ch.codePointAt(0);
1476
+ if (code !== void 0 && code > 32) cleaned += ch;
1477
+ }
1478
+ const scheme = cleaned.toLowerCase();
1479
+ if (scheme.startsWith("javascript:") || scheme.startsWith("vbscript:")) {
1480
+ return "#";
1481
+ }
1482
+ return url;
1483
+ }
1484
+
1485
+ // ../engine/src/authors/authors.ts
1486
+ function toAuthorRefs(value) {
1487
+ if (!Array.isArray(value)) return [];
1488
+ const refs = [];
1489
+ for (const entry of value) {
1490
+ if (entry === null || typeof entry !== "object") continue;
1491
+ const record = entry;
1492
+ const name = typeof record.name === "string" ? record.name : "";
1493
+ if (name.trim() === "") continue;
1494
+ refs.push({
1495
+ name,
1496
+ ...typeof record.user === "string" && record.user !== "" && { user: record.user },
1497
+ ...typeof record.href === "string" && record.href !== "" && { href: record.href }
1498
+ });
1499
+ }
1500
+ return refs;
1501
+ }
1502
+ function joinAuthors(names) {
1503
+ if (names.length === 0) return "";
1504
+ if (names.length === 1) return names[0] ?? "";
1505
+ const last = names[names.length - 1] ?? "";
1506
+ const head = names.slice(0, -1).join(", ");
1507
+ return `${head} and ${last}`;
1508
+ }
1509
+ function renderAuthors(value, directory) {
1510
+ const names = toAuthorRefs(value).map((ref) => {
1511
+ const resolved = ref.user !== void 0 ? directory?.(ref.user) : void 0;
1512
+ return escapeHtmlText(resolved?.displayName ?? ref.name);
1513
+ });
1514
+ return joinAuthors(names);
1515
+ }
1516
+
1517
+ // ../engine/src/media/media.ts
1518
+ var DEFAULT_WIDTHS = [400, 800, 1200, 1600];
1519
+ var MIME = {
1520
+ avif: "image/avif",
1521
+ webp: "image/webp",
1522
+ jpeg: "image/jpeg",
1523
+ png: "image/png"
1524
+ };
1525
+ function extOf(format) {
1526
+ return format === "jpeg" ? "jpg" : format;
1527
+ }
1528
+ function fallbackFormatFor(assetPath) {
1529
+ const ext = assetPath.slice(assetPath.lastIndexOf(".") + 1).toLowerCase();
1530
+ if (ext === "png") return "png";
1531
+ if (ext === "webp") return "webp";
1532
+ return "jpeg";
1533
+ }
1534
+ function contentHash(bytes) {
1535
+ let hash = 2166136261;
1536
+ for (let i = 0; i < bytes.length; i += 1) {
1537
+ hash ^= bytes[i] ?? 0;
1538
+ hash = Math.imul(hash, 16777619);
1539
+ }
1540
+ return (hash >>> 0).toString(36);
1541
+ }
1542
+ async function processImage(input, assetPath, encoder) {
1543
+ const fallbackFormat = fallbackFormatFor(assetPath);
1544
+ const formats = [.../* @__PURE__ */ new Set(["avif", "webp", fallbackFormat])];
1545
+ const encoded = await encoder.encode(input, { widths: DEFAULT_WIDTHS, formats });
1546
+ const hash = contentHash(input);
1547
+ const base = assetPath.slice(
1548
+ 0,
1549
+ assetPath.lastIndexOf(".") >= 0 ? assetPath.lastIndexOf(".") : assetPath.length
1550
+ );
1551
+ const artifacts = [];
1552
+ const urlOf = (variant) => {
1553
+ const path = `${base}.${hash}-${variant.width}.${extOf(variant.format)}`;
1554
+ artifacts.push({ path, contents: variant.bytes });
1555
+ return `/${path}`;
1556
+ };
1557
+ const byFormat = /* @__PURE__ */ new Map();
1558
+ for (const variant of [...encoded.variants].sort((a, b) => a.width - b.width)) {
1559
+ const url = urlOf(variant);
1560
+ const list = byFormat.get(variant.format) ?? [];
1561
+ list.push({ url, width: variant.width });
1562
+ byFormat.set(variant.format, list);
1563
+ }
1564
+ const srcsetOf = (format) => (byFormat.get(format) ?? []).map((entry) => `${entry.url} ${entry.width}w`).join(", ");
1565
+ const sources = ["avif", "webp"].filter((format) => byFormat.has(format)).map((format) => ({ format, mime: MIME[format], srcset: srcsetOf(format) }));
1566
+ const fallbackList = byFormat.get(fallbackFormat) ?? [];
1567
+ const fallbackSrc = fallbackList[fallbackList.length - 1]?.url ?? `/${assetPath}`;
1568
+ const info = {
1569
+ width: encoded.sourceWidth,
1570
+ height: encoded.sourceHeight,
1571
+ sources,
1572
+ fallbackSrc,
1573
+ ...encoded.blurDataUri !== void 0 && { blurDataUri: encoded.blurDataUri }
1574
+ };
1575
+ return { artifacts, info };
1576
+ }
1577
+ function buildPictureMarkup(info, extraAttrs) {
1578
+ const sources = info.sources.map(
1579
+ (source) => `<source type="${source.mime}" srcset="${escapeHtmlAttribute(source.srcset)}">`
1580
+ ).join("");
1581
+ const carried = extraAttrs.filter(([name]) => name !== "src" && name !== "width" && name !== "height").map(([name, value]) => ` ${name}="${escapeHtmlAttribute(value)}"`).join("");
1582
+ const img = `<img src="${escapeHtmlAttribute(info.fallbackSrc)}" width="${info.width}" height="${info.height}" loading="lazy" decoding="async"${carried}>`;
1583
+ return `<picture>${sources}${img}</picture>`;
1584
+ }
1585
+
1586
+ // ../engine/src/compile/compiler.ts
1587
+ var LOOP_ATTRS = ["data-each-in", "data-each", "data-sort", "data-limit", "data-pick-from"];
1588
+ var AUTHORING_ATTRS = ["data-type", "data-field", "data-required", "data-help", "data-label"];
1589
+ function compileTemplate(input) {
1590
+ const ctx = {
1591
+ components: input.components,
1592
+ usedComponents: /* @__PURE__ */ new Set(),
1593
+ ...input.context !== void 0 && { context: input.context },
1594
+ ...input.media !== void 0 && { media: input.media },
1595
+ ...input.authorDirectory !== void 0 && { authorDirectory: input.authorDirectory },
1596
+ ...input.componentStyles !== void 0 && { componentStyles: input.componentStyles }
1597
+ };
1598
+ const body = transformNodes(parseNodes(input.template), input.scope, ctx);
1599
+ const out = input.document === void 0 ? body : fillSlots(transformNodes(parseNodes(input.document), input.scope, ctx), body);
1600
+ injectPageAssets(out, input, ctx);
1601
+ return serializeNodes(out);
1602
+ }
1603
+ function injectPageAssets(nodes, input, ctx) {
1604
+ const style = input.templateStyle?.trim();
1605
+ if (style) {
1606
+ const styleNode = parseFragmentNodes(`<style>
1607
+ ${style}
1608
+ </style>`);
1609
+ const head = findElement(nodes, "head");
1610
+ if (head) head.childNodes.push(...styleNode);
1611
+ else nodes.unshift(...styleNode);
1612
+ }
1613
+ const scripts = [];
1614
+ const templateScript = input.templateScript?.trim();
988
1615
  if (templateScript) scripts.push(templateScript);
989
1616
  for (const tag of [...ctx.usedComponents].sort()) {
990
1617
  const js = input.componentScripts?.get(tag)?.trim();
@@ -1159,446 +1786,204 @@ function cloneNode(node) {
1159
1786
  function substituteAttributes(el, scope, ctx) {
1160
1787
  for (const attr of el.attrs) {
1161
1788
  if (findTokens(attr.value).length === 0) {
1162
- const referenced2 = resolveReference(attr.value, ctx);
1163
- if (referenced2 !== void 0) attr.value = sanitizeUrl(referenced2);
1164
- continue;
1165
- }
1166
- const annotation = getAttribute(el, "data-type");
1167
- const whole = wholeValueToken(attr.value) !== null;
1168
- const type = inferControl({
1169
- tag: el.tagName,
1170
- attribute: attr.name,
1171
- wholeValue: whole,
1172
- valuePrefix: literalPrefix(attr.value),
1173
- download: hasAttribute(el, "download"),
1174
- ...annotation !== void 0 && { annotation }
1175
- });
1176
- let value = substituteTokens(attr.value, scopeResolver(scope));
1177
- const referenced = resolveReference(value, ctx);
1178
- if (referenced !== void 0) value = referenced;
1179
- const fileUrl = ctx.media?.file(value);
1180
- if (fileUrl !== void 0) value = fileUrl;
1181
- if (whole && valueKindOf(type) === "url") value = sanitizeUrl(value);
1182
- attr.value = value;
1183
- }
1184
- }
1185
- function substituteRawText(el, scope, mode) {
1186
- const resolve = scopeResolver(scope);
1187
- for (const child of el.childNodes) {
1188
- if (!isTextNode(child)) continue;
1189
- child.value = substituteTokens(child.value, (token) => {
1190
- const text = resolve(token);
1191
- return mode === "json" ? escapeJsonStringContent(text) : text;
1192
- });
1193
- }
1194
- }
1195
- function wholeValueFieldToken(el, annotation) {
1196
- const meaningful = el.childNodes.filter(
1197
- (child) => !(isTextNode(child) && child.value.trim() === "")
1198
- );
1199
- const only = meaningful[0];
1200
- if (meaningful.length !== 1 || only === void 0 || !isTextNode(only)) return null;
1201
- const token = wholeValueToken(only.value);
1202
- if (token === null || token.dotted) return null;
1203
- const type = inferControl({
1204
- tag: el.tagName,
1205
- wholeValue: true,
1206
- ...annotation !== void 0 && { annotation }
1207
- });
1208
- return { token, kind: valueKindOf(type) };
1209
- }
1210
- function isJsonLd(el) {
1211
- return el.tagName === "script" && getAttribute(el, "type")?.toLowerCase() === "application/ld+json";
1212
- }
1213
- function expandComponent(el, scope, ctx) {
1214
- const source = ctx.components.get(el.tagName);
1215
- if (source === void 0) return [el];
1216
- const resolve = scopeResolver(scope);
1217
- const propScope = {};
1218
- for (const attr of el.attrs) {
1219
- propScope[attr.name] = substituteTokens(attr.value, resolve);
1220
- }
1221
- const slotChildren = transformNodes(el.childNodes, scope, ctx);
1222
- const body = transformNodes(parseNodes(source), propScope, ctx);
1223
- const result = fillSlots(body, slotChildren);
1224
- ctx.usedComponents.add(el.tagName);
1225
- const css = ctx.componentStyles?.get(el.tagName)?.trim();
1226
- if (css) {
1227
- const root = result.find(isElement);
1228
- if (root !== void 0) {
1229
- const styleNode = parseFragmentNodes(`<style>@scope {
1230
- ${css}
1231
- }</style>`);
1232
- root.childNodes = [...styleNode, ...root.childNodes];
1233
- }
1234
- }
1235
- return result;
1236
- }
1237
- function fillSlots(body, slotChildren) {
1238
- const named = /* @__PURE__ */ new Map();
1239
- const defaultSlot = [];
1240
- for (const child of slotChildren) {
1241
- const slotName = isElement(child) ? getAttribute(child, "slot") : void 0;
1242
- if (slotName !== void 0) {
1243
- if (isElement(child)) removeAttribute(child, "slot");
1244
- const group = named.get(slotName) ?? [];
1245
- group.push(child);
1246
- named.set(slotName, group);
1247
- } else {
1248
- defaultSlot.push(child);
1249
- }
1250
- }
1251
- return replaceSlots(body, named, defaultSlot);
1252
- }
1253
- function replaceSlots(nodes, named, defaultSlot) {
1254
- const out = [];
1255
- for (const node of nodes) {
1256
- if (isElement(node) && node.tagName === "slot") {
1257
- const name = getAttribute(node, "name");
1258
- const provided = name !== void 0 ? named.get(name) : defaultSlot;
1259
- if (provided && provided.length > 0) {
1260
- out.push(...provided);
1261
- } else {
1262
- out.push(...replaceSlots(node.childNodes, named, defaultSlot));
1263
- }
1264
- } else if (isElement(node)) {
1265
- node.childNodes = replaceSlots(node.childNodes, named, defaultSlot);
1266
- out.push(node);
1267
- } else {
1268
- out.push(node);
1269
- }
1270
- }
1271
- return out;
1272
- }
1273
-
1274
- // ../engine/src/aggregate/aggregate.ts
1275
- function joinUrl(baseUrl, sitePath) {
1276
- return baseUrl.replace(/\/+$/, "") + sitePath;
1277
- }
1278
- function textContent(html) {
1279
- const parts = [];
1280
- const visit = (nodes) => {
1281
- for (const node of nodes) {
1282
- if (isTextNode(node)) parts.push(node.value);
1283
- else if (isElement(node)) visit(node.childNodes);
1284
- }
1285
- };
1286
- visit(parseFragmentNodes(html));
1287
- return parts.join(" ").replace(/\s+/g, " ").trim();
1288
- }
1289
- function buildSitemap(entries, baseUrl) {
1290
- const urls = entries.map((entry) => {
1291
- const lastmod = entry.updated ?? entry.published;
1292
- const mod = lastmod !== void 0 ? `
1293
- <lastmod>${escapeHtmlText(lastmod)}</lastmod>` : "";
1294
- return ` <url>
1295
- <loc>${escapeHtmlText(joinUrl(baseUrl, entry.url))}</loc>${mod}
1296
- </url>`;
1297
- }).join("\n");
1298
- const contents = `<?xml version="1.0" encoding="UTF-8"?>
1299
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1300
- ${urls}
1301
- </urlset>
1302
- `;
1303
- return { path: "sitemap.xml", contents };
1304
- }
1305
- function buildRss(entries, options) {
1306
- const items = [...entries].sort(
1307
- (a, b) => String(b.published ?? "").localeCompare(String(a.published ?? ""))
1308
- );
1309
- const lastBuild = items[0]?.published;
1310
- const rfc822 = (iso) => new Date(iso).toUTCString();
1311
- const itemXml = items.map((entry) => {
1312
- const link = joinUrl(options.baseUrl, entry.url);
1313
- const pub = entry.published !== void 0 ? `
1314
- <pubDate>${escapeHtmlText(rfc822(entry.published))}</pubDate>` : "";
1315
- const desc = entry.summary !== void 0 ? `
1316
- <description>${escapeHtmlText(entry.summary)}</description>` : "";
1317
- return ` <item>
1318
- <title>${escapeHtmlText(entry.title)}</title>
1319
- <link>${escapeHtmlText(link)}</link>
1320
- <guid>${escapeHtmlText(link)}</guid>${pub}${desc}
1321
- </item>`;
1322
- }).join("\n");
1323
- const built = lastBuild !== void 0 ? `
1324
- <lastBuildDate>${escapeHtmlText(rfc822(lastBuild))}</lastBuildDate>` : "";
1325
- const contents = `<?xml version="1.0" encoding="UTF-8"?>
1326
- <rss version="2.0">
1327
- <channel>
1328
- <title>${escapeHtmlText(options.title)}</title>
1329
- <link>${escapeHtmlText(options.baseUrl)}</link>
1330
- <description>${escapeHtmlText(options.description)}</description>${built}
1331
- ${itemXml}
1332
- </channel>
1333
- </rss>
1334
- `;
1335
- return { path: "feed.xml", contents };
1336
- }
1337
- function buildContentIndex(entries) {
1338
- const docs = entries.map((entry) => ({
1339
- url: entry.url,
1340
- title: entry.title,
1341
- ...entry.published !== void 0 && { published: entry.published },
1342
- ...entry.summary !== void 0 && { summary: entry.summary }
1343
- }));
1344
- return { path: "content.json", contents: JSON.stringify(docs) };
1345
- }
1346
-
1347
- // ../engine/src/concurrency.ts
1348
- async function mapWithConcurrency(items, limit, fn) {
1349
- const results = new Array(items.length);
1350
- const workers = Math.max(1, Math.min(Math.floor(limit), items.length));
1351
- let next = 0;
1352
- const run = async () => {
1353
- while (next < items.length) {
1354
- const index = next++;
1355
- results[index] = await fn(items[index], index);
1356
- }
1357
- };
1358
- await Promise.all(Array.from({ length: workers }, () => run()));
1359
- return results;
1360
- }
1361
-
1362
- // ../engine/src/template/fields.ts
1363
- function lengthConstraints(el) {
1364
- const min = parseLength(getAttribute(el, "data-minlength"));
1365
- const max = parseLength(getAttribute(el, "data-maxlength"));
1366
- return {
1367
- ...min !== void 0 && { minLength: min },
1368
- ...max !== void 0 && { maxLength: max }
1369
- };
1370
- }
1371
- function parseLength(raw) {
1372
- if (raw === void 0) return void 0;
1373
- const value = Number(raw);
1374
- return Number.isInteger(value) && value >= 0 ? value : void 0;
1375
- }
1376
- function deriveFields(template, components = /* @__PURE__ */ new Map()) {
1377
- return deriveFromSource(template, components, /* @__PURE__ */ new Set());
1378
- }
1379
- function componentPropTypes(tag, components, seen) {
1380
- if (seen.has(tag)) return /* @__PURE__ */ new Map();
1381
- const source = components.get(tag);
1382
- if (source === void 0) return /* @__PURE__ */ new Map();
1383
- const fields = deriveFromSource(source, components, new Set(seen).add(tag));
1384
- return new Map(fields.map((field) => [field.name, field.type]));
1385
- }
1386
- function deriveFromSource(source, components, seen) {
1387
- const order = [];
1388
- const fields = /* @__PURE__ */ new Map();
1389
- const ensure = (name) => {
1390
- const existing = fields.get(name);
1391
- if (existing !== void 0) return existing;
1392
- const created = {
1393
- name,
1394
- type: "shorttext",
1395
- label: humanize(name),
1396
- required: false,
1397
- multiline: false
1398
- };
1399
- fields.set(name, created);
1400
- order.push(name);
1401
- return created;
1402
- };
1403
- const record = (name, type) => {
1404
- if (fields.has(name)) return;
1405
- const field = ensure(name);
1406
- field.type = type;
1407
- field.multiline = FIELD_TYPES[type].multiline;
1408
- };
1409
- const applyAnnotations = (el) => {
1410
- const named = getAttribute(el, "data-field") ?? firstTokenName(el);
1411
- if (named === void 0) return;
1412
- const field = ensure(named);
1413
- if (hasAttribute(el, "data-required")) field.required = true;
1414
- const help = getAttribute(el, "data-help");
1415
- if (help !== void 0) field.help = help;
1416
- const label = getAttribute(el, "data-label");
1417
- if (label !== void 0) field.label = label;
1418
- const { minLength, maxLength } = lengthConstraints(el);
1419
- if (minLength !== void 0) field.minLength = minLength;
1420
- if (maxLength !== void 0) field.maxLength = maxLength;
1421
- };
1422
- const visit = (nodes) => {
1423
- for (const node of nodes) {
1424
- if (!isElement(node)) {
1425
- if (isTextNode(node)) continue;
1426
- continue;
1427
- }
1428
- const eachField = getAttribute(node, "data-each");
1429
- if (eachField !== void 0) {
1430
- const field = ensure(eachField);
1431
- field.type = "url";
1432
- field.multiple = true;
1433
- const pickFrom = getAttribute(node, "data-pick-from");
1434
- if (pickFrom !== void 0) field.pickFrom = pickFrom;
1435
- applyAnnotations(node);
1436
- continue;
1437
- }
1438
- if (hasAttribute(node, "data-each-in")) continue;
1439
- const annotation = getAttribute(node, "data-type");
1440
- const propTypes = components.has(node.tagName) ? componentPropTypes(node.tagName, components, seen) : void 0;
1441
- for (const attr of node.attrs) {
1442
- const whole = wholeValueToken(attr.value);
1443
- for (const token of findTokens(attr.value)) {
1444
- if (token.dotted) continue;
1445
- const isWhole = whole?.name === token.name;
1446
- const propType = isWhole ? propTypes?.get(attr.name) : void 0;
1447
- record(
1448
- token.name,
1449
- propType ?? inferControl({
1450
- tag: node.tagName,
1451
- attribute: attr.name,
1452
- wholeValue: isWhole,
1453
- valuePrefix: literalPrefix(attr.value),
1454
- download: hasAttribute(node, "download"),
1455
- ...annotation !== void 0 && { annotation }
1456
- })
1457
- );
1458
- }
1459
- }
1460
- for (const child of node.childNodes) {
1461
- if (!isTextNode(child)) continue;
1462
- const whole = wholeValueToken(child.value);
1463
- for (const token of findTokens(child.value)) {
1464
- if (token.dotted) continue;
1465
- record(
1466
- token.name,
1467
- inferControl({
1468
- tag: node.tagName,
1469
- wholeValue: whole?.name === token.name,
1470
- ...annotation !== void 0 && { annotation }
1471
- })
1472
- );
1473
- }
1474
- }
1475
- applyAnnotations(node);
1476
- visit(node.childNodes);
1477
- }
1478
- };
1479
- visit(parseNodes(source));
1480
- return order.map((name) => freeze(fields.get(name)));
1481
- }
1482
- function firstTokenName(el) {
1483
- for (const attr of el.attrs) {
1484
- const token = findTokens(attr.value).find((candidate) => !candidate.dotted);
1485
- if (token !== void 0) return token.name;
1486
- }
1487
- for (const child of el.childNodes) {
1488
- if (isTextNode(child)) {
1489
- const token = findTokens(child.value).find((candidate) => !candidate.dotted);
1490
- if (token !== void 0) return token.name;
1491
- }
1492
- }
1493
- return void 0;
1494
- }
1495
- function freeze(field) {
1496
- return {
1497
- name: field.name,
1498
- type: field.type,
1499
- label: field.label,
1500
- required: field.required,
1501
- multiline: field.multiline,
1502
- ...field.help !== void 0 && { help: field.help },
1503
- ...field.minLength !== void 0 && { minLength: field.minLength },
1504
- ...field.maxLength !== void 0 && { maxLength: field.maxLength },
1505
- ...field.multiple !== void 0 && { multiple: field.multiple },
1506
- ...field.pickFrom !== void 0 && { pickFrom: field.pickFrom }
1507
- };
1508
- }
1509
-
1510
- // ../engine/src/template/analyze.ts
1511
- function analyzeTemplate(source, components = /* @__PURE__ */ new Map()) {
1512
- const requiredFields = /* @__PURE__ */ new Set();
1513
- const queryLoopPaths = [];
1514
- const curatedLoopFields = [];
1515
- const imageFields = /* @__PURE__ */ new Set();
1516
- const fileFields = /* @__PURE__ */ new Set();
1517
- const richTextFields = /* @__PURE__ */ new Set();
1518
- const constraints = /* @__PURE__ */ new Map();
1519
- const references = /* @__PURE__ */ new Set();
1520
- const visit = (nodes) => {
1521
- for (const node of nodes) {
1522
- if (!isElement(node)) continue;
1523
- const richText = richTextFieldName(node);
1524
- if (richText !== void 0) richTextFields.add(richText);
1525
- const eachIn = getAttribute(node, "data-each-in");
1526
- if (eachIn !== void 0) queryLoopPaths.push(eachIn);
1527
- const each = getAttribute(node, "data-each");
1528
- if (each !== void 0) curatedLoopFields.push(each);
1529
- if (hasAttribute(node, "data-required")) {
1530
- const field = annotatedFieldName(node);
1531
- if (field !== void 0) requiredFields.add(field);
1532
- }
1533
- const bounds = lengthConstraints(node);
1534
- if (bounds.minLength !== void 0 || bounds.maxLength !== void 0) {
1535
- const field = annotatedFieldName(node);
1536
- if (field !== void 0) constraints.set(field, bounds);
1537
- }
1538
- const annotation = getAttribute(node, "data-type");
1539
- const propTypes = components.has(node.tagName) ? componentPropTypes(node.tagName, components, /* @__PURE__ */ new Set()) : void 0;
1540
- for (const attr of node.attrs) {
1541
- const reference = referenceTarget(attr.value);
1542
- if (reference !== void 0) references.add(reference);
1543
- const token = wholeValueToken(attr.value);
1544
- if (token === null || token.dotted) continue;
1545
- const type = propTypes?.get(attr.name) ?? inferControl({
1546
- tag: node.tagName,
1547
- attribute: attr.name,
1548
- wholeValue: true,
1549
- valuePrefix: literalPrefix(attr.value),
1550
- download: hasAttribute(node, "download"),
1551
- ...annotation !== void 0 && { annotation }
1552
- });
1553
- if (type === "image") imageFields.add(token.name);
1554
- else if (type === "file") fileFields.add(token.name);
1555
- else if (type === "richtext") richTextFields.add(token.name);
1556
- }
1557
- visit(node.childNodes);
1558
- }
1559
- };
1560
- visit(parseNodes(source));
1561
- return {
1562
- requiredFields: [...requiredFields],
1563
- queryLoopPaths,
1564
- curatedLoopFields,
1565
- imageFields: [...imageFields],
1566
- fileFields: [...fileFields],
1567
- richTextFields: [...richTextFields],
1568
- constraints,
1569
- references: [...references].sort()
1570
- };
1789
+ const referenced2 = resolveReference(attr.value, ctx);
1790
+ if (referenced2 !== void 0) attr.value = sanitizeUrl(referenced2);
1791
+ continue;
1792
+ }
1793
+ const annotation = getAttribute(el, "data-type");
1794
+ const whole = wholeValueToken(attr.value) !== null;
1795
+ const type = inferControl({
1796
+ tag: el.tagName,
1797
+ attribute: attr.name,
1798
+ wholeValue: whole,
1799
+ valuePrefix: literalPrefix(attr.value),
1800
+ download: hasAttribute(el, "download"),
1801
+ ...annotation !== void 0 && { annotation }
1802
+ });
1803
+ let value = substituteTokens(attr.value, scopeResolver(scope));
1804
+ const referenced = resolveReference(value, ctx);
1805
+ if (referenced !== void 0) value = referenced;
1806
+ const fileUrl = ctx.media?.file(value);
1807
+ if (fileUrl !== void 0) value = fileUrl;
1808
+ if (whole && valueKindOf(type) === "url") value = sanitizeUrl(value);
1809
+ attr.value = value;
1810
+ }
1571
1811
  }
1572
- function richTextFieldName(el) {
1812
+ function substituteRawText(el, scope, mode) {
1813
+ const resolve = scopeResolver(scope);
1814
+ for (const child of el.childNodes) {
1815
+ if (!isTextNode(child)) continue;
1816
+ child.value = substituteTokens(child.value, (token) => {
1817
+ const text = resolve(token);
1818
+ return mode === "json" ? escapeJsonStringContent(text) : text;
1819
+ });
1820
+ }
1821
+ }
1822
+ function wholeValueFieldToken(el, annotation) {
1573
1823
  const meaningful = el.childNodes.filter(
1574
1824
  (child) => !(isTextNode(child) && child.value.trim() === "")
1575
1825
  );
1576
1826
  const only = meaningful[0];
1577
- if (meaningful.length !== 1 || only === void 0 || !isTextNode(only)) return void 0;
1827
+ if (meaningful.length !== 1 || only === void 0 || !isTextNode(only)) return null;
1578
1828
  const token = wholeValueToken(only.value);
1579
- if (token === null || token.dotted) return void 0;
1580
- const annotation = getAttribute(el, "data-type");
1829
+ if (token === null || token.dotted) return null;
1581
1830
  const type = inferControl({
1582
1831
  tag: el.tagName,
1583
1832
  wholeValue: true,
1584
1833
  ...annotation !== void 0 && { annotation }
1585
1834
  });
1586
- return valueKindOf(type) === "html" ? token.name : void 0;
1835
+ return { token, kind: valueKindOf(type) };
1587
1836
  }
1588
- function annotatedFieldName(el) {
1589
- const explicit = getAttribute(el, "data-field");
1590
- if (explicit !== void 0) return explicit;
1837
+ function isJsonLd(el) {
1838
+ return el.tagName === "script" && getAttribute(el, "type")?.toLowerCase() === "application/ld+json";
1839
+ }
1840
+ function expandComponent(el, scope, ctx) {
1841
+ const source = ctx.components.get(el.tagName);
1842
+ if (source === void 0) return [el];
1843
+ const resolve = scopeResolver(scope);
1844
+ const propScope = {};
1591
1845
  for (const attr of el.attrs) {
1592
- const token = findTokens(attr.value).find((candidate) => !candidate.dotted);
1593
- if (token !== void 0) return token.name;
1846
+ propScope[attr.name] = substituteTokens(attr.value, resolve);
1594
1847
  }
1595
- for (const child of el.childNodes) {
1596
- if (isTextNode(child)) {
1597
- const token = findTokens(child.value).find((candidate) => !candidate.dotted);
1598
- if (token !== void 0) return token.name;
1848
+ const slotChildren = transformNodes(el.childNodes, scope, ctx);
1849
+ const body = transformNodes(parseNodes(source), propScope, ctx);
1850
+ const result = fillSlots(body, slotChildren);
1851
+ ctx.usedComponents.add(el.tagName);
1852
+ const css = ctx.componentStyles?.get(el.tagName)?.trim();
1853
+ if (css) {
1854
+ const root = result.find(isElement);
1855
+ if (root !== void 0) {
1856
+ const styleNode = parseFragmentNodes(`<style>@scope {
1857
+ ${css}
1858
+ }</style>`);
1859
+ root.childNodes = [...styleNode, ...root.childNodes];
1599
1860
  }
1600
1861
  }
1601
- return void 0;
1862
+ return result;
1863
+ }
1864
+ function fillSlots(body, slotChildren) {
1865
+ const named = /* @__PURE__ */ new Map();
1866
+ const defaultSlot = [];
1867
+ for (const child of slotChildren) {
1868
+ const slotName = isElement(child) ? getAttribute(child, "slot") : void 0;
1869
+ if (slotName !== void 0) {
1870
+ if (isElement(child)) removeAttribute(child, "slot");
1871
+ const group = named.get(slotName) ?? [];
1872
+ group.push(child);
1873
+ named.set(slotName, group);
1874
+ } else {
1875
+ defaultSlot.push(child);
1876
+ }
1877
+ }
1878
+ return replaceSlots(body, named, defaultSlot);
1879
+ }
1880
+ function replaceSlots(nodes, named, defaultSlot) {
1881
+ const out = [];
1882
+ for (const node of nodes) {
1883
+ if (isElement(node) && node.tagName === "slot") {
1884
+ const name = getAttribute(node, "name");
1885
+ const provided = name !== void 0 ? named.get(name) : defaultSlot;
1886
+ if (provided && provided.length > 0) {
1887
+ out.push(...provided);
1888
+ } else {
1889
+ out.push(...replaceSlots(node.childNodes, named, defaultSlot));
1890
+ }
1891
+ } else if (isElement(node)) {
1892
+ node.childNodes = replaceSlots(node.childNodes, named, defaultSlot);
1893
+ out.push(node);
1894
+ } else {
1895
+ out.push(node);
1896
+ }
1897
+ }
1898
+ return out;
1899
+ }
1900
+
1901
+ // ../engine/src/aggregate/aggregate.ts
1902
+ function joinUrl(baseUrl, sitePath) {
1903
+ return baseUrl.replace(/\/+$/, "") + sitePath;
1904
+ }
1905
+ function textContent(html) {
1906
+ const parts = [];
1907
+ const visit = (nodes) => {
1908
+ for (const node of nodes) {
1909
+ if (isTextNode(node)) parts.push(node.value);
1910
+ else if (isElement(node)) visit(node.childNodes);
1911
+ }
1912
+ };
1913
+ visit(parseFragmentNodes(html));
1914
+ return parts.join(" ").replace(/\s+/g, " ").trim();
1915
+ }
1916
+ function buildSitemap(entries, baseUrl) {
1917
+ const urls = entries.map((entry) => {
1918
+ const lastmod = entry.updated ?? entry.published;
1919
+ const mod = lastmod !== void 0 ? `
1920
+ <lastmod>${escapeHtmlText(lastmod)}</lastmod>` : "";
1921
+ return ` <url>
1922
+ <loc>${escapeHtmlText(joinUrl(baseUrl, entry.url))}</loc>${mod}
1923
+ </url>`;
1924
+ }).join("\n");
1925
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
1926
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1927
+ ${urls}
1928
+ </urlset>
1929
+ `;
1930
+ return { path: "sitemap.xml", contents };
1931
+ }
1932
+ function buildRss(entries, options) {
1933
+ const items = [...entries].sort(
1934
+ (a, b) => String(b.published ?? "").localeCompare(String(a.published ?? ""))
1935
+ );
1936
+ const lastBuild = items[0]?.published;
1937
+ const rfc822 = (iso) => new Date(iso).toUTCString();
1938
+ const itemXml = items.map((entry) => {
1939
+ const link = joinUrl(options.baseUrl, entry.url);
1940
+ const pub = entry.published !== void 0 ? `
1941
+ <pubDate>${escapeHtmlText(rfc822(entry.published))}</pubDate>` : "";
1942
+ const desc = entry.summary !== void 0 ? `
1943
+ <description>${escapeHtmlText(entry.summary)}</description>` : "";
1944
+ return ` <item>
1945
+ <title>${escapeHtmlText(entry.title)}</title>
1946
+ <link>${escapeHtmlText(link)}</link>
1947
+ <guid>${escapeHtmlText(link)}</guid>${pub}${desc}
1948
+ </item>`;
1949
+ }).join("\n");
1950
+ const built = lastBuild !== void 0 ? `
1951
+ <lastBuildDate>${escapeHtmlText(rfc822(lastBuild))}</lastBuildDate>` : "";
1952
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
1953
+ <rss version="2.0">
1954
+ <channel>
1955
+ <title>${escapeHtmlText(options.title)}</title>
1956
+ <link>${escapeHtmlText(options.baseUrl)}</link>
1957
+ <description>${escapeHtmlText(options.description)}</description>${built}
1958
+ ${itemXml}
1959
+ </channel>
1960
+ </rss>
1961
+ `;
1962
+ return { path: "feed.xml", contents };
1963
+ }
1964
+ function buildContentIndex(entries) {
1965
+ const docs = entries.map((entry) => ({
1966
+ url: entry.url,
1967
+ title: entry.title,
1968
+ ...entry.published !== void 0 && { published: entry.published },
1969
+ ...entry.summary !== void 0 && { summary: entry.summary }
1970
+ }));
1971
+ return { path: "content.json", contents: JSON.stringify(docs) };
1972
+ }
1973
+
1974
+ // ../engine/src/concurrency.ts
1975
+ async function mapWithConcurrency(items, limit, fn) {
1976
+ const results = new Array(items.length);
1977
+ const workers = Math.max(1, Math.min(Math.floor(limit), items.length));
1978
+ let next = 0;
1979
+ const run = async () => {
1980
+ while (next < items.length) {
1981
+ const index = next++;
1982
+ results[index] = await fn(items[index], index);
1983
+ }
1984
+ };
1985
+ await Promise.all(Array.from({ length: workers }, () => run()));
1986
+ return results;
1602
1987
  }
1603
1988
 
1604
1989
  // ../engine/src/url/urls.ts
@@ -2036,7 +2421,8 @@ function buildAuthoringReference(context = {}) {
2036
2421
  annotationsSection(),
2037
2422
  loopsSection(context),
2038
2423
  componentsSection(context),
2039
- shellSection()
2424
+ shellSection(),
2425
+ guardrailsSection()
2040
2426
  ]
2041
2427
  };
2042
2428
  }
@@ -2181,6 +2567,41 @@ function componentsSection(context) {
2181
2567
  entries
2182
2568
  };
2183
2569
  }
2570
+ function guardrailsSection() {
2571
+ return {
2572
+ title: "Authoring guardrails for LLMs",
2573
+ intro: "Common pitfalls when authoring through MCP, where you can't see the editor form's shape change. Most stem from the same root: data-* annotations bind to the element they sit on, not to the token inside it.",
2574
+ entries: [
2575
+ {
2576
+ name: "data-* annotations are element-bound",
2577
+ summary: "data-type, data-required, data-field, data-minlength, and data-maxlength apply to the *element* that carries them \u2014 moving a token outside its annotated container silently strips those properties from the schema.",
2578
+ detail: 'Example: `<div data-type="richtext">{body}</div>` makes body a rich-text field. If you change it to `<div data-type="richtext"></div>{body}`, body drops back to plain shorttext. The compiler still substitutes the token at fragment root, but existing HTML content for body now renders escaped.',
2579
+ example: '<!-- WRONG (drops the annotation) -->\n<div data-type="richtext"></div>{body}\n\n<!-- RIGHT -->\n<div data-type="richtext">{body}</div>'
2580
+ },
2581
+ {
2582
+ name: "After write_file on a template, check `schemaDelta`",
2583
+ summary: "Every write to templates/*.html or components/*.html returns a `schemaDelta` describing added/removed/typeChanged fields. If `typeChanged` contains `destructive: true` entries or `removed` is non-empty, an auto-stamp has been written and authored content may need migration \u2014 surface to the user before further edits.",
2584
+ detail: "`schemaDelta` shape: `{added: [{name, type}], removed: [{name, type}], typeChanged: [{name, from, to, destructive}]}`. Empty arrays everywhere means the edit changed only whitespace, comments, or non-token markup \u2014 safe."
2585
+ },
2586
+ {
2587
+ name: "After rename_path on a template, search content for the old name",
2588
+ summary: 'Renaming `templates/article.html` to `templates/post.html` leaves every content item with `template: "article"` bound to a now-missing template. The validation gate will report these on publish, but the cheaper check is `list_pending_migrations` followed by editing the items\' `template` fields.'
2589
+ },
2590
+ {
2591
+ name: "After delete_path on a template, expect publish-blocking errors",
2592
+ summary: "Deleting a template that any item binds to will fail validation with `template-missing` per item. Use `list_pending_migrations` first to see who would be affected, and consider whether to rename items to a different template before deleting."
2593
+ },
2594
+ {
2595
+ name: "Read describe_template before editing a content item",
2596
+ summary: "A content item's `fields` object MUST match the bound template's schema (field names and value shapes). `describe_template` returns the field list with types so you build the right shape on the first try; mismatched keys become orphan fields the gate warns about."
2597
+ },
2598
+ {
2599
+ name: "richtext values are HTML, shorttext/text are plain text",
2600
+ summary: "For a `richtext` field, write the value as HTML (`<p>...</p>` etc). For `shorttext` or `text`, write plain text \u2014 any HTML you include is escaped at render time. If the gate emits a `content.type-drift` warning, a field's type changed destructively (commonly richtext \u2192 shorttext) and the old HTML content now renders as escaped text."
2601
+ }
2602
+ ]
2603
+ };
2604
+ }
2184
2605
  function shellSection() {
2185
2606
  return {
2186
2607
  title: "The document shell",
@@ -2269,18 +2690,91 @@ async function validateSite(source) {
2269
2690
  }
2270
2691
  };
2271
2692
  const analysisCache = /* @__PURE__ */ new Map();
2693
+ const sourceCache = /* @__PURE__ */ new Map();
2272
2694
  const analyze = async (name) => {
2273
2695
  const cached = analysisCache.get(name);
2274
2696
  if (cached !== void 0 || analysisCache.has(name)) return cached ?? null;
2275
2697
  let result;
2276
2698
  try {
2277
- result = analyzeTemplate(await loadTemplate(source, name), components);
2699
+ const html = await loadTemplate(source, name);
2700
+ sourceCache.set(name, html);
2701
+ result = analyzeTemplate(html, components);
2278
2702
  } catch {
2279
2703
  result = null;
2280
2704
  }
2281
2705
  analysisCache.set(name, result);
2282
2706
  return result;
2283
2707
  };
2708
+ const expectedFieldsCache = /* @__PURE__ */ new Map();
2709
+ const expectedFieldsFor = (templateName) => {
2710
+ const cached = expectedFieldsCache.get(templateName);
2711
+ if (cached !== void 0) return cached;
2712
+ const html = sourceCache.get(templateName);
2713
+ const fields = new Set(
2714
+ html !== void 0 ? deriveFields(html, components).map((f) => f.name) : []
2715
+ );
2716
+ expectedFieldsCache.set(templateName, fields);
2717
+ return fields;
2718
+ };
2719
+ const driftFieldsCache = /* @__PURE__ */ new Map();
2720
+ const driftFieldsFor = async (templateName) => {
2721
+ if (driftFieldsCache.has(templateName)) return driftFieldsCache.get(templateName) ?? null;
2722
+ const highest = await highestSnapshotName(templateName);
2723
+ if (highest === null) {
2724
+ driftFieldsCache.set(templateName, null);
2725
+ return null;
2726
+ }
2727
+ let snapshotHtml;
2728
+ try {
2729
+ snapshotHtml = await loadTemplate(source, highest);
2730
+ } catch {
2731
+ driftFieldsCache.set(templateName, null);
2732
+ return null;
2733
+ }
2734
+ const currentHtml = sourceCache.get(templateName);
2735
+ if (currentHtml === void 0) {
2736
+ driftFieldsCache.set(templateName, null);
2737
+ return null;
2738
+ }
2739
+ const delta = computeSchemaDelta(
2740
+ deriveFields(snapshotHtml, components),
2741
+ deriveFields(currentHtml, components)
2742
+ );
2743
+ const destructive = new Set(
2744
+ delta.typeChanged.filter((change) => change.destructive).map((change) => change.name)
2745
+ );
2746
+ driftFieldsCache.set(templateName, destructive);
2747
+ return destructive;
2748
+ };
2749
+ const snapshotNameCache = /* @__PURE__ */ new Map();
2750
+ const highestSnapshotName = async (baseName) => {
2751
+ if (snapshotNameCache.has(baseName)) return snapshotNameCache.get(baseName) ?? null;
2752
+ const slash = baseName.lastIndexOf("/");
2753
+ const dir = slash === -1 ? "templates" : `templates/${baseName.slice(0, slash)}`;
2754
+ const stem = slash === -1 ? baseName : baseName.slice(slash + 1);
2755
+ let entries;
2756
+ try {
2757
+ entries = await source.list(dir);
2758
+ } catch {
2759
+ snapshotNameCache.set(baseName, null);
2760
+ return null;
2761
+ }
2762
+ let highest = 0;
2763
+ let highestStem = null;
2764
+ const prefix = `${stem}@v`;
2765
+ for (const entry of entries) {
2766
+ if (entry.kind !== "file" || !entry.name.endsWith(".html")) continue;
2767
+ const entryStem = entry.name.slice(0, -".html".length);
2768
+ if (!entryStem.startsWith(prefix)) continue;
2769
+ const version = versionNumber(entryStem);
2770
+ if (version !== null && version > highest) {
2771
+ highest = version;
2772
+ highestStem = slash === -1 ? entryStem : `${baseName.slice(0, slash)}/${entryStem}`;
2773
+ }
2774
+ }
2775
+ snapshotNameCache.set(baseName, highestStem);
2776
+ return highestStem;
2777
+ };
2284
2778
  const urlOwners = /* @__PURE__ */ new Map();
2285
2779
  for (const { node, templateName } of published) {
2286
2780
  const owners = urlOwners.get(urlForItem(node.path)) ?? [];
@@ -2297,14 +2791,40 @@ async function validateSite(source) {
2297
2791
  }
2298
2792
  const analysis = await analyze(templateName);
2299
2793
  if (analysis === null) {
2794
+ const snapshotHint = isVersionedTemplateName(templateName) ? ` (the snapshot may have been deleted while this item still pins to it \u2014 restore it, rebind the item to current via the Migrations workspace, or delete this item).` : "";
2300
2795
  add(
2301
2796
  "error",
2302
2797
  "template-missing",
2303
- `"${node.path}" uses template "${templateName}", which does not exist.`,
2798
+ `"${node.path}" uses template "${templateName}", which does not exist.${snapshotHint}`,
2304
2799
  node.path
2305
2800
  );
2306
2801
  continue;
2307
2802
  }
2803
+ if (!isVersionedTemplateName(templateName)) {
2804
+ const expectedFields = expectedFieldsFor(templateName);
2805
+ const itemFieldNames = Object.keys(node.item.fields);
2806
+ const orphans = itemFieldNames.filter((name) => !expectedFields.has(name));
2807
+ if (orphans.length > 0) {
2808
+ add(
2809
+ "warning",
2810
+ "content.schema-drift",
2811
+ `"${node.path}" has field${orphans.length === 1 ? "" : "s"} not rendered by template "${templateName}": ${orphans.map((o) => `"${o}"`).join(", ")}. Open the Migrations workspace to drop, rename, or keep them.`,
2812
+ node.path
2813
+ );
2814
+ }
2815
+ const driftFields = await driftFieldsFor(templateName);
2816
+ if (driftFields !== null && driftFields.size > 0) {
2817
+ const affected = itemFieldNames.filter((name) => driftFields.has(name));
2818
+ if (affected.length > 0) {
2819
+ add(
2820
+ "warning",
2821
+ "content.type-drift",
2822
+ `"${node.path}" has field${affected.length === 1 ? "" : "s"} whose type changed destructively in template "${templateName}": ${affected.map((f) => `"${f}"`).join(", ")}. The value will render under the new type (commonly HTML escaped as text). Open the Migrations workspace to migrate.`,
2823
+ node.path
2824
+ );
2825
+ }
2826
+ }
2827
+ }
2308
2828
  const scope = buildScope2(node.item);
2309
2829
  for (const field of analysis.requiredFields) {
2310
2830
  if (isEmpty(scope[field])) {
@@ -2590,10 +3110,10 @@ function createDiagnosticRegistry() {
2590
3110
  repairs.set(repair.id, repair);
2591
3111
  },
2592
3112
  runAll,
2593
- async runRepair(id, deps) {
3113
+ async runRepair(id, deps, args = {}) {
2594
3114
  const repair = repairs.get(id);
2595
3115
  if (repair === void 0) throw new Error(`Unknown repair id: ${id}`);
2596
- await repair.run(deps);
3116
+ await repair.run(deps, args);
2597
3117
  return runAll(deps);
2598
3118
  }
2599
3119
  };
@@ -2733,6 +3253,22 @@ export {
2733
3253
  parseContentItem,
2734
3254
  parseSortFile,
2735
3255
  InMemoryBlobStore,
3256
+ FIELD_TYPES,
3257
+ isFieldType,
3258
+ valueKindOf,
3259
+ inferControl,
3260
+ findTokens,
3261
+ wholeValueToken,
3262
+ deriveFields,
3263
+ isDestructiveTypeChange,
3264
+ computeSchemaDelta,
3265
+ detectStamp,
3266
+ isVersionedTemplateName,
3267
+ baseTemplateName,
3268
+ versionNumber,
3269
+ snapshotName,
3270
+ nextVersionNumber,
3271
+ isReservedVersionedPath,
2736
3272
  RESERVED_PREFIX,
2737
3273
  emptyIndex,
2738
3274
  loadIndex,
@@ -2752,12 +3288,10 @@ export {
2752
3288
  loadTemplate,
2753
3289
  DOCUMENT_SHELL,
2754
3290
  loadDocumentShell,
2755
- FIELD_TYPES,
2756
- isFieldType,
2757
- valueKindOf,
2758
- inferControl,
2759
- findTokens,
2760
- wholeValueToken,
3291
+ analyzeTemplate,
3292
+ pendingMigrations,
3293
+ bestFitSnapshot,
3294
+ applyMigration,
2761
3295
  escapeHtmlText,
2762
3296
  escapeHtmlAttribute,
2763
3297
  escapeJsonStringContent,
@@ -2773,8 +3307,6 @@ export {
2773
3307
  buildSitemap,
2774
3308
  buildRss,
2775
3309
  buildContentIndex,
2776
- deriveFields,
2777
- analyzeTemplate,
2778
3310
  outputPathForItem,
2779
3311
  urlForItem,
2780
3312
  compileSite,