@likec4/core 1.34.2 → 1.36.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 (79) hide show
  1. package/README.md +1 -1
  2. package/dist/builder/index.d.mts +15 -4
  3. package/dist/builder/index.mjs +19 -15
  4. package/dist/compute-view/index.d.mts +3 -3
  5. package/dist/compute-view/index.mjs +16 -13
  6. package/dist/compute-view/relationships-view/index.d.mts +3 -3
  7. package/dist/compute-view/relationships-view/index.mjs +2 -2
  8. package/dist/index.d.mts +3 -3
  9. package/dist/index.mjs +14 -9
  10. package/dist/model/connection/deployment/index.d.mts +5 -5
  11. package/dist/model/connection/deployment/index.mjs +5 -4
  12. package/dist/model/connection/index.d.mts +6 -6
  13. package/dist/model/connection/index.mjs +6 -5
  14. package/dist/model/connection/model/index.d.mts +5 -5
  15. package/dist/model/connection/model/index.mjs +3 -3
  16. package/dist/model/index.d.mts +33 -9
  17. package/dist/model/index.mjs +12 -10
  18. package/dist/shared/{core.DAj3LuFH.d.mts → core.BFb3p5d-.d.mts} +4 -2
  19. package/dist/shared/{core.PqNe5WxQ.mjs → core.BlwZ4tBa.mjs} +98 -92
  20. package/dist/shared/core.BpJu1RrF.mjs +5 -0
  21. package/dist/shared/{core.CJSFKrYf.d.mts → core.Bt3kuGUN.d.mts} +3 -3
  22. package/dist/shared/{core.D5qVzkws.mjs → core.Bus32P0S.mjs} +4 -3
  23. package/dist/shared/{core.4SpVCM9m.mjs → core.BvIGRozf.mjs} +364 -28
  24. package/dist/shared/{core.C80Z37Eu.mjs → core.C1GV0WWV.mjs} +1 -1
  25. package/dist/shared/{core.DXRAwwDB.mjs → core.CaJp8Amg.mjs} +3 -2
  26. package/dist/shared/{core.D56nCp7e.mjs → core.CaaYNQDM.mjs} +1 -3
  27. package/dist/shared/core.CnIyafWn.mjs +3 -0
  28. package/dist/shared/{core.D3xEWj5B.d.mts → core.D8bTI5KC.d.mts} +2 -2
  29. package/dist/shared/{core.Di7__sMg.d.mts → core.DHYd2-VU.d.mts} +3 -3
  30. package/dist/shared/{core.C_C-3c1S.mjs → core.DQj9iv_y.mjs} +3 -5
  31. package/dist/shared/{core.DrS4Bf4y.d.mts → core.DoNP3Yl-.d.mts} +187 -11
  32. package/dist/shared/{core.IWLie-Ma.d.mts → core.DxDn5nB5.d.mts} +24 -6
  33. package/dist/shared/{core.CtKXPqN_.mjs → core.HUONfQfd.mjs} +1 -1
  34. package/dist/shared/{core.BTnf14gD.mjs → core.Y3j3s-oq.mjs} +2 -2
  35. package/dist/shared/{core.Br7Fab9l.mjs → core._jnYNUQ-.mjs} +8 -70
  36. package/dist/shared/{core.XptQjhMA.mjs → core.b2BzqGz0.mjs} +2 -2
  37. package/dist/shared/{core.BMCb0wJI.mjs → core.yaRvRnMW.mjs} +1 -1
  38. package/dist/theme/index.mjs +34 -3510
  39. package/dist/types/_aux.d.mts +1 -1
  40. package/dist/types/index.d.mts +3 -3
  41. package/dist/types/index.mjs +6 -4
  42. package/dist/types/scalar.d.mts +2 -0
  43. package/dist/types/scalar.mjs +68 -0
  44. package/dist/utils/index.d.mts +6 -8
  45. package/dist/utils/index.mjs +5 -5
  46. package/dist/utils/iterable/index.d.mts +18 -1
  47. package/dist/utils/iterable/index.mjs +22 -2
  48. package/package.json +11 -16
  49. package/src/builder/Builder.ts +17 -6
  50. package/src/builder/__snapshots__/Builder-style1.spec.ts.snap +9 -0
  51. package/src/builder/__snapshots__/Builder-style2.compute-model.json5 +3 -0
  52. package/src/builder/__snapshots__/Builder-style2.spec.ts.snap +3 -0
  53. package/src/builder/_types.ts +10 -0
  54. package/src/compute-view/dynamic-view/compute.ts +2 -0
  55. package/src/compute-view/element-view/__test__/fixture.ts +1 -0
  56. package/src/compute-view/element-view/predicates/relation-direct.ts +8 -2
  57. package/src/compute-view/utils/applyCustomRelationProperties.ts +2 -0
  58. package/src/model/DeploymentModel.ts +1 -1
  59. package/src/model/LikeC4Model.ts +162 -13
  60. package/src/model/index.ts +8 -0
  61. package/src/model/types.ts +1 -8
  62. package/src/model/utils.ts +66 -0
  63. package/src/model/view/LikeC4ViewModel.ts +62 -7
  64. package/src/model/view/LikeC4ViewsFolder.ts +151 -0
  65. package/src/theme/__test__/theme-index.ts +29 -0
  66. package/src/theme/element.ts +0 -19
  67. package/src/theme/index.ts +49 -3
  68. package/src/types/_aux.ts +6 -0
  69. package/src/types/index.ts +1 -0
  70. package/src/types/model-data.ts +2 -0
  71. package/src/types/model-dump.ts +8 -1
  72. package/src/types/model-spec.ts +4 -0
  73. package/src/types/project.ts +9 -0
  74. package/src/types/view-computed.ts +1 -1
  75. package/src/types/view-parsed.dynamic.ts +2 -2
  76. package/src/utils/compare-natural.ts +14 -12
  77. package/src/utils/index.ts +1 -0
  78. package/src/utils/iterable/first.ts +48 -0
  79. package/src/utils/iterable/index.ts +1 -0
