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/LICENSE +202 -0
- package/bin/squinch.js +2 -0
- package/dist/args.d.ts +19 -0
- package/dist/args.js +48 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -0
- package/dist/git.d.ts +13 -0
- package/dist/git.js +60 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +538 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +9 -0
- package/dist/project.d.ts +8 -0
- package/dist/project.js +24 -0
- package/dist/raster.d.ts +8 -0
- package/dist/raster.js +39 -0
- package/dist/skill.generated.d.ts +1 -0
- package/dist/skill.generated.js +5 -0
- package/dist/watch.d.ts +8 -0
- package/dist/watch.js +61 -0
- package/package.json +46 -6
- package/README.md +0 -5
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
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -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;
|
package/dist/project.js
ADDED
|
@@ -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
|
+
}
|
package/dist/raster.d.ts
ADDED
|
@@ -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
|
+
}
|