astro-archify 0.3.4 → 0.3.5

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/README.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # astro-archify
2
2
 
3
- An Astro integration for rendering [Archify](https://github.com/tt-a1i/archify) system diagrams — architecture, workflow, sequence, data flow, and lifecycle — from JSON IR code blocks in your markdown/MDX content.
3
+ An Astro integration for rendering [Archify](https://github.com/tt-a1i/archify) system diagrams — architecture, workflow, sequence, data flow, and lifecycle — from JSON IR code blocks in your markdown/MDX content. Works with standalone Astro projects and documentation frameworks like [Starlight](https://starlight.astro.build/).
4
+
5
+ ## Live demos
6
+
7
+ | Demo | URL | Description |
8
+ |------|-----|-------------|
9
+ | **Starlight** | [astro-archify-starlight-demo.netlify.app](https://astro-archify-starlight-demo.netlify.app/) | Documentation site with Starlight |
10
+ | **Standalone Astro** | [astro-archify-astro-demo.netlify.app](https://astro-archify-astro-demo.netlify.app/) | Pure Astro project template |
11
+
12
+ See also [`starlight-demo/`](./starlight-demo/) and [`astro-demo/`](./astro-demo/) in this repo.
13
+
14
+ Listed in the [Astro integrations directory](https://astro.build/integrations/?search=archify).
4
15
 
5
16
  Archify turns a typed JSON intermediate representation (IR) into a fully self-contained, already-interactive HTML artifact — inline SVG plus a small pan/zoom/focus viewer, with every script and its ~4800 lines of CSS inlined (the one exception is a Google Fonts `<link>`, which degrades gracefully if it can't load). This integration renders that artifact **at build time** using Archify's own renderer — bundled into this package, see [Attribution](#attribution) — and embeds it as a sandboxed `<iframe>`, so you get Archify's real viewer, not a re-implementation of it.
6
17
 
@@ -115,6 +126,54 @@ archify({
115
126
  })
116
127
  ```
117
128
 
129
+ There is no `width` option. The iframe is always `width: 100%` and fills whatever container your layout gives it. Use `height`/`minHeight`/`maxHeight` to control vertical sizing; control horizontal sizing in your site CSS (see [Layout and width](#layout-and-width) below).
130
+
131
+ ## Layout and width
132
+
133
+ Diagram width is owned by your page layout, not by `archify()`. Each fence becomes a wrapper (`.archify-diagram` by default, or your custom `className`) containing a full-width iframe — so whatever box that wrapper sits in is the diagram's width.
134
+
135
+ In a plain Astro project, that usually means your layout's content column — e.g. a `max-width` on `<main>` in your layout component.
136
+
137
+ In [Starlight](https://starlight.astro.build/), the prose column is controlled by `--sl-content-width` (default ~45rem). Three common approaches:
138
+
139
+ **Widen all docs content** — add a custom CSS file via Starlight's `customCss` option:
140
+
141
+ ```css
142
+ /* src/styles/custom.css */
143
+ :root {
144
+ --sl-content-width: 60rem;
145
+ }
146
+ ```
147
+
148
+ **Widen only diagrams** — leave prose at the default and target the embed wrapper:
149
+
150
+ ```css
151
+ /* src/styles/custom.css */
152
+ .archify-diagram {
153
+ width: calc(100% + 8rem);
154
+ max-width: 80rem;
155
+ margin-inline: -4rem;
156
+ }
157
+ ```
158
+
159
+ Tune the values to taste. This is useful when body text should stay narrow but architecture or workflow diagrams need more room.
160
+
161
+ **Per-page width** — in MDX, wrap a fence in a styled container:
162
+
163
+ ````mdx
164
+ <div class="diagram-wide">
165
+
166
+ ```archify
167
+ { ... }
168
+ ```
169
+
170
+ </div>
171
+ ````
172
+
173
+ Then style `.diagram-wide` in your custom CSS.
174
+
175
+ If you push `--sl-content-width` well past ~60rem, Starlight's table-of-contents layout can overflow at some viewport sizes — see [Starlight issue #3513](https://github.com/withastro/starlight/issues/3513) for workarounds.
176
+
118
177
  ## Astro Compatibility
119
178
 
120
179
  `astro-archify` follows the same markdown-engine detection as [astro-mermaid](https://github.com/joesaby/astro-mermaid) to work across Astro 4 through 7:
@@ -142,7 +201,7 @@ Static builds (`output: 'static'`, the default) are fully supported. Under SSR (
142
201
 
143
202
  ## Attribution
144
203
 
145
- Archify's own renderer and viewer are vendored into this package at [`vendor/archify/`](./vendor/archify) — copied from [tt-a1i/archify](https://github.com/tt-a1i/archify) (MIT licensed) at commit `12106be`, and used unmodified. See [`vendor/archify/NOTICE.md`](./vendor/archify/NOTICE.md) for exactly what was copied, why, and how to update it.
204
+ Archify's own renderer and viewer are vendored into this package at [`vendor/archify/`](./vendor/archify) — copied from [tt-a1i/archify](https://github.com/tt-a1i/archify) (MIT licensed) at commit `39a2113`, and used unmodified. See [`vendor/archify/NOTICE.md`](./vendor/archify/NOTICE.md) for exactly what was copied, why, and how to update it.
146
205
 
147
206
  To be clear about the boundary: **everything under `vendor/archify/` is Archify's own code**, doing Archify's own layout, rendering, and the entire interactive viewer. Everything else in this repository — the remark/Sätteri plugin glue that finds `archify` code fences, spawning the renderer as a subprocess, content-addressed caching, serving artifacts from their own URLs, the iframe embedding and its auto-resize bridge, the Astro markdown-engine compatibility shim, the tests, and the demo — is original to `astro-archify` (the markdown-engine detection follows the same pattern used in [astro-mermaid](https://github.com/joesaby/astro-mermaid), also by this author).
148
207
 
@@ -150,9 +209,16 @@ To be clear about the boundary: **everything under `vendor/archify/` is Archify'
150
209
 
151
210
  Archify inlines its entire viewer stylesheet (~4800 lines) into every artifact — the only external stylesheet is a Google Fonts `<link>`, which degrades gracefully if it can't load. Combined with the iframe embedding, this means a diagram always renders pixel-identical to opening the artifact standalone: your site's CSS can never leak into it, and its CSS can never leak into your site.
152
211
 
153
- ## Demo
212
+ You can still style the **wrapper** around the iframe from your site CSS — margins, borders, and width (see [Layout and width](#layout-and-width)). The `className` option is there if you need a project-specific hook.
213
+
214
+ ## Demos
215
+
216
+ | Demo | URL | Source |
217
+ |------|-----|--------|
218
+ | Starlight | [astro-archify-starlight-demo.netlify.app](https://astro-archify-starlight-demo.netlify.app/) | [`starlight-demo/`](./starlight-demo/) |
219
+ | Standalone Astro | [astro-archify-astro-demo.netlify.app](https://astro-archify-astro-demo.netlify.app/) | [`astro-demo/`](./astro-demo/) |
154
220
 
155
- See [`demo/`](./demo) for a minimal Astro project rendering Archify's own architecture, sequence, and workflow examples.
221
+ For Starlight, list `archify()` before `starlight()` in `astro.config.mjs` (see [Integration Order](#integration-order-important)).
156
222
 
157
223
  ## Supported Diagram Types
158
224
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-archify",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "An Astro integration that renders Archify system diagrams (architecture, workflow, sequence, dataflow, lifecycle) from JSON IR code blocks at build time, with Archify's renderer bundled in",
5
5
  "type": "module",
6
6
  "main": "./astro-archify-integration.js",
@@ -20,6 +20,8 @@
20
20
  "keywords": [
21
21
  "astro",
22
22
  "astro-integration",
23
+ "withastro",
24
+ "utility",
23
25
  "archify",
24
26
  "diagrams",
25
27
  "markdown",
@@ -51,13 +53,14 @@
51
53
  "test": "vitest",
52
54
  "test:ui": "vitest --ui",
53
55
  "test:coverage": "vitest --coverage",
54
- "update:vendor": "node scripts/update-vendor.mjs"
56
+ "update:vendor": "node scripts/update-vendor.mjs",
57
+ "vendor:verify": "node scripts/vendor-verify.mjs"
55
58
  },
56
59
  "repository": {
57
60
  "type": "git",
58
61
  "url": "git+https://github.com/joesaby/astro-archify.git"
59
62
  },
60
- "homepage": "https://github.com/joesaby/astro-archify#readme",
63
+ "homepage": "https://astro-archify-starlight-demo.netlify.app/",
61
64
  "bugs": {
62
65
  "url": "https://github.com/joesaby/astro-archify/issues"
63
66
  }
@@ -3,9 +3,9 @@
3
3
  This directory contains source code copied verbatim from [tt-a1i/archify](https://github.com/tt-a1i/archify), used under its MIT license (see `LICENSE` in this directory).
4
4
 
5
5
  - **Source**: https://github.com/tt-a1i/archify
6
- - **Pinned commit**: `12106be58b34f94b108ab30f6ac0eb37c16a8f71`
7
- - **Upstream version**: `2.16.0-dev.0` (per `archify/package.json` at that commit)
8
- - **Vendored on**: 2026-08-28
6
+ - **Pinned commit**: `39a21139a4661203888049d44e3b8c0da13fa576`
7
+ - **Upstream version**: `2.16.0` (per `archify/package.json` at that commit)
8
+ - **Vendored on**: 2026-08-30
9
9
 
10
10
  ## What was copied, and why
11
11
 
@@ -15,6 +15,8 @@ This directory contains source code copied verbatim from [tt-a1i/archify](https:
15
15
  renderers/architecture/render-architecture.mjs
16
16
  renderers/architecture/grid.mjs
17
17
  renderers/workflow/render-workflow.mjs
18
+ renderers/workflow/workflow-compiler.mjs
19
+ renderers/workflow/workflow-migration-geometry.mjs
18
20
  renderers/sequence/render-sequence.mjs
19
21
  renderers/dataflow/render-dataflow.mjs
20
22
  renderers/lifecycle/render-lifecycle.mjs
@@ -41,8 +43,9 @@ This re-traces the same import graph used to vendor these files originally (see
41
43
  ```bash
42
44
  git diff vendor/archify/ # review what actually changed upstream
43
45
  # update the Pinned commit / Upstream version / Vendored on fields above
44
- npm test
45
- cd demo && npm install && npm run build # rebuild and spot-check a page
46
+ npm run vendor:verify
46
47
  ```
47
48
 
49
+ `vendor:verify` runs the full test suite and rebuilds both demos. For a quicker check while iterating, `npm test -- --run` is enough.
50
+
48
51
  The script only touches files under `vendor/archify/` — it never edits this file, `git`, or anything outside `vendor/`.
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <meta name="generator" content="archify 2.16.0-dev.0">
6
+ <meta name="generator" content="archify 2.16.0">
7
7
  <title>[PROJECT NAME] Architecture Diagram</title>
8
8
  <script>
9
9
  // Resolve the theme before first paint so light-preference users don't
@@ -340,15 +340,6 @@ function validateArchitecture() {
340
340
  problems.push(resolvedBoundaryTitles.readabilityProblem);
341
341
  }
342
342
  const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership';
343
- if (arch.schema_version !== 1) problems.push('Architecture files must set "schema_version": 1.');
344
- if (arch.diagram_type !== 'architecture') problems.push('Architecture files must set "diagram_type": "architecture".');
345
- if (!arch.meta?.title) problems.push('Architecture files must include meta.title.');
346
- if (!Array.isArray(arch.components) || arch.components.length < 1) {
347
- problems.push('Architecture diagrams need at least one component.');
348
- }
349
- if (arch.connections !== undefined && !Array.isArray(arch.connections)) problems.push('Architecture "connections" must be an array.');
350
- if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.');
351
- if (arch.cards !== undefined && !Array.isArray(arch.cards)) problems.push('Architecture "cards" must be an array.');
352
343
  if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
353
344
  if (grid) {
354
345
  validateGridPlacement(arch, grid, problems);
@@ -527,13 +518,11 @@ function validateArchitecture() {
527
518
  }));
528
519
  problems.push(...cleanFlowProblems({
529
520
  relations: arch.connections,
530
- endpointIds: new Set(components.keys()),
531
521
  obstacles: components.values(),
532
522
  pathFor,
533
523
  diagramType: 'architecture',
534
524
  relationCollection: 'connections',
535
525
  obstacleKind: 'component',
536
- profile: arch.meta?.quality_profile,
537
526
  routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
538
527
  }));
539
528
  problems.push(...cleanCrossingProblems({
@@ -1046,7 +1035,7 @@ function renderLegend() {
1046
1035
  }
1047
1036
 
1048
1037
  function renderSvg() {
1049
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta, 'architecture diagram')}>
1038
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)}>
1050
1039
  ${svgAccessibleText(arch.meta, 'architecture')}
1051
1040
  ${renderDefinitions()}
1052
1041
 
@@ -117,17 +117,6 @@ for (const [index, node] of asArray(dataflow.nodes).entries()) {
117
117
 
118
118
  function validateDataflow() {
119
119
  const problems = [];
120
- if (dataflow.schema_version !== 1) problems.push('Data-flow files must set "schema_version": 1.');
121
- if (dataflow.diagram_type !== 'dataflow') problems.push('Data-flow files must set "diagram_type": "dataflow".');
122
- if (!dataflow.meta?.title) problems.push('Data-flow files must include meta.title.');
123
- if (!Array.isArray(dataflow.stages) || dataflow.stages.length < 2) {
124
- problems.push('Data-flow diagrams need at least two stages.');
125
- }
126
- if (!Array.isArray(dataflow.nodes) || dataflow.nodes.length < 2) {
127
- problems.push('Data-flow diagrams need at least two nodes.');
128
- }
129
- if (!Array.isArray(dataflow.flows)) problems.push('Data-flow diagrams must include a flows array.');
130
- if (dataflow.cards !== undefined && !Array.isArray(dataflow.cards)) problems.push('Data-flow "cards" must be an array.');
131
120
  if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
132
121
 
133
122
  const stageCount = asArray(dataflow.stages).length;
@@ -215,13 +204,11 @@ function validateDataflow() {
215
204
  }));
216
205
  problems.push(...cleanFlowProblems({
217
206
  relations: dataflow.flows,
218
- endpointIds: new Set(nodes.keys()),
219
207
  obstacles: nodes.values(),
220
208
  pathFor,
221
209
  diagramType: 'dataflow',
222
210
  relationCollection: 'flows',
223
211
  obstacleKind: 'node',
224
- profile: dataflow.meta?.quality_profile,
225
212
  routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
226
213
  }));
227
214
  problems.push(...cleanCrossingProblems({
@@ -355,7 +342,21 @@ function pathFor(flow) {
355
342
  const { fromSide, toSide } = flowSides(flow);
356
343
  const start = ports?.from || anchor(from, fromSide);
357
344
  const end = ports?.to || anchor(to, toSide);
358
- const points = [start, ...routeVia(flow, from, to, start, end), end];
345
+ // Drop consecutive duplicate points so a purely vertical (or horizontal)
346
+ // auto-route never emits a zero-length final segment — SVG derives
347
+ // marker-end orientation from the last segment, and a degenerate segment
348
+ // leaves the arrowhead angle undefined (see #169).
349
+ const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
350
+ const points = [];
351
+ for (const p of rawPoints) {
352
+ const prev = points.at(-1);
353
+ if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
354
+ points.push(p);
355
+ }
356
+ }
357
+ // Guard against an all-degenerate route (e.g. start === end): keep both
358
+ // endpoints so the path is still well-formed even if the marker is hidden.
359
+ if (points.length < 2) points.push(end);
359
360
  const routed = { d: polylinePath(points), points };
360
361
  pathCache.set(flow, routed);
361
362
  return routed;
@@ -447,7 +448,7 @@ function renderLegend() {
447
448
  }
448
449
 
449
450
  function renderSvg() {
450
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(dataflow.meta, 'data-flow diagram')}>
451
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(dataflow.meta)}>
451
452
  ${svgAccessibleText(dataflow.meta, 'dataflow')}
452
453
  ${renderDefinitions()}
453
454
 
@@ -140,13 +140,6 @@ for (const [index, state] of asArray(lifecycle.states).entries()) {
140
140
 
141
141
  function validateLifecycle() {
142
142
  const problems = [];
143
- if (lifecycle.schema_version !== 1) problems.push('Lifecycle files must set "schema_version": 1.');
144
- if (lifecycle.diagram_type !== 'lifecycle') problems.push('Lifecycle files must set "diagram_type": "lifecycle".');
145
- if (!lifecycle.meta?.title) problems.push('Lifecycle files must include meta.title.');
146
- if (!Array.isArray(lifecycle.lanes) || lifecycle.lanes.length < 1) problems.push('Lifecycle diagrams need at least one lane.');
147
- if (!Array.isArray(lifecycle.states) || lifecycle.states.length < 2) problems.push('Lifecycle diagrams need at least two states.');
148
- if (!Array.isArray(lifecycle.transitions)) problems.push('Lifecycle diagrams must include a transitions array.');
149
- if (lifecycle.cards !== undefined && !Array.isArray(lifecycle.cards)) problems.push('Lifecycle "cards" must be an array.');
150
143
  if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
151
144
 
152
145
  // The three bands are fixed at y=112/264/436. Preserve the original
@@ -246,13 +239,11 @@ function validateLifecycle() {
246
239
  }));
247
240
  problems.push(...cleanFlowProblems({
248
241
  relations: lifecycle.transitions,
249
- endpointIds: new Set(states.keys()),
250
242
  obstacles: states.values(),
251
243
  pathFor,
252
244
  diagramType: 'lifecycle',
253
245
  relationCollection: 'transitions',
254
246
  obstacleKind: 'state',
255
- profile: lifecycle.meta?.quality_profile,
256
247
  routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
257
248
  }));
258
249
  problems.push(...cleanCrossingProblems({
@@ -532,7 +523,7 @@ function renderLifecycleRail() {
532
523
  }
533
524
 
534
525
  function renderSvg() {
535
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta, 'lifecycle diagram')}>
526
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta)}>
536
527
  ${svgAccessibleText(lifecycle.meta, 'lifecycle')}
537
528
  ${renderDefinitions()}
538
529
 
@@ -134,20 +134,7 @@ function messagePath(message) {
134
134
 
135
135
  function validateSequence() {
136
136
  const problems = [];
137
- if (sequence.schema_version !== 1) problems.push('Sequence files must set "schema_version": 1.');
138
- if (sequence.diagram_type !== 'sequence') problems.push('Sequence files must set "diagram_type": "sequence".');
139
- if (!sequence.meta?.title) problems.push('Sequence files must include meta.title.');
140
- if (!Array.isArray(sequence.participants) || sequence.participants.length < 2) {
141
- problems.push('Sequence diagrams need at least two participants.');
142
- }
143
137
  if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
144
- if (!Array.isArray(sequence.messages) || sequence.messages.length < 1) {
145
- problems.push('Sequence diagrams need at least one message.');
146
- }
147
- if (sequence.cards !== undefined && !Array.isArray(sequence.cards)) problems.push('Sequence "cards" must be an array.');
148
- for (const arr of ['segments', 'activations']) {
149
- if (sequence[arr] !== undefined && !Array.isArray(sequence[arr])) problems.push(`Sequence "${arr}" must be an array.`);
150
- }
151
138
 
152
139
  if (layout.lifelineBottom - layout.lifelineTop < 120) {
153
140
  problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
@@ -188,13 +175,11 @@ function validateSequence() {
188
175
  // segment bands remain intentional pass-through geometry and are excluded.
189
176
  problems.push(...cleanFlowProblems({
190
177
  relations: sequence.messages,
191
- endpointIds: new Set(participants.keys()),
192
178
  obstacles: participants.values(),
193
179
  pathFor: messagePath,
194
180
  diagramType: 'sequence',
195
181
  relationCollection: 'messages',
196
182
  obstacleKind: 'participant header',
197
- profile: sequence.meta?.quality_profile,
198
183
  clearance: 0,
199
184
  routeHint: 'move the message y below the participant headers or reorder participants'
200
185
  }));
@@ -427,7 +412,7 @@ function renderLegend() {
427
412
 
428
413
  function renderSvg() {
429
414
  const participantList = [...participants.values()];
430
- return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(sequence.meta, 'sequence diagram')}>
415
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(sequence.meta)}>
431
416
  ${svgAccessibleText(sequence.meta, 'sequence')}
432
417
  ${renderDefinitions()}
433
418
 
@@ -26,8 +26,6 @@ export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = p
26
26
  validateEngineeringProfile(diagramType, diagram);
27
27
  const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
28
28
  const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
29
- // Optional chaining: in degraded mode (no ajv) malformed input must still
30
- // reach the renderer's friendly layout checks instead of crashing here.
31
29
  const outputRequest = {
32
30
  requestedOutput: argv[3],
33
31
  authoredOutput: diagram.meta?.output,
@@ -148,7 +146,7 @@ export function validateGuidedViews(diagramType, diagram) {
148
146
  }
149
147
 
150
148
  // Accessible name for the generated diagram SVG.
151
- export function svgRootAttrs(meta, kind) {
149
+ export function svgRootAttrs(meta) {
152
150
  const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
153
151
  const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
154
152
  const engineeringProfile = meta.engineering_profile
@@ -5,6 +5,7 @@ const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
5
5
  const recorded = [];
6
6
  const recordedMessages = new Set();
7
7
  const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
8
+ let recordingSuppressionDepth = 0;
8
9
 
9
10
  function plainObject(value) {
10
11
  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
@@ -22,17 +23,29 @@ function normalizedDiagnostic(diagnostic) {
22
23
  supportedFixes: Array.isArray(diagnostic?.supportedFixes)
23
24
  ? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
24
25
  : [],
26
+ ...(Array.isArray(diagnostic?.suppresses) ? {
27
+ suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
28
+ } : {}),
25
29
  };
26
30
  }
27
31
 
28
32
  export function recordDiagnostic(diagnostic) {
29
- if (!DIAGNOSTIC_MODE) return;
33
+ if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
30
34
  const normalized = normalizedDiagnostic(diagnostic);
31
35
  if (recordedMessages.has(normalized.message)) return;
32
36
  recordedMessages.add(normalized.message);
33
37
  recorded.push(normalized);
34
38
  }
35
39
 
40
+ export function withDiagnosticRecordingSuppressed(callback) {
41
+ recordingSuppressionDepth += 1;
42
+ try {
43
+ return callback();
44
+ } finally {
45
+ recordingSuppressionDepth -= 1;
46
+ }
47
+ }
48
+
36
49
  export function throwDiagnosticError(message, diagnostics) {
37
50
  for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
38
51
  const error = new Error(message);
@@ -42,17 +55,15 @@ export function throwDiagnosticError(message, diagnostics) {
42
55
 
43
56
  export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {} } = {}) {
44
57
  const messages = (problems || []).map((problem) => String(problem));
45
- for (const message of messages) {
46
- recordDiagnostic({
58
+ const diagnostics = messages.map((message) => normalizedDiagnostic({
47
59
  code,
48
60
  severity: 'error',
49
61
  message,
50
62
  subject,
51
63
  evidence: {},
52
64
  supportedFixes: [],
53
- });
54
- }
55
- throw new Error(`${prefix}:\n- ${messages.join('\n- ')}`);
65
+ }));
66
+ throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
56
67
  }
57
68
 
58
69
  function fallbackDiagnostic(error) {