@shumoku/core 0.2.4 → 0.2.13

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Hierarchical sheet generation utilities
3
+ * Shared logic for building child sheets with export connectors
4
+ */
5
+
6
+ import type { LayoutResult, Link, NetworkGraph, Node, Subgraph } from './models/types.js'
7
+
8
+ // ============================================
9
+ // Constants
10
+ // ============================================
11
+
12
+ const EXPORT_NODE_PREFIX = '__export_'
13
+ const EXPORT_LINK_PREFIX = '__export_link_'
14
+
15
+ // ============================================
16
+ // Types
17
+ // ============================================
18
+
19
+ export interface SheetData {
20
+ graph: NetworkGraph
21
+ layout: LayoutResult
22
+ }
23
+
24
+ export interface LayoutEngine {
25
+ layoutAsync(graph: NetworkGraph): Promise<LayoutResult>
26
+ }
27
+
28
+ interface ExportPoint {
29
+ subgraphId: string
30
+ device: string
31
+ port?: string
32
+ destSubgraphLabel: string
33
+ destDevice: string
34
+ destPort?: string
35
+ isSource: boolean
36
+ }
37
+
38
+ // ============================================
39
+ // Type Guards
40
+ // ============================================
41
+
42
+ /**
43
+ * Check if a node is a virtual export connector
44
+ */
45
+ export function isExportNode(nodeId: string): boolean {
46
+ return nodeId.startsWith(EXPORT_NODE_PREFIX)
47
+ }
48
+
49
+ /**
50
+ * Check if a link is a virtual export connector link
51
+ */
52
+ export function isExportLink(linkId: string): boolean {
53
+ return linkId.startsWith(EXPORT_LINK_PREFIX)
54
+ }
55
+
56
+ // ============================================
57
+ // Main Function
58
+ // ============================================
59
+
60
+ /**
61
+ * Build hierarchical sheets from a root graph
62
+ *
63
+ * Creates child sheets for each subgraph with:
64
+ * - Filtered nodes (only those belonging to the subgraph)
65
+ * - Internal links (both endpoints in subgraph)
66
+ * - Export connector nodes/links for boundary connections
67
+ *
68
+ * @param graph - Root network graph with subgraphs
69
+ * @param rootLayout - Layout result for the root graph
70
+ * @param layoutEngine - Engine to layout child sheets
71
+ * @returns Map of sheet ID to SheetData (includes 'root')
72
+ */
73
+ export async function buildHierarchicalSheets(
74
+ graph: NetworkGraph,
75
+ rootLayout: LayoutResult,
76
+ layoutEngine: LayoutEngine,
77
+ ): Promise<Map<string, SheetData>> {
78
+ const sheets = new Map<string, SheetData>()
79
+
80
+ // Add root sheet
81
+ sheets.set('root', { graph, layout: rootLayout })
82
+
83
+ if (!graph.subgraphs || graph.subgraphs.length === 0) {
84
+ return sheets
85
+ }
86
+
87
+ // Mark subgraphs as clickable
88
+ for (const sg of graph.subgraphs) {
89
+ sg.file = sg.id
90
+ }
91
+
92
+ // Build child sheets
93
+ for (const sg of graph.subgraphs) {
94
+ const childSheet = await buildChildSheet(graph, sg, layoutEngine)
95
+ sheets.set(sg.id, childSheet)
96
+ }
97
+
98
+ return sheets
99
+ }
100
+
101
+ // ============================================
102
+ // Internal Functions
103
+ // ============================================
104
+
105
+ async function buildChildSheet(
106
+ rootGraph: NetworkGraph,
107
+ subgraph: Subgraph,
108
+ layoutEngine: LayoutEngine,
109
+ ): Promise<SheetData> {
110
+ // Get nodes belonging to this subgraph
111
+ const childNodes = rootGraph.nodes.filter((n) => n.parent === subgraph.id)
112
+ const childNodeIds = new Set(childNodes.map((n) => n.id))
113
+
114
+ // Get internal links (both endpoints in subgraph)
115
+ const childLinks = rootGraph.links.filter((l) => {
116
+ const fromNode = typeof l.from === 'string' ? l.from : l.from.node
117
+ const toNode = typeof l.to === 'string' ? l.to : l.to.node
118
+ return childNodeIds.has(fromNode) && childNodeIds.has(toNode)
119
+ })
120
+
121
+ // Generate export connectors for boundary connections
122
+ const { exportNodes, exportLinks } = generateExportConnectors(
123
+ rootGraph,
124
+ subgraph.id,
125
+ childNodeIds,
126
+ )
127
+
128
+ // Build child graph
129
+ const childGraph: NetworkGraph = {
130
+ ...rootGraph,
131
+ name: subgraph.label,
132
+ nodes: [...childNodes.map((n) => ({ ...n, parent: undefined })), ...exportNodes],
133
+ links: [...childLinks, ...exportLinks],
134
+ subgraphs: undefined,
135
+ }
136
+
137
+ // Layout child sheet
138
+ const childLayout = await layoutEngine.layoutAsync(childGraph)
139
+
140
+ return { graph: childGraph, layout: childLayout }
141
+ }
142
+
143
+ function generateExportConnectors(
144
+ rootGraph: NetworkGraph,
145
+ subgraphId: string,
146
+ childNodeIds: Set<string>,
147
+ ): { exportNodes: Node[]; exportLinks: Link[] } {
148
+ const exportNodes: Node[] = []
149
+ const exportLinks: Link[] = []
150
+ const exportPoints = new Map<string, ExportPoint>()
151
+
152
+ // Find boundary links
153
+ for (const link of rootGraph.links) {
154
+ const fromNode = typeof link.from === 'string' ? link.from : link.from.node
155
+ const toNode = typeof link.to === 'string' ? link.to : link.to.node
156
+ const fromPort = typeof link.from === 'object' ? link.from.port : undefined
157
+ const toPort = typeof link.to === 'object' ? link.to.port : undefined
158
+
159
+ const fromInside = childNodeIds.has(fromNode)
160
+ const toInside = childNodeIds.has(toNode)
161
+
162
+ if (fromInside && !toInside) {
163
+ // Outgoing connection
164
+ const key = `${subgraphId}:${fromNode}:${fromPort || ''}`
165
+ if (!exportPoints.has(key)) {
166
+ const destSubgraph = findNodeSubgraph(rootGraph, toNode)
167
+ exportPoints.set(key, {
168
+ subgraphId,
169
+ device: fromNode,
170
+ port: fromPort,
171
+ destSubgraphLabel: destSubgraph?.label || toNode,
172
+ destDevice: toNode,
173
+ destPort: toPort,
174
+ isSource: true,
175
+ })
176
+ }
177
+ } else if (!fromInside && toInside) {
178
+ // Incoming connection
179
+ const key = `${subgraphId}:${toNode}:${toPort || ''}`
180
+ if (!exportPoints.has(key)) {
181
+ const destSubgraph = findNodeSubgraph(rootGraph, fromNode)
182
+ exportPoints.set(key, {
183
+ subgraphId,
184
+ device: toNode,
185
+ port: toPort,
186
+ destSubgraphLabel: destSubgraph?.label || fromNode,
187
+ destDevice: fromNode,
188
+ destPort: fromPort,
189
+ isSource: false,
190
+ })
191
+ }
192
+ }
193
+ }
194
+
195
+ // Create export nodes and links
196
+ for (const [key, exportPoint] of exportPoints) {
197
+ const exportId = key.replace(/:/g, '_')
198
+
199
+ // Export node
200
+ exportNodes.push({
201
+ id: `${EXPORT_NODE_PREFIX}${exportId}`,
202
+ label: exportPoint.destSubgraphLabel,
203
+ shape: 'stadium',
204
+ metadata: {
205
+ _isExport: true,
206
+ _destSubgraph: exportPoint.destSubgraphLabel,
207
+ _destDevice: exportPoint.destDevice,
208
+ _destPort: exportPoint.destPort,
209
+ _isSource: exportPoint.isSource,
210
+ },
211
+ })
212
+
213
+ // Export link
214
+ const exportNodeId = `${EXPORT_NODE_PREFIX}${exportId}`
215
+ const deviceEndpoint = exportPoint.port
216
+ ? { node: exportPoint.device, port: exportPoint.port }
217
+ : exportPoint.device
218
+
219
+ exportLinks.push({
220
+ id: `${EXPORT_LINK_PREFIX}${exportId}`,
221
+ from: exportPoint.isSource ? deviceEndpoint : exportNodeId,
222
+ to: exportPoint.isSource ? exportNodeId : deviceEndpoint,
223
+ type: 'dashed',
224
+ arrow: 'forward',
225
+ metadata: {
226
+ _destSubgraphLabel: exportPoint.destSubgraphLabel,
227
+ _destDevice: exportPoint.destDevice,
228
+ _destPort: exportPoint.destPort,
229
+ },
230
+ })
231
+ }
232
+
233
+ return { exportNodes, exportLinks }
234
+ }
235
+
236
+ function findNodeSubgraph(graph: NetworkGraph, nodeId: string): Subgraph | undefined {
237
+ const node = graph.nodes.find((n) => n.id === nodeId)
238
+ if (!node?.parent) return undefined
239
+ return graph.subgraphs?.find((s) => s.id === node.parent)
240
+ }
package/src/index.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import { sampleNetwork } from './fixtures/index.js'
2
3
  import { darkTheme, HierarchicalLayout, modernTheme, version } from './index.js'
