WikifitaGitHub live67e8de5
outro · wikifita-site-architecture/wikifita-site-graph-construction

Link Graph Construction

How nodes (pages) and edges (links) are built from wiki content: link extraction, resolution cascade, backlink computation, and the D3-force graph visualization layout.

Baixar raw

Link Graph Construction

The Wikifita Atlas constructs a directed graph from the wiki corpus where pages are nodes and inter-page links are edges. This graph powers three systems: PageRank computation, search proximity boosting, and the interactive D3 force-directed visualization.

The graph construction happens during Phase 5 of the corpus pipeline wikifita-site-corpus-pipeline, after frontmatter parsing and title resolution, and before PageRank computation wikifita-site-pagerank.


Graph Structure

Nodes

Every .md or .mdx file in the corpus (excluding AGENTS.md) becomes a graph node. Nodes are identified by their POSIX-normalized file path (e.g., memorias/MEMORY.md).

flowchart TD
    A[RawWikiFile[]] -->|filter .md/.mdx, exclude AGENTS.md| B[Graph Nodes]
    B --> C["Node = WikiPage (path, title, category, pagerank)"]

Edges

Edges are directed links from one page to another, extracted from the page's markdown content. Each edge represents a deliberate authorial reference -- a link written by the wiki author pointing to another page.

flowchart LR
    A["Page A (source)"] -->|"WIKI_LINK_TARGET_LABEL"| B["Page B (target)"]
    A -->|"[text](target.md)"| B
    A -->|"![alt](image.png)"| C["Image/Asset"]

Self-links (edges from a page to itself) are filtered out during outlink construction. This prevents a page from boosting its own PageRank or appearing in its own backlinks.


Link Extraction

Links are extracted from the markdown AST using unist-util-visit over the parsed remark tree. Three origin kinds are detected:

1. Markdown Links (kind: "markdown")

Standard markdown link syntax. The extractor visits link AST nodes and extracts the url field.

