@shop-on/theme-contract 0.4.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.
- package/dist/ai-files.d.ts +18 -0
- package/dist/ai-files.js +47 -0
- package/dist/block-allowlist.d.ts +27 -0
- package/dist/block-allowlist.js +21 -0
- package/dist/closest-scope.d.ts +51 -0
- package/dist/closest-scope.js +66 -0
- package/dist/document.d.ts +13 -0
- package/dist/document.js +19 -5
- package/dist/editor-tools/apply.d.ts +85 -0
- package/dist/editor-tools/apply.js +544 -0
- package/dist/editor-tools/args.d.ts +153 -0
- package/dist/editor-tools/args.js +145 -0
- package/dist/editor-tools/coerce.d.ts +43 -0
- package/dist/editor-tools/coerce.js +200 -0
- package/dist/editor-tools/digest.d.ts +75 -0
- package/dist/editor-tools/digest.js +99 -0
- package/dist/editor-tools/index.d.ts +21 -0
- package/dist/editor-tools/index.js +24 -0
- package/dist/editor-tools/recipes.d.ts +122 -0
- package/dist/editor-tools/recipes.js +192 -0
- package/dist/fonts.d.ts +9 -0
- package/dist/fonts.js +11 -0
- package/dist/google-fonts.generated.d.ts +7 -0
- package/dist/google-fonts.generated.js +753 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.js +15 -6
- package/dist/link-url.d.ts +9 -0
- package/dist/link-url.js +33 -0
- package/dist/metafield-binding.d.ts +89 -9
- package/dist/metafield-binding.js +254 -43
- package/dist/ops.d.ts +85 -0
- package/dist/ops.js +442 -0
- package/dist/richtext.d.ts +23 -0
- package/dist/richtext.js +115 -1
- package/dist/schemas.d.ts +329 -0
- package/dist/schemas.js +62 -8
- package/dist/section-scope.d.ts +17 -0
- package/dist/section-scope.js +22 -0
- package/dist/store-path.d.ts +9 -0
- package/dist/store-path.js +13 -1
- package/dist/template-limits.d.ts +34 -0
- package/dist/template-limits.js +71 -0
- package/dist/video-url.d.ts +10 -0
- package/dist/video-url.js +36 -0
- 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 {};
|