@likec4/core 1.32.2 → 1.33.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.
Files changed (96) hide show
  1. package/dist/builder/index.d.mts +6 -4
  2. package/dist/builder/index.mjs +45 -13
  3. package/dist/compute-view/index.d.mts +6 -4
  4. package/dist/compute-view/index.mjs +33 -10
  5. package/dist/compute-view/relationships-view/index.d.mts +6 -4
  6. package/dist/compute-view/relationships-view/index.mjs +6 -3
  7. package/dist/index.d.mts +9 -138
  8. package/dist/index.mjs +31 -3712
  9. package/dist/model/connection/deployment/index.d.mts +10 -0
  10. package/dist/model/connection/deployment/index.mjs +18 -0
  11. package/dist/model/connection/index.d.mts +70 -0
  12. package/dist/model/connection/index.mjs +23 -0
  13. package/dist/model/connection/model/index.d.mts +10 -0
  14. package/dist/model/connection/model/index.mjs +11 -0
  15. package/dist/model/index.d.mts +14 -256
  16. package/dist/model/index.mjs +27 -5
  17. package/dist/shared/{core.Bdz6fBF1.d.mts → core.1pNeDdRk.d.mts} +92 -81
  18. package/dist/shared/{core.BuO4ncfY.d.mts → core.668kXpZq.d.mts} +42 -52
  19. package/dist/shared/core.8KM0D6va.mjs +33 -0
  20. package/dist/shared/core.B0oEIXqd.mjs +47 -0
  21. package/dist/shared/core.BH9nN-_I.mjs +22 -0
  22. package/dist/shared/core.BMCb0wJI.mjs +177 -0
  23. package/dist/shared/core.BTnf14gD.mjs +331 -0
  24. package/dist/shared/core.BgvIuGGU.mjs +3 -0
  25. package/dist/shared/core.BoyJPEen.mjs +21 -0
  26. package/dist/shared/{core.CUYDgv2-.mjs → core.Br7Fab9l.mjs} +12 -15
  27. package/dist/shared/{core.BrCnZFTn.mjs → core.Btl47GOn.mjs} +3 -3
  28. package/dist/shared/core.ByrmCl9I.mjs +5 -0
  29. package/dist/shared/core.C80Z37Eu.mjs +29 -0
  30. package/dist/shared/{core.Pf5I9o0e.d.mts → core.CF82MWyh.d.mts} +1 -1
  31. package/dist/shared/{core.rmvsy0n3.mjs → core.C_C-3c1S.mjs} +5 -198
  32. package/dist/shared/core.CeQbbp7K.d.mts +41 -0
  33. package/dist/shared/{core.DoK86JEe.mjs → core.CpwrZi3D.mjs} +2 -73
  34. package/dist/shared/{core.CpXlRyE5.mjs → core.CtKXPqN_.mjs} +5 -36
  35. package/dist/shared/core.CxGKeL-L.mjs +144 -0
  36. package/dist/shared/core.CzdwsPmY.d.mts +69 -0
  37. package/dist/shared/{core.Cy9smucx.mjs → core.D4npX2q8.mjs} +1 -3
  38. package/dist/shared/core.D56nCp7e.mjs +168 -0
  39. package/dist/shared/core.D5qVzkws.mjs +724 -0
  40. package/dist/shared/core.DDAPzd3T.d.mts +87 -0
  41. package/dist/shared/{core.CBpKAlhj.mjs → core.DDynPqbh.mjs} +96 -56
  42. package/dist/shared/{core.Bk7wEZFG.mjs → core.DXRAwwDB.mjs} +3 -2
  43. package/dist/shared/core.DcS-0zys.mjs +10 -0
  44. package/dist/shared/{core.CBxTtXMi.d.mts → core.De___iBS.d.mts} +3 -2
  45. package/dist/shared/core.DsMrjY2q.mjs +34 -0
  46. package/dist/shared/core.DuSLYTG6.d.mts +57 -0
  47. package/dist/shared/core.Dy-F3xRP.d.mts +83 -0
  48. package/dist/shared/{core.DaAyklhe.d.mts → core.OLInHaJx.d.mts} +30 -22
  49. package/dist/shared/core.XptQjhMA.mjs +1295 -0
  50. package/dist/shared/{core.cbA9VZ02.mjs → core.hoq9W4rW.mjs} +28 -1073
  51. package/dist/shared/core.zLlww-07.mjs +3 -0
  52. package/dist/theme/index.d.mts +134 -0
  53. package/dist/theme/index.mjs +3709 -0
  54. package/dist/types/aux.d.mts +3 -2
  55. package/dist/types/index.d.mts +9 -5
  56. package/dist/types/index.mjs +17 -146
  57. package/dist/utils/index.d.mts +15 -100
  58. package/dist/utils/index.mjs +25 -1315
  59. package/dist/utils/iterable/index.d.mts +100 -0
  60. package/dist/utils/iterable/index.mjs +24 -0
  61. package/package.json +23 -7
  62. package/src/builder/Builder.ts +13 -4
  63. package/src/builder/__snapshots__/Builder-style1.spec.ts.snap +0 -13
  64. package/src/builder/__snapshots__/Builder-style2.compute-model.json5 +2 -6
  65. package/src/builder/__snapshots__/Builder-style2.spec.ts.snap +0 -7
  66. package/src/compute-view/deployment-view/__test__/fixture.ts +15 -5
  67. package/src/compute-view/deployment-view/utils.ts +13 -13
  68. package/src/compute-view/dynamic-view/compute.ts +2 -1
  69. package/src/compute-view/element-view/__test__/__snapshots__/legacy.spec.ts.snap +2 -2
  70. package/src/compute-view/relationships-view/layout.ts +1 -1
  71. package/src/compute-view/utils/applyCustomElementProperties.ts +8 -0
  72. package/src/compute-view/utils/applyCustomRelationProperties.ts +2 -0
  73. package/src/compute-view/utils/buildComputedNodes.ts +4 -3
  74. package/src/compute-view/utils/merge-props-from-relationships.ts +6 -5
  75. package/src/compute-view/utils/view-hash.ts +24 -3
  76. package/src/model/DeploymentElementModel.ts +20 -16
  77. package/src/model/ElementModel.ts +9 -3
  78. package/src/model/RelationModel.ts +19 -16
  79. package/src/model/index.ts +2 -0
  80. package/src/model/types.ts +1 -0
  81. package/src/model/view/EdgeModel.ts +4 -3
  82. package/src/model/view/LikeC4ViewModel.ts +5 -0
  83. package/src/model/view/NodeModel.ts +4 -2
  84. package/src/theme/index.ts +12 -5
  85. package/src/types/RichText.ts +200 -0
  86. package/src/types/index.ts +2 -0
  87. package/src/types/model-deployment.ts +4 -3
  88. package/src/types/model-logical.ts +3 -3
  89. package/src/types/scalar.ts +47 -2
  90. package/src/types/view-common.ts +1 -1
  91. package/src/types/view-computed.ts +2 -4
  92. package/src/types/view-layouted.ts +9 -0
  93. package/src/utils/index.ts +4 -9
  94. package/src/utils/markdown/index.ts +2 -0
  95. package/src/utils/markdown/to-html.ts +32 -0
  96. package/src/utils/markdown/to-text.ts +14 -0
