@jabworks/condux 0.22.0 → 0.23.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jabworks/condux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -4,18 +4,29 @@
|
|
|
4
4
|
// diagram-kit.md) and cannot see its own render. It reads one file, finds
|
|
5
5
|
// every inline <svg>…</svg> block, and reports the geometry mistakes a human
|
|
6
6
|
// would catch on sight: an edge that runs through a box it does not connect
|
|
7
|
-
// to, a label sitting on another label or on a node's own title text,
|
|
8
|
-
// edge nobody labeled, and text that falls
|
|
7
|
+
// to, a label sitting on another label or on a node's own title text, a
|
|
8
|
+
// title wider than its own box, an edge nobody labeled, and text that falls
|
|
9
|
+
// outside the canvas.
|
|
9
10
|
//
|
|
10
11
|
// This is a regex-level reader, not a DOM. It understands exactly the
|
|
11
12
|
// idioms diagram-kit.md teaches — `<g transform="translate(x,y)">` offsets,
|
|
12
13
|
// `var(--…)` fills/strokes (never resolved — colors play no part in any
|
|
13
14
|
// check), one shared `<marker>` — and two known approximations:
|
|
14
15
|
//
|
|
15
|
-
// - text width is estimated at 0.6 em per character (no font metrics)
|
|
16
|
+
// - text width is estimated at 0.6 em per character (no font metrics) —
|
|
17
|
+
// measured exact for the kit's mono labels, generous for regular sans,
|
|
18
|
+
// a little narrow for semibold titles on wide fallback fonts;
|
|
16
19
|
// - Bézier and arc commands (C S Q T A) contribute only their final
|
|
17
20
|
// endpoint as a straight segment — curvature itself is invisible to it.
|
|
18
21
|
//
|
|
22
|
+
// Font size is resolved the way a browser cascades it, restricted to what a
|
|
23
|
+
// regex can see: a `.class { font-size: Npx }` rule matching the text's
|
|
24
|
+
// `class`, then a bare `text` / `svg text` rule, then the element's own
|
|
25
|
+
// `font-size` attribute, then one inherited from an enclosing `<g>` or the
|
|
26
|
+
// `<svg>` tag, and finally the browser default of 16px — reported on stderr
|
|
27
|
+
// when it has to be assumed, because an unsized diagram is read blind. Any
|
|
28
|
+
// other selector shape, and any unit but px, is invisible to it.
|
|
29
|
+
//
|
|
19
30
|
// No dependencies, no network, no shelling out, no filesystem writes.
|
|
20
31
|
|
|
21
32
|
import fs from 'node:fs';
|
|
@@ -24,9 +35,13 @@ import { pathToFileURL } from 'node:url';
|
|
|
24
35
|
// ---------------------------------------------------------------------------
|
|
25
36
|
// Small geometry helpers. A "box" is always { x0, y0, x1, y1 } in viewBox
|
|
26
37
|
// units; nodes/boundaries are boxes with a `line` and (once assigned) a
|
|
27
|
-
// `title`; texts are boxes with `text`, `line`, `
|
|
38
|
+
// `title`; texts are boxes with `text`, `line`, `ax`/`ay` (the anchor
|
|
39
|
+
// point), `free`, `owner`, `ownEdge`.
|
|
28
40
|
// ---------------------------------------------------------------------------
|
|
29
41
|
|
|
42
|
+
// What a browser uses when nothing in the document sizes the text.
|
|
43
|
+
const DEFAULT_FONT_SIZE = 16;
|
|
44
|
+
|
|
30
45
|
const shrinkBox = (box, n) => ({ x0: box.x0 + n, y0: box.y0 + n, x1: box.x1 - n, y1: box.y1 - n });
|
|
31
46
|
|
|
32
47
|
const expandBox = (box, n) => shrinkBox(box, -n);
|
|
@@ -185,6 +200,42 @@ const stripTags = (s) =>
|
|
|
185
200
|
// idiom.
|
|
186
201
|
// ---------------------------------------------------------------------------
|
|
187
202
|
|
|
203
|
+
// The two style-rule shapes the checker reads, from every <style> block in
|
|
204
|
+
// the document (comments already blanked): `text { font-size: Npx }` or
|
|
205
|
+
// `svg text { … }` sets the base, `.name { … }` / `text.name { … }` /
|
|
206
|
+
// `svg .name { … }` maps a class. Later rules win, as in a stylesheet.
|
|
207
|
+
// Anything else — element ids, descendant chains, `em`/`rem`/`var()` — is
|
|
208
|
+
// skipped: a size the checker cannot resolve must not become a guess.
|
|
209
|
+
const BASE_SELECTOR = /^(?:svg\s+)?text$/;
|
|
210
|
+
const CLASS_SELECTOR = /^(?:svg\s+)?(?:text)?\.([\w-]+)$/;
|
|
211
|
+
|
|
212
|
+
const parseStyleFontSizes = (source) => {
|
|
213
|
+
const styles = { base: null, classes: new Map() };
|
|
214
|
+
const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
215
|
+
const ruleRe = /([^{}]+)\{([^}]*)\}/g;
|
|
216
|
+
let sm;
|
|
217
|
+
|
|
218
|
+
while ((sm = styleRe.exec(source))) {
|
|
219
|
+
// A CSS comment is not a rule, and left in place it glues itself onto
|
|
220
|
+
// the selector that follows it.
|
|
221
|
+
const css = sm[1].replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
222
|
+
let rm;
|
|
223
|
+
while ((rm = ruleRe.exec(css))) {
|
|
224
|
+
const size = /(?:^|[\s;{])font-size\s*:\s*(\d+(?:\.\d+)?)px\b/.exec(rm[2]);
|
|
225
|
+
if (!size) continue;
|
|
226
|
+
|
|
227
|
+
for (const selector of rm[1].split(',')) {
|
|
228
|
+
const s = selector.trim();
|
|
229
|
+
if (BASE_SELECTOR.test(s)) styles.base = parseFloat(size[1]);
|
|
230
|
+
const cm = CLASS_SELECTOR.exec(s);
|
|
231
|
+
if (cm) styles.classes.set(cm[1], parseFloat(size[1]));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return styles;
|
|
237
|
+
};
|
|
238
|
+
|
|
188
239
|
const CURVE_ARG_COUNTS = { C: 6, S: 4, Q: 4, T: 2, A: 7 };
|
|
189
240
|
|
|
190
241
|
const parsePathSegments = (d, warnCurve) => {
|
|
@@ -300,10 +351,14 @@ const parsePathSegments = (d, warnCurve) => {
|
|
|
300
351
|
// (and any positioned <tspan> children) without a general text-node model.
|
|
301
352
|
// ---------------------------------------------------------------------------
|
|
302
353
|
|
|
303
|
-
const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
|
|
354
|
+
const walkBlock = (source, blockOffset, blockSource, findLine, notify, styles) => {
|
|
304
355
|
const rects = [];
|
|
305
356
|
const texts = [];
|
|
306
357
|
const edges = [];
|
|
358
|
+
let defaulted = 0;
|
|
359
|
+
const onDefault = () => {
|
|
360
|
+
defaulted++;
|
|
361
|
+
};
|
|
307
362
|
|
|
308
363
|
const svgOpenMatch = /^<svg\b[^>]*>/.exec(blockSource);
|
|
309
364
|
const svgOpenTag = svgOpenMatch ? svgOpenMatch[0] : '<svg>';
|
|
@@ -319,7 +374,9 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
|
|
|
319
374
|
|
|
320
375
|
let curOffsetX = 0;
|
|
321
376
|
let curOffsetY = 0;
|
|
322
|
-
|
|
377
|
+
// null until some enclosing tag carries a font-size — the text element then
|
|
378
|
+
// decides between its own attribute, the style rules, and the default.
|
|
379
|
+
let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || null;
|
|
323
380
|
let skipDepth = 0;
|
|
324
381
|
const stack = [];
|
|
325
382
|
// The frame a closing tag restores: offsets, inherited font size, and
|
|
@@ -364,6 +421,8 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
|
|
|
364
421
|
offX: curOffsetX,
|
|
365
422
|
offY: curOffsetY,
|
|
366
423
|
curFontSize,
|
|
424
|
+
styles,
|
|
425
|
+
onDefault,
|
|
367
426
|
texts,
|
|
368
427
|
lineNo,
|
|
369
428
|
});
|
|
@@ -430,6 +489,12 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
|
|
|
430
489
|
if (!selfClosing) stack.push(snapshot());
|
|
431
490
|
}
|
|
432
491
|
|
|
492
|
+
if (defaulted > 0) {
|
|
493
|
+
notify(
|
|
494
|
+
`diagram-check: ${defaulted} text element(s) carry no font-size from an attribute, an enclosing tag, or a text/.class style rule — assumed ${DEFAULT_FONT_SIZE}px; set font-size on the <svg> tag so widths are read as drawn`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
433
498
|
return { rects, texts, edges, viewBox };
|
|
434
499
|
};
|
|
435
500
|
|
|
@@ -518,15 +583,39 @@ const pushTextBox = (texts, text, absX, absY, anchor, fontSize, lineNo) => {
|
|
|
518
583
|
x1 = absX + width;
|
|
519
584
|
}
|
|
520
585
|
|
|
521
|
-
texts.push({ x0, y0: top, x1, y1: bottom, text, line: lineNo });
|
|
586
|
+
texts.push({ x0, y0: top, x1, y1: bottom, ax: absX, ay: absY, text, line: lineNo });
|
|
522
587
|
};
|
|
523
588
|
|
|
524
|
-
|
|
589
|
+
// The cascade a browser applies to a <text>, restricted to the shapes the
|
|
590
|
+
// style scan understands: a matching class rule beats a bare `text` rule,
|
|
591
|
+
// which beats the element's own attribute, which beats an inherited one.
|
|
592
|
+
// `inherited` is null when no enclosing tag carried a size; `onDefault` is
|
|
593
|
+
// called when the browser default has to stand in.
|
|
594
|
+
const resolveFontSize = (tag, inherited, styles, onDefault) => {
|
|
595
|
+
const classAttr = getAttr(tag, 'class');
|
|
596
|
+
if (classAttr) {
|
|
597
|
+
for (const cls of classAttr.trim().split(/\s+/)) {
|
|
598
|
+
if (styles.classes.has(cls)) return styles.classes.get(cls);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (styles.base !== null) return styles.base;
|
|
603
|
+
|
|
604
|
+
const attr = getAttr(tag, 'font-size');
|
|
605
|
+
if (attr) return parseFloat(attr);
|
|
606
|
+
|
|
607
|
+
if (inherited !== null) return inherited;
|
|
608
|
+
|
|
609
|
+
onDefault();
|
|
610
|
+
|
|
611
|
+
return DEFAULT_FONT_SIZE;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, styles, onDefault, texts, lineNo }) => {
|
|
525
615
|
const x = parseFloat(getAttr(tag, 'x') ?? '0');
|
|
526
616
|
const y = parseFloat(getAttr(tag, 'y') ?? '0');
|
|
527
617
|
const anchor = getAttr(tag, 'text-anchor') ?? 'start';
|
|
528
|
-
const
|
|
529
|
-
const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : curFontSize;
|
|
618
|
+
const fontSize = resolveFontSize(tag, curFontSize, styles, onDefault);
|
|
530
619
|
|
|
531
620
|
const tspanRe = /<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/g;
|
|
532
621
|
const tspans = [];
|
|
@@ -594,9 +683,12 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
|
|
|
594
683
|
const nodes = rects.filter((r) => !r.isBoundary);
|
|
595
684
|
|
|
596
685
|
// Owned vs. free text, and each node's title (first owned text, in
|
|
597
|
-
// document order).
|
|
686
|
+
// document order). A text belongs to the node its box sits in — or, when
|
|
687
|
+
// the box has outgrown the node, the node its anchor point sits in: a
|
|
688
|
+
// title too wide for its box is still that box's title, and reporting it
|
|
689
|
+
// as a free label straddling the node would misname the defect.
|
|
598
690
|
for (const text of texts) {
|
|
599
|
-
const owner = nodes.find((node) => fullyInside(text, node, 1));
|
|
691
|
+
const owner = nodes.find((node) => fullyInside(text, node, 1) || pointInBox(text.ax, text.ay, node));
|
|
600
692
|
if (owner) {
|
|
601
693
|
text.free = false;
|
|
602
694
|
text.owner = owner;
|
|
@@ -666,11 +758,13 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
|
|
|
666
758
|
}
|
|
667
759
|
}
|
|
668
760
|
|
|
669
|
-
// 3. label-over-box (
|
|
761
|
+
// 3. label-over-box (a text box straddling the border of a node it does
|
|
762
|
+
// not belong to) and text-overflows-box (a node's own text wider or
|
|
763
|
+
// taller than the node, past the 1-unit tolerance)
|
|
670
764
|
for (const text of texts) {
|
|
671
|
-
if (!text.free) continue;
|
|
672
|
-
|
|
673
765
|
for (const node of nodes) {
|
|
766
|
+
if (node === text.owner) continue;
|
|
767
|
+
|
|
674
768
|
if (rectIntersect(shrinkBox(text, 1), node)) {
|
|
675
769
|
findings.push({
|
|
676
770
|
code: 'label-over-box',
|
|
@@ -679,6 +773,20 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
|
|
|
679
773
|
});
|
|
680
774
|
}
|
|
681
775
|
}
|
|
776
|
+
|
|
777
|
+
if (text.owner && !fullyInside(text, text.owner, 1)) {
|
|
778
|
+
const { owner } = text;
|
|
779
|
+
const over = Math.max(owner.x0 - text.x0, text.x1 - owner.x1, owner.y0 - text.y0, text.y1 - owner.y1);
|
|
780
|
+
const where =
|
|
781
|
+
owner.title === text.text
|
|
782
|
+
? `its own ${fmt(owner.x1 - owner.x0)}×${fmt(owner.y1 - owner.y0)} box`
|
|
783
|
+
: describeNode(owner);
|
|
784
|
+
findings.push({
|
|
785
|
+
code: 'text-overflows-box',
|
|
786
|
+
line: text.line,
|
|
787
|
+
detail: `"${text.text}" overflows ${where} by ${fmt(over)}`,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
682
790
|
}
|
|
683
791
|
|
|
684
792
|
// 4. edge-through-label (excluding a free text's own edge)
|
|
@@ -736,8 +844,9 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
|
|
|
736
844
|
// Public API
|
|
737
845
|
// ---------------------------------------------------------------------------
|
|
738
846
|
|
|
739
|
-
// Finds every <svg>…</svg> block in `source` and runs all
|
|
740
|
-
// each. Returns Finding[] = { code, line, detail }[], sorted by line
|
|
847
|
+
// Finds every <svg>…</svg> block in `source` and runs all seven checks
|
|
848
|
+
// against each. Returns Finding[] = { code, line, detail }[], sorted by line
|
|
849
|
+
// then code.
|
|
741
850
|
export const checkSvg = (rawSource) => {
|
|
742
851
|
// The kit's own marker block carries `<!-- usage: <line … /> -->`, and the
|
|
743
852
|
// kit says to paste it verbatim — scanned raw, that comment is a phantom
|
|
@@ -745,6 +854,7 @@ export const checkSvg = (rawSource) => {
|
|
|
745
854
|
// comments character-for-character (newlines kept) so line numbers hold.
|
|
746
855
|
const source = rawSource.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, ' '));
|
|
747
856
|
const findLine = makeLineFinder(source);
|
|
857
|
+
const styles = parseStyleFontSizes(source);
|
|
748
858
|
let curveWarned = false;
|
|
749
859
|
const notify = (msg) => process.stderr.write(msg + '\n');
|
|
750
860
|
notify.curve = () => {
|
|
@@ -758,7 +868,7 @@ export const checkSvg = (rawSource) => {
|
|
|
758
868
|
let m;
|
|
759
869
|
|
|
760
870
|
while ((m = blockRe.exec(source))) {
|
|
761
|
-
const parsed = walkBlock(source, m.index, m[0], findLine, notify);
|
|
871
|
+
const parsed = walkBlock(source, m.index, m[0], findLine, notify, styles);
|
|
762
872
|
findings.push(...findingsForBlock(parsed));
|
|
763
873
|
}
|
|
764
874
|
|
|
@@ -8,8 +8,10 @@ of the `<style>` block, verbatim.
|
|
|
8
8
|
|
|
9
9
|
## Shared Conventions
|
|
10
10
|
|
|
11
|
-
- Canvas: `<svg viewBox="0 0 W H" style="max-width:100%">`
|
|
12
|
-
the page wraps it in the wireframe kit's `.frame` shell
|
|
11
|
+
- Canvas: `<svg viewBox="0 0 W H" font-size="13" style="max-width:100%">`
|
|
12
|
+
sized to content; the page wraps it in the wireframe kit's `.frame` shell
|
|
13
|
+
(either mode's). The `font-size` is the base every text inherits — see
|
|
14
|
+
Sizing below.
|
|
13
15
|
- Palette is token roles, referenced with `var(--…)` (CSS variables work in
|
|
14
16
|
inline SVG attributes): fills `var(--card)` / `var(--muted)`, strokes
|
|
15
17
|
`var(--border)`, edges `var(--subtle)`, text `var(--foreground)`. The one
|
|
@@ -19,6 +21,17 @@ of the `<style>` block, verbatim.
|
|
|
19
21
|
rows, cardinalities, edge labels (D2). 13–14px labels, 11px annotations
|
|
20
22
|
in `var(--muted-foreground)`. If a label doesn't fit its box, the box
|
|
21
23
|
grows — never shrink the font below 11px.
|
|
24
|
+
- Sizing: the base size goes on the `<svg>` tag (`font-size="13"`), every
|
|
25
|
+
other size as a `font-size` attribute on the `<text>` itself. Never a
|
|
26
|
+
`text { font-size }` rule in `<style>` — in the browser it overrides every
|
|
27
|
+
`font-size` attribute in the diagram, so the sizes you wrote are not the
|
|
28
|
+
sizes that render. The checker resolves sizes the way the browser does but
|
|
29
|
+
sees only attributes, a bare `text` rule and `.class` rules; a text it
|
|
30
|
+
cannot size is read at the browser's 16px and reported on stderr. Budget
|
|
31
|
+
width at 0.6 em per character (exact for mono, generous for regular sans,
|
|
32
|
+
a little tight for semibold titles): at 16px a 23-character title needs
|
|
33
|
+
more than a 210 box, and when it doesn't fit the box grows — the checker
|
|
34
|
+
reports `text-overflows-box`, never a smaller font.
|
|
22
35
|
- Arrowheads via one shared `<marker>`:
|
|
23
36
|
|
|
24
37
|
```html
|
|
@@ -62,8 +75,10 @@ of the `<style>` block, verbatim.
|
|
|
62
75
|
`<rect>` in the fill the label sits on (`var(--background)` on open
|
|
63
76
|
canvas, `var(--muted)` inside a filled boundary) drawn behind the text —
|
|
64
77
|
the checker ignores stroke-less rects, so the halo is never mistaken for a
|
|
65
|
-
node.
|
|
66
|
-
|
|
78
|
+
node. Size the halo from the same budget as the label — characters × 11 ×
|
|
79
|
+
0.6, plus 4px each side — a halo narrower than its label lets the crossing
|
|
80
|
+
edge show through the ends. Two labels never share a corridor position:
|
|
81
|
+
stagger by 14px or move one to another segment.
|
|
67
82
|
6. *Fan-in is a smell.* More than four edges into one box, or more than three
|
|
68
83
|
edges in one gutter, means the diagram is answering two questions — split
|
|
69
84
|
it (same rule as "Choosing the Shape").
|
|
@@ -78,7 +93,8 @@ of the `<style>` block, verbatim.
|
|
|
78
93
|
|---|---|
|
|
79
94
|
| `edge-through-box` | an edge segment crosses a node rect it doesn't attach to |
|
|
80
95
|
| `label-over-label` | two text boxes intersect |
|
|
81
|
-
| `label-over-box` | a text box straddles a node
|
|
96
|
+
| `label-over-box` | a text box straddles the border of a node it doesn't belong to |
|
|
97
|
+
| `text-overflows-box` | a node's own text is wider or taller than the node |
|
|
82
98
|
| `edge-through-label` | an edge crosses a text box that isn't its own label |
|
|
83
99
|
| `unlabeled-edge` | no text within 24px of the edge |
|
|
84
100
|
| `text-outside-canvas` | text escapes the viewBox |
|