@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/LICENSE +109 -0
- package/README.md +48 -0
- package/dist/bin.d.ts +5 -0
- package/dist/bin.js +698 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +2 -0
- package/dist/live/index.d.ts +87 -0
- package/dist/live/index.js +279 -0
- package/dist/live/index.js.map +1 -0
- package/dist/src-unNYVMlC.js +1278 -0
- package/dist/src-unNYVMlC.js.map +1 -0
- package/live/index.html +978 -0
- package/package.json +107 -0
- package/schema.json +1504 -0
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
|