@plitzi/sdk-mcp 0.38.6 → 0.38.8
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/CHANGELOG.md +397 -0
- package/README.md +9 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -3
- package/dist/modules/mcp/helpers/guide.js +74 -35
- package/dist/modules/mcp/index.d.ts +2 -2
- package/dist/modules/mcp/server.d.ts +4 -4
- package/dist/modules/mcp/server.js +10 -5
- package/dist/modules/mcp/tests/cheapMistakes.test.d.ts +1 -0
- package/dist/modules/mcp/tests/helpers.d.ts +9 -2
- package/dist/modules/mcp/tests/intents.test.d.ts +1 -0
- package/dist/modules/mcp/tests/look.test.d.ts +1 -0
- package/dist/modules/mcp/tools/apply/index.d.ts +706 -62
- package/dist/modules/mcp/tools/apply/index.js +70 -20
- package/dist/modules/mcp/tools/describeOperation.d.ts +11 -0
- package/dist/modules/mcp/tools/describeOperation.js +40 -0
- package/dist/modules/mcp/tools/describeOperation.test.d.ts +1 -0
- package/dist/modules/mcp/tools/index.d.ts +1 -6
- package/dist/modules/mcp/tools/index.js +12 -9
- package/dist/modules/mcp/tools/intents.d.ts +5 -0
- package/dist/modules/mcp/tools/intents.js +201 -0
- package/dist/modules/mcp/tools/look.d.ts +17 -0
- package/dist/modules/mcp/tools/look.js +32 -0
- package/dist/modules/mcp/tools/operations/closed.d.ts +7 -0
- package/dist/modules/mcp/tools/operations/closed.js +29 -0
- package/dist/modules/mcp/tools/operations/index.d.ts +2928 -359
- package/dist/modules/mcp/tools/operations/index.js +72 -51
- package/dist/modules/mcp/tools/operations/schema/bindings/upsertBinding.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/elements/moveElement.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/elements/repeatElement.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/elements/upsertElement.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/interactions/upsertInteractionFlow.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/operations.d.ts +5 -5
- package/dist/modules/mcp/tools/operations/schema/shared.d.ts +3 -3
- package/dist/modules/mcp/tools/operations/schema/shared.js +7 -6
- package/dist/modules/mcp/tools/operations/schemaIds.js +11 -5
- package/dist/modules/mcp/tools/operations/style/definitions/patchDefinition.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/definitions/patchDefinition.js +2 -1
- package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinition.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinition.js +2 -1
- package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinitions.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/definitions/upsertDefinitions.js +8 -1
- package/dist/modules/mcp/tools/operations/style/globalStyles/patchGlobalStyle.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/globalStyles/patchGlobalStyle.js +2 -1
- package/dist/modules/mcp/tools/operations/style/globalStyles/upsertGlobalStyle.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/globalStyles/upsertGlobalStyle.js +2 -1
- package/dist/modules/mcp/tools/operations/style/idStyles/patchIdStyle.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/idStyles/patchIdStyle.js +2 -1
- package/dist/modules/mcp/tools/operations/style/idStyles/upsertIdStyle.d.ts +90 -0
- package/dist/modules/mcp/tools/operations/style/idStyles/upsertIdStyle.js +2 -1
- package/dist/modules/mcp/tools/operations/style/operations.d.ts +639 -9
- package/dist/modules/mcp/tools/operations/style/shared.d.ts +394 -0
- package/dist/modules/mcp/tools/operations/style/shared.js +26 -8
- package/dist/modules/mcp/tools/operations/style/translator.js +26 -1
- package/dist/modules/mcp/tools/operations/style/validate.d.ts +4 -0
- package/dist/modules/mcp/tools/operations/style/validate.js +63 -0
- package/dist/modules/mcp/tools/operations/style/write.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/style/write.js +88 -2
- package/dist/modules/mcp/tools/render.d.ts +675 -45
- package/dist/modules/mcp/tools/render.js +5 -1
- package/dist/modules/mcp/tools/shared/draftBatch.d.ts +3 -2
- package/dist/modules/mcp/tools/shared/draftBatch.js +16 -5
- package/dist/modules/mcp/tools/shared/effects.d.ts +2 -0
- package/dist/modules/mcp/tools/shared/effects.js +155 -0
- package/dist/modules/mcp/tools/shared/effects.test.d.ts +1 -0
- package/dist/modules/mcp/tools/shared/look.d.ts +32 -0
- package/dist/modules/mcp/tools/shared/look.js +128 -0
- package/dist/modules/mcp/tools/shared/normalizeOperations.d.ts +6 -0
- package/dist/modules/mcp/tools/shared/normalizeOperations.js +37 -0
- package/dist/modules/mcp/tools/shared/repeats.d.ts +13 -0
- package/dist/modules/mcp/tools/shared/repeats.js +38 -0
- package/dist/modules/mcp/tools/shared/tool.d.ts +9 -6
- package/dist/modules/mcp/tools/shared/tool.js +2 -1
- package/dist/modules/mcp/tools/shared/validator/context.d.ts +0 -1
- package/dist/modules/mcp/tools/shared/validator/elements.js +0 -1
- package/dist/modules/mcp/tools/shared/validator/index.js +25 -2
- package/dist/modules/mcp/types/aiSchema.d.ts +13 -1
- package/dist/modules/mcp/types/screenshotTypes.d.ts +3 -3
- package/dist/modules/mcp/types/toolTypes.d.ts +2 -4
- package/dist/options.d.ts +4 -5
- package/dist/preview/createPreview.js +9 -18
- package/package.json +7 -7
- package/skills/plitzi-render/SKILL.md +8 -184
- package/skills/plitzi-render/reference/good-call.md +88 -0
- package/skills/plitzi-render/reference/interactive.md +64 -0
- package/skills/plitzi-render/reference/looks.md +29 -0
- package/dist/modules/mcp/tools/preview.d.ts +0 -793
- package/dist/modules/mcp/tools/preview.js +0 -37
- package/dist/modules/mcp/tools/screenshot.d.ts +0 -1078
- package/dist/modules/mcp/tools/screenshot.js +0 -127
- package/dist/modules/mcp/tools/validate.d.ts +0 -1069
- package/dist/modules/mcp/tools/validate.js +0 -32
|
@@ -2,7 +2,7 @@ import { iconFontCss } from "../apps/render/styles.js";
|
|
|
2
2
|
import { RENDER_APP_URI } from "../apps/render/index.js";
|
|
3
3
|
import { emptySpace } from "../helpers/space.js";
|
|
4
4
|
import { proxifyResources } from "../proxy/rewrite.js";
|
|
5
|
-
import { documentOperations } from "./operations/index.js";
|
|
5
|
+
import { compactDocumentOperations, documentOperations } from "./operations/index.js";
|
|
6
6
|
import { draftBatch } from "./shared/draftBatch.js";
|
|
7
7
|
import { defineTool } from "./shared/tool.js";
|
|
8
8
|
import { interactionReport } from "./shared/interactionReport.js";
|
|
@@ -146,6 +146,10 @@ var renderTool = defineTool({
|
|
|
146
146
|
title: "Render widget",
|
|
147
147
|
description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look, not per property.\n2a. GRAPHICS — a logo, a sparkline, a badge, a decorative shape: draw it as an INLINE <svg> in a blockHtml element (props.content), keeping a viewBox with width/height \"100%\" so its class sizes it, and fill/stroke \"currentColor\" so it follows the host theme. Budget it: a handful of paths, drawn once and reused, never a data: URI and never a full illustration — a photo-real scene costs more than the whole widget, so use an https image, a flat colour or a gradient for that. Markup only: scripts and on* handlers are rejected.\n2b. LAYOUT — it renders in a side panel, so width is free and HEIGHT is scarce. Plain containers stack children vertically, which is the tall half-empty default to avoid: put peers (metrics, plans, options, image + text) in a wrapping row — display:flex, flex-direction:row, flex-wrap:wrap, children flex-grow:\"1\" + flex-basis:\"0%\" + min-width — or a grid with grid-template-columns:\"repeat(auto-fit, minmax(160px, 1fr))\". Keep padding 12-16px and gap 8-12px, and let the outer container fill the panel. Stack only what reads in order (heading over paragraph, forms, steps, prose). Nothing carries a minimum size, so an element with no content and no size takes none — give a spacer or a rail its own height/width — while heading/paragraph do keep the margins the browser gives them (zero them, space with the parent gap).\n2c. THEME — it is embedded in the host UI, which MAY BE DARK, so never hardcode a light palette. Take colours from the host variables with a light-dark() fallback — background-color:\"var(--color-background-secondary, light-dark(#ffffff, #1f2430))\", color:\"var(--color-text-primary, light-dark(#0f172a, #e8eaed))\", border-color:\"var(--color-border-primary, light-dark(#e2e8f0, #333a48))\" — and always set `color` wherever you set `background-color` (a brand accent states its own text colour too).\n3. CONTENT — visible copy goes in props.content (text, heading, paragraph, button); heading level is the element subType (\"h1\"..\"h6\"); image/video take props.src. Write real https URLs: everything the widget loads from outside (images, media, fonts, an apiContainer endpoint) is fetched for it by the render server, so redirects and hotlink rules are handled and there is nothing to configure. It must still point at the actual file — use a URL you have seen work, and when you have none, draw the block with a gradient or an inline SVG instead of guessing one. An unknown prop comes back as a warning naming the right one.\n\nCommon types: container, heading, paragraph, text, button, link, image, video, list, listItem, markdown (plitzi://render/types lists every built-in type with descriptions). Widgets can also be data-driven and interactive — an apiContainer fetches at runtime, upsertBinding wires data into elements, and upsertInteractionFlow makes them react to clicks (see the guide).\nREAD the resource plitzi://render/guide first — it has the element/prop table, the style model and a full worked example, and following it is the difference between a widget that renders and repeated failed calls.\nITERATING — to change a widget you already rendered, do NOT rebuild it: call again with patch:true, the `renderId` that render answered with, and ONLY the operations that differ (patchDefinition, patchElement, deleteElement…). The widget merges them and reports back what it applied; address rows by the refs you already know. Patch ONLY to modify that widget: a different subject or a different kind of widget is a fresh render, without patch — a patch is merged into the previous batch, so patching a new idea leaves you with both.\nReturns a compact summary including the renderId (the widget itself is shown to the user), plus — when the widget has interaction flows — an `interactions` line per flow naming what got wired to what. That reports the WIRING, not that a click was performed: say what you connected, never that you verified it at runtime. On failure it returns teachable errors (path + hint) — read them and retry.",
|
|
148
148
|
inputShape: renderShape,
|
|
149
|
+
compactInputShape: {
|
|
150
|
+
...renderShape,
|
|
151
|
+
operations: compactDocumentOperations
|
|
152
|
+
},
|
|
149
153
|
access: "read",
|
|
150
154
|
spaceless: true,
|
|
151
155
|
ui: { resourceUri: RENDER_APP_URI },
|
|
@@ -17,9 +17,10 @@ export type DraftResult = {
|
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* A batch run on a copy of the space and read back — the one way every tool that takes operations handles them, so
|
|
20
|
-
* `
|
|
20
|
+
* a `dryRun` answers exactly what `plitzi_apply` would save, and `plitzi_render` holds a widget to the same rules.
|
|
21
21
|
*
|
|
22
|
-
* In order: the sugar ops expanded; what
|
|
22
|
+
* In order: the sugar ops expanded; what has one reading read that way (`normalizeOperations`, said in `warnings`);
|
|
23
|
+
* what was already wrong with the elements the batch touches fixed where it has one
|
|
23
24
|
* reading (`fixTouched`, each fix said in `warnings`); each op checked against the input it takes; the batch applied to
|
|
24
25
|
* the copy; and the copy read by `lintDraft` — structure and meaning, the same reading every other writer gets. The
|
|
25
26
|
* first stage to refuse ends it; the space handed in is never touched.
|
|
@@ -2,14 +2,16 @@ import { cloneSpace } from "../../helpers/space.js";
|
|
|
2
2
|
import { expandOperations } from "./expandOperations.js";
|
|
3
3
|
import { fixTouched, lintDraft } from "./lintDraft.js";
|
|
4
4
|
import { newSuggestions } from "./newSuggestions.js";
|
|
5
|
+
import { normalizeOperations } from "./normalizeOperations.js";
|
|
5
6
|
import { validateOperations } from "./validator/index.js";
|
|
6
7
|
import { applyOperations } from "../apply/dispatch.js";
|
|
7
8
|
//#region src/modules/mcp/tools/shared/draftBatch.ts
|
|
8
9
|
/**
|
|
9
10
|
* A batch run on a copy of the space and read back — the one way every tool that takes operations handles them, so
|
|
10
|
-
* `
|
|
11
|
+
* a `dryRun` answers exactly what `plitzi_apply` would save, and `plitzi_render` holds a widget to the same rules.
|
|
11
12
|
*
|
|
12
|
-
* In order: the sugar ops expanded; what
|
|
13
|
+
* In order: the sugar ops expanded; what has one reading read that way (`normalizeOperations`, said in `warnings`);
|
|
14
|
+
* what was already wrong with the elements the batch touches fixed where it has one
|
|
13
15
|
* reading (`fixTouched`, each fix said in `warnings`); each op checked against the input it takes; the batch applied to
|
|
14
16
|
* the copy; and the copy read by `lintDraft` — structure and meaning, the same reading every other writer gets. The
|
|
15
17
|
* first stage to refuse ends it; the space handed in is never touched.
|
|
@@ -21,23 +23,32 @@ var draftBatch = (space, env, operations, mode = "space") => {
|
|
|
21
23
|
errors: expansion.errors,
|
|
22
24
|
warnings: []
|
|
23
25
|
};
|
|
24
|
-
const ops = expansion.operations;
|
|
26
|
+
const { operations: ops, notes } = normalizeOperations(space, expansion.operations);
|
|
25
27
|
const prepared = fixTouched(space, ops);
|
|
26
28
|
const validation = validateOperations(prepared.space, ops, mode);
|
|
27
29
|
if (!validation.valid) return {
|
|
28
30
|
ok: false,
|
|
29
31
|
errors: validation.errors,
|
|
30
|
-
warnings: [
|
|
32
|
+
warnings: [
|
|
33
|
+
...notes,
|
|
34
|
+
...prepared.fixed,
|
|
35
|
+
...validation.warnings
|
|
36
|
+
]
|
|
31
37
|
};
|
|
32
38
|
const draft = cloneSpace(prepared.space);
|
|
33
39
|
const outcome = applyOperations(draft, env, ops);
|
|
34
40
|
if (outcome.errors.length > 0) return {
|
|
35
41
|
ok: false,
|
|
36
42
|
errors: outcome.errors,
|
|
37
|
-
warnings: [
|
|
43
|
+
warnings: [
|
|
44
|
+
...notes,
|
|
45
|
+
...prepared.fixed,
|
|
46
|
+
...validation.warnings
|
|
47
|
+
]
|
|
38
48
|
};
|
|
39
49
|
const reading = lintDraft(draft, ops, prepared.space);
|
|
40
50
|
const warnings = [
|
|
51
|
+
...notes,
|
|
41
52
|
...prepared.fixed,
|
|
42
53
|
...validation.warnings,
|
|
43
54
|
...reading.warnings
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { canonicalJson } from "@plitzi/sdk-shared/helpers/canonicalJson";
|
|
2
|
+
import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
|
|
3
|
+
//#region src/modules/mcp/tools/shared/effects.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a write did to the space, read off the documents before and after it — never off what was asked. An agent
|
|
6
|
+
* that is told only what it asked for cannot see the element a shared class also restyled, the child a move took with
|
|
7
|
+
* it, or the operation that changed nothing; every one of those is a line here. Past `LIMIT` lines the rest is
|
|
8
|
+
* counted, never dropped unsaid.
|
|
9
|
+
*/
|
|
10
|
+
var LIMIT = 40;
|
|
11
|
+
var SHOWN = 60;
|
|
12
|
+
var same = (before, after) => canonicalJson(before) === canonicalJson(after);
|
|
13
|
+
var shown = (value) => {
|
|
14
|
+
const text = canonicalJson(value);
|
|
15
|
+
return text.length > SHOWN ? `${text.slice(0, 59)}…` : text;
|
|
16
|
+
};
|
|
17
|
+
/** Each entry of a list by what names it — its `id`, or its `name` — when every entry has one. */
|
|
18
|
+
var keyedList = (list) => {
|
|
19
|
+
const keyed = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const entry of list) {
|
|
21
|
+
const key = isRecord(entry) ? entry.id ?? entry.name : void 0;
|
|
22
|
+
if (typeof key !== "string" || keyed.has(key)) return;
|
|
23
|
+
keyed.set(key, entry);
|
|
24
|
+
}
|
|
25
|
+
return keyed;
|
|
26
|
+
};
|
|
27
|
+
var isScalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
28
|
+
/** Every difference between two values, at its path: a key added, removed or changed, an entry of a named list. */
|
|
29
|
+
var differences = (path, before, after, out) => {
|
|
30
|
+
if (same(before, after)) return;
|
|
31
|
+
if (before === void 0) {
|
|
32
|
+
out.push(`${path} = ${shown(after)}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (after === void 0) {
|
|
36
|
+
out.push(`${path} removed (was ${shown(before)})`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
40
|
+
const was = keyedList(before);
|
|
41
|
+
const is = keyedList(after);
|
|
42
|
+
if (was && is) {
|
|
43
|
+
for (const key of /* @__PURE__ */ new Set([...was.keys(), ...is.keys()])) differences(`${path}[${key}]`, was.get(key), is.get(key), out);
|
|
44
|
+
if ([...was.keys()].join("\n") !== [...is.keys()].join("\n") && same([...was.keys()].toSorted(), [...is.keys()].toSorted())) out.push(`${path} reordered`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (before.every(isScalar) && after.every(isScalar)) {
|
|
48
|
+
const added = after.filter((item) => !before.includes(item));
|
|
49
|
+
const removed = before.filter((item) => !after.includes(item));
|
|
50
|
+
if (added.length > 0 || removed.length > 0) out.push(`${path}: ${[...added.map((item) => `+${shown(item)}`), ...removed.map((item) => `−${shown(item)}`)].join(" ")}`);
|
|
51
|
+
else out.push(`${path}: ${shown(before)} → ${shown(after)}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (isRecord(before) && isRecord(after)) {
|
|
56
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) differences(`${path}.${key}`, before[key], after[key], out);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
out.push(`${path}: ${shown(before)} → ${shown(after)}`);
|
|
60
|
+
};
|
|
61
|
+
/** A key of a record, or nothing when it has none — said as such, where indexing would claim it is there. */
|
|
62
|
+
var lookup = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
63
|
+
/** An object without the keys a caller reads apart. */
|
|
64
|
+
var without = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
|
|
65
|
+
var classesOf = (selector) => (selector ?? "").split(/\s+/).filter(Boolean);
|
|
66
|
+
/** An element's own changes: its attributes, its classes, where it is, and the rest of its definition. */
|
|
67
|
+
var elementChanges = (id, before, after, out) => {
|
|
68
|
+
const bound = new Map((after.definition.bindings?.attributes ?? []).map((binding) => [binding.to, binding.source]));
|
|
69
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before.attributes), ...Object.keys(after.attributes)])) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
differences(`${id}.${key}`, before.attributes[key], after.attributes[key], lines);
|
|
72
|
+
const source = bound.get(key);
|
|
73
|
+
out.push(...source ? lines.map((line) => `${line} — bound to ${source}: the page shows the binding's value`) : lines);
|
|
74
|
+
}
|
|
75
|
+
const { parentId: wasIn, items: hadItems, styleSelectors: wore } = before.definition;
|
|
76
|
+
const { parentId: isIn, items: hasItems, styleSelectors: wears } = after.definition;
|
|
77
|
+
if (wasIn !== isIn) out.push(`${id} moved from ${wasIn ?? "the top"} to ${isIn ?? "the top"}`);
|
|
78
|
+
if (!same(hadItems, hasItems) && same([...hadItems ?? []].toSorted(), [...hasItems ?? []].toSorted())) out.push(`${id}'s children reordered`);
|
|
79
|
+
for (const selector of /* @__PURE__ */ new Set([...Object.keys(wore), ...Object.keys(wears)])) {
|
|
80
|
+
const was = classesOf(wore[selector]);
|
|
81
|
+
const is = classesOf(wears[selector]);
|
|
82
|
+
const added = is.filter((name) => !was.includes(name));
|
|
83
|
+
const removed = was.filter((name) => !is.includes(name));
|
|
84
|
+
if (added.length > 0 || removed.length > 0) {
|
|
85
|
+
const changes = [...added.map((name) => `+${name}`), ...removed.map((name) => `−${name}`)].join(" ");
|
|
86
|
+
out.push(`${id} classes${selector === "base" ? "" : ` (${selector})`}: ${changes}`);
|
|
87
|
+
} else if (was.join(" ") !== is.join(" ")) out.push(`${id} classes${selector === "base" ? "" : ` (${selector})`} reordered`);
|
|
88
|
+
}
|
|
89
|
+
const apart = [
|
|
90
|
+
"parentId",
|
|
91
|
+
"items",
|
|
92
|
+
"styleSelectors"
|
|
93
|
+
];
|
|
94
|
+
differences(id, without(before.definition, apart), without(after.definition, apart), out);
|
|
95
|
+
};
|
|
96
|
+
/** The elements added to a tree, or taken from it, said by the outermost of each subtree with how many it held. */
|
|
97
|
+
var subtrees = (ids, flat, verb, where, out) => {
|
|
98
|
+
const top = (id) => {
|
|
99
|
+
const parentId = flat[id].definition.parentId;
|
|
100
|
+
return parentId !== void 0 && ids.has(parentId) ? top(parentId) : id;
|
|
101
|
+
};
|
|
102
|
+
const held = /* @__PURE__ */ new Map();
|
|
103
|
+
for (const id of ids) {
|
|
104
|
+
const root = top(id);
|
|
105
|
+
held.set(root, (held.get(root) ?? 0) + (root === id ? 0 : 1));
|
|
106
|
+
}
|
|
107
|
+
for (const [id, inside] of held) {
|
|
108
|
+
const { type, parentId } = flat[id].definition;
|
|
109
|
+
out.push(`${id} (${type}) ${verb}${parentId ? ` ${verb === "added" ? "in" : "from"} ${parentId}` : ""}${where}${inside > 0 ? `, with ${String(inside)} inside` : ""}`);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var treeChanges = (before, after, where, out) => {
|
|
113
|
+
const added = new Set(Object.keys(after).filter((id) => !(id in before)));
|
|
114
|
+
const removed = new Set(Object.keys(before).filter((id) => !(id in after)));
|
|
115
|
+
subtrees(added, after, "added", where, out);
|
|
116
|
+
subtrees(removed, before, "removed", where, out);
|
|
117
|
+
for (const id of Object.keys(after)) {
|
|
118
|
+
const was = lookup(before, id);
|
|
119
|
+
if (was) elementChanges(id, was, after[id], out);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var effectsOf = (before, after) => {
|
|
123
|
+
const out = [];
|
|
124
|
+
treeChanges(before.schema.flat, after.schema.flat, "", out);
|
|
125
|
+
const components = /* @__PURE__ */ new Set([...Object.keys(before.schema.components), ...Object.keys(after.schema.components)]);
|
|
126
|
+
for (const id of components) {
|
|
127
|
+
const was = lookup(before.schema.components, id);
|
|
128
|
+
const is = lookup(after.schema.components, id);
|
|
129
|
+
if (!was || !is) {
|
|
130
|
+
const held = Object.keys((was ?? is)?.flat ?? {}).length;
|
|
131
|
+
out.push(`component ${id} ${was ? "removed" : "added"}, ${String(held)} elements`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
treeChanges(was.flat, is.flat, ` (component ${id})`, out);
|
|
135
|
+
differences(`component ${id}`, without(was, ["flat"]), without(is, ["flat"]), out);
|
|
136
|
+
}
|
|
137
|
+
differences("space", without(before.schema, [
|
|
138
|
+
"flat",
|
|
139
|
+
"components",
|
|
140
|
+
"pages"
|
|
141
|
+
]), without(after.schema, [
|
|
142
|
+
"flat",
|
|
143
|
+
"components",
|
|
144
|
+
"pages"
|
|
145
|
+
]), out);
|
|
146
|
+
differences("space.pages", before.schema.pages.filter((id) => after.schema.pages.includes(id)), after.schema.pages.filter((id) => before.schema.pages.includes(id)), out);
|
|
147
|
+
differences("style", without(before.style, ["cache"]), without(after.style, ["cache"]), out);
|
|
148
|
+
differences("connectors", before.connectors, after.connectors, out);
|
|
149
|
+
differences("actions", before.actions, after.actions, out);
|
|
150
|
+
differences("functions", before.functions, after.functions, out);
|
|
151
|
+
differences("data", before.data, after.data, out);
|
|
152
|
+
return out.length > LIMIT ? [...out.slice(0, LIMIT), `… and ${String(out.length - LIMIT)} more changes: read the resources listed in \`changed\``] : out;
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
export { effectsOf };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ScreenshotImage } from '../../types';
|
|
3
|
+
import { Operation } from '../operations';
|
|
4
|
+
import { ToolContext } from './tool';
|
|
5
|
+
type LookViewport = 'desktop' | 'mobile' | 'both';
|
|
6
|
+
type LookView = 'html' | 'image' | 'accessibility' | 'both';
|
|
7
|
+
export declare const pageRef: z.ZodOptional<z.ZodString>;
|
|
8
|
+
export declare const viewport: z.ZodOptional<z.ZodEnum<{
|
|
9
|
+
desktop: "desktop";
|
|
10
|
+
mobile: "mobile";
|
|
11
|
+
both: "both";
|
|
12
|
+
}>>;
|
|
13
|
+
export declare const fullPage: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
type LookRequest = {
|
|
15
|
+
pageRef?: string;
|
|
16
|
+
/** A batch to render without saving it; left out, what is saved. */
|
|
17
|
+
operations?: Operation[];
|
|
18
|
+
view: LookView;
|
|
19
|
+
viewport?: LookViewport;
|
|
20
|
+
fullPage?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** What a look found: the facts about the page, and the pictures when one was asked for and could be taken. */
|
|
23
|
+
type Look = {
|
|
24
|
+
meta: Record<string, unknown>;
|
|
25
|
+
images: ScreenshotImage[];
|
|
26
|
+
};
|
|
27
|
+
/** A look that could not happen at all — no renderer, or a batch the renderer refused — said as the tools say it. */
|
|
28
|
+
type LookRefusal = {
|
|
29
|
+
refused: Record<string, unknown>;
|
|
30
|
+
};
|
|
31
|
+
export declare const lookAt: (ctx: ToolContext, request: LookRequest) => Promise<Look | LookRefusal>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { unnamedControls } from "../../accessibilityOutline.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/modules/mcp/tools/shared/look.ts
|
|
4
|
+
/**
|
|
5
|
+
* How a page looks — as HTML, as a picture, or as a screen reader and a browser agent read it — rendered from the saved
|
|
6
|
+
* space or from a batch that was never saved.
|
|
7
|
+
*
|
|
8
|
+
* One path for every tool that shows a page: `plitzi_apply`'s `dryRun` (the batch it just checked, so the agent writes
|
|
9
|
+
* its operations once instead of once to check, once to look and once to save), and `plitzi_look` (what is saved).
|
|
10
|
+
*/
|
|
11
|
+
var VIEWPORTS = {
|
|
12
|
+
desktop: {
|
|
13
|
+
label: "desktop",
|
|
14
|
+
width: 1440,
|
|
15
|
+
height: 900
|
|
16
|
+
},
|
|
17
|
+
mobile: {
|
|
18
|
+
label: "mobile",
|
|
19
|
+
width: 390,
|
|
20
|
+
height: 844
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var resolveViewports = (choice) => {
|
|
24
|
+
if (choice === "both") return [VIEWPORTS.desktop, VIEWPORTS.mobile];
|
|
25
|
+
if (choice === "mobile") return [VIEWPORTS.mobile];
|
|
26
|
+
return [VIEWPORTS.desktop];
|
|
27
|
+
};
|
|
28
|
+
var CAPTURES = {
|
|
29
|
+
image: ["image"],
|
|
30
|
+
accessibility: ["accessibility"],
|
|
31
|
+
both: ["image", "accessibility"]
|
|
32
|
+
};
|
|
33
|
+
var pageRef = z.string().optional().describe("The page, by name; defaults to the space default page");
|
|
34
|
+
var viewport = z.enum([
|
|
35
|
+
"desktop",
|
|
36
|
+
"mobile",
|
|
37
|
+
"both"
|
|
38
|
+
]).optional().describe("Which viewport(s) to capture. \"both\" catches responsive issues (e.g. overflow at one width). Default desktop.");
|
|
39
|
+
var fullPage = z.boolean().optional().describe("Capture the full scrollable page instead of only the visible viewport. Default false.");
|
|
40
|
+
/** Each viewport's outline, with what in it has no name — the part an agent acts on. */
|
|
41
|
+
var readingOf = (outlines) => outlines.map(({ label, outline }) => ({
|
|
42
|
+
viewport: label,
|
|
43
|
+
outline,
|
|
44
|
+
unnamed: unnamedControls(outline)
|
|
45
|
+
}));
|
|
46
|
+
var ACCESSIBILITY_HINT = "Every entry in `unnamed` is a control or picture announced by its role alone (\"button\") — nobody using a screen reader, and no browser agent, can tell what it does. Name it — a button: `title` or words inside; a link: `label`; a field: `label` (with `hideLabel` to keep it out of sight); an image: `alt`, or `decorative: true`; an icon that means something alone: `label`. `line` is where it sits in the outline.";
|
|
47
|
+
var lookAt = async (ctx, request) => {
|
|
48
|
+
if (!ctx.preview || ctx.spaceId === void 0) return { refused: {
|
|
49
|
+
error: "PREVIEW_UNAVAILABLE",
|
|
50
|
+
message: "Visual preview is not enabled on this server.",
|
|
51
|
+
hint: "Preview needs the SSR render service; it is unavailable in MCP-only mode."
|
|
52
|
+
} };
|
|
53
|
+
const draft = {
|
|
54
|
+
spaceId: ctx.spaceId,
|
|
55
|
+
env: ctx.env,
|
|
56
|
+
pageRef: request.pageRef,
|
|
57
|
+
operations: request.operations
|
|
58
|
+
};
|
|
59
|
+
const page = request.pageRef ?? "default";
|
|
60
|
+
const wantsHtml = request.view === "html" || !ctx.screenshot;
|
|
61
|
+
const rendered = await ctx.preview.render({
|
|
62
|
+
...draft,
|
|
63
|
+
includeHtml: wantsHtml
|
|
64
|
+
});
|
|
65
|
+
if (!rendered.ok) return { refused: rendered };
|
|
66
|
+
const facts = {
|
|
67
|
+
pageRef: page,
|
|
68
|
+
pagePath: rendered.pagePath,
|
|
69
|
+
stateVersion: rendered.stateVersion
|
|
70
|
+
};
|
|
71
|
+
if (request.view === "html") return {
|
|
72
|
+
meta: {
|
|
73
|
+
...facts,
|
|
74
|
+
html: rendered.html
|
|
75
|
+
},
|
|
76
|
+
images: []
|
|
77
|
+
};
|
|
78
|
+
if (!ctx.screenshot) return {
|
|
79
|
+
meta: {
|
|
80
|
+
...facts,
|
|
81
|
+
warning: "SCREENSHOT_DISABLED",
|
|
82
|
+
message: request.view === "image" ? "The screenshot service is not configured; returning the HTML preview instead." : "No browser is configured, so the accessibility tree cannot be read; returning the HTML preview instead. Its roles and names are in the markup: every button, link and field needs words, every image an `alt`.",
|
|
83
|
+
html: rendered.html
|
|
84
|
+
},
|
|
85
|
+
images: []
|
|
86
|
+
};
|
|
87
|
+
const viewports = resolveViewports(request.viewport);
|
|
88
|
+
const shot = await ctx.screenshot.capture({
|
|
89
|
+
pagePath: rendered.pagePath,
|
|
90
|
+
token: rendered.token,
|
|
91
|
+
viewports,
|
|
92
|
+
fullPage: request.fullPage,
|
|
93
|
+
views: CAPTURES[request.view]
|
|
94
|
+
});
|
|
95
|
+
if (!shot.ok) {
|
|
96
|
+
const fallback = await ctx.preview.render(draft);
|
|
97
|
+
return {
|
|
98
|
+
meta: {
|
|
99
|
+
...facts,
|
|
100
|
+
warning: "SCREENSHOT_UNAVAILABLE",
|
|
101
|
+
message: `The screenshot service failed (${shot.message}); returning the HTML preview instead.`,
|
|
102
|
+
hint: "The dedicated browser service is down or unreachable. Inspect the HTML, or retry later.",
|
|
103
|
+
html: fallback.ok ? fallback.html : ""
|
|
104
|
+
},
|
|
105
|
+
images: []
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const meta = {
|
|
109
|
+
...facts,
|
|
110
|
+
viewports: viewports.map((v) => v.label)
|
|
111
|
+
};
|
|
112
|
+
const accessibility = request.view === "image" ? {} : shot.accessibility ? {
|
|
113
|
+
accessibility: readingOf(shot.accessibility),
|
|
114
|
+
hint: ACCESSIBILITY_HINT
|
|
115
|
+
} : {
|
|
116
|
+
warning: "ACCESSIBILITY_UNSUPPORTED",
|
|
117
|
+
message: "The browser service answered without the accessibility tree — it predates the accessibility view. The image, if asked for, is still here."
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
meta: {
|
|
121
|
+
...meta,
|
|
122
|
+
...accessibility
|
|
123
|
+
},
|
|
124
|
+
images: request.view === "accessibility" ? [] : shot.images
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
export { fullPage, lookAt, pageRef, viewport };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildTypeRegistry } from "../../catalogs/registry.js";
|
|
2
|
+
import { elementTypeNames } from "../../catalogs/index.js";
|
|
3
|
+
//#region src/modules/mcp/tools/shared/normalizeOperations.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a batch wrote that has one reading, read that way and said: an element type spelt with other capitals
|
|
6
|
+
* (`Heading`) is the type the catalog spells (`heading`). A guess between two names is never made here — a name with a
|
|
7
|
+
* typo is refused, or warned of, with the nearest one, by the checks that come after.
|
|
8
|
+
*/
|
|
9
|
+
/** The element types this space renders: the SDK's own and every one its catalog or its elements bring. */
|
|
10
|
+
var knownTypes = (space) => /* @__PURE__ */ new Set([...Object.keys(buildTypeRegistry(space.schema, space.catalog).types), ...elementTypeNames]);
|
|
11
|
+
var normalizedTree = (element, path, byLowerCase, known, notes) => {
|
|
12
|
+
const spelt = known.has(element.type) ? void 0 : byLowerCase.get(element.type.toLowerCase());
|
|
13
|
+
if (spelt) notes.push(`Read element type "${element.type}" as "${spelt}" (${path}.type): write it as the catalog spells it.`);
|
|
14
|
+
const children = element.children?.map((child, index) => normalizedTree(child, `${path}.children[${String(index)}]`, byLowerCase, known, notes));
|
|
15
|
+
return {
|
|
16
|
+
...element,
|
|
17
|
+
...spelt ? { type: spelt } : {},
|
|
18
|
+
...children ? { children } : {}
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
var normalizeOperations = (space, operations) => {
|
|
22
|
+
const known = knownTypes(space);
|
|
23
|
+
const spellings = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const type of known) spellings.set(type.toLowerCase(), [...spellings.get(type.toLowerCase()) ?? [], type]);
|
|
25
|
+
const byLowerCase = new Map([...spellings].flatMap(([lower, types]) => types.length === 1 ? [[lower, types[0]]] : []));
|
|
26
|
+
const notes = [];
|
|
27
|
+
const normalized = operations.map((operation, index) => operation.type === "upsertElement" ? {
|
|
28
|
+
...operation,
|
|
29
|
+
element: normalizedTree(operation.element, `operations[${String(index)}].element`, byLowerCase, known, notes)
|
|
30
|
+
} : operation);
|
|
31
|
+
return {
|
|
32
|
+
operations: notes.length > 0 ? normalized : operations,
|
|
33
|
+
notes
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { normalizeOperations };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** One batch, by where it was sent and what it holds. */
|
|
2
|
+
export declare const batchKey: (scope: string, operations: unknown) => string;
|
|
3
|
+
/** How many times this batch was refused lately — before this one. */
|
|
4
|
+
export declare const timesRefused: (key: string, now?: number) => number;
|
|
5
|
+
/** The batch was refused once more. */
|
|
6
|
+
export declare const noteRefused: (key: string, now?: number) => void;
|
|
7
|
+
/** The batch went through: what was refused before no longer counts. */
|
|
8
|
+
export declare const forgetRefused: (key: string) => void;
|
|
9
|
+
export declare const AGAIN: string;
|
|
10
|
+
export declare const REPEATED: {
|
|
11
|
+
error: string;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/modules/mcp/tools/shared/repeats.ts
|
|
3
|
+
/**
|
|
4
|
+
* The same batch refused again — an agent going round in circles, which a model is not trusted to notice. Counted per
|
|
5
|
+
* process, as a hint and never a lock: a call that lands on another worker only loses the hint.
|
|
6
|
+
*/
|
|
7
|
+
var WINDOW_MS = 6e5;
|
|
8
|
+
var KEPT = 500;
|
|
9
|
+
var refused = /* @__PURE__ */ new Map();
|
|
10
|
+
/** One batch, by where it was sent and what it holds. */
|
|
11
|
+
var batchKey = (scope, operations) => `${scope}:${createHash("sha256").update(JSON.stringify(operations)).digest("hex")}`;
|
|
12
|
+
/** How many times this batch was refused lately — before this one. */
|
|
13
|
+
var timesRefused = (key, now = Date.now()) => {
|
|
14
|
+
const entry = refused.get(key);
|
|
15
|
+
return entry && now - entry.at < WINDOW_MS ? entry.count : 0;
|
|
16
|
+
};
|
|
17
|
+
/** The batch was refused once more. */
|
|
18
|
+
var noteRefused = (key, now = Date.now()) => {
|
|
19
|
+
refused.set(key, {
|
|
20
|
+
count: timesRefused(key, now) + 1,
|
|
21
|
+
at: now
|
|
22
|
+
});
|
|
23
|
+
if (refused.size > KEPT) {
|
|
24
|
+
const oldest = refused.keys().next().value;
|
|
25
|
+
if (oldest !== void 0) refused.delete(oldest);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** The batch went through: what was refused before no longer counts. */
|
|
29
|
+
var forgetRefused = (key) => {
|
|
30
|
+
refused.delete(key);
|
|
31
|
+
};
|
|
32
|
+
var AGAIN = "This batch was refused before, for these reasons. Sending it again will not change the answer: change what the errors name — plitzi_describe_operation { type } gives an operation’s fields, a hint its way out — or stop and ask the person what they meant.";
|
|
33
|
+
var REPEATED = {
|
|
34
|
+
error: "REPEATED_BATCH",
|
|
35
|
+
message: "This exact batch was refused twice, and was not run again. Change what the errors named, or ask the person."
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { AGAIN, REPEATED, batchKey, forgetRefused, noteRefused, timesRefused };
|
|
@@ -24,9 +24,6 @@ export interface ToolContext {
|
|
|
24
24
|
* wired it. Only plitzi_try_function uses it. */
|
|
25
25
|
tryFunction?: (task: string, params: Record<string, unknown>) => Promise<ActionRunReport>;
|
|
26
26
|
}
|
|
27
|
-
/** A capability a tool depends on; the host skips registering a tool whose capability it did not wire (so
|
|
28
|
-
* plitzi_screenshot simply does not appear when no browser service is configured). */
|
|
29
|
-
export type ToolRequires = 'screenshot';
|
|
30
27
|
/** Build the MCP CallToolResult for an image-returning tool: a text meta block followed by one image block per
|
|
31
28
|
* PNG, so a vision-capable agent (or MCP client) sees the render directly. */
|
|
32
29
|
export declare const imageResult: (images: ScreenshotImage[], meta: unknown) => CallToolResult;
|
|
@@ -40,8 +37,13 @@ export interface ToolSpec<Shape extends ZodRawShape> {
|
|
|
40
37
|
description: string;
|
|
41
38
|
inputShape: Shape;
|
|
42
39
|
access: 'read' | 'write';
|
|
43
|
-
/**
|
|
44
|
-
|
|
40
|
+
/** This tool's input carries the whole operations vocabulary (`plitzi_apply`): the one copy of it a connection lists. */
|
|
41
|
+
carriesOperations?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* What is LISTED for this tool when another tool on the connection carries the vocabulary: the same input with the
|
|
44
|
+
* operations' fields left out. Its calls are still parsed against `inputShape`, so nothing is checked less.
|
|
45
|
+
*/
|
|
46
|
+
compactInputShape?: ZodRawShape;
|
|
45
47
|
/** The tool operates on no space (plitzi_render authors a throwaway one), so the host must NOT resolve a spaceId
|
|
46
48
|
* or load a space for it — that keeps it callable with no auth, on the public surface. */
|
|
47
49
|
spaceless?: boolean;
|
|
@@ -63,7 +65,8 @@ export interface ToolDef {
|
|
|
63
65
|
description: string;
|
|
64
66
|
inputShape: ZodRawShape;
|
|
65
67
|
access: 'read' | 'write';
|
|
66
|
-
|
|
68
|
+
carriesOperations?: boolean;
|
|
69
|
+
compactInputShape?: ZodRawShape;
|
|
67
70
|
spaceless?: boolean;
|
|
68
71
|
ui?: McpUiToolMeta;
|
|
69
72
|
execute: (args: unknown, ctx: ToolContext) => unknown;
|
|
@@ -22,7 +22,8 @@ var defineTool = (spec) => {
|
|
|
22
22
|
description: spec.description,
|
|
23
23
|
inputShape: spec.inputShape,
|
|
24
24
|
access: spec.access,
|
|
25
|
-
|
|
25
|
+
carriesOperations: spec.carriesOperations,
|
|
26
|
+
compactInputShape: spec.compactInputShape,
|
|
26
27
|
spaceless: spec.spaceless,
|
|
27
28
|
ui: spec.ui,
|
|
28
29
|
execute: (args, ctx) => spec.run(parse(args), ctx),
|
|
@@ -21,7 +21,6 @@ export interface ValidationCtx {
|
|
|
21
21
|
errors: ValidationError[];
|
|
22
22
|
warnings: string[];
|
|
23
23
|
warned: Set<string>;
|
|
24
|
-
knownTypes: Set<string>;
|
|
25
24
|
typeProps: Map<string, Set<string>>;
|
|
26
25
|
typeMeta: Map<string, TypeMeta>;
|
|
27
26
|
elementType: (ref: string) => string | undefined;
|
|
@@ -48,7 +48,6 @@ var checkElementInput = (element, path, ctx, seen) => {
|
|
|
48
48
|
message: "Element type is required",
|
|
49
49
|
hint: "Read plitzi://types for known types"
|
|
50
50
|
});
|
|
51
|
-
else if (!ctx.knownTypes.has(element.type)) ctx.warnings.push(`Type "${element.type}" was not seen in this space; ensure a plugin provides it (${path}.type).`);
|
|
52
51
|
checkElementProps(element, path, ctx);
|
|
53
52
|
element.children?.forEach((child, i) => checkElementInput(child, `${path}.children[${i}]`, ctx, seen));
|
|
54
53
|
};
|