@templatical/template-tools 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js ADDED
@@ -0,0 +1,698 @@
1
+ #!/usr/bin/env node
2
+ import { a as validateTemplate, i as schema, r as runQualityLint, t as applyOperation } from "./src-unNYVMlC.js";
3
+ import { DEFAULT_PORT, WORKING_DIR, listWorkingFiles, openBrowser, pidfilePath, processAlive, readPidfile, readWorkingFile, startBridgePreferring } from "./live/index.js";
4
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { renderToMjml } from "@templatical/renderer";
8
+ //#region src/cli/args.ts
9
+ /** Flags that consume the next argv entry as their value. */
10
+ const VALUE_FLAGS = /* @__PURE__ */ new Set([
11
+ "format",
12
+ "out",
13
+ "o",
14
+ "op",
15
+ "ops",
16
+ "file",
17
+ "port",
18
+ "cwd"
19
+ ]);
20
+ function parseArgs(argv) {
21
+ const positional = [];
22
+ const flags = {};
23
+ let json = false;
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const arg = argv[i];
26
+ if (!arg.startsWith("-")) {
27
+ positional.push(arg);
28
+ continue;
29
+ }
30
+ const bare = arg.replace(/^--?/, "");
31
+ const eq = bare.indexOf("=");
32
+ if (eq !== -1) {
33
+ flags[bare.slice(0, eq)] = bare.slice(eq + 1);
34
+ continue;
35
+ }
36
+ if (bare === "json") {
37
+ json = true;
38
+ continue;
39
+ }
40
+ flags[bare] = VALUE_FLAGS.has(bare) && i + 1 < argv.length ? argv[++i] : true;
41
+ }
42
+ return {
43
+ command: positional.shift(),
44
+ positional,
45
+ json,
46
+ flags
47
+ };
48
+ }
49
+ /** Read a value flag as a string, or undefined when absent or valueless. */
50
+ function flagValue(args, ...names) {
51
+ for (const name of names) {
52
+ const v = args.flags[name];
53
+ if (typeof v === "string") return v;
54
+ }
55
+ }
56
+ //#endregion
57
+ //#region src/cli/output.ts
58
+ let jsonMode = false;
59
+ function setJsonMode(on) {
60
+ jsonMode = on;
61
+ }
62
+ /**
63
+ * Emit a command's result. Under `--json` the payload is serialized; otherwise
64
+ * `human()` is called for the readable form — as a thunk, so building the
65
+ * pretty output costs nothing in JSON mode.
66
+ */
67
+ function emit(payload, human) {
68
+ process.stdout.write(jsonMode ? `${JSON.stringify(payload)}\n` : `${human()}\n`);
69
+ }
70
+ /** Diagnostics, progress and errors. Always stderr, in both modes. */
71
+ function note(message) {
72
+ process.stderr.write(`${message}\n`);
73
+ }
74
+ //#endregion
75
+ //#region src/cli/io.ts
76
+ const EXIT = {
77
+ ok: 0,
78
+ /** Structural errors, or quality issues of severity "error". */
79
+ invalid: 1,
80
+ /** Bad flags, missing arguments, unreadable input. */
81
+ usage: 2,
82
+ /** An optional dependency is not installed; the message names the install. */
83
+ missingDep: 3
84
+ };
85
+ var UsageError = class extends Error {};
86
+ var InvalidTemplateError = class extends Error {
87
+ errors;
88
+ constructor(message, errors = []) {
89
+ super(message);
90
+ this.errors = errors;
91
+ }
92
+ };
93
+ var MissingDependencyError = class extends Error {
94
+ pkg;
95
+ constructor(pkg, message) {
96
+ super(message);
97
+ this.pkg = pkg;
98
+ }
99
+ };
100
+ function resolveFrom(file, cwd = process.cwd()) {
101
+ return isAbsolute(file) ? file : resolve(cwd, file);
102
+ }
103
+ function readTemplateFile(file, cwd = process.cwd()) {
104
+ const path = resolveFrom(file, cwd);
105
+ let raw;
106
+ try {
107
+ raw = readFileSync(path, "utf8");
108
+ } catch {
109
+ throw new UsageError(`Could not read ${file}`);
110
+ }
111
+ try {
112
+ return JSON.parse(raw);
113
+ } catch (err) {
114
+ throw new UsageError(`${file} is not valid JSON: ${err.message}`);
115
+ }
116
+ }
117
+ /** Write a template, creating the parent directory. Returns the path written. */
118
+ function writeTemplateFile(file, content, cwd = process.cwd()) {
119
+ const path = resolveFrom(file, cwd);
120
+ mkdirSync(dirname(path), { recursive: true });
121
+ writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`, "utf8");
122
+ return path;
123
+ }
124
+ //#endregion
125
+ //#region src/cli/commands/schema.ts
126
+ function runSchema(args) {
127
+ const out = flagValue(args, "out", "o");
128
+ if (out) {
129
+ note(`Wrote ${writeTemplateFile(out, schema)}`);
130
+ return EXIT.ok;
131
+ }
132
+ emit(schema, () => JSON.stringify(schema, null, 2));
133
+ return EXIT.ok;
134
+ }
135
+ //#endregion
136
+ //#region src/cli/commands/validate.ts
137
+ function runValidate(args) {
138
+ const file = args.positional[0];
139
+ if (!file) throw new UsageError("validate needs a template file.");
140
+ const data = readTemplateFile(file, flagValue(args, "cwd") ?? process.cwd());
141
+ const { valid, errors } = validateTemplate(data);
142
+ if (!valid) {
143
+ emit({
144
+ valid: false,
145
+ errors,
146
+ issues: []
147
+ }, () => [`✗ Structural validation failed (${errors.length}):`, ...errors.map((e) => ` - ${e}`)].join("\n"));
148
+ return EXIT.invalid;
149
+ }
150
+ const quality = runQualityLint(data);
151
+ if (quality.error) note(`Quality lint could not run: ${quality.error}`);
152
+ const issues = quality.issues;
153
+ const blocking = issues.filter((i) => i.severity === "error");
154
+ emit({
155
+ valid: true,
156
+ errors: [],
157
+ issues
158
+ }, () => {
159
+ if (issues.length === 0) return "✓ Valid — no quality issues";
160
+ const lines = issues.map((i) => ` - [${i.severity}] ${i.ruleId}: ${i.message}`);
161
+ return [`✓ Structurally valid · ${issues.length} quality issue(s):`, ...lines].join("\n");
162
+ });
163
+ return blocking.length > 0 ? EXIT.invalid : EXIT.ok;
164
+ }
165
+ //#endregion
166
+ //#region src/cli/resolve-optional.ts
167
+ /**
168
+ * Walk up from `dir` through every ancestor's node_modules looking for
169
+ * <specifier>/package.json — the same directory-walk Node's own resolver uses
170
+ * to locate a package, without then asking Node to apply exports-map
171
+ * resolution. `dir` is normalized to an absolute path via `resolve()` before
172
+ * the walk starts, so a relative anchor (e.g. ".") climbs real ancestors —
173
+ * unnormalized, `dirname(".")` is "." forever and the walk would give up
174
+ * after the first miss.
175
+ */
176
+ function findPackageDir(specifier, dir) {
177
+ let current = resolve(dir);
178
+ for (;;) {
179
+ const candidate = join(current, "node_modules", specifier);
180
+ if (existsSync(join(candidate, "package.json"))) return candidate;
181
+ const parent = dirname(current);
182
+ if (parent === current) return null;
183
+ current = parent;
184
+ }
185
+ }
186
+ /**
187
+ * Pick a found package's entry file straight from its own manifest, honoring
188
+ * exports["."].import, a string exports["."], exports["."].default, a
189
+ * root-string exports field, "module", then "main" — the fields an ESM
190
+ * consumer needs, read without Node's exports-map gating.
191
+ */
192
+ function entryFileFor(pkgDir) {
193
+ const manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
194
+ const dot = manifest.exports && typeof manifest.exports === "object" ? manifest.exports["."] : void 0;
195
+ const conditions = dot && typeof dot === "object" ? dot : void 0;
196
+ const rootExports = typeof manifest.exports === "string" ? manifest.exports : void 0;
197
+ const entry = conditions?.import ?? (typeof dot === "string" ? dot : void 0) ?? conditions?.default ?? rootExports ?? manifest.module ?? manifest.main;
198
+ if (!entry || typeof entry !== "string") throw new Error(`${pkgDir} has no resolvable entry point (exports/module/main).`);
199
+ return join(pkgDir, entry);
200
+ }
201
+ async function resolveOptional(specifier, cwd = process.cwd()) {
202
+ const anchors = [cwd, dirname(fileURLToPath(import.meta.url))];
203
+ for (const anchor of anchors) {
204
+ const pkgDir = findPackageDir(specifier, anchor);
205
+ if (!pkgDir) continue;
206
+ const resolved = entryFileFor(pkgDir);
207
+ try {
208
+ return await import(pathToFileURL(resolved).href);
209
+ } catch {
210
+ continue;
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+ //#endregion
216
+ //#region src/cli/commands/render.ts
217
+ const FORMATS$1 = /* @__PURE__ */ new Set(["mjml", "html"]);
218
+ async function runRender(args) {
219
+ const file = args.positional[0];
220
+ if (!file) throw new UsageError("render needs a template file.");
221
+ const format = flagValue(args, "format") ?? "mjml";
222
+ if (!FORMATS$1.has(format)) throw new UsageError(`Unknown --format "${format}". Use mjml or html.`);
223
+ const cwd = flagValue(args, "cwd") ?? process.cwd();
224
+ const data = readTemplateFile(file, cwd);
225
+ const { valid, errors } = validateTemplate(data);
226
+ if (!valid) throw new InvalidTemplateError(`${file} is not structurally valid (${errors.length} error(s)).`, errors);
227
+ const mjml = await renderToMjml(data);
228
+ let output = mjml;
229
+ if (format === "html") {
230
+ const mod = await resolveOptional("mjml", cwd);
231
+ if (!mod) throw new MissingDependencyError("mjml", "Rendering HTML needs the optional `mjml` package, which isn't installed.\n npm install mjml");
232
+ output = (await mod.default(mjml, { validationLevel: "soft" })).html;
233
+ }
234
+ const out = flagValue(args, "out", "o");
235
+ if (out) {
236
+ const path = resolveFrom(out, cwd);
237
+ mkdirSync(dirname(path), { recursive: true });
238
+ writeFileSync(path, output, "utf8");
239
+ note(`Wrote ${path}`);
240
+ return EXIT.ok;
241
+ }
242
+ emit({
243
+ format,
244
+ output
245
+ }, () => output);
246
+ return EXIT.ok;
247
+ }
248
+ //#endregion
249
+ //#region src/cli/commands/edit.ts
250
+ function parseOperations(args, cwd) {
251
+ const inline = flagValue(args, "op");
252
+ const batchFile = flagValue(args, "ops");
253
+ if (inline !== void 0 && batchFile !== void 0) throw new UsageError("Pass either --op or --ops, not both.");
254
+ if (inline) try {
255
+ return [JSON.parse(inline)];
256
+ } catch (err) {
257
+ throw new UsageError(`--op is not valid JSON: ${err.message}`);
258
+ }
259
+ if (batchFile) {
260
+ const parsed = readTemplateFile(batchFile, cwd);
261
+ if (!Array.isArray(parsed)) throw new UsageError("--ops must point at a JSON array of operations.");
262
+ return parsed;
263
+ }
264
+ throw new UsageError("edit needs --op '<json>' for one operation or --ops <file> for a batch.");
265
+ }
266
+ function runEdit(args) {
267
+ const file = args.positional[0];
268
+ if (!file) throw new UsageError("edit needs a template file.");
269
+ const cwd = flagValue(args, "cwd") ?? process.cwd();
270
+ const operations = parseOperations(args, cwd);
271
+ let content = readTemplateFile(file, cwd);
272
+ for (const [index, input] of operations.entries()) {
273
+ const result = applyOperation(content, {
274
+ operation: input.operation,
275
+ data: input.data ?? {},
276
+ timestamp: Date.now()
277
+ });
278
+ if (!result.ok) throw new UsageError(`Operation ${index + 1} (${String(input.operation)}) was rejected: ${result.error}`);
279
+ content = result.content;
280
+ }
281
+ const { valid, errors } = validateTemplate(content);
282
+ if (!valid) throw new InvalidTemplateError(`The edited template is not structurally valid (${errors.length} error(s)); ${file} was not written.`, errors);
283
+ const path = writeTemplateFile(file, content, cwd);
284
+ emit({
285
+ applied: operations.length,
286
+ file: path
287
+ }, () => `Applied ${operations.length} operation(s) to ${path}`);
288
+ return EXIT.ok;
289
+ }
290
+ //#endregion
291
+ //#region src/cli/commands/import.ts
292
+ const FORMATS = {
293
+ unlayer: {
294
+ pkg: "@templatical/import-unlayer",
295
+ fn: "convertUnlayerTemplate",
296
+ input: "json"
297
+ },
298
+ beefree: {
299
+ pkg: "@templatical/import-beefree",
300
+ fn: "convertBeeFreeTemplate",
301
+ input: "json"
302
+ },
303
+ stripo: {
304
+ pkg: "@templatical/import-stripo",
305
+ fn: "convertStripoTemplate",
306
+ input: "stripo"
307
+ },
308
+ topol: {
309
+ pkg: "@templatical/import-topol",
310
+ fn: "convertTopolTemplate",
311
+ input: "json"
312
+ },
313
+ chamaileon: {
314
+ pkg: "@templatical/import-chamaileon",
315
+ fn: "convertChamaileonTemplate",
316
+ input: "json"
317
+ },
318
+ "easy-email-pro": {
319
+ pkg: "@templatical/import-easy-email-pro",
320
+ fn: "convertEasyEmailProTemplate",
321
+ input: "json"
322
+ },
323
+ mjml: {
324
+ pkg: "@templatical/import-mjml",
325
+ fn: "convertMjmlTemplate",
326
+ input: "text"
327
+ },
328
+ html: {
329
+ pkg: "@templatical/import-html",
330
+ fn: "convertHtmlTemplate",
331
+ input: "text"
332
+ }
333
+ };
334
+ /** Strip a tag and its contents, so class scanning never reads CSS or script. */
335
+ function withoutElements(html, tag) {
336
+ const open = `<${tag}`;
337
+ const close = `</${tag}`;
338
+ const lower = html.toLowerCase();
339
+ let out = "";
340
+ let pos = 0;
341
+ while (pos < html.length) {
342
+ const start = lower.indexOf(open, pos);
343
+ if (start === -1) {
344
+ out += html.slice(pos);
345
+ break;
346
+ }
347
+ const next = lower[start + open.length];
348
+ if (next !== void 0 && /[a-z0-9-]/.test(next)) {
349
+ out += html.slice(pos, start + open.length);
350
+ pos = start + open.length;
351
+ continue;
352
+ }
353
+ out += html.slice(pos, start);
354
+ const gt = html.indexOf(">", start);
355
+ if (gt === -1) break;
356
+ const closeAt = lower.indexOf(close, gt + 1);
357
+ if (closeAt === -1) break;
358
+ const closeGt = html.indexOf(">", closeAt);
359
+ if (closeGt === -1) break;
360
+ out += " ";
361
+ pos = closeGt + 1;
362
+ }
363
+ return out;
364
+ }
365
+ /** Every class token in the markup, ignoring <style> and <script> contents. */
366
+ function markupClassTokens(html) {
367
+ const stripped = withoutElements(withoutElements(html, "style"), "script");
368
+ const tokens = [];
369
+ const re = /\bclass\s*=\s*(["'])([^"']*)\1/gi;
370
+ let m;
371
+ while (m = re.exec(stripped)) tokens.push(...m[2].trim().split(/\s+/).filter(Boolean));
372
+ return tokens;
373
+ }
374
+ /** Stripo's own class prefixes, which survive both of its export shapes. */
375
+ function looksLikeStripoHtml(html) {
376
+ if (typeof html !== "string" || html.trim().length === 0) return false;
377
+ const tokens = markupClassTokens(html);
378
+ if (tokens.some((t) => t === "esd-stripe" || t === "esd-structure" || t === "esd-container-frame" || t.startsWith("esd-block-"))) return true;
379
+ return tokens.some((t) => t === "es-wrapper" || t === "es-content-body" || t === "es-header-body");
380
+ }
381
+ /** Easy Email Pro marks its own nodes `standard-*`; OSS Easy Email does not. */
382
+ function hasStandardType(node) {
383
+ if (!node || typeof node !== "object") return false;
384
+ const n = node;
385
+ if (typeof n.type === "string" && n.type.startsWith("standard-")) return true;
386
+ if (Array.isArray(n.children)) return n.children.some(hasStandardType);
387
+ return false;
388
+ }
389
+ /** Guess the source format, or null when the caller must pass --format. */
390
+ function detectFormat(fileName, content) {
391
+ const ext = extname(fileName).toLowerCase();
392
+ const trimmed = content.trimStart();
393
+ if (ext === ".mjml") return "mjml";
394
+ if (/^<(\?xml[^>]*\?>\s*)?<?\s*mjml[\s>]/i.test(trimmed)) return "mjml";
395
+ if (/^<\s*mj-body[\s>]/i.test(trimmed)) return "mjml";
396
+ if (looksLikeStripoHtml(content)) return "stripo";
397
+ if (ext === ".html" || ext === ".htm") return "html";
398
+ if (trimmed.startsWith("<")) return "html";
399
+ if (trimmed.startsWith("{")) {
400
+ let obj;
401
+ try {
402
+ obj = JSON.parse(content);
403
+ } catch {
404
+ return null;
405
+ }
406
+ if (obj?.body?.rows) return "unlayer";
407
+ if (obj?.page?.rows) return "beefree";
408
+ if (obj?.tagName === "mj-global-style") return "topol";
409
+ if (obj?.body?.type === "body") return "chamaileon";
410
+ const page = obj?.content?.type === "page" ? obj.content : obj?.type === "page" ? obj : null;
411
+ if (page && hasStandardType(page)) return "easy-email-pro";
412
+ if (typeof obj?.html === "string" && looksLikeStripoHtml(obj.html)) return "stripo";
413
+ return null;
414
+ }
415
+ return null;
416
+ }
417
+ /** Split a Stripo source into html + css, whichever shape it arrived in. */
418
+ function unpackStripoSource(source) {
419
+ if (source.trimStart().startsWith("{")) try {
420
+ const obj = JSON.parse(source);
421
+ if (typeof obj?.html === "string") return {
422
+ html: obj.html,
423
+ css: typeof obj.css === "string" ? obj.css : void 0
424
+ };
425
+ } catch {}
426
+ return { html: source };
427
+ }
428
+ /** The stylesheet a plugin host wrote beside an HTML export, if there is one. */
429
+ function siblingCss(sourcePath, source) {
430
+ if (source.trimStart().startsWith("{")) return void 0;
431
+ const cssPath = sourcePath.replace(/\.[^.]+$/, ".css");
432
+ if (cssPath === sourcePath || !existsSync(cssPath)) return void 0;
433
+ return readFileSync(cssPath, "utf8");
434
+ }
435
+ /**
436
+ * Status counts from a converter's report. Derived from `report.entries` (every
437
+ * entry carries a `status`) rather than each package's own `summary` shape, so
438
+ * it works identically across all three converters and any future one.
439
+ */
440
+ function summarizeReport(report) {
441
+ const r = report;
442
+ const counts = {
443
+ total: 0,
444
+ converted: 0,
445
+ approximated: 0,
446
+ htmlFallback: 0,
447
+ skipped: 0,
448
+ warnings: r?.warnings ?? []
449
+ };
450
+ for (const e of r?.entries ?? []) {
451
+ counts.total++;
452
+ if (e.status === "converted") counts.converted++;
453
+ else if (e.status === "approximated") counts.approximated++;
454
+ else if (e.status === "html-fallback") counts.htmlFallback++;
455
+ else if (e.status === "skipped") counts.skipped++;
456
+ }
457
+ return counts;
458
+ }
459
+ async function listFormats(cwd) {
460
+ const formats = [];
461
+ for (const [format, spec] of Object.entries(FORMATS)) formats.push({
462
+ format,
463
+ package: spec.pkg,
464
+ available: await resolveOptional(spec.pkg, cwd) !== null
465
+ });
466
+ emit({ formats }, () => formats.map((f) => ` ${f.format.padEnd(14)} ${f.package}${f.available ? "" : " (not installed)"}`).join("\n"));
467
+ return EXIT.ok;
468
+ }
469
+ async function runImport(args) {
470
+ const cwd = flagValue(args, "cwd") ?? process.cwd();
471
+ if (args.flags["list-formats"]) return listFormats(cwd);
472
+ const file = args.positional[0];
473
+ if (!file) throw new UsageError("import needs a source file.");
474
+ const path = resolveFrom(file, cwd);
475
+ let source;
476
+ try {
477
+ source = readFileSync(path, "utf8");
478
+ } catch {
479
+ throw new UsageError(`Could not read ${file}`);
480
+ }
481
+ const known = Object.keys(FORMATS).join(", ");
482
+ const requested = flagValue(args, "format");
483
+ if (requested && !FORMATS[requested]) throw new UsageError(`Unknown --format "${requested}". Known formats: ${known}.`);
484
+ const format = requested ?? detectFormat(path, source);
485
+ if (!format) throw new UsageError(`Could not detect the format of ${file}. Pass --format with one of: ${known}.`);
486
+ const spec = FORMATS[format];
487
+ const mod = await resolveOptional(spec.pkg, cwd);
488
+ if (!mod) throw new MissingDependencyError(spec.pkg, `Importing ${format} needs ${spec.pkg}, which isn't installed.\n npm install ${spec.pkg}`);
489
+ const convert = mod[spec.fn];
490
+ let content;
491
+ let report;
492
+ if (spec.input === "stripo") {
493
+ const unpacked = unpackStripoSource(source);
494
+ const css = unpacked.css ?? siblingCss(path, source);
495
+ ({content, report} = convert(unpacked.html, css ? { css } : void 0));
496
+ } else {
497
+ const input = spec.input === "json" ? JSON.parse(source) : source;
498
+ ({content, report} = convert(input));
499
+ }
500
+ const outName = flagValue(args, "out") ?? basename(path, extname(path));
501
+ const written = writeTemplateFile(`${WORKING_DIR}/${outName}.json`, content, cwd);
502
+ const counts = summarizeReport(report);
503
+ const lossy = counts.htmlFallback + counts.skipped > 0;
504
+ emit({
505
+ format,
506
+ file: written,
507
+ report: counts
508
+ }, () => [
509
+ `Imported ${format} to ${written}`,
510
+ ` ${counts.converted} converted, ${counts.approximated} approximated, ${counts.htmlFallback} html fallback, ${counts.skipped} skipped`,
511
+ ...counts.warnings.map((w) => ` ! ${w}`),
512
+ lossy ? " Import is lossy. Open it in live mode and refine the fallback blocks." : ""
513
+ ].filter(Boolean).join("\n"));
514
+ return EXIT.ok;
515
+ }
516
+ //#endregion
517
+ //#region src/cli/commands/live.ts
518
+ /**
519
+ * The first title block's text in document order, as a hint for `list`.
520
+ *
521
+ * It must descend into a section's columns, not scan top-level blocks only:
522
+ * templates put their content inside sections (that is the documented
523
+ * structure), so a top-level scan finds nothing for a real template. Measured
524
+ * across all five of the templatical skill's examples — event-invite, newsletter,
525
+ * product-sale, receipt, welcome — none has a top-level title block, so a
526
+ * shallow version of this returns null every time and the hint is dead code.
527
+ */
528
+ function titleHint(content) {
529
+ return findTitle(content?.blocks);
530
+ }
531
+ function findTitle(blocks) {
532
+ if (!Array.isArray(blocks)) return null;
533
+ for (const block of blocks) {
534
+ if (block?.type === "title" && typeof block.content === "string") return block.content;
535
+ if (block?.type === "section" && Array.isArray(block.children)) for (const column of block.children) {
536
+ const hit = findTitle(column);
537
+ if (hit) return hit;
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ function runList(args) {
543
+ const cwd = flagValue(args, "cwd") ?? process.cwd();
544
+ const templates = listWorkingFiles(cwd).map((name) => ({
545
+ name,
546
+ title: titleHint(readWorkingFile(join(cwd, WORKING_DIR, name)))
547
+ }));
548
+ emit({ templates }, () => templates.length === 0 ? `No templates in ${WORKING_DIR}/` : templates.map((t) => ` ${t.name}${t.title ? ` - ${t.title}` : ""}`).join("\n"));
549
+ return EXIT.ok;
550
+ }
551
+ async function postTo(port, path) {
552
+ return fetch(`http://localhost:${port}${path}`, {
553
+ method: "POST",
554
+ headers: { "content-type": "application/json" }
555
+ });
556
+ }
557
+ async function runLive(args) {
558
+ const sub = args.positional[0];
559
+ const cwd = resolveFrom(flagValue(args, "cwd") ?? ".", process.cwd());
560
+ if (sub === "reload" || sub === "stop") {
561
+ const info = readPidfile(cwd);
562
+ if (!info || !processAlive(info.pid)) throw new UsageError(`No live server is running here (no live pidfile at ${WORKING_DIR}/live-server.pid).`);
563
+ if (sub === "reload") {
564
+ const body = await (await postTo(info.port, "/reload")).json().catch(() => ({}));
565
+ emit({
566
+ reloaded: true,
567
+ clients: body.clients ?? 0
568
+ }, () => `Pushed the working file to ${body.clients ?? 0} connected page(s).`);
569
+ return EXIT.ok;
570
+ }
571
+ try {
572
+ process.kill(info.pid, "SIGTERM");
573
+ } catch {}
574
+ rmSync(pidfilePath(cwd), { force: true });
575
+ emit({ stopped: true }, () => "Stopped the live server.");
576
+ return EXIT.ok;
577
+ }
578
+ if (sub !== void 0) throw new UsageError(`Unknown "live ${sub}". Use \`live\`, \`live reload\` or \`live stop\`.`);
579
+ const portFlag = flagValue(args, "port");
580
+ const preferredPort = portFlag ? Number(portFlag) : DEFAULT_PORT;
581
+ const existing = readPidfile(cwd);
582
+ if (existing && processAlive(existing.pid)) {
583
+ emit({
584
+ url: `http://localhost:${existing.port}/`,
585
+ pid: existing.pid,
586
+ alreadyRunning: true
587
+ }, () => `Live server already running (pid ${existing.pid}) at http://localhost:${existing.port}/`);
588
+ return EXIT.ok;
589
+ }
590
+ if (existing) rmSync(pidfilePath(cwd), { force: true });
591
+ const file = flagValue(args, "file");
592
+ const handle = await startBridgePreferring({
593
+ cwd,
594
+ preferredPort,
595
+ file
596
+ });
597
+ mkdirSync(dirname(pidfilePath(cwd)), { recursive: true });
598
+ writeFileSync(pidfilePath(cwd), JSON.stringify({
599
+ pid: process.pid,
600
+ port: handle.port
601
+ }), "utf8");
602
+ const cleanup = () => {
603
+ rmSync(pidfilePath(cwd), { force: true });
604
+ handle.close().finally(() => process.exit(EXIT.ok));
605
+ };
606
+ process.on("SIGINT", cleanup);
607
+ process.on("SIGTERM", cleanup);
608
+ emit({
609
+ url: handle.url,
610
+ port: handle.port,
611
+ preferredPort,
612
+ fellBack: handle.fellBack,
613
+ workingFile: handle.workingPath
614
+ }, () => [
615
+ `Templatical live preview running at ${handle.url}`,
616
+ handle.fellBack ? `(port ${preferredPort} was busy - using ${handle.port})` : "",
617
+ `Working file: ${handle.workingPath}`
618
+ ].filter(Boolean).join("\n"));
619
+ if (args.flags["no-open"]) note(`Open ${handle.url} in a browser.`);
620
+ else {
621
+ note(`Opening ${handle.url} in your default browser...`);
622
+ openBrowser(handle.url);
623
+ }
624
+ note("After writing the working file, run: templatical live reload");
625
+ return new Promise(() => {});
626
+ }
627
+ //#endregion
628
+ //#region src/bin.ts
629
+ const USAGE = `templatical <command> [options]
630
+
631
+ validate <file> structural + quality lint
632
+ schema [--out <file>] print the block JSON Schema
633
+ render <file> [--format mjml|html] [-o <file>] render to MJML or HTML
634
+ edit <file> --op '<json>' | --ops <file> apply operations, write the result
635
+ import <file> [--format <fmt>] | --list-formats convert a design to Templatical JSON
636
+ live [--file <f>] [--port <n>] [--cwd <d>] [--no-open]
637
+ live reload | live stop
638
+ list working files in .templatical/
639
+
640
+ Options:
641
+ --json machine-readable output on stdout
642
+ `;
643
+ async function main(argv) {
644
+ const args = parseArgs(argv);
645
+ setJsonMode(args.json);
646
+ switch (args.command) {
647
+ case "validate": return runValidate(args);
648
+ case "schema": return runSchema(args);
649
+ case "render": return await runRender(args);
650
+ case "edit": return runEdit(args);
651
+ case "import": return await runImport(args);
652
+ case "live": return await runLive(args);
653
+ case "list": return runList(args);
654
+ case "help":
655
+ note(USAGE);
656
+ return EXIT.ok;
657
+ case void 0:
658
+ if (args.flags.help === true || args.flags.h === true) {
659
+ note(USAGE);
660
+ return EXIT.ok;
661
+ }
662
+ note(USAGE);
663
+ return EXIT.usage;
664
+ default:
665
+ note(`Unknown command "${args.command}".\n\n${USAGE}`);
666
+ return EXIT.usage;
667
+ }
668
+ }
669
+ function isEntryPoint() {
670
+ const argv1 = process.argv[1];
671
+ if (!argv1) return false;
672
+ try {
673
+ return pathToFileURL(realpathSync(argv1)).href === import.meta.url;
674
+ } catch {
675
+ return false;
676
+ }
677
+ }
678
+ if (isEntryPoint()) main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
679
+ if (err instanceof InvalidTemplateError) {
680
+ note(err.message);
681
+ for (const e of err.errors) note(` - ${e}`);
682
+ process.exit(EXIT.invalid);
683
+ }
684
+ if (err instanceof MissingDependencyError) {
685
+ note(err.message);
686
+ process.exit(EXIT.missingDep);
687
+ }
688
+ if (err instanceof UsageError) {
689
+ note(err.message);
690
+ process.exit(EXIT.usage);
691
+ }
692
+ note(`Unexpected error: ${err?.message ?? String(err)}`);
693
+ process.exit(EXIT.usage);
694
+ });
695
+ //#endregion
696
+ export { isEntryPoint, main };
697
+
698
+ //# sourceMappingURL=bin.js.map