@likec4/core 1.2.0 → 1.2.1

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 (51) hide show
  1. package/dist/colors/element.d.ts +1 -0
  2. package/dist/colors/index.d.ts +1 -0
  3. package/dist/colors/relationships.d.ts +1 -0
  4. package/dist/errors/errors.spec.d.ts +2 -0
  5. package/dist/errors/errors.spec.js +69 -0
  6. package/dist/errors/index.d.ts +1 -0
  7. package/dist/errors/index.js +7 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/types/_common.d.ts +1 -0
  10. package/dist/types/element.d.ts +1 -0
  11. package/dist/types/expression.d.ts +1 -0
  12. package/dist/types/index.d.ts +1 -0
  13. package/dist/types/index.js +2 -1
  14. package/dist/types/model.d.ts +1 -0
  15. package/dist/types/opaque.d.ts +1 -0
  16. package/dist/types/relation.d.ts +1 -0
  17. package/dist/types/theme.d.ts +1 -0
  18. package/dist/types/view.d.ts +1 -0
  19. package/dist/utils/fqn.d.ts +1 -0
  20. package/dist/utils/fqn.spec.d.ts +2 -0
  21. package/dist/utils/fqn.spec.js +82 -0
  22. package/dist/utils/guards.d.ts +1 -0
  23. package/dist/utils/index.d.ts +1 -0
  24. package/dist/utils/promises.d.ts +1 -0
  25. package/dist/utils/relations.d.ts +1 -0
  26. package/dist/utils/relations.spec.d.ts +2 -0
  27. package/dist/utils/relations.spec.js +210 -0
  28. package/package.json +6 -6
  29. package/src/colors/element.ts +80 -0
  30. package/src/colors/index.ts +12 -0
  31. package/src/colors/relationships.ts +54 -0
  32. package/src/errors/errors.spec.ts +88 -0
  33. package/src/errors/index.ts +120 -0
  34. package/src/index.ts +9 -0
  35. package/src/reset.d.ts +2 -0
  36. package/src/types/_common.ts +5 -0
  37. package/src/types/element.ts +56 -0
  38. package/src/types/expression.ts +140 -0
  39. package/src/types/index.ts +10 -0
  40. package/src/types/model.ts +15 -0
  41. package/src/types/opaque.ts +108 -0
  42. package/src/types/relation.ts +38 -0
  43. package/src/types/theme.ts +46 -0
  44. package/src/types/view.ts +263 -0
  45. package/src/utils/fqn.spec.ts +105 -0
  46. package/src/utils/fqn.ts +112 -0
  47. package/src/utils/guards.ts +10 -0
  48. package/src/utils/index.ts +4 -0
  49. package/src/utils/promises.ts +9 -0
  50. package/src/utils/relations.spec.ts +268 -0
  51. package/src/utils/relations.ts +104 -0
