@likec4/generators 1.52.0 → 1.53.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.
@@ -0,0 +1,4 @@
1
+ {
2
+ "types": "../dist/likec4/index.d.mts",
3
+ "module": "../dist/likec4/index.mjs"
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@likec4/generators",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "license": "MIT",
5
5
  "bugs": "https://github.com/likec4/likec4/issues",
6
6
  "homepage": "https://likec4.dev",
@@ -30,6 +30,14 @@
30
30
  "import": "./dist/index.mjs",
31
31
  "default": "./dist/index.mjs"
32
32
  }
33
+ },
34
+ "./likec4": {
35
+ "sources": "./src/likec4/index.ts",
36
+ "default": {
37
+ "types": "./dist/likec4/index.d.mts",
38
+ "import": "./dist/likec4/index.mjs",
39
+ "default": "./dist/likec4/index.mjs"
40
+ }
33
41
  }
34
42
  },
35
43
  "publishConfig": {
@@ -40,18 +48,24 @@
40
48
  "langium": "3.5.0",
41
49
  "pako": "^2.1.0",
42
50
  "remeda": "^2.33.6",
51
+ "immer": "^11.1.4",
43
52
  "json5": "^2.2.3",
44
- "@likec4/core": "1.52.0",
45
- "@likec4/log": "1.52.0"
53
+ "type-fest": "^4.41.0",
54
+ "indent-string": "^5.0.0",
55
+ "strip-indent": "^4.1.1",
56
+ "zod": "^3.25.76",
57
+ "@likec4/core": "1.53.0",
58
+ "@likec4/log": "1.53.0",
59
+ "@likec4/config": "1.53.0"
46
60
  },
47
61
  "devDependencies": {
48
- "@types/node": "~22.19.11",
62
+ "@types/node": "~22.19.15",
49
63
  "@types/pako": "^2.0.4",
50
64
  "typescript": "5.9.3",
51
- "obuild": "^0.4.31",
52
- "vitest": "4.0.18",
53
- "@likec4/tsconfig": "1.52.0",
54
- "@likec4/devops": "1.42.0"
65
+ "obuild": "0.4.31",
66
+ "vitest": "4.1.0",
67
+ "@likec4/devops": "1.42.0",
68
+ "@likec4/tsconfig": "1.53.0"
55
69
  },