@@ -47,6 +47,7 @@ export function applyCustomElementProperties<A extends AnyAux>(
47
47
  padding,
48
48
  size,
49
49
  textSize,
50
+ description,
50
51
  ...rest
51
52
  } = omitBy(props, isNullish)
52
53
  const notEmpty = !isEmpty(rest)
@@ -62,6 +63,13 @@ export function applyCustomElementProperties<A extends AnyAux>(
62
63
  ...rest,
63
64
  }
64
65
  }
66
+ if (description !== undefined) {
67
+ node = {
68
+ ...node,
69
+ isCustomized: true,
70
+ description: { txt: description },
71
+ }
72
+ }
65
73
 
66
74
  let styleOverride: ComputedNode['style'] | undefined
67
75
  if (border !== undefined) {
@@ -18,6 +18,7 @@ export function applyCustomRelationProperties<A extends AnyAux>(
18
18
  customRelation: {
19
19
  expr,
20
20
  title,
21
+ description,
21
22
  ...customprops
22
23
  },
23
24
  } of rules
@@ -44,6 +45,7 @@ export function applyCustomRelationProperties<A extends AnyAux>(
44
45
  edges[i] = {
45
46
  ...edge,
46
47
  ...props,
48
+ ...description && { description: { txt: description } },
47
49
  label: title ?? edge.label,
48
50
  isCustomized: true,
49
51
  }
@@ -29,8 +29,8 @@ function updateDepthOfAncestors(node: ComputedNode, nodes: ReadonlyMap<string, C
29
29
  }
30
30
 
31
31
  export type ComputedNodeSource<A extends AnyAux = Unknown> = Simplify<
32
- & Pick<ComputedNode<A>, 'id' | 'title' | 'kind' | 'deploymentRef' | 'modelRef'>
33
- & Partial<Omit<Element<A>, 'id' | 'title' | 'kind'>>
32
+ & Pick<ComputedNode<A>, 'id' | 'title' | 'description' | 'kind' | 'deploymentRef' | 'modelRef'>
33
+ & Partial<Omit<Element<A>, 'id' | 'title' | 'description' | 'kind'>>
34
34
  >
35
35
 
36
36
  export function elementModelToNodeSource<A extends AnyAux>(el: ElementModel<A>): ComputedNodeSource<A> {
@@ -86,7 +86,7 @@ export function buildComputedNodes<A extends AnyAux>(
86
86
  // Ensure that parent nodes are created before child nodes
87
87
  Array.from(elements)
88
88
  .sort(compareByFqnHierarchically)
89
- .forEach(({ id, style, kind, title, color, shape, tags, ...el }) => {
89
+ .forEach(({ id, style, kind, title, color, shape, tags, notation, ...el }) => {
90
90
  let parent = parentFqn(id)
91
91
  let level = 0
92
92
  let parentNd: ComputedNode<A> | undefined
@@ -130,6 +130,7 @@ export function buildComputedNodes<A extends AnyAux>(
130
130
  children: [],
131
131
  inEdges: [],
132
132
  outEdges: [],
133
+ ...notation && { notation },
133
134
  ...el,
134
135
  style: {
135
136
  ...style,
@@ -1,4 +1,4 @@
1
- import { isTruthy, only, pickBy, pipe, reduce, unique } from 'remeda'
1
+ import { isDeepEqual, isTruthy, only, pickBy, pipe, reduce, unique } from 'remeda'
2
2
  import type {
3
3
  AnyAux,
4
4
  aux,
@@ -9,12 +9,13 @@ import type {
9
9
  Relationship,
10
10
  RelationshipArrowType,
11
11
  RelationshipLineType,
12
+ scalar,
12
13
  } from '../../types'
13
14
  import { isNonEmptyArray } from '../../utils'
14
15
 
15
16
  function pickRelationshipProps<A extends AnyAux>(relation: Relationship<A> | DeploymentRelationship<A>): {
16
17
  title?: string
17
- description?: string | null
18
+ description?: scalar.MarkdownOrString | null
18
19
  technology: string | null
19
20
  kind: aux.RelationKind<A> | null
20
21
  color: Color | null
@@ -52,7 +53,7 @@ function pickRelationshipProps<A extends AnyAux>(relation: Relationship<A> | Dep
52
53
 
53
54
  export type MergedRelationshipProps<A extends AnyAux> = {
54
55
  title?: string | null
55
- description?: string
56
+ description?: scalar.MarkdownOrString | null
56
57
  technology?: string
57
58
  kind?: aux.RelationKind<A>
58
59
  color?: Color
@@ -80,7 +81,7 @@ export function mergePropsFromRelationships<A extends AnyAux>(
80
81
  if (isTruthy(r.title) && !acc.title.includes(r.title)) {
81
82
  acc.title.push(r.title)
82
83
  }
83
- if (isTruthy(r.description) && !acc.description.includes(r.description)) {
84
+ if (isTruthy(r.description) && !acc.description.some(isDeepEqual(r.description))) {
84
85
  acc.description.push(r.description)
85
86
  }
86
87
  if (isTruthy(r.technology) && !acc.technology.includes(r.technology)) {
@@ -114,7 +115,7 @@ export function mergePropsFromRelationships<A extends AnyAux>(
114
115
  },
115
116
  {
116
117
  title: [] as string[],
117
- description: [] as string[],
118
+ description: [] as scalar.MarkdownOrString[],
118
119
  technology: [] as string[],
119
120
  kind: [] as aux.RelationKind<A>[],
120
121
  head: [] as RelationshipArrowType[],
@@ -10,12 +10,33 @@ export function calcViewLayoutHash<V extends ComputedView>(view: SetOptional<V,
10
10
  autoLayout: view.autoLayout,
11
11
  nodes: pipe(
12
12
  view.nodes,
13
- map(pick(['id', 'title', 'description', 'technology', 'shape', 'icon', 'children'])),
14
- mapToObj(({ id, icon, ...node }) => [id, { ...node, icon: isTruthy(icon) ? 'Y' : 'N' }]),
13
+ map(n => ({
14
+ id: n.id,
15
+ icon: isTruthy(n.icon) ? 'Y' : 'N',
16
+ title: n.title,
17
+ description: n.description?.md ?? n.description?.txt ?? null,
18
+ technology: n.technology ?? null,
19
+ shape: n.shape,
20
+ size: n.style.size ?? null,
21
+ textSize: n.style.textSize ?? null,
22
+ padding: n.style.padding ?? null,
23
+ children: n.children,
24
+ })),
25
+ mapToObj(({ id, ...node }) => [id, node]),
15
26
  ),
16
27
  edges: pipe(
17
28
  view.edges,
18
- map(pick(['source', 'target', 'label', 'description', 'technology', 'dir', 'head', 'tail', 'line'])),
29
+ map(e => ({
30
+ source: e.source,
31
+ target: e.target,
32
+ label: e.label,
33
+ description: e.description?.md ?? e.description?.txt ?? null,
34
+ technology: e.technology ?? null,
35
+ dir: e.dir,
36
+ head: e.head,
37
+ tail: e.tail,
38
+ line: e.line,
39
+ })),
19
40
  mapToObj(({ source, target, ...edge }) => [`${source}:${target}`, edge]),
20
41
  ),
21
42
  }
@@ -1,4 +1,4 @@
1
- import { isTruthy, only, unique } from 'remeda'
1
+ import { isEmpty, isTruthy, only, unique } from 'remeda'
2
2
  import type { SetRequired } from 'type-fest'
3
3
  import {
4
4
  type Any,
@@ -12,11 +12,13 @@ import {
12
12
  type IteratorLike,
13
13
  type Link,
14
14
  type RelationshipLineType,
15
+ type RichTextOrEmpty,
15
16
  type scalar,
16
17
  DefaultElementShape,
17
18
  DefaultLineStyle,
18
19
  DefaultShapeSize,
19
20
  DefaultThemeColor,
21
+ RichText,
20
22
  } from '../types'
21
23
  import * as aux from '../types/aux'
22
24
  import { commonAncestor, hierarchyLevel, memoizeProp, nonNullable } from '../utils'
@@ -61,8 +63,8 @@ abstract class AbstractDeploymentElementModel<A extends Any> implements WithTags
61
63
  return this.$node.style?.color as Color ?? DefaultThemeColor
62
64
  }
63
65
 
64
- get description(): string | null {
65
- return this.$node.description ?? null
66
+ get description(): RichTextOrEmpty {
67
+ return RichText.memoize(this, this.$node.description)
66
68
  }
67
69
 
68
70
  get technology(): string | null {
@@ -199,6 +201,10 @@ abstract class AbstractDeploymentElementModel<A extends Any> implements WithTags
199
201
  ))
200
202
  }
201
203
 
204
+ public hasMetadata(): boolean {
205
+ return !!this.$node.metadata && !isEmpty(this.$node.metadata)
206
+ }
207
+
202
208
  public getMetadata(): aux.Metadata<A>
203
209
  public getMetadata(field: aux.MetadataKey<A>): string | undefined
204
210
  public getMetadata(field?: aux.MetadataKey<A>) {
@@ -402,12 +408,12 @@ export class DeployedInstanceModel<A extends Any = Any> extends AbstractDeployme
402
408
  return this.element.kind
403
409
  }
404
410
 
405
- override get description(): string | null {
406
- return this.$instance.description ?? this.element.description
411
+ override get description(): RichTextOrEmpty {
412
+ return RichText.memoize(this, this.$instance.description ?? this.element.$element.description)
407
413
  }
408
414
 
409
415
  override get technology(): string | null {
410
- return this.$instance.technology ?? this.element.technology
416
+ return this.$instance.technology ?? this.element.technology ?? null
411
417
  }
412
418
 
413
419
  override get links(): ReadonlyArray<Link> {
@@ -486,7 +492,7 @@ export class NestedElementOfDeployedInstanceModel<A extends Any = Any> {
486
492
  return this.element.title
487
493
  }
488
494
 
489
- get description(): string | null {
495
+ get description(): RichTextOrEmpty {
490
496
  return this.element.description
491
497
  }
492
498
 
@@ -536,17 +542,11 @@ export class DeploymentRelationModel<A extends Any = Any> implements AnyRelation
536
542
  }
537
543
 
538
544
  get technology(): string | null {
539
- if (!isTruthy(this.$relationship.technology)) {
540
- return null
541
- }
542
- return this.$relationship.technology
545
+ return this.$relationship.technology ?? null
543
546
  }
544
547
 
545
- get description(): string | null {
546
- if (!isTruthy(this.$relationship.description)) {
547
- return null
548
- }
549
- return this.$relationship.description
548
+ get description(): RichTextOrEmpty {
549
+ return RichText.memoize(this, this.$relationship.description)
550
550
  }
551
551
 
552
552
  get tags(): aux.Tags<A> {
@@ -590,6 +590,10 @@ export class DeploymentRelationModel<A extends Any = Any> implements AnyRelation
590
590
  return false
591
591
  }
592
592
 
593
+ public hasMetadata(): boolean {
594
+ return !!this.$relationship.metadata && !isEmpty(this.$relationship.metadata)
595
+ }
596
+
593
597
  public getMetadata(): aux.Metadata<A>
594
598
  public getMetadata(field: aux.MetadataKey<A>): string | undefined
595
599
  public getMetadata(field?: aux.MetadataKey<A>) {
@@ -1,4 +1,4 @@
1
- import { isTruthy, unique } from 'remeda'
1
+ import { isEmpty, isTruthy, unique } from 'remeda'
2
2
  import type { SetRequired } from 'type-fest'
3
3
  import type { Any, AnyAux, Color, IteratorLike } from '../types'
4
4
  import {
@@ -8,9 +8,11 @@ import {
8
8
  type IconUrl,
9
9
  type Link,
10
10
  type ProjectId,
11
+ type RichTextOrEmpty,
11
12
  DefaultElementShape,
12
13
  DefaultShapeSize,
13
14
  DefaultThemeColor,
15
+ RichText,
14
16
  splitGlobalFqn,
15
17
  } from '../types'
16
18
  import * as aux from '../types/aux'
@@ -89,8 +91,8 @@ export class ElementModel<A extends AnyAux = Any> implements WithTags<A>, WithMe
89
91
  return this.$element.title
90
92
  }
91
93
 
92
- get description(): string | null {
93
- return this.$element.description ?? null
94
+ get description(): RichTextOrEmpty {
95
+ return RichText.memoize(this, this.$element.description)
94
96
  }
95
97
 
96
98
  get technology(): string | null {
@@ -265,6 +267,10 @@ export class ElementModel<A extends AnyAux = Any> implements WithTags<A>, WithMe
265
267
  return this.$model.deployment.instancesOf(this)
266
268
  }
267
269
 
270
+ public hasMetadata(): boolean {
271
+ return !!this.$element.metadata && !isEmpty(this.$element.metadata)
272
+ }
273
+
268
274
  public getMetadata(): aux.Metadata<A>
269
275
  public getMetadata(field: aux.MetadataKey<A>): string | undefined
270
276
  public getMetadata(field?: aux.MetadataKey<A>) {
@@ -1,11 +1,13 @@
1
- import { isTruthy } from 'remeda'
1
+ import { isEmpty, isTruthy } from 'remeda'
2
2
  import type { AnyAux, Color, IteratorLike, Link, scalar } from '../types'
3
3
  import {
4
4
  type Relationship,
5
5
  type RelationshipLineType,
6
+ type RichTextOrEmpty,
6
7
  DefaultLineStyle,
7
8
  DefaultRelationshipColor,
8
9
  FqnRef,
10
+ RichText,
9
11
  } from '../types'
10
12
  import type * as aux from '../types/aux'
11
13
  import { commonAncestor } from '../utils/fqn'
@@ -23,16 +25,16 @@ export type RelationshipsIterator<A extends AnyAux> = IteratorLike<RelationshipM
23
25
  * use {@link isDeploymentRelationModel} guard to check if the relationship is a deployment relationship
24
26
  */
25
27
  export interface AnyRelationshipModel<A extends AnyAux> extends WithTags<A>, WithMetadata<A> {
26
- get id(): scalar.RelationId
27
- get expression(): string
28
- get title(): string | null
29
- get technology(): string | null
30
- get description(): string | null
31
- get navigateTo(): LikeC4ViewModel<A> | null
32
- get kind(): aux.RelationKind<A> | null
33
- get links(): ReadonlyArray<Link>
34
- get color(): Color
35
- get line(): RelationshipLineType
28
+ readonly id: scalar.RelationId
29
+ readonly expression: string
30
+ readonly title: string | null
31
+ readonly technology: string | null
32
+ readonly description: RichTextOrEmpty
33
+ readonly navigateTo: LikeC4ViewModel<A> | null
34
+ readonly kind: aux.RelationKind<A> | null
35
+ readonly links: ReadonlyArray<Link>
36
+ readonly color: Color
37
+ readonly line: RelationshipLineType
36
38
  isDeploymentRelation(): this is DeploymentRelationModel<A>
37
39
  isModelRelation(): this is RelationshipModel<A>
38
40
  views(): ViewsIterator<A>
@@ -80,11 +82,8 @@ export class RelationshipModel<A extends AnyAux = AnyAux> implements AnyRelation
80
82
  return this.$relationship.technology
81
83
  }
82
84
 
83
- get description(): string | null {
84
- if (!isTruthy(this.$relationship.description)) {
85
- return null
86
- }
87
- return this.$relationship.description
85
+ get description(): RichTextOrEmpty {
86
+ return RichText.memoize(this, this.$relationship.description)
88
87
  }
89
88
 
90
89
  get navigateTo(): LikeC4ViewModel<A> | null {
@@ -131,6 +130,10 @@ export class RelationshipModel<A extends AnyAux = AnyAux> implements AnyRelation
131
130
  return true
132
131
  }
133
132
 
133
+ public hasMetadata(): boolean {
134
+ return !!this.$relationship.metadata && !isEmpty(this.$relationship.metadata)
135
+ }
136
+
134
137
  public getMetadata(): aux.Metadata<A>
135
138
  public getMetadata(field: aux.MetadataKey<A>): string | undefined
136
139
  public getMetadata(field?: aux.MetadataKey<A>) {
@@ -60,3 +60,5 @@ export type {
60
60
  AuxFromLikeC4ModelData,
61
61
  SpecAux,
62
62
  } from '../types'
63
+
64
+ export { RichText, type RichTextEmpty, type RichTextOrEmpty } from '../types'
@@ -68,6 +68,7 @@ export interface WithTags<A extends AnyAux> {
68
68
  }
69
69
 
70
70
  export interface WithMetadata<A extends AnyAux> {
71
+ hasMetadata(): boolean
71
72
  getMetadata(): aux.Metadata<A>
72
73
  getMetadata(field: aux.MetadataKey<A>): string | undefined
73
74
  }
@@ -11,6 +11,7 @@ import {
11
11
  extractStep,
12
12
  isStepEdgeId,
13
13
  } from '../../types'
14
+ import { type RichTextOrEmpty, RichText } from '../../types'
14
15
  import type { DeploymentRelationModel } from '../DeploymentElementModel'
15
16
  import type { RelationshipModel } from '../RelationModel'
16
17
  import type { $View, WithTags } from '../types'
@@ -39,11 +40,11 @@ export class EdgeModel<A extends Any = Any, View extends $View<A> = $View<A>> im
39
40
  }
40
41
 
41
42
  get label(): string | null {
42
- return this.#edge.label
43
+ return this.#edge.label ?? null
43
44
  }
44
45
 
45
- get description(): string | null {
46
- return this.#edge.description ?? null
46
+ get description(): RichTextOrEmpty {
47
+ return RichText.memoize(this, this.#edge.description)
47
48
  }
48
49
 
49
50
  get technology(): string | null {
@@ -8,6 +8,7 @@ import type {
8
8
  ViewWithType,
9
9
  } from '../../types'
10
10
  import { _stage, _type } from '../../types'
11
+ import { type RichTextOrEmpty, RichText } from '../../types'
11
12
  import type * as aux from '../../types/aux'
12
13
  import { DefaultMap, ifind, nonNullable } from '../../utils'
13
14
  import type { ElementModel } from '../ElementModel'
@@ -92,6 +93,10 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
92
93
  return this.$view.title
93
94
  }
94
95
 
96
+ get description(): RichTextOrEmpty {
97
+ return RichText.memoize(this, this.$view.description)
98
+ }
99
+
95
100
  get tags(): aux.Tags<A> {
96
101
  return this.$view.tags ?? []
97
102
  }
@@ -10,9 +10,11 @@ import {
10
10
  type IconUrl,
11
11
  type IteratorLike,
12
12
  type Link,
13
+ type RichTextOrEmpty,
13
14
  type scalar,
14
15
  GroupElementKind,
15
16
  isGroupElementKind,
17
+ RichText,
16
18
  } from '../../types'
17
19
  import type * as aux from '../../types/aux'
18
20
  import { memoizeProp } from '../../utils'
@@ -46,8 +48,8 @@ export class NodeModel<A extends Any = Any, V extends $View<A> = $View<A>> imple
46
48
  return this.#node.kind as any
47
49
  }
48
50
 
49
- get description(): string | null {
50
- return this.#node.description ?? null
51
+ get description(): RichTextOrEmpty {
52
+ return RichText.memoize(this, this.#node.description)
51
53
  }
52
54
 
53
55
  get technology(): string | null {
@@ -37,12 +37,19 @@ export const defaultTheme: LikeC4Theme = {
37
37
  lg: 24, // 1.5rem = 16px + 8px
38
38
  xl: 32, // 2rem
39
39
  },
40
+ /**
41
+ * Text sizes for titles
42
+ * https://typescale.com/
43
+ *
44
+ * Scale: 1.2
45
+ * Base: 16px
46
+ */
40
47
  textSizes: {
41
- xs: 12,
42
- sm: 14,
43
- md: 19,
44
- lg: 24,
45
- xl: 32,
48
+ xs: 13.33,
49
+ sm: 16,
50
+ md: 19.2,
51
+ lg: 23.04,
52
+ xl: 27.65,
46
53
  },
47
54
  }
48
55
 
@@ -0,0 +1,200 @@
1
+ import type { MarkdownOrString } from '../types'
2
+ import { markdownToHtml, markdownToText, memoizeProp, nonexhaustive } from '../utils'
3
+
4
+ const richtxt = Symbol.for('richtxt')
5
+ const symb_text = Symbol.for('text')
6
+ const symb_html = Symbol.for('html')
7
+
8
+ const emptyTxt = ''
9
+
10
+ export interface RichTextEmpty {
11
+ readonly isEmpty: true
12
+ readonly isMarkdown: false
13
+ readonly nonEmpty: false
14
+ readonly $source: null
15
+ readonly text: null
16
+ readonly md: null
17
+ readonly html: null
18
+ equals(other: unknown): boolean
19
+ }
20
+
21
+ export interface RichTextNonEmpty {
22
+ readonly isEmpty: false
23
+ readonly nonEmpty: true
24
+ readonly isMarkdown: boolean
25
+ readonly $source: MarkdownOrString
26
+ readonly text: string
27
+ readonly md: string
28
+ readonly html: string
29
+ equals(other: unknown): boolean
30
+ }
31
+
32
+ export type RichTextOrEmpty = RichTextNonEmpty | RichTextEmpty
33
+
34
+ /**
35
+ * RichText is a class that represents a potentially markdown string.
36
+ * It can be either a plain text or a markdown.
37
+ * It is used to represent the content of a node or a link.
38
+ */
39
+ export class RichText {
40
+ private static _cache = new WeakMap<MarkdownOrString, RichTextOrEmpty>()
41
+
42
+ /**
43
+ * Creates and memoizes a RichText instance.
44
+ * @see ElementModel.description
45
+ * @example
46
+ *
47
+ * get description(): RichTextOrEmpty {
48
+ * return RichText.memoize(this, this.$element.description)
49
+ * }
50
+ */
51
+ static memoize(obj: object, source: MarkdownOrString | null | undefined): RichTextOrEmpty {
52
+ return memoizeProp(obj, richtxt, () => RichText.from(source))
53
+ }
54
+
55
+ /**
56
+ * Creates a RichText instance from a source.
57
+ */
58
+ static from(source: RichTextOrEmpty | MarkdownOrString | string | null | undefined): RichTextOrEmpty {
59
+ if (source === null || source === undefined || source === RichText.EMPTY) {
60
+ return RichText.EMPTY
61
+ }
62
+ if (source instanceof RichText) {
63
+ return source as RichTextOrEmpty
64
+ }
65
+ if (typeof source === 'string') {
66
+ return RichText.from({ txt: source })
67
+ }
68
+ if ('isEmpty' in source && source.isEmpty) {
69
+ return RichText.EMPTY
70
+ }
71
+ if ('md' in source && source.md.trim() === emptyTxt) {
72
+ return RichText.EMPTY
73
+ }
74
+ if ('txt' in source && source.txt.trim() === emptyTxt) {
75
+ return RichText.EMPTY
76
+ }
77
+ const _source = source as MarkdownOrString
78
+ const cached = RichText._cache.get(_source)
79
+ if (cached) {
80
+ return cached
81
+ }
82
+ const rt = new RichText(_source) as RichTextOrEmpty
83
+ RichText._cache.set(_source, rt)
84
+ return rt
85
+ }
86
+
87
+ /**
88
+ * This is a workaround for the fact that we need instance of RichText for `instanceof` checks
89
+ * It is invalid inheritance (returning `null` from getters), and we cast to @see RichTextEmpty
90
+ */
91
+ static EMPTY: RichTextEmpty = new class extends RichText {
92
+ public override readonly isEmpty: true = true
93
+ public override readonly nonEmpty: false = false
94
+ public override readonly isMarkdown: false = false
95
+ public override readonly $source: null = null
96
+ constructor() {
97
+ super({ txt: emptyTxt })
98
+ }
99
+
100
+ override get text(): any {
101
+ return null
102
+ }
103
+
104
+ override get md(): any {
105
+ return null
106
+ }
107
+
108
+ override get html(): any {
109
+ return null
110
+ }
111
+ }()
112
+
113
+ public readonly $source: Readonly<MarkdownOrString> | null
114
+
115
+ public readonly isEmpty: boolean
116
+ public readonly nonEmpty: boolean
117
+ public readonly isMarkdown: boolean
118
+
119
+ /**
120
+ * Private constructor to prevent direct instantiation.
121
+ * Use {@link RichText.from} or {@link RichText.memoize} instead.
122
+ */
123
+ private constructor(source: MarkdownOrString | string) {
124
+ this.isMarkdown = false
125
+ if (typeof source === 'string') {
126
+ this.$source = { txt: source }
127
+ this.isEmpty = source.trim() === emptyTxt
128
+ } else {
129
+ this.$source = source
130
+ this.isEmpty = true
131
+ if ('md' in source) {
132
+ this.isEmpty = source.md === emptyTxt
133
+ this.isMarkdown = true
134
+ } else {
135
+ this.isEmpty = source.txt === emptyTxt
136
+ }
137
+ }
138
+ this.nonEmpty = !this.isEmpty
139
+ }
140
+
141
+ /**
142
+ * Returns the text content of the rich text.
143
+ * If the source is a string, it returns the string.
144
+ * If the source is a markdown, it returns the markdown.
145
+ */
146
+ get text(): string {
147
+ if (this.isEmpty || this.$source === null) {
148
+ return emptyTxt
149
+ }
150
+ const source = this.$source
151
+ if ('txt' in source) {
152
+ return source.txt
153
+ }
154
+ return memoizeProp(this, symb_text, () => markdownToText(source.md!))
155
+ }
156
+
157
+ /**
158
+ * Returns the markdown content of the rich text.
159
+ * If the source is a string, it returns the string.
160
+ * If the source is a markdown, it returns the markdown.
161
+ */
162
+ get md(): string {
163
+ if (this.isEmpty || this.$source === null) {
164
+ return emptyTxt
165
+ }
166
+ const source = this.$source
167
+ if ('md' in source) {
168
+ return source.md
169
+ }
170
+ if ('txt' in source) {
171
+ return source.txt
172
+ }
173
+ nonexhaustive(source)
174
+ }
175
+
176
+ /**
177
+ * Returns the html content of the rich text.
178
+ * If the source is a string, it returns the string.
179
+ * If the source is a markdown, it returns the HTML.
180
+ */
181
+ get html(): string {
182
+ if (this.isEmpty || this.$source === null) {
183
+ return emptyTxt
184
+ }
185
+ const markdown = this.$source.md ?? this.$source.txt
186
+ return memoizeProp(this, symb_html, () => markdownToHtml(markdown))
187
+ }
188
+
189
+ equals(other: unknown): boolean {
190
+ if (this === other) return true
191
+ if (!(other instanceof RichText)) return false
192
+ if (this.isEmpty && other.isEmpty) return true
193
+ if (this.isEmpty !== other.isEmpty || this.isMarkdown !== other.isMarkdown) return false
194
+
195
+ if (this.isMarkdown) {
196
+ return this.$source?.md === other.$source?.md
197
+ }
198
+ return this.$source?.txt === other.$source?.txt
199
+ }
200
+ }
@@ -37,3 +37,5 @@ export * from './view-parsed.dynamic'
37
37
  export * from './view-parsed.element'
38
38
 
39
39
  export type * as scalar from './scalar'
40
+
41
+ export { RichText, type RichTextEmpty, type RichTextOrEmpty } from './RichText'