@@ -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,
@@ -8,6 +8,7 @@ import type {
8
8
  IteratorLike,
9
9
  LayoutedLikeC4ModelData,
10
10
  LikeC4ModelDump,
11
+ LikeC4Project,
11
12
  ModelGlobals,
12
13
  ParsedLikeC4ModelData,
13
14
  Relationship,
@@ -17,7 +18,7 @@ import type {
17
18
  } from '../types'
18
19
  import { type ProjectId, _stage, GlobalFqn, isGlobalFqn, isOnStage, whereOperatorAsPredicate } from '../types'
19
20
  import type * as aux from '../types/_aux'
20
- import { compareNatural, ifilter, invariant, memoizeProp, nonNullable } from '../utils'
21
+ import { compareNatural, compareNaturalHierarchically, ifilter, invariant, memoizeProp, nonNullable } from '../utils'
21
22
  import { ancestorsFqn, commonAncestor, parentFqn, sortParentsFirst } from '../utils/fqn'
22
23
  import { DefaultMap } from '../utils/mnemonist'
23
24
  import type {
@@ -36,9 +37,10 @@ import {
36
37
  type IncomingFilter,
37
38
  type OutgoingFilter,
38
39
  type RelationOrId,
39
- getId,
40
40
  } from './types'
41
+ import { getId, getViewFolderPath, normalizeViewPath, VIEW_FOLDERS_SEPARATOR } from './utils'
41
42
  import { LikeC4ViewModel } from './view/LikeC4ViewModel'
43
+ import { LikeC4ViewsFolder } from './view/LikeC4ViewsFolder'
42
44
  import type { NodeModel } from './view/NodeModel'
43
45
 
44
46
  export class LikeC4Model<A extends Any = aux.Unknown> {
@@ -68,6 +70,16 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
68
70
 
69
71
  protected readonly _views = new Map<aux.ViewId<A>, LikeC4ViewModel<A>>()
70
72
 
73
+ protected readonly _rootViewFolder: LikeC4ViewsFolder<A>
74
+ protected readonly _viewFolders = new Map<string, LikeC4ViewsFolder<A>>()
75
+
76
+ protected readonly _viewFolderItems = new DefaultMap<
77
+ string,
78
+ Set<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>>
79
+ >(
80
+ () => new Set(),
81
+ )
82
+
71
83
  protected readonly _allTags = new DefaultMap<
72
84
  aux.Tag<A>,
73
85
  Set<ElementModel<A> | RelationshipModel<A> | LikeC4ViewModel<A>>
@@ -108,6 +120,7 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
108
120
  const {
109
121
  _stage: stage = 'layouted',
110
122
  projectId = 'unknown',
123
+ project,
111
124
  globals,
112
125
  imports,
113
126
  deployments,
@@ -119,6 +132,7 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
119
132
  return new LikeC4Model({
120
133
  [_stage]: stage as 'layouted',
121
134
  projectId,
135
+ project,
122
136
  globals: {
123
137
  predicates: globals?.predicates ?? {},
124
138
  dynamicPredicates: globals?.dynamicPredicates ?? {},
@@ -165,17 +179,65 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
165
179
  this.deployment = new LikeC4DeploymentModel(this)
166
180
 
167
181
  if (isOnStage($data, 'computed') || isOnStage($data, 'layouted')) {
182
+ const compare = compareNaturalHierarchically(VIEW_FOLDERS_SEPARATOR)
168
183
  const views = pipe(
169
184
  values($data.views as Record<string, $View<A>>),
170
- sort((a, b) => compareNatural(a.title ?? 'untitled', b.title ?? 'untitled')),
185
+ map(view => ({
186
+ view,
187
+ path: normalizeViewPath(view.title ?? view.id),
188
+ folderPath: view.title && getViewFolderPath(view.title) || '',
189
+ })),
190
+ // Sort hierarchically by groups, but keep same order within groups
191
+ sort((a, b) => compare(a.folderPath, b.folderPath)),
171
192
  )
172
- for (const view of views) {
173
- const vm = new LikeC4ViewModel(this, view)
193
+
194
+ const getOrCreateFolder = (path: string) => {
195
+ let folder = this._viewFolders.get(path)
196
+ if (!folder) {
197
+ const segments = split(path, VIEW_FOLDERS_SEPARATOR)
198
+ invariant(hasAtLeast(segments, 1), `View group path "${path}" must have at least one element`)
199
+ let defaultView
200
+ // Root group has "index" as default view
201
+ if (path === '') {
202
+ defaultView = views.find(view => view.view.id === 'index')
203
+ } else {
204
+ defaultView = views.find(view => view.path === path)
205
+ }
206
+ folder = new LikeC4ViewsFolder(this, segments, defaultView?.view.id)
207
+ this._viewFolders.set(path, folder)
208
+ }
209
+ return folder
210
+ }
211
+
212
+ this._rootViewFolder = getOrCreateFolder('')
213
+
214
+ // Process view groups
215
+ // Sort in natural order to preserve hierarchy
216
+ for (const { folderPath } of views) {
217
+ if (this._viewFolders.has(folderPath)) {
218
+ continue
219
+ }
220
+ // Create groups for each segment of the path
221
+ split(folderPath, VIEW_FOLDERS_SEPARATOR).reduce((parent, segment) => {
222
+ const path = [...parent, segment]
223
+ const folder = getOrCreateFolder(path.join(VIEW_FOLDERS_SEPARATOR))
224
+ this._viewFolderItems.get(parent.join(VIEW_FOLDERS_SEPARATOR)).add(folder)
225
+ return path
226
+ }, [] as string[])
227
+ }
228
+
229
+ for (const { view, folderPath } of views) {
230
+ const vm = new LikeC4ViewModel(this, view, getOrCreateFolder(folderPath))
231
+ this._viewFolderItems.get(folderPath).add(vm)
174
232
  this._views.set(view.id, vm)
175
233
  for (const tag of vm.tags) {
176
234
  this._allTags.get(tag).add(vm)
177
235
  }
178
236
  }
237
+ } else {
238
+ // Model is not computed or layouted, but we still need to create root folder
239
+ this._rootViewFolder = new LikeC4ViewsFolder(this, [''], undefined)
240
+ this._viewFolders.set(this._rootViewFolder.path, this._rootViewFolder)
179
241
  }
180
242
  }
181
243
 
@@ -236,6 +298,12 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
236
298
  return this.$data.projectId ?? 'unknown' as any
237
299
  }
238
300
 
301
+ get project(): LikeC4Project {
302
+ return this.$data.project ?? memoizeProp(this, Symbol.for('project'), () => ({
303
+ id: this.projectId as unknown as scalar.ProjectId,
304
+ }))
305
+ }
306
+
239
307
  get specification(): Specification<A> {
240
308
  return this.$data.specification
241
309
  }
@@ -254,6 +322,21 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
254
322
  }))
255
323
  }
256
324
 
325
+ /**
326
+ * Returns the element with the given FQN.
327
+ *
328
+ * @throws Error if element is not found\
329
+ * Use {@link findElement} if you don't want to throw an error
330
+ *
331
+ * @note Method is type-safe for typed model
332
+
333
+ * @example
334
+ * model.element('cloud.frontend')
335
+ * // or object with id property of scalar.Fqn
336
+ * model.element({
337
+ * id: 'dashboard',
338
+ * })
339
+ */
257
340
  public element(el: ElementOrFqn<A>): ElementModel<A> {
258
341
  if (el instanceof ElementModel) {
259
342
  return el
@@ -261,6 +344,16 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
261
344
  const id = getId(el)
262
345
  return nonNullable(this._elements.get(id), `Element ${id} not found`)
263
346
  }
347
+
348
+ /**
349
+ * Returns the element with the given FQN.
350
+ *
351
+ * @returns Element if found, null otherwise
352
+ * @note Method is not type-safe as {@link element}
353
+ *
354
+ * @example
355
+ * model.findElement('cloud.frontend')
356
+ */
264
357
  public findElement(el: aux.LooseElementId<A>): ElementModel<A> | null {
265
358
  return this._elements.get(getId(el)) ?? null
266
359
  }
@@ -342,15 +435,69 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
342
435
 
343
436
  /**
344
437
  * Returns a specific view by its ID.
438
+ * @note Method is type-safe for typed model
439
+ * @throws Error if view is not found\
440
+ * Use {@link findView} if you don't want to throw an error
441
+ *
442
+ * @example
443
+ * model.view('index')
444
+ * // or object with id property of scalar.ViewId
445
+ * model.view({
446
+ * id: 'index',
447
+ * })
345
448
  */
346
449
  public view(viewId: aux.ViewId<A> | { id: scalar.ViewId<aux.ViewId<A>> }): LikeC4ViewModel<A, $View<A>> {
347
450
  const id = getId(viewId)
348
451
  return nonNullable(this._views.get(id), `View ${id} not found`)
349
452
  }
453
+
454
+ /**
455
+ * Returns a specific view by its ID.
456
+ * @note Method is not type-safe as {@link view}
457
+ *
458
+ * @example
459
+ * model.findView('index')
460
+ */
350
461
  public findView(viewId: aux.LooseViewId<A>): LikeC4ViewModel<A, $View<A>> | null {
351
462
  return this._views.get(viewId as aux.ViewId<A>) ?? null
352
463
  }
353
464
 
465
+ /**
466
+ * Returns a view folder by its path.
467
+ * Path is extracted from the view title, e.g. "Group 1/Group 2/View" -> "Group 1/Group 2"
468
+ * @throws Error if view folder is not found.
469
+ */
470
+ public viewFolder(path: string): LikeC4ViewsFolder<A> {
471
+ return nonNullable(this._viewFolders.get(path), `View folder ${path} not found`)
472
+ }
473
+
474
+ /**
475
+ * Root folder is a special one with an empty path and used only for internal purposes.
476
+ * It is not visible to the user and should be used only to get top-level folders and views.
477
+ */
478
+ get rootViewFolder(): LikeC4ViewsFolder<A> {
479
+ return this._rootViewFolder
480
+ }
481
+
482
+ /**
483
+ * Whether the model has any view folders.
484
+ */
485
+ get hasViewFolders(): boolean {
486
+ // Root view folder is always present
487
+ return this._viewFolders.size > 1
488
+ }
489
+
490
+ /**
491
+ * Returns all children of a view folder.
492
+ * Path is extracted from the view title, e.g. "Group 1/Group 2/View" -> "Group 1/Group 2"
493
+ *
494
+ * @throws Error if view folder is not found.
495
+ */
496
+ public viewFolderItems(path: string): ReadonlySet<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>> {
497
+ invariant(this._viewFolders.has(path), `View folder ${path} not found`)
498
+ return this._viewFolderItems.get(path)
499
+ }
500
+
354
501
  /**
355
502
  * Returns the parent element of given element.
356
503
  * @see ancestors
@@ -452,7 +599,8 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
452
599
  }
453
600
 
454
601
  /**
455
- * Returns all tags used in the model, sorted alphabetically.
602
+ * Returns array of all tags used in the model, sorted naturally.\
603
+ * Use {@link specification.tags} to get all defined tags
456
604
  */
457
605
  get tags(): aux.Tags<A> {
458
606
  return memoizeProp(this, 'tags', () => sort([...this._allTags.keys()], compareNatural))
@@ -667,6 +815,7 @@ export namespace LikeC4Model {
667
815
  export const EMPTY = LikeC4Model.create<aux.UnknownComputed>({
668
816
  _stage: 'computed',
669
817
  projectId: 'default' as never,
818
+ project: { id: 'default' as never },
670
819
  specification: {
671
820
  elements: {},
672
821
  relationships: {},
@@ -692,24 +841,24 @@ export namespace LikeC4Model {
692
841
  // dprint-ignore
693
842
  aux.Unknown extends A
694
843
  ? LikeC4Model<aux.UnknownParsed>
695
- : A extends Aux<any, infer E, infer D, infer V, infer P, infer Spec>
696
- ? LikeC4Model<Aux<'parsed', E, D, V, P, Spec>>
844
+ : A extends Aux<any, infer E, infer D, infer V, infer PID, infer Spec>
845
+ ? LikeC4Model<Aux<'parsed', E, D, V, PID, Spec>>
697
846
  : never
698
847
 
699
848
  export type Computed<A = aux.Unknown> =
700
849
  // dprint-ignore
701
850
  aux.Unknown extends A
702
851
  ? LikeC4Model<aux.UnknownComputed>
703
- : A extends Aux<any, infer E, infer D, infer V, infer P, infer Spec>
704
- ? LikeC4Model<Aux<'computed', E, D, V, P, Spec>>
852
+ : A extends Aux<any, infer E, infer D, infer V, infer PID, infer Spec>
853
+ ? LikeC4Model<Aux<'computed', E, D, V, PID, Spec>>
705
854
  : never
706
855
 
707
856
  export type Layouted<A = aux.Unknown> =
708
857
  // dprint-ignore
709
858
  aux.Unknown extends A
710
859
  ? LikeC4Model<aux.UnknownLayouted>
711
- : A extends Aux<any, infer E, infer D, infer V, infer P, infer Spec>
712
- ? LikeC4Model<Aux<'layouted', E, D, V, P, Spec>>
860
+ : A extends Aux<any, infer E, infer D, infer V, infer PID, infer Spec>
861
+ ? LikeC4Model<Aux<'layouted', E, D, V, PID, Spec>>
713
862
  : never
714
863
 
715
864
  export type Node<A = aux.Unknown> = A extends aux.AnyAux ? NodeModel<A> : never
@@ -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 {
@@ -0,0 +1,151 @@
1
+ import { isEmpty, last } from 'remeda'
2
+ import type { aux, NonEmptyArray } from '../../types'
3
+ import { invariant, memoizeProp } from '../../utils'
4
+ import type { LikeC4Model } from '../LikeC4Model'
5
+ import { LikeC4ViewModel } from './LikeC4ViewModel'
6
+
7
+ export class LikeC4ViewsFolder<A extends aux.Any = aux.Any> {
8
+ public readonly $model: LikeC4Model<A>
9
+
10
+ /**
11
+ * Path to this view folder, processed by {@link normalizeViewPath}
12
+ *
13
+ * @example
14
+ * "Folder 1/Folder 2/Folder 3"
15
+ */
16
+ public readonly path: string
17
+
18
+ /**
19
+ * Title of this view folder.
20
+ *
21
+ * @example
22
+ * // title is the last segment of the path
23
+ * path = "Folder 1/Folder 2/Folder 3"
24
+ * title = "Folder 3"
25
+ */
26
+ public readonly title: string
27
+
28
+ /**
29
+ * Whether this is the root view folder.
30
+ *
31
+ * !NOTE
32
+ * Root folder is special folder with an empty path and used only for internal purposes. \
33
+ * It is not visible to the user and should not be used in the code.
34
+ */
35
+ public readonly isRoot: boolean
36
+
37
+ protected readonly parentPath: string | undefined
38
+
39
+ protected readonly defaultViewId: aux.StrictViewId<A> | undefined
40
+
41
+ constructor(
42
+ $model: LikeC4Model<A>,
43
+ path: NonEmptyArray<string>,
44
+ defaultViewId: aux.StrictViewId<A> | undefined,
45
+ ) {
46
+ this.$model = $model
47
+ this.path = path.join('/')
48
+ this.isRoot = this.path === ''
49
+ this.title = last(path)
50
+ if (this.isRoot) {
51
+ this.parentPath = undefined
52
+ } else {
53
+ this.parentPath = path.slice(0, -1).join('/')
54
+ }
55
+ this.defaultViewId = defaultViewId
56
+ }
57
+
58
+ /**
59
+ * Default view of this view folder.\
60
+ * It is for the case when there is a view at the same path as this folder.\
61
+ * (if there are multiple views at the same path, the first one is chosen)
62
+ *
63
+ * @example
64
+ * ```
65
+ * // Assume the following views exist:
66
+ * const views = [
67
+ * "Folder 1/ Folder 2 / View",
68
+ * "Folder 1/ Folder 2 / View / Subview",
69
+ * ]
70
+ * ```
71
+ * Group with path `Folder 1/ Folder 2 / View`\
72
+ * will have default view `Folder 1/ Folder 2 / View`
73
+ */
74
+ get defaultView(): LikeC4ViewModel<A> | null {
75
+ if (!this.defaultViewId) {
76
+ return null
77
+ }
78
+ return this.$model.view(this.defaultViewId)
79
+ }
80
+
81
+ /**
82
+ * Returns path to this view folder as an array of ancestors (excluding root) and this view folder as the last element
83
+ *
84
+ * @throws Error if this is the root folder.
85
+ */
86
+ get breadcrumbs(): [...LikeC4ViewsFolder<A>[], this] {
87
+ invariant(!this.isRoot, 'Root view folder has no breadcrumbs')
88
+ return memoizeProp(this, 'breadcrumbs', () => {
89
+ const parent = this.parent
90
+ if (parent) {
91
+ if (parent.isRoot) {
92
+ return [parent, this]
93
+ }
94
+ return [...parent.breadcrumbs, this]
95
+ }
96
+ return [this]
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Returns parent folder
102
+ *
103
+ * @throws Error if this is the root folder.
104
+ */
105
+ get parent(): LikeC4ViewsFolder<A> | null {
106
+ invariant(!this.isRoot, 'Root view folder has no parent')
107
+ if (isEmpty(this.parentPath)) {
108
+ return null
109
+ }
110
+ return this.$model.viewFolder(this.parentPath)
111
+ }
112
+
113
+ /**
114
+ * Returns sorted set of children
115
+ * - First folders
116
+ * - Then views
117
+ */
118
+ get children(): ReadonlySet<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>> {
119
+ return this.$model.viewFolderItems(this.path)
120
+ }
121
+
122
+ /**
123
+ * Returns sorted array of children folders
124
+ */
125
+ get folders(): ReadonlyArray<LikeC4ViewsFolder<A>> {
126
+ return memoizeProp(this, 'folders', () => {
127
+ const folders: LikeC4ViewsFolder<A>[] = []
128
+ for (const child of this.children) {
129
+ if (child instanceof LikeC4ViewsFolder) {
130
+ folders.push(child)
131
+ }
132
+ }
133
+ return folders
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Returns all views in this view folder.
139
+ */
140
+ get views(): ReadonlyArray<LikeC4ViewModel<A>> {
141
+ return memoizeProp(this, 'views', () => {
142
+ const views: LikeC4ViewModel<A>[] = []
143
+ for (const child of this.children) {
144
+ if (child instanceof LikeC4ViewModel) {
145
+ views.push(child)
146
+ }
147
+ }
148
+ return views
149
+ })
150
+ }
151
+ }