diff --git a/bin/plinth-sketch.js b/bin/plinth-sketch.js
index eea6d13..4832700 100755
--- a/bin/plinth-sketch.js
+++ b/bin/plinth-sketch.js
@@ -58,7 +58,16 @@ function parseArgs(argv) {
if (a === "-w" || a === "--watch") { args.watch = true; continue; }
if (a === "--check") { args.check = true; continue; }
if (a === "--no-signature") { args.signature = false; continue; }
- if (a === "-o" || a === "--out") { args.out = argv[++i]; continue; }
+ if (a === "-o" || a === "--out") {
+ const next = argv[i + 1];
+ if (next === undefined || (next.startsWith("-") && next !== "-")) {
+ console.error(`plinth-sketch: ${a} requires a value (file path or '-' for stdout)`);
+ process.exit(2);
+ }
+ args.out = next;
+ i++;
+ continue;
+ }
if (a.startsWith("-") && a.length > 1 && a !== "-") {
console.error(`plinth-sketch: unknown option: ${a}`);
process.exit(2);
@@ -94,22 +103,20 @@ function emitSvg(dsl, args) {
const lay = layout(parsed);
const svg = render(parsed, lay, { signature: args.signature });
- // Surface parse warnings on stderr (don't fail unless --check).
+ // Surface parse errors (unparsed lines) and warnings (undefined-node edges,
+ // self-loops) on stderr. --check will fail on either; default render won't.
if (parsed.errors.length) {
for (const err of parsed.errors) {
- console.error(`plinth-sketch: warning: line ${err.line}: cannot parse: ${err.text}`);
+ console.error(`plinth-sketch: error: line ${err.line}: cannot parse: ${err.text}`);
}
}
- const missing = new Set();
- for (const e of parsed.edges) {
- if (!parsed.nodes[e.from]) missing.add(e.from);
- if (!parsed.nodes[e.to]) missing.add(e.to);
- }
- if (missing.size) {
- console.error(`plinth-sketch: warning: edge references undefined nodes: ${[...missing].join(", ")}`);
+ const warnings = parsed.warnings || [];
+ for (const w of warnings) {
+ const where = w.line ? `line ${w.line}: ` : "";
+ console.error(`plinth-sketch: warning: ${where}${w.message}`);
}
- const hasErrors = parsed.errors.length > 0 || missing.size > 0;
+ const hasErrors = parsed.errors.length > 0 || warnings.length > 0;
return { svg, hasErrors };
}
diff --git a/examples/plinth.svg b/examples/plinth.svg
index 9012be0..5961494 100644
--- a/examples/plinth.svg
+++ b/examples/plinth.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/examples/three-tier.svg b/examples/three-tier.svg
index 1353633..0e050f3 100644
--- a/examples/three-tier.svg
+++ b/examples/three-tier.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 56f3e2c..c247026 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@plinth-dev/sketch",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Render Plinth Sketch DSL files to typeset SVG architecture diagrams. Drop the DSL in your repo, render at build time, embed the SVG in your README.",
"license": "MIT",
"type": "module",
diff --git a/src/layout.js b/src/layout.js
index fb97b5c..7948dc0 100644
--- a/src/layout.js
+++ b/src/layout.js
@@ -14,10 +14,9 @@ const NODE_H = 64;
const NODE_GAP_X = 32;
const NODE_MIN_W = 140;
const TOTAL_W_DEFAULT = 960;
-const TOTAL_W_MAX = 1600;
-
export function layout(parsed) {
// First pass: figure out the canvas width we need to honour NODE_MIN_W.
+ // No upper cap — clipping nodes off-canvas is worse than a wide diagram.
let canvasW = TOTAL_W_DEFAULT;
for (const layer of parsed.layers) {
if (!layer.ids.length) continue;
@@ -25,7 +24,7 @@ export function layout(parsed) {
const totalGap = NODE_GAP_X * (n - 1);
const neededInner = LAYER_INTERNAL_PAD_X * 2 + totalGap + NODE_MIN_W * n;
const neededTotal = neededInner + PADDING_X * 2;
- if (neededTotal > canvasW) canvasW = Math.min(neededTotal, TOTAL_W_MAX);
+ if (neededTotal > canvasW) canvasW = neededTotal;
}
const innerW = canvasW - PADDING_X * 2;
diff --git a/src/parser.js b/src/parser.js
index 8482a33..d801281 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -7,8 +7,9 @@
// a -> b : edge label — directed edge (label optional)
// @cite text — bottom-right cite text (optional)
//
-// The parser is intentionally permissive: unknown lines surface as errors[]
-// but don't abort the parse. Whitespace tolerant. CRLF tolerant.
+// The parser is intentionally permissive: unparsed lines surface in errors[]
+// and edges referencing undefined nodes surface in warnings[] — neither
+// aborts the parse. Whitespace and CRLF tolerant.
const NODE_RE = /^([\w\-./]+)\s*:\s*(.+?)(?:\s+\/\s+(.+))?$/;
const EDGE_RE = /^([\w\-./]+)\s*->\s*([\w\-./]+)\s*(?::\s*(.+))?$/;
@@ -20,6 +21,7 @@ export function parse(text) {
const layers = [];
const edges = [];
const errors = [];
+ const warnings = [];
let cite = null;
let currentLayerName = null;
@@ -59,7 +61,7 @@ export function parse(text) {
const e = line.match(EDGE_RE);
if (e) {
- edges.push({ from: e[1], to: e[2], label: e[3] || null });
+ edges.push({ from: e[1], to: e[2], label: e[3] || null, line: i + 1 });
continue;
}
@@ -72,5 +74,27 @@ export function parse(text) {
errors.push({ line: i + 1, text: raw });
}
- return { nodes, nodeOrder, layers, edges, cite, errors };
+ // Surface edges that reference undefined nodes as warnings. Self-loops
+ // also surface as warnings since they aren't currently rendered.
+ const missing = new Set();
+ for (const e of edges) {
+ if (!nodes[e.from]) missing.add(e.from);
+ if (!nodes[e.to]) missing.add(e.to);
+ if (e.from === e.to) {
+ warnings.push({
+ line: e.line,
+ kind: "self-loop",
+ message: `edge ${e.from} -> ${e.to} is a self-loop and is not rendered`,
+ });
+ }
+ }
+ if (missing.size) {
+ warnings.push({
+ kind: "undefined-nodes",
+ ids: [...missing],
+ message: `undefined node${missing.size === 1 ? "" : "s"}: ${[...missing].join(", ")}`,
+ });
+ }
+
+ return { nodes, nodeOrder, layers, edges, cite, errors, warnings };
}
diff --git a/src/renderer.js b/src/renderer.js
index 8efcd66..209d13b 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -17,7 +17,7 @@ const PALETTE = {
};
const FONT_SANS = "'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif";
-const FONT_MONO = "'JetBrains Mono', 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace";
+const FONT_MONO = "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace";
function escapeXml(s) {
return String(s)
@@ -28,29 +28,51 @@ function escapeXml(s) {
.replace(/'/g, "'");
}
+// Deterministic short hash of a string. Used for per-render marker / class
+// suffixes so the same DSL renders byte-stable across runs while two SVGs
+// embedded on one page don't collide on `` or scoped CSS.
+function shortHash(s) {
+ let h = 5381;
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
+ return (h >>> 0).toString(36).slice(0, 8);
+}
+
export function render(parsed, lay, options = {}) {
- const { embedFonts = true, signature = true } = options;
+ const { signature = true } = options;
const { width: W, height: H } = lay;
+ // Per-render namespace for SVG ids/classes — lets multiple Plinth Sketch
+ // SVGs coexist on the same HTML page without `` collisions
+ // or CSS class bleed.
+ const ns = "ps-" + shortHash(JSON.stringify({
+ nodes: parsed.nodeOrder,
+ edges: parsed.edges,
+ layers: parsed.layers.map((l) => l.name),
+ cite: parsed.cite,
+ }));
+ const arr = `${ns}-arr`;
+ const arrMute = `${ns}-arr-mute`;
+ const klass = ns;
+
const styles = `
-.plinth-topo { font-family: ${FONT_SANS}; }
-.plinth-topo .layer-rect { fill: ${PALETTE.bgSoft}; stroke: ${PALETTE.ruleFirm}; stroke-width: 1; stroke-dasharray: 4 3; }
-.plinth-topo .comp-rect { fill: ${PALETTE.bg}; stroke: ${PALETTE.accent}; stroke-width: 1; }
-.plinth-topo .conn { stroke: ${PALETTE.accent}; stroke-width: 1; fill: none; }
-.plinth-topo .conn-mute { stroke: ${PALETTE.accentMute}; stroke-width: 1; fill: none; }
-.plinth-topo text { fill: ${PALETTE.ink}; }
-.plinth-topo .lbl { font-family: ${FONT_SANS}; font-weight: 500; font-size: 14px; fill: ${PALETTE.ink}; }
-.plinth-topo .lbl-sub { font-family: ${FONT_MONO}; font-size: 11px; fill: ${PALETTE.inkMute}; letter-spacing: 0.02em; }
-.plinth-topo .layer-label { font-family: ${FONT_SANS}; font-size: 12px; font-weight: 600; fill: ${PALETTE.inkMute}; letter-spacing: 0.04em; }
-.plinth-topo .conn-label { font-family: ${FONT_MONO}; font-size: 10.5px; fill: ${PALETTE.inkSoft}; letter-spacing: 0.04em; }
-.plinth-topo .made-with { font-family: ${FONT_SANS}; font-size: 11px; fill: ${PALETTE.inkSoft}; }
+.${klass} { font-family: ${FONT_SANS}; }
+.${klass} .layer-rect { fill: ${PALETTE.bgSoft}; stroke: ${PALETTE.ruleFirm}; stroke-width: 1; stroke-dasharray: 4 3; }
+.${klass} .comp-rect { fill: ${PALETTE.bg}; stroke: ${PALETTE.accent}; stroke-width: 1; }
+.${klass} .conn { stroke: ${PALETTE.accent}; stroke-width: 1; fill: none; }
+.${klass} .conn-mute { stroke: ${PALETTE.accentMute}; stroke-width: 1; fill: none; }
+.${klass} text { fill: ${PALETTE.ink}; }
+.${klass} .lbl { font-family: ${FONT_SANS}; font-weight: 500; font-size: 14px; fill: ${PALETTE.ink}; }
+.${klass} .lbl-sub { font-family: ${FONT_MONO}; font-size: 11px; fill: ${PALETTE.inkMute}; letter-spacing: 0.02em; }
+.${klass} .layer-label { font-family: ${FONT_SANS}; font-size: 12px; font-weight: 600; fill: ${PALETTE.inkMute}; letter-spacing: 0.04em; }
+.${klass} .conn-label { font-family: ${FONT_MONO}; font-size: 10.5px; fill: ${PALETTE.inkSoft}; letter-spacing: 0.04em; }
+.${klass} .made-with { font-family: ${FONT_SANS}; font-size: 11px; fill: ${PALETTE.inkSoft}; }
`;
- let svg = `