Skip to content

Commit a352079

Browse files
committed
feat(graph): add Mermaid click directives for node hyperlinks
Add click directives to Mermaid diagram output so nodes become clickable links. Two modes: - Embedded diagrams (json2md --diagram-links): links to node documentation sections using Markdown anchors - Standalone graph (graph --links): links to first external reference Opt-in via --diagram-links (json2md) or --links (graph command).
1 parent c45fd00 commit a352079

8 files changed

Lines changed: 150 additions & 10 deletions

File tree

src/cli/commands/graph.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import * as z from "zod";
33
import type { CommandDef } from "../define-command.js";
44
import { graphOp } from "../../operations/index.js";
5+
import { buildExternalRefClickMap } from "../../operations/graph-shared.js";
56
import { noArgs, readOpts, loadDoc } from "../shared.js";
67

78
const optsSchema = readOpts.extend({
@@ -40,6 +41,10 @@ const optsSchema = readOpts.extend({
4041
.boolean()
4142
.optional()
4243
.describe("Only show nodes that have relationships"),
44+
links: z
45+
.boolean()
46+
.optional()
47+
.describe("Add click hyperlinks from external references to Mermaid nodes"),
4348
});
4449

4550
export const graphCommand: CommandDef<typeof noArgs, typeof optsSchema> = {
@@ -60,9 +65,15 @@ export const graphCommand: CommandDef<typeof noArgs, typeof optsSchema> = {
6065
? opts.relTypes.split(",").map((s) => s.trim())
6166
: undefined;
6267

68+
const format = opts.format ?? "mermaid";
69+
const clickTargets =
70+
opts.links && format === "mermaid"
71+
? Object.fromEntries(buildExternalRefClickMap(doc.nodes))
72+
: undefined;
73+
6374
const output = graphOp({
6475
doc,
65-
format: opts.format ?? "mermaid",
76+
format,
6677
typeFilter: opts.type,
6778
nodeTypes,
6879
nodeIds,
@@ -71,6 +82,7 @@ export const graphCommand: CommandDef<typeof noArgs, typeof optsSchema> = {
7182
cluster: opts.cluster ?? true,
7283
labelMode: opts.labelMode ?? "friendly",
7384
connectedOnly: opts.connectedOnly ?? false,
85+
clickTargets,
7486
});
7587
console.log(output);
7688
} catch (err: unknown) {

src/cli/commands/json2md.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const optsSchema = z
3939
.enum(["LR", "TD", "RL", "BT"])
4040
.optional()
4141
.describe("Override layout for dependency diagrams"),
42+
diagramLinks: z
43+
.boolean()
44+
.optional()
45+
.describe("Add click hyperlinks to Mermaid diagram nodes"),
4246
})
4347
.strict();
4448

@@ -80,6 +84,7 @@ export const json2mdCommand: CommandDef<z.ZodObject, typeof optsSchema> = {
8084
jsonToMarkdown(raw, outputPath, {
8185
form,
8286
embedDiagrams: opts.embedDiagrams,
87+
diagramLinks: opts.diagramLinks,
8388
// forward labelMode for embedded diagrams (default friendly)
8489
labelMode: opts.labelMode ?? "friendly",
8590
relationshipLayout: opts.relationshipLayout,

src/json-to-md.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ interface DiagramOptions {
526526
refinementLayout?: DiagramLayout;
527527
decisionLayout?: DiagramLayout;
528528
dependencyLayout?: DiagramLayout;
529+
clickTargets?: Record<string, string>;
529530
}
530531

531532
function generateDiagramsFile(
@@ -555,6 +556,7 @@ function generateDiagramsFile(
555556
labelMode: opts?.labelMode ?? "friendly",
556557
cluster: true,
557558
connectedOnly: false,
559+
clickTargets: opts?.clickTargets,
558560
}),
559561
);
560562
lines.push("```");
@@ -565,6 +567,7 @@ function generateDiagramsFile(
565567
format: "mermaid",
566568
layout: opts?.refinementLayout ?? "TD",
567569
labelMode: opts?.labelMode ?? "friendly",
570+
clickTargets: opts?.clickTargets,
568571
});
569572
if (refinement.includes("-->")) {
570573
lines.push("## Refinement Chain");
@@ -580,6 +583,7 @@ function generateDiagramsFile(
580583
format: "mermaid",
581584
layout: opts?.decisionLayout ?? "TD",
582585
labelMode: opts?.labelMode ?? "friendly",
586+
clickTargets: opts?.clickTargets,
583587
});
584588
if (decisions.includes("-->")) {
585589
lines.push("## Decision Map");
@@ -595,6 +599,7 @@ function generateDiagramsFile(
595599
format: "mermaid",
596600
layout: opts?.dependencyLayout ?? "LR",
597601
labelMode: opts?.labelMode ?? "friendly",
602+
clickTargets: opts?.clickTargets,
598603
});
599604
if (dependencies.includes("-->") || dependencies.includes("-.->")) {
600605
lines.push("## Dependency Graph");
@@ -612,10 +617,29 @@ function generateDiagramsFile(
612617
// Public API
613618
// ---------------------------------------------------------------------------
614619

620+
/** Build a click target map from node anchors for use in embedded diagrams. */
621+
function buildAnchorClickMap(
622+
nodes: Node[],
623+
nodeMap: NodeLocationMap,
624+
currentFile: string,
625+
): Record<string, string> {
626+
const targets: Record<string, string> = {};
627+
for (const node of nodes) {
628+
const loc = nodeMap.get(node.id);
629+
if (!loc) continue;
630+
targets[node.id] =
631+
loc.file === "" || loc.file === currentFile
632+
? `#${loc.anchor}`
633+
: `./${loc.file}#${loc.anchor}`;
634+
}
635+
return targets;
636+
}
637+
615638
/** Options for controlling JSON-to-Markdown conversion. */
616639
export interface ConvertOptions {
617640
form: "single-file" | "multi-doc";
618641
embedDiagrams?: boolean;
642+
diagramLinks?: boolean;
619643
labelMode?: "friendly" | "compact";
620644
relationshipLayout?: DiagramLayout;
621645
refinementLayout?: DiagramLayout;
@@ -625,6 +649,7 @@ export interface ConvertOptions {
625649

626650
interface MarkdownRenderOptions {
627651
embedDiagrams?: boolean;
652+
diagramLinks?: boolean;
628653
labelMode?: "friendly" | "compact";
629654
relationshipLayout?: DiagramLayout;
630655
refinementLayout?: DiagramLayout;
@@ -690,6 +715,9 @@ export function jsonToMarkdownSingle(
690715
doc.relationships &&
691716
doc.relationships.length > 0
692717
) {
718+
const clickTargets = options?.diagramLinks
719+
? buildAnchorClickMap(doc.nodes, nodeMap, "")
720+
: undefined;
693721
lines.push("## Diagrams");
694722
lines.push("");
695723
lines.push("### Relationship Graph");
@@ -703,6 +731,7 @@ export function jsonToMarkdownSingle(
703731
labelMode: options?.labelMode ?? "friendly",
704732
cluster: true,
705733
connectedOnly: false,
734+
clickTargets,
706735
}),
707736
);
708737
lines.push("```");
@@ -782,6 +811,9 @@ export function jsonToMarkdownMultiDoc(
782811
doc.relationships &&
783812
doc.relationships.length > 0
784813
) {
814+
const clickTargets = options?.diagramLinks
815+
? buildAnchorClickMap(doc.nodes, nodeMap, "DIAGRAMS.md")
816+
: undefined;
785817
writeFileSync(
786818
join(outDir, "DIAGRAMS.md"),
787819
generateDiagramsFile(doc, {
@@ -790,6 +822,7 @@ export function jsonToMarkdownMultiDoc(
790822
refinementLayout: options?.refinementLayout,
791823
decisionLayout: options?.decisionLayout,
792824
dependencyLayout: options?.dependencyLayout,
825+
clickTargets,
793826
}),
794827
);
795828
}
@@ -870,6 +903,7 @@ export function jsonToMarkdown(
870903
output,
871904
jsonToMarkdownSingle(doc, {
872905
embedDiagrams: options.embedDiagrams,
906+
diagramLinks: options.diagramLinks,
873907
labelMode: options.labelMode,
874908
relationshipLayout: options.relationshipLayout,
875909
refinementLayout: options.refinementLayout,
@@ -880,6 +914,7 @@ export function jsonToMarkdown(
880914
} else {
881915
jsonToMarkdownMultiDoc(doc, output, {
882916
embedDiagrams: options.embedDiagrams,
917+
diagramLinks: options.diagramLinks,
883918
labelMode: options.labelMode,
884919
relationshipLayout: options.relationshipLayout,
885920
refinementLayout: options.refinementLayout,

src/operations/graph-decision.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
mermaidClassForNode,
1010
dotNodeAttrsWithMode,
1111
renderRelationshipLabel,
12+
renderMermaidClickDirectives,
13+
type MermaidClickMap,
1214
} from "./graph-shared.js";
1315

1416
const DECISION_REL_TYPES = new Set([
@@ -49,6 +51,7 @@ function generateDecisionMermaid(
4951
nodes: Node[],
5052
rels: Relationship[],
5153
labelMode: "friendly" | "compact",
54+
clickMap?: MermaidClickMap,
5255
): string {
5356
const lines: string[] = [];
5457
lines.push("graph TD");
@@ -75,6 +78,8 @@ function generateDecisionMermaid(
7578
lines.push(` ${fromId} ${style}|${label}| ${toId}`);
7679
}
7780

81+
lines.push(...renderMermaidClickDirectives(nodes, clickMap));
82+
7883
return lines.join("\n");
7984
}
8085

@@ -122,14 +127,18 @@ export const graphDecisionOp = defineOperation({
122127
seedIds: z.array(z.string()).optional(),
123128
layout: z.enum(["LR", "TD", "RL", "BT"]).default("TD"),
124129
labelMode: z.enum(["friendly", "compact"]).default("friendly"),
130+
clickTargets: z.record(z.string(), z.string()).optional(),
125131
}),
126132
output: z.string(),
127-
fn({ doc, format, seedIds, layout, labelMode }) {
133+
fn({ doc, format, seedIds, layout, labelMode, clickTargets }) {
128134
const { nodes, rels } = collectDecisionMap(doc, seedIds);
129135
if (format === "dot") {
130136
return generateDecisionDot(nodes, rels, layout, labelMode);
131137
}
132-
const mermaid = generateDecisionMermaid(nodes, rels, labelMode);
138+
const clickMap = clickTargets
139+
? new Map(Object.entries(clickTargets))
140+
: undefined;
141+
const mermaid = generateDecisionMermaid(nodes, rels, labelMode, clickMap);
133142
const mermaidLines = mermaid.split("\n");
134143
mermaidLines[0] = `graph ${layout}`;
135144
return mermaidLines.join("\n");

src/operations/graph-dependency.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
mermaidClassForNode,
1010
dotNodeAttrsWithMode,
1111
renderRelationshipLabel,
12+
renderMermaidClickDirectives,
13+
type MermaidClickMap,
1214
} from "./graph-shared.js";
1315

1416
const DEPENDENCY_REL_TYPES = new Set([
@@ -57,6 +59,7 @@ function generateDependencyMermaid(
5759
nodes: Node[],
5860
rels: Relationship[],
5961
labelMode: "friendly" | "compact",
62+
clickMap?: MermaidClickMap,
6063
): string {
6164
const lines: string[] = [];
6265
lines.push("graph LR");
@@ -82,6 +85,8 @@ function generateDependencyMermaid(
8285
lines.push(` ${fromId} -->|${label}| ${toId}`);
8386
}
8487

88+
lines.push(...renderMermaidClickDirectives(nodes, clickMap));
89+
8590
return lines.join("\n");
8691
}
8792

@@ -127,14 +132,18 @@ export const graphDependencyOp = defineOperation({
127132
seedIds: z.array(z.string()).optional(),
128133
layout: z.enum(["LR", "TD", "RL", "BT"]).default("LR"),
129134
labelMode: z.enum(["friendly", "compact"]).default("friendly"),
135+
clickTargets: z.record(z.string(), z.string()).optional(),
130136
}),
131137
output: z.string(),
132-
fn({ doc, format, seedIds, layout, labelMode }) {
138+
fn({ doc, format, seedIds, layout, labelMode, clickTargets }) {
133139
const { nodes, rels } = collectDependencyGraph(doc, seedIds);
134140
if (format === "dot") {
135141
return generateDependencyDot(nodes, rels, layout, labelMode);
136142
}
137-
const mermaid = generateDependencyMermaid(nodes, rels, labelMode);
143+
const clickMap = clickTargets
144+
? new Map(Object.entries(clickTargets))
145+
: undefined;
146+
const mermaid = generateDependencyMermaid(nodes, rels, labelMode, clickMap);
138147
const mermaidLines = mermaid.split("\n");
139148
mermaidLines[0] = `graph ${layout}`;
140149
return mermaidLines.join("\n");

src/operations/graph-refinement.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
mermaidClassForNode,
1010
dotNodeAttrsWithMode,
1111
renderRelationshipLabel,
12+
renderMermaidClickDirectives,
13+
type MermaidClickMap,
1214
} from "./graph-shared.js";
1315

1416
const REFINEMENT_REL_TYPES = new Set(["refines", "realises", "implements"]);
@@ -51,6 +53,7 @@ function generateRefinementMermaid(
5153
nodes: Node[],
5254
rels: Relationship[],
5355
labelMode: "friendly" | "compact",
56+
clickMap?: MermaidClickMap,
5457
): string {
5558
const lines: string[] = [];
5659
lines.push("graph TD");
@@ -76,6 +79,8 @@ function generateRefinementMermaid(
7679
lines.push(` ${fromId} -->|${label}| ${toId}`);
7780
}
7881

82+
lines.push(...renderMermaidClickDirectives(nodes, clickMap));
83+
7984
return lines.join("\n");
8085
}
8186

@@ -121,14 +126,18 @@ export const graphRefinementOp = defineOperation({
121126
seedIds: z.array(z.string()).optional(),
122127
layout: z.enum(["LR", "TD", "RL", "BT"]).default("TD"),
123128
labelMode: z.enum(["friendly", "compact"]).default("friendly"),
129+
clickTargets: z.record(z.string(), z.string()).optional(),
124130
}),
125131
output: z.string(),
126-
fn({ doc, format, seedIds, layout, labelMode }) {
132+
fn({ doc, format, seedIds, layout, labelMode, clickTargets }) {
127133
const { nodes, rels } = collectRefinementChain(doc, seedIds);
128134
if (format === "dot") {
129135
return generateRefinementDot(nodes, rels, layout, labelMode);
130136
}
131-
const mermaid = generateRefinementMermaid(nodes, rels, labelMode);
137+
const clickMap = clickTargets
138+
? new Map(Object.entries(clickTargets))
139+
: undefined;
140+
const mermaid = generateRefinementMermaid(nodes, rels, labelMode, clickMap);
132141
const mermaidLines = mermaid.split("\n");
133142
mermaidLines[0] = `graph ${layout}`;
134143
return mermaidLines.join("\n");

src/operations/graph-shared.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,45 @@ export function applyConnectedOnly(
357357
}
358358
return nodes.filter((n) => connectedIds.has(n.id));
359359
}
360+
361+
// ---------------------------------------------------------------------------
362+
// Mermaid click directives (hyperlinks on nodes)
363+
// ---------------------------------------------------------------------------
364+
365+
/** Map from raw node ID to click target URL. */
366+
export type MermaidClickMap = Map<string, string>;
367+
368+
/**
369+
* Render Mermaid click directives for nodes that have a click target.
370+
* @param nodes - Nodes to render click directives for
371+
* @param clickMap - Map from node ID to target URL
372+
* @returns Lines like `click INT1 "url" "tooltip"`
373+
*/
374+
export function renderMermaidClickDirectives(
375+
nodes: Node[],
376+
clickMap: MermaidClickMap | undefined,
377+
): string[] {
378+
if (!clickMap || clickMap.size === 0) return [];
379+
const lines: string[] = [""];
380+
for (const node of nodes) {
381+
const url = clickMap.get(node.id);
382+
if (!url) continue;
383+
const safeId = sanitiseMermaidId(node.id);
384+
lines.push(` click ${safeId} "${url}" "${node.name}"`);
385+
}
386+
return lines;
387+
}
388+
389+
/**
390+
* Build a MermaidClickMap from nodes' external_references.
391+
* Uses the first external reference's identifier as the URL.
392+
* @param nodes - Nodes to extract external references from
393+
*/
394+
export function buildExternalRefClickMap(nodes: Node[]): MermaidClickMap {
395+
const map: MermaidClickMap = new Map();
396+
for (const node of nodes) {
397+
const ref = node.external_references?.[0];
398+
if (ref) map.set(node.id, ref.identifier);
399+
}
400+
return map;
401+
}

0 commit comments

Comments
 (0)