@elixpo/lixsketch 5.6.1 → 5.6.2

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.
@@ -0,0 +1,585 @@
1
+ // src/mcp/scene.js
2
+ var FORMAT = "lixsketch";
3
+ var VERSION = 1;
4
+ var MAX_SHAPES = 5e3;
5
+ var MAX_OPERATIONS = 500;
6
+ var SCENE_TYPES = /* @__PURE__ */ new Set(["rectangle", "circle", "line", "arrow", "freehandStroke", "frame", "text", "code", "image", "icon"]);
7
+ var WRITABLE_TYPES = /* @__PURE__ */ new Set(["rectangle", "circle", "line", "arrow", "freehandStroke", "frame", "text"]);
8
+ var clone = (value) => JSON.parse(JSON.stringify(value));
9
+ var finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
10
+ var positive = (value, fallback = 1) => Math.max(1, finite(value, fallback));
11
+ function createEmptyScene(name = "MCP Canvas") {
12
+ return {
13
+ format: FORMAT,
14
+ version: VERSION,
15
+ sessionID: `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
16
+ name: String(name || "MCP Canvas").trim().slice(0, 72),
17
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
18
+ viewport: { x: 0, y: 0, width: 1280, height: 720 },
19
+ zoom: 1,
20
+ mcpRevision: 0,
21
+ shapes: []
22
+ };
23
+ }
24
+ function validateScene(scene) {
25
+ const errors = [];
26
+ if (!scene || typeof scene !== "object") return { valid: false, errors: ["Scene must be an object"] };
27
+ if (scene.format !== FORMAT) errors.push(`Scene format must be "${FORMAT}"`);
28
+ if (scene.version !== VERSION) errors.push(`Scene version must be ${VERSION}`);
29
+ if (!Array.isArray(scene.shapes)) errors.push("Scene shapes must be an array");
30
+ if (Array.isArray(scene.shapes) && scene.shapes.length > MAX_SHAPES) errors.push(`Scene exceeds ${MAX_SHAPES} shapes`);
31
+ const ids = /* @__PURE__ */ new Set();
32
+ for (const [index, shape] of (scene.shapes || []).entries()) {
33
+ if (!shape || typeof shape !== "object") {
34
+ errors.push(`Shape ${index} must be an object`);
35
+ continue;
36
+ }
37
+ if (!SCENE_TYPES.has(shape.type)) errors.push(`Shape ${index} has unsupported type "${shape.type}"`);
38
+ if (!shape.shapeID || typeof shape.shapeID !== "string") errors.push(`Shape ${index} is missing shapeID`);
39
+ else if (ids.has(shape.shapeID)) errors.push(`Duplicate shapeID "${shape.shapeID}"`);
40
+ else ids.add(shape.shapeID);
41
+ validateShapeGeometry(shape, index, errors);
42
+ }
43
+ return { valid: errors.length === 0, errors };
44
+ }
45
+ function escapeXml(value) {
46
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
47
+ }
48
+ function normalizeOptions(value = {}) {
49
+ return {
50
+ roughness: Math.max(0, Math.min(3, finite(value.roughness, 1.2))),
51
+ stroke: typeof value.stroke === "string" ? value.stroke : "#8b76d6",
52
+ strokeWidth: Math.max(0.5, Math.min(20, finite(value.strokeWidth, 2))),
53
+ fill: typeof value.fill === "string" ? value.fill : "transparent",
54
+ fillStyle: typeof value.fillStyle === "string" ? value.fillStyle : "solid",
55
+ opacity: Math.max(0, Math.min(1, finite(value.opacity, 1)))
56
+ };
57
+ }
58
+ function createTextShape(input, shapeID) {
59
+ const x = finite(input.x), y = finite(input.y), rotation = finite(input.rotation);
60
+ const text = String(input.text || "").slice(0, 1e4);
61
+ const fontSize = Math.max(8, Math.min(160, finite(input.fontSize, 20)));
62
+ const color2 = typeof input.color === "string" ? input.color : "#e8e3f3";
63
+ const family = typeof input.fontFamily === "string" ? input.fontFamily.slice(0, 80) : "lixFont";
64
+ const transform = `translate(${x}, ${y})${rotation ? ` rotate(${rotation}, 0, 0)` : ""}`;
65
+ const lines = text.split("\n").map((line, index) => `<tspan x="0" dy="${index === 0 ? 0 : "1.2em"}">${escapeXml(line || " ")}</tspan>`).join("");
66
+ return {
67
+ shapeID,
68
+ type: "text",
69
+ x,
70
+ y,
71
+ rotation,
72
+ mcpText: text,
73
+ mcpFontSize: fontSize,
74
+ mcpColor: color2,
75
+ mcpFontFamily: family,
76
+ groupHTML: `<g id="${escapeXml(shapeID)}" data-type="text-group" data-x="${x}" data-y="${y}" transform="${transform}"><text id="${escapeXml(shapeID)}-text" x="0" y="0" fill="${escapeXml(color2)}" font-size="${fontSize}" font-family="${escapeXml(family)}" dominant-baseline="hanging" white-space="pre" pointer-events="painted" data-type="text" data-initial-size="${fontSize}" data-initial-font="${escapeXml(family)}" data-initial-color="${escapeXml(color2)}">${lines}</text></g>`
77
+ };
78
+ }
79
+ function normalizeShape(input, existingIds = /* @__PURE__ */ new Set()) {
80
+ if (!input || typeof input !== "object") throw new Error("Shape must be an object");
81
+ if (!WRITABLE_TYPES.has(input.type)) throw new Error(`Shape type "${input.type}" is read-only through MCP`);
82
+ let shapeID = String(input.shapeID || `${input.type}-${crypto.randomUUID()}`).slice(0, 120);
83
+ while (existingIds.has(shapeID)) shapeID = `${input.type}-${crypto.randomUUID()}`;
84
+ const base = { shapeID, type: input.type, rotation: finite(input.rotation), options: normalizeOptions(input.options), groupId: input.groupId || null, parentFrame: input.parentFrame || null, docBlockIds: [] };
85
+ switch (input.type) {
86
+ case "rectangle":
87
+ return { ...base, x: finite(input.x), y: finite(input.y), width: positive(input.width, 160), height: positive(input.height, 90) };
88
+ case "circle":
89
+ return { ...base, x: finite(input.x), y: finite(input.y), rx: positive(input.rx, 60), ry: positive(input.ry, 60) };
90
+ case "line":
91
+ return { ...base, startPoint: point(input.startPoint), endPoint: point(input.endPoint, 120), isCurved: Boolean(input.isCurved), controlPoint: input.controlPoint ? point(input.controlPoint) : null };
92
+ case "arrow":
93
+ return { ...base, startPoint: point(input.startPoint), endPoint: point(input.endPoint, 120), arrowHeadStyle: input.arrowHeadStyle || "triangle", arrowOutlineStyle: input.arrowOutlineStyle || "solid", arrowCurved: Boolean(input.arrowCurved), arrowCurveAmount: finite(input.arrowCurveAmount, 0.2) };
94
+ case "freehandStroke": {
95
+ const points = (Array.isArray(input.points) ? input.points : []).slice(0, 4096).map((entry) => [finite(entry?.[0]), finite(entry?.[1]), finite(entry?.[2], 0.5)]);
96
+ if (points.length < 2) throw new Error("freehandStroke requires at least two points");
97
+ return { ...base, points };
98
+ }
99
+ case "frame":
100
+ return { ...base, x: finite(input.x), y: finite(input.y), width: positive(input.width, 640), height: positive(input.height, 360), frameName: String(input.frameName || "Frame").slice(0, 80), fillStyle: input.fillStyle || "transparent", fillColor: input.fillColor || "#1e1e28", gridSize: positive(input.gridSize, 20), containedShapeIDs: [] };
101
+ case "text":
102
+ return { ...base, ...createTextShape(input, shapeID) };
103
+ default:
104
+ throw new Error(`Unsupported shape type "${input.type}"`);
105
+ }
106
+ }
107
+ function point(value, fallbackX = 0) {
108
+ return { x: finite(value?.x, fallbackX), y: finite(value?.y) };
109
+ }
110
+ function translateShape(shape, dx, dy) {
111
+ const moved = clone(shape);
112
+ if (moved.startPoint) {
113
+ moved.startPoint.x += dx;
114
+ moved.startPoint.y += dy;
115
+ }
116
+ if (moved.endPoint) {
117
+ moved.endPoint.x += dx;
118
+ moved.endPoint.y += dy;
119
+ }
120
+ if (moved.controlPoint) {
121
+ moved.controlPoint.x += dx;
122
+ moved.controlPoint.y += dy;
123
+ }
124
+ if (moved.controlPoint1) {
125
+ moved.controlPoint1.x += dx;
126
+ moved.controlPoint1.y += dy;
127
+ }
128
+ if (moved.controlPoint2) {
129
+ moved.controlPoint2.x += dx;
130
+ moved.controlPoint2.y += dy;
131
+ }
132
+ if (Array.isArray(moved.points)) moved.points = moved.points.map((p) => [p[0] + dx, p[1] + dy, ...p.slice(2)]);
133
+ if (Number.isFinite(moved.x)) moved.x += dx;
134
+ if (Number.isFinite(moved.y)) moved.y += dy;
135
+ if (moved.type === "text" && moved.groupHTML) {
136
+ moved.groupHTML = moved.groupHTML.replace(/data-x="[^"]*"/, `data-x="${moved.x}"`).replace(/data-y="[^"]*"/, `data-y="${moved.y}"`).replace(/transform="translate\([^)]*\)/, `transform="translate(${moved.x}, ${moved.y})`);
137
+ }
138
+ if (moved.type === "code" && moved.groupHTML) {
139
+ moved.groupHTML = moved.groupHTML.replace(/data-x="[^"]*"/, `data-x="${moved.x}"`).replace(/data-y="[^"]*"/, `data-y="${moved.y}"`).replace(/transform="translate\([^)]*\)/, `transform="translate(${moved.x}, ${moved.y})`);
140
+ }
141
+ if (moved.type === "icon" && moved.elementHTML) {
142
+ moved.elementHTML = moved.elementHTML.replace(/\bx="[^"]*"/, `x="${moved.x}"`).replace(/\by="[^"]*"/, `y="${moved.y}"`);
143
+ }
144
+ return moved;
145
+ }
146
+ function applyScenePatch(sceneInput, operations, { expectedRevision, dryRun = false } = {}) {
147
+ const scene = clone(sceneInput);
148
+ const check = validateScene(scene);
149
+ if (!check.valid) throw new Error(`Invalid scene: ${check.errors.join("; ")}`);
150
+ if (!Array.isArray(operations) || operations.length === 0) throw new Error("At least one operation is required");
151
+ if (operations.length > MAX_OPERATIONS) throw new Error(`Patch exceeds ${MAX_OPERATIONS} operations`);
152
+ const revision = Number(scene.mcpRevision || 0);
153
+ if (expectedRevision !== void 0 && Number(expectedRevision) !== revision) throw new Error(`Revision conflict: expected ${expectedRevision}, current ${revision}`);
154
+ const changedIds = /* @__PURE__ */ new Set();
155
+ for (const operation of operations) {
156
+ if (!operation || typeof operation !== "object") throw new Error("Each operation must be an object");
157
+ if (operation.op === "add") {
158
+ if (scene.shapes.length >= MAX_SHAPES) throw new Error(`Scene exceeds ${MAX_SHAPES} shapes`);
159
+ const ids = new Set(scene.shapes.map((shape2) => shape2.shapeID));
160
+ const shape = normalizeShape(operation.shape, ids);
161
+ scene.shapes.push(shape);
162
+ changedIds.add(shape.shapeID);
163
+ } else if (operation.op === "update") {
164
+ const index = scene.shapes.findIndex((shape) => shape.shapeID === operation.shapeID);
165
+ if (index < 0) throw new Error(`Shape "${operation.shapeID}" was not found`);
166
+ const immutable = scene.shapes[index];
167
+ scene.shapes[index] = applyShapeChanges(immutable, operation.changes || {});
168
+ changedIds.add(immutable.shapeID);
169
+ } else if (operation.op === "delete") {
170
+ const ids = new Set(Array.isArray(operation.shapeIDs) ? operation.shapeIDs : [operation.shapeID]);
171
+ const before = scene.shapes.length;
172
+ scene.shapes = scene.shapes.filter((shape) => !ids.has(shape.shapeID));
173
+ if (scene.shapes.length === before) throw new Error("No requested shapes were found");
174
+ scene.shapes.forEach((shape) => {
175
+ if (ids.has(shape.parentFrame)) shape.parentFrame = null;
176
+ if (Array.isArray(shape.containedShapeIDs)) shape.containedShapeIDs = shape.containedShapeIDs.filter((id) => !ids.has(id));
177
+ });
178
+ ids.forEach((id) => changedIds.add(id));
179
+ } else if (operation.op === "translate") {
180
+ const ids = new Set(operation.shapeIDs || []), dx = finite(operation.dx), dy = finite(operation.dy);
181
+ if (!ids.size) throw new Error("translate requires shapeIDs");
182
+ scene.shapes = scene.shapes.map((shape) => ids.has(shape.shapeID) ? translateShape(shape, dx, dy) : shape);
183
+ ids.forEach((id) => changedIds.add(id));
184
+ } else if (operation.op === "rename_canvas") {
185
+ scene.name = String(operation.name || "").trim().slice(0, 72) || scene.name;
186
+ } else throw new Error(`Unsupported operation "${operation.op}"`);
187
+ }
188
+ scene.mcpRevision = revision + 1;
189
+ scene.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
190
+ const result = validateScene(scene);
191
+ if (!result.valid) throw new Error(`Patch produced an invalid scene: ${result.errors.join("; ")}`);
192
+ return { scene, revision: scene.mcpRevision, dryRun: Boolean(dryRun), changedShapeIDs: [...changedIds] };
193
+ }
194
+ function applyShapeChanges(shape, changes) {
195
+ if (!changes || typeof changes !== "object" || Array.isArray(changes)) throw new Error("Shape changes must be an object");
196
+ const allowed = {
197
+ rectangle: ["x", "y", "width", "height", "rotation", "options", "groupId", "parentFrame"],
198
+ circle: ["x", "y", "rx", "ry", "rotation", "options", "groupId", "parentFrame"],
199
+ line: ["startPoint", "endPoint", "controlPoint", "isCurved", "options", "groupId", "parentFrame"],
200
+ arrow: ["startPoint", "endPoint", "controlPoint1", "controlPoint2", "arrowHeadStyle", "arrowOutlineStyle", "arrowCurved", "arrowCurveAmount", "options", "groupId", "parentFrame"],
201
+ freehandStroke: ["points", "rotation", "options", "groupId", "parentFrame"],
202
+ frame: ["x", "y", "width", "height", "rotation", "frameName", "fillStyle", "fillColor", "gridSize", "options", "groupId", "parentFrame"],
203
+ text: ["x", "y", "rotation", "text", "fontSize", "color", "fontFamily", "groupId", "parentFrame"]
204
+ }[shape.type] || [];
205
+ const rejected = Object.keys(changes).filter((key) => !allowed.includes(key));
206
+ if (rejected.length) throw new Error(`Cannot update ${shape.type} fields: ${rejected.join(", ")}`);
207
+ if (shape.type === "text") {
208
+ const text = changes.text ?? shape.mcpText ?? extractText(shape.groupHTML);
209
+ return { ...shape, ...createTextShape({ x: changes.x ?? shape.x, y: changes.y ?? shape.y, rotation: changes.rotation ?? shape.rotation, text, fontSize: changes.fontSize ?? shape.mcpFontSize, color: changes.color ?? shape.mcpColor, fontFamily: changes.fontFamily ?? shape.mcpFontFamily }, shape.shapeID), groupId: changes.groupId ?? shape.groupId, parentFrame: changes.parentFrame ?? shape.parentFrame };
210
+ }
211
+ const copy = { ...clone(shape), ...clone(changes), shapeID: shape.shapeID, type: shape.type };
212
+ if (changes.options) copy.options = { ...shape.options || {}, ...normalizeOptions({ ...shape.options || {}, ...changes.options }) };
213
+ return copy;
214
+ }
215
+ function extractText(groupHTML = "") {
216
+ return String(groupHTML).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
217
+ }
218
+ function validateShapeGeometry(shape, index, errors) {
219
+ const numbers = [];
220
+ if (["rectangle", "frame", "text", "code", "image", "icon"].includes(shape.type)) numbers.push(["x", shape.x], ["y", shape.y]);
221
+ if (["rectangle", "frame", "image", "icon"].includes(shape.type)) numbers.push(["width", shape.width], ["height", shape.height]);
222
+ if (shape.type === "circle") numbers.push(["x", shape.x], ["y", shape.y], ["rx", shape.rx], ["ry", shape.ry]);
223
+ if (shape.startPoint) numbers.push(["startPoint.x", shape.startPoint.x], ["startPoint.y", shape.startPoint.y]);
224
+ if (shape.endPoint) numbers.push(["endPoint.x", shape.endPoint.x], ["endPoint.y", shape.endPoint.y]);
225
+ for (const [field, value] of numbers) if (!Number.isFinite(Number(value))) errors.push(`Shape ${index} has invalid ${field}`);
226
+ if (["rectangle", "frame", "image", "icon"].includes(shape.type) && (Number(shape.width) <= 0 || Number(shape.height) <= 0)) errors.push(`Shape ${index} must have positive dimensions`);
227
+ if (shape.type === "circle" && (Number(shape.rx) <= 0 || Number(shape.ry) <= 0)) errors.push(`Shape ${index} must have positive radii`);
228
+ if (shape.type === "freehandStroke" && (!Array.isArray(shape.points) || shape.points.length < 2 || shape.points.length > 4096)) errors.push(`Shape ${index} has invalid freehand points`);
229
+ if (shape.type === "text" && typeof shape.groupHTML !== "string") errors.push(`Shape ${index} is missing text markup`);
230
+ if (shape.type === "code" && typeof shape.groupHTML !== "string") errors.push(`Shape ${index} is missing code markup`);
231
+ if (shape.type === "image" && typeof shape.href !== "string") errors.push(`Shape ${index} is missing image href`);
232
+ if (shape.type === "icon" && typeof shape.elementHTML !== "string") errors.push(`Shape ${index} is missing icon markup`);
233
+ }
234
+ function getSceneSummary(scene) {
235
+ const counts = {};
236
+ for (const shape of scene.shapes || []) counts[shape.type] = (counts[shape.type] || 0) + 1;
237
+ return { name: scene.name, format: scene.format, version: scene.version, revision: Number(scene.mcpRevision || 0), shapeCount: scene.shapes?.length || 0, counts, bounds: getSceneBounds(scene) };
238
+ }
239
+ function getSceneBounds(scene) {
240
+ const boxes = (scene.shapes || []).map(shapeBounds).filter(Boolean);
241
+ if (!boxes.length) return null;
242
+ const minX = Math.min(...boxes.map((b) => b.x)), minY = Math.min(...boxes.map((b) => b.y));
243
+ const maxX = Math.max(...boxes.map((b) => b.x + b.width)), maxY = Math.max(...boxes.map((b) => b.y + b.height));
244
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
245
+ }
246
+ function shapeBounds(shape) {
247
+ if (shape.type === "circle") return { x: shape.x - shape.rx, y: shape.y - shape.ry, width: shape.rx * 2, height: shape.ry * 2 };
248
+ if (shape.startPoint && shape.endPoint) {
249
+ const x = Math.min(shape.startPoint.x, shape.endPoint.x), y = Math.min(shape.startPoint.y, shape.endPoint.y);
250
+ return { x, y, width: Math.abs(shape.endPoint.x - shape.startPoint.x), height: Math.abs(shape.endPoint.y - shape.startPoint.y) };
251
+ }
252
+ if (Array.isArray(shape.points) && shape.points.length) {
253
+ const xs = shape.points.map((p) => p[0]), ys = shape.points.map((p) => p[1]);
254
+ return { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
255
+ }
256
+ if (shape.type === "text") return { x: finite(shape.x), y: finite(shape.y), width: 160, height: 32 };
257
+ return { x: finite(shape.x), y: finite(shape.y), width: positive(shape.width), height: positive(shape.height) };
258
+ }
259
+ function mergeTemplateScene(sceneInput, templateInput, { x, y } = {}) {
260
+ const scene = clone(sceneInput), template = clone(templateInput);
261
+ const validation = validateScene(template);
262
+ if (!validation.valid) throw new Error(`Template scene is invalid: ${validation.errors.join("; ")}`);
263
+ if (scene.shapes.length + template.shapes.length > MAX_SHAPES) throw new Error(`Imported template would exceed ${MAX_SHAPES} shapes`);
264
+ const bounds = getSceneBounds(template) || { x: 0, y: 0 };
265
+ const targetX = finite(x, scene.viewport?.x || 0), targetY = finite(y, scene.viewport?.y || 0);
266
+ const dx = targetX - bounds.x, dy = targetY - bounds.y;
267
+ const idMap = new Map(template.shapes.map((shape) => [shape.shapeID, `${shape.type}-${crypto.randomUUID()}`]));
268
+ const imported = template.shapes.map((shape) => {
269
+ const moved = translateShape(shape, dx, dy);
270
+ moved.shapeID = idMap.get(shape.shapeID);
271
+ if (moved.parentFrame) moved.parentFrame = idMap.get(moved.parentFrame) || null;
272
+ if (Array.isArray(moved.containedShapeIDs)) moved.containedShapeIDs = moved.containedShapeIDs.map((id) => idMap.get(id)).filter(Boolean);
273
+ if (moved.startAttachmentID) moved.startAttachmentID = idMap.get(moved.startAttachmentID) || null;
274
+ if (moved.endAttachmentID) moved.endAttachmentID = idMap.get(moved.endAttachmentID) || null;
275
+ if (moved.groupHTML) moved.groupHTML = moved.groupHTML.split(shape.shapeID).join(moved.shapeID);
276
+ if (moved.elementHTML) moved.elementHTML = moved.elementHTML.split(shape.shapeID).join(moved.shapeID);
277
+ return moved;
278
+ });
279
+ scene.shapes.push(...imported);
280
+ scene.mcpRevision = Number(scene.mcpRevision || 0) + 1;
281
+ scene.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
282
+ return { scene, revision: scene.mcpRevision, importedShapeIDs: imported.map((shape) => shape.shapeID) };
283
+ }
284
+ var MCP_LIMITS = Object.freeze({ maxShapes: MAX_SHAPES, maxOperations: MAX_OPERATIONS });
285
+
286
+ // src/mcp/templates.js
287
+ var DEFAULT_MARKETPLACE_URL = "https://sketch.elixpo.com";
288
+ function decodeBase64Url(value) {
289
+ const base64 = String(value).replaceAll("-", "+").replaceAll("_", "/");
290
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
291
+ const binary = atob(padded);
292
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
293
+ }
294
+ async function decryptPublicTemplate(ciphertext, keyValue) {
295
+ const keyBytes = decodeBase64Url(keyValue);
296
+ if (keyBytes.byteLength !== 32) throw new Error("Template key is not AES-256");
297
+ const combined = decodeBase64Url(ciphertext);
298
+ if (combined.byteLength < 28) throw new Error("Template ciphertext is invalid");
299
+ const key = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
300
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: combined.slice(0, 12) }, key, combined.slice(12));
301
+ return JSON.parse(new TextDecoder().decode(plaintext));
302
+ }
303
+ var MarketplaceTemplateProvider = class {
304
+ constructor({ baseUrl = DEFAULT_MARKETPLACE_URL, fetchImpl = globalThis.fetch } = {}) {
305
+ if (typeof fetchImpl !== "function") throw new Error("MarketplaceTemplateProvider requires fetch");
306
+ this.baseUrl = String(baseUrl).replace(/\/$/, "");
307
+ this.fetch = fetchImpl;
308
+ }
309
+ async search({ query = "", tag = "", limit = 12 } = {}) {
310
+ const url = new URL("/api/templates", this.baseUrl);
311
+ if (query) url.searchParams.set("q", String(query).slice(0, 80));
312
+ if (tag) url.searchParams.set("tag", String(tag).slice(0, 24));
313
+ url.searchParams.set("limit", String(Math.min(24, Math.max(1, Number(limit) || 12))));
314
+ const response = await this.fetch(url, { headers: { accept: "application/json" } });
315
+ const body = await response.json();
316
+ if (!response.ok) throw new Error(body.error || `Marketplace request failed (${response.status})`);
317
+ return body.templates || [];
318
+ }
319
+ async load(slug) {
320
+ const safeSlug = String(slug || "").trim();
321
+ if (!/^[a-z0-9-]{1,80}$/.test(safeSlug)) throw new Error("Template slug is invalid");
322
+ const url = new URL(`/api/templates/${encodeURIComponent(safeSlug)}`, this.baseUrl);
323
+ url.searchParams.set("snapshot", "1");
324
+ const response = await this.fetch(url, { headers: { accept: "application/json" } });
325
+ const body = await response.json();
326
+ if (!response.ok) throw new Error(body.error || `Template request failed (${response.status})`);
327
+ const template = body.template;
328
+ if (!template?.encryptedData || !template?.publicKey) throw new Error("Template snapshot is unavailable");
329
+ return { metadata: { ...template, encryptedData: void 0, publicKey: void 0, encryptedDocData: void 0 }, scene: await decryptPublicTemplate(template.encryptedData, template.publicKey) };
330
+ }
331
+ };
332
+
333
+ // src/mcp/preview.js
334
+ var MAX_PREVIEW_BYTES = 5 * 1024 * 1024;
335
+ var esc = (value) => String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
336
+ var color = (value, fallback) => typeof value === "string" && /^#[0-9a-f]{3,8}$/i.test(value) ? value : fallback;
337
+ function options(shape) {
338
+ return {
339
+ stroke: color(shape.options?.stroke, "#8b76d6"),
340
+ fill: shape.options?.fill === "transparent" ? "none" : color(shape.options?.fill, "none"),
341
+ width: Math.max(0.5, Math.min(20, Number(shape.options?.strokeWidth) || 2)),
342
+ opacity: Math.max(0, Math.min(1, Number(shape.options?.opacity) || 1))
343
+ };
344
+ }
345
+ function renderShape(shape) {
346
+ const style = options(shape);
347
+ const attrs = `stroke="${style.stroke}" stroke-width="${style.width}" fill="${style.fill}" opacity="${style.opacity}"`;
348
+ if (shape.type === "rectangle") return `<rect x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}" rx="6" ${attrs}/>`;
349
+ if (shape.type === "circle") return `<ellipse cx="${shape.x}" cy="${shape.y}" rx="${shape.rx}" ry="${shape.ry}" ${attrs}/>`;
350
+ if (shape.type === "line") return `<line x1="${shape.startPoint.x}" y1="${shape.startPoint.y}" x2="${shape.endPoint.x}" y2="${shape.endPoint.y}" ${attrs}/>`;
351
+ if (shape.type === "arrow") return `<line x1="${shape.startPoint.x}" y1="${shape.startPoint.y}" x2="${shape.endPoint.x}" y2="${shape.endPoint.y}" ${attrs} marker-end="url(#arrowhead)"/>`;
352
+ if (shape.type === "freehandStroke") return `<polyline points="${shape.points.map((p) => `${p[0]},${p[1]}`).join(" ")}" ${attrs} fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
353
+ if (shape.type === "frame") return `<g><rect x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}" ${attrs} stroke-dasharray="6 5"/><text x="${shape.x + 8}" y="${shape.y - 8}" fill="#9f94b5" font-size="14">${esc(shape.frameName || "Frame")}</text></g>`;
354
+ if (shape.type === "text") return `<text x="${shape.x}" y="${shape.y}" fill="${color(shape.mcpColor, "#e8e3f3")}" font-size="${Number(shape.mcpFontSize) || 20}" font-family="sans-serif">${esc(shape.mcpText || String(shape.groupHTML || "").replace(/<[^>]+>/g, " ").trim())}</text>`;
355
+ return "";
356
+ }
357
+ function renderSceneSvg(scene, { background = "#15111f", padding = 40 } = {}) {
358
+ const bounds = getSceneBounds(scene) || { x: 0, y: 0, width: 1280, height: 720 };
359
+ const pad = Math.max(0, Math.min(200, Number(padding) || 0));
360
+ const viewBox = { x: bounds.x - pad, y: bounds.y - pad, width: Math.max(1, bounds.width + pad * 2), height: Math.max(1, bounds.height + pad * 2) };
361
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}" width="${Math.ceil(viewBox.width)}" height="${Math.ceil(viewBox.height)}"><defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,10 3.5,0 7" fill="#8b76d6"/></marker></defs><rect x="${viewBox.x}" y="${viewBox.y}" width="${viewBox.width}" height="${viewBox.height}" fill="${color(background, "#15111f")}"/>${(scene.shapes || []).map(renderShape).join("")}</svg>`;
362
+ if (new TextEncoder().encode(svg).byteLength > MAX_PREVIEW_BYTES) {
363
+ throw new Error("Canvas preview exceeds the 5 MB output limit");
364
+ }
365
+ return svg;
366
+ }
367
+
368
+ // src/mcp/server.js
369
+ var SERVER_NAME = "lixsketch";
370
+ var SERVER_VERSION = "1.0.0";
371
+ var PROTOCOL_VERSION = "2025-11-25";
372
+ var SUPPORTED_PROTOCOL_VERSIONS = /* @__PURE__ */ new Set([PROTOCOL_VERSION, "2025-06-18", "2024-11-05"]);
373
+ var PATCH_OPERATION_SCHEMA = {
374
+ oneOf: [
375
+ { type: "object", required: ["op", "shape"], properties: { op: { const: "add" }, shape: { type: "object", description: "A rectangle, circle, line, arrow, frame, freehandStroke, or text shape." } } },
376
+ { type: "object", required: ["op", "shapeID", "changes"], properties: { op: { const: "update" }, shapeID: { type: "string" }, changes: { type: "object" } } },
377
+ { type: "object", required: ["op"], properties: { op: { const: "delete" }, shapeID: { type: "string" }, shapeIDs: { type: "array", items: { type: "string" } } } },
378
+ { type: "object", required: ["op", "shapeIDs", "dx", "dy"], properties: { op: { const: "translate" }, shapeIDs: { type: "array", items: { type: "string" } }, dx: { type: "number" }, dy: { type: "number" } } },
379
+ { type: "object", required: ["op", "name"], properties: { op: { const: "rename_canvas" }, name: { type: "string", maxLength: 72 } } }
380
+ ]
381
+ };
382
+ var LIXSKETCH_MCP_TOOLS = Object.freeze([
383
+ {
384
+ name: "canvas_get",
385
+ title: "Read LixSketch canvas",
386
+ description: "Return the canvas summary and optionally its editable scene shapes. Read this before mutation to obtain the current revision.",
387
+ inputSchema: { type: "object", properties: { includeShapes: { type: "boolean", default: false }, shapeIDs: { type: "array", maxItems: 500, items: { type: "string" } } }, additionalProperties: false },
388
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
389
+ },
390
+ {
391
+ name: "canvas_apply_patch",
392
+ title: "Apply atomic canvas patch",
393
+ description: `Atomically add, update, translate, or delete shapes. Supports ${MCP_LIMITS.maxOperations} operations per call, optimistic revision checks, and dry runs.`,
394
+ inputSchema: { type: "object", required: ["operations"], properties: { expectedRevision: { type: "integer", minimum: 0 }, dryRun: { type: "boolean", default: false }, operations: { type: "array", minItems: 1, maxItems: MCP_LIMITS.maxOperations, items: PATCH_OPERATION_SCHEMA } }, additionalProperties: false },
395
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
396
+ },
397
+ {
398
+ name: "canvas_validate",
399
+ title: "Validate LixSketch canvas",
400
+ description: "Validate the current scene format, supported shapes, unique IDs, and package limits.",
401
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
402
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
403
+ },
404
+ {
405
+ name: "canvas_preview",
406
+ title: "Render canvas preview",
407
+ description: "Render a lightweight SVG preview of the current scene for visual inspection before or after edits.",
408
+ inputSchema: { type: "object", properties: { background: { type: "string", pattern: "^#[0-9a-fA-F]{3,8}$" }, padding: { type: "number", minimum: 0, maximum: 200, default: 40 } }, additionalProperties: false },
409
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
410
+ },
411
+ {
412
+ name: "canvas_new",
413
+ title: "Create blank LixSketch canvas",
414
+ description: "Replace the current scene with a blank canvas. Requires explicit confirmation.",
415
+ inputSchema: { type: "object", required: ["confirm"], properties: { name: { type: "string", maxLength: 72 }, confirm: { const: true } }, additionalProperties: false },
416
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
417
+ },
418
+ {
419
+ name: "templates_search",
420
+ title: "Search LixSketch templates",
421
+ description: "Search published workspace and component templates in the LixSketch marketplace.",
422
+ inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 80 }, tag: { type: "string", maxLength: 24 }, limit: { type: "integer", minimum: 1, maximum: 24, default: 12 } }, additionalProperties: false },
423
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
424
+ },
425
+ {
426
+ name: "template_insert",
427
+ title: "Insert LixSketch template",
428
+ description: "Insert a published template into the current canvas, remapping every shape and relationship ID. The operation is atomic and supports a dry run.",
429
+ inputSchema: { type: "object", required: ["slug"], properties: { slug: { type: "string", pattern: "^[a-z0-9-]{1,80}$" }, x: { type: "number" }, y: { type: "number" }, expectedRevision: { type: "integer", minimum: 0 }, dryRun: { type: "boolean", default: false } }, additionalProperties: false },
430
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
431
+ }
432
+ ]);
433
+ function toolResult(value, message) {
434
+ return {
435
+ content: [{ type: "text", text: message || JSON.stringify(value, null, 2) }],
436
+ structuredContent: value
437
+ };
438
+ }
439
+ function toolError(error) {
440
+ const message = error instanceof Error ? error.message : String(error);
441
+ return { isError: true, content: [{ type: "text", text: message }], structuredContent: { error: message } };
442
+ }
443
+ var LixSketchMcpServer = class {
444
+ constructor({ store, templateProvider = new MarketplaceTemplateProvider(), serverInfo = {} } = {}) {
445
+ if (!store?.read || !store?.write) throw new Error("createLixSketchMcpServer requires a scene store with read() and write()");
446
+ this.store = store;
447
+ this.templateProvider = templateProvider;
448
+ this.serverInfo = { name: SERVER_NAME, version: SERVER_VERSION, ...serverInfo };
449
+ this.mutationChain = Promise.resolve();
450
+ }
451
+ listTools() {
452
+ return LIXSKETCH_MCP_TOOLS;
453
+ }
454
+ async callTool(name, args = {}) {
455
+ try {
456
+ switch (name) {
457
+ case "canvas_get": {
458
+ const scene = await this.store.read();
459
+ let shapes;
460
+ if (args.includeShapes) {
461
+ const ids = new Set(args.shapeIDs || []);
462
+ shapes = ids.size ? scene.shapes.filter((shape) => ids.has(shape.shapeID)) : scene.shapes;
463
+ }
464
+ return toolResult({ summary: getSceneSummary(scene), ...shapes ? { shapes } : {} });
465
+ }
466
+ case "canvas_validate": {
467
+ const scene = await this.store.read();
468
+ const validation = validateScene(scene);
469
+ return toolResult({ ...validation, summary: getSceneSummary(scene), limits: MCP_LIMITS });
470
+ }
471
+ case "canvas_preview": {
472
+ const scene = await this.store.read();
473
+ const svg = renderSceneSvg(scene, args);
474
+ return toolResult({ svg, dataUrl: `data:image/svg+xml;base64,${encodeBase64(svg)}`, summary: getSceneSummary(scene) }, svg);
475
+ }
476
+ case "templates_search": {
477
+ const templates = await this.templateProvider.search(args);
478
+ return toolResult({ templates: templates.map(safeTemplateMetadata) });
479
+ }
480
+ case "canvas_apply_patch":
481
+ return await this.enqueueMutation(async () => {
482
+ const scene = await this.store.read();
483
+ const result = applyScenePatch(scene, args.operations, args);
484
+ if (!args.dryRun) await this.store.write(result.scene);
485
+ return toolResult({ revision: result.revision, dryRun: result.dryRun, changedShapeIDs: result.changedShapeIDs, summary: getSceneSummary(result.scene) }, args.dryRun ? "Canvas patch is valid. No changes were saved." : `Canvas patch saved at revision ${result.revision}.`);
486
+ });
487
+ case "canvas_new":
488
+ if (args.confirm !== true) throw new Error("canvas_new requires confirm=true");
489
+ return await this.enqueueMutation(async () => {
490
+ const scene = createEmptyScene(args.name);
491
+ await this.store.write(scene);
492
+ return toolResult({ summary: getSceneSummary(scene) }, "Blank canvas created.");
493
+ });
494
+ case "template_insert":
495
+ return await this.enqueueMutation(async () => {
496
+ const scene = await this.store.read();
497
+ const revision = Number(scene.mcpRevision || 0);
498
+ if (args.expectedRevision !== void 0 && Number(args.expectedRevision) !== revision) throw new Error(`Revision conflict: expected ${args.expectedRevision}, current ${revision}`);
499
+ const template = await this.templateProvider.load(args.slug);
500
+ const result = mergeTemplateScene(scene, template.scene, args);
501
+ if (!args.dryRun) await this.store.write(result.scene);
502
+ return toolResult({ template: safeTemplateMetadata(template.metadata), revision: result.revision, dryRun: Boolean(args.dryRun), importedShapeIDs: result.importedShapeIDs, summary: getSceneSummary(result.scene) }, args.dryRun ? "Template import is valid. No changes were saved." : `Template inserted with ${result.importedShapeIDs.length} shapes.`);
503
+ });
504
+ default:
505
+ throw new Error(`Unknown tool "${name}"`);
506
+ }
507
+ } catch (error) {
508
+ return toolError(error);
509
+ }
510
+ }
511
+ enqueueMutation(operation) {
512
+ const pending = this.mutationChain.then(operation, operation);
513
+ this.mutationChain = pending.catch(() => {
514
+ });
515
+ return pending;
516
+ }
517
+ async handleRequest(request) {
518
+ const method = request?.method;
519
+ if (method === "initialize") {
520
+ const requested = request.params?.protocolVersion;
521
+ const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;
522
+ return { protocolVersion, capabilities: { tools: { listChanged: false }, resources: { subscribe: false, listChanged: false } }, serverInfo: this.serverInfo, instructions: "Read canvas_get before mutations. Use expectedRevision and dryRun for safe edits. Prefer template_insert for reusable component packs." };
523
+ }
524
+ if (method === "ping") return {};
525
+ if (method === "tools/list") return { tools: this.listTools() };
526
+ if (method === "tools/call") return this.callTool(request.params?.name, request.params?.arguments || {});
527
+ if (method === "resources/list") return { resources: [{ uri: "lixsketch://canvas", name: "Current LixSketch canvas", description: "The active editable .lixjson scene", mimeType: "application/vnd.lixsketch+json" }, { uri: "lixsketch://canvas/preview.svg", name: "Current canvas preview", description: "A lightweight SVG preview of the current scene", mimeType: "image/svg+xml" }] };
528
+ if (method === "resources/read") {
529
+ const scene = await this.store.read();
530
+ if (request.params?.uri === "lixsketch://canvas") return { contents: [{ uri: "lixsketch://canvas", mimeType: "application/vnd.lixsketch+json", text: JSON.stringify(scene) }] };
531
+ if (request.params?.uri === "lixsketch://canvas/preview.svg") return { contents: [{ uri: "lixsketch://canvas/preview.svg", mimeType: "image/svg+xml", text: renderSceneSvg(scene) }] };
532
+ throw Object.assign(new Error("Resource not found"), { code: -32002 });
533
+ }
534
+ if (method?.startsWith("notifications/")) return void 0;
535
+ throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
536
+ }
537
+ };
538
+ function safeTemplateMetadata(template = {}) {
539
+ return { id: template.id, slug: template.slug, title: template.title, description: template.description || "", tags: template.tags || [], publisher: template.publisher, views: template.views, forks: template.forks, clones: template.clones, publishedAt: template.publishedAt, updatedAt: template.updatedAt };
540
+ }
541
+ function encodeBase64(value) {
542
+ if (typeof btoa === "function") return btoa(unescape(encodeURIComponent(value)));
543
+ return Buffer.from(value, "utf8").toString("base64");
544
+ }
545
+ function createLixSketchMcpServer(options2) {
546
+ return new LixSketchMcpServer(options2);
547
+ }
548
+
549
+ // src/mcp/store.js
550
+ var MemorySceneStore = class {
551
+ #scene;
552
+ constructor(scene = createEmptyScene()) {
553
+ const validation = validateScene(scene);
554
+ if (!validation.valid) throw new Error(`Invalid initial scene: ${validation.errors.join("; ")}`);
555
+ this.#scene = structuredClone(scene);
556
+ }
557
+ async read() {
558
+ return structuredClone(this.#scene);
559
+ }
560
+ async write(scene) {
561
+ const validation = validateScene(scene);
562
+ if (!validation.valid) throw new Error(`Refusing to store invalid scene: ${validation.errors.join("; ")}`);
563
+ this.#scene = structuredClone(scene);
564
+ return this.read();
565
+ }
566
+ };
567
+ export {
568
+ PROTOCOL_VERSION as LIXSKETCH_MCP_PROTOCOL_VERSION,
569
+ LIXSKETCH_MCP_TOOLS,
570
+ LixSketchMcpServer,
571
+ MCP_LIMITS,
572
+ MarketplaceTemplateProvider,
573
+ MemorySceneStore,
574
+ applyScenePatch,
575
+ createEmptyScene,
576
+ createLixSketchMcpServer,
577
+ decryptPublicTemplate,
578
+ getSceneBounds,
579
+ getSceneSummary,
580
+ mergeTemplateScene,
581
+ normalizeShape,
582
+ renderSceneSvg,
583
+ validateScene
584
+ };
585
+ //# sourceMappingURL=index.js.map