squinch 0.0.1 → 0.1.1

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/index.js ADDED
@@ -0,0 +1,538 @@
1
+ // squinch CLI — check / render / icons / init / skill / watch.
2
+ // Exit codes: 0 ok, 1 diagnostics-or-stale, 2 usage error.
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
4
+ import { join, relative, isAbsolute } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { buildProject, renderProject, formatDiagnostics, validateSVG, searchIcons, themes, packInfo, diffProjects, formatDiff, formatDiffMarkdown, exportHTML, } from "@squinch/core";
8
+ import { parseArgs, str } from "./args.js";
9
+ import { SKILL_MD } from "./skill.generated.js";
10
+ import { loadInput } from "./project.js";
11
+ import { isGitRepo, loadInputAtRef } from "./git.js";
12
+ import { toPosix } from "./paths.js";
13
+ import { watchPaths } from "./watch.js";
14
+ import { createRequire } from "node:module";
15
+ /** Read from our own manifest, never retyped. The determinism contract pins
16
+ * output to a *tool version*, and `squinch.lock` records it — a literal here
17
+ * would keep claiming 0.0.0 through every release, so a reader could not tell
18
+ * which renderer produced their SVGs, which is the one thing the lockfile
19
+ * exists to answer. `src/` and `dist/` both sit one level under the package
20
+ * root, so this resolves the same whether run through tsx or from the build. */
21
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
22
+ const THEMES = ["light", "dark"];
23
+ const USAGE = `squinch ${VERSION} — architecture diagrams as code
24
+
25
+ Usage
26
+ squinch check <path> [--format json] parse + lint (dir = project)
27
+ squinch render <path> [options] render SVG
28
+ squinch icons search <query> [--pack aws] find icon ids
29
+ squinch init [dir] scaffold a starter project
30
+ squinch skill [dir] [options] install the agent skill (SKILL.md)
31
+ squinch watch <path> [options] re-render on change
32
+ squinch diff [<old>] [<new>] [options] what changed in the architecture
33
+
34
+ Skill options
35
+ --global install for you (~/.agents/skills) instead of the project
36
+ --claude also write .claude/skills/ even if no CLAUDE.md is found
37
+ --print print SKILL.md to stdout and write nothing
38
+
39
+ Diff options
40
+ --base <ref> compare against a git ref (default: HEAD)
41
+ --format <fmt> text | json | markdown (default: text)
42
+ --fail-on <what> exit 1 on change: structural | any
43
+
44
+ Render options
45
+ --view <name> view to render (default: first)
46
+ --theme <name> ${Object.keys(themes).join(" | ")} (default: the view's, else dark)
47
+ --adaptive one SVG carrying both palettes, switched by the reader's
48
+ prefers-color-scheme (svg only)
49
+ -o <file> output path (default: stdout; .png rasterizes,
50
+ .html builds the interactive export)
51
+ --format <fmt> svg | png | html (default: inferred from -o)
52
+ --views <which> html only: all (default) | declared — 'all' bundles the
53
+ automatic view every container gets, so every card that
54
+ zooms in the playground zooms in the export; 'declared'
55
+ is smaller but leaves those cards dead
56
+ --themes <a,b> html only: palettes to bundle (default: the theme and its
57
+ prefers-color-scheme counterpart)
58
+ --no-flow-steps html only: skip the per-hop frames a 'show flow' view needs
59
+ for stepping in presentation mode
60
+ --scale <n> png only: multiply the diagram's natural size
61
+ --width <px> png only: exact output width (height follows)
62
+ --background <c> png only: flatten onto a colour. Every theme paints its own
63
+ canvas, so this only shows through where one does not
64
+ --sync write every view × theme next to the source, refresh
65
+ squinch.lock, and print a README <picture> snippet
66
+ --check verify committed SVGs match the source (CI gate)
67
+ `;
68
+ /**
69
+ * Semantic diff. Three shapes, in the order people reach for them:
70
+ * squinch diff working tree vs HEAD (or --base <ref>)
71
+ * squinch diff <path> that project, working tree vs the ref
72
+ * squinch diff <old> <new> two paths on disk
73
+ */
74
+ function cmdDiff(positionals, flags) {
75
+ const format = str(flags["format"]) ?? "text";
76
+ if (!["text", "json", "markdown"].includes(format))
77
+ throw new Error(`unknown --format \`${format}\` — use text | json | markdown`);
78
+ const failOn = str(flags["fail-on"]);
79
+ if (failOn && !["structural", "any"].includes(failOn))
80
+ throw new Error(`unknown --fail-on \`${failOn}\` — use structural | any`);
81
+ let before, after, subject;
82
+ if (positionals.length >= 2) {
83
+ before = loadInput(positionals[0]).files;
84
+ after = loadInput(positionals[1]).files;
85
+ subject = `${positionals[0]} → ${positionals[1]}`;
86
+ }
87
+ else {
88
+ const path = positionals[0] ?? ".";
89
+ const ref = str(flags["base"]) ?? "HEAD";
90
+ if (!isGitRepo(process.cwd()))
91
+ throw new Error("not a git repository — pass two paths instead: squinch diff <old> <new>");
92
+ const at = loadInputAtRef(path, ref);
93
+ if (!at)
94
+ throw new Error(`\`${path}\` does not exist at ${ref} — it looks new`);
95
+ before = at;
96
+ after = loadInput(path).files;
97
+ subject = `${ref} → working tree`;
98
+ }
99
+ const result = diffProjects(before, after);
100
+ if (format === "json")
101
+ console.log(JSON.stringify({ subject, ...result }, null, 2));
102
+ else if (format === "markdown")
103
+ console.log(formatDiffMarkdown(result));
104
+ else
105
+ console.log(formatDiff(result));
106
+ if (failOn === "any" && !result.same)
107
+ return 1;
108
+ if (failOn === "structural" && result.structural > 0)
109
+ return 1;
110
+ return 0;
111
+ }
112
+ function reportDiagnostics(diags, json, views) {
113
+ if (json) {
114
+ const errors = diags.filter((d) => d.severity === "error").length;
115
+ console.log(JSON.stringify({
116
+ ok: errors === 0,
117
+ errors,
118
+ warnings: diags.length - errors,
119
+ diagnostics: diags,
120
+ // The quality bar asks authors to render every view they declared,
121
+ // and nothing told them what those were: every cold-run agent ended
122
+ // up copying the file elsewhere and running `--sync` to read the
123
+ // filenames back off disk.
124
+ ...(views ? { views } : {}),
125
+ }, null, 2));
126
+ return;
127
+ }
128
+ if (diags.length)
129
+ console.error(formatDiagnostics(diags));
130
+ }
131
+ /** Shortest readable form: relative when inside cwd, absolute otherwise —
132
+ * always POSIX, because this is what the Action prints into a pull request. */
133
+ function display(file) {
134
+ const rel = relative(process.cwd(), file);
135
+ return toPosix(rel.startsWith("..") || isAbsolute(rel) ? file : rel);
136
+ }
137
+ function hash(s) {
138
+ return createHash("sha256").update(s).digest("hex").slice(0, 16);
139
+ }
140
+ /**
141
+ * Views to render. Containers all get an auto view (so the SPA can zoom
142
+ * anywhere), but `--sync` commits only what the author declared — otherwise
143
+ * every container would spawn files nobody asked for. With no explicit views,
144
+ * the auto ones are all we have.
145
+ */
146
+ function viewNames(input) {
147
+ const built = buildProject(input.files);
148
+ const explicit = built.model.views.filter((v) => !v.auto).map((v) => v.name);
149
+ if (explicit.length)
150
+ return explicit;
151
+ return built.model.views.map((v) => v.name); // may be empty — see SOLE
152
+ }
153
+ /**
154
+ * Filename label for a project that declares no views at all — a diagram of
155
+ * top-level components with no `view` block and no container to auto-generate
156
+ * one. It is a *label*, never a view name: this list used to end in a literal
157
+ * `["default"]`, which every caller then passed to the renderer as if the
158
+ * author had written `view default`. That was harmless while an unknown view
159
+ * silently fell back to the implicit one; once round 3 made an unknown `--view`
160
+ * a hard error (rightly — a typo used to hand you a different diagram at exit
161
+ * 0), the sentinel turned into a landmine. `check` failed on a perfectly valid
162
+ * file with "unknown view `default`, this project declares no views", which is
163
+ * the tool contradicting itself in a single sentence. Five of twenty cold
164
+ * agents hit it.
165
+ */
166
+ const SOLE = "default";
167
+ /** What to render, as (filename label, view to ask the renderer for). */
168
+ function targets(input) {
169
+ const names = viewNames(input);
170
+ return names.length
171
+ ? names.map((label) => ({ label, view: label }))
172
+ : [{ label: SOLE, view: undefined }];
173
+ }
174
+ async function renderOne(input, view,
175
+ /** undefined = let the file's own `theme` decide, then core's default.
176
+ * Passing "light" here unconditionally is what made a declared `theme dark`
177
+ * a no-op through the CLI: core treats an explicit theme as the *override*
178
+ * (dsl-coverage.test.ts pins that precedence), so the CLI was overriding on
179
+ * every render. `--sync` is unaffected — it asks for each theme by name. */
180
+ theme, adaptive = false) {
181
+ const r = await renderProject(input.files, {
182
+ ...(view ? { view } : {}), ...(theme ? { theme } : {}), adaptive,
183
+ });
184
+ if (!r.ok) {
185
+ reportDiagnostics(r.diagnostics, false);
186
+ throw new Error(`render failed for view \`${view ?? SOLE}\``);
187
+ }
188
+ const valid = validateSVG(r.svg);
189
+ if (!valid.ok)
190
+ throw new Error(`internal: produced invalid SVG (${valid.error})`);
191
+ if (r.diagnostics.length)
192
+ reportDiagnostics(r.diagnostics, false);
193
+ return r.svg;
194
+ }
195
+ const outName = (base, view, theme) => `${base}.${view}.${theme}.svg`;
196
+ async function cmdCheck(path, json) {
197
+ const input = loadInput(path);
198
+ const built = buildProject(input.files);
199
+ // layout-stage diagnostics too: render every view
200
+ const all = [...built.diagnostics];
201
+ if (built.ok)
202
+ for (const { view } of targets(input))
203
+ for (const theme of ["light"]) {
204
+ const r = await renderProject(input.files, { ...(view ? { view } : {}), theme });
205
+ for (const d of r.diagnostics)
206
+ if (!all.some((x) => x.message === d.message && x.loc.from === d.loc.from))
207
+ all.push(d);
208
+ }
209
+ const views = built.ok
210
+ ? built.model.views.map((v) => ({ name: v.name, scope: v.scope, auto: v.auto }))
211
+ : [];
212
+ reportDiagnostics(all, json, views);
213
+ const errors = all.filter((d) => d.severity === "error").length;
214
+ const warnings = all.length - errors;
215
+ if (!json) {
216
+ console.error(errors === 0 && warnings === 0
217
+ ? `${input.files.length} file(s) OK`
218
+ : `\n${errors} error(s), ${warnings} warning(s)`);
219
+ if (views.length)
220
+ console.error(`views: ${views.map((v) => (v.auto ? `${v.name} (auto)` : v.name)).join(", ")}`);
221
+ }
222
+ return errors ? 1 : 0;
223
+ }
224
+ async function cmdRender(path, flags) {
225
+ const input = loadInput(path);
226
+ const theme = str(flags.theme);
227
+ const view = str(flags.view);
228
+ if (flags.sync || flags.check) {
229
+ const views = targets(input);
230
+ const stale = [];
231
+ const crlfOnDisk = [];
232
+ const written = [];
233
+ const lock = {};
234
+ for (const { label, view } of views)
235
+ for (const t of THEMES) {
236
+ const svg = await renderOne(input, view, t);
237
+ const file = join(input.dir, outName(input.base, label, t));
238
+ lock[outName(input.base, label, t)] = hash(svg);
239
+ if (flags.check) {
240
+ // Compare content, not bytes-on-disk. Squinch only ever writes LF
241
+ // (asserted per-golden and across the corpus in core), so normalizing
242
+ // here can mask exactly one difference: one the renderer could not
243
+ // have produced. Comparing raw would make this command unusable on a
244
+ // Windows checkout without .gitattributes — every diagram reads
245
+ // stale, `--sync` rewrites them as LF, git cleans that back to the
246
+ // identical blob, the commit is empty, and the next checkout is CRLF
247
+ // again. The advisory below keeps the byte claim visible instead.
248
+ const onDisk = existsSync(file) ? readFileSync(file, "utf8") : undefined;
249
+ if (onDisk === undefined || onDisk.replace(/\r\n/g, "\n") !== svg)
250
+ stale.push(display(file));
251
+ else if (onDisk.includes("\r"))
252
+ crlfOnDisk.push(display(file));
253
+ }
254
+ else {
255
+ writeFileSync(file, svg);
256
+ written.push(display(file));
257
+ }
258
+ }
259
+ if (flags.check) {
260
+ if (crlfOnDisk.length)
261
+ console.error(`note: ${crlfOnDisk.length} committed diagram(s) have CRLF line endings on disk:\n` +
262
+ crlfOnDisk.map((f) => ` ${f}`).join("\n") +
263
+ `\n\nsquinch always writes LF — your checkout is converting them. Add a\n` +
264
+ `.gitattributes with \`*.svg text eol=lf\` and re-clone (or run\n` +
265
+ `\`git add --renormalize .\`) to keep them byte-identical across platforms.\n`);
266
+ if (stale.length) {
267
+ console.error(`${stale.length} committed diagram(s) are stale or missing:\n` +
268
+ stale.map((f) => ` ${f}`).join("\n") +
269
+ `\n\nrun \`squinch render ${path} --sync\` and commit the result`);
270
+ return 1;
271
+ }
272
+ console.error(`${views.length * THEMES.length} diagram(s) up to date`);
273
+ return 0;
274
+ }
275
+ writeFileSync(join(input.dir, "squinch.lock"), JSON.stringify({ version: VERSION, files: lock }, null, 2) + "\n");
276
+ console.error(written.map((f) => `wrote ${f}`).join("\n"));
277
+ console.log(`\n<!-- README snippet -->`);
278
+ for (const { label } of views)
279
+ console.log(`<picture>\n` +
280
+ ` <source media="(prefers-color-scheme: dark)" srcset="${outName(input.base, label, "dark")}">\n` +
281
+ ` <img alt="${label}" src="${outName(input.base, label, "light")}">\n` +
282
+ `</picture>`);
283
+ return 0;
284
+ }
285
+ const adaptive = !!flags.adaptive;
286
+ const out = str(flags.o) ?? str(flags.output);
287
+ // the format follows the output extension, so `-o d.png` and `-o d.html`
288
+ // both just work; the explicit --format is for piping, and for saying so
289
+ // when both are present.
290
+ const format = str(flags.format) ??
291
+ (out?.endsWith(".png") ? "png" : out?.endsWith(".html") ? "html" : "svg");
292
+ if (format !== "svg" && format !== "png" && format !== "html")
293
+ throw new Error(`unknown --format \`${format}\` — use svg | png | html`);
294
+ if (format === "html") {
295
+ // Every view in one file, with a viewer that dives between them — the
296
+ // altitude experience, portable. Handled before renderOne because that
297
+ // renders exactly one view, and this artifact is the whole set.
298
+ if (!out)
299
+ throw new Error("html is a document — give it a destination, e.g. -o diagram.html");
300
+ // `--adaptive` folds two palettes into ONE svg using document-global class
301
+ // names (sq-t0…). Inline several of those in one document and the second
302
+ // view's classes repaint the first. The export carries real palettes and a
303
+ // real switch instead, which is strictly more than adaptive buys here.
304
+ if (adaptive)
305
+ throw new Error("--adaptive has no meaning for html — an interactive export already carries " +
306
+ "every palette as a real theme switch, and folding two into one SVG makes its " +
307
+ "class names collide across views. Drop --adaptive, or use --themes");
308
+ const themesFlag = str(flags.themes);
309
+ const viewsFlag = str(flags.views);
310
+ if (viewsFlag && viewsFlag !== "declared" && viewsFlag !== "all")
311
+ throw new Error(`unknown --views \`${viewsFlag}\` — use declared | all`);
312
+ const r = await exportHTML(input.files, {
313
+ ...(view ? { view } : {}),
314
+ ...(theme ? { themes: [theme] } : {}),
315
+ ...(themesFlag ? { themes: themesFlag.split(",").map((t) => t.trim()) } : {}),
316
+ ...(viewsFlag ? { views: viewsFlag } : {}),
317
+ ...(flags["no-flow-steps"] ? { flowSteps: false } : {}),
318
+ });
319
+ if (!r.ok || !r.html) {
320
+ reportDiagnostics(r.diagnostics, false);
321
+ throw new Error("interactive export failed");
322
+ }
323
+ writeFileSync(out, r.html);
324
+ const kb = Math.round(r.manifest.bytes / 1024);
325
+ const walked = Object.entries(r.manifest.flows);
326
+ // stderr, like every other `wrote`, so stdout stays pipeable
327
+ console.error(`wrote ${out} — ${r.manifest.views.length} view(s) × ${r.manifest.themes.length} palette(s), ` +
328
+ `${r.manifest.renders} renders, ${kb} KB` +
329
+ (walked.length ? ` (${walked.map(([v, n]) => `${v}: ${n} hops`).join(", ")})` : ""));
330
+ return 0;
331
+ }
332
+ const svg = await renderOne(input, view ?? viewNames(input)[0], theme, adaptive);
333
+ if (format === "png") {
334
+ if (!out)
335
+ throw new Error("png is binary — give it a destination, e.g. -o diagram.png");
336
+ // A raster has one palette by definition, and resvg would silently bake in
337
+ // the light one. Say so rather than hand back a file that ignored the flag.
338
+ if (adaptive)
339
+ throw new Error("--adaptive has no meaning for png — a raster carries one palette; render two, or drop --adaptive");
340
+ const { svgToPng } = await import("./raster.js");
341
+ const scale = str(flags.scale);
342
+ const width = str(flags.width);
343
+ if (scale && width)
344
+ throw new Error("--scale and --width both set — pick one");
345
+ const n = (v, what) => {
346
+ const parsed = Number(v);
347
+ if (!Number.isFinite(parsed) || parsed <= 0)
348
+ throw new Error(`--${what} must be a positive number`);
349
+ return parsed;
350
+ };
351
+ const png = svgToPng(svg, {
352
+ width: width
353
+ ? n(width, "width")
354
+ : scale
355
+ ? Math.round(svgWidth(svg) * n(scale, "scale"))
356
+ : undefined,
357
+ background: str(flags.background),
358
+ });
359
+ writeFileSync(out, png);
360
+ console.error(`wrote ${out}`);
361
+ return 0;
362
+ }
363
+ if (out) {
364
+ writeFileSync(out, svg);
365
+ console.error(`wrote ${out}`);
366
+ }
367
+ else
368
+ console.log(svg.trimEnd());
369
+ return 0;
370
+ }
371
+ /** The root <svg width="…">, which the renderer always emits in px. */
372
+ function svgWidth(svg) {
373
+ const m = svg.match(/<svg[^>]*\swidth="(\d+)"/);
374
+ if (!m)
375
+ throw new Error("could not read the diagram's width");
376
+ return Number(m[1]);
377
+ }
378
+ async function cmdIcons(positionals, flags) {
379
+ const [sub, ...rest] = positionals;
380
+ if (sub !== "search") {
381
+ console.error("usage: squinch icons search <query> [--pack aws]");
382
+ return 2;
383
+ }
384
+ const query = rest.join(" ");
385
+ const hits = searchIcons(query, str(flags.pack));
386
+ if (!hits.length) {
387
+ console.error(`no icons match \`${query}\``);
388
+ return 1;
389
+ }
390
+ // Name the short forms on the row. Search returns one row per icon, so
391
+ // without this an author who was told to write `azure/key-vault` sees only
392
+ // `azure/key-vaults` and concludes the documented name is wrong.
393
+ console.log(hits
394
+ .map((hit) => {
395
+ const [pack, id] = [hit.slice(0, hit.indexOf("/")), hit.slice(hit.indexOf("/") + 1)];
396
+ const short = Object.entries(packInfo(pack)?.aliases ?? {})
397
+ .filter(([, target]) => target === id)
398
+ .map(([alias]) => `${pack}/${alias}`);
399
+ return short.length ? `${hit} (or ${short.join(", ")})` : hit;
400
+ })
401
+ .join("\n"));
402
+ return 0;
403
+ }
404
+ const STARTER = `pack aws
405
+
406
+ system app "My Service" {
407
+ api = aws/api-gateway "API Gateway"
408
+ fn = aws/lambda "Handler"
409
+ db = aws/dynamodb "Table"
410
+
411
+ api -> fn
412
+ fn -> db
413
+ }
414
+
415
+ view app {
416
+ layout {
417
+ rows [api] [fn] [db]
418
+ }
419
+ }
420
+ `;
421
+ function cmdInit(dir) {
422
+ mkdirSync(dir, { recursive: true });
423
+ const file = join(dir, "diagram.squinch");
424
+ if (existsSync(file)) {
425
+ console.error(`${file} already exists — not overwriting`);
426
+ return 1;
427
+ }
428
+ writeFileSync(file, STARTER);
429
+ console.error(`created ${file}\n\nnext:\n squinch render ${file} -o diagram.svg\n squinch render ${file} --sync # commit-ready SVGs + README snippet\n squinch skill # teach your coding agent the language`);
430
+ return 0;
431
+ }
432
+ /**
433
+ * Install the bundled SKILL.md where agents discover skills. Unlike `init`,
434
+ * this *overwrites silently*: the installed file is a squinch-owned artifact
435
+ * version-locked to this CLI, and refreshing it after an upgrade is the point —
436
+ * there is nothing of the user's in it to protect.
437
+ *
438
+ * Both modes always write the cross-agent `.agents/skills/` dir (Cursor, Codex,
439
+ * Gemini CLI, Copilot and friends read it — in the project, and under `$HOME`
440
+ * for the user scope). Claude Code is the one agent that doesn't, so its
441
+ * `.claude/skills/` copy is added when the target shows Claude Code markers —
442
+ * or on request via `--claude`, which an agent that knows who it is can pass
443
+ * for itself.
444
+ */
445
+ function cmdSkill(positionals, flags) {
446
+ if (flags.print) {
447
+ console.log(SKILL_MD);
448
+ return 0;
449
+ }
450
+ if (flags.global && positionals[0])
451
+ throw new Error("--global installs to your home directory — drop the path, or drop the flag");
452
+ const install = (dir) => {
453
+ mkdirSync(dir, { recursive: true });
454
+ const file = join(dir, "SKILL.md");
455
+ writeFileSync(file, SKILL_MD);
456
+ return file;
457
+ };
458
+ // The two scopes differ only in where they root and what announces Claude
459
+ // Code: a `.claude/` dir either way, and a project also has CLAUDE.md — which
460
+ // in a home directory would be a memory file, not a marker that Claude Code
461
+ // is installed.
462
+ const dir = flags.global ? homedir() : (positionals[0] ?? ".");
463
+ const marker = existsSync(join(dir, ".claude")) || (!flags.global && existsSync(join(dir, "CLAUDE.md")));
464
+ const wrote = [install(join(dir, ".agents", "skills", "squinch"))];
465
+ if (marker || flags.claude) {
466
+ wrote.push(install(join(dir, ".claude", "skills", "squinch")));
467
+ if (!flags.claude)
468
+ console.error(flags.global
469
+ ? "detected Claude Code for your account (~/.claude) — installing there too"
470
+ : "detected Claude Code in this project (CLAUDE.md or .claude/) — installing there too");
471
+ }
472
+ for (const file of wrote)
473
+ console.error(`wrote ${file} (squinch ${VERSION})`);
474
+ console.error(`\nthe skill drives \`squinch check\` and \`squinch render\`, so keep squinch on\nPATH (\`npx squinch\` works too) — and re-run this after upgrading to refresh`);
475
+ return 0;
476
+ }
477
+ async function cmdWatch(path, flags) {
478
+ const run = async () => {
479
+ try {
480
+ await cmdRender(path, flags);
481
+ }
482
+ catch (e) {
483
+ console.error(e.message);
484
+ }
485
+ };
486
+ await run();
487
+ console.error(`\nwatching ${path} … (ctrl-c to stop)`);
488
+ watchPaths(path, () => void run());
489
+ return new Promise(() => { }); // runs until interrupted
490
+ }
491
+ export async function main(argv) {
492
+ const { command, positionals, flags } = parseArgs(argv);
493
+ // Version stands alone, and is answered before the usage branch — behind it,
494
+ // `--version` fell into "no command" and printed the whole 40-line USAGE
495
+ // with exit 2. Alongside a command the flag is ignored rather than honoured:
496
+ // `check -v diagrams/` is somebody reaching for a verbose flag, and printing
497
+ // a version instead of validating would exit 0 on a broken project — the one
498
+ // failure a CI gate must never have.
499
+ if (!command && (flags.version || flags.v)) {
500
+ console.log(VERSION);
501
+ return 0;
502
+ }
503
+ if (!command || flags.help || flags.h) {
504
+ console.log(USAGE);
505
+ return command ? 0 : 2;
506
+ }
507
+ try {
508
+ switch (command) {
509
+ case "check":
510
+ if (!positionals[0])
511
+ throw new Error("usage: squinch check <path>");
512
+ return await cmdCheck(positionals[0], flags.format === "json");
513
+ case "render":
514
+ if (!positionals[0])
515
+ throw new Error("usage: squinch render <path>");
516
+ return await cmdRender(positionals[0], flags);
517
+ case "icons":
518
+ return await cmdIcons(positionals, flags);
519
+ case "init":
520
+ return cmdInit(positionals[0] ?? ".");
521
+ case "skill":
522
+ return cmdSkill(positionals, flags);
523
+ case "watch":
524
+ if (!positionals[0])
525
+ throw new Error("usage: squinch watch <path>");
526
+ return await cmdWatch(positionals[0], flags);
527
+ case "diff":
528
+ return cmdDiff(positionals, flags);
529
+ default:
530
+ console.error(`unknown command \`${command}\`\n\n${USAGE}`);
531
+ return 2;
532
+ }
533
+ }
534
+ catch (e) {
535
+ console.error(`error: ${e.message}`);
536
+ return 2;
537
+ }
538
+ }
@@ -0,0 +1 @@
1
+ export declare const toPosix: (p: string) => string;
package/dist/paths.js ADDED
@@ -0,0 +1,9 @@
1
+ // Paths in output are always POSIX.
2
+ //
3
+ // `path.relative` yields backslashes on Windows, and these strings are not
4
+ // private: `--check`'s stale list is what the squinch-check Action prints into
5
+ // somebody else's pull request, and test failure labels are read by whoever
6
+ // picks up the failure. A path that renders differently depending on the
7
+ // machine that produced it is noise in a diff and a false difference in a
8
+ // comparison. git already speaks POSIX everywhere, so this matches it.
9
+ export const toPosix = (p) => p.split(/[\\/]/).join("/");
@@ -0,0 +1,8 @@
1
+ import type { ProjectFile } from "@squinch/core";
2
+ export interface Input {
3
+ files: ProjectFile[];
4
+ /** Base name used for output files: the file stem, or the directory name. */
5
+ base: string;
6
+ dir: string;
7
+ }
8
+ export declare function loadInput(path: string): Input;
@@ -0,0 +1,24 @@
1
+ // Input resolution: a path is either one .squinch file or a project directory
2
+ // (all .squinch files in it merge into one model namespace — SPEC §2).
3
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
4
+ import { join, basename, resolve } from "node:path";
5
+ export function loadInput(path) {
6
+ const abs = resolve(path);
7
+ if (!existsSync(abs))
8
+ throw new Error(`no such file or directory: ${path}`);
9
+ if (statSync(abs).isDirectory()) {
10
+ const names = readdirSync(abs).filter((n) => n.endsWith(".squinch")).sort();
11
+ if (names.length === 0)
12
+ throw new Error(`no .squinch files in ${path}`);
13
+ return {
14
+ files: names.map((n) => ({ name: n, src: readFileSync(join(abs, n), "utf8") })),
15
+ base: basename(abs),
16
+ dir: abs,
17
+ };
18
+ }
19
+ return {
20
+ files: [{ name: basename(abs), src: readFileSync(abs, "utf8") }],
21
+ base: basename(abs).replace(/\.squinch$/, ""),
22
+ dir: resolve(abs, ".."),
23
+ };
24
+ }
@@ -0,0 +1,8 @@
1
+ export interface RasterOpts {
2
+ /** Output width in px. Height follows from the diagram's aspect ratio.
3
+ * Omitted, the SVG's own pixel dimensions are used (1×). */
4
+ width?: number;
5
+ /** Flatten onto this colour instead of leaving the canvas transparent. */
6
+ background?: string;
7
+ }
8
+ export declare function svgToPng(svg: string, opts?: RasterOpts): Buffer;
package/dist/raster.js ADDED
@@ -0,0 +1,39 @@
1
+ // SVG → PNG, for the places an SVG won't go: slide decks, Confluence, chat.
2
+ //
3
+ // The subtlety is fonts. Our SVGs embed the subsetted face as a base64 woff2
4
+ // via @font-face, which browsers honour — but resvg does not support
5
+ // @font-face at all (linebender/resvg#541, open and blocked on WOFF2 support),
6
+ // so left alone it would silently rasterize with whatever it found on the
7
+ // machine. That is precisely the environment dependence the project forbids, and
8
+ // it fails quietly: you get a PNG, it just isn't the diagram you designed.
9
+ //
10
+ // So we hand resvg the same faces the metrics were measured from, as sfnt, and
11
+ // switch system fonts off entirely. Nothing about the output can then depend on
12
+ // what happens to be installed.
13
+ import { createRequire } from "node:module";
14
+ import { Resvg } from "@resvg/resvg-js";
15
+ const require = createRequire(import.meta.url);
16
+ /** The sfnt twins of the embedded woff2 faces, emitted by core's gen-fonts.
17
+ * All of them, always: a face the SVG asks for and this list omits does not
18
+ * fail, it silently rasterizes in the nearest weight resvg does have. The
19
+ * restyle added Inter 600 (a view's title) and IBM Plex Mono (a zone's
20
+ * `detail:` segment) without extending this list, so every PNG of a titled
21
+ * diagram drew its heading a weight light. Keep this in step with
22
+ * `BASE_FACES` and the mono usage in core's render/svg.ts. */
23
+ const FACES = ["inter-400", "inter-500", "inter-600", "mono-400"];
24
+ let fontFiles;
25
+ function faces() {
26
+ return (fontFiles ??= FACES.map((f) => require.resolve(`@squinch/core/fonts/${f}.ttf`)));
27
+ }
28
+ export function svgToPng(svg, opts = {}) {
29
+ const resvg = new Resvg(svg, {
30
+ font: {
31
+ loadSystemFonts: false, // non-negotiable: no environment in the output
32
+ fontFiles: faces(),
33
+ defaultFontFamily: "Inter",
34
+ },
35
+ ...(opts.width ? { fitTo: { mode: "width", value: opts.width } } : {}),
36
+ ...(opts.background ? { background: opts.background } : {}),
37
+ });
38
+ return resvg.render().asPng();
39
+ }