@brignano/driftwood 0.0.1 → 0.1.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/README.md CHANGED
@@ -23,8 +23,8 @@ Nobody owns the middle. driftwood is the middle.
23
23
 
24
24
  | Engine | Needs | Output |
25
25
  |---|---|---|
26
- | `graphviz` (native) | `dot` on PATH | SVG. Preferred when present — faster on very large graphs, honours a site's own Graphviz build |
27
- | `graphviz` (WASM) | **nothing — bundled** | SVG. The default |
26
+ | `graphviz` (native) | `dot` on PATH | SVG, icons and all. Preferred when present — faster on very large graphs, honours a site's own Graphviz build |
27
+ | `graphviz` (WASM) | **nothing — bundled** | SVG with category icons. The default |
28
28
  | `dot` | nothing | DOT source (it's just text) |
29
29
  | `mermaid` | nothing | Mermaid, renders natively in GitHub |
30
30
 
@@ -71,13 +71,15 @@ flowchart LR
71
71
  REC["Reconciler<br/>declared vs observed"]
72
72
  end
73
73
  subgraph Renderers["Renderers — present"]
74
- MMD["Mermaid"]
74
+ GV["Graphviz SVG"]
75
+ MMD["Mermaid · DOT"]
75
76
  LATER["2D live · 3D<br/>(not yet)"]
76
77
  end
77
78
  TF --> REC
78
79
  SOON -.-> REC
79
80
  MODEL --> REC
80
81
  REC -->|"divergence"| MODEL
82
+ MODEL --> GV
81
83
  MODEL --> MMD
82
84
  MODEL -.-> LATER
83
85
  ```
@@ -98,11 +100,13 @@ Requires Node 20+. Runtime dependencies are `commander`, `yaml`, `zod`, and `@hp
98
100
  ### Import a model from Terraform state
99
101
 
100
102
  ```bash
101
- npx tsx src/cli.ts import terraform examples/aws-config.tfstate.json \
102
- --name aws-config -o examples/architecture.yaml
103
+ npx tsx src/cli.ts import terraform examples/orders-platform.tfstate.json \
104
+ --name orders-platform -o examples/architecture.yaml
103
105
  ```
104
106
 
105
- Entity ids are Terraform addresses (`aws_s3_bucket.emails`). That's deliberate: the address is stable across plans, readable in a diff, and sidesteps the identity-resolution problem that kills CMDBs. Edges come from Terraform's own `dependencies`.
107
+ Entity ids are Terraform addresses (`aws_s3_bucket.assets`). That's deliberate: the address is stable across plans, readable in a diff, and sidesteps the identity-resolution problem that kills CMDBs. Edges come from Terraform's own `dependencies`.
108
+
109
+ The worked example in `examples/` is a fictional e-commerce platform — CloudFront and Route 53 at the edge, an ALB in front of two ECS services, Postgres, Redis, DynamoDB and S3 behind them, and an SQS/SNS order pipeline with a Lambda worker. 45 resources: enough that the view mechanism is doing real work rather than decorating a diagram that fits on a page anyway.
106
110
 
107
111
  ### Validate
108
112
 
@@ -115,54 +119,103 @@ Catches schema errors, duplicate ids, edges pointing at entities that don't exis
115
119
  ### Render
116
120
 
117
121
  ```bash
118
- npx tsx src/cli.ts render examples/architecture.yaml --view email
122
+ npx tsx src/cli.ts render examples/architecture.yaml --view app -o app.svg
119
123
  ```
120
124
 
125
+ ![The app view: an ALB listener and target group feeding two ECS services and their task definitions](docs/app.svg)
126
+
127
+ Nodes are drawn with a category icon, the entity name, and its `kind` underneath. The icons are **drawn in this repo and chosen by category** — database, queue, load balancer — not by vendor: no icon pack to install, nothing to license, and one glyph that fits an RDS instance, a Cloud SQL instance and an on-prem Postgres alike. Colour groups the categories into families (edge, compute, data, messaging, security), so a diagram reads as a few zones instead of thirty unrelated boxes. Use `--no-icons` for plain boxes.
128
+
129
+ The icons are inlined into the SVG rather than handed to Graphviz as `image=` attributes, because the native `dot` binary resolves those as filesystem paths — a `data:` URI would work on the WASM tier and break on the native one. Post-processing the SVG means both tiers draw the same picture, and the result stays self-contained — no external references, no base64 bloat.
130
+
131
+ Every image in this README is generated from `examples/architecture.yaml` by `npm run docs`, and CI re-runs it and fails if the result differs from what is committed. A stale picture of your own output is worse than no picture, so the renders are checked against their source rather than trusted — the drift gate's own argument, one level up.
132
+
133
+ The `async` view through Mermaid, which renders natively in a pull request:
134
+
135
+ <!-- generated:async -->
121
136
  ```mermaid
122
137
  flowchart LR
123
138
  subgraph n_lambda["lambda"]
124
- n_aws_lambda_function_email_forwarder["email-forwarder<br/>aws_lambda_function"]
139
+ n_aws_lambda_event_source_mapping_orders[/"orders<br/>aws_lambda_event_source_mapping"/]
140
+ n_aws_lambda_function_order_worker["shop-order-worker<br/>aws_lambda_function"]
125
141
  end
126
- subgraph n_s3["s3"]
127
- n_aws_s3_bucket_emails[("brignano.io-emails<br/>aws_s3_bucket")]
142
+ subgraph n_sns["sns"]
143
+ n_aws_sns_topic_subscription_orders[/"orders<br/>aws_sns_topic_subscription"/]
144
+ n_aws_sns_topic_order_events[/"shop-order-events<br/>aws_sns_topic"/]
128
145
  end
129
- subgraph n_ses["ses"]
130
- n_aws_ses_receipt_rule_set_main["main<br/>aws_ses_receipt_rule_set"]
131
- n_aws_ses_receipt_rule_archive["archive-hi<br/>aws_ses_receipt_rule"]
132
- n_aws_ses_receipt_rule_forward["forward-hi<br/>aws_ses_receipt_rule"]
133
- n_aws_ses_receipt_rule_noreply["bounce-noreply<br/>aws_ses_receipt_rule"]
146
+ subgraph n_sqs["sqs"]
147
+ n_aws_sqs_queue_orders[/"shop-orders<br/>aws_sqs_queue"/]
148
+ n_aws_sqs_queue_orders_dlq[/"shop-orders-dlq<br/>aws_sqs_queue"/]
134
149
  end
135
- n_aws_lambda_function_email_forwarder --> n_aws_s3_bucket_emails
136
- n_aws_ses_receipt_rule_archive --> n_aws_s3_bucket_emails
137
- n_aws_ses_receipt_rule_archive --> n_aws_ses_receipt_rule_set_main
138
- n_aws_ses_receipt_rule_forward --> n_aws_lambda_function_email_forwarder
139
- n_aws_ses_receipt_rule_forward --> n_aws_ses_receipt_rule_set_main
140
- n_aws_ses_receipt_rule_noreply --> n_aws_ses_receipt_rule_set_main
150
+ n_aws_lambda_event_source_mapping_orders --> n_aws_lambda_function_order_worker
151
+ n_aws_lambda_event_source_mapping_orders --> n_aws_sqs_queue_orders
152
+ n_aws_lambda_function_order_worker --> n_aws_sqs_queue_orders
153
+ n_aws_sns_topic_subscription_orders --> n_aws_sns_topic_order_events
154
+ n_aws_sns_topic_subscription_orders --> n_aws_sqs_queue_orders
155
+ n_aws_sqs_queue_orders --> n_aws_sqs_queue_orders_dlq
156
+ class n_aws_lambda_event_source_mapping_orders f_messaging;
157
+ class n_aws_lambda_function_order_worker f_compute;
158
+ class n_aws_sns_topic_subscription_orders f_messaging;
159
+ class n_aws_sns_topic_order_events f_messaging;
160
+ class n_aws_sqs_queue_orders f_messaging;
161
+ class n_aws_sqs_queue_orders_dlq f_messaging;
162
+ classDef f_compute fill:#fffbeb,stroke:#d97706,color:#0f172a;
163
+ classDef f_messaging fill:#f5f3ff,stroke:#7c3aed,color:#0f172a;
141
164
  ```
165
+ <!-- /generated:async -->
166
+
167
+ Mermaid has no icon primitive, so it maps the *same* categorisation onto shapes and colours — a queue must not be a queue in one engine and a cylinder in the other, or the two pictures stop describing the same system.
168
+
169
+ #### The rest of the views
170
+
171
+ | View | What it scopes to | File |
172
+ |---|---|---|
173
+ | `edge` | Route 53, CloudFront, ACM, WAF, the ALB | [svg](docs/edge.svg) |
174
+ | `app` | Load balancer through to the ECS services | [svg](docs/app.svg) |
175
+ | `data` | Postgres, Redis, DynamoDB, S3 | [svg](docs/data.svg) |
176
+ | `async` | The SQS/SNS/Lambda order pipeline | [svg](docs/async.svg) |
177
+ | `network` | VPC, subnets, security groups, NAT | [svg](docs/network.svg) |
178
+ | `context` | Everything, minus IAM and CloudWatch noise | [svg](docs/context.svg) |
142
179
 
143
- **Views exist from v1, not as a later optimization.** Flat Mermaid becomes unreadable past roughly 150 nodes, and any real enterprise graph blows through that immediately. A view is a scoped slice matching entity ids or groups, with a trailing `*` wildcard.
180
+ **Views exist from v1, not as a later optimization.** Flat Mermaid becomes unreadable past roughly 150 nodes, and any real enterprise graph blows through that immediately. A view is a scoped slice matching entity ids or groups, with a trailing `*` wildcard. The example model ships six — `context`, `edge`, `app`, `data`, `async`, `network` — and even at 45 entities the difference between a view and the whole graph is the difference between a diagram and a wall.
144
181
 
145
182
  ### Reconcile — the point of the whole thing
146
183
 
147
184
  ```bash
148
185
  npx tsx src/cli.ts reconcile examples/architecture.yaml \
149
- --terraform examples/aws-config.drifted.tfstate.json
186
+ --terraform examples/orders-platform.drifted.tfstate.json
150
187
  ```
151
188
 
189
+ Two resources created by hand during an incident, one bucket deleted, one database renamed:
190
+
152
191
  ```markdown
153
192
  ## Architecture drift detected
154
193
 
155
194
  ### Present in infrastructure, missing from the model (2)
156
- - `aws_cloudfront_distribution.cdn` - aws_cloudfront_distribution (d123.cloudfront.net)
157
- - `aws_sqs_queue.dlq` - aws_sqs_queue (email-forwarder-dlq)
195
+
196
+ - `aws_elasticache_replication_group.sessions_failover` - aws_elasticache_replication_group (shop-sessions-ha)
197
+ - `aws_sqs_queue.payments` - aws_sqs_queue (shop-payments)
158
198
 
159
199
  ### Declared in the model, not found in infrastructure (1)
160
- - `aws_ses_receipt_rule.noreply` - aws_ses_receipt_rule (bounce-noreply)
200
+
201
+ - `aws_s3_bucket.uploads` - aws_s3_bucket (shop-example-com-uploads)
202
+
203
+ ### Changed (1)
204
+
205
+ | Entity | Field | Declared | Observed |
206
+ |---|---|---|---|
207
+ | `aws_db_instance.orders` | name | shop-orders-prod | shop-orders-prod-v2 |
161
208
 
162
209
  ### Relationships
163
- - **added** `aws_cloudfront_distribution.cdn` -> `aws_s3_bucket.emails`
164
- - **added** `aws_sqs_queue.dlq` -> `aws_lambda_function.email_forwarder`
165
- - **removed** `aws_ses_receipt_rule.noreply` -> `aws_ses_receipt_rule_set.main`
210
+
211
+ - **added** `aws_elasticache_replication_group.sessions_failover` -> `aws_security_group.data`
212
+ - **added** `aws_elasticache_replication_group.sessions_failover` -> `aws_subnet.private_a`
213
+ - **added** `aws_sqs_queue.payments` -> `aws_kms_key.data`
214
+ - **removed** `aws_iam_role_policy.order_worker` -> `aws_s3_bucket.uploads`
215
+ - **removed** `aws_lambda_function.order_worker` -> `aws_s3_bucket.uploads`
216
+ - **removed** `aws_s3_bucket.uploads` -> `aws_kms_key.data`
217
+
218
+ _Ignored by policy: 1 entities, 0 edges._
166
219
  ```
167
220
 
168
221
  Exits **1** on drift and **0** when clean, so it works directly as a CI gate. Output is markdown because its destination is a pull request body.
@@ -264,20 +317,23 @@ src/
264
317
  providers/dynatrace.ts runtime — Smartscape topology, read-only
265
318
  render/types.ts the renderer extension point (probe + render)
266
319
  render/select.ts shared view scoping
320
+ render/icons.ts category icons, the kind -> icon/colour table, SVG injection
267
321
  render/mermaid.ts always available
268
322
  render/dot.ts DOT source, always available
269
323
  render/graphviz.ts SVG via native dot or WASM, with tier detection
270
324
  reconcile/index.ts declared vs observed -> drift report
271
325
  config.ts driftwood.config.yaml
272
326
  cli.ts validate · render · engines · providers · import · reconcile
273
- examples/ a worked AWS example, a drifted copy, and a config
327
+ examples/ a worked 45-resource AWS platform, a drifted copy, and a config
328
+ scripts/render-docs.ts regenerates docs/ and the README's embedded render
329
+ docs/ committed renders of the example, kept current by CI
274
330
  .claude/skills/ add-provider and add-renderer walkthroughs for agents
275
331
  ```
276
332
 
277
333
  ## Development
278
334
 
279
335
  ```bash
280
- npm test # 76 tests
336
+ npm test # 98 tests
281
337
  npm run typecheck
282
338
  npm run build
283
339
  ```
@@ -289,6 +345,7 @@ Built:
289
345
  - [x] The model, with a schema and a real validator
290
346
  - [x] Pluggable provider registry — Terraform (any platform) and Dynatrace built in
291
347
  - [x] Pluggable renderer registry — Graphviz bundled and working out of the box, with Mermaid/DOT fallback
348
+ - [x] Category icons and a family palette, shared by every engine
292
349
  - [x] Multi-provider merge with provenance, explicit aliases, and conflict reporting
293
350
  - [x] Declarative `driftwood.config.yaml` wiring
294
351
  - [x] Reconciler with an explicit drift policy, wired as a CI gate
package/dist/cli.js CHANGED
@@ -38,11 +38,12 @@ program
38
38
  .option('--view <id>', 'render a single named view')
39
39
  .option('--engine <name>', 'auto | mermaid | dot | graphviz', 'auto')
40
40
  .option('--direction <dir>', 'LR or TD', 'LR')
41
+ .option('--no-icons', 'draw plain boxes instead of category icons')
41
42
  .option('-o, --out <file>', 'write to a file instead of stdout')
42
43
  .action(async (path, opts) => {
43
44
  const model = requireModel(path);
44
45
  const direction = opts.direction === 'TD' ? 'TD' : 'LR';
45
- const result = await render(model, { view: opts.view, direction }, opts.engine);
46
+ const result = await render(model, { view: opts.view, direction, icons: opts.icons }, opts.engine);
46
47
  if (result.fellBackFrom) {
47
48
  console.error(`note: ${result.fellBackFrom} unavailable, using ${result.renderer.name} (${result.via})`);
48
49
  }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export { importTerraformState, parseTerraformState } from './providers/terraform
13
13
  export type { ImportOptions } from './providers/terraform.js';
14
14
  export { toModel as dynatraceToModel } from './providers/dynatrace.js';
15
15
  export { renderMermaid } from './render/mermaid.js';
16
+ export { iconFor, familyFor, styleFor, iconSvg, ICONS, PALETTE, ICON_SIZE } from './render/icons.js';
17
+ export type { IconKey, Family, FamilyStyle } from './render/icons.js';
16
18
  export { renderDot } from './render/dot.js';
17
19
  export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
18
20
  export { selectEntities } from './render/select.js';
package/dist/index.js CHANGED
@@ -12,6 +12,12 @@ export { importTerraformState, parseTerraformState } from './providers/terraform
12
12
  export { toModel as dynatraceToModel } from './providers/dynatrace.js';
13
13
  // Renderers
14
14
  export { renderMermaid } from './render/mermaid.js';
15
+ // How an entity is classified and coloured. A third-party renderer is told to
16
+ // classify through this rather than inventing a second table, so it has to be
17
+ // reachable from the package entry point, not just from inside the repo.
18
+ // The marker/injection mechanism stays internal: it is a private contract
19
+ // between `renderDot` and the Graphviz renderer, not API to build on.
20
+ export { iconFor, familyFor, styleFor, iconSvg, ICONS, PALETTE, ICON_SIZE } from './render/icons.js';
15
21
  export { renderDot } from './render/dot.js';
16
22
  export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
17
23
  export { selectEntities } from './render/select.js';
@@ -29,7 +29,17 @@ function groupFor(type) {
29
29
  * resource's local name.
30
30
  */
31
31
  function displayName(r, attrs) {
32
- const candidates = ['name', 'bucket', 'domain_name', 'function_name', 'identifier'];
32
+ const candidates = [
33
+ 'name',
34
+ 'bucket',
35
+ 'domain_name',
36
+ 'function_name',
37
+ 'identifier',
38
+ 'cluster_id',
39
+ 'replication_group_id',
40
+ 'alarm_name',
41
+ 'family',
42
+ ];
33
43
  for (const key of candidates) {
34
44
  const v = attrs?.[key];
35
45
  if (typeof v === 'string' && v.length > 0)
@@ -1,5 +1,6 @@
1
1
  import { defineRenderer } from './types.js';
2
2
  import { selectEntities } from './select.js';
3
+ import { ICON_SIZE, MARKER_PREFIX, iconFor, marker, styleFor } from './icons.js';
3
4
  /**
4
5
  * Graphviz DOT source.
5
6
  *
@@ -7,39 +8,61 @@ import { selectEntities } from './select.js';
7
8
  * matters: `dot` (this renderer) always works, while `graphviz` (rendering DOT
8
9
  * to SVG) needs an engine. So even a locked-down machine can produce DOT for
9
10
  * someone else to render, and nothing is lost by not having the binary.
11
+ *
12
+ * Nodes are HTML-like labels rather than plain `label="a\nb"` strings for two
13
+ * reasons. It allows a name/kind type hierarchy — one line at reading size and
14
+ * one small and muted — instead of two lines competing at the same weight; and
15
+ * it allows a fixed-size cell to be reserved for an icon (see `icons.ts`),
16
+ * which Graphviz then accounts for during layout. Both are what stop nodes
17
+ * coming out as long flat slabs: a box holding an icon above two short lines
18
+ * is roughly 3:2, while one holding a single wide line of text is closer to
19
+ * 6:1, and a graph full of 6:1 boxes is what reads as "squashed".
10
20
  */
21
+ const INK = '#0f172a';
22
+ const MUTED = '#64748b';
23
+ const CLUSTER_LINE = '#dbe2ea';
24
+ const CLUSTER_FILL = '#f8fafc';
25
+ const EDGE_COLOR = '#94a3b8';
26
+ /** Border and fill applied on top of the family colours when health is known. */
27
+ const HEALTH_COLORS = {
28
+ healthy: { accent: '#16a34a', tint: '#f0fdf4' },
29
+ degraded: { accent: '#d97706', tint: '#fffbeb' },
30
+ down: { accent: '#dc2626', tint: '#fef2f2' },
31
+ };
11
32
  function quote(text) {
12
33
  return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
13
34
  }
14
- const SHAPES = [
15
- [/(s3|bucket|rds|dynamodb|database|efs|volume|storage)/i, 'cylinder'],
16
- [/(sqs|sns|queue|topic|kinesis|kafka|eventbridge)/i, 'parallelogram'],
17
- [/(route53|dns|cloudfront|vpc|subnet|lb|gateway|cdn|zone|record)/i, 'ellipse'],
18
- [/(iam|role|policy|secret|kms|cert|acm|auth)/i, 'hexagon'],
19
- ];
20
- function shapeFor(kind) {
21
- for (const [pattern, shape] of SHAPES)
22
- if (pattern.test(kind))
23
- return shape;
24
- return 'box';
35
+ /**
36
+ * HTML-like labels are parsed as XML, so the five entity references matter —
37
+ * an unescaped `&` in a name is a hard parse error, not a cosmetic problem.
38
+ * The icon marker prefix is stripped here too: a model is data, and data must
39
+ * not be able to place its own artwork in the output.
40
+ */
41
+ function xml(text) {
42
+ return text
43
+ .split(MARKER_PREFIX)
44
+ .join('')
45
+ .replace(/&/g, '&amp;')
46
+ .replace(/</g, '&lt;')
47
+ .replace(/>/g, '&gt;')
48
+ .replace(/"/g, '&quot;');
25
49
  }
26
- const HEALTH_COLORS = {
27
- healthy: '#0f9d58',
28
- degraded: '#f4b400',
29
- down: '#db4437',
30
- };
31
50
  export function renderDot(model, ctx = {}) {
32
51
  const view = ctx.view ? model.views.find((v) => v.id === ctx.view) : undefined;
33
52
  if (ctx.view && !view) {
34
53
  throw new Error(`unknown view: ${ctx.view} (have: ${model.views.map((v) => v.id).join(', ') || 'none'})`);
35
54
  }
55
+ // Off by default: DOT source is meant to be renderable by any Graphviz, and
56
+ // a marker only becomes artwork if driftwood post-processes the SVG itself.
57
+ const withIcons = ctx.icons === true;
36
58
  const entities = selectEntities(model, view);
37
59
  const visible = new Set(entities.map((e) => e.id));
38
60
  const lines = [
39
61
  `digraph "${quote(model.name)}" {`,
40
- ` rankdir=${ctx.direction === 'TD' ? 'TB' : 'LR'};`,
41
- ' node [fontname="Helvetica" style=filled fillcolor="#ffffff"];',
42
- ' edge [fontname="Helvetica" color="#666666"];',
62
+ ` graph [rankdir=${ctx.direction === 'TD' ? 'TB' : 'LR'} fontname="Helvetica" fontsize=11 bgcolor="white"`,
63
+ ' nodesep=0.35 ranksep=0.75 pad=0.3 newrank=true];',
64
+ ' node [shape=box style="rounded,filled" fontname="Helvetica" margin=0.06 penwidth=1.2];',
65
+ ` edge [fontname="Helvetica" fontsize=9 fontcolor="${MUTED}" color="${EDGE_COLOR}" penwidth=1.1 arrowsize=0.7];`,
43
66
  ];
44
67
  const grouped = new Map();
45
68
  const ungrouped = [];
@@ -53,16 +76,29 @@ export function renderDot(model, ctx = {}) {
53
76
  ungrouped.push(e);
54
77
  }
55
78
  const nodeLine = (e, indent) => {
56
- const label = `${e.name ?? e.id}\\n${e.kind}`;
57
79
  const health = ctx.health?.[e.id];
58
- const color = health ? ` fillcolor="${HEALTH_COLORS[health]}" fontcolor="#ffffff"` : '';
59
- return `${indent}"${quote(e.id)}" [label="${quote(label)}" shape=${shapeFor(e.kind)}${color}];`;
80
+ const style = (health ? HEALTH_COLORS[health] : undefined) ?? styleFor(e.kind);
81
+ const penwidth = health ? 2.2 : 1.2;
82
+ const rows = [];
83
+ if (withIcons) {
84
+ // A fixed-size cell so Graphviz reserves exactly the room the icon will
85
+ // occupy; the 1pt marker inside it is what `injectIcons` finds and
86
+ // replaces, and is invisible if it somehow survives.
87
+ rows.push(`<TR><TD FIXEDSIZE="TRUE" WIDTH="${ICON_SIZE + 10}" HEIGHT="${ICON_SIZE + 6}">` +
88
+ `<FONT POINT-SIZE="1" COLOR="${CLUSTER_FILL}">${marker(iconFor(e.kind))}</FONT></TD></TR>`);
89
+ }
90
+ rows.push(`<TR><TD><FONT POINT-SIZE="11" COLOR="${INK}"><B>${xml(e.name ?? e.id)}</B></FONT></TD></TR>`);
91
+ rows.push(`<TR><TD><FONT POINT-SIZE="8" COLOR="${MUTED}">${xml(e.kind)}</FONT></TD></TR>`);
92
+ const label = `<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="1">${rows.join('')}</TABLE>>`;
93
+ return (`${indent}"${quote(e.id)}" [label=${label} fillcolor="${style.tint}" ` +
94
+ `color="${style.accent}" penwidth=${penwidth}];`);
60
95
  };
61
96
  let clusterIndex = 0;
62
97
  for (const [group, members] of [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b))) {
63
98
  lines.push(` subgraph cluster_${clusterIndex++} {`);
64
- lines.push(` label="${quote(group)}";`);
65
- lines.push(' style=rounded; color="#999999";');
99
+ lines.push(` label=<<FONT POINT-SIZE="11" COLOR="${MUTED}">${xml(group)}</FONT>>;`);
100
+ lines.push(` style="rounded,filled"; fillcolor="${CLUSTER_FILL}"; color="${CLUSTER_LINE}";`);
101
+ lines.push(' penwidth=1; labeljust=l; margin=14;');
66
102
  for (const e of members)
67
103
  lines.push(nodeLine(e, ' '));
68
104
  lines.push(' }');
@@ -42,6 +42,15 @@ export declare function loadWasmGraphviz(): Promise<WasmGraphviz | undefined>;
42
42
  /** Test seam: forget any cached WASM instance. */
43
43
  export declare function resetWasmCache(): void;
44
44
  export declare function detectTier(): Promise<GraphvizTier>;
45
- export declare function renderGraphvizSvg(model: Model, ctx?: RenderContext): Promise<string>;
45
+ /**
46
+ * @param forceTier Pin the tier instead of detecting one. The committed
47
+ * example renders use this: layout is byte-deterministic for a given Graphviz
48
+ * build, but a native `dot` and the bundled WASM build are two different
49
+ * builds, so a contributor with Graphviz installed would otherwise regenerate
50
+ * `docs/` into a diff that fails the freshness check in CI, where no `dot`
51
+ * exists. Pinning is for reproducibility only — nothing in normal rendering
52
+ * uses it, and `auto` must keep choosing for itself.
53
+ */
54
+ export declare function renderGraphvizSvg(model: Model, ctx?: RenderContext, forceTier?: Exclude<GraphvizTier, 'none'>): Promise<string>;
46
55
  export declare const graphvizRenderer: import("./types.js").Renderer;
47
56
  export {};
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { renderDot } from './dot.js';
3
+ import { PALETTE, injectIcons, styleFor } from './icons.js';
3
4
  import { defineRenderer } from './types.js';
4
5
  /** Runs `dot -V` to see whether a usable native Graphviz is on PATH. */
5
6
  export function probeNativeDot(command = 'dot') {
@@ -82,15 +83,35 @@ function runNativeDot(source, engine, format) {
82
83
  child.stdin.end();
83
84
  });
84
85
  }
85
- export async function renderGraphvizSvg(model, ctx = {}) {
86
- const source = renderDot(model, ctx);
87
- const tier = await detectTier();
86
+ /**
87
+ * @param forceTier Pin the tier instead of detecting one. The committed
88
+ * example renders use this: layout is byte-deterministic for a given Graphviz
89
+ * build, but a native `dot` and the bundled WASM build are two different
90
+ * builds, so a contributor with Graphviz installed would otherwise regenerate
91
+ * `docs/` into a diff that fails the freshness check in CI, where no `dot`
92
+ * exists. Pinning is for reproducibility only — nothing in normal rendering
93
+ * uses it, and `auto` must keep choosing for itself.
94
+ */
95
+ export async function renderGraphvizSvg(model, ctx = {}, forceTier) {
96
+ // Icons are on unless switched off: this renderer produces the finished
97
+ // picture, so it is the one place a marker can actually become artwork.
98
+ const withIcons = ctx.icons !== false;
99
+ const source = renderDot(model, { ...ctx, icons: withIcons });
100
+ // Colour by the entity's own kind rather than by anything in the SVG, so the
101
+ // icon always matches the border Graphviz drew around it.
102
+ const accents = new Map(model.entities.map((e) => [e.id, styleFor(e.kind).accent]));
103
+ const finish = (svg) => withIcons ? injectIcons(svg, (id) => accents.get(id) ?? PALETTE.other.accent) : svg;
104
+ const tier = forceTier ?? (await detectTier());
88
105
  if (tier === 'native')
89
- return runNativeDot(source, 'dot', 'svg');
106
+ return finish(await runNativeDot(source, 'dot', 'svg'));
90
107
  if (tier === 'wasm') {
91
108
  const gv = await loadWasmGraphviz();
92
109
  if (gv)
93
- return gv.layout(source, 'svg', 'dot');
110
+ return finish(gv.layout(source, 'svg', 'dot'));
111
+ // Only reachable when the caller pinned `wasm`; detection would have
112
+ // returned 'none'. Say which tier was asked for, not just that it failed.
113
+ throw new Error('the bundled WASM Graphviz was requested but could not load — WebAssembly ' +
114
+ 'is disabled in this runtime (for example `node --jitless`).');
94
115
  }
95
116
  throw new Error('Graphviz is unavailable because WebAssembly is disabled in this runtime ' +
96
117
  '(for example `node --jitless`). Install the `dot` binary, or use ' +
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Icons for rendered diagrams.
3
+ *
4
+ * Two constraints shape this module, and they rule out the obvious answers:
5
+ *
6
+ * 1. **No native dependencies and no downloads.** Vendor icon sets (the AWS
7
+ * Architecture Icons, Azure's, GCP's) are large binary asset packs with
8
+ * their own trademark terms, so they are not something this tool can
9
+ * quietly vendor. These icons are therefore drawn here, by category —
10
+ * "database", "queue", "load balancer" — not by vendor. A category set
11
+ * also survives contact with a multi-cloud estate: an on-prem Postgres,
12
+ * an RDS instance and a Cloud SQL instance all want the same glyph.
13
+ * 2. **Engine parity.** The picture must be identical whether Graphviz ran
14
+ * as a native binary or as the bundled WASM build. Graphviz's own
15
+ * `image=` attribute is not viable for that: the native binary resolves
16
+ * it as a filesystem path, so a `data:` URI works in one tier and fails
17
+ * in the other.
18
+ *
19
+ * So the icon is not handed to Graphviz at all. `renderDot` reserves a
20
+ * fixed-size cell in the node's HTML-like label containing a 1pt marker token,
21
+ * Graphviz lays out the graph knowing exactly how much room the icon needs,
22
+ * and `injectIcons` swaps each marker for inline SVG afterwards. Both tiers
23
+ * emit the same SVG structure, so both get the same picture, and the result
24
+ * stays a self-contained SVG with no external references and no base64 bloat.
25
+ */
26
+ /**
27
+ * The visual family an entity belongs to. Icons carry the specific meaning;
28
+ * families carry the colour, so a diagram reads as a handful of zones rather
29
+ * than thirty unrelated hues.
30
+ */
31
+ export type Family = 'edge' | 'compute' | 'data' | 'messaging' | 'security' | 'observability' | 'other';
32
+ export interface FamilyStyle {
33
+ /** Border and icon colour. */
34
+ accent: string;
35
+ /** Node fill — a wash of the accent, light enough for 9pt text on top. */
36
+ tint: string;
37
+ }
38
+ export declare const PALETTE: Record<Family, FamilyStyle>;
39
+ export type IconKey = 'dns' | 'cdn' | 'api' | 'loadbalancer' | 'network' | 'firewall' | 'certificate' | 'identity' | 'secret' | 'compute' | 'function' | 'container' | 'cluster' | 'service' | 'database' | 'storage' | 'cache' | 'queue' | 'topic' | 'events' | 'email' | 'monitoring' | 'user' | 'generic';
40
+ interface Icon {
41
+ family: Family;
42
+ /** SVG shapes on a 24x24 canvas. Stroke and fill are set by the wrapper. */
43
+ body: string;
44
+ }
45
+ /**
46
+ * Drawn on a 24x24 grid, stroked rather than filled so a single accent colour
47
+ * carries the whole glyph and one wrapper `<g>` can restyle it.
48
+ */
49
+ export declare const ICONS: Record<IconKey, Icon>;
50
+ export declare function iconFor(kind: string): IconKey;
51
+ export declare function familyFor(kind: string): Family;
52
+ export declare function styleFor(kind: string): FamilyStyle;
53
+ /**
54
+ * Namespaced so it cannot collide with anything a provider might legitimately
55
+ * put in an entity name. `renderDot` additionally strips the prefix from
56
+ * labels, so a hostile model cannot inject its own icon.
57
+ *
58
+ * Deliberately letters, digits, `_` and `@` only: Graphviz XML-escapes SVG
59
+ * text, so a hyphen would come back out as `&#45;` and the marker would no
60
+ * longer match itself.
61
+ */
62
+ export declare const MARKER_PREFIX = "@@dwicon_";
63
+ export declare function marker(key: IconKey): string;
64
+ /** Rendered size of the icon, in points, matching the cell `renderDot` reserves. */
65
+ export declare const ICON_SIZE = 24;
66
+ export declare function iconSvg(key: IconKey, x: number, y: number, color: string, size?: number): string;
67
+ /**
68
+ * Replaces every icon marker in a Graphviz-produced SVG with inline vector
69
+ * artwork. Anything it does not recognise is left exactly as it was, so a
70
+ * marker-free SVG (Mermaid, or DOT rendered by someone else) passes through
71
+ * untouched.
72
+ */
73
+ export declare function injectIcons(svg: string, colorFor?: (nodeId: string) => string): string;
74
+ export {};
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Icons for rendered diagrams.
3
+ *
4
+ * Two constraints shape this module, and they rule out the obvious answers:
5
+ *
6
+ * 1. **No native dependencies and no downloads.** Vendor icon sets (the AWS
7
+ * Architecture Icons, Azure's, GCP's) are large binary asset packs with
8
+ * their own trademark terms, so they are not something this tool can
9
+ * quietly vendor. These icons are therefore drawn here, by category —
10
+ * "database", "queue", "load balancer" — not by vendor. A category set
11
+ * also survives contact with a multi-cloud estate: an on-prem Postgres,
12
+ * an RDS instance and a Cloud SQL instance all want the same glyph.
13
+ * 2. **Engine parity.** The picture must be identical whether Graphviz ran
14
+ * as a native binary or as the bundled WASM build. Graphviz's own
15
+ * `image=` attribute is not viable for that: the native binary resolves
16
+ * it as a filesystem path, so a `data:` URI works in one tier and fails
17
+ * in the other.
18
+ *
19
+ * So the icon is not handed to Graphviz at all. `renderDot` reserves a
20
+ * fixed-size cell in the node's HTML-like label containing a 1pt marker token,
21
+ * Graphviz lays out the graph knowing exactly how much room the icon needs,
22
+ * and `injectIcons` swaps each marker for inline SVG afterwards. Both tiers
23
+ * emit the same SVG structure, so both get the same picture, and the result
24
+ * stays a self-contained SVG with no external references and no base64 bloat.
25
+ */
26
+ export const PALETTE = {
27
+ edge: { accent: '#0284c7', tint: '#f0f9ff' },
28
+ compute: { accent: '#d97706', tint: '#fffbeb' },
29
+ data: { accent: '#059669', tint: '#ecfdf5' },
30
+ messaging: { accent: '#7c3aed', tint: '#f5f3ff' },
31
+ security: { accent: '#e11d48', tint: '#fff1f2' },
32
+ observability: { accent: '#475569', tint: '#f8fafc' },
33
+ other: { accent: '#64748b', tint: '#f8fafc' },
34
+ };
35
+ /**
36
+ * Drawn on a 24x24 grid, stroked rather than filled so a single accent colour
37
+ * carries the whole glyph and one wrapper `<g>` can restyle it.
38
+ */
39
+ export const ICONS = {
40
+ dns: {
41
+ family: 'edge',
42
+ body: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/>' +
43
+ '<path d="M3.2 12h17.6M5 7h14M5 17h14"/>',
44
+ },
45
+ cdn: {
46
+ family: 'edge',
47
+ body: '<circle cx="12" cy="12" r="3.6"/>' +
48
+ '<path d="M12 8.4V4M12 15.6V20M8.9 10.2 5.1 8M15.1 13.8l3.8 2.2M8.9 13.8 5.1 16M15.1 10.2 18.9 8"/>' +
49
+ '<circle cx="12" cy="3" r="1.6"/><circle cx="12" cy="21" r="1.6"/>' +
50
+ '<circle cx="3.9" cy="7.3" r="1.6"/><circle cx="20.1" cy="16.7" r="1.6"/>' +
51
+ '<circle cx="3.9" cy="16.7" r="1.6"/><circle cx="20.1" cy="7.3" r="1.6"/>',
52
+ },
53
+ api: {
54
+ family: 'edge',
55
+ body: '<path d="M8.5 5.5 3.5 12l5 6.5M15.5 5.5 20.5 12l-5 6.5M13.6 4.2 10.4 19.8"/>',
56
+ },
57
+ loadbalancer: {
58
+ family: 'edge',
59
+ body: '<rect x="9" y="2.5" width="6" height="5" rx="1.5"/>' +
60
+ '<path d="M12 7.5v3M4 13.5v-3h16v3M12 10.5v3"/>' +
61
+ '<rect x="1.5" y="13.5" width="5" height="5" rx="1.5"/>' +
62
+ '<rect x="9.5" y="13.5" width="5" height="5" rx="1.5"/>' +
63
+ '<rect x="17.5" y="13.5" width="5" height="5" rx="1.5"/>',
64
+ },
65
+ network: {
66
+ family: 'edge',
67
+ body: '<circle cx="12" cy="4.6" r="2.6"/><circle cx="4.8" cy="18" r="2.6"/><circle cx="19.2" cy="18" r="2.6"/>' +
68
+ '<path d="M10.7 6.9 6.1 15.7M13.3 6.9l4.6 8.8M7.4 18h9.2"/>',
69
+ },
70
+ firewall: {
71
+ family: 'security',
72
+ body: '<path d="M12 2.5 20 5.4v6.2c0 4.9-3.4 8-8 9.9-4.6-1.9-8-5-8-9.9V5.4Z"/><path d="M8.4 12.1 11 14.7l4.6-5"/>',
73
+ },
74
+ certificate: {
75
+ family: 'security',
76
+ body: '<circle cx="12" cy="9" r="6.1"/><path d="M8.4 14.2v7.3L12 19.4l3.6 2.1v-7.3"/><path d="M9.7 9l1.7 1.8 3-3.4"/>',
77
+ },
78
+ identity: {
79
+ family: 'security',
80
+ body: '<rect x="2.8" y="3.2" width="18.4" height="17.6" rx="3"/>' +
81
+ '<circle cx="12" cy="10.2" r="2.9"/><path d="M7.2 17.9a4.9 4.9 0 0 1 9.6 0"/>',
82
+ },
83
+ secret: {
84
+ family: 'security',
85
+ body: '<circle cx="8.4" cy="8.4" r="4.6"/><path d="M11.7 11.7 20.4 20.4M15.6 19.2l2-2M18.1 16.7l2-2"/>',
86
+ },
87
+ compute: {
88
+ family: 'compute',
89
+ body: '<rect x="2.8" y="3.6" width="18.4" height="7" rx="1.8"/><rect x="2.8" y="13.4" width="18.4" height="7" rx="1.8"/>' +
90
+ '<circle cx="6.4" cy="7.1" r=".9" fill="currentColor" stroke="none"/>' +
91
+ '<circle cx="6.4" cy="16.9" r=".9" fill="currentColor" stroke="none"/>',
92
+ },
93
+ function: {
94
+ family: 'compute',
95
+ body: '<path d="M13.6 2.4 5.2 13.6h5.6L10.4 21.6l8.4-11.2h-5.6Z"/>',
96
+ },
97
+ container: {
98
+ family: 'compute',
99
+ body: '<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><path d="M3.4 7 12 11.4 20.6 7M12 11.4v10"/>',
100
+ },
101
+ cluster: {
102
+ family: 'compute',
103
+ body: '<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><circle cx="12" cy="12" r="3.2"/>' +
104
+ '<path d="M12 8.8V4.6M14.8 13.8l3.6 2.1M9.2 13.8l-3.6 2.1"/>',
105
+ },
106
+ service: {
107
+ family: 'compute',
108
+ body: '<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><path d="M8.4 12h7.2M12 8.4v7.2"/>',
109
+ },
110
+ database: {
111
+ family: 'data',
112
+ body: '<ellipse cx="12" cy="6" rx="8" ry="3.2"/>' +
113
+ '<path d="M4 6v12c0 1.8 3.6 3.2 8 3.2s8-1.4 8-3.2V6"/>' +
114
+ '<path d="M4 12c0 1.8 3.6 3.2 8 3.2s8-1.4 8-3.2"/>',
115
+ },
116
+ storage: {
117
+ family: 'data',
118
+ body: '<ellipse cx="12" cy="6.2" rx="8" ry="2.6"/><path d="M4 6.2 5.9 19a6.2 6.2 0 0 0 12.2 0L20 6.2"/>',
119
+ },
120
+ cache: {
121
+ family: 'data',
122
+ body: '<rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9.6" y="9.6" width="4.8" height="4.8" rx="1"/>' +
123
+ '<path d="M9.2 6V2.8M14.8 6V2.8M9.2 18v3.2M14.8 18v3.2M6 9.2H2.8M6 14.8H2.8M18 9.2h3.2M18 14.8h3.2"/>',
124
+ },
125
+ queue: {
126
+ family: 'messaging',
127
+ body: '<rect x="2.4" y="6" width="19.2" height="12" rx="2"/><path d="M8.8 6v12M15.2 6v12"/>',
128
+ },
129
+ topic: {
130
+ family: 'messaging',
131
+ body: '<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/>' +
132
+ '<path d="M8 8.2a5.4 5.4 0 0 0 0 7.6M16 8.2a5.4 5.4 0 0 1 0 7.6"/>' +
133
+ '<path d="M5.2 5.2a9.6 9.6 0 0 0 0 13.6M18.8 5.2a9.6 9.6 0 0 1 0 13.6"/>',
134
+ },
135
+ events: {
136
+ family: 'messaging',
137
+ body: '<path d="M12 2.4 21.6 12 12 21.6 2.4 12Z"/><path d="M12.9 7.2 9.4 12.6h2.5l-.8 4.2 3.5-5.4h-2.5Z"/>',
138
+ },
139
+ email: {
140
+ family: 'messaging',
141
+ body: '<rect x="2.4" y="5" width="19.2" height="14" rx="2"/><path d="M3.2 7 12 13.4 20.8 7"/>',
142
+ },
143
+ monitoring: {
144
+ family: 'observability',
145
+ body: '<path d="M3.4 3.4v17.2h17.2"/><path d="M6.6 16.6 11 11.2l3.4 2.8 5.4-7"/>',
146
+ },
147
+ user: {
148
+ family: 'other',
149
+ body: '<circle cx="12" cy="7.6" r="4"/><path d="M4.4 20.6a7.6 7.6 0 0 1 15.2 0"/>',
150
+ },
151
+ generic: {
152
+ family: 'other',
153
+ body: '<rect x="3.4" y="3.4" width="17.2" height="17.2" rx="3.4"/><path d="M8.6 12h6.8"/>',
154
+ },
155
+ };
156
+ /**
157
+ * `kind` is an open string by design, so this is pattern matching over
158
+ * vocabularies rather than a lookup table — Terraform types, Kubernetes kinds,
159
+ * Dynatrace entity types and anything a future provider invents.
160
+ *
161
+ * **Order is the whole trick.** More specific patterns must come first, and
162
+ * several pairs actively collide: `aws_elasticache_cluster` is a cache, not a
163
+ * Kubernetes cluster; `aws_db_instance` is a database, not an EC2 instance;
164
+ * `aws_db_subnet_group` is data, not network. Those cases are pinned by tests.
165
+ */
166
+ /**
167
+ * Whole-token match. `\b` is no use here: `_` is a word character, so `\bses\b`
168
+ * never matches `aws_ses_receipt_rule`, and `\bkey\b` never matches
169
+ * `aws_kms_key`. Separators in the wild are `_`, `-`, `.` and case changes, so
170
+ * the boundary is "anything that isn't alphanumeric".
171
+ */
172
+ function tok(alternatives) {
173
+ return `(?:^|[^a-z0-9])(?:${alternatives})(?:[^a-z0-9]|$)`;
174
+ }
175
+ function rule(source, key) {
176
+ return [new RegExp(source, 'i'), key];
177
+ }
178
+ const KIND_ICONS = [
179
+ rule(`route53|hosted_?zone|dns|record_?set|${tok('record|zone|zones')}`, 'dns'),
180
+ rule(`cloudfront|front_?door|fastly|akamai|${tok('cdn')}`, 'cdn'),
181
+ rule(`certificate|${tok('acm|cert|certs|tls|ssl')}`, 'certificate'),
182
+ rule(`waf|firewall|security_?group|network_?acl|${tok('nacl|nsg|acl')}`, 'firewall'),
183
+ rule(`load_?balanc|target_?group|ingress|${tok('lb|alb|nlb|elb')}`, 'loadbalancer'),
184
+ rule(`api_?gateway|apigw|appsync|graphql|endpoint|${tok('api')}`, 'api'),
185
+ rule(`elasticache|memcache|redis|${tok('cache')}`, 'cache'),
186
+ rule(`rds|dynamodb|aurora|spanner|cosmos|mongo|postgres|mysql|bigtable|firestore|database|${tok('db|sql')}`, 'database'),
187
+ rule(`bucket|blob|storage|volume|disk|file_?system|glacier|${tok('s3|efs|fs')}`, 'storage'),
188
+ rule(`vpc|subnet|vnet|nat_?gateway|route_?table|peering|transit_?gateway|network_?interface|${tok('nat|cidr')}`, 'network'),
189
+ rule('event_?bridge|event_?bus|event_?rule|event_?source|scheduler', 'events'),
190
+ rule(`sqs|queue|kinesis|kafka|rabbit|stream|${tok('msk')}`, 'queue'),
191
+ rule('sns|topic|pubsub|subscription|notification', 'topic'),
192
+ rule(`smtp|email|sendgrid|${tok('ses|mail')}`, 'email'),
193
+ rule(`task_?definition|container|docker|image|${tok('ecr|pod|pods')}`, 'container'),
194
+ rule(`cluster|node_?group|node_?pool|${tok('eks|gke|aks')}`, 'cluster'),
195
+ rule('lambda|function|serverless', 'function'),
196
+ rule(`virtual_?machine|autoscal|compute|${tok('ec2|vm|asg|instance|instances|server|servers|host|hosts')}`, 'compute'),
197
+ rule('service|deployment|app_?service|workload|daemonset|statefulset|replicaset', 'service'),
198
+ rule(`iam|policy|principal|service_?account|user_?pool|cognito|${tok('role|roles|auth|rbac')}`, 'identity'),
199
+ rule(`secret|key_?vault|parameter_?store|ssm_parameter|vault|${tok('kms|key|keys')}`, 'secret'),
200
+ rule(`cloudwatch|metric|alarm|monitor|prometheus|grafana|dashboard|log_?group|telemetry|${tok('log|logs|trace|traces')}`, 'monitoring'),
201
+ rule(`browser|customer|${tok('user|users|client|clients|actor|external')}`, 'user'),
202
+ ];
203
+ export function iconFor(kind) {
204
+ for (const [pattern, key] of KIND_ICONS)
205
+ if (pattern.test(kind))
206
+ return key;
207
+ return 'generic';
208
+ }
209
+ export function familyFor(kind) {
210
+ return ICONS[iconFor(kind)].family;
211
+ }
212
+ export function styleFor(kind) {
213
+ return PALETTE[familyFor(kind)];
214
+ }
215
+ /**
216
+ * Namespaced so it cannot collide with anything a provider might legitimately
217
+ * put in an entity name. `renderDot` additionally strips the prefix from
218
+ * labels, so a hostile model cannot inject its own icon.
219
+ *
220
+ * Deliberately letters, digits, `_` and `@` only: Graphviz XML-escapes SVG
221
+ * text, so a hyphen would come back out as `&#45;` and the marker would no
222
+ * longer match itself.
223
+ */
224
+ export const MARKER_PREFIX = '@@dwicon_';
225
+ export function marker(key) {
226
+ return `${MARKER_PREFIX}${key}@@`;
227
+ }
228
+ /** Rendered size of the icon, in points, matching the cell `renderDot` reserves. */
229
+ export const ICON_SIZE = 24;
230
+ export function iconSvg(key, x, y, color, size = ICON_SIZE) {
231
+ const icon = ICONS[key] ?? ICONS.generic;
232
+ const scale = size / 24;
233
+ const left = round(x - size / 2);
234
+ const top = round(y - size / 2);
235
+ return (`<g class="driftwood-icon" transform="translate(${left} ${top}) scale(${round(scale)})" ` +
236
+ `fill="none" stroke="${color}" color="${color}" stroke-width="1.7" ` +
237
+ `stroke-linecap="round" stroke-linejoin="round">${icon.body}</g>`);
238
+ }
239
+ function round(n) {
240
+ return Math.round(n * 100) / 100;
241
+ }
242
+ const NODE_RE = /<g id="node\d+" class="node">[\s\S]*?<\/g>/g;
243
+ const MARKER_TEXT_RE = new RegExp(`<text\\b[^>]*\\bx="(-?[\\d.]+)"[^>]*\\by="(-?[\\d.]+)"[^>]*>${escapeRe(MARKER_PREFIX)}([a-z]+)@@</text>`);
244
+ function escapeRe(s) {
245
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
+ }
247
+ /**
248
+ * Horizontal centre of a node's shape.
249
+ *
250
+ * The marker text's own `x` is the *start* of the run, which depends on the
251
+ * marker's width in the current font — unusable. The node's outline is not:
252
+ * every label this renderer emits is a single-column table, so the icon cell's
253
+ * centre is the shape's centre.
254
+ */
255
+ function centreX(nodeSvg) {
256
+ const ellipse = nodeSvg.match(/<ellipse[^>]*\bcx="(-?[\d.]+)"/);
257
+ if (ellipse?.[1])
258
+ return Number(ellipse[1]);
259
+ const geometry = nodeSvg.match(/<(?:polygon|path)[^>]*\b(?:points|d)="([^"]+)"/);
260
+ if (!geometry?.[1])
261
+ return undefined;
262
+ const numbers = geometry[1]
263
+ .replace(/[A-Za-z]/g, ' ')
264
+ .split(/[\s,]+/)
265
+ .filter((t) => t !== '')
266
+ .map(Number);
267
+ const xs = [];
268
+ for (let i = 0; i < numbers.length; i += 2) {
269
+ const x = numbers[i];
270
+ if (x !== undefined && Number.isFinite(x))
271
+ xs.push(x);
272
+ }
273
+ if (xs.length === 0)
274
+ return undefined;
275
+ return (Math.min(...xs) + Math.max(...xs)) / 2;
276
+ }
277
+ /**
278
+ * Replaces every icon marker in a Graphviz-produced SVG with inline vector
279
+ * artwork. Anything it does not recognise is left exactly as it was, so a
280
+ * marker-free SVG (Mermaid, or DOT rendered by someone else) passes through
281
+ * untouched.
282
+ */
283
+ export function injectIcons(svg, colorFor) {
284
+ return svg.replace(NODE_RE, (node) => {
285
+ const found = MARKER_TEXT_RE.exec(node);
286
+ if (!found)
287
+ return node;
288
+ const [element, , yRaw, key] = found;
289
+ const y = Number(yRaw);
290
+ const x = centreX(node);
291
+ if (x === undefined || !Number.isFinite(y))
292
+ return node.replace(element, '');
293
+ const id = node.match(/<title>([\s\S]*?)<\/title>/)?.[1];
294
+ const color = colorFor && id ? colorFor(unescapeXml(id)) : PALETTE.other.accent;
295
+ return node.replace(element, iconSvg((key ?? 'generic'), x, y, color));
296
+ });
297
+ }
298
+ function unescapeXml(s) {
299
+ return s
300
+ .replace(/&#45;/g, '-')
301
+ .replace(/&lt;/g, '<')
302
+ .replace(/&gt;/g, '>')
303
+ .replace(/&quot;/g, '"')
304
+ .replace(/&#39;/g, "'")
305
+ .replace(/&amp;/g, '&');
306
+ }
@@ -1,3 +1,4 @@
1
+ import { PALETTE, familyFor, iconFor } from './icons.js';
1
2
  import { selectEntities } from './select.js';
2
3
  import { defineRenderer } from './types.js';
3
4
  /**
@@ -13,35 +14,35 @@ function escapeLabel(text) {
13
14
  // Mermaid renders <br/> inside a quoted label; a raw newline breaks parsing.
14
15
  return text.replace(/"/g, '&quot;').replace(/\n/g, '<br/>');
15
16
  }
16
- const KIND_PATTERNS = [
17
- [/(s3|bucket|rds|dynamodb|db|database|efs|volume|storage)/i, 'storage'],
18
- [/(sqs|sns|queue|topic|kinesis|kafka|eventbridge)/i, 'messaging'],
19
- [/(route53|dns|cloudfront|vpc|subnet|lb|gateway|cdn|zone|record)/i, 'network'],
20
- [/(iam|role|policy|secret|kms|cert|acm|auth)/i, 'identity'],
21
- [/(lambda|ec2|ecs|function|instance|service|container|app)/i, 'compute'],
22
- ];
23
- function shapeFor(kind) {
24
- for (const [pattern, family] of KIND_PATTERNS) {
25
- if (pattern.test(kind))
26
- return family;
27
- }
28
- return 'compute';
29
- }
17
+ /**
18
+ * Mermaid has no icon primitive, so the same categorisation that picks an icon
19
+ * for the Graphviz renderer picks a shape and a colour here. Sharing it is the
20
+ * point: a queue must not be a cylinder in one engine and a parallelogram in
21
+ * the other, or the two pictures stop describing the same system.
22
+ */
23
+ const SHAPES = {
24
+ database: (id, l) => `${id}[("${l}")]`,
25
+ storage: (id, l) => `${id}[("${l}")]`,
26
+ cache: (id, l) => `${id}[("${l}")]`,
27
+ queue: (id, l) => `${id}[/"${l}"/]`,
28
+ topic: (id, l) => `${id}[/"${l}"/]`,
29
+ events: (id, l) => `${id}[/"${l}"/]`,
30
+ email: (id, l) => `${id}[/"${l}"/]`,
31
+ dns: (id, l) => `${id}("${l}")`,
32
+ cdn: (id, l) => `${id}("${l}")`,
33
+ network: (id, l) => `${id}("${l}")`,
34
+ loadbalancer: (id, l) => `${id}("${l}")`,
35
+ api: (id, l) => `${id}("${l}")`,
36
+ identity: (id, l) => `${id}{{"${l}"}}`,
37
+ secret: (id, l) => `${id}{{"${l}"}}`,
38
+ certificate: (id, l) => `${id}{{"${l}"}}`,
39
+ firewall: (id, l) => `${id}{{"${l}"}}`,
40
+ };
30
41
  function renderNode(e) {
31
42
  const label = escapeLabel(`${e.name ?? e.id}\n${e.kind}`);
32
43
  const id = nodeId(e.id);
33
- switch (shapeFor(e.kind)) {
34
- case 'storage':
35
- return `${id}[("${label}")]`;
36
- case 'messaging':
37
- return `${id}[/"${label}"/]`;
38
- case 'network':
39
- return `${id}("${label}")`;
40
- case 'identity':
41
- return `${id}{{"${label}"}}`;
42
- default:
43
- return `${id}["${label}"]`;
44
- }
44
+ const shape = SHAPES[iconFor(e.kind)];
45
+ return shape ? shape(id, label) : `${id}["${label}"]`;
45
46
  }
46
47
  export function renderMermaid(model, opts = {}) {
47
48
  const view = opts.view ? model.views.find((v) => v.id === opts.view) : undefined;
@@ -79,6 +80,18 @@ export function renderMermaid(model, opts = {}) {
79
80
  const label = edge.label ? `|"${escapeLabel(edge.label)}"|` : '';
80
81
  lines.push(` ${nodeId(edge.from)} -->${label} ${nodeId(edge.to)}`);
81
82
  }
83
+ // Family colours, matching the Graphviz renderer's palette. Emitted before
84
+ // the health overlay so that health, being the more urgent fact, wins.
85
+ const families = new Set();
86
+ for (const e of entities) {
87
+ const family = familyFor(e.kind);
88
+ families.add(family);
89
+ lines.push(` class ${nodeId(e.id)} f_${family};`);
90
+ }
91
+ for (const family of [...families].sort()) {
92
+ const { accent, tint } = PALETTE[family];
93
+ lines.push(` classDef f_${family} fill:${tint},stroke:${accent},color:#0f172a;`);
94
+ }
82
95
  if (opts.health) {
83
96
  lines.push('');
84
97
  for (const [id, status] of Object.entries(opts.health)) {
@@ -4,6 +4,12 @@ export interface RenderContext {
4
4
  direction?: 'LR' | 'TD';
5
5
  /** Runtime health, applied at render time. Never committed to the model. */
6
6
  health?: Record<string, 'healthy' | 'degraded' | 'down'>;
7
+ /**
8
+ * Draw category icons on nodes. Only renderers that produce final artwork
9
+ * honour this — Mermaid has no icon primitive, and raw DOT source has no
10
+ * post-processing step to turn a marker into a picture.
11
+ */
12
+ icons?: boolean;
7
13
  }
8
14
  export interface Availability {
9
15
  available: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brignano/driftwood",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Architecture as code, reconciled with live infrastructure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,7 @@
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "prepublishOnly": "npm run build",
43
43
  "dev": "tsx src/cli.ts",
44
+ "docs": "tsx scripts/render-docs.ts",
44
45
  "test": "vitest run",
45
46
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
47
  },
@@ -54,6 +55,6 @@
54
55
  "@types/node": "^22.7.4",
55
56
  "tsx": "^4.19.1",
56
57
  "typescript": "^5.6.2",
57
- "vitest": "^2.1.2"
58
+ "vitest": "^3.2.6"
58
59
  }
59
60
  }