@likec4/core 1.34.1 → 1.35.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 (56) hide show
  1. package/dist/builder/index.d.mts +2 -2
  2. package/dist/builder/index.mjs +11 -10
  3. package/dist/compute-view/index.d.mts +2 -2
  4. package/dist/compute-view/index.mjs +12 -11
  5. package/dist/compute-view/relationships-view/index.d.mts +2 -2
  6. package/dist/compute-view/relationships-view/index.mjs +2 -2
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.mjs +9 -8
  9. package/dist/model/connection/deployment/index.d.mts +4 -4
  10. package/dist/model/connection/deployment/index.mjs +3 -3
  11. package/dist/model/connection/index.d.mts +5 -5
  12. package/dist/model/connection/index.mjs +4 -4
  13. package/dist/model/connection/model/index.d.mts +4 -4
  14. package/dist/model/connection/model/index.mjs +3 -3
  15. package/dist/model/index.d.mts +31 -7
  16. package/dist/model/index.mjs +9 -9
  17. package/dist/shared/{core.D3xEWj5B.d.mts → core.72IoI17N.d.mts} +1 -1
  18. package/dist/shared/{core.DXRAwwDB.mjs → core.B1ZN723l.mjs} +1 -1
  19. package/dist/shared/core.BpJu1RrF.mjs +5 -0
  20. package/dist/shared/{core.C80Z37Eu.mjs → core.C1GV0WWV.mjs} +1 -1
  21. package/dist/shared/{core.D5qVzkws.mjs → core.CQS11dNK.mjs} +2 -2
  22. package/dist/shared/{core.C_C-3c1S.mjs → core.DQj9iv_y.mjs} +3 -5
  23. package/dist/shared/{core.IWLie-Ma.d.mts → core.Ddua-wiI.d.mts} +3 -0
  24. package/dist/shared/{core.CJSFKrYf.d.mts → core.DhTO8rgm.d.mts} +2 -2
  25. package/dist/shared/{core.CtKXPqN_.mjs → core.HUONfQfd.mjs} +1 -1
  26. package/dist/shared/{core.Di7__sMg.d.mts → core.NBNeVyag.d.mts} +2 -2
  27. package/dist/shared/{core.PqNe5WxQ.mjs → core.RnUJywKk.mjs} +94 -92
  28. package/dist/shared/{core.DrS4Bf4y.d.mts → core.SciGxb--.d.mts} +183 -8
  29. package/dist/shared/{core.BTnf14gD.mjs → core.Y3j3s-oq.mjs} +2 -2
  30. package/dist/shared/{core.XptQjhMA.mjs → core.b2BzqGz0.mjs} +2 -2
  31. package/dist/shared/{core.4SpVCM9m.mjs → core.rpF8esVq.mjs} +354 -28
  32. package/dist/shared/{core.BMCb0wJI.mjs → core.yaRvRnMW.mjs} +1 -1
  33. package/dist/theme/index.mjs +33 -3
  34. package/dist/types/index.d.mts +2 -2
  35. package/dist/types/index.mjs +2 -2
  36. package/dist/utils/index.d.mts +6 -8
  37. package/dist/utils/index.mjs +5 -5
  38. package/dist/utils/iterable/index.d.mts +18 -1
  39. package/dist/utils/iterable/index.mjs +22 -2
  40. package/package.json +6 -5
  41. package/src/compute-view/element-view/predicates/relation-direct.ts +8 -2
  42. package/src/model/DeploymentModel.ts +1 -1
  43. package/src/model/LikeC4Model.ts +146 -7
  44. package/src/model/index.ts +8 -0
  45. package/src/model/types.ts +1 -8
  46. package/src/model/utils.ts +66 -0
  47. package/src/model/view/LikeC4ViewModel.ts +62 -7
  48. package/src/model/view/LikeC4ViewsFolder.ts +151 -0
  49. package/src/theme/__test__/theme-index.ts +29 -0
  50. package/src/theme/index.ts +49 -3
  51. package/src/types/model-spec.ts +4 -0
  52. package/src/utils/compare-natural.ts +14 -12
  53. package/src/utils/index.ts +1 -0
  54. package/src/utils/iterable/first.ts +48 -0
  55. package/src/utils/iterable/index.ts +1 -0
  56. package/dist/shared/{core.Br7Fab9l.mjs → core.BajmN8zR.mjs} +6 -6
@@ -2,7 +2,7 @@ export { D as DefaultMap } from '../shared/core.CywrQs86.mjs';
2
2
  export { I as IterableContainer, R as ReorderedArray, a as ancestorsFqn, c as commonAncestor, b as compareByFqnHierarchically, d as compareFqnHierarchically, h as hierarchyDistance, e as hierarchyLevel, i as isAncestor, f as isDescendantOf, g as isSameHierarchy, n as nameFromFqn, p as parentFqn, s as sortByFqnHierarchically, j as sortNaturalByFqn, k as sortParentsFirst } from '../shared/core.CF82MWyh.mjs';
