Generator Specification — Building an HTML Site from TriG¶
This document is a complete implementation guide for building a static site generator that reads Linked.Archi TriG output and produces browsable HTML pages with embedded SVG diagrams, metadata, element tables, and navigation.
The generator is notation-agnostic — it works with output from any converter (ArchiMate, BPMN, PlantUML, Structurizr, Backstage) without notation-specific code.
Architecture¶
flowchart LR
subgraph input["Input (from converters)"]
trig["model.trig\n(named-graph RDF)"]
svg["svg/\n(optional pre-rendered diagrams)"]
end
subgraph generator["Generator (rdf2docs)"]
load["Load TriG\n→ RDF4J / rdflib"]
query["SPARQL queries\n→ extract structure"]
render_svg["Render archvis:\n→ SVG (if no pre-rendered)"]
template["HTML templates\n→ generate pages"]
end
subgraph output["Output (static site)"]
index["index.html"]
diagrams["diagrams/{id}/index.html"]
elements["elements/{id}/index.html"]
assets["assets/style.css"]
end
trig --> load
svg --> template
load --> query
query --> template
query --> render_svg
render_svg --> template
template --> index
template --> diagrams
template --> elements
template --> assets
Data available in the TriG¶
The converters produce four named graphs. The generator reads all of them.
Do not compose a graph IRI to reach content. Match on the graph/semantic prefix, or better, follow
?g prov:wasDerivedFrom ?src from the provenance graph: where a model is built from several inputs the
semantic graph is split one per input, and the depth under graph/ is therefore not fixed.
Semantic graph — facts lifted from an input¶
# … or {base}{notation}/{modelId}/graph/semantic/{repo}/{path}, one per input
<{base}{notation}/{modelId}/graph/semantic> {
# ── Elements ──
<.../element/UserTask_1> a arch:Element, arch:ModelConcept, bpmn:UserTask ;
arch:inModel <.../bpmn/demo> ;
skos:prefLabel "Validate Order"@en ;
skos:notation "UserTask_1" ;
skos:definition "Validates the incoming order against business rules." .
# ── Relationships ──
<.../relationship/Flow_1> a arch:QualifiedRelationship, arch:ModelConcept, bpmn:SequenceFlow ;
arch:inModel <.../bpmn/demo> ;
arch:source <.../element/Start_1> ;
arch:target <.../element/UserTask_1> ;
skos:notation "Flow_1" ;
skos:prefLabel "triggers"@en .
# ── Diagrams/Views ──
<.../view/order-process> a arch:View, arch:Diagram, arch:ModelConcept ;
arch:inModel <.../bpmn/demo> ;
skos:prefLabel "Order Process"@en ;
skos:notation "order-process" ;
schema:image <https://example.org/svg/order-process.svg> .
}
arch:inModel is how a generator groups concepts into models. It is in the same graph as the
concept, so it works whether or not the output kept its graph boundaries.
Model graph — the curated model¶
Everything here is curation rather than lifted content: the model resource, the folder tree and each concept's place in it. A generator building a navigation tree reads this graph.
<{base}{notation}/{modelId}/graph/model> {
<.../bpmn/demo> a arch:Model ;
arch:modelConformsToMetamodel <https://meta.linked.archi/bpmn/metamodel#Bpmn202> .
<.../bpmn/demo/folder> a arch:Folder ;
schema:name "demo" ;
schema:itemListElement [
a schema:ListItem ; schema:position 1 ;
schema:item <.../bpmn/demo/folder/Elements>
] .
<.../bpmn/demo/folder/Elements> a arch:Folder ;
schema:name "Elements" ;
dct:isPartOf <.../bpmn/demo/folder> ;
schema:itemListElement [
a schema:ListItem ; schema:position 1 ;
schema:item <.../element/UserTask_1>
] .
# both directions of the containment, in the graph that defines the folders
<.../element/UserTask_1> dct:isPartOf <.../bpmn/demo/folder/Elements> .
}
Views graph — diagram geometry¶
<{base}{notation}/{modelId}/graph/views> {
<.../view/order-process/node/n1> a archvis:ArchNode ;
archvis:view <.../view/order-process> ;
archvis:archElement <.../element/UserTask_1> ;
archvis:bounds-x "270"^^xsd:double ;
archvis:bounds-y "190"^^xsd:double ;
archvis:bounds-w "100"^^xsd:double ;
archvis:bounds-h "56"^^xsd:double .
<.../view/order-process/link/l1> a archvis:Link ;
archvis:view <.../view/order-process> ;
archvis:archRelationship <.../relationship/Flow_1> ;
archvis:source <.../view/order-process/node/n0> ;
archvis:target <.../view/order-process/node/n1> ;
archvis:points ( [ archvis:point-x 216 ; archvis:point-y 218 ]
[ archvis:point-x 270 ; archvis:point-y 218 ] ) .
}
Provenance graph — metadata¶
<{base}{notation}/{modelId}/graph/provenance> {
<.../bpmn/demo>
dct:source "order-fulfillment.bpmn" ; # the input, as a filename here: this run named no forge
dct:title "Order Fulfillment Process" ; # from diagram-index.yaml
dct:creator "Commerce Team" ; # author
owl:versionInfo "1.0" ; # version
dct:created "2024-03-15" ; # original creation date
dct:modified "2025-11-20" ; # last modification
dct:description "End-to-end flow." . # description
# How the graph was made — PROV. Dublin Core above describes the *diagram*; PROV describes the
# *run*. That is why the conversion time is not a second dct:created, and the converter is not a
# second dct:creator. See ADR 0008.
#
# These nodes live under {base}provenance/, not under the model: one file at one commit is one
# node and one run is one activity, shared across every model the run converted.
<{base}provenance/run/9c2f1e04> a prov:Activity ;
prov:startedAtTime "2026-06-28T12:07:57Z"^^xsd:dateTime ;
prov:endedAtTime "2026-06-28T12:07:59Z"^^xsd:dateTime ;
prov:wasAssociatedWith <{base}provenance/agent/7d1c40ba> ;
prov:used <{base}provenance/source/group-models/8c44d1a4e9f0/models-bpmn-order-fulfillment-bpmn> .
<{base}provenance/agent/7d1c40ba> a prov:SoftwareAgent, schema:SoftwareApplication ;
schema:name "bpmn2linkedarchi" ; # which converter of the image ran
dct:identifier "registry.gitlab.com/…/converters@sha256:89ab…" .
<{base}provenance/source/group-models/8c44d1a4e9f0/models-bpmn-order-fulfillment-bpmn>
a prov:Entity ;
schema:name "models/bpmn/order-fulfillment.bpmn" ; # path inside the repository
dct:isPartOf <https://git.example.org/group/models> ; # which repository
dct:identifier "8c44d1a4e9f0112233445566778899aabbccddee" .
<.../bpmn/demo>
prov:wasGeneratedBy <{base}provenance/run/9c2f1e04> ;
prov:wasDerivedFrom <{base}provenance/source/group-models/8c44d1a4e9f0/models-bpmn-order-fulfillment-bpmn> .
# Each graph the run wrote, and what it was lifted from. This is how a generator finds a graph:
# by asking which input it holds, rather than by composing its IRI.
<.../bpmn/demo/graph/semantic> a prov:Bundle ;
prov:wasGeneratedBy <{base}provenance/run/9c2f1e04> ;
prov:generatedAtTime "2026-06-28T12:07:59Z"^^xsd:dateTime ;
prov:wasDerivedFrom <{base}provenance/source/group-models/8c44d1a4e9f0/models-bpmn-order-fulfillment-bpmn> .
<.../bpmn/demo/graph/views> a prov:Bundle ; … .
# the curated graph's input is the index, not the diagram
<.../bpmn/demo/graph/model> a prov:Bundle ;
prov:wasGeneratedBy <{base}provenance/run/9c2f1e04> ;
prov:wasDerivedFrom <{base}provenance/source/group-models/8c44d1a4e9f0/models-bpmn-diagram-index-yaml> .
}
Reassembling a model
?concept arch:inModel ?model is how a generator groups concepts, and it works in every output
format. Do not derive membership from the IRI path or from the named graph: the graph boundary
disappears under Turtle, RDF/XML and N-Triples, and ADR 0008 §3 declares the IRIs opaque.
Identifying a source file
Four facts, not one: the repository (dct:isPartOf), the path inside it (schema:name), the
revision (dct:identifier) and, where the reader recorded one, the content digest
(schema:sha256). A path identifies a file only relative to a repository, so a generator that
reads only schema:name cannot tell two same-named files apart.
Enumerate source files as ?s a prov:Entity ; schema:name ?path. The type alone over-matches: the
upstream blob a source is prov:alternateOf is also a prov:Entity, and it carries nothing but its
type. The repository and the commit author carry schema:name but are not entities, and a graph is
typed prov:Bundle only.
The source entity has no dct:source. It is the file, not something derived from a file — see
ADR 0008 §5. For a link to
the file on the forge read prov:alternateOf. dct:source is on the view or model, where it
names the input the output came from: the blob IRI where the run could build one, the bare filename
otherwise, so bind it with FILTER(isIRI(?o)) if a generator needs the link form.
Finding which input produced a fact
Where a model was built from several inputs, each input's facts are in a graph of their own. So
SELECT ?path WHERE {
GRAPH ?g { <…/element/component/default/order-service> bs:lifecycleState ?state }
?g prov:wasDerivedFrom ?src . ?src schema:name ?path .
}
returns one row per input that asserted it. The per-concept prov:wasDerivedFrom answers the
weaker question — which inputs contributed to this resource — and cannot pair a value with a file
when two inputs disagree. Both need the store's default graph to be a union of the named graphs.
SPARQL queries for the generator¶
1. List all diagrams (for index page)¶
PREFIX arch: <https://meta.linked.archi/core#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX schema: <https://schema.org/>
PREFIX dct: <http://purl.org/dc/terms/>
SELECT ?diagram ?label ?notation ?image ?description ?modified
WHERE {
?diagram a arch:Diagram .
OPTIONAL { ?diagram skos:prefLabel ?label }
OPTIONAL { ?diagram skos:notation ?notation }
OPTIONAL { ?diagram schema:image ?image }
OPTIONAL { ?diagram skos:definition ?description }
OPTIONAL { ?diagram dct:modified ?modified }
}
ORDER BY ?label
2. Get elements for a specific diagram (for diagram page)¶
PREFIX archvis: <https://meta.linked.archi/core-vis#>
PREFIX arch: <https://meta.linked.archi/core#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?element ?label ?type ?x ?y ?w ?h
WHERE {
?node a archvis:ArchNode ;
archvis:view <{diagramIri}> ;
archvis:archElement ?element .
OPTIONAL { ?node archvis:bounds-x ?x }
OPTIONAL { ?node archvis:bounds-y ?y }
OPTIONAL { ?node archvis:bounds-w ?w }
OPTIONAL { ?node archvis:bounds-h ?h }
OPTIONAL { ?element skos:prefLabel ?label }
?element a ?type .
FILTER(?type != arch:Element)
}
3. Get relationships for a diagram¶
PREFIX archvis: <https://meta.linked.archi/core-vis#>
PREFIX arch: <https://meta.linked.archi/core#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?relationship ?label ?type ?sourceName ?targetName
WHERE {
?link a archvis:Link ;
archvis:view <{diagramIri}> ;
archvis:archRelationship ?relationship .
?relationship arch:source ?source ;
arch:target ?target .
?relationship a ?type .
FILTER(?type != arch:QualifiedRelationship)
OPTIONAL { ?relationship skos:prefLabel ?label }
OPTIONAL { ?source skos:prefLabel ?sourceName }
OPTIONAL { ?target skos:prefLabel ?targetName }
}
4. Get folder tree (for sidebar navigation)¶
PREFIX arch: <https://meta.linked.archi/core#>
PREFIX schema: <https://schema.org/>
PREFIX dct: <http://purl.org/dc/terms/>
SELECT ?folder ?name ?parent ?position ?childItem
WHERE {
?folder a arch:Folder ;
schema:name ?name .
OPTIONAL { ?folder dct:isPartOf ?parent }
OPTIONAL {
?folder schema:itemListElement ?listItem .
?listItem schema:position ?position ;
schema:item ?childItem .
}
}
ORDER BY ?folder ?position
5. Get provenance metadata (for page footer/header)¶
PREFIX dct: <http://purl.org/dc/terms/>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
SELECT ?model ?title ?author ?version ?created ?modified ?description ?source
WHERE {
?model dct:source ?source .
OPTIONAL { ?model dct:title ?title }
OPTIONAL { ?model dct:creator ?author }
OPTIONAL { ?model owl:versionInfo ?version }
OPTIONAL { ?model dct:created ?created }
OPTIONAL { ?model dct:modified ?modified }
OPTIONAL { ?model dct:description ?description }
}
Those are the author's own facts about the diagram. For "where did this come from and what built it", query the PROV side instead — a separate query because the two answer different questions and share no predicate:
PREFIX dct: <http://purl.org/dc/terms/>
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX schema: <https://schema.org/>
SELECT ?model ?repo ?path ?commit ?blob ?converter ?image ?ended
WHERE {
?model prov:wasGeneratedBy ?run ;
prov:wasDerivedFrom ?src .
?src schema:name ?path .
?run prov:wasAssociatedWith ?agent .
?agent schema:name ?converter .
OPTIONAL { ?run prov:endedAtTime ?ended }
OPTIONAL { ?agent dct:identifier ?image } # the image reference, when the run was pinned
OPTIONAL { ?src dct:isPartOf ?repo }
OPTIONAL { ?src dct:identifier ?commit }
OPTIONAL { ?src prov:alternateOf ?blob } # the file upstream, for a "view source" link
}
?blob is what a per-page "view source" link should use: it is commit-pinned, so it cannot drift, and
prov:alternateOf asserts it denotes the same document as ?src rather than merely relating to it.
Bind only the guaranteed patterns
Four things are always present when provenance is on: both derivations, the source's schema:name,
and the run's agent with its schema:name. Everything else is conditional and must be OPTIONAL:
--run-timestamp none omits the run times, a run that cannot identify its container image makes no
dct:identifier claim rather than a guessed one, and a conversion outside a known forge names no
repository and no blob. A generator that binds any of those directly gets zero rows rather than a
partial answer — and zero rows on a page footer reads as "no provenance" when the truth is
"no image reference".
SVG embedding strategy¶
Case 1: Pre-rendered SVG available (schema:image present)¶
The converter's render command produced an SVG and the emitter linked it:
The generator:
1. Resolves the schema:image URL
2. If it's a local file path → embed inline (<svg>...</svg>)
3. If it's an HTTP URL → use <img src="..."> or fetch and embed
Case 2: No pre-rendered SVG, but archvis: geometry exists¶
The TriG has archvis:ArchNode with bounds and archvis:Link with points.
The generator renders SVG from this data:
1. Query all nodes for the diagram → get bounds (x, y, w, h)
2. Query all links → get source/target nodes + bendpoints
3. Generate SVG:
- Rect for each node (positioned at bounds)
- Polyline for each link (following bendpoints)
- Text labels from skos:prefLabel
- Hyperlinks to element detail pages
<svg width="1200" height="400" xmlns="http://www.w3.org/2000/svg">
<!-- Node: UserTask_1 -->
<a href="elements/UserTask_1/">
<rect x="270" y="190" width="100" height="56" fill="#ddeeff" stroke="#333"/>
<text x="320" y="222" text-anchor="middle">Validate Order</text>
</a>
<!-- Link: Flow_1 -->
<polyline points="216,218 270,218" fill="none" stroke="#333"
marker-end="url(#arrow)"/>
</svg>
Case 3: No SVG data at all (e.g. Backstage, some ArchiMate views without geometry)¶
The generator shows a text-only view: element table, relationship table, metadata. No diagram is rendered.
Decision tree¶
flowchart TB
A{"schema:image\npresent?"}
B{"archvis:ArchNode\nwith bounds?"}
C["Embed pre-rendered SVG"]
D["Generate SVG from archvis: triples"]
E["Text-only view\n(element table + relationships)"]
A -->|Yes| C
A -->|No| B
B -->|Yes| D
B -->|No| E
HTML page structure¶
Index page (index.html)¶
<h1>Architecture Knowledge Graph</h1>
<p>Generated from {source files} on {date}</p>
<h2>Diagrams</h2>
<div class="diagram-grid">
<!-- For each arch:Diagram -->
<div class="diagram-card">
<a href="diagrams/order-process/">
<img src="diagrams/order-process/diagram.svg" alt="Order Process"/>
</a>
<h3>Order Fulfillment Process</h3>
<p class="meta">Commerce Team · v1.0 · Modified 2025-11-20</p>
</div>
</div>
<h2>Navigation</h2>
<nav class="folder-tree">
<!-- Built from arch:Folder hierarchy -->
<ul>
<li>Tasks
<ul>
<li><a href="elements/UserTask_1/">Validate Order</a></li>
</ul>
</li>
</ul>
</nav>
Diagram page (diagrams/{id}/index.html)¶
<h1>Order Fulfillment Process</h1>
<div class="metadata">
<dl>
<dt>Author</dt><dd>Commerce Team</dd>
<dt>Version</dt><dd>1.0</dd>
<dt>Modified</dt><dd>2025-11-20</dd>
<dt>Source</dt><dd>order-fulfillment.bpmn</dd>
</dl>
</div>
<div class="diagram-svg">
<!-- Embedded or linked SVG -->
<svg>...</svg>
</div>
<h2>Elements</h2>
<table>
<tr><th>Name</th><th>Type</th><th>ID</th></tr>
<tr><td><a href="../elements/UserTask_1/">Validate Order</a></td><td>bpmn:UserTask</td><td>UserTask_1</td></tr>
<tr><td><a href="../elements/Start_1/">Order Received</a></td><td>bpmn:StartEvent</td><td>Start_1</td></tr>
</table>
<h2>Relationships</h2>
<table>
<tr><th>Source</th><th>Type</th><th>Target</th></tr>
<tr><td>Order Received</td><td>bpmn:SequenceFlow</td><td>Validate Order</td></tr>
</table>
Element page (elements/{id}/index.html)¶
<h1>Validate Order</h1>
<div class="metadata">
<dl>
<dt>Type</dt><dd>bpmn:UserTask</dd>
<dt>ID</dt><dd>UserTask_1</dd>
<dt>Folder</dt><dd>Tasks</dd>
</dl>
</div>
<h2>Appears in diagrams</h2>
<ul>
<li><a href="../../diagrams/order-process/">Order Fulfillment Process</a></li>
</ul>
<h2>Relationships</h2>
<table>
<tr><th>Direction</th><th>Type</th><th>Connected to</th></tr>
<tr><td>incoming</td><td>bpmn:SequenceFlow</td><td><a href="../Start_1/">Order Received</a></td></tr>
<tr><td>outgoing</td><td>bpmn:SequenceFlow</td><td><a href="../Payment_1/">Process Payment</a></td></tr>
</table>
<h2>Properties</h2>
<!-- If schema:additionalProperty exists -->
<table>
<tr><th>Key</th><th>Value</th></tr>
<tr><td>lifecycle</td><td>production</td></tr>
</table>
Bendpoint rendering (RDF list traversal)¶
Bendpoints are stored as proper RDF lists:
<.../link/l1> archvis:points (
[ archvis:point-x 216 ; archvis:point-y 218 ]
[ archvis:point-x 270 ; archvis:point-y 218 ]
) .
To traverse in SPARQL:
PREFIX archvis: <https://meta.linked.archi/core-vis#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT ?link ?px ?py
WHERE {
?link archvis:points/rdf:rest*/rdf:first ?point .
?point archvis:point-x ?px ;
archvis:point-y ?py .
}
Or in code (RDF4J / rdflib), walk the list:
fun extractPoints(model: Model, link: IRI): List<Pair<Double, Double>> {
val points = mutableListOf<Pair<Double, Double>>()
var node = model.filter(link, archvisPoints, null).objects().firstOrNull() ?: return points
while (node != RDF.NIL) {
val point = model.filter(node as Resource, RDF.FIRST, null).objects().firstOrNull() as? Resource ?: break
val x = model.filter(point, archvisPointX, null).objects().firstOrNull()?.let { (it as Literal).doubleValue() }
val y = model.filter(point, archvisPointY, null).objects().firstOrNull()?.let { (it as Literal).doubleValue() }
if (x != null && y != null) points.add(x to y)
node = model.filter(node, RDF.REST, null).objects().firstOrNull() ?: break
}
return points
}
Notation-agnostic rendering¶
The generator never needs to know which notation produced the data. It uses only:
| Predicate | Purpose |
|---|---|
a arch:Diagram |
Identifies diagram pages |
a arch:Element |
Identifies element pages |
a arch:QualifiedRelationship |
Identifies relationships |
a arch:Folder |
Builds sidebar navigation |
skos:prefLabel |
Display names everywhere |
skos:notation |
IDs for URLs and file names |
skos:definition |
Descriptions |
schema:image |
Pre-rendered SVG link |
archvis:ArchNode / archvis:Link |
Diagram geometry |
archvis:bounds-* |
Node positioning |
archvis:points |
Link routing |
archvis:view |
Node/link → diagram association |
archvis:archElement |
Node → semantic element link |
dct:isPartOf |
Folder membership |
schema:itemListElement |
Ordered folder contents |
dct:title / dct:creator / dct:modified |
Provenance metadata |
This is the complete interface contract between converters and generators. Any data conforming to these predicates can be rendered — regardless of whether it came from BPMN, ArchiMate, PlantUML, or a custom notation.
Implementation checklist¶
Core pages¶
- [ ] Index page with diagram grid (thumbnails from SVG)
- [ ] Per-diagram page (SVG + element table + relationship table + metadata)
- [ ] Per-element page (type, properties, diagrams, relationships)
- [ ] Sidebar navigation from folder hierarchy
SVG handling¶
- [ ] Embed pre-rendered SVG when
schema:imagepoints to a local file - [ ] Render SVG from
archvis:geometry when no pre-rendered SVG exists - [ ] Show text-only view when neither SVG source is available
- [ ] Make SVG elements clickable (link to element detail pages)
Metadata¶
- [ ] Show provenance (author, version, dates) on diagram pages
- [ ] Show notation type (e.g. "bpmn:UserTask") on element pages
- [ ] Show properties (
schema:additionalProperty) on element pages
Navigation¶
- [ ] Folder tree sidebar from
arch:Folder+schema:itemListElement - [ ] Ordered members (respect
schema:position) - [ ] Breadcrumbs from
dct:isPartOfchain
Multi-model¶
- [ ] Handle multiple models in one TriG (multiple semantic graphs)
- [ ] Cross-model element links (if elements reference each other)
- [ ] Notation-type badge/icon on element cards (derived from the non-
arch:type)