@shop-on/theme-contract 0.5.0 → 0.6.0

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 (43) hide show
  1. package/dist/ai-files.d.ts +18 -0
  2. package/dist/ai-files.js +47 -0
  3. package/dist/block-allowlist.d.ts +27 -0
  4. package/dist/block-allowlist.js +21 -0
  5. package/dist/closest-scope.d.ts +51 -0
  6. package/dist/closest-scope.js +66 -0
  7. package/dist/editor-tools/apply.d.ts +85 -0
  8. package/dist/editor-tools/apply.js +544 -0
  9. package/dist/editor-tools/args.d.ts +153 -0
  10. package/dist/editor-tools/args.js +145 -0
  11. package/dist/editor-tools/coerce.d.ts +43 -0
  12. package/dist/editor-tools/coerce.js +200 -0
  13. package/dist/editor-tools/digest.d.ts +75 -0
  14. package/dist/editor-tools/digest.js +99 -0
  15. package/dist/editor-tools/index.d.ts +21 -0
  16. package/dist/editor-tools/index.js +24 -0
  17. package/dist/editor-tools/recipes.d.ts +122 -0
  18. package/dist/editor-tools/recipes.js +192 -0
  19. package/dist/fonts.d.ts +9 -0
  20. package/dist/fonts.js +11 -0
  21. package/dist/google-fonts.generated.d.ts +7 -0
  22. package/dist/google-fonts.generated.js +753 -0
  23. package/dist/index.d.ts +14 -5
  24. package/dist/index.js +14 -5
  25. package/dist/link-url.d.ts +9 -0
  26. package/dist/link-url.js +33 -0
  27. package/dist/metafield-binding.d.ts +89 -9
  28. package/dist/metafield-binding.js +254 -43
  29. package/dist/ops.d.ts +85 -0
  30. package/dist/ops.js +442 -0
  31. package/dist/richtext.d.ts +23 -0
  32. package/dist/richtext.js +115 -1
  33. package/dist/schemas.d.ts +325 -0
  34. package/dist/schemas.js +57 -7
  35. package/dist/section-scope.d.ts +17 -0
  36. package/dist/section-scope.js +22 -0
  37. package/dist/store-path.d.ts +9 -0
  38. package/dist/store-path.js +13 -1
  39. package/dist/template-limits.d.ts +34 -0
  40. package/dist/template-limits.js +71 -0
  41. package/dist/video-url.d.ts +10 -0
  42. package/dist/video-url.js +36 -0
  43. package/package.json +8 -1