3
3
  import { N as NonEmptyArray } from '../shared/core.DuSLYTG6.mjs';
4
4
  import { Tagged, Simplify, Or, IfNever, IsLiteral, And, GreaterThan, IsNever, IsEqual, ReadonlyTuple, IsNumericLiteral } from 'type-fest';
5
- export { ifilter, ifind, iflat, ihead, imap, ireduce, isIterable, isome, iunique, toArray, toSet } from './iterable/index.mjs';
5
+ export { ifilter, ifind, ifirst, iflat, ihead, imap, ireduce, isIterable, isome, iunique, toArray, toSet } from './iterable/index.mjs';
6
6
  import '../shared/core.668kXpZq.mjs';
7
7
 
8
8
  /**
@@ -231,22 +231,20 @@ declare function commonHead<T>(sources: ReadonlyArray<T>, targets: ReadonlyArray
231
231
 
232
232
  declare function compareNatural(a: string | undefined, b: string | undefined): -1 | 0 | 1;
233
233
  /**
234
- * Compares two strings hierarchically based on their depth.
234
+ * Compares two strings lexicographically first, then hierarchically based on their depth.\
235
235
  * From parent nodes to leaves
236
236
  *
237
237
  * @example
238
238
  * const lines = [
239
+ * 'b.c',
240
+ * 'b',
239
241
  * 'a.b.c',
240
- * 'a',
241
- * 'a.b',
242
- * 'a.c.c',
243
242
  * ]
244
243
  * lines.sort(compareNaturalHierarchically('.'))
245
244
  * // [
246
- * // 'a',
247
- * // 'a.b',
248
245
  * // 'a.b.c',
249
- * // 'a.c.c',
246
+ * // 'b',
247
+ * // 'b.c',
250
248
  * // ]
251
249
  */
252
250
  declare function compareNaturalHierarchically(separator?: string): (a: string | undefined, b: string | undefined) => number;
@@ -1,16 +1,16 @@
1
1
  export { i as invariant, a as nonNullable, n as nonexhaustive } from '../shared/core.D4npX2q8.mjs';
2
- export { S as Stack, c as commonHead, o as objectHash } from '../shared/core.CtKXPqN_.mjs';
3
- export { b as ancestorsFqn, d as commonAncestor, e as compareByFqnHierarchically, f as compareFqnHierarchically, c as compareNatural, a as compareNaturalHierarchically, h as hierarchyDistance, g as hierarchyLevel, i as isAncestor, j as isDescendantOf, o as isNonEmptyArray, k as isSameHierarchy, q as isString, n as nameFromFqn, p as parentFqn, s as sortByFqnHierarchically, l as sortNaturalByFqn, m as sortParentsFirst } from '../shared/core.C_C-3c1S.mjs';
2
+ export { S as Stack, c as commonHead, o as objectHash } from '../shared/core.HUONfQfd.mjs';
3
+ export { b as ancestorsFqn, d as commonAncestor, e as compareByFqnHierarchically, f as compareFqnHierarchically, c as compareNatural, a as compareNaturalHierarchically, h as hierarchyDistance, g as hierarchyLevel, i as isAncestor, j as isDescendantOf, o as isNonEmptyArray, k as isSameHierarchy, q as isString, n as nameFromFqn, p as parentFqn, s as sortByFqnHierarchically, l as sortNaturalByFqn, m as sortParentsFirst } from '../shared/core.DQj9iv_y.mjs';
4
4
  export { g as getOrCreate } from '../shared/core.DcS-0zys.mjs';
5
5
  export { a as markdownToHtml, b as markdownToText, m as memoizeProp } from '../shared/core.B0oEIXqd.mjs';
6
- export { b as BiMap, d as DefaultWeakMap, l as LinkedList, m as MultiMap, q as Queue, c as compareRelations, a as delay, p as promiseNextTick } from '../shared/core.XptQjhMA.mjs';
6
+ export { b as BiMap, d as DefaultWeakMap, l as LinkedList, m as MultiMap, q as Queue, c as compareRelations, a as delay, p as promiseNextTick } from '../shared/core.b2BzqGz0.mjs';
7
7
  export { d as difference, e as equalsSet, i as hasAtLeast, a as intersection, b as stringHash, s as symmetricDifference, u as union } from '../shared/core.CpwrZi3D.mjs';
8
- export { D as DefaultMap } from '../shared/core.BMCb0wJI.mjs';
8
+ export { D as DefaultMap } from '../shared/core.yaRvRnMW.mjs';
9
9
  export { i as ifilter } from '../shared/core.BH9nN-_I.mjs';
10
10
  export { i as ifind, a as ihead } from '../shared/core.DsMrjY2q.mjs';
11
+ export { ifirst, ireduce } from './iterable/index.mjs';
11
12
  export { i as iflat, a as isIterable, b as iunique } from '../shared/core.8KM0D6va.mjs';
