@zombie-mermaid/svg-renderer 3.0.0 → 3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/svg-renderer",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "license": "MIT",
5
5
  "description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "elkjs": "^0.11.0",
39
39
  "entities": "^7.0.1",
40
- "@zombie-mermaid/core": "3.0.0",
41
- "@zombie-mermaid/mermaid-parser": "3.0.0"
40
+ "@zombie-mermaid/core": "3.1.0",
41
+ "@zombie-mermaid/mermaid-parser": "3.1.0"
42
42
  }
43
43
  }
@@ -16,6 +16,7 @@ import {
16
16
  escapeXml as escapeXmlUtil,
17
17
  escapeAttr,
18
18
  safeHref,
19
+ f,
19
20
  } from '@zombie-mermaid/core'
20
21
  import { withDataSrc } from '../renderer.ts'
21
22
  import {
@@ -230,20 +231,20 @@ function renderClassBox(
230
231
  // data-label: class name
231
232
  // data-annotation: stereotype (interface, abstract, etc.)
232
233
  const annotationAttr = cls.annotation
233
- ? ` data-annotation="${escapeAttr(cls.annotation)}"`
234
+ ? f` data-annotation="${escapeAttr(cls.annotation)}"`
234
235
  : ''
235
236
  // A style class from `cssClass`/`class A name`/`:::name` is emitted onto
236
237
  // the group so external CSS can target it, after the same allowlist the
237
238
  // flowchart renderer applies.
238
239
  const safeClassName = sanitizeClassName(cls.className)
239
240
  const groupAttrs = [
240
- `class="${safeClassName ? `class-node ${safeClassName}` : 'class-node'}"`,
241
- `data-id="${escapeAttr(cls.id)}"`,
242
- `data-label="${escapeAttr(cls.label)}"`,
241
+ f`class="${safeClassName ? f`class-node ${safeClassName}` : 'class-node'}"`,
242
+ f`data-id="${escapeAttr(cls.id)}"`,
243
+ f`data-label="${escapeAttr(cls.label)}"`,
243
244
  ]
244
245
  // `click ClassName call fn()` is parsed, never invoked, and never written
245
246
  // into the markup — see renderNode() in packages/svg-renderer/src/renderer.ts for the rationale.
246
- parts.push(`<g ${groupAttrs.join(' ')}${annotationAttr}>`)
247
+ parts.push(f`<g ${groupAttrs.join(' ')}${annotationAttr}>`)
247
248
 
248
249
  // An href becomes a real SVG link, which needs no script to work.
249
250
  // `interactivity: 'none'` strips it — a link is meaningless in
@@ -251,28 +252,28 @@ function renderClassBox(
251
252
  const href = linksEnabled ? safeHref(interaction?.href) : undefined
252
253
  if (href) {
253
254
  const targetAttr = interaction?.target
254
- ? ` target="${escapeAttr(interaction.target)}"`
255
+ ? f` target="${escapeAttr(interaction.target)}"`
255
256
  : ''
256
- parts.push(` <a href="${escapeAttr(href)}"${targetAttr}>`)
257
+ parts.push(f` <a href="${escapeAttr(href)}"${targetAttr}>`)
257
258
  }
258
259
 
259
260
  if (linksEnabled && interaction?.tooltip) {
260
261
  // <title> is SVG's native tooltip — no script, no CSS. Gated by
261
262
  // linksEnabled alongside href above: both come from the same `click`
262
263
  // statement, and 'none' strips both.
263
- parts.push(` <title>${escapeXml(interaction.tooltip)}</title>`)
264
+ parts.push(f` <title>${escapeXml(interaction.tooltip)}</title>`)
264
265
  }
265
266
 
266
267
  // Outer rectangle (full box)
267
268
  parts.push(
268
- ` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
269
- `rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
269
+ f` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
270
+ f`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
270
271
  )
271
272
 
272
273
  // Header background
273
274
  parts.push(
274
- ` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
275
- `rx="0" ry="0" fill="${headerFill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
275
+ f` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
276
+ f`rx="0" ry="0" fill="${headerFill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
276
277
  )
277
278
 
278
279
  // Annotation (<<interface>>, <<abstract>>, etc.)
@@ -280,9 +281,9 @@ function renderClassBox(
280
281
  if (cls.annotation) {
281
282
  const annotY = y + 12
282
283
  parts.push(
283
- ` <text x="${x + width / 2}" y="${annotY}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
284
- `font-size="${CLS_FONT.annotationSize}" font-weight="${CLS_FONT.annotationWeight}" ` +
285
- `font-style="italic" fill="${textColor ?? 'var(--_text-muted)'}">&lt;&lt;${escapeXml(cls.annotation)}&gt;&gt;</text>`,
284
+ f` <text x="${x + width / 2}" y="${annotY}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
285
+ f`font-size="${CLS_FONT.annotationSize}" font-weight="${CLS_FONT.annotationWeight}" ` +
286
+ f`font-style="italic" fill="${textColor ?? 'var(--_text-muted)'}">&lt;&lt;${escapeXml(cls.annotation)}&gt;&gt;</text>`,
286
287
  )
287
288
  nameY = y + headerHeight / 2 + 6
288
289
  }
@@ -295,15 +296,15 @@ function renderClassBox(
295
296
  x + width / 2,
296
297
  nameY,
297
298
  fontSizes.nodeLabel,
298
- `text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="${textColor ?? 'var(--_text)'}"`,
299
+ f`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="${textColor ?? 'var(--_text)'}"`,
299
300
  ),
300
301
  )
301
302
 
302
303
  // Divider line between header and attributes
303
304
  const attrTop = y + headerHeight
304
305
  parts.push(
305
- ` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
306
- `stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
306
+ f` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
307
+ f`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
307
308
  )
308
309
 
309
310
  // Attributes
@@ -317,8 +318,8 @@ function renderClassBox(
317
318
  // Divider line between attributes and methods
318
319
  const methodTop = attrTop + attrHeight
319
320
  parts.push(
320
- ` <line x1="${x}" y1="${methodTop}" x2="${x + width}" y2="${methodTop}" ` +
321
- `stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
321
+ f` <line x1="${x}" y1="${methodTop}" x2="${x + width}" y2="${methodTop}" ` +
322
+ f`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
322
323
  )
323
324
 
324
325
  // Methods
@@ -366,27 +367,27 @@ function renderMember(
366
367
 
367
368
  if (member.visibility) {
368
369
  spans.push(
369
- `<tspan fill="${faint}">${escapeXml(member.visibility)} </tspan>`,
370
+ f`<tspan fill="${faint}">${escapeXml(member.visibility)} </tspan>`,
370
371
  )
371
372
  }
372
373
 
373
374
  // Add parentheses for methods to distinguish from attributes, including parameters if present
374
375
  const displayName = member.isMethod
375
- ? `${member.name}(${member.params || ''})`
376
+ ? f`${member.name}(${member.params || ''})`
376
377
  : member.name
377
378
  // False positive: displayName is passed through escapeXml() (see packages/core/src/multiline-utils.ts),
378
379
  // which escapes &, <, >, ", ' before interpolation, so this is not raw/unescaped HTML.
379
- spans.push(`<tspan fill="${secondary}">${escapeXml(displayName)}</tspan>`) // nosemgrep: javascript.express.security.injection.raw-html-format.raw-html-format
380
+ spans.push(f`<tspan fill="${secondary}">${escapeXml(displayName)}</tspan>`) // nosemgrep: javascript.express.security.injection.raw-html-format.raw-html-format
380
381
 
381
382
  if (member.type) {
382
- spans.push(`<tspan fill="${faint}">: </tspan>`)
383
- spans.push(`<tspan fill="${muted}">${escapeXml(member.type)}</tspan>`)
383
+ spans.push(f`<tspan fill="${faint}">: </tspan>`)
384
+ spans.push(f`<tspan fill="${muted}">${escapeXml(member.type)}</tspan>`)
384
385
  }
385
386
 
386
387
  return (
387
- `<text x="${x}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
388
- `font-size="${CLS_FONT.memberSize}" font-weight="${CLS_FONT.memberWeight}"${fontStyle}${decoration}>` +
389
- `${spans.join('')}</text>`
388
+ f`<text x="${x}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
389
+ f`font-size="${CLS_FONT.memberSize}" font-weight="${CLS_FONT.memberWeight}"${fontStyle}${decoration}>` +
390
+ f`${spans.join('')}</text>`
390
391
  )
391
392
  }
392
393
 
@@ -408,33 +409,33 @@ function renderNote(note: PositionedClassNote, fontSizes: FontSizes): string {
408
409
  const { x, y, width: w, height: h } = note
409
410
  const forAttr =
410
411
  note.forClass !== undefined
411
- ? ` data-for="${escapeAttr(note.forClass)}"`
412
+ ? f` data-for="${escapeAttr(note.forClass)}"`
412
413
  : ''
413
414
 
414
415
  // Note body: (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
415
416
  const bodyPoints = [
416
- `${x},${y}`,
417
- `${x + w - NOTE_FOLD},${y}`,
418
- `${x + w},${y + NOTE_FOLD}`,
419
- `${x + w},${y + h}`,
420
- `${x},${y + h}`,
417
+ f`${x},${y}`,
418
+ f`${x + w - NOTE_FOLD},${y}`,
419
+ f`${x + w},${y + NOTE_FOLD}`,
420
+ f`${x + w},${y + h}`,
421
+ f`${x},${y + h}`,
421
422
  ].join(' ')
422
423
  const foldPoints =
423
- `${x + w - NOTE_FOLD},${y} ${x + w},${y + NOTE_FOLD} ` +
424
- `${x + w - NOTE_FOLD},${y + NOTE_FOLD}`
424
+ f`${x + w - NOTE_FOLD},${y} ${x + w},${y + NOTE_FOLD} ` +
425
+ f`${x + w - NOTE_FOLD},${y + NOTE_FOLD}`
425
426
 
426
427
  return (
427
- `<g class="class-note"${forAttr}>` +
428
- `\n <polygon points="${bodyPoints}" ` +
429
- `fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
430
- `\n <polygon points="${foldPoints}" ` +
431
- `fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
432
- `\n ${renderMultilineText(
428
+ f`<g class="class-note"${forAttr}>` +
429
+ f`\n <polygon points="${bodyPoints}" ` +
430
+ f`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
431
+ f`\n <polygon points="${foldPoints}" ` +
432
+ f`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
433
+ f`\n ${renderMultilineText(
433
434
  note.text,
434
435
  x + w / 2,
435
436
  y + h / 2,
436
437
  fontSizes.edgeLabel,
437
- `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
438
+ f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
438
439
  )}` +
439
440
  `\n</g>`
440
441
  )
@@ -446,14 +447,14 @@ function renderNote(note: PositionedClassNote, fontSizes: FontSizes): string {
446
447
  */
447
448
  function renderNoteLink(note: PositionedClassNote): string {
448
449
  if (!note.linkPoints || note.linkPoints.length < 2) return ''
449
- const pathData = note.linkPoints.map((p) => `${p.x},${p.y}`).join(' ')
450
+ const pathData = note.linkPoints.map((p) => f`${p.x},${p.y}`).join(' ')
450
451
  const forAttr =
451
452
  note.forClass !== undefined
452
- ? ` data-for="${escapeAttr(note.forClass)}"`
453
+ ? f` data-for="${escapeAttr(note.forClass)}"`
453
454
  : ''
454
455
  return (
455
- `<polyline class="class-note-link"${forAttr} points="${pathData}" ` +
456
- `fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}" stroke-dasharray="2 3" />`
456
+ f`<polyline class="class-note-link"${forAttr} points="${pathData}" ` +
457
+ f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}" stroke-dasharray="2 3" />`
457
458
  )
458
459
  }
459
460
 
@@ -468,7 +469,7 @@ function renderNoteLink(note: PositionedClassNote): string {
468
469
  function renderRelationship(rel: PositionedClassRelationship): string {
469
470
  if (rel.points.length < 2) return ''
470
471
 
471
- const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
472
+ const pathData = rel.points.map((p) => f`${p.x},${p.y}`).join(' ')
472
473
  const isDashed = rel.type === 'dependency' || rel.type === 'realization'
473
474
  const dashArray = isDashed ? ' stroke-dasharray="6 4"' : ''
474
475
 
@@ -484,24 +485,26 @@ function renderRelationship(rel: PositionedClassRelationship): string {
484
485
  // - data-label: relationship label if present
485
486
  const dataAttrs = [
486
487
  'class="class-relationship"',
487
- `data-from="${escapeAttr(rel.from)}"`,
488
- `data-to="${escapeAttr(rel.to)}"`,
489
- `data-type="${rel.type}"`,
490
- `data-marker-at="${rel.markerAt}"`,
488
+ f`data-from="${escapeAttr(rel.from)}"`,
489
+ f`data-to="${escapeAttr(rel.to)}"`,
490
+ f`data-type="${rel.type}"`,
491
+ f`data-marker-at="${rel.markerAt}"`,
491
492
  ]
492
493
  if (rel.label) {
493
- dataAttrs.push(`data-label="${escapeAttr(rel.label)}"`)
494
+ dataAttrs.push(f`data-label="${escapeAttr(rel.label)}"`)
494
495
  }
495
496
  if (rel.fromCardinality) {
496
- dataAttrs.push(`data-from-cardinality="${escapeAttr(rel.fromCardinality)}"`)
497
+ dataAttrs.push(
498
+ f`data-from-cardinality="${escapeAttr(rel.fromCardinality)}"`,
499
+ )
497
500
  }
498
501
  if (rel.toCardinality) {
499
- dataAttrs.push(`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`)
502
+ dataAttrs.push(f`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`)
500
503
  }
501
504
 
502
505
  return (
503
- `<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="var(--_line)" ` +
504
- `stroke-width="${STROKE_WIDTHS.connector}"${dashArray}${markers} />`
506
+ f`<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="var(--_line)" ` +
507
+ f`stroke-width="${STROKE_WIDTHS.connector}"${dashArray}${markers} />`
505
508
  )
506
509
  }
507
510
 
@@ -519,9 +522,9 @@ function getRelationshipMarkers(
519
522
  if (!markerId) return ''
520
523
 
521
524
  if (markerAt === 'from') {
522
- return ` marker-start="url(#${markerId})"`
525
+ return f` marker-start="url(#${markerId})"`
523
526
  } else {
524
- return ` marker-end="url(#${markerId})"`
527
+ return f` marker-end="url(#${markerId})"`
525
528
  }
526
529
  }
527
530
 
@@ -562,7 +565,7 @@ function renderRelationshipLabels(
562
565
  pos.x,
563
566
  pos.y - 8,
564
567
  fontSizes.edgeLabel,
565
- `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
568
+ f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
566
569
  ),
567
570
  )
568
571
  }
@@ -578,7 +581,7 @@ function renderRelationshipLabels(
578
581
  p.x + offset.x,
579
582
  p.y + offset.y,
580
583
  fontSizes.edgeLabel,
581
- `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
584
+ f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
582
585
  ),
583
586
  )
584
587
  }
@@ -594,7 +597,7 @@ function renderRelationshipLabels(
594
597
  p.x + offset.x,
595
598
  p.y + offset.y,
596
599
  fontSizes.edgeLabel,
597
- `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
600
+ f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
598
601
  ),
599
602
  )
600
603
  }
@@ -10,10 +10,11 @@
10
10
  // ============================================================================
11
11
 
12
12
  import type { Point, CurveStyle } from '@zombie-mermaid/core'
13
+ import { f } from '@zombie-mermaid/core'
13
14
 
14
15
  /** `M x y` for the first point. */
15
16
  function moveTo(p: Point): string {
16
- return `M ${p.x} ${p.y}`
17
+ return f`M ${p.x} ${p.y}`
17
18
  }
18
19
 
19
20
  /** Straight segments through every point — the historical default. */
@@ -22,7 +23,7 @@ function linearPath(points: Point[]): string {
22
23
  moveTo(points[0]!) +
23
24
  points
24
25
  .slice(1)
25
- .map((p) => ` L ${p.x} ${p.y}`)
26
+ .map((p) => f` L ${p.x} ${p.y}`)
26
27
  .join('')
27
28
  )
28
29
  }
@@ -51,14 +52,14 @@ function basisPath(points: Point[]): string {
51
52
 
52
53
  /** One B-spline segment across three successive control points. */
53
54
  const segment = (p0: Point, p1: Point, p2: Point): string =>
54
- ` C ${(2 * p0.x + p1.x) / 3} ${(2 * p0.y + p1.y) / 3},` +
55
- ` ${(p0.x + 2 * p1.x) / 3} ${(p0.y + 2 * p1.y) / 3},` +
56
- ` ${(p0.x + 4 * p1.x + p2.x) / 6} ${(p0.y + 4 * p1.y + p2.y) / 6}`
55
+ f` C ${(2 * p0.x + p1.x) / 3} ${(2 * p0.y + p1.y) / 3},` +
56
+ f` ${(p0.x + 2 * p1.x) / 3} ${(p0.y + 2 * p1.y) / 3},` +
57
+ f` ${(p0.x + 4 * p1.x + p2.x) / 6} ${(p0.y + 4 * p1.y + p2.y) / 6}`
57
58
 
58
59
  // Lead-in to the first knot, one sixth of the way along the opening leg.
59
60
  parts.push(
60
- ` L ${(5 * points[0]!.x + points[1]!.x) / 6}` +
61
- ` ${(5 * points[0]!.y + points[1]!.y) / 6}`,
61
+ f` L ${(5 * points[0]!.x + points[1]!.x) / 6}` +
62
+ f` ${(5 * points[0]!.y + points[1]!.y) / 6}`,
62
63
  )
63
64
 
64
65
  for (let i = 2; i < points.length; i++) {
@@ -67,7 +68,7 @@ function basisPath(points: Point[]): string {
67
68
 
68
69
  // d3 closes by feeding the last point twice, then drawing to it.
69
70
  parts.push(segment(points[points.length - 2]!, last, last))
70
- parts.push(` L ${last.x} ${last.y}`)
71
+ parts.push(f` L ${last.x} ${last.y}`)
71
72
 
72
73
  return parts.join('')
73
74
  }
@@ -109,7 +110,7 @@ function naturalPath(points: Point[]): string {
109
110
  // A zero-length leg means duplicated routed points; there is no corner
110
111
  // to round, so pass straight through.
111
112
  if (inLen === 0 || outLen === 0) {
112
- parts.push(` L ${corner.x} ${corner.y}`)
113
+ parts.push(f` L ${corner.x} ${corner.y}`)
113
114
  continue
114
115
  }
115
116
 
@@ -123,12 +124,12 @@ function naturalPath(points: Point[]): string {
123
124
  const endY = corner.y + ((next.y - corner.y) / outLen) * radius
124
125
 
125
126
  // Straight up to the fillet, then a quadratic through the corner.
126
- parts.push(` L ${startX} ${startY}`)
127
- parts.push(` Q ${corner.x} ${corner.y}, ${endX} ${endY}`)
127
+ parts.push(f` L ${startX} ${startY}`)
128
+ parts.push(f` Q ${corner.x} ${corner.y}, ${endX} ${endY}`)
128
129
  }
129
130
 
130
131
  const last = points[points.length - 1]!
131
- parts.push(` L ${last.x} ${last.y}`)
132
+ parts.push(f` L ${last.x} ${last.y}`)
132
133
 
133
134
  return parts.join('')
134
135
  }
@@ -155,17 +156,17 @@ function stepPath(points: Point[], where: 'mid' | 'before' | 'after'): string {
155
156
  const b = points[i + 1]!
156
157
 
157
158
  if (i === lastIndex - 1) {
158
- parts.push(` L ${b.x} ${b.y}`)
159
+ parts.push(f` L ${b.x} ${b.y}`)
159
160
  break
160
161
  }
161
162
 
162
163
  if (where === 'before') {
163
- parts.push(` L ${a.x} ${b.y} L ${b.x} ${b.y}`)
164
+ parts.push(f` L ${a.x} ${b.y} L ${b.x} ${b.y}`)
164
165
  } else if (where === 'after') {
165
- parts.push(` L ${b.x} ${a.y} L ${b.x} ${b.y}`)
166
+ parts.push(f` L ${b.x} ${a.y} L ${b.x} ${b.y}`)
166
167
  } else {
167
168
  const midY = (a.y + b.y) / 2
168
- parts.push(` L ${a.x} ${midY} L ${b.x} ${midY} L ${b.x} ${b.y}`)
169
+ parts.push(f` L ${a.x} ${midY} L ${b.x} ${midY} L ${b.x} ${b.y}`)
169
170
  }
170
171
  }
171
172
 
@@ -13,6 +13,7 @@ import {
13
13
  escapeXml as escapeXmlUtil,
14
14
  escapeAttr,
15
15
  measureMultilineText,
16
+ f,
16
17
  } from '@zombie-mermaid/core'
17
18
  import { withDataSrc } from '../renderer.ts'
18
19
  import {
@@ -144,19 +145,19 @@ function renderEntityBox(
144
145
 
145
146
  // Semantic wrapper with entity metadata
146
147
  parts.push(
147
- `<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
148
+ f`<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
148
149
  )
149
150
 
150
151
  // Outer rectangle
151
152
  parts.push(
152
- ` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
153
- `rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
153
+ f` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
154
+ f`rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
154
155
  )
155
156
 
156
157
  // Header background
157
158
  parts.push(
158
- ` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
159
- `rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
159
+ f` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
160
+ f`rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
160
161
  )
161
162
 
162
163
  // Entity name (supports multi-line via <br> tags)
@@ -167,15 +168,15 @@ function renderEntityBox(
167
168
  x + width / 2,
168
169
  y + headerHeight / 2,
169
170
  fontSizes.nodeLabel,
170
- `text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
171
+ f`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
171
172
  ),
172
173
  )
173
174
 
174
175
  // Divider
175
176
  const attrTop = y + headerHeight
176
177
  parts.push(
177
- ` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
178
- `stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
178
+ f` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
179
+ f`stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
179
180
  )
180
181
 
181
182
  // Attribute rows
@@ -190,8 +191,8 @@ function renderEntityBox(
190
191
  // Empty row placeholder when no attributes
191
192
  if (attributes.length === 0) {
192
193
  parts.push(
193
- ` <text x="${x + width / 2}" y="${attrTop + rowHeight / 2}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
194
- `font-size="${ER_FONT.attrSize}" fill="var(--_text-faint)" font-style="italic">(no attributes)</text>`,
194
+ f` <text x="${x + width / 2}" y="${attrTop + rowHeight / 2}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
195
+ f`font-size="${ER_FONT.attrSize}" fill="var(--_text-faint)" font-style="italic">(no attributes)</text>`,
195
196
  )
196
197
  }
197
198
 
@@ -220,7 +221,7 @@ function renderAttribute(
220
221
  if (attr.comment && attr.comment.length > 0) {
221
222
  // Replace <br> with newlines for tooltip display
222
223
  const tooltipText = attr.comment.replace(/<br\s*\/?>/gi, '\n')
223
- parts.push(`<g><title>${escapeXml(tooltipText)}</title>`)
224
+ parts.push(f`<g><title>${escapeXml(tooltipText)}</title>`)
224
225
  }
225
226
 
226
227
  // Key badges on the left (keep proportional font — they're visual tags, not code)
@@ -230,29 +231,29 @@ function renderAttribute(
230
231
  keyWidth =
231
232
  estimateTextWidth(keyText, ER_FONT.keySize, ER_FONT.keyWeight) + 8
232
233
  parts.push(
233
- `<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
234
+ f`<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
234
235
  `fill="var(--_key-badge)" />`,
235
236
  )
236
237
  parts.push(
237
- `<text x="${boxX + 6 + keyWidth / 2}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
238
- `font-size="${ER_FONT.keySize}" font-weight="${ER_FONT.keyWeight}" fill="var(--_text-sec)">${attr.keys.join(',')}</text>`,
238
+ f`<text x="${boxX + 6 + keyWidth / 2}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
239
+ f`font-size="${ER_FONT.keySize}" font-weight="${ER_FONT.keyWeight}" fill="var(--_text-sec)">${attr.keys.join(',')}</text>`,
239
240
  )
240
241
  }
241
242
 
242
243
  // Type (left-aligned after keys, monospace with syntax highlighting)
243
244
  const typeX = boxX + 8 + (keyWidth > 0 ? keyWidth + 6 : 0)
244
245
  parts.push(
245
- `<text x="${typeX}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
246
- `font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
247
- `<tspan fill="var(--_text-muted)">${escapeXml(attr.type)}</tspan></text>`,
246
+ f`<text x="${typeX}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
247
+ f`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
248
+ f`<tspan fill="var(--_text-muted)">${escapeXml(attr.type)}</tspan></text>`,
248
249
  )
249
250
 
250
251
  // Name (right-aligned, monospace with syntax highlighting)
251
252
  const nameX = boxX + boxWidth - 8
252
253
  parts.push(
253
- `<text x="${nameX}" y="${y}" class="mono" text-anchor="end" dy="${TEXT_BASELINE_SHIFT}" ` +
254
- `font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
255
- `<tspan fill="var(--_text-sec)">${escapeXml(attr.name)}</tspan></text>`,
254
+ f`<text x="${nameX}" y="${y}" class="mono" text-anchor="end" dy="${TEXT_BASELINE_SHIFT}" ` +
255
+ f`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
256
+ f`<tspan fill="var(--_text-sec)">${escapeXml(attr.name)}</tspan></text>`,
256
257
  )
257
258
 
258
259
  // Close the group if we opened one
@@ -273,23 +274,23 @@ function renderAttribute(
273
274
  function renderRelationshipLine(rel: PositionedErRelationship): string {
274
275
  if (rel.points.length < 2) return ''
275
276
 
276
- const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
277
+ const pathData = rel.points.map((p) => f`${p.x},${p.y}`).join(' ')
277
278
  const dashArray = !rel.identifying ? ' stroke-dasharray="6 4"' : ''
278
279
 
279
280
  // Semantic data attributes for relationship inspection
280
- const labelAttr = rel.label ? ` data-label="${escapeAttr(rel.label)}"` : ''
281
+ const labelAttr = rel.label ? f` data-label="${escapeAttr(rel.label)}"` : ''
281
282
  const dataAttrs = [
282
283
  'class="er-relationship"',
283
- `data-entity1="${escapeAttr(rel.entity1)}"`,
284
- `data-entity2="${escapeAttr(rel.entity2)}"`,
285
- `data-cardinality1="${rel.cardinality1}"`,
286
- `data-cardinality2="${rel.cardinality2}"`,
287
- `data-identifying="${rel.identifying}"`,
284
+ f`data-entity1="${escapeAttr(rel.entity1)}"`,
285
+ f`data-entity2="${escapeAttr(rel.entity2)}"`,
286
+ f`data-cardinality1="${rel.cardinality1}"`,
287
+ f`data-cardinality2="${rel.cardinality2}"`,
288
+ f`data-identifying="${rel.identifying}"`,
288
289
  ]
289
290
 
290
291
  return (
291
- `<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
292
- `stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
292
+ f`<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
293
+ f`stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
293
294
  )
294
295
  }
295
296
 
@@ -312,14 +313,14 @@ function renderRelationshipLabel(
312
313
  const bgH = metrics.height + 6
313
314
 
314
315
  return (
315
- `<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
316
+ f`<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
316
317
  `fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="0.5" />` +
317
- `\n${renderMultilineText(
318
+ f`\n${renderMultilineText(
318
319
  rel.label,
319
320
  mid.x,
320
321
  mid.y,
321
322
  fontSizes.edgeLabel,
322
- `text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
323
+ f`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
323
324
  )}`
324
325
  )
325
326
  }
@@ -389,17 +390,17 @@ function renderCrowsFoot(
389
390
  if (hasOneLine) {
390
391
  const halfW = 6
391
392
  parts.push(
392
- `<line x1="${tipX + px * halfW}" y1="${tipY + py * halfW}" ` +
393
- `x2="${tipX - px * halfW}" y2="${tipY - py * halfW}" ` +
394
- `stroke="var(--_line)" stroke-width="${sw}" />`,
393
+ f`<line x1="${tipX + px * halfW}" y1="${tipY + py * halfW}" ` +
394
+ f`x2="${tipX - px * halfW}" y2="${tipY - py * halfW}" ` +
395
+ f`stroke="var(--_line)" stroke-width="${sw}" />`,
395
396
  )
396
397
  // Second line slightly back for "exactly one" emphasis
397
398
  const line2X = tipX - ux * 4
398
399
  const line2Y = tipY - uy * 4
399
400
  parts.push(
400
- `<line x1="${line2X + px * halfW}" y1="${line2Y + py * halfW}" ` +
401
- `x2="${line2X - px * halfW}" y2="${line2Y - py * halfW}" ` +
402
- `stroke="var(--_line)" stroke-width="${sw}" />`,
401
+ f`<line x1="${line2X + px * halfW}" y1="${line2Y + py * halfW}" ` +
402
+ f`x2="${line2X - px * halfW}" y2="${line2Y - py * halfW}" ` +
403
+ f`stroke="var(--_line)" stroke-width="${sw}" />`,
403
404
  )
404
405
  }
405
406
 
@@ -412,21 +413,21 @@ function renderCrowsFoot(
412
413
  // Three lines from tip to back, fanning out
413
414
  parts.push(
414
415
  // Top fan line
415
- `<line x1="${cfTipX + px * fanW}" y1="${cfTipY + py * fanW}" ` +
416
- `x2="${backX}" y2="${backY}" ` +
417
- `stroke="var(--_line)" stroke-width="${sw}" />`,
416
+ f`<line x1="${cfTipX + px * fanW}" y1="${cfTipY + py * fanW}" ` +
417
+ f`x2="${backX}" y2="${backY}" ` +
418
+ f`stroke="var(--_line)" stroke-width="${sw}" />`,
418
419
  )
419
420
  parts.push(
420
421
  // Center line
421
- `<line x1="${cfTipX}" y1="${cfTipY}" ` +
422
- `x2="${backX}" y2="${backY}" ` +
423
- `stroke="var(--_line)" stroke-width="${sw}" />`,
422
+ f`<line x1="${cfTipX}" y1="${cfTipY}" ` +
423
+ f`x2="${backX}" y2="${backY}" ` +
424
+ f`stroke="var(--_line)" stroke-width="${sw}" />`,
424
425
  )
425
426
  parts.push(
426
427
  // Bottom fan line
427
- `<line x1="${cfTipX - px * fanW}" y1="${cfTipY - py * fanW}" ` +
428
- `x2="${backX}" y2="${backY}" ` +
429
- `stroke="var(--_line)" stroke-width="${sw}" />`,
428
+ f`<line x1="${cfTipX - px * fanW}" y1="${cfTipY - py * fanW}" ` +
429
+ f`x2="${backX}" y2="${backY}" ` +
430
+ f`stroke="var(--_line)" stroke-width="${sw}" />`,
430
431
  )
431
432
  }
432
433
 
@@ -436,8 +437,8 @@ function renderCrowsFoot(
436
437
  const circleX = point.x - ux * circleOffset
437
438
  const circleY = point.y - uy * circleOffset
438
439
  parts.push(
439
- `<circle cx="${circleX}" cy="${circleY}" r="4" ` +
440
- `fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
440
+ f`<circle cx="${circleX}" cy="${circleY}" r="4" ` +
441
+ f`fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
441
442
  )
442
443
  }
443
444