56
70
  "scripts": {
57
71
  "typecheck": "tsc -b --verbose",
@@ -438,6 +438,39 @@ function buildLikec4StyleForNode(params: NodeLikec4StyleParams): string {
438
438
  return parts.length > 0 ? parts.join(';') + ';' : ''
439
439
  }
440
440
 
441
+ /** Bridge-managed style parts for profile 'leanix': likec4Id, likec4Kind, likec4ViewId, likec4ProjectId, bridgeManaged, optional leanixFactSheetType. */
442
+ function buildBridgeManagedStyleForNode(
443
+ nodeId: string,
444
+ nodeKind: string,
445
+ viewId: string,
446
+ options: GenerateDrawioOptions | undefined,
447
+ ): string {
448
+ if (options?.profile !== 'leanix') return ''
449
+ const parts = [
450
+ 'bridgeManaged=true',
451
+ `likec4Id=${encodeURIComponent(nodeId)}`,
452
+ `likec4Kind=${encodeURIComponent(nodeKind)}`,
453
+ `likec4ViewId=${encodeURIComponent(viewId)}`,
454
+ ]
455
+ if (options.projectId != null && options.projectId !== '') {
456
+ parts.push(`likec4ProjectId=${encodeURIComponent(options.projectId)}`)
457
+ }
458
+ const factSheetType = options.leanixFactSheetTypeByKind?.[nodeKind]
459
+ if (factSheetType != null && factSheetType !== '') {
460
+ parts.push(`leanixFactSheetType=${encodeURIComponent(factSheetType)}`)
461
+ }
462
+ return parts.join(';') + ';'
463
+ }
464
+
465
+ /** Bridge-managed style parts for edge when profile is 'leanix': likec4RelationId, bridgeManaged. */
466
+ function buildBridgeManagedStyleForEdge(
467
+ relationId: string,
468
+ options: GenerateDrawioOptions | undefined,
469
+ ): string {
470
+ if (options?.profile !== 'leanix') return ''
471
+ return `bridgeManaged=true;likec4RelationId=${encodeURIComponent(relationId)};`
472
+ }
473
+
441
474
  /** Build mxUserObject XML from customData for round-trip; returns empty string when customData is missing or empty. */
442
475
  function buildMxUserObjectXml(customData: Record<string, string> | undefined): string {
443
476
  if (
@@ -508,12 +541,13 @@ function buildEdgeGeometryXml(
508
541
  return `<mxGeometry relative="1" as="geometry">${pointsXml}</mxGeometry>`
509
542
  }
510
543
 
511
- /** Full edge style string for mxCell (arrows, anchors, stroke, dash, label, likec4 roundtrip). */
544
+ /** Full edge style string for mxCell (arrows, anchors, stroke, dash, label, likec4 roundtrip, optional bridge-managed). */
512
545
  function buildEdgeStyleString(
513
546
  edge: Edge,
514
547
  layout: DiagramLayoutState,
515
548
  viewmodel: DrawioViewModelLike,
516
549
  label: string,
550
+ options: GenerateDrawioOptions | undefined,
517
551
  ): string {
518
552
  const { bboxes, fontFamily } = layout
519
553
  const sourceBbox = bboxes.get(edge.source)
@@ -550,13 +584,14 @@ function buildEdgeStyleString(
550
584
  edgeLinksJson,
551
585
  edgeMetadataJson,
552
586
  })
587
+ const edgeBridgeStyle = buildBridgeManagedStyleForEdge(edge.id, options)
553
588
  const edgeLabelColors = getEdgeLabelColors(viewmodel, edge.color)
554
589
  const edgeLabelStyle = label === ''
555
590
  ? ''
556
591
  : `fontColor=${edgeLabelColors.font};fontSize=12;align=center;verticalAlign=middle;labelBackgroundColor=none;fontFamily=${
557
592
  encodeURIComponent(fontFamily)
558
593
  };`
559
- return `endArrow=${endArrow};startArrow=${startArrow};html=1;rounded=0;${anchorStyle}strokeColor=${strokeColor};strokeWidth=2;${dashStyle}${edgeLabelStyle}${edgeLikec4Style}`
594
+ return `endArrow=${endArrow};startArrow=${startArrow};html=1;rounded=0;${anchorStyle}strokeColor=${strokeColor};strokeWidth=2;${dashStyle}${edgeLabelStyle}${edgeLikec4Style}${edgeBridgeStyle}`
560
595
  }
561
596
 
562
597
  /** Build a single edge mxCell XML (orchestrator: label + geometry + style + assembly). */
@@ -573,7 +608,7 @@ function buildEdgeCellXml(
573
608
  const targetId = getCellId(edge.target)
574
609
  const label = buildEdgeLabelValue(edge)
575
610
  const edgeGeometryXml = buildEdgeGeometryXml(edge, options?.edgeWaypoints)
576
- const styleStr = buildEdgeStyleString(edge, layout, viewmodel, label)
611
+ const styleStr = buildEdgeStyleString(edge, layout, viewmodel, label, options)
577
612
  const edgeCustomData = edgeOptionalFields.getCustomData(edge)
578
613
  const edgeUserObjectXml = buildMxUserObjectXml(edgeCustomData)
579
614
  return `<mxCell id="${edgeCellId}" value="${label}" style="${styleStr}" edge="1" parent="${defaultParentId}" source="${sourceId}" target="${targetId}">
@@ -650,6 +685,7 @@ function computeNodeStylePartsAndValue(
650
685
  const strokeColorByNodeId = options?.strokeColorByNodeId
651
686
  const strokeWidthByNodeId = options?.strokeWidthByNodeId
652
687
  const isContainer = containerNodeIds.has(node.id)
688
+ const nodeKind = (node as Node & { kind?: string }).kind ?? ''
653
689
  const title = node.title
654
690
  const desc = toExportString(node.description)
655
691
  const tech = toExportString(node.technology)
@@ -659,8 +695,11 @@ function computeNodeStylePartsAndValue(
659
695
  const navTo = toNonEmptyString(nodeOptionalFields.getNavigateTo(node))
660
696
  const iconName = toNonEmptyString(nodeOptionalFields.getIcon(node))
661
697
 
698
+ const isActor = nodeKind === 'actor' || node.shape === 'person'
662
699
  const shapeStyle = isContainer
663
700
  ? 'shape=rectangle;rounded=0;container=1;collapsible=0;startSize=0;'
701
+ : isActor
702
+ ? 'shape=actor;'
664
703
  : drawioShape(node.shape)
665
704
  const strokeColorOverride = strokeColorByNodeId?.[node.id]
666
705
  const strokeWidthOverride = strokeWidthByNodeId?.[node.id]
@@ -713,6 +752,8 @@ function computeNodeStylePartsAndValue(
713
752
  strokeHex,
714
753
  nodeNotation,
715
754
  })
755
+ const bridgeStyle = buildBridgeManagedStyleForNode(node.id, nodeKind, layout.view.id, options)
756
+ const likec4StyleWithBridge = likec4Style + bridgeStyle
716
757
 
717
758
  const nodeCustomData = nodeOptionalFields.getCustomData(node)
718
759
  const userObjectXml = buildMxUserObjectXml(nodeCustomData)
@@ -726,7 +767,7 @@ function computeNodeStylePartsAndValue(
726
767
 
727
768
  // vertexTextStyle already includes html=1; for both container and non-container
728
769
  const styleStr =
729
- `${vertexTextStyle}${shapeStyle}${colorStyle}${strokeWidthStyle}${containerDashed}${fillOpacityStyle}${navLinkStyle}${likec4Style}`
770
+ `${vertexTextStyle}${shapeStyle}${colorStyle}${strokeWidthStyle}${containerDashed}${fillOpacityStyle}${navLinkStyle}${likec4StyleWithBridge}`
730
771
 
731
772
  return {
732
773
  value,
@@ -850,20 +891,32 @@ function getViewDescriptionString(view: View): string {
850
891
  return ''
851
892
  }
852
893
 
853
- /** Build root cell style string from view metadata (title, description, notation) for round-trip. */
854
- function buildRootCellStyle(view: View): string {
894
+ /** Returns draw.io style tokens for the leanix profile (bridgeManaged, likec4ViewId, likec4ProjectId). Each token ends with ";". */
895
+ function getLeanixRootStyleParts(view: View, options: GenerateDrawioOptions): string[] {
896
+ const parts = ['bridgeManaged=true;', `likec4ViewId=${encodeURIComponent(view.id)};`]
897
+ if (options.projectId != null && options.projectId !== '') {
898
+ parts.push(`likec4ProjectId=${encodeURIComponent(options.projectId)};`)
899
+ }
900
+ return parts
901
+ }
902
+
903
+ /** Build root cell style string from view metadata (title, description, notation) for round-trip; when profile is 'leanix' adds likec4ViewId, likec4ProjectId, bridgeManaged. */
904
+ function buildRootCellStyle(view: View, options: GenerateDrawioOptions | undefined): string {
855
905
  const viewTitle = getViewTitle(view)
856
906
  const viewDesc = getViewDescriptionString(view)
857
907
  const viewDescEnc = viewDesc.trim() !== '' ? encodeURIComponent(viewDesc.trim()) : ''
858
908
  const viewNotationRaw = (view as unknown as { notation?: unknown }).notation
859
909
  const viewNotation = typeof viewNotationRaw === 'string' && viewNotationRaw !== '' ? viewNotationRaw : undefined
860
910
  const viewNotationEnc = viewNotation != null ? encodeURIComponent(viewNotation) : ''
861
- const rootParts = [
911
+ const rootParts: string[] = [
862
912
  'rounded=1;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;',
863
913
  `likec4ViewTitle=${encodeURIComponent(viewTitle ?? view.id)};`,
864
914
  viewDescEnc !== '' ? `likec4ViewDescription=${viewDescEnc};` : '',
865
915
  viewNotationEnc !== '' ? `likec4ViewNotation=${viewNotationEnc};` : '',
866
916
  ]
917
+ if (options?.profile === 'leanix') {
918
+ rootParts.push(...getLeanixRootStyleParts(view, options))
919
+ }
867
920
  return rootParts.join('')
868
921
  }
869
922
 
@@ -892,6 +945,9 @@ function drawioArrow(arrow: string | undefined | null): string {
892
945
  }
893
946
  }
894
947
 
948
+ /** Draw.io export profile: default (round-trip) or leanix (bridge-managed metadata for LeanIX interoperability). */
949
+ export type DrawioExportProfile = 'default' | 'leanix'
950
+
895
951
  /** Optional overrides for round-trip (e.g. from parsed comment blocks). Keys are node/edge ids from the view. */
896
952
  export type GenerateDrawioOptions = {
897
953
  /** Node id -> bbox to use instead of viewmodel layout */
@@ -912,6 +968,15 @@ export type GenerateDrawioOptions = {
912
968
  * Set for deterministic output (e.g. tests, content-addressable storage).
913
969
  */
914
970
  modified?: string
971
+ /**
972
+ * Export profile. When 'leanix', adds bridge-managed metadata (likec4Id, likec4Kind, likec4ViewId,
973
+ * likec4ProjectId, likec4RelationId, bridgeManaged) for round-trip and LeanIX interoperability.
974
+ */
975
+ profile?: DrawioExportProfile
976
+ /** Project id (included when profile is 'leanix' as likec4ProjectId). */
977
+ projectId?: string
978
+ /** Optional mapping of element kind -> LeanIX fact sheet type (included when profile is 'leanix' as leanixFactSheetType on vertices). */
979
+ leanixFactSheetTypeByKind?: Record<string, string>
915
980
  }
916
981
 
917
982
  /** Result of layout phase: bboxes, offsets, and shared styling so cell-building phase stays readable. */
@@ -1202,7 +1267,7 @@ function generateDiagramContent(
1202
1267
  )
1203
1268
  }
1204
1269
 
1205
- const rootCellStyle = buildRootCellStyle(view)
1270
+ const rootCellStyle = buildRootCellStyle(view, options)
1206
1271
 
1207
1272
  const allCells = [
1208
1273
  `<mxCell id="${defaultParentId}" value="" style="${rootCellStyle}" vertex="1" parent="${rootId}">
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  buildDrawioExportOptionsForViews,
3
3
  buildDrawioExportOptionsFromSource,
4
+ type DrawioExportProfile,
4
5
  type DrawioViewModelLike,
5
6
  generateDrawio,
6
7
  generateDrawioEditUrl,
@@ -89,10 +89,16 @@ export interface DrawioCell {
89
89
  notation?: string
90
90
  /** From style likec4Metadata (edge; JSON object for relation metadata block) */
91
91
  metadata?: string
92
+ /** From style likec4Id (vertex; bridge-managed element id for round-trip) */
93
+ likec4Id?: string
94
+ /** From style likec4RelationId (edge; bridge-managed relation id for round-trip) */
95
+ likec4RelationId?: string
92
96
  /** From mxUserObject/data keys not mapped to fields above (JSON object for round-trip comment) */
93
97
  customData?: string
94
98
  /** From mxGeometry Array/mxPoint (edge waypoints; JSON array of [x,y][]) */
95
99
  edgePoints?: string
100
+ /** From style key "shape" (e.g. actor, person) for kind/shape inference when raw style string is unavailable */
101
+ shapeFromStyle?: string
96
102
  }
97
103
 
98
104
  /** Edge cell with required source and target; use after filtering with isEdgeWithEndpoints. */
@@ -132,6 +138,77 @@ function getAttr(attrs: string, name: string): string | undefined {
132
138
  return undefined
133
139
  }
134
140
 
141
+ /**
142
+ * Fallback to extract style value from attrs when getAttr(attrs, 'style') returns undefined (e.g. spacing like "style =\"").
143
+ * Looks for style="..." (case-insensitive, optional spaces before '=') and returns the quoted value so the cell gets style when present in XML.
144
+ */
145
+ function extractStyleFromAttrsFallback(attrs: string): string | undefined {
146
+ const lower = attrs.toLowerCase()
147
+ let i = lower.indexOf('style')
148
+ while (i !== -1) {
149
+ const prev = i === 0 ? ' ' : (attrs[i - 1] ?? ' ')
150
+ if (!isAttrBoundaryChar(prev)) {
151
+ i = lower.indexOf('style', i + 1)
152
+ continue
153
+ }
154
+ let j = i + 5 // after "style"
155
+ while (j < attrs.length && isAttrBoundaryChar(attrs[j] ?? '')) j += 1
156
+ if (j < attrs.length && attrs[j] === '=') {
157
+ j += 1
158
+ while (j < attrs.length && isAttrBoundaryChar(attrs[j] ?? '')) j += 1
159
+ if (j < attrs.length && attrs[j] === '"') {
160
+ const valueStart = j + 1
161
+ const valueEnd = attrs.indexOf('"', valueStart)
162
+ if (valueEnd !== -1) return attrs.slice(valueStart, valueEnd)
163
+ }
164
+ }
165
+ i = lower.indexOf('style', i + 1)
166
+ }
167
+ return undefined
168
+ }
169
+
170
+ /** Extract style value from full open tag (e.g. from fullTag.slice(0, fullTag.indexOf('>'))). Use when attrs-based extraction missed it. */
171
+ function extractStyleFromOpenTag(fullTag: string): string | undefined {
172
+ const gt = fullTag.indexOf('>')
173
+ if (gt === -1) return undefined
174
+ return getAttr(fullTag.slice(7, gt), 'style') ?? extractStyleFromAttrsFallback(fullTag.slice(7, gt))
175
+ }
176
+
177
+ /** Max fullTag length to run style re-extraction (avoids scanning huge strings). */
178
+ const MAX_FULLTAG_LENGTH_FOR_STYLE_SCAN = 10_000
179
+ /** Characters to scan for style=" or style=' when re-extracting from tag (covers open tag). */
180
+ const STYLE_VALUE_SCAN_CHARS = 1500
181
+
182
+ /**
183
+ * Re-extract style attribute value from tag content when getAttr missed it.
184
+ * Scans the first maxScan chars for style="..." or style='...'.
185
+ */
186
+ function extractStyleFromTagContent(fullTag: string, maxScan = STYLE_VALUE_SCAN_CHARS): string | undefined {
187
+ const scan = fullTag.slice(0, maxScan)
188
+ const styleDq = scan.toLowerCase().indexOf('style="')
189
+ const styleSq = scan.toLowerCase().indexOf('style=\'')
190
+ const useDq = styleDq !== -1 && (styleSq === -1 || styleDq <= styleSq)
191
+ const styleIdx = useDq ? styleDq : styleSq
192
+ const quote = styleIdx !== -1 ? (useDq ? '"' : '\'') : ''
193
+ if (styleIdx === -1 || !quote) return undefined
194
+ const valueStart = styleIdx + 7
195
+ const valueEnd = scan.indexOf(quote, valueStart)
196
+ return valueEnd !== -1 ? scan.slice(valueStart, valueEnd) : undefined
197
+ }
198
+
199
+ /** True when style or fullTag (lowercased) indicates actor/person shape. */
200
+ function styleOrTagIndicatesActor(style: string | undefined, fullTagLower: string): boolean {
201
+ const s = style?.toLowerCase() ?? ''
202
+ return (
203
+ s.includes('shape=actor') ||
204
+ s.includes('shape=person') ||
205
+ s.includes('umlactor') ||
206
+ fullTagLower.includes('shape=actor') ||
207
+ fullTagLower.includes('shape=person') ||
208
+ fullTagLower.includes('umlactor')
209
+ )
210
+ }
211
+
135
212
  /** Find end of XML open tag (first unquoted '>'). Handles both single- and double-quoted attributes. Avoids regex for S5852. */
136
213
  function findOpenTagEnd(xml: string, start: number): number {
137
214
  let quoteChar = ''
@@ -338,6 +415,8 @@ function buildCellOptionalFields(params: {
338
415
  const relationshipKind = getDecodedStyle(styleMap, 'likec4relationshipkind')
339
416
  const notation = getDecodedStyle(styleMap, 'likec4notation')
340
417
  const metadata = getDecodedStyle(styleMap, 'likec4metadata')
418
+ const likec4Id = getDecodedStyle(styleMap, 'likec4id')
419
+ const likec4RelationId = getDecodedStyle(styleMap, 'likec4relationid')
341
420
  const optional: Partial<DrawioCell> = {}
342
421
  if (params.valueRaw != null && params.valueRaw !== '') {
343
422
  optional.value = decodeXmlEntities(params.valueRaw)
@@ -346,6 +425,10 @@ function buildCellOptionalFields(params: {
346
425
  if (params.source != null && params.source !== '') optional.source = params.source
347
426
  if (params.target != null && params.target !== '') optional.target = params.target
348
427
  if (params.style != null && params.style !== '') optional.style = params.style
428
+ else if (params.styleMap.has('shape')) {
429
+ // Fallback: raw style string may be missing from attrs (e.g. parsing path); ensure kind/shape inference can run
430
+ optional.style = `shape=${params.styleMap.get('shape')};`
431
+ }
349
432
  if (x !== undefined) optional.x = x
350
433
  if (y !== undefined) optional.y = y
351
434
  if (width !== undefined) optional.width = width
@@ -376,6 +459,16 @@ function buildCellOptionalFields(params: {
376
459
  if (relationshipKind != null) optional.relationshipKind = relationshipKind
377
460
  if (notation != null) optional.notation = notation
378
461
  if (metadata != null && edge) optional.metadata = metadata
462
+ if (likec4Id != null && vertex) optional.likec4Id = likec4Id
463
+ if (likec4RelationId != null && edge) optional.likec4RelationId = likec4RelationId
464
+ let shapeFromStyle = params.styleMap.get('shape')?.trim()
465
+ const fullTagLower = params.fullTag.toLowerCase()
466
+ if (
467
+ (shapeFromStyle == null || shapeFromStyle === '') && vertex && styleOrTagIndicatesActor(params.style, fullTagLower)
468
+ ) {
469
+ shapeFromStyle = 'actor'
470
+ }
471
+ if (shapeFromStyle != null && shapeFromStyle !== '' && vertex) optional.shapeFromStyle = shapeFromStyle
379
472
  if (userData.customData != null) optional.customData = userData.customData
380
473
  if (edge) {
381
474
  const pts = parseEdgePoints(fullTag)
@@ -399,9 +492,19 @@ function buildCellFromMxCell(
399
492
  if (!id) return null
400
493
  const vertex = getAttr(attrs, 'vertex') === '1'
401
494
  const edge = getAttr(attrs, 'edge') === '1'
402
- const style = getAttr(attrs, 'style')
495
+ let style = getAttr(attrs, 'style') ??
496
+ extractStyleFromAttrsFallback(attrs) ??
497
+ extractStyleFromOpenTag(fullTag)
498
+ const styleMissing = !style || style.trim() === ''
499
+ const tagHasActor = fullTag.length < MAX_FULLTAG_LENGTH_FOR_STYLE_SCAN &&
500
+ fullTag.toLowerCase().includes('shape=actor')
501
+ const needLastResort = styleMissing || (tagHasActor && !style?.toLowerCase().includes('shape=actor'))
502
+ if (needLastResort && fullTag.length < MAX_FULLTAG_LENGTH_FOR_STYLE_SCAN) {
503
+ const reExtracted = extractStyleFromTagContent(fullTag)
504
+ if (reExtracted != null) style = reExtracted
505
+ }
403
506
  const geomStr = extractMxGeometryOpenTag(fullTag)
404
- const styleMap = parseStyle(style ?? undefined)
507
+ const styleMap = parseStyle(style?.trim() || undefined)
405
508
  const userData = parseUserData(inner)
406
509
  const navigateTo = overrides?.navigateTo ?? getDecodedStyle(styleMap, 'likec4navigateto')
407
510
  const optional = buildCellOptionalFields({
@@ -409,7 +512,7 @@ function buildCellFromMxCell(
409
512
  parent: getAttr(attrs, 'parent'),
410
513
  source: getAttr(attrs, 'source'),
411
514
  target: getAttr(attrs, 'target'),
412
- style: getAttr(attrs, 'style'),
515
+ style,
413
516
  styleMap,
414
517
  userData,
415
518
  geomStr,
@@ -418,7 +521,7 @@ function buildCellFromMxCell(
418
521
  edge,
419
522
  navigateTo,
420
523
  })
421
- return { id, vertex, edge, ...optional } as DrawioCell
524
+ return { id, vertex, edge, ...(style != null && style !== '' ? { style } : {}), ...optional } as DrawioCell
422
525
  }
423
526
 
424
527
  /** Extract one mxCell from xml starting at tagStart. Returns attrs, inner, fullTag and next search index, or null. */
@@ -549,20 +652,29 @@ function likec4LineType(
549
652
  return undefined
550
653
  }
551
654
 
655
+ /** True when style or shapeFromStyle indicates actor/person (DrawIO shape=actor, shape=person, umlActor). */
656
+ function isActorShapeInStyle(style: string | undefined, shapeFromStyle?: string): boolean {
657
+ const s = style?.toLowerCase() ?? ''
658
+ const shape = shapeFromStyle?.toLowerCase().trim()
659
+ return shape === 'actor' || shape === 'person' || s.includes('shape=actor') || s.includes('shape=person') ||
660
+ s.includes('umlactor')
661
+ }
662
+
552
663
  /**
553
664
  * Infer LikeC4 element kind from DrawIO shape style. When parent is a container (container=1), child is component.
554
665
  * Explicit container=1 in style → system (context box); others default to container unless actor/swimlane.
666
+ * Uses shapeFromStyle when raw style is missing so actor/person is still inferred.
555
667
  */
556
668
  function inferKind(
557
669
  style: string | undefined,
558
670
  parentCell?: DrawioCell,
671
+ shapeFromStyle?: string,
559
672
  ): 'actor' | 'system' | 'container' | 'component' {
560
673
  const s = style?.toLowerCase() ?? ''
674
+ if (isActorShapeInStyle(style, shapeFromStyle)) return 'actor'
561
675
  switch (true) {
562
- case !style:
676
+ case !style && !shapeFromStyle:
563
677
  return parentCell?.style?.toLowerCase().includes('container=1') ? 'component' : 'container'
564
- case s.includes('umlactor') || s.includes('shape=person') || s.includes('shape=actor'):
565
- return 'actor'
566
678
  case s.includes('swimlane'):
567
679
  case s.includes('container=1'):
568
680
  return 'system'
@@ -573,12 +685,10 @@ function inferKind(
573
685
  }
574
686
  }
575
687
 
576
- /** Infer LikeC4 shape from DrawIO style when possible (person, cylinder, document, etc.). */
577
- function inferShape(style: string | undefined): string | undefined {
578
- if (!style) return undefined
579
- const s = style.toLowerCase()
580
- // Actor/person shape (export may use shape=actor or shape=umlActor; legacy may have shape=person)
581
- if (s.includes('shape=actor') || s.includes('shape=person') || s.includes('umlactor')) return 'person'
688
+ /** Infer LikeC4 shape from DrawIO style (or shapeFromStyle) when possible (person, cylinder, document, etc.). */
689
+ function inferShape(style: string | undefined, shapeFromStyle?: string): string | undefined {
690
+ const s = style?.toLowerCase() ?? ''
691
+ if (isActorShapeInStyle(style, shapeFromStyle)) return 'person'
582
692
  if (s.includes('shape=cylinder') || s.includes('cylinder3')) return 'cylinder'
583
693
  if (s.includes('shape=document')) return 'document'
584
694
  if (s.includes('shape=rectangle') && s.includes('rounded')) return 'rectangle'
@@ -610,16 +720,60 @@ function makeUniqueName(usedNames: Set<string>): (base: string) => string {
610
720
  }
611
721
  }
612
722
 
613
- /** Assign FQNs to element vertices: root first, then hierarchy by parent, then orphans (DRY). */
723
+ /** True when s is a syntactically valid dot-separated FQN (each segment non-empty, identifier-like). */
724
+ function isValidFqn(s: string): boolean {
725
+ if (s.length === 0) return false
726
+ const segments = s.split('.')
727
+ return segments.every(seg => /^[a-zA-Z0-9_-]+$/.test(seg))
728
+ }
729
+
730
+ /** Depth of vertex from root (0 = root or parent not in diagram). Cycle-safe: cycles in parent graph return 0. */
731
+ function vertexDepth(
732
+ v: DrawioCell,
733
+ idToVertex: Map<string, DrawioCell>,
734
+ visited: Set<string> = new Set(),
735
+ ): number {
736
+ if (visited.has(v.id)) return 0
737
+ visited.add(v.id)
738
+ if (v.parent == null || !idToVertex.has(v.parent)) return 0
739
+ return 1 + vertexDepth(idToVertex.get(v.parent)!, idToVertex, visited)
740
+ }
741
+
742
+ /** True when bridgeId is valid FQN and matches parent chain (root or prefix). */
743
+ function isUsableBridgeId(
744
+ bridgeId: string,
745
+ v: DrawioCell,
746
+ idToFqn: Map<string, string>,
747
+ _idToVertex: Map<string, DrawioCell>,
748
+ isRootParent: (parent: string | undefined) => boolean,
749
+ ): boolean {
750
+ if (!isValidFqn(bridgeId)) return false
751
+ const parentFqn = v.parent ? idToFqn.get(v.parent) : undefined
752
+ if (parentFqn === undefined) return isRootParent(v.parent)
753
+ return bridgeId.startsWith(parentFqn + '.') && bridgeId.length > parentFqn.length + 1
754
+ }
755
+
756
+ /** Assign FQNs to element vertices: bridge-managed likec4Id first (when valid), then root, hierarchy, orphans (DRY). */
614
757
  function assignFqnsToElementVertices(
615
758
  idToFqn: Map<string, string>,
616
759
  elementVertices: DrawioCell[],
617
760
  containerIdToTitle: Map<string, string>,
618
761
  isRootParent: (parent: string | undefined) => boolean,
619
762
  uniqueName: (base: string) => string,
763
+ usedNames: Set<string>,
620
764
  ): void {
621
765
  const baseName = (v: DrawioCell) => v.value ?? containerIdToTitle.get(v.id) ?? v.id
766
+ const idToVertex = new Map(elementVertices.map(v => [v.id, v]))
767
+ const byDepth = [...elementVertices].sort((a, b) => vertexDepth(a, idToVertex) - vertexDepth(b, idToVertex))
768
+ for (const v of byDepth) {
769
+ const bridgeId = v.likec4Id?.trim()
770
+ if (bridgeId && isUsableBridgeId(bridgeId, v, idToFqn, idToVertex, isRootParent)) {
771
+ idToFqn.set(v.id, bridgeId)
772
+ for (const segment of bridgeId.split('.')) usedNames.add(segment)
773
+ }
774
+ }
622
775
  for (const v of elementVertices) {
776
+ if (idToFqn.has(v.id)) continue
623
777
  if (isRootParent(v.parent)) idToFqn.set(v.id, uniqueName(baseName(v)))
624
778
  }
625
779
  let changed = true
@@ -804,7 +958,7 @@ function pushElementStyleBlock(
804
958
  ): void {
805
959
  const border = cell.border?.trim()
806
960
  const opacityVal = cell.opacity
807
- const shapeOverride = inferShape(cell.style)
961
+ const shapeOverride = inferShape(cell.style, cell.shapeFromStyle)
808
962
  const sizeVal = cell.size?.trim()
809
963
  const paddingVal = cell.padding?.trim()
810
964
  const textSizeVal = cell.textSize?.trim()
@@ -918,7 +1072,7 @@ function elementHasBody(
918
1072
  !!colorName ||
919
1073
  !!cell.border?.trim() ||
920
1074
  !!cell.opacity ||
921
- !!inferShape(cell.style) ||
1075
+ !!inferShape(cell.style, cell.shapeFromStyle) ||
922
1076
  !!cell.size ||
923
1077
  !!cell.padding ||
924
1078
  !!cell.textSize ||
@@ -974,7 +1128,7 @@ function emitElementToLines(ctx: ElementEmitContext, cellId: string, fqn: string
974
1128
  const cell = ctx.idToCell.get(cellId)
975
1129
  if (!cell) return
976
1130
  const parentCell = cell.parent ? ctx.byId.get(cell.parent) : undefined
977
- const kind = inferKind(cell.style, parentCell)
1131
+ const kind = inferKind(cell.style, parentCell, cell.shapeFromStyle)
978
1132
  const rawTitle = (cell.value && cell.value.trim()) || ''
979
1133
  const title = stripHtml(rawTitle) ||
980
1134
  (ctx.containerIdToTitle.get(cell.id) ?? ctx.containerIdToTitle.get(cellId) ?? '') ||
@@ -1363,7 +1517,7 @@ function buildCommonDiagramStateFromCells(
1363
1517
  const elementVertices = vertices.filter(v => !titleCellIds.has(v.id))
1364
1518
  const usedNames = new Set<string>()
1365
1519
  const uniqueName = makeUniqueName(usedNames)
1366
- assignFqnsToElementVertices(idToFqn, elementVertices, containerIdToTitle, isRootParent, uniqueName)
1520
+ assignFqnsToElementVertices(idToFqn, elementVertices, containerIdToTitle, isRootParent, uniqueName, usedNames)
1367
1521
  const hexToCustomName = buildHexToCustomName(elementVertices, edges)
1368
1522
  const children = new Map<string, Array<{ cellId: string; fqn: string }>>()
1369
1523
  const roots: Array<{ cellId: string; fqn: string }> = []
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { DEFAULT_DRAWIO_ALL_FILENAME } from './drawio/constants'
3
3
  export {
4
4
  buildDrawioExportOptionsForViews,
5
5
  buildDrawioExportOptionsFromSource,
6
+ type DrawioExportProfile,
6
7
  type DrawioViewModelLike,
7
8
  generateDrawio,
8
9
  generateDrawioEditUrl,
@@ -10,6 +11,7 @@ export {
10
11
  type GenerateDrawioOptions,
11
12
  } from './drawio/generate-drawio'
12
13
  export {
14
+ decompressDrawioDiagram,
13
15
  getAllDiagrams,
14
16
  parseDrawioRoundtripComments,
15
17
  parseDrawioToLikeC4,
@@ -0,0 +1,72 @@
1
+ import * as operators from './operators'
2
+ import { type AnyOp, type ctxOf, materialize, withctx } from './operators/base'
3
+ import { schemas } from './schemas'
4
+
5
+ type Params = {
6
+ indentation?: string | number
7
+ }
8
+
9
+ export function generateLikeC4(input: schemas.likec4data.Input, params?: Params): string {
10
+ params = {
11
+ indentation: 2,
12
+ ...params,
13
+ }
14
+ return materialize(withctx(input, operators.likec4data()), params.indentation)
15
+ }
16
+
17
+ /**
18
+ * Prints the result of an operation with the data
19
+ *
20
+ * @see operators
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * print(operators.expression, {
25
+ * ref: {
26
+ * model: 'some.el',
27
+ * },
28
+ * selector: 'descendants',
29
+ * })
30
+ * // "some.el.**"
31
+ * ```
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * print(operators.model, {
36
+ * elements: [
37
+ * {
38
+ * id: 'cloud',
39
+ * kind: 'system',
40
+ * },
41
+ * {
42
+ * id: 'cloud.mobile',
43
+ * kind: 'mobileapp',
44
+ * shape: 'mobile',
45
+ * color: 'amber',
46
+ * }
47
+ * ],
48
+ * })
49
+ * // model {
50
+ * // cloud = system {
51
+ * // mobile = mobileapp {
52
+ * // style {
53
+ * // shape mobile
54
+ * // color amber
55
+ * // }
56
+ * // }
57
+ * // }
58
+ * // }
59
+ * ```
60
+ */
61
+ export function print<O extends () => AnyOp>(operator: O, data: ctxOf<O>, params?: Params): string {
62
+ return materialize(withctx(data, operator()), params?.indentation)
63
+ }
64
+
65
+ /**
66
+ * Same as {@link print} but uses tab indentation
67
+ */
68
+ export function printTabIndent<O extends () => AnyOp>(operator: O, data: ctxOf<O>): string {
69
+ return materialize(withctx(data, operator()), '\t')
70
+ }
71
+
72
+ export { operators }
@@ -0,0 +1,12 @@
1
+ export {
2
+ generateLikeC4 as generate,
3
+ operators,
4
+ print,
5
+ printTabIndent,
6
+ } from './generate-likec4'
7
+
8
+ export type * from './operators/base'
9
+
10
+ export type {
11
+ schemas,
12
+ } from './schemas'