12
13
  export { i as imap, t as toArray, a as toSet } from '../shared/core.Btl47GOn.mjs';
13
- export { ireduce } from './iterable/index.mjs';
14
14
  export { i as isome } from '../shared/core.BoyJPEen.mjs';
15
15
  import '../shared/core.DbRvwARP.mjs';
16
16
  import '../shared/core.ByrmCl9I.mjs';
@@ -39,6 +39,23 @@ declare function ifind<T>(predicate: (item: T) => boolean): (iterable: Iterable<
39
39
  declare function ifind<T, S extends T>(iterable: Iterable<T>, predicate: (item: T) => item is S): S | undefined;
40
40
  declare function ifind<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): T | undefined;
41
41
 
42
+ /**
43
+ * Takes the first N elements from an iterable.
44
+ * Composable first version of `ifirst`.
45
+ * @signature
46
+ * ifirst(count)(data)
47
+ */
48
+ declare function ifirst<T>(count: number): (iterable: Iterable<T>) => IteratorLike<T>;
49
+ /**
50
+ * Takes the first N elements from an iterable.
51
+ * Data first version of `ifirst`.
52
+ * @signature
53
+ * ifirst(data, count)
54
+ * @example
55
+ * ifirst([1, 2, 3, 4, 5], 3) // => Iterable<[1, 2, 3]>
56
+ */
57
+ declare function ifirst<T>(iterable: Iterable<T>, count: number): IteratorLike<T>;
58
+
42
59
  declare function iflat(): <T>(iterable: Iterable<IteratorLike<T>>) => IteratorLike<T>;
43
60
  declare function iflat<T>(iterable: Iterable<IteratorLike<T>>): IteratorLike<T>;
44
61
 
@@ -97,4 +114,4 @@ declare function toSet<T>(iterable: Iterable<T>): Set<T>;
97
114
  declare function iunique(): <T>(iterable: Iterable<T>) => IteratorLike<T>;
98
115
  declare function iunique<T>(iterable: Iterable<T>): IteratorLike<T>;
99
116
 
100
- export { ifilter, ifind, iflat, ihead, imap, ireduce, isIterable, isome, iunique, toArray, toSet };
117
+ export { ifilter, ifind, ifirst, iflat, ihead, imap, ireduce, isIterable, isome, iunique, toArray, toSet };
@@ -3,10 +3,30 @@ export { i as iflat, a as isIterable, b as iunique } from '../../shared/core.8KM
3
3
  import { t } from '../../shared/core.BH9nN-_I.mjs';
4
4
  export { i as ifilter } from '../../shared/core.BH9nN-_I.mjs';
5
5
  export { i as ifind, a as ihead } from '../../shared/core.DsMrjY2q.mjs';
6
- export { i as imap, t as toArray, a as toSet } from '../../shared/core.Btl47GOn.mjs';
7
6
  import { i as invariant } from '../../shared/core.D4npX2q8.mjs';
7
+ export { i as imap, t as toArray, a as toSet } from '../../shared/core.Btl47GOn.mjs';
8
8
  export { i as isome } from '../../shared/core.BoyJPEen.mjs';
9
9
 
10
+ function ifirst(arg1, arg2) {
11
+ const count = arg2 ?? arg1;
12
+ invariant(typeof count === "number" && count >= 0, "Count must be a non-negative number");
13
+ function* _first(iter) {
14
+ let taken = 0;
15
+ for (const value of iter) {
16
+ if (taken >= count) {
17
+ break;
18
+ }
19
+ yield value;
20
+ taken++;
21
+ }
22
+ return;
23
+ }
24
+ if (arg2 === void 0) {
25
+ return (iterable) => _first(iterable);
26
+ }
27
+ return _first(arg1);
28
+ }
29
+
10
30
  function ireduce(arg1, arg2, arg3) {
11
31
  const reducer = n(arg3) ? arg2 : arg1;
12
32
  const initialValue = arg3 ?? arg2;
@@ -21,4 +41,4 @@ function ireduce(arg1, arg2, arg3) {
21
41
  return n(arg3) ? _reduce(arg1) : _reduce;
22
42
  }
23
43
 
24
- export { ireduce };
44
+ export { ifirst, ireduce };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@likec4/core",
3
- "version": "1.34.1",
3
+ "version": "1.35.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://likec4.dev",
6
6
  "author": "Denis Davydkov <denis@davydkov.com>",
@@ -106,15 +106,16 @@
106
106
  "unified": "11.0.5"
107
107
  },