3
4
  import type { NetworkGraph } from './models/index.js'
4
5
 
@@ -63,4 +64,26 @@ describe('@shumoku/core', () => {
63
64
  expect(darkTheme.colors.background).toBeDefined()
64
65
  })
65
66
  })
67
+
68
+ describe('fixtures', () => {
69
+ it('should export sampleNetwork', () => {
70
+ expect(sampleNetwork).toBeDefined()
71
+ expect(Array.isArray(sampleNetwork)).toBe(true)
72
+ expect(sampleNetwork.length).toBeGreaterThan(0)
73
+ })
74
+
75
+ it('should have main.yaml as first file', () => {
76
+ expect(sampleNetwork[0].name).toBe('main.yaml')
77
+ expect(sampleNetwork[0].content).toContain('Sample Network')
78
+ })
79
+
80
+ it('should have all required files', () => {
81
+ const fileNames = sampleNetwork.map((f) => f.name)
82
+ expect(fileNames).toContain('main.yaml')
83
+ expect(fileNames).toContain('cloud.yaml')
84
+ expect(fileNames).toContain('perimeter.yaml')
85
+ expect(fileNames).toContain('dmz.yaml')
86
+ expect(fileNames).toContain('campus.yaml')
87
+ })
88
+ })
66
89
  })
package/src/index.ts CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  // Constants
6
6
  export * from './constants.js'
7
+ // Fixtures
8
+ export * from './fixtures/index.js'
9
+ // Hierarchical
10
+ export * from './hierarchical.js'
7
11
  // Icons
8
12
  export * from './icons/index.js'
9
13
  // Layout