@likec4/core 1.2.0 → 1.2.2
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/dist/colors/element.d.ts +1 -0
- package/dist/colors/index.d.ts +1 -0
- package/dist/colors/relationships.d.ts +1 -0
- package/dist/errors/errors.spec.d.ts +2 -0
- package/dist/errors/errors.spec.js +69 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +7 -1
- package/dist/index.d.ts +1 -0
- package/dist/types/_common.d.ts +1 -0
- package/dist/types/element.d.ts +1 -0
- package/dist/types/expression.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -1
- package/dist/types/model.d.ts +1 -0
- package/dist/types/opaque.d.ts +1 -0
- package/dist/types/relation.d.ts +1 -0
- package/dist/types/theme.d.ts +1 -0
- package/dist/types/view.d.ts +1 -0
- package/dist/utils/fqn.d.ts +1 -0
- package/dist/utils/fqn.spec.d.ts +2 -0
- package/dist/utils/fqn.spec.js +82 -0
- package/dist/utils/guards.d.ts +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/promises.d.ts +1 -0
- package/dist/utils/relations.d.ts +1 -0
- package/dist/utils/relations.spec.d.ts +2 -0
- package/dist/utils/relations.spec.js +210 -0
- package/package.json +6 -6
- package/src/colors/element.ts +80 -0
- package/src/colors/index.ts +12 -0
- package/src/colors/relationships.ts +54 -0
- package/src/errors/errors.spec.ts +88 -0
- package/src/errors/index.ts +120 -0
- package/src/index.ts +9 -0
- package/src/reset.d.ts +2 -0
- package/src/types/_common.ts +5 -0
- package/src/types/element.ts +56 -0
- package/src/types/expression.ts +140 -0
- package/src/types/index.ts +10 -0
- package/src/types/model.ts +15 -0
- package/src/types/opaque.ts +108 -0
- package/src/types/relation.ts +38 -0
- package/src/types/theme.ts +46 -0
- package/src/types/view.ts +263 -0
- package/src/utils/fqn.spec.ts +105 -0
- package/src/utils/fqn.ts +112 -0
- package/src/utils/guards.ts +10 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/promises.ts +9 -0
- package/src/utils/relations.spec.ts +268 -0
- 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
|
+
})
|
package/src/utils/fqn.ts
ADDED
|
@@ -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
|
+
}
|