108
108
  "devDependencies": {
109
+ "@types/chroma-js": "^3.1.1",
109
110
  "@dagrejs/dagre": "1.1.5",
110
- "@mantine/colors-generator": "8.1.2",
111
+ "@mantine/colors-generator": "8.2.1",
111
112
  "@types/extend": "3.0.4",
112
113
  "@types/hast": "3.0.4",
113
114
  "@types/mdast": "4.0.4",
114
115
  "@types/natural-compare-lite": "^1.4.2",
115
116
  "@types/object-hash": "^3.0.6",
116
117
  "@types/unist": "3.0.3",
117
- "@types/node": "~20.19.1",
118
+ "@types/node": "~20.19.2",
118
119
  "chroma-js": "^3.1.2",
119
120
  "defu": "^6.1.4",
120
121
  "fdir": "6.4.6",
@@ -126,11 +127,11 @@
126
127
  "object-hash": "^3.0.0",
127
128
  "obliterator": "^2.0.5",
128
129
  "remeda": "^2.23.1",
129
- "turbo": "2.5.4",
130
+ "turbo": "2.5.5",
130
131
  "typescript": "5.8.3",
131
132
  "unbuild": "3.5.0",
132
133
  "vitest": "3.2.4",
133
- "@likec4/tsconfig": "1.34.1"
134
+ "@likec4/tsconfig": "1.35.0"
134
135
  },
