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.ConnectionKindLINKedges thatwikilink/link_resolveralready record. Nodes are the document-backed pages; links are the resolved references between them (covering bothwikilinksand relative.mdlinks). 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 consumesgraph.json. It powers a full-page global graph at/graph/(2D force layout with an optional 3D mode) and -- opt-in vialocal=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:
GraphConfigshaping what the graph shows and how it looks (filtering, grouping/colour, local depth, tag nodes, node sizing). The render-relevant subset is serialized intograph.jsonso 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
| Name | Type | Description |
|---|---|---|
include | Path globs (matched against a document's ``source_path``); when set, only matching documents become nodes. ``None`` keeps all. | |
exclude | Path globs whose matching documents are dropped. | |
include_tags | When set, keep only documents carrying at least one of these tags. | |
exclude_tags | Drop documents carrying any of these tags. | |
drop_orphans | Drop nodes whose total degree (in + out) is zero. | |
min_degree | Drop 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. | |
colors | Map of group name to CSS colour, serialized for the client; any group without an entry gets a deterministic palette colour. | |
local | Inject 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_depth | How many hops out from the current page the local graph expands (clamped to >= 1). | |
global_page | Emit the full-page global graph at ``/graph/``. | |
tag_nodes | Promote tags to first-class graph nodes (``tag:<name>``) with a page->tag edge each, so the graph can cluster by topic. | |
size_min | Smallest rendered node diameter (px), for the lowest degree. | |
size_max | Largest 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
| Type | Description |
|---|---|
| ``{"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.