@@ -0,0 +1,46 @@
1
+ export type ThemeColor =
2
+ | 'amber'
3
+ | 'blue'
4
+ | 'gray'
5
+ | 'slate'
6
+ | 'green'
7
+ | 'indigo'
8
+ | 'muted'
9
+ | 'primary'
10
+ | 'red'
11
+ | 'secondary'
12
+ | 'sky'
13
+
14
+ export type HexColorLiteral = `#${string}`
15
+
16
+ export type ColorLiteral = HexColorLiteral
17
+
18
+ export interface ElementThemeColorValues {
19
+ fill: ColorLiteral
20
+ stroke: ColorLiteral
21
+ // Main text (title, etc.)
22
+ hiContrast: ColorLiteral
23
+ // Secondary text (description, etc.)
24
+ loContrast: ColorLiteral
25
+ }
26
+
27
+ export type ElementThemeColors = {
28
+ [key in ThemeColor]: ElementThemeColorValues
29
+ }
30
+
31
+ export interface RelationshipThemeColorValues {
32
+ lineColor: ColorLiteral
33
+ labelBgColor: ColorLiteral
34
+ labelColor: ColorLiteral
35
+ }
36
+
37
+ export type RelationshipThemeColors = {
38
+ [key in ThemeColor]: RelationshipThemeColorValues
39
+ }
40
+
41
+ export interface LikeC4Theme {
42
+ font: 'Helvetica' // for now only support Helvetica
43
+ shadow: ColorLiteral
44
+ relationships: RelationshipThemeColors
45
+ elements: ElementThemeColors
46
+ }
@@ -0,0 +1,263 @@
1
+ import { isNullish } from 'remeda'
2
+ import type { IconUrl, NonEmptyArray } from './_common'
3
+ import type { ElementKind, ElementShape, ElementStyle, Fqn, Tag } from './element'
4
+ import type { CustomElementExpr, ElementExpression, Expression } from './expression'
5
+ import type { Opaque } from './opaque'
6
+ import type { RelationID, RelationshipArrowType, RelationshipLineType } from './relation'
7
+ import type { ColorLiteral, ThemeColor } from './theme'
8
+
9
+ // Full-qualified-name
10
+ export type ViewID = Opaque<string, 'ViewID'>
11
+
12
+ export type ViewRuleExpression =
13
+ | {
14
+ include: Expression[]
15
+ exclude?: never
16
+ }
17
+ | {
18
+ include?: never
19
+ exclude: Expression[]
20
+ }
21
+ export function isViewRuleExpression(rule: ViewRule): rule is ViewRuleExpression {
22
+ return (
23
+ ('include' in rule && Array.isArray(rule.include))
24
+ || ('exclude' in rule && Array.isArray(rule.exclude))
25
+ )
26
+ }
27
+
28
+ export interface ViewRuleStyle {
29
+ targets: ElementExpression[]
30
+ style: ElementStyle & {
31
+ color?: ThemeColor
32
+ shape?: ElementShape
33
+ icon?: IconUrl
34
+ }
35
+ }
36
+ export function isViewRuleStyle(rule: ViewRule): rule is ViewRuleStyle {
37
+ return 'style' in rule && 'targets' in rule
38
+ }
39
+
40
+ export type AutoLayoutDirection = 'TB' | 'BT' | 'LR' | 'RL'
41
+ export interface ViewRuleAutoLayout {
42
+ autoLayout: AutoLayoutDirection
43
+ }
44
+ export function isViewRuleAutoLayout(rule: ViewRule): rule is ViewRuleAutoLayout {
45
+ return 'autoLayout' in rule
46
+ }
47
+
48
+ export type ViewRule = ViewRuleExpression | ViewRuleStyle | ViewRuleAutoLayout
49
+
50
+ export interface BasicView<ViewType extends 'element' | 'dynamic' = 'element' | 'dynamic'> {
51
+ readonly __?: ViewType
52
+ readonly id: ViewID
53
+ readonly title: string | null
54
+ readonly description: string | null
55
+ readonly tags: NonEmptyArray<Tag> | null
56
+ readonly links: NonEmptyArray<string> | null
57
+
58
+ /**
59
+ * URI to the source file of this view.
60
+ * Undefined if the view is auto-generated.
61
+ */
62
+ readonly docUri?: string
63
+ /**
64
+ * For all views we find common ancestor path.
65
+ * This is used to generate relative paths, i.e.:
66
+ * - "" for views in the common ancestor directory (or root)
67
+ * - "subdir" for views in "<root>/subdir"
68
+ * - "subdir/subdir1" for views in "<root>/subdir/subdir1"
69
+ *
70
+ * Undefined if the view is auto-generated.
71
+ */
72
+ readonly relativePath?: string
73
+ }
74
+
75
+ export interface BasicElementView extends BasicView<'element'> {
76
+ readonly viewOf?: Fqn
77
+ readonly rules: ViewRule[]
78
+ }
79
+ export interface StrictElementView extends BasicElementView {
80
+ readonly viewOf: Fqn
81
+ }
82
+
83
+ export interface ExtendsElementView extends BasicElementView {
84
+ readonly extends: ViewID
85
+ }
86
+ export type ElementView = StrictElementView | ExtendsElementView | BasicElementView
87
+
88
+ export interface DynamicViewStep {
89
+ readonly source: Fqn
90
+ readonly target: Fqn
91
+ readonly title: string | null
92
+ readonly isBackward?: boolean
93
+ }
94
+
95
+ export type DynamicViewIncludeRule = {
96
+ include: (ElementExpression | CustomElementExpr)[]
97
+ }
98
+
99
+ export function isDynamicViewIncludeRule(rule: DynamicViewRule): rule is DynamicViewIncludeRule {
100
+ return 'include' in rule && Array.isArray(rule.include)
101
+ }
102
+
103
+ export type DynamicViewRule = DynamicViewIncludeRule | ViewRuleStyle | ViewRuleAutoLayout
104
+ export interface DynamicView extends BasicView<'dynamic'> {
105
+ readonly __: 'dynamic'
106
+
107
+ readonly steps: DynamicViewStep[]
108
+
109
+ readonly rules: DynamicViewRule[]
110
+ }
111
+
112
+ export type View = ElementView | DynamicView
113
+
114
+ export function isDynamicView(view: View): view is DynamicView {
115
+ return view.__ === 'dynamic'
116
+ }
117
+ export function isElementView(view: View): view is ElementView {
118
+ return isNullish(view.__) || view.__ === 'element'
119
+ }
120
+
121
+ export function isExtendsElementView(view: View): view is ExtendsElementView {
122
+ return isElementView(view) && 'extends' in view
123
+ }
124
+
125
+ export function isStrictElementView(view: View): view is StrictElementView {
126
+ return isElementView(view) && 'viewOf' in view
127
+ }
128
+
129
+ export type NodeId = Fqn
130
+
131
+ export type EdgeId = Opaque<string, 'EdgeId'>
132
+
133
+ export type StepEdgeIdLiteral = `step-${number}`
134
+ export type StepEdgeId = Opaque<StepEdgeIdLiteral, 'EdgeId'>
135
+ export function StepEdgeId(step: number): StepEdgeId {
136
+ return `step-${String(step).padStart(3, '0')}` as StepEdgeId
137
+ }
138
+
139
+ export function isStepEdgeId(id: string): id is StepEdgeId {
140
+ return id.startsWith('step-')
141
+ }
142
+ export function extractStep(id: EdgeId): number {
143
+ if (!isStepEdgeId(id)) {
144
+ throw new Error(`Invalid step edge id: ${id}`)
145
+ }
146
+ return Number(id.slice('step-'.length))
147
+ }
148
+
149
+ export interface ComputedNode {
150
+ id: NodeId
151
+ kind: ElementKind
152
+ parent: NodeId | null
153
+ title: string
154
+ description: string | null
155
+ technology: string | null
156
+ tags: NonEmptyArray<Tag> | null
157
+ links: NonEmptyArray<string> | null
158
+ children: NodeId[]
159
+ inEdges: EdgeId[]
160
+ outEdges: EdgeId[]
161
+ shape: ElementShape
162
+ /**
163
+ * @deprecated Use `style` instead
164
+ */
165
+ color: ThemeColor
166
+ /**
167
+ * @deprecated Use `style` instead
168
+ */
169
+ icon?: IconUrl
170
+ style: ElementStyle
171
+ navigateTo?: ViewID
172
+ level: number
173
+ // For compound nodes, the max depth of nested nodes
174
+ depth?: number
175
+ }
176
+
177
+ export interface ComputedEdge {
178
+ id: EdgeId
179
+ parent: NodeId | null
180
+ source: NodeId
181
+ target: NodeId
182
+ label: string | null
183
+ relations: RelationID[]
184
+ color?: ThemeColor
185
+ line?: RelationshipLineType
186
+ head?: RelationshipArrowType
187
+ tail?: RelationshipArrowType
188
+
189
+ /**
190
+ * For layouting purposes
191
+ * @default 'forward'
192
+ */
193
+ dir?: 'forward' | 'back'
194
+ }
195
+
196
+ export interface ComputedElementView extends Omit<ElementView, 'rules'> {
197
+ readonly extends?: ViewID
198
+ readonly autoLayout: ViewRuleAutoLayout['autoLayout']
199
+ readonly nodes: ComputedNode[]
200
+ readonly edges: ComputedEdge[]
201
+ }
202
+ export interface ComputedDynamicView extends Omit<DynamicView, 'rules' | 'steps'> {
203
+ readonly autoLayout: ViewRuleAutoLayout['autoLayout']
204
+ readonly nodes: ComputedNode[]
205
+ readonly edges: ComputedEdge[]
206
+ }
207
+ export function isComputedDynamicView(view: ComputedView): view is ComputedDynamicView {
208
+ return view.__ === 'dynamic'
209
+ }
210
+
211
+ export type ComputedView = ComputedElementView | ComputedDynamicView
212
+
213
+ export function isComputedElementView(view: ComputedView): view is ComputedElementView {
214
+ return isNullish(view.__) || view.__ === 'element'
215
+ }
216
+
217
+ export type Point = readonly [x: number, y: number]
218
+
219
+ // Bounding box
220
+ export type BBox = {
221
+ x: number
222
+ y: number
223
+ width: number
224
+ height: number
225
+ }
226
+
227
+ export interface DiagramLabel {
228
+ align: 'left' | 'right' | 'center'
229
+ fontStyle?: 'bold' | 'normal'
230
+ color?: ColorLiteral
231
+ fontSize: number
232
+ pt: Point
233
+ width: number
234
+ text: string
235
+ }
236
+
237
+ export interface DiagramNode extends ComputedNode {
238
+ width: number
239
+ height: number
240
+ position: Point // Absolute position, top left
241
+ // relative: Point // Top left, relative to parent
242
+ labels: DiagramLabel[]
243
+ }
244
+
245
+ export interface DiagramEdge extends ComputedEdge {
246
+ points: NonEmptyArray<Point>
247
+ // Polygons are used to draw arrows
248
+ headArrow?: NonEmptyArray<Point>
249
+ // Draw arrow from the last point of the edge to this point
250
+ headArrowPoint?: Point
251
+ tailArrow?: NonEmptyArray<Point>
252
+ // Draw arrow from the first point of the edge to this point
253
+ tailArrowPoint?: Point
254
+ labels?: NonEmptyArray<DiagramLabel>
255
+ labelBBox?: BBox
256
+ }
257
+
258
+ export interface DiagramView extends Omit<ComputedView, 'nodes' | 'edges'> {
259
+ readonly nodes: DiagramNode[]
260
+ readonly edges: DiagramEdge[]
261
+ readonly width: number
262
+ readonly height: number
263
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Element, Fqn } from '../types'
3
+ import {
4
+ ancestorsFqn,
5
+ commonAncestor,
6
+ compareFqnHierarchically,
7
+ isAncestor,
8
+ isDescendantOf,
9
+ notDescendantOf,
10
+ parentFqn
11
+ } from './fqn'
12
+
13
+ const el = (id: string): Element => ({ id }) as unknown as Element
14
+
15
+ describe('parentFqn', () => {
16
+ it('should return null if no parent', () => {
17
+ expect(parentFqn('a' as Fqn)).toBeNull()
18
+ })
19
+ it('should return parent', () => {
20
+ expect(parentFqn('a.b' as Fqn)).toBe('a')
21
+ expect(parentFqn('a.b.c' as Fqn)).toBe('a.b')
22
+ })
23
+ })
24
+
25
+ describe('ancestorsFqn', () => {
26
+ it('should return empty array if no parent', () => {
27
+ expect(ancestorsFqn('a' as Fqn)).toEqual([])
28
+ })
29
+ it('should return ancestors', () => {
30
+ expect(ancestorsFqn('a.b.c.d.e' as Fqn)).toEqual(['a.b.c.d', 'a.b.c', 'a.b', 'a'])
31
+ })
32
+ })
33
+
34
+ describe('commonAncestor', () => {
35
+ it('should return null if no common ancestor', () => {
36
+ expect(commonAncestor('a' as Fqn, 'b' as Fqn)).toBeNull()
37
+ expect(commonAncestor('a.b' as Fqn, 'c.d' as Fqn)).toBeNull()
38
+ })
39
+
40
+ it('should return common ancestor', () => {
41
+ expect(commonAncestor('a.b' as Fqn, 'a.c' as Fqn)).toBe('a')
42
+ expect(commonAncestor('a.b.c' as Fqn, 'a.b.e' as Fqn)).toBe('a.b')
43
+ expect(commonAncestor('a.b.c.d.e' as Fqn, 'a.b.c.d' as Fqn)).toBe('a.b.c')
44
+ })
45
+ })
46
+
47
+ describe('isAncestor', () => {
48
+ it('should return true if ancestor', () => {
49
+ expect(isAncestor('a' as Fqn, 'a.b' as Fqn)).toBe(true)
50
+ expect(isAncestor('a.b' as Fqn, 'a.b.c' as Fqn)).toBe(true)
51
+ })
52
+ it('should return false if not ancestor', () => {
53
+ expect(isAncestor('a' as Fqn, 'b' as Fqn)).toBe(false)
54
+ expect(isAncestor('a.b' as Fqn, 'a' as Fqn)).toBe(false)
55
+ expect(isAncestor('a.b' as Fqn, 'b.a' as Fqn)).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('isDescendantOf', () => {
60
+ const predicate = isDescendantOf(['a', 'b', 'a.b', 'a.b.c'].map(el))
61
+
62
+ it('should return true if isDescendantOf', () => {
63
+ expect(predicate(el('a'))).toBe(true)
64
+ expect(predicate(el('b.c'))).toBe(true)
65
+ expect(predicate(el('a.b.c.d.e'))).toBe(true)
66
+ })
67
+ it('should return false if not descendantOf', () => {
68
+ expect(predicate(el('c'))).toBe(false)
69
+ expect(predicate(el('ac'))).toBe(false)
70
+ expect(predicate(el('d.a.c'))).toBe(false)
71
+ })
72
+ })
73
+
74
+ describe('notDescendantOf', () => {
75
+ const predicate = notDescendantOf(['a', 'b', 'a.b', 'a.b.c'].map(el))
76
+
77
+ it('should return true if notDescendantOf', () => {
78
+ expect(predicate(el('c'))).toBe(true)
79
+ expect(predicate(el('ac'))).toBe(true)
80
+ expect(predicate(el('d.a.c'))).toBe(true)
81
+ })
82
+ it('should return false if descendantOf', () => {
83
+ expect(predicate(el('a'))).toBe(false)
84
+ expect(predicate(el('b.c'))).toBe(false)
85
+ expect(predicate(el('a.b.c.d.e'))).toBe(false)
86
+ })
87
+ })
88
+
89
+ describe('compareFqnHierarchically', () => {
90
+ it('should compare hierarchically', () => {
91
+ expect(['a', 'b', 'a.b', 'a.b.c', 'a.c.c'].sort(compareFqnHierarchically)).toEqual([
92
+ 'a',
93
+ 'b',
94
+ 'a.b',
95
+ 'a.b.c',
96
+ 'a.c.c'
97
+ ])
98
+ })
99
+
100
+ it('should preserve initial order', () => {
101
+ expect(
102
+ ['aaa', 'aa', 'a', 'aaa.c', 'aa.b', 'a.c', 'a.b'].sort(compareFqnHierarchically)
103
+ ).toEqual(['aaa', 'aa', 'a', 'aaa.c', 'aa.b', 'a.c', 'a.b'])
104
+ })
105
+ })
@@ -0,0 +1,112 @@
1
+ import type { Element, Fqn } from '../types'
2
+ import { isString } from './guards'
3
+
4
+ export function nameFromFqn(fqn: Fqn) {
5
+ const lastDot = fqn.lastIndexOf('.')
6
+ if (lastDot > 0) {
7
+ return fqn.slice(lastDot + 1)
8
+ } else {
9
+ return fqn
10
+ }
11
+ }
12
+
13
+ export function isAncestor<E extends { id: Fqn }>(
14
+ ...args: [ancestor: string, another: string] | [ancestor: E, another: E]
15
+ ) {
16
+ const ancestor = isString(args[0]) ? args[0] : args[0].id
17
+ const another = isString(args[1]) ? args[1] : args[1].id
18
+ return another.startsWith(ancestor + '.')
19
+ }
20
+
21
+ export function isSameHierarchy<E extends { id: Fqn }>(one: E | Fqn, another: E | Fqn) {
22
+ const first = isString(one) ? one : one.id
23
+ const second = isString(another) ? another : another.id
24
+ return first === second || second.startsWith(first + '.') || first.startsWith(second + '.')
25
+ }
26
+
27
+ export function isDescendantOf<E extends { id: Fqn }>(ancestors: E[]): (e: E) => boolean {
28
+ const predicates = ancestors.flatMap(a => [(e: E) => e.id === a.id, (e: E) => isAncestor(a, e)])
29
+ return (e: E) => predicates.some(p => p(e))
30
+ }
31
+
32
+ export function notDescendantOf(ancestors: Element[]): (e: Element) => boolean {
33
+ const isDescendant = isDescendantOf(ancestors)
34
+ return (e: Element) => !isDescendant(e)
35
+ }
36
+
37
+ export function commonAncestor(first: Fqn, second: Fqn) {
38
+ const parentA = parentFqn(first)
39
+ const parentB = parentFqn(second)
40
+ if (parentA === parentB) {
41
+ return parentA
42
+ }
43
+ if (!parentA || !parentB) {
44
+ return null
45
+ }
46
+
47
+ const a = first.split('.')
48
+ const b = second.split('.')
49
+ let ancestor: Fqn | null = null
50
+
51
+ while (a.length > 1 && b.length > 1 && !!a[0] && a[0] === b[0]) {
52
+ ancestor = (ancestor ? `${ancestor}.${a[0]}` : a[0]) as Fqn
53
+ a.shift()
54
+ b.shift()
55
+ }
56
+ return ancestor
57
+ }
58
+
59
+ export function parentFqn(fqn: Fqn): Fqn | null {
60
+ const lastDot = fqn.lastIndexOf('.')
61
+ if (lastDot > 0) {
62
+ return fqn.substring(0, lastDot) as Fqn
63
+ }
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * Get all ancestor elements (i.e. parent, parent’s parent, etc.)
69
+ * (from closest to root)
70
+ */
71
+ export function ancestorsFqn(fqn: Fqn): Fqn[] {
72
+ const path = fqn.split('.')
73
+ path.pop()
74
+ if (path.length === 0) {
75
+ return []
76
+ }
77
+ return path.reduce((acc, _, idx) => {
78
+ const ancestor = path.slice(0, idx + 1).join('.')
79
+ acc.unshift(ancestor as Fqn)
80
+ return acc
81
+ }, [] as Fqn[])
82
+ }
83
+
84
+ /**
85
+ * Compares two fully qualified names (fqns) hierarchically based on their depth.
86
+ * From parent nodes to leaves
87
+ *
88
+ * @param {string} a - The first fqn to compare.
89
+ * @param {string} b - The second fqn to compare.
90
+ * @returns {number} - 0 if the fqns have the same depth.
91
+ * - Positive number if a is deeper than b.
92
+ * - Negative number if b is deeper than a.
93
+ */
94
+ export function compareFqnHierarchically<T extends string = string>(a: T, b: T): number {
95
+ const depthA = a.split('.').length
96
+ const depthB = b.split('.').length
97
+ switch (true) {
98
+ case depthA > depthB: {
99
+ return 1
100
+ }
101
+ case depthA < depthB: {
102
+ return -1
103
+ }
104
+ default: {
105
+ return 0
106
+ }
107
+ }
108
+ }
109
+
110
+ export function compareByFqnHierarchically<T extends { id: string }>(a: T, b: T) {
111
+ return compareFqnHierarchically(a.id, b.id)
112
+ }
@@ -0,0 +1,10 @@
1
+ import type { NonEmptyArray } from '../types'
2
+ export { hasAtLeast } from 'remeda'
3
+
4
+ export function isString(value: unknown): value is string {
5
+ return value != null && typeof value === 'string'
6
+ }
7
+
8
+ export function isNonEmptyArray<A>(arr: ArrayLike<A> | undefined): arr is NonEmptyArray<A> {
9
+ return !!arr && Array.isArray(arr) && arr.length > 0
10
+ }
@@ -0,0 +1,4 @@
1
+ export * from './fqn'
2
+ export * from './guards'
3
+ export * from './promises'
4
+ export * from './relations'
@@ -0,0 +1,9 @@
1
+ const DELAY = 'LIKEC4_DELAY'
2
+
3
+ export function delay(ms: number = 100) {
4
+ return new Promise(resolve => {
5
+ setTimeout(() => {
6
+ resolve(DELAY)
7
+ }, ms)
8
+ })
9
+ }