Skip to content

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:

<.../view/order-process> schema:image <https://example.org/svg/order-process.svg> .

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:image points 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
  • [ ] Folder tree sidebar from arch:Folder + schema:itemListElement
  • [ ] Ordered members (respect schema:position)
  • [ ] Breadcrumbs from dct:isPartOf chain

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)