pyssg is pre-1.0 and under active development - APIs, config, and themes may change.
pyssg.contrib.graph

5 min read

pyssg.contrib.graph

Contrib plugin: interactive document-graph output (graph.json + view).

This turns pyssg's internal link graph into a public, renderable artifact -- the Obsidian / Quartz style "graph view" of how notes connect. It has three layers, all owned by this one plugin so the feature drops into any theme:

  • Data -- a deterministic /graph.json ({nodes, links, config}) built from the resolved :class:~pyssg.core.types.ConnectionKind LINK edges that wikilink / link_resolver already record. Nodes are the document-backed pages; links are the resolved references between them (covering both wikilinks and relative .md links). Optionally tags are promoted to first-class nodes so the graph can cluster by topic.
  • View -- a small client renderer (graph.js + graph.css, emitted by the plugin to /assets/graph/) that consumes graph.json. It powers a full-page global graph at /graph/ (2D force layout with an optional 3D mode) and -- opt-in via local=True -- a per-page local graph (the current page's neighbourhood out to a configurable depth) injected as a panel into each document page.
  • Config -- a single :class:GraphConfig shaping what the graph shows and how it looks (filtering, grouping/colour, local depth, tag nodes, node sizing). The render-relevant subset is serialized into graph.json so the client stays a dumb consumer of declared facts.

Like the sitemap/rss/llms plugins this is a summarizer fan-in: it taps evaluate_collections (after nav/taxonomy so every virtual page already exists), reads the finalized graph, and materializes virtual pages carrying their payload as content_html with template=None (the render contract for "emit verbatim, no layout"). It reads only declared inputs -- page urls, document meta, the recorded LINK edges and its own packaged asset files -- and sorts deterministically, so two builds are byte-identical and an incremental rebuild matches a full one.

The client renderer is a clean-room reimplementation; its interaction design (2D cytoscape layout plus a lazily loaded 3D force-graph) follows the prior art in magiskboy/wiki. Per the contrib rules this module is pure, ships tests, passes mypy --strict and is not auto re-exported into pyssg.plugins. Third-party graph libraries are loaded at view time from a CDN (see :data:CYTOSCAPE_URL), not vendored.

class GraphConfig

Declarative shape of the document graph (filter, group, size, depth).

Every field is optional with a sensible default, so graph() works with zero configuration. The instance is immutable and read-only, so it carries no global mutable state and two builds stay byte-identical.

Parameters

NameTypeDescription
includePath globs (matched against a document's ``source_path``); when set, only matching documents become nodes. ``None`` keeps all.
excludePath globs whose matching documents are dropped.
include_tagsWhen set, keep only documents carrying at least one of these tags.
exclude_tagsDrop documents carrying any of these tags.
drop_orphansDrop nodes whose total degree (in + out) is zero.
min_degreeDrop nodes whose total degree is below this threshold.
group_by``"folder"`` groups a node by its top-level path segment; ``"tag"`` groups it by its first tag. The group drives node colour.
colorsMap of group name to CSS colour, serialized for the client; any group without an entry gets a deterministic palette colour.
localInject the per-page local-graph panel into each document page. A theme can place the marker ``<!-- pyssg:local-graph -->`` (see :data:`LOCAL_PLACEHOLDER`) to control where it renders; otherwise it is appended to the body end. Off by default (opt-in), since it adds a panel to every page.
local_depthHow many hops out from the current page the local graph expands (clamped to >= 1).
global_pageEmit the full-page global graph at ``/graph/``.
tag_nodesPromote tags to first-class graph nodes (``tag:<name>``) with a page->tag edge each, so the graph can cluster by topic.
size_minSmallest rendered node diameter (px), for the lowest degree.
size_maxLargest rendered node diameter (px), for the highest degree.

GraphConfig.client_config(self) -> dict[str, object]

The render-relevant subset serialized into graph.json.

The client renderer needs only colours, sizing, depth and the tag-node/grouping facts; the filtering knobs are applied server-side (so the full graph is never shipped) and are intentionally omitted.

build_graph_data(build: Build, cfg: GraphConfig) -> dict[str, object]

Build the {nodes, links, config} graph payload from the link graph.

Pure projection of the finalized graph: nodes are filtered document pages, links are the resolved LINK edges between them (deduplicated to one undirected edge per pair, with bidirectional set when both directions exist). inDegree / outDegree count the distinct directed links touching a node (including page->tag edges when tag_nodes is on). Node and link ordering is stable (by id, then by endpoints), so the serialized JSON is byte-identical across rebuilds.

Returns

TypeDescription
``{"nodes": [...], "links": [...], "config": {...}}``.

render_global_page(title: str, blob: str) -> str

Render the standalone /graph/ HTML page embedding the graph data.

The page is theme-independent (template=None): it inlines the data as a <script id="graph-data"> block -- which graph.js prefers over a network fetch -- and pulls in the renderer assets plus the cytoscape CDN bundle.

inject_local_graph(html: str, page: Page, build: Build, cfg: GraphConfig) -> str

Inject the local-graph panel into a rendered document page.

A render_page waterfall tap (run after the main render) for a real document page (a concrete template, Markdown provenance, not opted out via graph: false / graph_local: false). Placement:

  • Theme-provided placeholder -- if the page contains the marker comment <!-- pyssg:local-graph --> (see :data:LOCAL_PLACEHOLDER), it is replaced in place by the panel, so the theme decides where the graph lives (e.g. a sidebar). This is the recommended integration.
  • Fallback -- with no marker, the panel is appended to the end of the body so the feature still works on themes that know nothing about it.

Either way the renderer assets are loaded before </body>. Virtual/raw pages (template is None) and pages with no body element are returned unchanged.

materialize_graph(build: Build, cfg: GraphConfig) -> None

Emit /graph.json, the renderer assets, and the optional global page.

Idempotent: re-running upserts the same virtual pages in place, and drops a stale global page when global_page is turned off, so an incremental rebuild's page-set diff matches a full build.

class GraphPlugin

Emits the document graph data, renderer assets and graph views.

GraphPlugin.apply(self, builder: Builder) -> None

graph(*, include: Iterable[str] | None = None, exclude: Iterable[str] = (), include_tags: Iterable[str] | None = None, exclude_tags: Iterable[str] = (), drop_orphans: bool = False, min_degree: int = 0, group_by: str = 'folder', colors: Mapping[str, str] | None = None, local: bool = False, local_depth: int = 1, global_page: bool = True, tag_nodes: bool = False, size_min: float = 8.0, size_max: float = 40.0) -> GraphPlugin

Factory used in pyssg.config.py; see :class:GraphConfig for the knobs.