135
136
  "scripts": {
136
137
  "typecheck": "tsc -b --verbose",
@@ -1,5 +1,5 @@
1
- import { concat, constant, flatMap, hasAtLeast, map, partition, pipe, piped, prop, when } from 'remeda'
2
- import type { RelationshipModel } from '../../../model'
1
+ import { concat, constant, filter, flatMap, hasAtLeast, map, partition, pipe, piped, prop, when } from 'remeda'
2
+ import type { ElementModel, RelationshipModel } from '../../../model'
3
3
  import { ConnectionModel } from '../../../model'
4
4
  import { type AnyAux, type ModelRelationExpr, FqnRef, ModelFqnExpr } from '../../../types'
5
5
  import { invariant, isSameHierarchy } from '../../../utils'
@@ -16,6 +16,8 @@ import {
16
16
  } from './_utils'
17
17
 
18
18
  const isWildcard = ModelFqnExpr.isWildcard
19
+ const isAncestorOrDescendantOf = (a: ElementModel<AnyAux>, b: ElementModel<AnyAux>) =>
20
+ a.isAncestorOf(b) || a.isDescendantOf(b)
19
21
 
20
22
  export const DirectRelationExprPredicate: PredicateExecutor<ModelRelationExpr.Direct<AnyAux>> = {
21
23
  include: ({ expr: { source, target, isBidirectional = false }, memory, model, stage, where, filterWhere }) => {
@@ -75,6 +77,7 @@ export const DirectRelationExprPredicate: PredicateExecutor<ModelRelationExpr.Di
75
77
  concat(
76
78
  pipe(
77
79
  outgoing,
80
+ filter(r => !isAncestorOrDescendantOf(source, r.target)),
78
81
  map(outgoing =>
79
82
  new ConnectionModel(
80
83
  source,
@@ -85,6 +88,7 @@ export const DirectRelationExprPredicate: PredicateExecutor<ModelRelationExpr.Di
85
88
  ),
86
89
  pipe(
87
90
  incoming,
91
+ filter(r => !isAncestorOrDescendantOf(source, r.source)),
88
92
  map(incoming =>
89
93
  new ConnectionModel(
90
94
  incoming.source,
@@ -133,6 +137,7 @@ export const DirectRelationExprPredicate: PredicateExecutor<ModelRelationExpr.Di
133
137
  concat(
134
138
  pipe(
135
139
  outgoing,
140
+ filter(r => !isAncestorOrDescendantOf(target, r.target)),
136
141
  map(outgoing =>
137
142
  new ConnectionModel(
138
143
  target,
@@ -143,6 +148,7 @@ export const DirectRelationExprPredicate: PredicateExecutor<ModelRelationExpr.Di
143
148
  ),
144
149
  pipe(
145
150
  incoming,
151
+ filter(r => !isAncestorOrDescendantOf(target, r.source)),
146
152
  map(incoming =>
147
153
  new ConnectionModel(
148
154
  incoming.source,
@@ -30,8 +30,8 @@ import {
30
30
  type IncomingFilter,
31
31
  type OutgoingFilter,
32
32
  type RelationOrId,
33
- getId,
34
33
  } from './types'
34
+ import { getId } from './utils'
35
35
  import type { LikeC4ViewModel } from './view/LikeC4ViewModel'
36
36
 
37
37
  export class LikeC4DeploymentModel<A extends Any = Any> {
@@ -1,4 +1,4 @@
1
- import { entries, map, pipe, prop, sort, sortBy, values } from 'remeda'
1
+ import { entries, hasAtLeast, map, pipe, prop, sort, sortBy, split, values } from 'remeda'
2
2
  import type {
3
3
  Any,
4
4
  Aux,
@@ -17,7 +17,7 @@ import type {
17
17
  } from '../types'
18
18
  import { type ProjectId, _stage, GlobalFqn, isGlobalFqn, isOnStage, whereOperatorAsPredicate } from '../types'
19
19
  import type * as aux from '../types/_aux'
20
- import { compareNatural, ifilter, invariant, memoizeProp, nonNullable } from '../utils'
20
+ import { compareNatural, compareNaturalHierarchically, ifilter, invariant, memoizeProp, nonNullable } from '../utils'
21
21
  import { ancestorsFqn, commonAncestor, parentFqn, sortParentsFirst } from '../utils/fqn'
22
22
  import { DefaultMap } from '../utils/mnemonist'
23
23
  import type {
@@ -36,9 +36,10 @@ import {
36
36
  type IncomingFilter,
37
37
  type OutgoingFilter,
38
38
  type RelationOrId,
39
- getId,
40
39
  } from './types'
40
+ import { getId, getViewFolderPath, normalizeViewPath, VIEW_FOLDERS_SEPARATOR } from './utils'
41
41
  import { LikeC4ViewModel } from './view/LikeC4ViewModel'
42
+ import { LikeC4ViewsFolder } from './view/LikeC4ViewsFolder'
42
43
  import type { NodeModel } from './view/NodeModel'
43
44
 
44
45
  export class LikeC4Model<A extends Any = aux.Unknown> {
@@ -68,6 +69,16 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
68
69
 
69
70
  protected readonly _views = new Map<aux.ViewId<A>, LikeC4ViewModel<A>>()
70
71
 
72
+ protected readonly _rootViewFolder: LikeC4ViewsFolder<A>
73
+ protected readonly _viewFolders = new Map<string, LikeC4ViewsFolder<A>>()
74
+
75
+ protected readonly _viewFolderItems = new DefaultMap<
76
+ string,
77
+ Set<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>>
78
+ >(
79
+ () => new Set(),
80
+ )
81
+
71
82
  protected readonly _allTags = new DefaultMap<
72
83
  aux.Tag<A>,
73
84
  Set<ElementModel<A> | RelationshipModel<A> | LikeC4ViewModel<A>>
@@ -165,17 +176,65 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
165
176
  this.deployment = new LikeC4DeploymentModel(this)
166
177
 
167
178
  if (isOnStage($data, 'computed') || isOnStage($data, 'layouted')) {
179
+ const compare = compareNaturalHierarchically(VIEW_FOLDERS_SEPARATOR)
168
180
  const views = pipe(
169
181
  values($data.views as Record<string, $View<A>>),
170
- sort((a, b) => compareNatural(a.title ?? 'untitled', b.title ?? 'untitled')),
182
+ map(view => ({
183
+ view,
184
+ path: normalizeViewPath(view.title ?? view.id),
185
+ folderPath: view.title && getViewFolderPath(view.title) || '',
186
+ })),
187
+ // Sort hierarchically by groups, but keep same order within groups
188
+ sort((a, b) => compare(a.folderPath, b.folderPath)),
171
189
  )
172
- for (const view of views) {
173
- const vm = new LikeC4ViewModel(this, view)
190
+
191
+ const getOrCreateFolder = (path: string) => {
192
+ let folder = this._viewFolders.get(path)
193
+ if (!folder) {
194
+ const segments = split(path, VIEW_FOLDERS_SEPARATOR)
195
+ invariant(hasAtLeast(segments, 1), `View group path "${path}" must have at least one element`)
196
+ let defaultView
197
+ // Root group has "index" as default view
198
+ if (path === '') {
199
+ defaultView = views.find(view => view.view.id === 'index')
200
+ } else {
201
+ defaultView = views.find(view => view.path === path)
202
+ }
203
+ folder = new LikeC4ViewsFolder(this, segments, defaultView?.view.id)
204
+ this._viewFolders.set(path, folder)
205
+ }
206
+ return folder
207
+ }
208
+
209
+ this._rootViewFolder = getOrCreateFolder('')
210
+
211
+ // Process view groups
212
+ // Sort in natural order to preserve hierarchy
213
+ for (const { folderPath } of views) {
214
+ if (this._viewFolders.has(folderPath)) {
215
+ continue
216
+ }
217
+ // Create groups for each segment of the path
218
+ split(folderPath, VIEW_FOLDERS_SEPARATOR).reduce((parent, segment) => {
219
+ const path = [...parent, segment]
220
+ const folder = getOrCreateFolder(path.join(VIEW_FOLDERS_SEPARATOR))
221
+ this._viewFolderItems.get(parent.join(VIEW_FOLDERS_SEPARATOR)).add(folder)
222
+ return path
223
+ }, [] as string[])
224
+ }
225
+
226
+ for (const { view, folderPath } of views) {
227
+ const vm = new LikeC4ViewModel(this, view, getOrCreateFolder(folderPath))
228
+ this._viewFolderItems.get(folderPath).add(vm)
174
229
  this._views.set(view.id, vm)
175
230
  for (const tag of vm.tags) {
176
231
  this._allTags.get(tag).add(vm)
177
232
  }
178
233
  }
234
+ } else {
235
+ // Model is not computed or layouted, but we still need to create root folder
236
+ this._rootViewFolder = new LikeC4ViewsFolder(this, [''], undefined)
237
+ this._viewFolders.set(this._rootViewFolder.path, this._rootViewFolder)
179
238
  }
180
239
  }
181
240
 
@@ -254,6 +313,21 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
254
313
  }))
255
314
  }
256
315
 
316
+ /**
317
+ * Returns the element with the given FQN.
318
+ *
319
+ * @throws Error if element is not found\
320
+ * Use {@link findElement} if you don't want to throw an error
321
+ *
322
+ * @note Method is type-safe for typed model
323
+
324
+ * @example
325
+ * model.element('cloud.frontend')
326
+ * // or object with id property of scalar.Fqn
327
+ * model.element({
328
+ * id: 'dashboard',
329
+ * })
330
+ */
257
331
  public element(el: ElementOrFqn<A>): ElementModel<A> {
258
332
  if (el instanceof ElementModel) {
259
333
  return el
@@ -261,6 +335,16 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
261
335
  const id = getId(el)
262
336
  return nonNullable(this._elements.get(id), `Element ${id} not found`)
263
337
  }
338
+
339
+ /**
340
+ * Returns the element with the given FQN.
341
+ *
342
+ * @returns Element if found, null otherwise
343
+ * @note Method is not type-safe as {@link element}
344
+ *
345
+ * @example
346
+ * model.findElement('cloud.frontend')
347
+ */
264
348
  public findElement(el: aux.LooseElementId<A>): ElementModel<A> | null {
265
349
  return this._elements.get(getId(el)) ?? null
266
350
  }
@@ -342,15 +426,69 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
342
426
 
343
427
  /**
344
428
  * Returns a specific view by its ID.
429
+ * @note Method is type-safe for typed model
430
+ * @throws Error if view is not found\
431
+ * Use {@link findView} if you don't want to throw an error
432
+ *
433
+ * @example
434
+ * model.view('index')
435
+ * // or object with id property of scalar.ViewId
436
+ * model.view({
437
+ * id: 'index',
438
+ * })
345
439
  */
346
440
  public view(viewId: aux.ViewId<A> | { id: scalar.ViewId<aux.ViewId<A>> }): LikeC4ViewModel<A, $View<A>> {
347
441
  const id = getId(viewId)
348
442
  return nonNullable(this._views.get(id), `View ${id} not found`)
349
443
  }
444
+
445
+ /**
446
+ * Returns a specific view by its ID.
447
+ * @note Method is not type-safe as {@link view}
448
+ *
449
+ * @example
450
+ * model.findView('index')
451
+ */
350
452
  public findView(viewId: aux.LooseViewId<A>): LikeC4ViewModel<A, $View<A>> | null {
351
453
  return this._views.get(viewId as aux.ViewId<A>) ?? null
352
454
  }
353
455
 
456
+ /**
457
+ * Returns a view folder by its path.
458
+ * Path is extracted from the view title, e.g. "Group 1/Group 2/View" -> "Group 1/Group 2"
459
+ * @throws Error if view folder is not found.
460
+ */
461
+ public viewFolder(path: string): LikeC4ViewsFolder<A> {
462
+ return nonNullable(this._viewFolders.get(path), `View folder ${path} not found`)
463
+ }
464
+
465
+ /**
466
+ * Root folder is a special one with an empty path and used only for internal purposes.
467
+ * It is not visible to the user and should be used only to get top-level folders and views.
468
+ */
469
+ get rootViewFolder(): LikeC4ViewsFolder<A> {
470
+ return this._rootViewFolder
471
+ }
472
+
473
+ /**
474
+ * Whether the model has any view folders.
475
+ */
476
+ get hasViewFolders(): boolean {
477
+ // Root view folder is always present
478
+ return this._viewFolders.size > 1
479
+ }
480
+
481
+ /**
482
+ * Returns all children of a view folder.
483
+ * Path is extracted from the view title, e.g. "Group 1/Group 2/View" -> "Group 1/Group 2"
484
+ *
485
+ * @throws Error if view folder is not found.
486
+ */
487
+ public viewFolderItems(path: string): ReadonlySet<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>> {
488
+ invariant(this._viewFolders.has(path), `View folder ${path} not found`)
489
+ return this._viewFolderItems.get(path)
490
+ }
491
+
354
492
  /**
355
493
  * Returns the parent element of given element.
356
494
  * @see ancestors
@@ -452,7 +590,8 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
452
590
  }
453
591
 
454
592
  /**
455
- * Returns all tags used in the model, sorted alphabetically.
593
+ * Returns array of all tags used in the model, sorted naturally.\
594
+ * Use {@link specification.tags} to get all defined tags
456
595
  */
457
596
  get tags(): aux.Tags<A> {
458
597
  return memoizeProp(this, 'tags', () => sort([...this._allTags.keys()], compareNatural))
@@ -36,6 +36,7 @@ export { LikeC4DeploymentModel } from './DeploymentModel'
36
36
 
37
37
  export { EdgeModel } from './view/EdgeModel'
38
38
  export { LikeC4ViewModel } from './view/LikeC4ViewModel'
39
+ export { LikeC4ViewsFolder } from './view/LikeC4ViewsFolder'
39
40
  export { NodeModel } from './view/NodeModel'
40
41
 
41
42
  export type {
@@ -62,3 +63,10 @@ export type {
62
63
  } from '../types'
63
64
 
64
65
  export { RichText, type RichTextEmpty, type RichTextOrEmpty } from '../types'
66
+
67
+ export {
68
+ extractViewTitleFromPath,
69
+ getViewFolderPath,
70
+ normalizeViewPath,
71
+ VIEW_FOLDERS_SEPARATOR,
72
+ } from './utils'
@@ -16,7 +16,7 @@ import type { LikeC4ViewModel } from './view/LikeC4ViewModel'
16
16
  export type IncomingFilter = 'all' | 'direct' | 'to-descendants'
17
17
  export type OutgoingFilter = 'all' | 'direct' | 'from-descendants'
18
18
 
19
- type AnyScalar<V extends string> =
19
+ export type AnyScalar<V extends string> =
20
20
  | Tagged<V, 'Fqn'>
21
21
  | Tagged<V, 'DeploymentFqn'>
22
22
  | Tagged<V, 'ViewId'>
@@ -24,13 +24,6 @@ type AnyScalar<V extends string> =
24
24
  | Tagged<V, 'EdgeId'>
25
25
  | Tagged<V, 'RelationId'>
26
26
 
27
- /**
28
- * Utility function to extract `id` from the given element.
29
- */
30
- export function getId<Id extends string, Scalar extends AnyScalar<Id>>(element: Id | { id: Scalar }): Scalar {
31
- return typeof element === 'string' ? element as unknown as Scalar : element.id
32
- }
33
-
34
27
  export type ElementOrFqn<A extends AnyAux> = aux.ElementId<A> | { id: aux.Fqn<A> }
35
28
 
36
29
  export type DeploymentOrFqn<A extends AnyAux> = aux.DeploymentId<A> | { id: aux.DeploymentFqn<A> }
@@ -0,0 +1,66 @@
1
+ import { hasAtLeast } from 'remeda'
2
+ import type { NonEmptyArray } from '../types'
3
+ import { invariant } from '../utils'
4
+ import type { AnyScalar } from './types'
5
+
6
+ /**
7
+ * Utility function to extract `id` from the given element.
8
+ */
9
+ export const getId = <Id extends string, Scalar extends AnyScalar<Id>>(element: Id | { id: Scalar }): Scalar => {
10
+ return typeof element === 'string' ? element as unknown as Scalar : element.id
11
+ }
12
+
13
+ export const VIEW_FOLDERS_SEPARATOR = '/'
14
+
15
+ const splitViewTitle = (title: string): NonEmptyArray<string> => {
16
+ invariant(!title.includes('\n'), 'View title cannot contain newlines')
17
+ if (title.includes(VIEW_FOLDERS_SEPARATOR)) {
18
+ const segments = title
19
+ .split(VIEW_FOLDERS_SEPARATOR)
20
+ .map(s => s.trim())
21
+ .filter(s => s.length > 0)
22
+ if (hasAtLeast(segments, 1)) {
23
+ return segments
24
+ }
25
+ return ['']
26
+ }
27
+ return [title.trim()]
28
+ }
29
+
30
+ /**
31
+ * Normalizes view path by removing spaces from segments, removing empty segments,
32
+ * and removing leading/trailing slashes
33
+ * @example
34
+ * normalizeViewPath('One / Tw o / Thre e') === 'One/Tw o/Thre e'
35
+ */
36
+ export const normalizeViewPath = (title: string): string => {
37
+ return splitViewTitle(title).join(VIEW_FOLDERS_SEPARATOR)
38
+ }
39
+
40
+ /**
41
+ * Returns view group path if it is used as a path
42
+ * Returns empty string if it is not a path
43
+ * @example
44
+ * getViewFolderPath('One / Tw o / Thre e') === 'One/Tw o'
45
+ * getViewFolderPath('One') === ''
46
+ */
47
+ export const getViewFolderPath = (title: string): string | null => {
48
+ const segments = splitViewTitle(title)
49
+ if (!hasAtLeast(segments, 2)) {
50
+ return null
51
+ }
52
+ return segments.slice(0, -1).join(VIEW_FOLDERS_SEPARATOR)
53
+ }
54
+
55
+ /**
56
+ * Returns view title if it is used as a path
57
+ * @example
58
+ * getViewTitleFromPath('One / Tw o / Thre e') === 'Thre e'
59
+ * getViewTitleFromPath('One') === 'One'
60
+ */
61
+ export const extractViewTitleFromPath = (title: string): string => {
62
+ if (!title.includes(VIEW_FOLDERS_SEPARATOR)) {
63
+ return title.trim()
64
+ }
65
+ return splitViewTitle(title).pop() ?? title
66
+ }
@@ -7,10 +7,9 @@ import type {
7
7
  scalar,
8
8
  ViewWithType,
9
9
  } from '../../types'
10
- import { _stage, _type } from '../../types'
11
- import { type RichTextOrEmpty, RichText } from '../../types'
10
+ import { type RichTextOrEmpty, _stage, _type, RichText } from '../../types'
12
11
  import type * as aux from '../../types/_aux'
13
- import { DefaultMap, ifind, nonNullable } from '../../utils'
12
+ import { DefaultMap, ifind, memoizeProp, nonNullable } from '../../utils'
14
13
  import type { ElementModel } from '../ElementModel'
15
14
  import type { LikeC4Model } from '../LikeC4Model'
16
15
  import {
@@ -18,9 +17,10 @@ import {
18
17
  type EdgeOrId,
19
18
  type NodeOrId,
20
19
  type WithTags,
21
- getId,
22
20
  } from '../types'
21
+ import { extractViewTitleFromPath, getId, normalizeViewPath } from '../utils'
23
22
  import { type EdgesIterator, EdgeModel } from './EdgeModel'
23
+ import type { LikeC4ViewsFolder } from './LikeC4ViewsFolder'
24
24
  import { type NodesIterator, NodeModel } from './NodeModel'
25
25
 
26
26
  export type ViewsIterator<A extends Any, V extends $View<A> = $View<A>> = IteratorLike<LikeC4ViewModel<A, V>>
@@ -44,9 +44,29 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
44
44
  public readonly $view: V
45
45
  public readonly $model: LikeC4Model<A>
46
46
 
47
- constructor(model: LikeC4Model<A>, view: V) {
47
+ public readonly title: string | null
48
+
49
+ /**
50
+ * View folder this view belongs to.
51
+ * If view is top-level, this is the root folder.
52
+ */
53
+ public readonly folder: LikeC4ViewsFolder<A>
54
+ /**
55
+ * Path to this view, processed by {@link normalizeViewPath}
56
+ *
57
+ * @example
58
+ * "Group 1/Group 2/View"
59
+ */
60
+ public readonly viewPath: string
61
+
62
+ constructor(
63
+ model: LikeC4Model<A>,
64
+ view: V,
65
+ folder: LikeC4ViewsFolder<A>,
66
+ ) {
48
67
  this.$model = model
49
68
  this.$view = view
69
+ this.folder = folder
50
70
  for (const node of view.nodes) {
51
71
  const el = new NodeModel<A, V>(this, Object.freeze(node))
52
72
  this.#nodes.set(node.id, el)
@@ -79,6 +99,9 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
79
99
  }
80
100
  this.#edges.set(edge.id, edgeModel)
81
101
  }
102
+
103
+ this.title = this.$view.title ? extractViewTitleFromPath(this.$view.title) : null
104
+ this.viewPath = this.$view.title ? normalizeViewPath(this.$view.title) : this.$view.id
82
105
  }
83
106
 
84
107
  get _type(): V[_type] {
@@ -89,8 +112,40 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
89
112
  return this.$view.id
90
113
  }
91
114
 
92
- get title(): string | null {
93
- return this.$view.title
115
+ /**
116
+ * Returns title if defined, otherwise returns its {@link id}
117
+ */
118
+ get titleOrId(): string {
119
+ return this.title ?? this.id
120
+ }
121
+
122
+ /**
123
+ * Returns title if defined, otherwise returns `Untitled`.
124
+ */
125
+ get titleOrUntitled(): string {
126
+ return this.title ?? 'Untitled'
127
+ }
128
+
129
+ /**
130
+ * Returns path to this view as an array of groups and this view as the last element
131
+ * If view is top-level, returns only this view.
132
+ *
133
+ * @example
134
+ * viewPath = "Group 1/Group 2/View"
135
+ *
136
+ * breadcrumbs = [
137
+ * "Group 1", // folder
138
+ * "Group 1/Group 2", // folder
139
+ * "Group 1/Group 2/View" // view
140
+ * ]
141
+ */
142
+ get breadcrumbs(): [...LikeC4ViewsFolder<A>[], this] {
143
+ return memoizeProp(this, 'breadcrumbs', () => {
144
+ if (!this.folder.isRoot) {
145
+ return [...this.folder.breadcrumbs, this]
146
+ }
147
+ return [this]
148
+ })
94
149
  }
95
150
 
96
151
  get description(): RichTextOrEmpty {