@@ -35,6 +35,75 @@ function renderFrontMatter(fields: Record<string, unknown>): string {
3535 return lines . join ( "\n" ) ;
3636}
3737
38+ // ---------------------------------------------------------------------------
39+ // Node location map (for hyperlinking)
40+ // ---------------------------------------------------------------------------
41+
42+ /** GitHub-compatible heading anchor slug. */
43+ function slugify ( text : string ) : string {
44+ return text
45+ . toLowerCase ( )
46+ . replace ( / [ ^ \w \s - ] / g, "" )
47+ . replace ( / \s / g, "-" ) ;
48+ }
49+
50+ /** Heading anchor for a node: `id--name` slugified from `### ID — Name`. */
51+ function nodeAnchor ( n : Node ) : string {
52+ return slugify ( `${ n . id } — ${ n . name } ` ) ;
53+ }
54+
55+ interface NodeLocation {
56+ file : string ;
57+ anchor : string ;
58+ }
59+
60+ type NodeLocationMap = Map < string , NodeLocation > ;
61+
62+ /** Build a map from node ID to its markdown file and heading anchor. */
63+ function buildNodeLocationMap (
64+ nodes : Node [ ] ,
65+ mode : "single-file" | "multi-doc" ,
66+ ) : NodeLocationMap {
67+ const map : NodeLocationMap = new Map ( ) ;
68+ for ( const n of nodes ) {
69+ const anchor = nodeAnchor ( n ) ;
70+ if ( mode === "single-file" ) {
71+ map . set ( n . id , { file : "" , anchor } ) ;
72+ } else {
73+ const file = fileForNodeType ( n . type ) ;
74+ map . set ( n . id , { file, anchor } ) ;
75+ }
76+ }
77+ return map ;
78+ }
79+
80+ /** Determine the markdown file a node type belongs to in multi-doc mode. */
81+ function fileForNodeType ( type : string ) : string {
82+ for ( const [ fileName , types ] of Object . entries ( NODE_FILE_MAP ) ) {
83+ if ( types . includes ( type ) ) return `${ fileName } .md` ;
84+ }
85+ return "README.md" ;
86+ }
87+
88+ /**
89+ * Format a node ID as a markdown hyperlink.
90+ * In single-file mode: `[ID](#anchor)`
91+ * In multi-doc mode: `[ID](./FILE.md#anchor)`
92+ * Falls back to plain ID if the node isn't in the map.
93+ */
94+ function linkNodeId (
95+ id : string ,
96+ nodeMap : NodeLocationMap ,
97+ currentFile ?: string ,
98+ ) : string {
99+ const loc = nodeMap . get ( id ) ;
100+ if ( ! loc ) return id ;
101+ if ( loc . file === "" || loc . file === currentFile ) {
102+ return `[${ id } ](#${ loc . anchor } )` ;
103+ }
104+ return `[${ id } ](./${ loc . file } #${ loc . anchor } )` ;
105+ }
106+
38107// ---------------------------------------------------------------------------
39108// Relationship lookups
40109// ---------------------------------------------------------------------------
@@ -99,7 +168,12 @@ function renderLifecycle(
99168 } ) ;
100169}
101170
102- function renderNodeRelationships ( nodeId : string , fromIdx : RelIndex ) : string [ ] {
171+ function renderNodeRelationships (
172+ nodeId : string ,
173+ fromIdx : RelIndex ,
174+ nodeMap : NodeLocationMap ,
175+ currentFile ?: string ,
176+ ) : string [ ] {
103177 const rels = fromIdx . get ( nodeId ) ;
104178 if ( ! rels || rels . length === 0 ) return [ ] ;
105179
@@ -116,11 +190,11 @@ function renderNodeRelationships(nodeId: string, fromIdx: RelIndex): string[] {
116190 ? RELATIONSHIP_TYPE_LABELS [ type ]
117191 : type ;
118192 if ( targets . length === 1 ) {
119- lines . push ( `- ${ label } : ${ targets [ 0 ] } ` ) ;
193+ lines . push ( `- ${ label } : ${ linkNodeId ( targets [ 0 ] , nodeMap , currentFile ) } ` ) ;
120194 } else {
121195 lines . push ( `- ${ label } :` ) ;
122196 for ( const t of targets ) {
123- lines . push ( ` - ${ t } ` ) ;
197+ lines . push ( ` - ${ linkNodeId ( t , nodeMap , currentFile ) } ` ) ;
124198 }
125199 }
126200 }
@@ -146,6 +220,8 @@ function renderNode(
146220 n : Node ,
147221 headingLevel : number ,
148222 fromIdx : RelIndex ,
223+ nodeMap : NodeLocationMap ,
224+ currentFile ?: string ,
149225) : string [ ] {
150226 const prefix = "#" . repeat ( headingLevel ) ;
151227 const lines : string [ ] = [ ] ;
@@ -158,7 +234,7 @@ function renderNode(
158234 lines . push ( "" ) ;
159235 }
160236
161- const rels = renderNodeRelationships ( n . id , fromIdx ) ;
237+ const rels = renderNodeRelationships ( n . id , fromIdx , nodeMap , currentFile ) ;
162238 if ( rels . length > 0 ) {
163239 lines . push ( ...rels ) ;
164240 lines . push ( "" ) ;
@@ -237,7 +313,7 @@ function renderNode(
237313 if ( n . includes && n . includes . length > 0 ) {
238314 lines . push ( "Includes:" ) ;
239315 for ( const inc of n . includes ) {
240- lines . push ( `- ${ inc } ` ) ;
316+ lines . push ( `- ${ linkNodeId ( inc , nodeMap , currentFile ) } ` ) ;
241317 }
242318 lines . push ( "" ) ;
243319 }
@@ -266,8 +342,9 @@ function renderNode(
266342 const subNodes = n . subsystem . nodes ;
267343 const subRels = n . subsystem . relationships ?? [ ] ;
268344 const subIdx = indexRelationshipsFrom ( subRels ) ;
345+ const subMap = buildNodeLocationMap ( subNodes , "single-file" ) ;
269346 for ( const sub of subNodes ) {
270- lines . push ( ...renderNode ( sub , headingLevel + 2 , subIdx ) ) ;
347+ lines . push ( ...renderNode ( sub , headingLevel + 2 , subIdx , subMap ) ) ;
271348 }
272349 }
273350
@@ -283,6 +360,8 @@ function renderNodesGrouped(
283360 types : string [ ] ,
284361 fromIdx : RelIndex ,
285362 headingLevel : number ,
363+ nodeMap : NodeLocationMap ,
364+ currentFile ?: string ,
286365) : string [ ] {
287366 const lines : string [ ] = [ ] ;
288367 for ( const type of types ) {
@@ -294,13 +373,13 @@ function renderNodesGrouped(
294373 lines . push ( "" ) ;
295374
296375 for ( const n of matching ) {
297- lines . push ( ...renderNode ( n , headingLevel + 1 , fromIdx ) ) ;
376+ lines . push ( ...renderNode ( n , headingLevel + 1 , fromIdx , nodeMap , currentFile ) ) ;
298377 }
299378 }
300379 return lines ;
301380}
302381
303- function generateReadme ( doc : SysProMDocument , fromIdx : RelIndex ) : string {
382+ function generateReadme ( doc : SysProMDocument , fromIdx : RelIndex , nodeMap : NodeLocationMap ) : string {
304383 const lines : string [ ] = [ ] ;
305384 const title = doc . metadata ?. title ?? "SysProM" ;
306385
@@ -381,7 +460,7 @@ function generateReadme(doc: SysProMDocument, fromIdx: RelIndex): string {
381460 // Views
382461 const views = doc . nodes . filter ( ( n ) => n . type === "view" ) ;
383462 if ( views . length > 0 ) {
384- lines . push ( ...renderNodesGrouped ( doc . nodes , [ "view" ] , fromIdx , 2 ) ) ;
463+ lines . push ( ...renderNodesGrouped ( doc . nodes , [ "view" ] , fromIdx , 2 , nodeMap , "README.md" ) ) ;
385464 }
386465
387466 // Graph-level external references
@@ -405,6 +484,7 @@ function generateDocFile(
405484 fileName : string ,
406485 types : string [ ] ,
407486 fromIdx : RelIndex ,
487+ nodeMap : NodeLocationMap ,
408488) : string {
409489 const lines : string [ ] = [ ] ;
410490
@@ -417,7 +497,7 @@ function generateDocFile(
417497 lines . push ( "" ) ;
418498 lines . push ( `# ${ fileName . replace ( ".md" , "" ) } ` ) ;
419499 lines . push ( "" ) ;
420- lines . push ( ...renderNodesGrouped ( doc . nodes , types , fromIdx , 2 ) ) ;
500+ lines . push ( ...renderNodesGrouped ( doc . nodes , types , fromIdx , 2 , nodeMap , ` ${ fileName } .md` ) ) ;
421501
422502 return lines . join ( "\n" ) + "\n" ;
423503}
@@ -443,6 +523,7 @@ export interface ConvertOptions {
443523 */
444524export function jsonToMarkdownSingle ( doc : SysProMDocument ) : string {
445525 const fromIdx = indexRelationshipsFrom ( doc . relationships ?? [ ] ) ;
526+ const nodeMap = buildNodeLocationMap ( doc . nodes , "single-file" ) ;
446527 const lines : string [ ] = [ ] ;
447528 const title = doc . metadata ?. title ?? "SysProM" ;
448529
@@ -471,7 +552,7 @@ export function jsonToMarkdownSingle(doc: SysProMDocument): string {
471552 "version" ,
472553 ] ;
473554
474- lines . push ( ...renderNodesGrouped ( doc . nodes , allTypes , fromIdx , 2 ) ) ;
555+ lines . push ( ...renderNodesGrouped ( doc . nodes , allTypes , fromIdx , 2 , nodeMap ) ) ;
475556
476557 // Relationships summary
477558 if ( doc . relationships && doc . relationships . length > 0 ) {
@@ -516,15 +597,16 @@ export function jsonToMarkdownMultiDoc(
516597 mkdirSync ( outDir , { recursive : true } ) ;
517598
518599 const fromIdx = indexRelationshipsFrom ( doc . relationships ?? [ ] ) ;
600+ const nodeMap = buildNodeLocationMap ( doc . nodes , "multi-doc" ) ;
519601
520- writeFileSync ( join ( outDir , "README.md" ) , generateReadme ( doc , fromIdx ) ) ;
602+ writeFileSync ( join ( outDir , "README.md" ) , generateReadme ( doc , fromIdx , nodeMap ) ) ;
521603
522604 for ( const [ fileName , types ] of Object . entries ( NODE_FILE_MAP ) ) {
523605 const hasNodes = doc . nodes . some ( ( n ) => types . includes ( n . type ) ) ;
524606 if ( ! hasNodes ) continue ;
525607 writeFileSync (
526608 join ( outDir , `${ fileName } .md` ) ,
527- generateDocFile ( doc , fileName , types , fromIdx ) ,
609+ generateDocFile ( doc , fileName , types , fromIdx , nodeMap ) ,
528610 ) ;
529611 }
530612
0 commit comments