[Link text](target.md)
[Link text](target.md#section)
[Link text](https://external.com)
ComponentExtraction
targetnode.url from the AST
labelConcatenation of all child text nodes
fragmentEverything after # in the URL

2. Wiki Links (kind: "wiki")

Double-bracket wiki-style links found in raw text nodes. The extractor visits text AST nodes and applies a regex:

Pattern: /\[\[([^\]|#]+(?:#[^\]|]+)?)(?:\|([^\]]+))?\]\]/g

This matches:

  • target -- simple wiki link
  • WIKI_LINK_TARGET_LABEL -- wiki link with display text
  • target-fragment -- wiki link with anchor
  • WIKI_LINK_TARGET_FRAGMENT_LABEL -- wiki link with anchor and display text
ComponentExtraction
targetFirst capture group (before |)
labelSecond capture group (after |), or target if absent
fragmentEverything after # in the target

Wiki links inside code fences are not extracted because unist-util-visit does not visit text nodes within code or inlineCode AST elements.

3. Image References (kind: "image")

Markdown image syntax. The extractor visits image AST nodes.

![Alt text](image.png)
![Alt text](https://cdn.example.com/photo.jpg)
ComponentExtraction
targetnode.url from the AST
labelnode.alt (alt text)
fragmentParsed from URL

Code Fence Exclusion

A critical design property: links inside code blocks are automatically excluded from extraction. The remark parser treats code blocks as atomic code nodes. The unist-util-visit function does not descend into their children, so any triple-dot patterns or markdown links inside fenced code are invisible to the link extractor.

This is essential for a wiki about code -- pages frequently contain example markdown syntax that should not be treated as actual links.


Link Resolution Cascade

Each extracted raw link target goes through a multi-stage resolution pipeline (resolveLinkTarget). The stages form a cascade: if resolution succeeds at any stage, later stages are skipped.

Stage 1: Fragment Extraction

Input:  "path/to/page#section"
Output: pathTarget = "path/to/page"
        fragment   = "section"

The target is split at the first #. If there is no #, the fragment is undefined.

Stage 2: External URL Detection

If the target matches the external URL pattern, it is immediately classified as external:

Pattern: /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i

This matches absolute URLs (https://..., http://..., ftp://...) and protocol-relative URLs (//...).

InputResult
https://example.comstate: "external"
http://example.comstate: "external"
//cdn.example.com/img.pngstate: "external"
mailto:user@example.comstate: "external"

Stage 3: Fragment-Only Links

If the path target is empty but a fragment exists (e.g., #section), the link resolves to the source page itself with the given fragment:

Input:  "#introduction" (on page "docs/guide.md")
Result: destination = "docs/guide.md", state = "resolved"

Stage 4: Exact Match

The path target is normalized (POSIX paths, stripped leading ../, ./, /) and checked against the set of all page paths:

1. Try exact match:     "memorias/MEMORY.md"  -> check allPaths
2. Try with .md:        "memorias/MEMORY"      -> check "memorias/MEMORY.md"
3. Try with .mdx:       "memorias/MEMORY"      -> check "memorias/MEMORY.mdx"

If any variant matches, the link resolves with state: "resolved".

Stage 5: Relative Path Resolution

If the target is not root-relative (does not start with /), it is resolved relative to the source page's directory:

Source:  "docs/guide/setup.md"
Target: "../config/options"
Result:  path.posix.join("docs/guide", "../config/options") = "docs/config/options"

The joined path is then checked with the same exact-match and extension-variant logic as Stage 4.

Stage 6: Fuzzy Slug Matching

If all previous stages fail, the system performs a fuzzy match on the last path component (the "slug"):

Target slug: "MEMORY"
Candidate slugs: ["memory.md", "MEMORY.md", "Memory.mdx"]

The matching uses:

  • Case-insensitive comparison with pt-BR locale (slug.localeCompare(candidate, "pt-BR", { sensitivity: "base" }))
  • Full route path comparison as a fallback (the entire path, not just the slug)
Matches FoundResult
Exactly 1state: "resolved", destination = matched page
More than 1state: "ambiguous"
0state: "missing"

The ambiguity case is important: if two pages have the same filename in different directories (e.g., docs/config.md and wiki/config.md), a link to config is flagged as ambiguous rather than arbitrarily choosing one.

Resolution Cascade Summary

flowchart TD
    A[Raw Link Target] --> B{Fragment?}
    B -->|"path#frag"| C[Split path and fragment]
    B -->|"#frag only"| D["state: resolved (self)"]
    B -->|no fragment| C
    C --> E{External URL?}
    E -->|yes| F["state: external"]
    E -->|no| G{Exact match in allPaths?}
    G -->|yes| H["state: resolved"]
    G -->|no| I{Relative resolution?}
    I -->|match| H
    I -->|no match| J{Fuzzy slug match?}
    J -->|1 match| H
    J -->|">1 match"| K["state: ambiguous"]
    J -->|0 matches| L["state: missing"]

Backlink Computation

After all pages have their outgoing links resolved, backlinks are computed as the inverse mapping. This is a single pass over all pages:

For each page in corpus:
    outlinks = unique resolved destinations (excluding self-links)
    For each outlink target:
        backlinksByPath[target].push(page.path)

Properties

  • Symmetric by construction: If page A has an outlink to page B, then A appears in B's backlinks
  • Deduplication: If page A links to page B multiple times, A appears only once in B's backlinks (outlinks are deduplicated)
  • Self-exclusion: A page never appears in its own backlinks
  • Directed: Backlinks are computed from directed outlinks, but the backlinks array itself represents the reverse direction

Usage

Backlinks are used in three contexts:

  1. Search proximity boost: The BFS graph distance uses both outlinks and backlinks as undirected edges wikifita-site-search-system
  2. Context rail sidebar: The document view shows which pages link to the current page
  3. LLM endpoint: The per-page /llms.txt endpoint includes a backlinks section wikifita-site-llm-integration

Graph Edge List

The final graph edge list is a flat array of {source, target} pairs, derived directly from outlinks:

links = basePages.flatMap(page =>
  page.outlinks.map(target => ({ source: page.path, target }))
)

This is a directed edge list. Each edge represents a resolved, non-self link from one page to another. The edge list is stored on the WikiCorpus.links field and serialized to the client for graph visualization.


D3 Force-Directed Graph Layout

The graph is visualized using d3-force in the client-side AtlasClient.tsx component. The layout applies physics simulation to position nodes in a way that reveals the wiki's structure.

Force Configuration

ForceTypeParametersPurpose
forceLinkLinkdistance: 72, strength: 0.16Pulls connected nodes toward a target distance
forceManyBodyChargestrength: -92 (global), -56 (background)Repels all nodes from each other
forceCollideCollisionradius: nodeRadius + 10, strength: 0.8Prevents node overlap
forceXPositionper-community X targetPulls nodes toward category-based columns
forceYPositioncenter Y, strength: 0.04Gentle vertical centering
forceCenterCentercanvas centerCenters the entire graph

Seeded Randomness

The simulation uses a deterministic seeded random number generator for node initial positions. This ensures the same corpus produces the same layout across renders, given the same commit SHA and page count.

The layout is cached by the key {commit}:{variant}:{selectedPath}:{pageCount}:{linkCount}:

  • commit: Git SHA (changes when content changes)
  • variant: "global" (full graph) or "background" (dimmed background)
  • selectedPath: Currently focused page (affects forceX targets)
  • pageCount and linkCount: Graph dimensions

Community-Based X-Positioning

The forceX pull organizes nodes into vertical columns by category:

CategoryX TargetVisual Position
memoryLeft columnLeft side
projectLeft-center
researchCenterCenter
directiveRight-center
personRight columnRight side
otherFar rightRight edge

This creates a natural visual clustering where related pages are near each other horizontally, even if they are not directly linked. The forceLink attraction works within and across clusters, creating the final layout.

Simulation Lifecycle

1. Initialize nodes with seeded random positions
2. Configure forces
3. Run simulation:
   - Global mode: 240 ticks (full computation)
   - Background mode: 180 ticks (reduced computation for dimmed graph)
4. Stop simulation
5. Cache layout positions
6. Render nodes and edges on canvas

Node Rendering

Each node is rendered as a circle on an HTML5 canvas:

PropertyFormula
Radius5+pagerankmaxRank×185 + \sqrt{\frac{\text{pagerank}}{\text{maxRank}}} \times 18
ColorCategory base color mixed with tag-derived accent (FNV-1a hash)
LabelTitle text, rendered below the node
Opacity1.0 for selected/highlighted, 0.3-0.6 for background

Edge Rendering

Edges are rendered as lines with:

  • Direction indicated by arrowheads (optional)
  • Color derived from source node color at reduced opacity
  • Curved paths when multiple edges exist between the same node pair

Undirected Adjacency for Client

The directed {source, target} edge list is converted to an undirected adjacency map on the client for graph traversal:

adjacency = new Map()
for (const { source, target } of links) {
  adjacency.get(source).push(target)
  adjacency.get(target).push(source)  // reverse direction
}

This undirected adjacency is used for the BFS proximity computation in search wikifita-site-search-system.


Self-Link Filtering

Self-links are filtered at two levels:

Outlink Construction

When building a page's outlinks array, any resolved destination that equals the page's own path is excluded:

outlinks = resolvedLinks
  .filter(link => link.destination !== page.path)
  .map(link => link.destination)
  .unique()

Backlink Computation

Since backlinks are derived from outlinks, self-exclusion propagates automatically. A page that links to itself will not appear in its own backlinks because the self-link was already removed from outlinks.

Why Self-Links Are Excluded

Without filtering:

  • A page could artificially inflate its own PageRank
  • The backlinks section would show the page itself (confusing)
  • The graph would have self-loops (visually noisy and mechanically meaningless)

Link Resolution Diagnostics

The resolution cascade produces diagnostics that are available for debugging and quality assurance:

StateMeaningAction
resolvedLink target found in corpusRender as clickable link
externalLink points outside the wikiRender as external link (may open in new tab)
missingNo matching page foundRender with unresolved CSS class
ambiguousMultiple pages match the slugRender with unresolved ambiguous CSS class

The rendering pipeline wikifita-site-rendering-pipeline uses these states to add CSS classes and data-link-state attributes, enabling visual feedback for broken links.


Corpus Impact

The link graph has measurable structural properties that affect the wiki's usability:

Connectivity Metrics

MetricDescriptionTypical Value
Edge countTotal resolved directed links2-5x page count
Density$\frac{E
Dangling nodesPages with 0 outlinks5-15% of pages
Isolated nodesPages with 0 inlinks and 0 outlinksRare (< 5%)
Average outdegreeMean outlinks per page2-6

Structural Patterns

  • Hub pages: Index pages, CLAUDE.md files, and memory indexes tend to have high outdegree (many links to other pages)
  • Authority pages: Key concept pages and popular research topics accumulate high indegree (many backlinks)
  • Bridge pages: Pages that link across categories (e.g., a research page linking to infrastructure docs) act as bridges in the PageRank computation
  • Leaf pages: Pages with no incoming links are discoverable only through search or manual navigation

Related Pages