clickable_plantuml
Sphinx extension that makes PlantUML diagrams clickable by injecting hyperlinks into rendered SVG/PNG diagrams.
Concept: From .puml Source to Clickable Diagram
This extension is only the last of three stages in a pipeline that turns plain
.puml text files into cross-linked, clickable architecture diagrams. It does
not parse PlantUML itself — it only reads the small .idmap.json sidecar
files produced upstream and uses them to inject link directives before
sphinxcontrib-plantuml renders the image.
A PlantUML class diagram of the pipeline (steps plus their input/output
artifacts) shows the three stages summarized below. Its source is stored in
docs/assets/clickable_plantuml_pipeline.puml:
Parse & resolve —
puml_cli(inplantuml/parser/) is invoked once per.pumlfile by thearchitectural_design()Bazel rule. It preprocesses!includes, parses the diagram with apest-grammar parser (puml_parser), and resolves aliases/references into a fully-qualified model (puml_resolver). This is the only stage that actually understands PlantUML syntax.idmap generation —
puml_idmapwalks the resolved model and classifies each element as either a define (something this diagram elaborates, e.g. a component with children, or a class with members) or a reference (a leaf mention or relation endpoint that should link elsewhere). The result is written as one<file_stem>.idmap.jsonsidecar per diagram — see the Role detection algorithm and idmap Format sections below for the exact rules and shape.Sphinx consumption —
clickable_plantuml.py(this directory) never touches.pumlsource or FlatBuffers output. Atbuilder-initedit scans the source tree for every*.idmap.json, builds a global{alias|id → [definer source paths]}index, and atdoctree-resolveduses that index to rewrite each diagram’sumltext withurl of <alias> is [[<link>]]directives before handing it off tosphinxcontrib-plantuml’s own renderer, which does the actual PlantUML → SVG/PNG rendering.
Sphinx Integration
The extension hooks into the native Sphinx build lifecycle. URL computation
depends on the configured plantuml_output_format: in svg_obj mode the
rendered SVG lives in _images/, so links are made relative to that directory
(os.path.relpath(target_uri, imagedir)); for inline svg/png the link is
relative to the containing HTML page via
app.builder.get_relative_uri(from_docname, to_docname).
Sphinx build lifecycle clickable_plantuml hooks
═══════════════════════════════════ ═══════════════════════════════════════
builder-inited ───► on_builder_inited()
│ (one-time setup) Load all *.idmap.json files from
│ srcdir (recursive).
│ Build definition index:
│ {alias|id → [definer source paths]}.
│
├─ READ PHASE ──────────────────────────────────────────────────────────────
│ for each document:
│ env-purge-doc ───► on_env_purge_doc()
│ │ (incremental rebuild) Remove stale puml→docname entries
│ │ for the document being re-read.
│ │
│ parse RST → doctree
│ │
│ doctree-read ───► on_doctree_read()
│ (per document) Traverse the parsed doctree.
│ For every plantuml node, record
│ {normalized_source_path → docname}
│ in app.env (path identity, not basename).
│
│ env-merge-info ───► on_env_merge_info()
│ (parallel builds only) Merge puml→docname maps gathered
│ by worker sub-processes into the
│ main environment.
│
├─ WRITE PHASE ─────────────────────────────────────────────────────────────
│ for each document:
│ post-transform / resolve
│ │
│ doctree-resolved ───► on_doctree_resolved()
│ (per document) For each plantuml node, load its idmap.
│ For each reference entry, look up the
│ definition index (FQN first, then alias).
│ Apply proximity tiebreak on ambiguity.
│ Build the URL (relative to _images/ in
│ svg_obj mode, else page-relative via
│ get_relative_uri), then append
│ url of <alias> is [[url]] directives to
│ node['uml'] before rendering.
│
build-finished
How It Works
idmap discovery (
builder-inited) – Scans for*.idmap.jsonfiles in the Sphinx source directory. Each sidecar records defines (elements elaborated in that diagram, i.e. with children/members) and references (leaf mentions and relation endpoints). A global definition index maps each alias/FQN to the set of diagrams that elaborate it.Diagram location mapping (
doctree-read) – Records whichdocnamecontains which.pumldiagram, keyed by the canonical workspace-relative path. A node’s identity is recovered by normalising its absolute path (srcdir+ the node’sincdir+filename) and deriving exactly one canonical key using the precomputed workspace offset frombuilder-inited. Matching is exact canonical-key equality after anos.path.realpathprefix strip; a safe unique-suffix fallback additionally covers symlinked/staged layouts where the prefix strip doesn’t line up (only used when exactly one known source key matches — never a best-effort guess among several same-basename candidates), so same-basename diagrams in different packages remain distinct.URL resolution & link injection (
doctree-resolved) – For each reference in a diagram’s idmap, resolves the unique definer via the index. When multiple diagrams define the same element, a proximity tiebreak selects the definer sharing the longest common path prefix with the source diagram. On a genuine tie, no link is emitted (safe over wrong). URLs are built relative to_images/insvg_objmode (else page-relative viaapp.builder.get_relative_uri()) and percent-encoded before injection.Incremental / parallel support –
env-purge-docremoves stale entries when a document is re-read;env-merge-infomerges state from parallel worker processes.
Automatic idmap Generation (Bazel)
.idmap.json sidecars are produced by the architectural_design() rule.
The rule passes --source-name <puml_file.short_path> and
--idmap-output-dir to puml_cli for every .puml file. The
--source-name argument must be the srcdir-relative workspace path to
satisfy the canonical-key invariant. Passing short_path (the workspace
root-relative path for Bazel sources) ensures this requirement is met. The
resulting idmap source field is a stable, unique, workspace-relative path
(e.g. score/mw/com/proxy_detail.puml), which becomes the diagram’s identity
key throughout the extension. Duplicate source values across idmaps raise
an error; duplicate basenames (file stems) within one target also raise an
error.
Role detection algorithm
Given the resolved model of one .puml diagram:
defines – An element is a define when any of the following hold:
At least one other element lists it as its
parent_id(component diagrams).It has member variables or methods (class diagrams).
The diagram’s
@startuml <name>matches its alias or display name (component and class diagrams).It is a
$TopEventnode — the tree root whoseconnectionisNone, never used as a relation source (FTA diagrams).
references – Elements that link away to another diagram:
Top-level leaf boxes and relation endpoints (component diagrams).
All participants (sequence diagrams — no defines in sequence).
$TransferInGatenodes whose alias is a TRLC-style FQN (Package.Record) referencing another diagram’s top event (FTA diagrams).Internal FTA nodes (
$BasicEvent,$IntermediateEvent,$AndGate,$OrGate) are omitted — they do not cross-link to other diagrams.
Concrete example
' overview.puml — top-level leaves are REFERENCES
@startuml
[Gateway] --> [Proxy]
@enduml
' proxy_detail.puml — Proxy has a child → DEFINE
@startuml
package Proxy { [RequestHandler] }
@enduml
proxy_detail.idmap.json:
{ "source": "score/mw/com/proxy_detail.puml",
"defines": [{ "alias": "Proxy", "id": "Proxy" }],
"references": [{ "alias": "RequestHandler", "id": "Proxy.RequestHandler" }] }
overview.idmap.json:
{ "source": "score/overview.puml",
"defines": [],
"references": [{ "alias": "Gateway", "id": "Gateway" },
{ "alias": "Proxy", "id": "Proxy" }] }
Result: Proxy in overview.puml links to proxy_detail.puml.
Gateway has no definer → no link.
idmap Format
.idmap.json files are written by the parser and read by this extension.
They are not intended to be authored manually.
{
"source": "path/to/diagram.puml",
"defines": [
{ "alias": "ComponentName", "id": "fully.qualified.Name" }
],
"references": [
{ "alias": "OtherComponent", "id": "OtherComponent" }
]
}
End-to-End Clickable Diagram Example
Rather than duplicating a hand-written, untested example here, this exact
scenario is built and regression-tested as part of rules_score’s own test
suite:
overview.pumlreferencesProxy(a top-level leaf, per the Role detection algorithm above).proxy_detail.pumldefinesProxyas apackagewith a nested child, making it a define.
Both files are wired into a real architectural_design() +
dependable_element() target
(clickable_example_lib in bazel/rules/rules_score/test/BUILD),
so every change to the parser, puml_idmap, or this extension is checked
against genuinely Bazel-built .idmap.json artifacts — not just prose. The
clickable_example_link_rendered_test
sh_test asserts that:
overview.idmap.jsonreferencesProxyproxy_detail.idmap.jsondefinesProxy
i.e. that clickable_plantuml has exactly what it needs to make the rendered
Proxy element in overview.puml clickable, linking to the page containing
proxy_detail.puml. Run it with:
bazel test //bazel/rules/rules_score/test:clickable_example_link_rendered_test
The same pattern covers the other diagram-type scenarios from the Role detection algorithm above:
Cross-diagram-type interface linking —
interface_overview.puml(a component diagram) referencesInternalInterfacevia a unit’s-(binding, andinterface_detail.puml(auto-detected as a class diagram because the interface has a method) defines it. Both share the FQNpackage_a.InternalInterface, since ids are rooted at the enclosingpackage_apackage rather than the@startumlname. Seeinterface_example_libandinterface_example_link_rendered_testinbazel/rules/rules_score/test/BUILD.Pure class-diagram linking —
class_overview.pumlreferencesAuditTrailas a bare, member-less class, andclass_detail.pumldefines it (it has a method, making it the elaboration site). Seeclass_example_libandclass_example_link_rendered_testin the sameBUILDfile.
The following three scenarios each cover a full, real-world “chain” from the Role detection algorithm — a reference in one architectural view and the matching definition in another — and are wired up the same way:
Interface in the static architecture links to the public API —
public_api_overview.puml(a component diagram,staticattribute) shows a unit bound toPublicInterfacevia a-(port, andpublic_api_detail.puml(passed via architectural_design’spublic_apiattribute, which also feeds FMEA/safety-analysis traceability) defines it. Both share the FQNpackage_pub.PublicInterface. Seepublic_api_example_libandpublic_api_example_link_rendered_testinbazel/rules/rules_score/test/BUILD.Static architecture unit links to its class diagram —
unit_overview.puml(a component diagram) showsunit_oneas a leaf unit (no children, so a reference), andunit_class_detail.puml(a class diagram naming a class after the same unit, nested in the matching namespace) defines it. Both share the FQNpackage_u.component_u.unit_one, since component-diagram ids and class-diagram namespace FQNs use the same dot-joined scheme. Seeunit_example_libandunit_example_link_rendered_testin the sameBUILDfile.Dynamic architecture participant links to its own class diagram, with a shared public API —
dynamic_overview.puml(a sequence diagram,dynamicattribute) shows participantsDispatcherandWorker— both references, since sequence diagrams have no defines.dynamic_component_detail.puml(a component diagram,staticattribute) shows both as leaf units (also references, since neither has children) sharing aTaskInterfacebinding (so the diagrams also pass the architectural-design consistency validator). Each unit is elaborated by its own class diagram (dynamic_dispatcher_class.puml,dynamic_worker_class.puml, also instatic), which is where the click ultimately lands.TaskInterfaceitself is only ever a reference in the component diagram; its definition lives in the public API (dynamic_public_api_detail.puml, via thepublic_apiattribute), exactly like the public-API scenario above. Seedynamic_example_lib,dynamic_example_link_rendered_test,dynamic_dispatcher_class_link_rendered_test,dynamic_worker_class_link_rendered_testanddynamic_public_api_link_rendered_testin the sameBUILDfile.Limitation: clicking on individual messages in a sequence diagram (e.g.
Assign/Done) is not currently supported, for two independent reasons:sequence_model_to_idmap()inpuml_idmap/src/lib.rsonly extractscaller/callee(participant names) intoreferences. The resolvedSequenceTree(tools/metamodel/sequence/sequence_logic.rs) does carry the message text (Interaction.method,Return.return_content), but the idmap converter never reads those fields, so no reference is ever produced for a message label today.Even if (1) were fixed, this extension’s injection mechanism (
_inject_links_into_uml) only knows how to appendurl of <alias> is [[url]]directives before@enduml— a PlantUML feature that attaches a link to a declared, aliased element (participant/class/component), not to an arrow’s message text. Making a message clickable requires PlantUML’s other, unrelated hyperlink form — rewriting the message line itself, e.g.Dispatcher -> Worker : [[url Assign]]— which needs a different, source-location-aware rewriting path (to target the right occurrence when a label repeats) that doesn’t exist yet.