@@ -0,0 +1,544 @@
1
+ import { resolveContainerAtPath } from "../block-path.js";
2
+ import { buildBlockInstance, buildSectionInstance } from "../document.js";
3
+ import { parsePaletteRef } from "../color-palette.js";
4
+ import { diffDocument } from "../ops.js";
5
+ import { MAX_BLOCKS_PER_TEMPLATE, maxBlockDepth, templateCounts } from "../template-limits.js";
6
+ import { isSectionAddableInGroup, isSectionEnabledOnTemplate } from "../section-scope.js";
7
+ import { allowedChildBlockTypes } from "../block-allowlist.js";
8
+ import { EDITOR_TOOL_SCHEMAS, MAX_TEXT_PARAM_BYTES, isEditorToolName } from "./args.js";
9
+ import { checkRecipe, recipeSummary, substituteParams } from "./recipes.js";
10
+ import { prepareSettingValue } from "./coerce.js";
11
+ import { digestInstance, digestSchemas, digestSettings, digestTemplate } from "./digest.js";
12
+ //#region src/editor-tools/apply.ts
13
+ var Refusal = class {
14
+ reason;
15
+ constructor(reason) {
16
+ this.reason = reason;
17
+ }
18
+ };
19
+ function refuse(reason) {
20
+ throw new Refusal(reason);
21
+ }
22
+ const defaultIdFor = (type) => `${type}_${crypto.randomUUID().slice(0, 8)}`;
23
+ function locate(doc, scope) {
24
+ if ("template_id" in scope) {
25
+ const entry = Object.hasOwn(doc.templates, scope.template_id) ? doc.templates[scope.template_id] : void 0;
26
+ return entry ? {
27
+ kind: "template",
28
+ id: scope.template_id,
29
+ entry
30
+ } : refuse("template not found");
31
+ }
32
+ const entry = Object.hasOwn(doc.groups, scope.group) ? doc.groups[scope.group] : void 0;
33
+ return entry ? {
34
+ kind: "group",
35
+ id: scope.group,
36
+ entry
37
+ } : refuse("group not found");
38
+ }
39
+ function withEntry(doc, loc, entry) {
40
+ return loc.kind === "template" ? {
41
+ ...doc,
42
+ templates: {
43
+ ...doc.templates,
44
+ [loc.id]: entry
45
+ }
46
+ } : {
47
+ ...doc,
48
+ groups: {
49
+ ...doc.groups,
50
+ [loc.id]: entry
51
+ }
52
+ };
53
+ }
54
+ function sectionAt(loc, sectionId) {
55
+ return Object.hasOwn(loc.entry.sections, sectionId) ? loc.entry.sections[sectionId] : refuse("section not found");
56
+ }
57
+ function sectionSchema(schemas, type) {
58
+ return schemas.sections.find((s) => s.type === type);
59
+ }
60
+ function blockSchema(schemas, type) {
61
+ return schemas.blocks.find((b) => b.type === type) ?? schemas.blocks.find((b) => b.type === type.replace(/^_/, ""));
62
+ }
63
+ function containerSchema(schemas, container, blockPath) {
64
+ return blockPath.length === 0 ? sectionSchema(schemas, container.type) : blockSchema(schemas, container.type);
65
+ }
66
+ function placeable(schema, loc) {
67
+ return loc.kind === "template" ? isSectionEnabledOnTemplate(schema, loc.id) : isSectionAddableInGroup(schema, loc.id);
68
+ }
69
+ function updateAt(node, path, fn) {
70
+ if (path.length === 0) return fn(node);
71
+ const [head, ...rest] = path;
72
+ const child = node.blocks[head];
73
+ return {
74
+ ...node,
75
+ blocks: {
76
+ ...node.blocks,
77
+ [head]: updateAt(child, rest, fn)
78
+ }
79
+ };
80
+ }
81
+ function stampApp(node, schema) {
82
+ if (schema?.source !== "app") return;
83
+ node.source = "app";
84
+ node.appId = schema.appId;
85
+ node.tag = schema.tag;
86
+ node.scriptUrl = schema.scriptUrl;
87
+ }
88
+ function stampAppBlocks(node, schemas) {
89
+ for (const child of Object.values(node.blocks ?? {})) {
90
+ stampApp(child, blockSchema(schemas, child.type));
91
+ stampAppBlocks(child, schemas);
92
+ }
93
+ }
94
+ function isPermutation(order, current) {
95
+ return order.length === current.length && [...order].sort().join("\n") === [...current].sort().join("\n");
96
+ }
97
+ /** The caps an add may not push an entry past. An entry already over one (a
98
+ * peer's edit) can still shrink. */
99
+ function capRefusal(prev, next) {
100
+ const a = templateCounts(prev);
101
+ const b = templateCounts(next);
102
+ if (b.sections > 25 && b.sections > a.sections) return `a template holds at most 25 sections`;
103
+ if (b.blocks > 1250 && b.blocks > a.blocks) return `a template holds at most ${MAX_BLOCKS_PER_TEMPLATE} blocks`;
104
+ const depth = maxBlockDepth(next);
105
+ if (depth > 8 && depth > maxBlockDepth(prev)) return `blocks nest at most 8 deep`;
106
+ return null;
107
+ }
108
+ function insertAt(order, ids, index) {
109
+ const at = index === void 0 ? order.length : Math.min(index, order.length);
110
+ return [
111
+ ...order.slice(0, at),
112
+ ...ids,
113
+ ...order.slice(at)
114
+ ];
115
+ }
116
+ function getBlockSchemaFn(schemas) {
117
+ return (type) => blockSchema(schemas, type);
118
+ }
119
+ function buildSection(ctx, schema, preset) {
120
+ const section = buildSectionInstance({
121
+ type: schema.type,
122
+ sectionSchema: schema,
123
+ getBlockSchema: getBlockSchemaFn(ctx.schemas),
124
+ preset,
125
+ idFor: ctx.idFor ?? defaultIdFor
126
+ });
127
+ stampApp(section, schema);
128
+ stampAppBlocks(section, ctx.schemas);
129
+ return section;
130
+ }
131
+ function valueContext(ctx) {
132
+ return {
133
+ themeSettings: ctx.doc.settings,
134
+ sanitizeRichText: ctx.sanitizeRichText,
135
+ images: ctx.images,
136
+ fonts: ctx.fonts
137
+ };
138
+ }
139
+ function prepared(where, settings, schemaSettings, params, vctx) {
140
+ const out = {};
141
+ for (const [id, value] of Object.entries(settings ?? {})) {
142
+ const setting = schemaSettings.find((s) => s.id === id);
143
+ const raw = typeof value === "string" ? substituteParams(value, params) : value;
144
+ const res = prepareSettingValue(setting, raw, vctx);
145
+ if (!res.ok) refuse(`${where}.settings.${id}: ${res.reason}`);
146
+ out[id] = res.value;
147
+ }
148
+ return out;
149
+ }
150
+ function recipeBlockDefs(where, blocks, ctx, params, vctx) {
151
+ return blocks.map((block, i) => {
152
+ const at = `${where}.blocks[${i}]`;
153
+ const schema = blockSchema(ctx.schemas, block.type);
154
+ const preset = schema.presets?.[0];
155
+ return {
156
+ type: block.type,
157
+ settings: {
158
+ ...preset?.settings,
159
+ ...prepared(at, block.settings, schema.settings, params, vctx)
160
+ },
161
+ blocks: block.blocks ? recipeBlockDefs(at, block.blocks, ctx, params, vctx) : preset?.blocks
162
+ };
163
+ });
164
+ }
165
+ function recipeParams(recipe, given) {
166
+ const declared = recipe.params ?? {};
167
+ for (const name of Object.keys(given)) if (!Object.hasOwn(declared, name)) refuse(`unknown param ${name}`);
168
+ const out = {};
169
+ for (const [name, param] of Object.entries(declared)) {
170
+ const value = Object.hasOwn(given, name) ? given[name] : param.default;
171
+ if (value === void 0 || value === "") {
172
+ if (param.required) refuse(`missing required param ${name}`);
173
+ out[name] = "";
174
+ continue;
175
+ }
176
+ const str = String(value);
177
+ switch (param.type) {
178
+ case "text":
179
+ if (new TextEncoder().encode(str).length > 2048) refuse(`param ${name} is over ${MAX_TEXT_PARAM_BYTES} bytes`);
180
+ break;
181
+ case "number":
182
+ if (str.trim() === "" || !Number.isFinite(Number(str))) refuse(`param ${name} must be a number`);
183
+ break;
184
+ case "product":
185
+ case "collection": if (!/^[\p{L}\p{N}][\p{L}\p{N}_-]{0,254}$/u.test(str)) refuse(`param ${name} must be a ${param.type} handle`);
186
+ }
187
+ out[name] = str;
188
+ }
189
+ return out;
190
+ }
191
+ /**
192
+ * A recipe's sections, built and coerced as `add_section` and
193
+ * `update_*_setting` would build and coerce them, keyed by their new ids in
194
+ * recipe order. Refuses the whole recipe at the first bad section, block,
195
+ * setting or param.
196
+ */
197
+ function expandRecipe(recipe, params, ctx, scope) {
198
+ try {
199
+ const bad = checkRecipe(recipe, ctx.schemas, scope);
200
+ if (bad) refuse(bad);
201
+ const values = recipeParams(recipe, params);
202
+ const vctx = valueContext(ctx);
203
+ const idFor = ctx.idFor ?? defaultIdFor;
204
+ return {
205
+ ok: true,
206
+ sections: recipe.sections.map((section, i) => {
207
+ const at = `sections[${i}]`;
208
+ const schema = sectionSchema(ctx.schemas, section.type);
209
+ const preset = schema.presets?.[0];
210
+ const settings = {
211
+ ...preset?.settings,
212
+ ...prepared(at, section.settings, schema.settings, values, vctx)
213
+ };
214
+ const blocks = section.blocks ? [...(schema.blocks ?? []).filter((b) => b.static), ...recipeBlockDefs(at, section.blocks, ctx, values, vctx)] : preset?.blocks;
215
+ return [idFor(section.type), buildSection(ctx, schema, {
216
+ settings,
217
+ blocks
218
+ })];
219
+ })
220
+ };
221
+ } catch (e) {
222
+ if (e instanceof Refusal) return {
223
+ ok: false,
224
+ reason: e.reason
225
+ };
226
+ throw e;
227
+ }
228
+ }
229
+ function settingWrite(ctx, loc, sectionId, blockPath, settingId, value) {
230
+ const section = sectionAt(loc, sectionId);
231
+ const node = resolveContainerAtPath(section, blockPath) ?? refuse("block not found");
232
+ const setting = (blockPath.length === 0 && node === section ? sectionSchema(ctx.schemas, node.type) : blockSchema(ctx.schemas, node.type))?.settings?.find((s) => s.id === settingId) ?? refuse(`unknown setting id ${settingId}`);
233
+ const res = prepareSettingValue(setting, value, valueContext(ctx));
234
+ if (!res.ok) refuse(res.reason);
235
+ if (Object.hasOwn(node.settings ?? {}, settingId) && node.settings[settingId] === res.value) return { result: { unchanged: true } };
236
+ const nextSection = updateAt(section, blockPath, (c) => ({
237
+ ...c,
238
+ settings: {
239
+ ...c.settings,
240
+ [settingId]: res.value
241
+ }
242
+ }));
243
+ const entry = {
244
+ ...loc.entry,
245
+ sections: {
246
+ ...loc.entry.sections,
247
+ [sectionId]: nextSection
248
+ }
249
+ };
250
+ return {
251
+ next: withEntry(ctx.doc, loc, entry),
252
+ result: {}
253
+ };
254
+ }
255
+ /** `block_path` + `block_id` → the path of the block itself. */
256
+ const blockTarget = (path, id) => [...path ?? [], id];
257
+ const HANDLERS = {
258
+ list_templates: (_args, ctx) => ({ result: { data: {
259
+ templates: Object.keys(ctx.doc.templates).sort(),
260
+ groups: Object.keys(ctx.doc.groups).sort()
261
+ } } }),
262
+ get_template: (args, ctx) => ({ result: digestTemplate(ctx.doc, args.scope) ?? refuse(notFound(args.scope)) }),
263
+ get_theme_settings: (_args, ctx) => ({ result: digestSettings(ctx.doc, ctx.schemas.settingsSchema) }),
264
+ list_section_types: (args, ctx) => {
265
+ const loc = locate(ctx.doc, args.scope);
266
+ return { result: { data: ctx.schemas.sections.filter((s) => placeable(s, loc)).map((s) => ({
267
+ type: s.type,
268
+ ...s.name ? { name: s.name } : {},
269
+ ...s.max_blocks !== void 0 ? { max_blocks: s.max_blocks } : {}
270
+ })) } };
271
+ },
272
+ get_schemas: (args, ctx) => {
273
+ const types = [...new Set(args.types)];
274
+ const digestCtx = {
275
+ kind: args.kind,
276
+ blockSchemas: ctx.schemas.blocks
277
+ };
278
+ const schemas = args.kind === "section" ? ctx.schemas.sections : ctx.schemas.blocks;
279
+ return { result: { data: digestSchemas(types, schemas, digestCtx) } };
280
+ },
281
+ get_theme_guide: (_args, ctx) => ({ result: { data: {
282
+ guide: ctx.guide ?? "",
283
+ recipes: ctx.recipes.map((r) => ({
284
+ handle: r.handle,
285
+ name: r.name,
286
+ ...r.description ? { description: r.description } : {}
287
+ }))
288
+ } } }),
289
+ list_recipes: (_args, ctx) => ({ result: { data: ctx.recipes.map(recipeSummary) } }),
290
+ get_seq: () => ({ result: {} }),
291
+ add_section: (args, ctx) => {
292
+ const loc = locate(ctx.doc, args.scope);
293
+ const schema = sectionSchema(ctx.schemas, args.type) ?? refuse(`unknown section type ${args.type}`);
294
+ if (!placeable(schema, loc)) refuse(`section type ${args.type} is not allowed in this ${loc.kind}`);
295
+ const preset = args.preset === void 0 ? schema.presets?.[0] : schema.presets?.[args.preset] ?? refuse(`section type ${args.type} has no preset ${args.preset}`);
296
+ const id = (ctx.idFor ?? defaultIdFor)(args.type);
297
+ const section = buildSection(ctx, schema, preset);
298
+ const entry = {
299
+ ...loc.entry,
300
+ sections: {
301
+ ...loc.entry.sections,
302
+ [id]: section
303
+ },
304
+ order: insertAt(loc.entry.order, [id], args.index)
305
+ };
306
+ const cap = capRefusal(loc.entry, entry);
307
+ if (cap) refuse(cap);
308
+ return {
309
+ next: withEntry(ctx.doc, loc, entry),
310
+ result: {
311
+ id,
312
+ ...digestInstance(id, section)
313
+ }
314
+ };
315
+ },
316
+ delete_section: (args, ctx) => {
317
+ const loc = locate(ctx.doc, args.scope);
318
+ sectionAt(loc, args.section_id);
319
+ const { [args.section_id]: _removed, ...sections } = loc.entry.sections;
320
+ const entry = {
321
+ ...loc.entry,
322
+ sections,
323
+ order: loc.entry.order.filter((id) => id !== args.section_id)
324
+ };
325
+ return {
326
+ next: withEntry(ctx.doc, loc, entry),
327
+ result: {}
328
+ };
329
+ },
330
+ reorder_sections: (args, ctx) => {
331
+ const loc = locate(ctx.doc, args.scope);
332
+ if (!isPermutation(args.order, loc.entry.order)) refuse("order must be a permutation of the current section ids");
333
+ if (args.order.every((id, i) => id === loc.entry.order[i])) return { result: { unchanged: true } };
334
+ return {
335
+ next: withEntry(ctx.doc, loc, {
336
+ ...loc.entry,
337
+ order: args.order
338
+ }),
339
+ result: {}
340
+ };
341
+ },
342
+ add_block: (args, ctx) => {
343
+ const loc = locate(ctx.doc, args.scope);
344
+ const path = args.block_path ?? [];
345
+ const section = sectionAt(loc, args.section_id);
346
+ const container = resolveContainerAtPath(section, path) ?? refuse("section or container block not found");
347
+ const schema = containerSchema(ctx.schemas, container, path);
348
+ const allowed = allowedChildBlockTypes(schema, ctx.schemas.blocks, { depth: path.length });
349
+ if (!allowed.includes(args.type)) refuse(`block type not allowed here; allowed: ${allowed.join(", ") || "none"}`);
350
+ const max = schema?.max_blocks;
351
+ if (typeof max === "number" && (container.block_order?.length ?? 0) >= max) refuse(`this container holds at most ${max} blocks`);
352
+ const typeSchema = blockSchema(ctx.schemas, args.type);
353
+ const idFor = ctx.idFor ?? defaultIdFor;
354
+ const id = idFor(args.type);
355
+ const block = buildBlockInstance({
356
+ type: args.type,
357
+ getBlockSchema: getBlockSchemaFn(ctx.schemas),
358
+ preset: typeSchema?.presets?.[0],
359
+ idFor
360
+ });
361
+ stampApp(block, typeSchema);
362
+ stampAppBlocks(block, ctx.schemas);
363
+ const nextSection = updateAt(section, path, (c) => ({
364
+ ...c,
365
+ blocks: {
366
+ ...c.blocks,
367
+ [id]: block
368
+ },
369
+ block_order: [...c.block_order ?? [], id]
370
+ }));
371
+ const entry = {
372
+ ...loc.entry,
373
+ sections: {
374
+ ...loc.entry.sections,
375
+ [args.section_id]: nextSection
376
+ }
377
+ };
378
+ const cap = capRefusal(loc.entry, entry);
379
+ if (cap) refuse(cap);
380
+ return {
381
+ next: withEntry(ctx.doc, loc, entry),
382
+ result: {
383
+ id,
384
+ ...digestInstance(id, block)
385
+ }
386
+ };
387
+ },
388
+ delete_block: (args, ctx) => {
389
+ const loc = locate(ctx.doc, args.scope);
390
+ const path = args.block_path ?? [];
391
+ const section = sectionAt(loc, args.section_id);
392
+ const container = resolveContainerAtPath(section, path);
393
+ if ((container?.blocks && Object.hasOwn(container.blocks, args.block_id) ? container.blocks[args.block_id] : refuse("block not found"))?.static) refuse("a static block can't be deleted");
394
+ const nextSection = updateAt(section, path, (c) => {
395
+ const { [args.block_id]: _removed, ...blocks } = c.blocks ?? {};
396
+ return {
397
+ ...c,
398
+ blocks,
399
+ block_order: (c.block_order ?? []).filter((id) => id !== args.block_id)
400
+ };
401
+ });
402
+ const entry = {
403
+ ...loc.entry,
404
+ sections: {
405
+ ...loc.entry.sections,
406
+ [args.section_id]: nextSection
407
+ }
408
+ };
409
+ return {
410
+ next: withEntry(ctx.doc, loc, entry),
411
+ result: {}
412
+ };
413
+ },
414
+ reorder_blocks: (args, ctx) => {
415
+ const loc = locate(ctx.doc, args.scope);
416
+ const path = args.block_path ?? [];
417
+ const section = sectionAt(loc, args.section_id);
418
+ const current = (resolveContainerAtPath(section, path) ?? refuse("section or container block not found")).block_order ?? [];
419
+ if (!isPermutation(args.order, current)) refuse("order must be a permutation of the container's block_order (static blocks never move)");
420
+ if (args.order.every((id, i) => id === current[i])) return { result: { unchanged: true } };
421
+ const nextSection = updateAt(section, path, (c) => ({
422
+ ...c,
423
+ block_order: args.order
424
+ }));
425
+ const entry = {
426
+ ...loc.entry,
427
+ sections: {
428
+ ...loc.entry.sections,
429
+ [args.section_id]: nextSection
430
+ }
431
+ };
432
+ return {
433
+ next: withEntry(ctx.doc, loc, entry),
434
+ result: {}
435
+ };
436
+ },
437
+ update_section_setting: (args, ctx) => settingWrite(ctx, locate(ctx.doc, args.scope), args.section_id, [], args.setting_id, args.value),
438
+ update_block_setting: (args, ctx) => settingWrite(ctx, locate(ctx.doc, args.scope), args.section_id, blockTarget(args.block_path, args.block_id), args.setting_id, args.value),
439
+ update_theme_setting: (args, ctx) => {
440
+ const id = args.setting_id;
441
+ const settings = ctx.doc.settings;
442
+ const setting = ctx.schemas.settingsSchema.flatMap((panel) => panel.settings ?? []).find((s) => s.id === id);
443
+ if (!setting) {
444
+ if (!id.includes(".") || !Object.hasOwn(settings, id)) refuse(`unknown theme setting ${id}`);
445
+ if (id.endsWith(".label")) refuse(`theme setting ${id} is not editable`);
446
+ if (String(args.value).trim() === "") refuse(`theme setting ${id} can't be cleared`);
447
+ if (id.startsWith("color_palette.") && parsePaletteRef(args.value) !== null) refuse("a palette color must be a literal color, not a palette ref");
448
+ }
449
+ const res = prepareSettingValue(setting ?? { type: "color" }, args.value, valueContext(ctx));
450
+ if (!res.ok) refuse(res.reason);
451
+ if (Object.hasOwn(settings, id) && settings[id] === res.value) return { result: { unchanged: true } };
452
+ return {
453
+ next: {
454
+ ...ctx.doc,
455
+ settings: {
456
+ ...settings,
457
+ [id]: res.value
458
+ }
459
+ },
460
+ result: {}
461
+ };
462
+ },
463
+ apply_recipe: (args, ctx) => {
464
+ const loc = locate(ctx.doc, args.scope);
465
+ const recipe = ctx.recipes.find((r) => r.handle === args.handle) ?? refuse(`no recipe ${args.handle}`);
466
+ const position = args.position ?? recipe.position ?? "append";
467
+ if (position === "replace" && args.expected_seq === void 0) refuse("position replace needs expected_seq: read the template first");
468
+ const expanded = expandRecipe(recipe, Object.fromEntries((args.params ?? []).map((p) => [p.name, p.value])), ctx, args.scope);
469
+ if (!expanded.ok) refuse(expanded.reason);
470
+ const built = expanded.sections;
471
+ const ids = built.map(([id]) => id);
472
+ const added = Object.fromEntries(built);
473
+ let entry;
474
+ if (position === "replace") {
475
+ const kept = Object.fromEntries(Object.entries(loc.entry.sections).filter(([id]) => !loc.entry.order.includes(id)));
476
+ entry = {
477
+ ...loc.entry,
478
+ sections: {
479
+ ...kept,
480
+ ...added
481
+ },
482
+ order: ids
483
+ };
484
+ } else entry = {
485
+ ...loc.entry,
486
+ sections: {
487
+ ...loc.entry.sections,
488
+ ...added
489
+ },
490
+ order: insertAt(loc.entry.order, ids, position === "prepend" ? 0 : void 0)
491
+ };
492
+ const cap = capRefusal(loc.entry, entry);
493
+ if (cap) refuse(cap);
494
+ return {
495
+ next: withEntry(ctx.doc, loc, entry),
496
+ result: {
497
+ section_ids: ids,
498
+ position
499
+ }
500
+ };
501
+ }
502
+ };
503
+ function notFound(scope) {
504
+ return "template_id" in scope ? "template not found" : "group not found";
505
+ }
506
+ function argsRefusal(error) {
507
+ const issue = error.issues[0];
508
+ const path = issue.path.map(String).join(".");
509
+ return `invalid arguments${path ? ` at ${path}` : ""}: ${issue.message}`;
510
+ }
511
+ /**
512
+ * Runs one editor tool against a document: the ops that make the edit (what
513
+ * the admin would have sent for it, via `diffDocument`) and the result the
514
+ * model sees, or a refusal. Pure: no I/O, and `ctx.doc` is never mutated.
515
+ */
516
+ function applyEditorTool(name, args, ctx) {
517
+ if (!isEditorToolName(name)) return {
518
+ ok: false,
519
+ reason: `unknown tool ${name}`
520
+ };
521
+ const parsed = EDITOR_TOOL_SCHEMAS[name].safeParse(args ?? {});
522
+ if (!parsed.success) return {
523
+ ok: false,
524
+ reason: argsRefusal(parsed.error)
525
+ };
526
+ try {
527
+ const handler = HANDLERS[name];
528
+ const out = handler(parsed.data, ctx);
529
+ const ops = out.next ? diffDocument(ctx.doc, out.next) : [];
530
+ return {
531
+ ok: true,
532
+ ops,
533
+ result: out.next && ops.length === 0 ? { unchanged: true } : out.result ?? {}
534
+ };
535
+ } catch (e) {
536
+ if (e instanceof Refusal) return {
537
+ ok: false,
538
+ reason: e.reason
539
+ };
540
+ throw e;
541
+ }
542
+ }
543
+ //#endregion
544
+ export { applyEditorTool, expandRecipe };
@@ -0,0 +1,153 @@
1
+ import { z } from "zod";
2
+ //#region src/editor-tools/args.d.ts
3
+ export declare const ScopeSchema: z.ZodUnion<readonly [z.ZodObject<{
4
+ template_id: z.ZodString;
5
+ }, z.core.$strict>, z.ZodObject<{
6
+ group: z.ZodString;
7
+ }, z.core.$strict>]>;
8
+ /** A recipe text param's cap, in bytes (the engine checks bytes). */
9
+ export declare const MAX_TEXT_PARAM_BYTES: number;
10
+ export declare const EDITOR_TOOL_SCHEMAS: {
11
+ readonly list_templates: z.ZodObject<{}, z.core.$strict>;
12
+ readonly get_template: z.ZodObject<{
13
+ scope: z.ZodUnion<readonly [z.ZodObject<{
14
+ template_id: z.ZodString;
15
+ }, z.core.$strict>, z.ZodObject<{
16
+ group: z.ZodString;
17
+ }, z.core.$strict>]>;
18
+ }, z.core.$strict>;
19
+ readonly get_theme_settings: z.ZodObject<{}, z.core.$strict>;
20
+ readonly list_section_types: z.ZodObject<{
21
+ scope: z.ZodUnion<readonly [z.ZodObject<{
22
+ template_id: z.ZodString;
23
+ }, z.core.$strict>, z.ZodObject<{
24
+ group: z.ZodString;
25
+ }, z.core.$strict>]>;
26
+ }, z.core.$strict>;
27
+ readonly get_schemas: z.ZodObject<{
28
+ kind: z.ZodEnum<{
29
+ block: "block";
30
+ section: "section";
31
+ }>;
32
+ types: z.ZodArray<z.ZodString>;
33
+ }, z.core.$strict>;
34
+ readonly get_theme_guide: z.ZodObject<{}, z.core.$strict>;
35
+ readonly list_recipes: z.ZodObject<{}, z.core.$strict>;
36
+ readonly get_seq: z.ZodObject<{}, z.core.$strict>;
37
+ readonly add_section: z.ZodObject<{
38
+ scope: z.ZodUnion<readonly [z.ZodObject<{
39
+ template_id: z.ZodString;
40
+ }, z.core.$strict>, z.ZodObject<{
41
+ group: z.ZodString;
42
+ }, z.core.$strict>]>;
43
+ type: z.ZodString;
44
+ index: z.ZodOptional<z.ZodNumber>;
45
+ preset: z.ZodOptional<z.ZodNumber>;
46
+ }, z.core.$strict>;
47
+ readonly delete_section: z.ZodObject<{
48
+ scope: z.ZodUnion<readonly [z.ZodObject<{
49
+ template_id: z.ZodString;
50
+ }, z.core.$strict>, z.ZodObject<{
51
+ group: z.ZodString;
52
+ }, z.core.$strict>]>;
53
+ section_id: z.ZodString;
54
+ }, z.core.$strict>;
55
+ readonly reorder_sections: z.ZodObject<{
56
+ scope: z.ZodUnion<readonly [z.ZodObject<{
57
+ template_id: z.ZodString;
58
+ }, z.core.$strict>, z.ZodObject<{
59
+ group: z.ZodString;
60
+ }, z.core.$strict>]>;
61
+ order: z.ZodArray<z.ZodString>;
62
+ }, z.core.$strict>;
63
+ readonly add_block: z.ZodObject<{
64
+ scope: z.ZodUnion<readonly [z.ZodObject<{
65
+ template_id: z.ZodString;
66
+ }, z.core.$strict>, z.ZodObject<{
67
+ group: z.ZodString;
68
+ }, z.core.$strict>]>;
69
+ section_id: z.ZodString;
70
+ block_path: z.ZodOptional<z.ZodArray<z.ZodString>>;
71
+ type: z.ZodString;
72
+ }, z.core.$strict>;
73
+ readonly delete_block: z.ZodObject<{
74
+ scope: z.ZodUnion<readonly [z.ZodObject<{
75
+ template_id: z.ZodString;
76
+ }, z.core.$strict>, z.ZodObject<{
77
+ group: z.ZodString;
78
+ }, z.core.$strict>]>;
79
+ section_id: z.ZodString;
80
+ block_path: z.ZodOptional<z.ZodArray<z.ZodString>>;
81
+ block_id: z.ZodString;
82
+ }, z.core.$strict>;
83
+ readonly reorder_blocks: z.ZodObject<{
84
+ scope: z.ZodUnion<readonly [z.ZodObject<{
85
+ template_id: z.ZodString;
86
+ }, z.core.$strict>, z.ZodObject<{
87
+ group: z.ZodString;
88
+ }, z.core.$strict>]>;
89
+ section_id: z.ZodString;
90
+ block_path: z.ZodOptional<z.ZodArray<z.ZodString>>;
91
+ order: z.ZodArray<z.ZodString>;
92
+ }, z.core.$strict>;
93
+ readonly update_section_setting: z.ZodObject<{
94
+ scope: z.ZodUnion<readonly [z.ZodObject<{
95
+ template_id: z.ZodString;
96
+ }, z.core.$strict>, z.ZodObject<{
97
+ group: z.ZodString;
98
+ }, z.core.$strict>]>;
99
+ section_id: z.ZodString;
100
+ setting_id: z.ZodString;
101
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
102
+ }, z.core.$strict>;
103
+ readonly update_block_setting: z.ZodObject<{
104
+ scope: z.ZodUnion<readonly [z.ZodObject<{
105
+ template_id: z.ZodString;
106
+ }, z.core.$strict>, z.ZodObject<{
107
+ group: z.ZodString;
108
+ }, z.core.$strict>]>;
109
+ section_id: z.ZodString;
110
+ block_path: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
+ block_id: z.ZodString;
112
+ setting_id: z.ZodString;
113
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
114
+ }, z.core.$strict>;
115
+ readonly update_theme_setting: z.ZodObject<{
116
+ setting_id: z.ZodString;
117
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
118
+ }, z.core.$strict>;
119
+ readonly apply_recipe: z.ZodObject<{
120
+ scope: z.ZodUnion<readonly [z.ZodObject<{
121
+ template_id: z.ZodString;
122
+ }, z.core.$strict>, z.ZodObject<{
123
+ group: z.ZodString;
124
+ }, z.core.$strict>]>;
125
+ handle: z.ZodString;
126
+ params: z.ZodOptional<z.ZodArray<z.ZodObject<{
127
+ name: z.ZodString;
128
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
129
+ }, z.core.$strict>>>;
130
+ position: z.ZodOptional<z.ZodEnum<{
131
+ append: "append";
132
+ prepend: "prepend";
133
+ replace: "replace";
134
+ }>>;
135
+ expected_seq: z.ZodOptional<z.ZodNumber>;
136
+ }, z.core.$strict>;
137
+ };
138
+ export type EditorToolName = keyof typeof EDITOR_TOOL_SCHEMAS;
139
+ export type EditorToolArgs<N extends EditorToolName> = z.infer<(typeof EDITOR_TOOL_SCHEMAS)[N]>;
140
+ export declare const EDITOR_TOOL_NAMES: EditorToolName[];
141
+ export declare function isEditorToolName(name: string): name is EditorToolName;
142
+ type ToolMeta = {
143
+ access: "read" | "write";
144
+ destructive: boolean;
145
+ idempotent: boolean;
146
+ description: string;
147
+ /** False for a tool that answers from the document alone: hosts skip
148
+ * loading schemas (and the apps service) for it. Not in the listing. */
149
+ needsSchemas: boolean;
150
+ };
151
+ export declare const EDITOR_TOOL_META: Record<EditorToolName, ToolMeta>;
152
+ //#endregion
153
+ export {};