Skip to content

Commit 54dde87

Browse files
committed
feat(md): hyperlink node IDs in markdown output
Node IDs in relationships, view includes, and scope lists are now rendered as markdown links pointing to the target node's heading. Same-file references use anchor-only links; cross-file references use relative paths. The markdown parser strips links on import to preserve roundtrip fidelity.
1 parent dd28e63 commit 54dde87

2 files changed

Lines changed: 104 additions & 17 deletions

File tree

src/json-to-md.ts

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
444524
export 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

src/md-to-json.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
ExternalReferenceRole,
1818
} from "./schema.js";
1919

20+
/** Strip markdown link syntax `[text](url)` → `text`. */
21+
function stripMarkdownLink(s: string): string {
22+
return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
23+
}
24+
2025
const LABEL_TO_TYPE: Record<string, string> = Object.fromEntries(
2126
Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]),
2227
);
@@ -218,19 +223,19 @@ function parseListItems(body: string, prefix: string): string[] {
218223
collecting = true;
219224
const inline = line.slice(prefix.length + 1).trim();
220225
if (inline) {
221-
items.push(inline);
226+
items.push(stripMarkdownLink(inline));
222227
collecting = false;
223228
}
224229
continue;
225230
}
226231
if (collecting && line.startsWith(" - ")) {
227-
items.push(line.slice(4));
232+
items.push(stripMarkdownLink(line.slice(4)));
228233
} else if (
229234
collecting &&
230235
line.startsWith("- ") &&
231236
!isRelationshipLabel(line)
232237
) {
233-
items.push(line.slice(2));
238+
items.push(stripMarkdownLink(line.slice(2)));
234239
} else if (collecting) {
235240
collecting = false;
236241
}
@@ -268,7 +273,7 @@ function parseRelationshipsFromBody(
268273
if (items.length === 0) {
269274
const val = parseSingleValue(body, `- ${label}`);
270275
if (val) {
271-
rels.push({ from: nodeId, to: val, type: relType });
276+
rels.push({ from: nodeId, to: stripMarkdownLink(val), type: relType });
272277
}
273278
} else {
274279
for (const target of items) {

0 commit comments

Comments
 (0)