@templatical/template-tools 0.45.0 → 0.47.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/README.md +15 -3
- package/custom-block-schema.json +459 -0
- package/dist/bin.js +164 -17
- package/dist/bin.js.map +1 -1
- package/dist/custom-block-sAM61QPU.js +2628 -0
- package/dist/custom-block-sAM61QPU.js.map +1 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.js +3 -1
- package/dist/live/index.d.ts +48 -7
- package/dist/live/index.js +210 -9
- package/dist/live/index.js.map +1 -1
- package/dist/src-Ce1Oh8m_.js +172 -0
- package/dist/src-Ce1Oh8m_.js.map +1 -0
- package/live/index.html +64 -0
- package/package.json +20 -16
- package/schema.json +82 -2
- package/dist/src-GcX5tdei.js +0 -1462
- package/dist/src-GcX5tdei.js.map +0 -1
package/dist/live/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { a as buildSpecimenTemplate, c as runRecipe, f as validateTemplate, i as buildSpecimen, l as validateCustomBlockDefinition, o as fetchedSpecimenBlocks, s as checkRecipe } from "../custom-block-sAM61QPU.js";
|
|
2
|
+
import { createDefaultTemplateContent } from "@templatical/types";
|
|
1
3
|
import { createServer } from "node:http";
|
|
2
4
|
import { spawn } from "node:child_process";
|
|
3
5
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
@@ -5,7 +7,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
6
8
|
//#region src/live/index.ts
|
|
7
9
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const EDITOR_VERSION = "0.
|
|
10
|
+
const EDITOR_VERSION = "0.47.0";
|
|
9
11
|
const DEFAULT_PORT = 4747;
|
|
10
12
|
const WORKING_DIR = ".templatical";
|
|
11
13
|
const PID_FILE = join(WORKING_DIR, "live-server.pid");
|
|
@@ -128,6 +130,13 @@ function annotationIdFromPath(pathname) {
|
|
|
128
130
|
return null;
|
|
129
131
|
}
|
|
130
132
|
}
|
|
133
|
+
function loopbackNames(port) {
|
|
134
|
+
return [
|
|
135
|
+
`localhost:${port}`,
|
|
136
|
+
`127.0.0.1:${port}`,
|
|
137
|
+
`[::1]:${port}`
|
|
138
|
+
];
|
|
139
|
+
}
|
|
131
140
|
function noteNotFound(res) {
|
|
132
141
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
133
142
|
res.end("No note with that id.");
|
|
@@ -141,8 +150,11 @@ function noteNotFound(res) {
|
|
|
141
150
|
* own bridge — that is the whole reason live mode can work inside a sandboxed
|
|
142
151
|
* agent. The equivalent HTTP endpoints stay for the skill's out-of-process CLI.
|
|
143
152
|
*/
|
|
144
|
-
function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "template.json"), harnessFile = HARNESS_FILE } = {}) {
|
|
145
|
-
const
|
|
153
|
+
function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "template.json"), harnessFile = HARNESS_FILE, customBlock, host, env = process.env, fetch: fetchOption } = {}) {
|
|
154
|
+
const abs = (p) => isAbsolute(p) ? p : resolve(cwd, p);
|
|
155
|
+
const customBlockPath = customBlock ? abs(customBlock) : null;
|
|
156
|
+
const hostPath = host ? abs(host) : null;
|
|
157
|
+
const workingPath = customBlockPath ?? abs(file);
|
|
146
158
|
const state = {
|
|
147
159
|
baseline: null,
|
|
148
160
|
editorCurrent: null,
|
|
@@ -158,6 +170,95 @@ function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "
|
|
|
158
170
|
const payload = `event: template\ndata: ${JSON.stringify(content)}\n\n`;
|
|
159
171
|
for (const res of clients) res.write(payload);
|
|
160
172
|
}
|
|
173
|
+
/** Why the bridge won't run this definition's recipe, or null when it will. */
|
|
174
|
+
function recipeRefusal(def) {
|
|
175
|
+
if (!def.dataSourcePreview) return "This block has no dataSourcePreview.";
|
|
176
|
+
const errors = checkRecipe(def).filter((i) => i.severity === "error");
|
|
177
|
+
return errors.length > 0 ? `The dataSourcePreview recipe is refused: ${errors.map((i) => `${i.ruleId}: ${i.message}`).join("; ")}` : null;
|
|
178
|
+
}
|
|
179
|
+
let fetched = null;
|
|
180
|
+
async function refreshFetched() {
|
|
181
|
+
const data = readWorkingFile(customBlockPath);
|
|
182
|
+
if (data === null || !validateCustomBlockDefinition(data).valid) {
|
|
183
|
+
fetched = {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: "The custom block definition is missing or invalid."
|
|
186
|
+
};
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const def = data;
|
|
190
|
+
const recipe = def.dataSourcePreview;
|
|
191
|
+
const refusal = recipeRefusal(def);
|
|
192
|
+
if (!recipe || refusal !== null) {
|
|
193
|
+
fetched = {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: refusal ?? ""
|
|
196
|
+
};
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const values = {
|
|
200
|
+
...buildSpecimen(def)[0].fieldValues,
|
|
201
|
+
...fetchOption?.values
|
|
202
|
+
};
|
|
203
|
+
const result = await runRecipe(recipe, values, { env });
|
|
204
|
+
fetched = result.ok ? {
|
|
205
|
+
ok: true,
|
|
206
|
+
fieldValues: {
|
|
207
|
+
...values,
|
|
208
|
+
...result.values
|
|
209
|
+
},
|
|
210
|
+
unmapped: result.unmapped
|
|
211
|
+
} : {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: result.error
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function lastFetch() {
|
|
217
|
+
if (!fetched) return null;
|
|
218
|
+
return fetched.ok ? {
|
|
219
|
+
ok: true,
|
|
220
|
+
unmapped: fetched.unmapped ?? []
|
|
221
|
+
} : {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: fetched.error
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function readBlockView() {
|
|
227
|
+
const data = readWorkingFile(customBlockPath);
|
|
228
|
+
if (data === null || !validateCustomBlockDefinition(data).valid) return {
|
|
229
|
+
ok: false,
|
|
230
|
+
error: "The custom block definition is missing or invalid. Run `templatical custom-block validate` on it."
|
|
231
|
+
};
|
|
232
|
+
let hostContent;
|
|
233
|
+
if (hostPath) {
|
|
234
|
+
const h = readWorkingFile(hostPath);
|
|
235
|
+
if (h === null || !validateTemplate(h).valid) return {
|
|
236
|
+
ok: false,
|
|
237
|
+
error: "The --host template is missing or invalid. Run `templatical validate` on it."
|
|
238
|
+
};
|
|
239
|
+
hostContent = h;
|
|
240
|
+
}
|
|
241
|
+
const def = data;
|
|
242
|
+
const { dataSourcePreview, ...definition } = def;
|
|
243
|
+
const base = hostContent ?? createDefaultTemplateContent();
|
|
244
|
+
const lead = fetchOption && fetched ? fetchedSpecimenBlocks(def, fetched) : [];
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
def,
|
|
248
|
+
payload: {
|
|
249
|
+
definition,
|
|
250
|
+
dataSource: dataSourcePreview ? { label: dataSourcePreview.label } : null
|
|
251
|
+
},
|
|
252
|
+
template: buildSpecimenTemplate(def, {
|
|
253
|
+
...base,
|
|
254
|
+
blocks: [...base.blocks, ...lead]
|
|
255
|
+
})
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function sendJson(res, status, body) {
|
|
259
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
260
|
+
res.end(JSON.stringify(body));
|
|
261
|
+
}
|
|
161
262
|
function getEditorState() {
|
|
162
263
|
return {
|
|
163
264
|
divergent: state.divergent,
|
|
@@ -166,30 +267,127 @@ function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "
|
|
|
166
267
|
};
|
|
167
268
|
}
|
|
168
269
|
function reload(options) {
|
|
270
|
+
if (customBlockPath) {
|
|
271
|
+
const view = readBlockView();
|
|
272
|
+
if (!view.ok) return {
|
|
273
|
+
ok: false,
|
|
274
|
+
clients: clients.size,
|
|
275
|
+
consumed: false,
|
|
276
|
+
mode: "custom-block",
|
|
277
|
+
error: view.error
|
|
278
|
+
};
|
|
279
|
+
state.baseline = null;
|
|
280
|
+
state.editorCurrent = null;
|
|
281
|
+
state.divergent = false;
|
|
282
|
+
const consumed = options?.consumeAnnotations === true;
|
|
283
|
+
if (consumed) state.annotations = [];
|
|
284
|
+
const payload = `event: custom-block\ndata: ${JSON.stringify({
|
|
285
|
+
block: view.payload,
|
|
286
|
+
template: view.template
|
|
287
|
+
})}\n\n`;
|
|
288
|
+
for (const res of clients) res.write(payload);
|
|
289
|
+
const fetch = fetchOption ? lastFetch() : null;
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
clients: clients.size,
|
|
293
|
+
consumed,
|
|
294
|
+
mode: "custom-block",
|
|
295
|
+
...fetch ? { fetch } : {}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
169
298
|
const content = readWorkingFile(workingPath);
|
|
299
|
+
if (content === null) return {
|
|
300
|
+
ok: false,
|
|
301
|
+
clients: clients.size,
|
|
302
|
+
consumed: false,
|
|
303
|
+
mode: "template",
|
|
304
|
+
error: `${workingPath} is missing or isn't valid JSON. Run \`templatical validate\` on it.`
|
|
305
|
+
};
|
|
170
306
|
state.baseline = null;
|
|
171
307
|
state.editorCurrent = null;
|
|
172
308
|
state.divergent = false;
|
|
173
|
-
const consumed = options?.consumeAnnotations === true
|
|
309
|
+
const consumed = options?.consumeAnnotations === true;
|
|
174
310
|
if (consumed) state.annotations = [];
|
|
175
|
-
|
|
311
|
+
broadcastTemplate(content);
|
|
176
312
|
return {
|
|
177
313
|
ok: true,
|
|
178
314
|
clients: clients.size,
|
|
179
|
-
consumed
|
|
315
|
+
consumed,
|
|
316
|
+
mode: "template"
|
|
180
317
|
};
|
|
181
318
|
}
|
|
182
319
|
const server = createServer(async (req, res) => {
|
|
183
320
|
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
|
184
321
|
const method = req.method ?? "GET";
|
|
185
322
|
try {
|
|
323
|
+
const addr = server.address();
|
|
324
|
+
const listening = typeof addr === "object" && addr !== null ? addr.port : port;
|
|
325
|
+
const hostHeader = req.headers.host ?? "";
|
|
326
|
+
if (!loopbackNames(listening).some((n) => n === hostHeader)) {
|
|
327
|
+
res.writeHead(403, { "content-type": "text/plain" }).end("Forbidden host.");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
331
|
+
const origin = req.headers.origin;
|
|
332
|
+
if (origin !== void 0 && !loopbackNames(listening).some((n) => origin === `http://${n}`)) {
|
|
333
|
+
sendJson(res, 403, { error: "Forbidden origin." });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const type = (req.headers["content-type"] ?? "").toLowerCase();
|
|
337
|
+
if (method === "POST" && !type.startsWith("application/json")) {
|
|
338
|
+
sendJson(res, 415, { error: "Send application/json." });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
186
342
|
if (method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
187
343
|
const html = readFileSync(harnessFile, "utf8").replaceAll("{{EDITOR_VERSION}}", EDITOR_VERSION);
|
|
188
344
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
189
345
|
res.end(html);
|
|
190
346
|
return;
|
|
191
347
|
}
|
|
348
|
+
if (method === "GET" && pathname === "/custom-block") {
|
|
349
|
+
if (!customBlockPath) {
|
|
350
|
+
res.writeHead(204).end();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const view = readBlockView();
|
|
354
|
+
if (view.ok) sendJson(res, 200, view.payload);
|
|
355
|
+
else sendJson(res, 422, { error: view.error });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (method === "POST" && pathname === "/data-source/fetch") {
|
|
359
|
+
if (!customBlockPath) {
|
|
360
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const view = readBlockView();
|
|
364
|
+
if (!view.ok) {
|
|
365
|
+
sendJson(res, 422, { error: view.error });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const recipe = view.def.dataSourcePreview;
|
|
369
|
+
const refusal = recipeRefusal(view.def);
|
|
370
|
+
if (!recipe || refusal !== null) {
|
|
371
|
+
sendJson(res, recipe ? 422 : 409, { error: refusal });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const body = await readJsonBody(req);
|
|
375
|
+
const fieldValues = body?.fieldValues && typeof body.fieldValues === "object" && !Array.isArray(body.fieldValues) ? body.fieldValues : {};
|
|
376
|
+
const result = await runRecipe(recipe, fieldValues, { env });
|
|
377
|
+
if (result.ok) sendJson(res, 200, {
|
|
378
|
+
values: result.values,
|
|
379
|
+
unmapped: result.unmapped
|
|
380
|
+
});
|
|
381
|
+
else sendJson(res, 502, { error: result.error });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
192
384
|
if (method === "GET" && pathname === "/template") {
|
|
385
|
+
if (customBlockPath) {
|
|
386
|
+
const view = readBlockView();
|
|
387
|
+
if (view.ok) sendJson(res, 200, view.template);
|
|
388
|
+
else sendJson(res, 422, { error: view.error });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
193
391
|
const content = readWorkingFile(workingPath);
|
|
194
392
|
if (content === null) {
|
|
195
393
|
res.writeHead(204).end();
|
|
@@ -291,7 +489,9 @@ function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "
|
|
|
291
489
|
return;
|
|
292
490
|
}
|
|
293
491
|
if (method === "POST" && pathname === "/reload") {
|
|
294
|
-
const
|
|
492
|
+
const body = await readJsonBody(req);
|
|
493
|
+
if (fetchOption && customBlockPath) await refreshFetched();
|
|
494
|
+
const result = reload({ consumeAnnotations: body?.consumeAnnotations === true });
|
|
295
495
|
res.writeHead(200, { "content-type": "application/json" });
|
|
296
496
|
res.end(JSON.stringify(result));
|
|
297
497
|
return;
|
|
@@ -302,7 +502,7 @@ function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "
|
|
|
302
502
|
res.end(`Bad request: ${err.message}`);
|
|
303
503
|
}
|
|
304
504
|
});
|
|
305
|
-
return new Promise((resolvePromise, rejectPromise) => {
|
|
505
|
+
return (fetchOption && customBlockPath ? refreshFetched() : Promise.resolve()).then(() => new Promise((resolvePromise, rejectPromise) => {
|
|
306
506
|
const onListenError = (err) => rejectPromise(err);
|
|
307
507
|
server.once("error", onListenError);
|
|
308
508
|
server.listen(port, "127.0.0.1", () => {
|
|
@@ -316,6 +516,7 @@ function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "
|
|
|
316
516
|
workingPath,
|
|
317
517
|
getEditorState,
|
|
318
518
|
reload,
|
|
519
|
+
lastFetch,
|
|
319
520
|
close: () => new Promise((r) => {
|
|
320
521
|
for (const c of clients) c.end();
|
|
321
522
|
clients.clear();
|
|
@@ -323,7 +524,7 @@ function startBridge({ cwd = process.cwd(), port = 0, file = join(WORKING_DIR, "
|
|
|
323
524
|
})
|
|
324
525
|
});
|
|
325
526
|
});
|
|
326
|
-
});
|
|
527
|
+
}));
|
|
327
528
|
}
|
|
328
529
|
/**
|
|
329
530
|
* Start the bridge on `preferredPort`, falling back to an OS-assigned free port
|
package/dist/live/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/live/index.ts"],"sourcesContent":["// The local live-preview bridge.\n//\n// It serves live/index.html (which loads the REAL Templatical editor from the\n// CDN), streams agent-driven template updates to the page over Server-Sent\n// Events, and buffers the page's hand-edits so the caller can detect divergence\n// (a user edit in the browser) before overwriting.\n//\n// This package's own `live` CLI command drives it today, over localhost HTTP.\n// It stays side-effect free at import time and writes nothing to stdout so\n// that a future stdio-based caller — an MCP server reserving stdout for\n// JSON-RPC — can be added without auditing this module first: a stray write\n// here would corrupt that protocol. Keep all human-facing output in the\n// callers (cli/output.ts is the CLI's own single writer;\n// tests/cli-output-discipline.test.ts enforces that no other module under\n// src/ touches stdout).\n\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { spawn } from \"node:child_process\";\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst here = dirname(fileURLToPath(import.meta.url));\n\n// The CDN editor version the live harness loads. It is PINNED to this package's\n// schema version: schema.json is generated from @templatical/types, and types +\n// editor bump in lockstep (changesets fixed group), so the editor at this\n// version has the same block model the schema describes.\n//\n// Kept in step automatically by scripts/sync-pins.mjs at release time.\n// tests/cdn-pin.test.ts is the safety net that fails CI if it ever drifts.\nexport const EDITOR_VERSION = \"0.45.0\";\n\nexport const DEFAULT_PORT = 4747;\nexport const WORKING_DIR = \".templatical\";\nexport const PID_FILE = join(WORKING_DIR, \"live-server.pid\");\n\n// Resolved from this module rather than the cwd so it works identically from\n// src/live/ (tests) and dist/live/ (published) — both sit two levels under the\n// package root, where live/index.html ships.\nconst HARNESS_FILE = resolve(here, \"../../live/index.html\");\n\n// --------------------------------------------------------------------------\n// Pure helpers\n// --------------------------------------------------------------------------\n\n/** Order-insensitive structural equality for parsed-JSON template content. */\nexport function deepEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (a === null || b === null) return false;\n if (typeof a !== \"object\" || typeof b !== \"object\") return false;\n const aArr = Array.isArray(a);\n if (aArr !== Array.isArray(b)) return false;\n if (aArr) {\n const aList = a as unknown[];\n const bList = b as unknown[];\n if (aList.length !== bList.length) return false;\n for (let i = 0; i < aList.length; i++) {\n if (!deepEqual(aList[i], bList[i])) return false;\n }\n return true;\n }\n const aObj = a as Record<string, unknown>;\n const bObj = b as Record<string, unknown>;\n const ak = Object.keys(aObj);\n const bk = Object.keys(bObj);\n if (ak.length !== bk.length) return false;\n for (const k of ak) {\n if (!Object.prototype.hasOwnProperty.call(bObj, k)) return false;\n if (!deepEqual(aObj[k], bObj[k])) return false;\n }\n return true;\n}\n\n/** Read + parse the working file, or return null when it is absent/unparseable. */\nexport function readWorkingFile(path: string): unknown {\n if (!existsSync(path)) return null;\n try {\n return JSON.parse(readFileSync(path, \"utf8\"));\n } catch {\n return null;\n }\n}\n\n/** List the working templates in a project's `.templatical/` folder, by name. */\nexport function listWorkingFiles(cwd: string): string[] {\n const dir = resolve(cwd, WORKING_DIR);\n if (!existsSync(dir)) return [];\n return readdirSync(dir)\n .filter((f) => f.endsWith(\".json\"))\n .sort();\n}\n\nasync function readJsonBody(req: NodeJS.ReadableStream): Promise<\n | {\n content?: unknown;\n baseline?: boolean;\n blockId?: string;\n parentBlockId?: unknown;\n blockType?: unknown;\n label?: unknown;\n text?: string;\n consumeAnnotations?: boolean;\n }\n | undefined\n> {\n const chunks: Buffer[] = [];\n for await (const chunk of req) chunks.push(chunk as Buffer);\n const raw = Buffer.concat(chunks).toString(\"utf8\");\n return raw ? JSON.parse(raw) : undefined;\n}\n\n/**\n * Open a URL in the user's default browser — best-effort and cross-platform.\n * A silent no-op where there's no browser (headless / CI / sandbox); callers\n * always surface the URL too, so nothing is lost when this does nothing.\n */\nexport function openBrowser(url: string): void {\n const [cmd, cmdArgs]: [string, string[]] =\n process.platform === \"darwin\"\n ? [\"open\", [url]]\n : process.platform === \"win32\"\n ? [\"cmd\", [\"/c\", \"start\", \"\", url]]\n : [\"xdg-open\", [url]];\n try {\n const child = spawn(cmd, cmdArgs, { stdio: \"ignore\", detached: true });\n child.on(\"error\", () => {}); // no browser available — ignore\n child.unref();\n } catch {\n /* ignore — the URL is surfaced by the caller regardless */\n }\n}\n\n// --------------------------------------------------------------------------\n// Pidfile helpers (used by this package's own `live` CLI command; a future\n// stdio-based MCP server would own its process lifecycle directly and would\n// not need them)\n// --------------------------------------------------------------------------\n\nexport interface PidfileInfo {\n pid: number;\n port: number;\n}\n\nexport function pidfilePath(cwd: string): string {\n return resolve(cwd, PID_FILE);\n}\n\nexport function readPidfile(cwd: string): PidfileInfo | null {\n const p = pidfilePath(cwd);\n if (!existsSync(p)) return null;\n try {\n return JSON.parse(readFileSync(p, \"utf8\")) as PidfileInfo;\n } catch {\n return null;\n }\n}\n\nexport function processAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * A note the user left in the browser, against a block or the template as a\n * whole. Delivered on GET /content - the call the agent already makes before\n * every change - so notes need no polling and no separate endpoint to drain.\n */\nexport interface Annotation {\n id: string;\n /** null for a note about the template rather than one block. */\n blockId: string | null;\n /** The next data-block-id above the target. null at the top level and for a template note. */\n parentBlockId: string | null;\n blockType: string | null;\n /** Snapshot of the block's text at save time, already truncated. */\n label: string;\n text: string;\n createdAt: number;\n}\n\nconst NOTE_TEXT_MAX = 2000;\nconst NOTE_LABEL_MAX = 80;\nconst NOTE_BLOCK_TYPE_MAX = 40;\nconst NOTE_QUEUE_MAX = 40;\n\nfunction readNoteText(value: unknown): { text: string } | { error: string } {\n const text = typeof value === \"string\" ? value.trim() : \"\";\n if (!text) return { error: \"An annotation needs non-empty `text`.\" };\n if (text.length > NOTE_TEXT_MAX) {\n return { error: \"A note can be at most 2000 characters.\" };\n }\n return { text };\n}\n\nfunction readOptionalId(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction readBlockType(value: unknown): string | null {\n if (typeof value !== \"string\") return null;\n return value.trim().slice(0, NOTE_BLOCK_TYPE_MAX);\n}\n\nfunction readLabel(value: unknown): string {\n if (typeof value !== \"string\") return \"\";\n return value.replace(/\\s+/g, \" \").trim().slice(0, NOTE_LABEL_MAX);\n}\n\nfunction annotationIdFromPath(pathname: string): string | null {\n const prefix = \"/annotations/\";\n if (!pathname.startsWith(prefix)) return null;\n const raw = pathname.slice(prefix.length);\n if (!raw || raw.includes(\"/\")) return null;\n try {\n return decodeURIComponent(raw);\n } catch {\n return null;\n }\n}\n\nfunction noteNotFound(res: ServerResponse): void {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"No note with that id.\");\n}\n\n// --------------------------------------------------------------------------\n// Bridge server\n// --------------------------------------------------------------------------\n\nexport interface BridgeHandle {\n server: Server;\n port: number;\n url: string;\n /** The absolute path of the working file this bridge is serving. */\n workingPath: string;\n /** The page's latest state, for callers that drive the bridge in-process. */\n getEditorState: () => {\n divergent: boolean;\n content: unknown;\n annotations: Annotation[];\n };\n /** Re-read the working file and push it to every connected page. */\n reload: (options?: { consumeAnnotations?: boolean }) => {\n ok: boolean;\n clients: number;\n consumed: boolean;\n };\n close: () => Promise<void>;\n}\n\nexport interface StartBridgeOptions {\n cwd?: string;\n port?: number;\n file?: string;\n harnessFile?: string;\n}\n\n/**\n * Start the bridge HTTP server. Pure server + in-memory sync state; callers own\n * process lifecycle. Resolves once listening.\n *\n * The returned handle exposes `getEditorState` and `reload` directly, so an\n * in-process caller (the MCP server) never has to make an HTTP request to its\n * own bridge — that is the whole reason live mode can work inside a sandboxed\n * agent. The equivalent HTTP endpoints stay for the skill's out-of-process CLI.\n */\nexport function startBridge({\n cwd = process.cwd(),\n port = 0,\n file = join(WORKING_DIR, \"template.json\"),\n harnessFile = HARNESS_FILE,\n}: StartBridgeOptions = {}): Promise<BridgeHandle> {\n const workingPath = isAbsolute(file) ? file : resolve(cwd, file);\n\n // Sync state. `baseline` is the editor's NORMALIZED view of the caller's last\n // content (captured by the page right after it applies an update), so the\n // editor's own load-time normalization never reads as a user edit.\n // `editorCurrent` is the page's latest getContent(); `divergent` is true when\n // it structurally differs from `baseline` — i.e. the user hand-edited.\n const state: {\n baseline: unknown;\n editorCurrent: unknown;\n divergent: boolean;\n annotations: Annotation[];\n } = {\n baseline: null,\n editorCurrent: null,\n divergent: false,\n annotations: [],\n };\n let nextAnnotationNumber = 1;\n function mintAnnotationId(): string {\n const n = nextAnnotationNumber++;\n return `a${n}-${Date.now().toString(36)}`;\n }\n const clients = new Set<ServerResponse>();\n\n function broadcastTemplate(content: unknown): void {\n const payload = `event: template\\ndata: ${JSON.stringify(content)}\\n\\n`;\n for (const res of clients) res.write(payload);\n }\n\n function getEditorState(): {\n divergent: boolean;\n content: unknown;\n annotations: Annotation[];\n } {\n return {\n divergent: state.divergent,\n content: state.editorCurrent,\n annotations: state.annotations,\n };\n }\n\n function reload(options?: { consumeAnnotations?: boolean }): {\n ok: boolean;\n clients: number;\n consumed: boolean;\n } {\n // The freshly-written file is the new baseline, so any pending user edit is\n // superseded. Notes stay: a plain reload is an ordinary edit, and clearing\n // them here would drop a queue the agent has not applied. consumeAnnotations\n // clears only after the file was actually read — clearing first would drop\n // the notes and push nothing when the file is missing.\n const content = readWorkingFile(workingPath);\n state.baseline = null;\n state.editorCurrent = null;\n state.divergent = false;\n const consumed = options?.consumeAnnotations === true && content !== null;\n if (consumed) state.annotations = [];\n if (content !== null) broadcastTemplate(content);\n return { ok: true, clients: clients.size, consumed };\n }\n\n const server = createServer(async (req, res) => {\n const { pathname } = new URL(req.url ?? \"/\", \"http://localhost\");\n const method = req.method ?? \"GET\";\n\n try {\n if (\n method === \"GET\" &&\n (pathname === \"/\" || pathname === \"/index.html\")\n ) {\n const html = readFileSync(harnessFile, \"utf8\").replaceAll(\n \"{{EDITOR_VERSION}}\",\n EDITOR_VERSION,\n );\n res.writeHead(200, { \"content-type\": \"text/html; charset=utf-8\" });\n res.end(html);\n return;\n }\n\n if (method === \"GET\" && pathname === \"/template\") {\n const content = readWorkingFile(workingPath);\n if (content === null) {\n res.writeHead(204).end(); // no working file yet — page inits empty\n return;\n }\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(content));\n return;\n }\n\n if (method === \"GET\" && pathname === \"/events\") {\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n });\n res.write(\"event: ready\\ndata: {}\\n\\n\");\n clients.add(res);\n const ping = setInterval(() => res.write(\": ping\\n\\n\"), 15000);\n req.on(\"close\", () => {\n clearInterval(ping);\n clients.delete(res);\n });\n return;\n }\n\n if (method === \"POST\" && pathname === \"/content\") {\n const body = await readJsonBody(req);\n const content = body?.content;\n if (body?.baseline) {\n // Post-apply snapshot: this is the editor's normalized view of the\n // caller's content, not a user edit.\n state.baseline = content ?? null;\n state.editorCurrent = content ?? null;\n state.divergent = false;\n } else {\n state.editorCurrent = content ?? null;\n state.divergent =\n state.baseline !== null && !deepEqual(content, state.baseline);\n }\n res.writeHead(204).end();\n return;\n }\n\n if (method === \"GET\" && pathname === \"/content\") {\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(getEditorState()));\n return;\n }\n\n if (method === \"POST\" && pathname === \"/annotations\") {\n const body = await readJsonBody(req);\n const parsed = readNoteText(body?.text);\n if (\"error\" in parsed) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(parsed.error);\n return;\n }\n if (state.annotations.length >= NOTE_QUEUE_MAX) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"The note queue is full (40).\");\n return;\n }\n const blockId = readOptionalId(body?.blockId);\n let parentBlockId = readOptionalId(body?.parentBlockId);\n if (parentBlockId === blockId) parentBlockId = null;\n const annotation: Annotation = {\n id: mintAnnotationId(),\n blockId,\n parentBlockId,\n blockType: readBlockType(body?.blockType),\n label: readLabel(body?.label),\n text: parsed.text,\n createdAt: Date.now(),\n };\n state.annotations.push(annotation);\n res.writeHead(201, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(annotation));\n return;\n }\n\n const noteId = annotationIdFromPath(pathname);\n if (noteId && (method === \"PUT\" || method === \"DELETE\")) {\n const index = state.annotations.findIndex((note) => note.id === noteId);\n if (index < 0) {\n noteNotFound(res);\n return;\n }\n if (method === \"DELETE\") {\n state.annotations.splice(index, 1);\n res.writeHead(204).end();\n return;\n }\n const body = await readJsonBody(req);\n const parsed = readNoteText(body?.text);\n if (\"error\" in parsed) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(parsed.error);\n return;\n }\n const updated: Annotation = {\n ...state.annotations[index],\n text: parsed.text,\n };\n state.annotations[index] = updated;\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(updated));\n return;\n }\n\n if (method === \"POST\" && pathname === \"/reload\") {\n const body = await readJsonBody(req);\n const result = reload({\n consumeAnnotations: body?.consumeAnnotations === true,\n });\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(result));\n return;\n }\n\n res.writeHead(404, { \"content-type\": \"text/plain\" }).end(\"Not found\");\n } catch (err) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(`Bad request: ${(err as Error).message}`);\n }\n });\n\n return new Promise((resolvePromise, rejectPromise) => {\n // listen() reports failures (e.g. EADDRINUSE) as an 'error' event, not a\n // throw — without this the error would be unhandled and crash the process.\n const onListenError = (err: Error) => rejectPromise(err);\n server.once(\"error\", onListenError);\n server.listen(port, \"127.0.0.1\", () => {\n server.removeListener(\"error\", onListenError);\n const address = server.address();\n const actualPort =\n typeof address === \"object\" && address !== null ? address.port : port;\n resolvePromise({\n server,\n port: actualPort,\n url: `http://localhost:${actualPort}/`,\n workingPath,\n getEditorState,\n reload,\n close: () =>\n new Promise<void>((r) => {\n for (const c of clients) c.end();\n clients.clear();\n server.close(() => r());\n }),\n });\n });\n });\n}\n\nexport interface PreferringHandle extends BridgeHandle {\n fellBack: boolean;\n preferredPort?: number;\n}\n\n/**\n * Start the bridge on `preferredPort`, falling back to an OS-assigned free port\n * when that one is occupied — so a busy port never fails the launch. The page\n * uses relative URLs, so the actual port is discovered, not assumed. The\n * returned handle carries `fellBack: true` when it landed on a different port.\n */\nexport async function startBridgePreferring({\n preferredPort = DEFAULT_PORT,\n ...opts\n}: StartBridgeOptions & {\n preferredPort?: number;\n} = {}): Promise<PreferringHandle> {\n try {\n const handle = await startBridge({ ...opts, port: preferredPort });\n return { ...handle, fellBack: false };\n } catch (err) {\n if ((err as NodeJS.ErrnoException)?.code !== \"EADDRINUSE\") throw err;\n const handle = await startBridge({ ...opts, port: 0 });\n return { ...handle, fellBack: true, preferredPort };\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/live/index.ts"],"sourcesContent":["// The local live-preview bridge.\n//\n// It serves live/index.html (which loads the REAL Templatical editor from the\n// CDN), streams agent-driven template updates to the page over Server-Sent\n// Events, and buffers the page's hand-edits so the caller can detect divergence\n// (a user edit in the browser) before overwriting.\n//\n// This package's own `live` CLI command drives it today, over localhost HTTP.\n// It stays side-effect free at import time and writes nothing to stdout so\n// that a future stdio-based caller — an MCP server reserving stdout for\n// JSON-RPC — can be added without auditing this module first: a stray write\n// here would corrupt that protocol. Keep all human-facing output in the\n// callers (cli/output.ts is the CLI's own single writer;\n// tests/cli-output-discipline.test.ts enforces that no other module under\n// src/ touches stdout).\n\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { spawn } from \"node:child_process\";\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n createDefaultTemplateContent,\n type TemplateContent,\n} from \"@templatical/types\";\nimport {\n buildSpecimen,\n buildSpecimenTemplate,\n checkRecipe,\n runRecipe,\n validateCustomBlockDefinition,\n type CustomBlockWorkingFile,\n} from \"../custom-block\";\nimport {\n fetchedSpecimenBlocks,\n type FetchedSpecimen,\n} from \"../custom-block/specimen\";\nimport { validateTemplate } from \"../validate\";\n\nconst here = dirname(fileURLToPath(import.meta.url));\n\n// The CDN editor version the live harness loads. It is PINNED to this package's\n// schema version: schema.json is generated from @templatical/types, and types +\n// editor bump in lockstep (changesets fixed group), so the editor at this\n// version has the same block model the schema describes.\n//\n// Kept in step automatically by scripts/sync-pins.mjs at release time.\n// tests/cdn-pin.test.ts is the safety net that fails CI if it ever drifts.\nexport const EDITOR_VERSION = \"0.47.0\";\n\nexport const DEFAULT_PORT = 4747;\nexport const WORKING_DIR = \".templatical\";\nexport const PID_FILE = join(WORKING_DIR, \"live-server.pid\");\n\n// Resolved from this module rather than the cwd so it works identically from\n// src/live/ (tests) and dist/live/ (published) — both sit two levels under the\n// package root, where live/index.html ships.\nconst HARNESS_FILE = resolve(here, \"../../live/index.html\");\n\n// --------------------------------------------------------------------------\n// Pure helpers\n// --------------------------------------------------------------------------\n\n/** Order-insensitive structural equality for parsed-JSON template content. */\nexport function deepEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n if (a === null || b === null) return false;\n if (typeof a !== \"object\" || typeof b !== \"object\") return false;\n const aArr = Array.isArray(a);\n if (aArr !== Array.isArray(b)) return false;\n if (aArr) {\n const aList = a as unknown[];\n const bList = b as unknown[];\n if (aList.length !== bList.length) return false;\n for (let i = 0; i < aList.length; i++) {\n if (!deepEqual(aList[i], bList[i])) return false;\n }\n return true;\n }\n const aObj = a as Record<string, unknown>;\n const bObj = b as Record<string, unknown>;\n const ak = Object.keys(aObj);\n const bk = Object.keys(bObj);\n if (ak.length !== bk.length) return false;\n for (const k of ak) {\n if (!Object.prototype.hasOwnProperty.call(bObj, k)) return false;\n if (!deepEqual(aObj[k], bObj[k])) return false;\n }\n return true;\n}\n\n/** Read + parse the working file, or return null when it is absent/unparseable. */\nexport function readWorkingFile(path: string): unknown {\n if (!existsSync(path)) return null;\n try {\n return JSON.parse(readFileSync(path, \"utf8\"));\n } catch {\n return null;\n }\n}\n\n/** List the working templates in a project's `.templatical/` folder, by name. */\nexport function listWorkingFiles(cwd: string): string[] {\n const dir = resolve(cwd, WORKING_DIR);\n if (!existsSync(dir)) return [];\n return readdirSync(dir)\n .filter((f) => f.endsWith(\".json\"))\n .sort();\n}\n\nasync function readJsonBody(req: NodeJS.ReadableStream): Promise<\n | {\n content?: unknown;\n baseline?: boolean;\n blockId?: string;\n parentBlockId?: unknown;\n blockType?: unknown;\n label?: unknown;\n text?: string;\n consumeAnnotations?: boolean;\n fieldValues?: unknown;\n }\n | undefined\n> {\n const chunks: Buffer[] = [];\n for await (const chunk of req) chunks.push(chunk as Buffer);\n const raw = Buffer.concat(chunks).toString(\"utf8\");\n return raw ? JSON.parse(raw) : undefined;\n}\n\n/**\n * Open a URL in the user's default browser — best-effort and cross-platform.\n * A silent no-op where there's no browser (headless / CI / sandbox); callers\n * always surface the URL too, so nothing is lost when this does nothing.\n */\nexport function openBrowser(url: string): void {\n const [cmd, cmdArgs]: [string, string[]] =\n process.platform === \"darwin\"\n ? [\"open\", [url]]\n : process.platform === \"win32\"\n ? [\"cmd\", [\"/c\", \"start\", \"\", url]]\n : [\"xdg-open\", [url]];\n try {\n const child = spawn(cmd, cmdArgs, { stdio: \"ignore\", detached: true });\n child.on(\"error\", () => {}); // no browser available — ignore\n child.unref();\n } catch {\n /* ignore — the URL is surfaced by the caller regardless */\n }\n}\n\n// --------------------------------------------------------------------------\n// Pidfile helpers (used by this package's own `live` CLI command; a future\n// stdio-based MCP server would own its process lifecycle directly and would\n// not need them)\n// --------------------------------------------------------------------------\n\nexport type LiveMode = \"template\" | \"custom-block\";\n\nexport interface PidfileInfo {\n pid: number;\n port: number;\n /** Absent in a pidfile from a template-only bridge; read as \"template\". */\n mode?: LiveMode;\n /** The absolute path the server is serving. */\n path?: string;\n /** Custom-block mode: the absolute --host template, when one was given. */\n host?: string;\n /** Custom-block mode with --fetch: the --values the recipe runs with. */\n fetch?: Record<string, unknown>;\n}\n\n/** How the data-source request for the fetched specimen state went. */\nexport type FetchSummary =\n { ok: true; unmapped: string[] } | { ok: false; error: string };\n\n/** What a reload pushed, and why it pushed nothing when `ok` is false. */\nexport interface ReloadResult {\n ok: boolean;\n clients: number;\n consumed: boolean;\n mode: LiveMode;\n error?: string;\n /** With the `fetch` option: the request behind the fetched state. */\n fetch?: FetchSummary;\n}\n\nexport function pidfilePath(cwd: string): string {\n return resolve(cwd, PID_FILE);\n}\n\nexport function readPidfile(cwd: string): PidfileInfo | null {\n const p = pidfilePath(cwd);\n if (!existsSync(p)) return null;\n try {\n return JSON.parse(readFileSync(p, \"utf8\")) as PidfileInfo;\n } catch {\n return null;\n }\n}\n\nexport function processAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * A note the user left in the browser, against a block or the template as a\n * whole. Delivered on GET /content - the call the agent already makes before\n * every change - so notes need no polling and no separate endpoint to drain.\n */\nexport interface Annotation {\n id: string;\n /** null for a note about the template rather than one block. */\n blockId: string | null;\n /** The next data-block-id above the target. null at the top level and for a template note. */\n parentBlockId: string | null;\n blockType: string | null;\n /** Snapshot of the block's text at save time, already truncated. */\n label: string;\n text: string;\n createdAt: number;\n}\n\nconst NOTE_TEXT_MAX = 2000;\nconst NOTE_LABEL_MAX = 80;\nconst NOTE_BLOCK_TYPE_MAX = 40;\nconst NOTE_QUEUE_MAX = 40;\n\nfunction readNoteText(value: unknown): { text: string } | { error: string } {\n const text = typeof value === \"string\" ? value.trim() : \"\";\n if (!text) return { error: \"An annotation needs non-empty `text`.\" };\n if (text.length > NOTE_TEXT_MAX) {\n return { error: \"A note can be at most 2000 characters.\" };\n }\n return { text };\n}\n\nfunction readOptionalId(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction readBlockType(value: unknown): string | null {\n if (typeof value !== \"string\") return null;\n return value.trim().slice(0, NOTE_BLOCK_TYPE_MAX);\n}\n\nfunction readLabel(value: unknown): string {\n if (typeof value !== \"string\") return \"\";\n return value.replace(/\\s+/g, \" \").trim().slice(0, NOTE_LABEL_MAX);\n}\n\nfunction annotationIdFromPath(pathname: string): string | null {\n const prefix = \"/annotations/\";\n if (!pathname.startsWith(prefix)) return null;\n const raw = pathname.slice(prefix.length);\n if (!raw || raw.includes(\"/\")) return null;\n try {\n return decodeURIComponent(raw);\n } catch {\n return null;\n }\n}\n\nfunction loopbackNames(port: number): string[] {\n return [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];\n}\n\nfunction noteNotFound(res: ServerResponse): void {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"No note with that id.\");\n}\n\n// --------------------------------------------------------------------------\n// Bridge server\n// --------------------------------------------------------------------------\n\nexport interface BridgeHandle {\n server: Server;\n port: number;\n url: string;\n /** The absolute path of the working file this bridge is serving. */\n workingPath: string;\n /** The page's latest state, for callers that drive the bridge in-process. */\n getEditorState: () => {\n divergent: boolean;\n content: unknown;\n annotations: Annotation[];\n };\n /** Re-read the working file and push it to every connected page. */\n reload: (options?: { consumeAnnotations?: boolean }) => ReloadResult;\n /** With the `fetch` option: the last data-source request's outcome. */\n lastFetch: () => FetchSummary | null;\n close: () => Promise<void>;\n}\n\nexport interface StartBridgeOptions {\n cwd?: string;\n port?: number;\n file?: string;\n harnessFile?: string;\n /** Custom-block mode: serve this definition's specimen instead of a template. */\n customBlock?: string;\n /** Custom-block mode only: a template the specimen is appended to. */\n host?: string;\n /** Environment the data-source recipe resolves `${env:…}` from. Defaults to process.env. */\n env?: Record<string, string | undefined>;\n /**\n * Custom-block mode only: run the `dataSourcePreview` recipe and add a\n * \"fetched\" state, filled from the real endpoint, to the specimen. `values`\n * override the field defaults for the request (the id it fetches by). It\n * runs before the bridge starts listening and again on every `POST\n * /reload`; the in-process `reload()` reuses the last result.\n */\n fetch?: { values: Record<string, unknown> };\n}\n\n/**\n * Start the bridge HTTP server. Pure server + in-memory sync state; callers own\n * process lifecycle. Resolves once listening.\n *\n * The returned handle exposes `getEditorState` and `reload` directly, so an\n * in-process caller (the MCP server) never has to make an HTTP request to its\n * own bridge — that is the whole reason live mode can work inside a sandboxed\n * agent. The equivalent HTTP endpoints stay for the skill's out-of-process CLI.\n */\nexport function startBridge({\n cwd = process.cwd(),\n port = 0,\n file = join(WORKING_DIR, \"template.json\"),\n harnessFile = HARNESS_FILE,\n customBlock,\n host,\n env = process.env,\n fetch: fetchOption,\n}: StartBridgeOptions = {}): Promise<BridgeHandle> {\n const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));\n const customBlockPath = customBlock ? abs(customBlock) : null;\n const hostPath = host ? abs(host) : null;\n const workingPath = customBlockPath ?? abs(file);\n\n // Sync state. `baseline` is the editor's NORMALIZED view of the caller's last\n // content (captured by the page right after it applies an update), so the\n // editor's own load-time normalization never reads as a user edit.\n // `editorCurrent` is the page's latest getContent(); `divergent` is true when\n // it structurally differs from `baseline` — i.e. the user hand-edited.\n const state: {\n baseline: unknown;\n editorCurrent: unknown;\n divergent: boolean;\n annotations: Annotation[];\n } = {\n baseline: null,\n editorCurrent: null,\n divergent: false,\n annotations: [],\n };\n let nextAnnotationNumber = 1;\n function mintAnnotationId(): string {\n const n = nextAnnotationNumber++;\n return `a${n}-${Date.now().toString(36)}`;\n }\n const clients = new Set<ServerResponse>();\n\n function broadcastTemplate(content: unknown): void {\n const payload = `event: template\\ndata: ${JSON.stringify(content)}\\n\\n`;\n for (const res of clients) res.write(payload);\n }\n\n /** Why the bridge won't run this definition's recipe, or null when it will. */\n function recipeRefusal(def: CustomBlockWorkingFile): string | null {\n if (!def.dataSourcePreview) return \"This block has no dataSourcePreview.\";\n const errors = checkRecipe(def).filter((i) => i.severity === \"error\");\n return errors.length > 0\n ? `The dataSourcePreview recipe is refused: ${errors\n .map((i) => `${i.ruleId}: ${i.message}`)\n .join(\"; \")}`\n : null;\n }\n\n // The fetched state's last outcome, null until the first run.\n let fetched: (FetchedSpecimen & { unmapped?: string[] }) | null = null;\n\n async function refreshFetched(): Promise<void> {\n const data = readWorkingFile(customBlockPath as string);\n if (data === null || !validateCustomBlockDefinition(data).valid) {\n fetched = {\n ok: false,\n error: \"The custom block definition is missing or invalid.\",\n };\n return;\n }\n const def = data as CustomBlockWorkingFile;\n const recipe = def.dataSourcePreview;\n const refusal = recipeRefusal(def);\n if (!recipe || refusal !== null) {\n fetched = { ok: false, error: refusal ?? \"\" };\n return;\n }\n const values = {\n ...buildSpecimen(def)[0].fieldValues,\n ...fetchOption?.values,\n };\n const result = await runRecipe(recipe, values, { env });\n fetched = result.ok\n ? {\n ok: true,\n fieldValues: { ...values, ...result.values },\n unmapped: result.unmapped,\n }\n : { ok: false, error: result.error };\n }\n\n function lastFetch(): FetchSummary | null {\n if (!fetched) return null;\n return fetched.ok\n ? { ok: true, unmapped: fetched.unmapped ?? [] }\n : { ok: false, error: fetched.error };\n }\n\n type BlockView =\n | {\n ok: true;\n def: CustomBlockWorkingFile;\n payload: unknown;\n template: TemplateContent;\n }\n | { ok: false; error: string };\n\n // Read fresh on every request: the agent edits the file between reloads,\n // and the page must never be handed a definition that failed validation.\n function readBlockView(): BlockView {\n const data = readWorkingFile(customBlockPath as string);\n if (data === null || !validateCustomBlockDefinition(data).valid) {\n return {\n ok: false,\n error:\n \"The custom block definition is missing or invalid. Run `templatical custom-block validate` on it.\",\n };\n }\n let hostContent: TemplateContent | undefined;\n if (hostPath) {\n const h = readWorkingFile(hostPath);\n if (h === null || !validateTemplate(h).valid) {\n return {\n ok: false,\n error:\n \"The --host template is missing or invalid. Run `templatical validate` on it.\",\n };\n }\n hostContent = h as TemplateContent;\n }\n const def = data as CustomBlockWorkingFile;\n const { dataSourcePreview, ...definition } = def;\n // The fetched state leads the specimen: it is the realistic one.\n const base = hostContent ?? createDefaultTemplateContent();\n const lead =\n fetchOption && fetched ? fetchedSpecimenBlocks(def, fetched) : [];\n return {\n ok: true,\n def,\n payload: {\n definition,\n dataSource: dataSourcePreview\n ? { label: dataSourcePreview.label }\n : null,\n },\n template: buildSpecimenTemplate(def, {\n ...base,\n blocks: [...base.blocks, ...lead],\n }),\n };\n }\n\n function sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(body));\n }\n\n function getEditorState(): {\n divergent: boolean;\n content: unknown;\n annotations: Annotation[];\n } {\n return {\n divergent: state.divergent,\n content: state.editorCurrent,\n annotations: state.annotations,\n };\n }\n\n function reload(options?: { consumeAnnotations?: boolean }): ReloadResult {\n // A failed read pushes nothing and leaves the page's state alone: the\n // page still shows what it showed, so its divergence and notes still hold.\n if (customBlockPath) {\n const view = readBlockView();\n if (!view.ok) {\n return {\n ok: false,\n clients: clients.size,\n consumed: false,\n mode: \"custom-block\",\n error: view.error,\n };\n }\n state.baseline = null;\n state.editorCurrent = null;\n state.divergent = false;\n const consumed = options?.consumeAnnotations === true;\n if (consumed) state.annotations = [];\n const payload = `event: custom-block\\ndata: ${JSON.stringify({ block: view.payload, template: view.template })}\\n\\n`;\n for (const res of clients) res.write(payload);\n const fetch = fetchOption ? lastFetch() : null;\n return {\n ok: true,\n clients: clients.size,\n consumed,\n mode: \"custom-block\",\n ...(fetch ? { fetch } : {}),\n };\n }\n\n // The freshly-written file is the new baseline, so any pending user edit is\n // superseded. Notes stay: a plain reload is an ordinary edit, and clearing\n // them here would drop a queue the agent has not applied. consumeAnnotations\n // clears only after the file was actually read — clearing first would drop\n // the notes and push nothing when the file is missing.\n const content = readWorkingFile(workingPath);\n if (content === null) {\n return {\n ok: false,\n clients: clients.size,\n consumed: false,\n mode: \"template\",\n error: `${workingPath} is missing or isn't valid JSON. Run \\`templatical validate\\` on it.`,\n };\n }\n state.baseline = null;\n state.editorCurrent = null;\n state.divergent = false;\n const consumed = options?.consumeAnnotations === true;\n if (consumed) state.annotations = [];\n broadcastTemplate(content);\n return { ok: true, clients: clients.size, consumed, mode: \"template\" };\n }\n\n const server = createServer(async (req, res) => {\n const { pathname } = new URL(req.url ?? \"/\", \"http://localhost\");\n const method = req.method ?? \"GET\";\n\n try {\n // Only this listener's own loopback names are served: a DNS-rebound\n // hostname would otherwise let any web page read the bridge as same-origin.\n const addr = server.address();\n const listening =\n typeof addr === \"object\" && addr !== null ? addr.port : port;\n const hostHeader = req.headers.host ?? \"\";\n if (!loopbackNames(listening).some((n) => n === hostHeader)) {\n res\n .writeHead(403, { \"content-type\": \"text/plain\" })\n .end(\"Forbidden host.\");\n return;\n }\n\n // Writes. The Host check above doesn't stop a page on another site from\n // sending a simple cross-origin POST to localhost (text/plain, no\n // preflight): its response is unreadable, but the write still lands, so\n // a note it posts would reach the agent as the user's own. A browser\n // attaches its Origin to every such request, so a foreign one is\n // refused, and a POST must be JSON, which no simple request can be. PUT\n // and DELETE need a preflight cross-site, which the bridge never grants.\n if (method !== \"GET\" && method !== \"HEAD\") {\n const origin = req.headers.origin;\n if (\n origin !== undefined &&\n !loopbackNames(listening).some((n) => origin === `http://${n}`)\n ) {\n sendJson(res, 403, { error: \"Forbidden origin.\" });\n return;\n }\n const type = (req.headers[\"content-type\"] ?? \"\").toLowerCase();\n if (method === \"POST\" && !type.startsWith(\"application/json\")) {\n sendJson(res, 415, { error: \"Send application/json.\" });\n return;\n }\n }\n\n if (\n method === \"GET\" &&\n (pathname === \"/\" || pathname === \"/index.html\")\n ) {\n const html = readFileSync(harnessFile, \"utf8\").replaceAll(\n \"{{EDITOR_VERSION}}\",\n EDITOR_VERSION,\n );\n res.writeHead(200, { \"content-type\": \"text/html; charset=utf-8\" });\n res.end(html);\n return;\n }\n\n if (method === \"GET\" && pathname === \"/custom-block\") {\n if (!customBlockPath) {\n res.writeHead(204).end();\n return;\n }\n const view = readBlockView();\n if (view.ok) sendJson(res, 200, view.payload);\n else sendJson(res, 422, { error: view.error });\n return;\n }\n\n if (method === \"POST\" && pathname === \"/data-source/fetch\") {\n if (!customBlockPath) {\n res.writeHead(404, { \"content-type\": \"text/plain\" }).end(\"Not found\");\n return;\n }\n const view = readBlockView();\n if (!view.ok) {\n sendJson(res, 422, { error: view.error });\n return;\n }\n // The recipe gate runs before the page's request is even read: a\n // recipe `custom-block validate` rejects is never sent anywhere.\n const recipe = view.def.dataSourcePreview;\n const refusal = recipeRefusal(view.def);\n if (!recipe || refusal !== null) {\n sendJson(res, recipe ? 422 : 409, { error: refusal });\n return;\n }\n // Only fieldValues is read: the recipe (URL, headers) never comes from the page.\n const body = await readJsonBody(req);\n const fieldValues =\n body?.fieldValues &&\n typeof body.fieldValues === \"object\" &&\n !Array.isArray(body.fieldValues)\n ? (body.fieldValues as Record<string, unknown>)\n : {};\n const result = await runRecipe(recipe, fieldValues, { env });\n if (result.ok) {\n sendJson(res, 200, {\n values: result.values,\n unmapped: result.unmapped,\n });\n } else {\n sendJson(res, 502, { error: result.error });\n }\n return;\n }\n\n if (method === \"GET\" && pathname === \"/template\") {\n if (customBlockPath) {\n const view = readBlockView();\n if (view.ok) sendJson(res, 200, view.template);\n else sendJson(res, 422, { error: view.error });\n return;\n }\n const content = readWorkingFile(workingPath);\n if (content === null) {\n res.writeHead(204).end(); // no working file yet — page inits empty\n return;\n }\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(content));\n return;\n }\n\n if (method === \"GET\" && pathname === \"/events\") {\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n });\n res.write(\"event: ready\\ndata: {}\\n\\n\");\n clients.add(res);\n const ping = setInterval(() => res.write(\": ping\\n\\n\"), 15000);\n req.on(\"close\", () => {\n clearInterval(ping);\n clients.delete(res);\n });\n return;\n }\n\n if (method === \"POST\" && pathname === \"/content\") {\n const body = await readJsonBody(req);\n const content = body?.content;\n if (body?.baseline) {\n // Post-apply snapshot: this is the editor's normalized view of the\n // caller's content, not a user edit.\n state.baseline = content ?? null;\n state.editorCurrent = content ?? null;\n state.divergent = false;\n } else {\n state.editorCurrent = content ?? null;\n state.divergent =\n state.baseline !== null && !deepEqual(content, state.baseline);\n }\n res.writeHead(204).end();\n return;\n }\n\n if (method === \"GET\" && pathname === \"/content\") {\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(getEditorState()));\n return;\n }\n\n if (method === \"POST\" && pathname === \"/annotations\") {\n const body = await readJsonBody(req);\n const parsed = readNoteText(body?.text);\n if (\"error\" in parsed) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(parsed.error);\n return;\n }\n if (state.annotations.length >= NOTE_QUEUE_MAX) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"The note queue is full (40).\");\n return;\n }\n const blockId = readOptionalId(body?.blockId);\n let parentBlockId = readOptionalId(body?.parentBlockId);\n if (parentBlockId === blockId) parentBlockId = null;\n const annotation: Annotation = {\n id: mintAnnotationId(),\n blockId,\n parentBlockId,\n blockType: readBlockType(body?.blockType),\n label: readLabel(body?.label),\n text: parsed.text,\n createdAt: Date.now(),\n };\n state.annotations.push(annotation);\n res.writeHead(201, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(annotation));\n return;\n }\n\n const noteId = annotationIdFromPath(pathname);\n if (noteId && (method === \"PUT\" || method === \"DELETE\")) {\n const index = state.annotations.findIndex((note) => note.id === noteId);\n if (index < 0) {\n noteNotFound(res);\n return;\n }\n if (method === \"DELETE\") {\n state.annotations.splice(index, 1);\n res.writeHead(204).end();\n return;\n }\n const body = await readJsonBody(req);\n const parsed = readNoteText(body?.text);\n if (\"error\" in parsed) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(parsed.error);\n return;\n }\n const updated: Annotation = {\n ...state.annotations[index],\n text: parsed.text,\n };\n state.annotations[index] = updated;\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(updated));\n return;\n }\n\n if (method === \"POST\" && pathname === \"/reload\") {\n const body = await readJsonBody(req);\n if (fetchOption && customBlockPath) await refreshFetched();\n const result = reload({\n consumeAnnotations: body?.consumeAnnotations === true,\n });\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(result));\n return;\n }\n\n res.writeHead(404, { \"content-type\": \"text/plain\" }).end(\"Not found\");\n } catch (err) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(`Bad request: ${(err as Error).message}`);\n }\n });\n\n // The first fetch lands before the page can load, so it never shows a\n // sheet without the fetched state.\n const ready =\n fetchOption && customBlockPath ? refreshFetched() : Promise.resolve();\n\n return ready.then(\n () =>\n new Promise<BridgeHandle>((resolvePromise, rejectPromise) => {\n // listen() reports failures (e.g. EADDRINUSE) as an 'error' event, not a\n // throw — without this the error would be unhandled and crash the process.\n const onListenError = (err: Error) => rejectPromise(err);\n server.once(\"error\", onListenError);\n server.listen(port, \"127.0.0.1\", () => {\n server.removeListener(\"error\", onListenError);\n const address = server.address();\n const actualPort =\n typeof address === \"object\" && address !== null\n ? address.port\n : port;\n resolvePromise({\n server,\n port: actualPort,\n url: `http://localhost:${actualPort}/`,\n workingPath,\n getEditorState,\n reload,\n lastFetch,\n close: () =>\n new Promise<void>((r) => {\n for (const c of clients) c.end();\n clients.clear();\n server.close(() => r());\n }),\n });\n });\n }),\n );\n}\n\nexport interface PreferringHandle extends BridgeHandle {\n fellBack: boolean;\n preferredPort?: number;\n}\n\n/**\n * Start the bridge on `preferredPort`, falling back to an OS-assigned free port\n * when that one is occupied — so a busy port never fails the launch. The page\n * uses relative URLs, so the actual port is discovered, not assumed. The\n * returned handle carries `fellBack: true` when it landed on a different port.\n */\nexport async function startBridgePreferring({\n preferredPort = DEFAULT_PORT,\n ...opts\n}: StartBridgeOptions & {\n preferredPort?: number;\n} = {}): Promise<PreferringHandle> {\n try {\n const handle = await startBridge({ ...opts, port: preferredPort });\n return { ...handle, fellBack: false };\n } catch (err) {\n if ((err as NodeJS.ErrnoException)?.code !== \"EADDRINUSE\") throw err;\n const handle = await startBridge({ ...opts, port: 0 });\n return { ...handle, fellBack: true, preferredPort };\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import "./custom-block-sAM61QPU.js";
|
|
2
|
+
import { safeClone } from "@templatical/types";
|
|
3
|
+
//#region src/operations.ts
|
|
4
|
+
/** Columns a layout declares. Mirrors the helper in core's editor. */
|
|
5
|
+
function getColumnCount(layout) {
|
|
6
|
+
if (layout === "1") return 1;
|
|
7
|
+
if (layout === "3") return 3;
|
|
8
|
+
return 2;
|
|
9
|
+
}
|
|
10
|
+
function findBlockById(blocks, id) {
|
|
11
|
+
for (const block of blocks) {
|
|
12
|
+
if (block.id === id) return block;
|
|
13
|
+
if (block.type === "section") for (const column of block.children) {
|
|
14
|
+
const found = findBlockById(column, id);
|
|
15
|
+
if (found) return found;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function findBlockParent(blocks, id, parent = { blocks }) {
|
|
21
|
+
for (const block of blocks) {
|
|
22
|
+
if (block.id === id) return parent;
|
|
23
|
+
if (block.type === "section") for (let colIdx = 0; colIdx < block.children.length; colIdx++) {
|
|
24
|
+
const result = findBlockParent(block.children[colIdx], id, {
|
|
25
|
+
blocks: block.children[colIdx],
|
|
26
|
+
sectionId: block.id,
|
|
27
|
+
columnIndex: colIdx
|
|
28
|
+
});
|
|
29
|
+
if (result) return result;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function reject(content, error) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
content,
|
|
38
|
+
error
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the array an operation targets, enforcing the column rules.
|
|
43
|
+
* Returns a string on rejection so callers can surface the reason.
|
|
44
|
+
*/
|
|
45
|
+
function resolveTarget(content, targetSectionId, columnIndex) {
|
|
46
|
+
if (!targetSectionId) return content.blocks;
|
|
47
|
+
const section = findBlockById(content.blocks, targetSectionId);
|
|
48
|
+
if (!section) return `No block with id "${targetSectionId}".`;
|
|
49
|
+
if (section.type !== "section") return `Block "${targetSectionId}" is a ${section.type}, not a section — it has no columns.`;
|
|
50
|
+
const count = getColumnCount(section.columns);
|
|
51
|
+
if (columnIndex < 0 || columnIndex >= count) return `Column ${columnIndex} is out of range for a "${section.columns}" section (${count} column(s)).`;
|
|
52
|
+
const children = section;
|
|
53
|
+
children.children[columnIndex] = children.children[columnIndex] || [];
|
|
54
|
+
return children.children[columnIndex];
|
|
55
|
+
}
|
|
56
|
+
function insertAt(target, block, index) {
|
|
57
|
+
if (index !== void 0 && index < target.length) target.splice(index, 0, block);
|
|
58
|
+
else target.push(block);
|
|
59
|
+
}
|
|
60
|
+
/** Apply one operation, returning a new document. Never mutates the input. */
|
|
61
|
+
function applyOperation(content, payload) {
|
|
62
|
+
const data = payload.data ?? {};
|
|
63
|
+
switch (payload.operation) {
|
|
64
|
+
case "setContent": {
|
|
65
|
+
const next = data.content;
|
|
66
|
+
if (!next || typeof next !== "object" || !Array.isArray(next.blocks)) return reject(content, "setContent needs `content` with a blocks array.");
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
content: safeClone(next)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
case "updateSettings": {
|
|
73
|
+
const updates = data.settings;
|
|
74
|
+
if (!updates || typeof updates !== "object") return reject(content, "updateSettings needs a `settings` object.");
|
|
75
|
+
const draft = safeClone(content);
|
|
76
|
+
draft.settings = {
|
|
77
|
+
...draft.settings,
|
|
78
|
+
...updates
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
content: draft
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
case "addBlock": {
|
|
86
|
+
const block = data.block;
|
|
87
|
+
if (!block || typeof block !== "object" || typeof block.id !== "string") return reject(content, "addBlock needs a `block` with an id.");
|
|
88
|
+
const targetSectionId = data.targetSectionId;
|
|
89
|
+
if (targetSectionId && block.type === "section") return reject(content, "A section cannot be nested inside a section column — MJML would drop it on export. Add it at the top level instead.");
|
|
90
|
+
const draft = safeClone(content);
|
|
91
|
+
if (findBlockById(draft.blocks, block.id)) return reject(content, `A block with id "${block.id}" already exists.`);
|
|
92
|
+
const target = resolveTarget(draft, targetSectionId, data.columnIndex ?? 0);
|
|
93
|
+
if (typeof target === "string") return reject(content, target);
|
|
94
|
+
insertAt(target, safeClone(block), data.index);
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
content: draft
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
case "updateBlock": {
|
|
101
|
+
const blockId = data.blockId;
|
|
102
|
+
const updates = data.updates;
|
|
103
|
+
if (!blockId) return reject(content, "updateBlock needs a `blockId`.");
|
|
104
|
+
if (!updates || typeof updates !== "object") return reject(content, "updateBlock needs an `updates` object.");
|
|
105
|
+
const draft = safeClone(content);
|
|
106
|
+
const block = findBlockById(draft.blocks, blockId);
|
|
107
|
+
if (!block) return reject(content, `No block with id "${blockId}".`);
|
|
108
|
+
if ("type" in updates && updates.type !== block.type) return reject(content, `Cannot change a block's type (${block.type} → ${String(updates.type)}). Delete it and add the new block instead.`);
|
|
109
|
+
Object.assign(block, updates);
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
content: draft
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case "updateBlockStyle": {
|
|
116
|
+
const blockId = data.blockId;
|
|
117
|
+
const styles = data.styles;
|
|
118
|
+
if (!blockId) return reject(content, "updateBlockStyle needs a `blockId`.");
|
|
119
|
+
if (!styles || typeof styles !== "object") return reject(content, "updateBlockStyle needs a `styles` object.");
|
|
120
|
+
const draft = safeClone(content);
|
|
121
|
+
const block = findBlockById(draft.blocks, blockId);
|
|
122
|
+
if (!block) return reject(content, `No block with id "${blockId}".`);
|
|
123
|
+
block.styles = {
|
|
124
|
+
...block.styles,
|
|
125
|
+
...styles
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
content: draft
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case "deleteBlock": {
|
|
133
|
+
const blockId = data.blockId;
|
|
134
|
+
if (!blockId) return reject(content, "deleteBlock needs a `blockId`.");
|
|
135
|
+
const draft = safeClone(content);
|
|
136
|
+
const parent = findBlockParent(draft.blocks, blockId);
|
|
137
|
+
if (!parent) return reject(content, `No block with id "${blockId}".`);
|
|
138
|
+
const index = parent.blocks.findIndex((b) => b.id === blockId);
|
|
139
|
+
parent.blocks.splice(index, 1);
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
content: draft
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
case "moveBlock": {
|
|
146
|
+
const blockId = data.blockId;
|
|
147
|
+
const index = data.index;
|
|
148
|
+
if (!blockId) return reject(content, "moveBlock needs a `blockId`.");
|
|
149
|
+
if (typeof index !== "number" || index < 0) return reject(content, "moveBlock needs a non-negative `index`.");
|
|
150
|
+
const targetSectionId = data.targetSectionId;
|
|
151
|
+
const draft = safeClone(content);
|
|
152
|
+
const parent = findBlockParent(draft.blocks, blockId);
|
|
153
|
+
if (!parent) return reject(content, `No block with id "${blockId}".`);
|
|
154
|
+
const oldIndex = parent.blocks.findIndex((b) => b.id === blockId);
|
|
155
|
+
if (targetSectionId && parent.blocks[oldIndex].type === "section") return reject(content, "A section cannot be moved into a section column — MJML would drop it on export.");
|
|
156
|
+
if (targetSectionId === blockId) return reject(content, "A block cannot be moved into itself.");
|
|
157
|
+
const target = resolveTarget(draft, targetSectionId, data.columnIndex ?? 0);
|
|
158
|
+
if (typeof target === "string") return reject(content, target);
|
|
159
|
+
const [block] = parent.blocks.splice(oldIndex, 1);
|
|
160
|
+
target.splice(index, 0, block);
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
content: draft
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
default: return reject(content, `Unknown operation "${String(payload.operation)}".`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
export { getColumnCount as n, applyOperation as t };
|
|
171
|
+
|
|
172
|
+
//# sourceMappingURL=src-Ce1Oh8m_.js.map
|