Arcology Engine

The Arcology's Sitemap Graph and Tag Index

Contents

The Arcology's sitemap attempts to provide a lay of the land, since there is not a standard "blog" feed and pages interlink freely between the Arcology sites,

These are the most "dynamic" or interactive of the Arcology's web features: a SigmaJS force-directed graph of every published page, and an HTMX-enhanced tag index. Both are ports of arcology-django's sitemap module (the implementation there), reconstituted on the Arcology Hypermedia Publishing Platform's route table and link graph.

  • GET /sitemap renders the graph page: nodes are published pages (one per route, colored by site linkColor, sized by link counts), edges are links between published pages. ForceAtlas2 lays the graph out in a browser web worker for a few seconds, hovering a node greys out its non-neighbors, and clicking a node navigates to its page.

  • GET /sitemap.json serves the graphology-shaped {"nodes":[{key,attributes}],"edges":[{key,source,target}]} payload, from an in-memory content-keyed cache with a strong ETag.

  • GET /tags lists every tag with its published-page count; each count is an HTMX trigger that expands the tag's page list inline.

  • GET /tags/{tag} is the full tag page — or, when htmx sends HX-Request: true, just the list fragment to swap in.

Sitemap Graph Data

The node shape is lifted from django's Node.make_page_dict: label from the route title, =x=/=y= seeded by a SHA-224 hash of the file path (so every load starts from the same pseudo-random layout before ForceAtlas2 coalesces it), size the average of outbound + inbound link counts capped at 20, color the site's linkColor from domains.json, and href for click-to-navigate. Edges are the deduplicated set of links between visible nodes; production sees only published routes, localhost also sees drafts and archived entries — the same visibility split as servePath / servePathLocalhost.

Two deliberate divergences:

  • Edge keys use a "->" separator instead of django's "-": node IDs are timestamps or UUIDs and can contain dashes, so "a-b"+"c" and "a"+"b-c" could collide into one edge key where graphology would throw on a duplicate.

  • Self-loops (a page linking to its own heading) are skipped entirely rather than rendered; django tolerated them by accident.

The hover reducers keep the neighbor-highlighting behavior from the sigma.js use-reducers example; django's selectedNode branch was never set and stays dropped. The suggestions branch, though, is revived by the search box (graph.js — the SigmaJS frontend): typing filters nodes by title, and the reducer composes search with hover by AND — a node stays colored only if it survives both filters. clickNode reads graph.getNodeAttributes(node).href instead of django's graph._nodes.get(node) private-API poke.

Payload models

The /sitemap.json body in graphology's serialized format, so the frontend can call graphology.Graph.from(data) directly.

kotlin#+name: sigma-payload
/**
 * A node in the sitemap graph, in graphology's serialized format: Graph.from
 * on the frontend consumes {key, attributes} directly. The attributes match
 * arcology-django's SigmaJS nodes — label, seeded x/y, link-count size, site
 * link color, and the page URL for click-to-navigate.
 */
@Serializable
data class SigmaNode(
    val key: String,
    val attributes: SigmaNodeAttributes
)

@Serializable
data class SigmaNodeAttributes(
    val label: String,
    val x: Int,
    val y: Int,
    val size: Double,
    val color: String,
    val href: String
)

/**
 * An edge between two published pages. The key uses a "->" separator — node
 * IDs are timestamps or UUIDs and can contain "-", so a bare dash could
 * collide two different (source, target) pairs into one key.
 */
@Serializable
data class SigmaEdge(
    val key: String,
    val source: String,
    val target: String
)

/** The /sitemap.json response body: graphology's Graph.from shape. */
@Serializable
data class SigmaGraphPayload(
    val nodes: List<SigmaNode>,
    val edges: List<SigmaEdge>
)

/** Fallback node color for sites with no linkColor configured in domains.json. */
const val SITEMAP_DEFAULT_NODE_COLOR = "#808080"

SitemapGraphBuilder

Nodes are one-per-route, deduplicated by node ID first-wins to match buildRouteIndex. Per node, getLinksFrom drives the edges (target must be visible) and, together with getLinksTo, the size. Everything is emitted sorted, so the JSON is byte-stable for identical content.

kotlin#+name: sitemap-graph-builder
/**
 * Builds the /sitemap.json graph payload from the route table and the link
 * graph, with an in-memory cache keyed by content — a port of arcology-django's
 * sitemap models (Node.make_page_dict / Edge.get_sigmajs_edges).
 */
class SitemapGraphBuilder(
    private val repository: RoamRepository,
    private val publishingRepository: PublishingRepository,
    private val domainMap: DomainMap
) {
    private val json = Json
    private val mutex = Mutex()

    /** (localhost, contentKey) -> (body, strong ETag). */
    private val cache = mutableMapOf<Pair<Boolean, String>, Pair<String, String>>()

    /**
     * Optional metrics hook ([[file:metrics.org][ArcologyMetrics]]): the
     * /sitemap.json cache hit/miss counters (cache=sitemap). null = no-op,
     * matching how the server treats its metrics dependency.
     */
    var metrics: ArcologyMetrics? = null

    /**
     * The cached JSON body and a strong ETag (SHA-256 of the body). Two calls
     * with unchanged content return the identical pair; any re-index that
     * touches routes or file hashes misses the cache and rebuilds.
     */
    suspend fun json(localhost: Boolean): Pair<String, String> {
        val cacheKey = localhost to contentKey(localhost)
        cache[cacheKey]?.let {
            metrics?.cacheHit("sitemap")
            return it
        }
        return mutex.withLock {
            val locked = cache[cacheKey]
            if (locked != null) {
                metrics?.cacheHit("sitemap")
                locked
            } else {
                metrics?.cacheMiss("sitemap")
                val body = json.encodeToString(build(localhost))
                val entry = body to "\"${sha256Hex(body)}\""
                // Rebuilds happen whenever the org-roam repo churns; keep the
                // cache from growing without bound by dropping older entries.
                if (cache.size >= 8) cache.clear()
                cache[cacheKey] = entry
                entry
            }
        }
    }

    /**
     * The graph payload: nodes sorted by key, edges sorted by key — the output
     * is deterministic for identical content, so ETags survive restarts.
     */
    suspend fun build(localhost: Boolean): SigmaGraphPayload {
        val index = visibleIndex(localhost)
        val edges = mutableSetOf<SigmaEdge>()
        val nodes = mutableListOf<SigmaNode>()
        for (nodeId in index.keys.sorted()) {
            val entry = index.getValue(nodeId)
            val linksFrom = repository.getLinksFrom(nodeId)
            val linksTo = repository.getLinksTo(nodeId)
            for (link in linksFrom) {
                val target = link.toNode ?: continue
                // Self-loops (a page linking to its own heading) and links to
                // invisible nodes produce no edge; links to invisible targets
                // still count toward the source's size, as in django.
                if (target == nodeId || !index.containsKey(target)) continue
                edges.add(SigmaEdge(key = "$nodeId->$target", source = nodeId, target = target))
            }
            nodes.add(
                SigmaNode(
                    key = nodeId,
                    attributes = SigmaNodeAttributes(
                        label = entry.title ?: "Untitled",
                        x = makeLocHash(entry.file, 1),
                        y = makeLocHash(entry.file, 2),
                        size = minOf((linksFrom.size + linksTo.size) / 2.0, 20.0),
                        color = domainMap.meta(entry.site)?.linkColor ?: SITEMAP_DEFAULT_NODE_COLOR,
                        href = sitemapHrefFor(entry, localhost, domainMap)
                    )
                )
            )
        }
        return SigmaGraphPayload(nodes = nodes, edges = edges.sortedBy { it.key })
    }

    private suspend fun visibleIndex(localhost: Boolean): Map<String, RouteEntry> {
        val routes = if (localhost) publishingRepository.getAllRoutes() else publishingRepository.getPublishedRoutes()
        val index = mutableMapOf<String, RouteEntry>()
        for (route in routes) {
            if (!index.containsKey(route.nodeId)) index[route.nodeId] = route
        }
        return index
    }

    /**
     * Cheap cache key over everything the graph can depend on: the visible
     * route table and every indexed file's hash. Any edit that changes links,
     * titles, or publish state rewrites a file hash and invalidates the cached
     * graph — the same intent as django's cache key over all File digests.
     */
    private suspend fun contentKey(localhost: Boolean): String {
        val routes = if (localhost) publishingRepository.getAllRoutes() else publishingRepository.getPublishedRoutes()
        val files = repository.getAllFiles()
        // \u0001 / \u0002 separators can't appear in paths or hashes, so the
        // material can't be re-parsed into a collision by field contents.
        val material = buildString {
            append("v1;routes;")
            for (route in routes.sortedBy { it.nodeId }) {
                append(route.nodeId).append('\u0001')
                    .append(route.file).append('\u0001')
                    .append(route.title ?: "").append('\u0001')
                    .append(route.isDraft).append('\u0001')
                    .append(route.isArchived).append('\u0002')
            }
            append("files;")
            for (file in files.sortedBy { it.path }) {
                append(file.path).append('\u0001').append(file.hash).append('\u0002')
            }
        }
        return sha256Hex(material)
    }

    /**
     * Deterministic seed coordinate (0 until maxQ) for a node, ported from
     * django's make_loc_hash: SHA-224 over the file path plus a per-axis salt.
     * ForceAtlas2 coalesces the seeds in the browser, but identical inputs
     * always start identical, so the graph looks the same on every load.
     */
    private fun makeLocHash(path: String, salt: Int, maxQ: Int = 700): Int {
        val digest = MessageDigest.getInstance("SHA-224")
            .digest("$path$salt".toByteArray(Charsets.UTF_8))
        // Seven bytes = 56 bits: always a positive Long, unlike eight, which
        // can spill into the sign bit and yield negative coordinates.
        var value = 0L
        for (i in 0 until 7) {
            value = (value shl 8) or (digest[i].toLong() and 0xFF)
        }
        return (value % maxQ).toInt()
    }

    private fun sha256Hex(text: String): String {
        val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
        return digest.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
    }
}

href helper

Shared by the graph nodes and the tag index pages. Localhost links use the full SITE/path key so browsing stays on the local server; production links point at the entry's own domain, protocol-relative like SidebarService backlinks so they work over http or https.

kotlin#+name: sitemap-href
/**
 * URL for a sitemap node or tag-page link. Localhost uses the full SITE/path
 * key so browsing stays on the local server; production points at the entry's
 * own domain, protocol-relative (//domain/path) like SidebarService backlinks
 * so it works over http or https. Entries whose site has no domain fall back
 * to a same-host relative path.
 */
fun sitemapHrefFor(entry: RouteEntry, localhost: Boolean, domainMap: DomainMap): String {
    return if (localhost) {
        "/${entry.site}/${entry.path}"
    } else {
        val domain = domainMap.resolve(entry.site)
        if (domain != null) "//$domain/${entry.path}" else "/${entry.path}"
    }
}

Tag Index

The tag half of django's sitemap module: tags_index (all tags, counted) and tag_page (pages with the tag, weighted). Tags are counted over visible route nodes only, and pages are weighted by backlink count so the tag-cloud CSS can size them — django's weighted_pages_by_name did the same by link count.

kotlin#+name: tag-page-model
/**
 * A published page carrying a tag, for the tag index pages. [weight] is
 * backlink count + 1 and drives the tag-cloud font sizing in the templates,
 * the same way arcology-django weighted pages by link count.
 */
data class TagPage(
    val nodeId: String,
    val title: String,
    val href: String,
    val weight: Int
)
kotlin#+name: tag-index-service
/**
 * The /tags index: tags counted across visible pages (production sees only
 * published routes; localhost sees drafts and archived too), and per-tag page
 * lists weighted by backlinks. A port of arcology-django's tags_index /
 * tag_page views, with the HTMX partial swap done client-side by the
 * already-vendored htmx.
 */
class TagIndexService(
    private val repository: RoamRepository,
    private val publishingRepository: PublishingRepository,
    private val domainMap: DomainMap
) {
    /**
     * All tags across visible pages with their page counts, most-used first,
     * ties alphabetical. Tags in [EXCLUDE_TAGS] never appear.
     */
    suspend fun allTags(localhost: Boolean): List<Pair<String, Int>> {
        val nodeIds = visibleRoutes(localhost).map { it.nodeId }.distinct()
        val tagsByNode = repository.getTagsByNodes(nodeIds)
        val counts = mutableMapOf<String, Int>()
        for (nodeId in nodeIds) {
            for (tag in tagsByNode[nodeId] ?: emptyList()) {
                if (tag in EXCLUDE_TAGS) continue
                counts[tag] = (counts[tag] ?: 0) + 1
            }
        }
        return counts.entries
            .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
            .map { it.key to it.value }
    }

    /**
     * Visible pages carrying [tag], most-backlinked first, ties alphabetical.
     * Links from invisible nodes still count toward the weight, matching the
     * graph node sizing.
     */
    suspend fun tagPages(tag: String, localhost: Boolean): List<TagPage> {
        val index = visibleIndex(localhost)
        return repository.getNodesByTag(tag)
            .mapNotNull { nodeId -> index[nodeId]?.let { nodeId to it } }
            .map { (nodeId, entry) ->
                TagPage(
                    nodeId = nodeId,
                    title = entry.title ?: "Untitled",
                    href = sitemapHrefFor(entry, localhost, domainMap),
                    weight = repository.getLinksTo(nodeId).size + 1
                )
            }
            .sortedWith(compareByDescending<TagPage> { it.weight }.thenBy { it.title })
    }

    private suspend fun visibleRoutes(localhost: Boolean): List<RouteEntry> =
        if (localhost) publishingRepository.getAllRoutes() else publishingRepository.getPublishedRoutes()

    private suspend fun visibleIndex(localhost: Boolean): Map<String, RouteEntry> {
        val index = mutableMapOf<String, RouteEntry>()
        for (route in visibleRoutes(localhost)) {
            if (!index.containsKey(route.nodeId)) index[route.nodeId] = route
        }
        return index
    }
}

Templates

The graph and tag pages extend app.peb for the site chrome — the cross-site nav, the footer's /sitemap and /tags links (already pointing here), the per-site CSS, and the htmx include the tag index needs.

graph.peb — the SigmaJS page

The four scripts load defer, in dependency order: graphology, graphology-library (FA2), sigma, then our graph.js. defer scripts execute in document order after parsing, so the globals exist before graph.js runs, and the =#sitemap-container= div is already in the DOM.

html#+name: graph-peb:tangle ../src/jvmMain/resources/templates/graph.peb
{% extends "app.peb" %}

{% block title %}A Map of the Arcology Sites{% endblock %}

{% block h1 %}
<h1><a href="/">{{ site.title | default(site.key) }}</a></h1>
<h2>A Map of the Arcology Sites</h2>
{% endblock %}

{% block extra_head %}
  <link rel="stylesheet" href="/static/sitemap/sitemap.css"/>
  <script src="/static/sitemap/graphology.min.js" defer></script>
  <script src="/static/sitemap/graphology-library.min.js" defer></script>
  <script src="/static/sitemap/sigma.min.js" defer></script>
  <script src="/static/sitemap/graph.js" defer></script>
{% endblock %}

{% block content %}
  <section>
    <p>
      This is a network graph of the pages published in the Arcology. The
      color of each node corresponds to its site; hover a node to highlight
      its neighbors, type in the search box to filter by title, or click one
      to jump to that page.
    </p>
    {% if localhost %}
    <p><em>Local preview — drafts and archived pages are included.</em></p>
    {% endif %}
    <p>You may also like the <a href="/tags/">Tag Index</a>.</p>
    <p>
      <input id="sitemap-search" type="search"
             placeholder="Search the graph by page title&hellip;"
             autocomplete="off"/>
    </p>
    <div id="sitemap-container"></div>
  </section>
{% endblock %}

tags.peb — the tag index

Every tag's hit count is an HTMX trigger: hx-get fetches the tag's list fragment and =hx-swap="outerHTML"= replaces the empty <ul> beneath the tag with the populated one, exactly django's arrangement.

html#+name: tags-peb:tangle ../src/jvmMain/resources/templates/tags.peb
{% extends "app.peb" %}

{% block title %}Tag Index{% endblock %}

{% block h1 %}
<h1><a href="/">{{ site.title | default(site.key) }}</a></h1>
<h2>Tag Index</h2>
{% endblock %}

{% block content %}
  <section class="tag-index">
    {% if localhost %}
    <p><em>Local preview — drafts and archived pages are included.</em></p>
    {% endif %}
    <ul>
      {% for tag in tags %}
      <li>
        {{ tag.tag }}&nbsp;
        (<a class="page_count" href="/tags/{{ tag.tag }}"
            hx-get="/tags/{{ tag.tag }}" hx-swap="outerHTML"
            hx-target="#tag-{{ tag.tag }}-pages"><b>{{ tag.count }}</b> Hits</a>)
        <ul id="tag-{{ tag.tag }}-pages"></ul>
      </li>
      {% endfor %}
    </ul>
  </section>
{% endblock %}

tag.peb — a tag's page

The full-page rendering of one tag's page list, sharing the list markup with the HTMX fragment.

html#+name: tag-peb:tangle ../src/jvmMain/resources/templates/tag.peb
{% extends "app.peb" %}

{% block title %}Pages tagged with {{ tag }}{% endblock %}

{% block h1 %}
<h1><a href="/">{{ site.title | default(site.key) }}</a></h1>
<h2>Pages tagged with {{ tag }}</h2>
{% endblock %}

{% block content %}
  <section class="tag-index">
    <a href="/tags/">&larr;&nbsp;Show all tags</a>
    {% include "tag-list.peb" %}
  </section>
{% endblock %}

tag-list.peb — the shared list fragment

Rendered whole for the HTMX swap (the hx-target points at the empty =<ul id="tag-...-pages">= in tags.peb, which this replaces), and included by tag.peb for the full page. The --size custom property drives the tag-cloud font sizing in sitemap.css.

html#+name: tag-list-peb:tangle ../src/jvmMain/resources/templates/tag-list.peb
<ul id="tag-{{ tag }}-pages" class="tag-list">
  {% for page in pages %}
  <li><a style="--size: {{ page.weight }};" href="{{ page.href }}">{{ page.title }}</a></li>
  {% endfor %}
</ul>

Static Assets

sitemap.css

css#+name: sitemap-css:tangle ../src/jvmMain/resources/static/sitemap/sitemap.css
/* SigmaJS graph container: a tall canvas, with the site link colors
   saturated so the per-site coloring reads at a glance (from django). */
#sitemap-container {
  height: 80em;
  filter: saturate(500%);
}

/* Live-search box above the graph: match the page typography and give it
   room so it doesn't sit flush against the canvas. */
#sitemap-search {
  width: 24em;
  max-width: 100%;
  padding: 0.3em 0.5em;
  font: inherit;
}

/* Tag "cloud": pages with more backlinks render larger (log scale). */
.tag-index ul.tag-list a {
  --size: 1;
  font-size: calc(log(var(--size) + 1) * 120%);
}

graph.js — the SigmaJS frontend

A cleaned-up port of django's sitemap.js — the hover reducers keep the neighbor-highlighting that was ever live, the suggestions branch is revived by the title search box (composing with hover by AND), and clickNode uses the public getNodeAttributes API.

javascript#+name: graph-js:tangle ../src/jvmMain/resources/static/sitemap/graph.js
// Arcology sitemap graph — fetch /sitemap.json, lay out with ForceAtlas2 in
// a browser worker, render with SigmaJS, highlight neighbors on hover,
// filter by title via the search box, navigate on click. The graphology /
// graphologyLibrary / Sigma globals are vendored minified files loaded
// (deferred, in order) by graph.peb.
const container = document.getElementById("sitemap-container");
const searchInput = document.getElementById("sitemap-search");

fetch("/sitemap.json")
  .then((response) => response.json())
  .then((data) => {
    const graph = new graphology.Graph.from(data);

    // ForceAtlas2: infer sensible settings from the graph, run the layout
    // for a few seconds so the seeded coordinates coalesce, then stop.
    const forceAtlas2 = graphologyLibrary.layoutForceAtlas2;
    const layout = new graphologyLibrary.FA2Layout(graph, {
      settings: forceAtlas2.inferSettings(graph),
    });

    const renderer = new Sigma(graph, container);

    // Two independent filters share the reducers:
    //   - hoveredNode / hoveredNeighbors: the hover-focus set
    //   - suggestions: node IDs whose title matches the search box
    // A node stays colored only if it passes BOTH (AND); failing either
    // filter greys it out. Edge hiding follows the same rule per filter.
    const state = {};
    function setHoveredNode(node) {
      state.hoveredNode = node;
      state.hoveredNeighbors = node
        ? new Set(graph.neighbors(node))
        : undefined;
      renderer.refresh();
    }

    renderer.on("enterNode", ({ node }) => setHoveredNode(node));
    renderer.on("leaveNode", () => setHoveredNode(undefined));

    // Live title search: case-insensitive substring against each node's
    // label. Debounced so fast typing doesn't refresh-storm the renderer;
    // ESC or an empty query clears the filter.
    let searchTimer = null;
    function applySearch() {
      const q = (searchInput.value || "").trim().toLowerCase();
      if (!q) {
        state.suggestions = undefined;
      } else {
        const matches = new Set();
        graph.forEachNode((node, attrs) => {
          if ((attrs.label || "").toLowerCase().includes(q)) {
            matches.add(node);
          }
        });
        state.suggestions = matches;
      }
      renderer.refresh();
    }
    searchInput.addEventListener("input", () => {
      clearTimeout(searchTimer);
      searchTimer = setTimeout(applySearch, 80);
    });
    searchInput.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        searchInput.value = "";
        applySearch();
      }
    });

    // Node reducer: a node is greyed (label cleared, color #f6f6f6) unless it
    // passes the hover filter AND the search filter. Matches keep their site
    // color and label (data passes through unchanged).
    renderer.setSetting("nodeReducer", (node, data) => {
      const res = { ...data };
      const inHoverScope =
        !state.hoveredNeighbors ||
        state.hoveredNeighbors.has(node) ||
        state.hoveredNode === node;
      const inSearchScope = !state.suggestions || state.suggestions.has(node);
      if (!inHoverScope || !inSearchScope) {
        res.label = "";
        res.color = "#f6f6f6";
      }
      return res;
    });

    // Edge reducer: hide an edge if it fails the hover filter (doesn't touch
    // the hovered node) OR the search filter (an endpoint isn't a match).
    renderer.setSetting("edgeReducer", (edge, data) => {
      const res = { ...data };
      if (state.hoveredNode && !graph.hasExtremity(edge, state.hoveredNode)) {
        res.hidden = true;
      }
      if (state.suggestions) {
        const src = graph.source(edge);
        const tgt = graph.target(edge);
        if (!state.suggestions.has(src) || !state.suggestions.has(tgt)) {
          res.hidden = true;
        }
      }
      return res;
    });

    // Click a node to navigate to its page (the href attribute from the JSON).
    renderer.on("clickNode", ({ node }) => {
      window.location = graph.getNodeAttributes(node).href;
    });

    layout.start();
    setTimeout(() => layout.stop(), 5000);

    return { renderer, graph };
  });

Vendored JavaScript

Not tangled from this file — copied verbatim, the same arrangement as htmx.min.js in templates.org:

  • static/sitemap/sigma.min.js — SigmaJS v2.3.1

  • static/sitemap/graphology.min.js — graphology 0.24.1

  • static/sitemap/graphology-library.min.js — graphology standard library 0.24.1 (ForceAtlas2)

All three were copied from arcology-django's sitemap/static/sitemap/js/ vendor directory. No Node, no bundler, no build chain.

Tests

One test file covering both halves: SitemapGraphTest exercises the builder (visibility split, edge filtering/dedup, node attributes, loc-hash determinism, size cap, cache behavior) and TagIndexServiceTest covers the tag counting and weighted pages. The fake RoamRepository is data-backed for the queries the builders use (files, links, tags) and empty for everything else, following the EmptyRoamRepository pattern from server.org.

kotlin#+name: sitemap-test-prelude
@file:OptIn(kotlin.time.ExperimentalTime::class)
package computer.whatthefuck.arcology.publishing

import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.FailedFile
import computer.whatthefuck.arcology.domain.FileDiscoveryCache
import computer.whatthefuck.arcology.domain.FileProperty
import computer.whatthefuck.arcology.domain.FtsContentEntry
import computer.whatthefuck.arcology.domain.FtsSearchResult
import computer.whatthefuck.arcology.domain.FtsStagingEntry
import computer.whatthefuck.arcology.domain.FtsTitleEntry
import computer.whatthefuck.arcology.domain.GeoCoordinate
import computer.whatthefuck.arcology.domain.NodeProperty
import computer.whatthefuck.arcology.domain.OrgAlias
import computer.whatthefuck.arcology.domain.OrgAttachment
import computer.whatthefuck.arcology.domain.OrgFile
import computer.whatthefuck.arcology.domain.OrgLink
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.OrgRef
import computer.whatthefuck.arcology.domain.OrgTag
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import kotlin.time.Instant

class SitemapGraphTest {

    private val domainMap: DomainMap = DomainMap.parse("""
        {
          "sites": [
            {"key":"garden","title":"Garden","linkColor":"#4060a6","domains":["arcology.garden"]},
            {"key":"lionsrear","title":"Lions Rear","linkColor":"#a64040","domains":["thelionsrear.com"]}
          ]
        }
    """.trimIndent())

    private fun route(
        nodeId: String, site: String, path: String, file: String = "$nodeId.org",
        title: String? = nodeId, isDraft: Boolean = false, isArchived: Boolean = false
    ): RouteEntry = RouteEntry(
        nodeId = nodeId, site = site, path = path, file = file, title = title,
        timestamp = null, isDraft = isDraft, isArchived = isArchived
    )

    private fun link(from: String, to: String?): OrgLink =
        OrgLink(position = 0, fromNode = from, toNode = to, type = "id")

    private fun orgFile(path: String, hash: String): OrgFile = OrgFile(
        path = path, title = null, hash = hash,
        accessTime = Instant.fromEpochSeconds(0),
        modificationTime = Instant.fromEpochSeconds(0)
    )

    private fun builder(
        roam: FakeSitemapRoamRepository,
        routes: List<RouteEntry>
    ): SitemapGraphBuilder = SitemapGraphBuilder(roam, FakePublishingRepository(routes), domainMap)

SitemapGraphBuilder tests

kotlin#+name: sitemap-graph-test
    @Test
    fun `production graph excludes draft and archived routes`() = runBlocking {
        val routes = listOf(
            route("n1", "garden", "one"),
            route("n2", "garden", "two", isDraft = true),
            route("n3", "garden", "three", isArchived = true)
        )
        val payload = builder(FakeSitemapRoamRepository(), routes).build(localhost = false)
        assertEquals(listOf("n1"), payload.nodes.map { it.key })
    }

    @Test
    fun `localhost graph includes draft and archived routes`() = runBlocking {
        val routes = listOf(
            route("n1", "garden", "one"),
            route("n2", "garden", "two", isDraft = true),
            route("n3", "garden", "three", isArchived = true)
        )
        val payload = builder(FakeSitemapRoamRepository(), routes).build(localhost = true)
        assertEquals(setOf("n1", "n2", "n3"), payload.nodes.map { it.key }.toSet())
    }

    @Test
    fun `edges only connect visible nodes and dedupe parallel links`() = runBlocking {
        val routes = listOf(
            route("a", "garden", "a"),
            route("b", "garden", "b"),
            route("c", "garden", "c"),
            route("d", "garden", "d", isDraft = true)
        )
        val links = listOf(
            link("a", "b"),
            link("a", "b"),        // parallel link: one edge
            link("a", "c"),
            link("b", "d"),        // draft target: no edge
            link("a", "ghost"),    // not a route: no edge
            link("a", null),       // unresolved link: no edge
            link("a", "a")         // self-loop: no edge
        )
        val payload = builder(FakeSitemapRoamRepository(links = links), routes).build(localhost = false)
        assertEquals(listOf("a->b", "a->c"), payload.edges.map { it.key })
        assertEquals("a", payload.edges.first().source)
        assertEquals("b", payload.edges.first().target)
    }

    @Test
    fun `localhost edges connect draft nodes too`() = runBlocking {
        val routes = listOf(
            route("a", "garden", "a"),
            route("d", "garden", "d", isDraft = true)
        )
        val links = listOf(link("a", "d"))
        val payload = builder(FakeSitemapRoamRepository(links = links), routes).build(localhost = true)
        assertEquals(listOf("a->d"), payload.edges.map { it.key })
    }

    @Test
    fun `nodes carry label color href and deterministic coordinates`() = runBlocking {
        val routes = listOf(route("n1", "garden", "index", file = "garden.org", title = "Garden Index"))
        val roam = FakeSitemapRoamRepository()
        val node = builder(roam, routes).build(localhost = false).nodes.single()

        assertEquals("Garden Index", node.attributes.label)
        assertEquals("#4060a6", node.attributes.color)
        assertEquals("//arcology.garden/index", node.attributes.href)
        assertTrue(node.attributes.x in 0 until 700)
        assertTrue(node.attributes.y in 0 until 700)

        // The seed is a pure function of the file path: same input, same layout.
        val again = builder(FakeSitemapRoamRepository(), routes).build(localhost = false).nodes.single()
        assertEquals(node.attributes.x, again.attributes.x)
        assertEquals(node.attributes.y, again.attributes.y)
    }

    @Test
    fun `localhost hrefs use the full site path key`() = runBlocking {
        val routes = listOf(route("n1", "garden", "index"))
        val node = builder(FakeSitemapRoamRepository(), routes).build(localhost = true).nodes.single()
        assertEquals("/garden/index", node.attributes.href)
    }

    @Test
    fun `unknown site falls back to default color and relative href`() = runBlocking {
        val routes = listOf(route("n1", "unknown", "x"))
        val node = builder(FakeSitemapRoamRepository(), routes).build(localhost = false).nodes.single()
        assertEquals(SITEMAP_DEFAULT_NODE_COLOR, node.attributes.color)
        assertEquals("/x", node.attributes.href)
    }

    @Test
    fun `node size averages link counts capped at twenty`() = runBlocking {
        val routes = listOf(
            route("a", "garden", "a"),
            route("b", "garden", "b")
        )
        val links = listOf(
            link("a", "b"),        // a: 1 out, b: 1 in
            link("x1", "b"), link("x2", "b")   // invisible sources still size b
        )
        val byKey = builder(FakeSitemapRoamRepository(links = links), routes)
            .build(localhost = false).nodes.associateBy { it.key }
        assertEquals(0.5, byKey.getValue("a").attributes.size)
        assertEquals(1.5, byKey.getValue("b").attributes.size)

        // A hub with 45 inbound links: (0 + 45) / 2 = 22.5, capped at 20.
        val hubLinks = (1..45).map { link("y$it", "hub") }
        val hubRoutes = listOf(route("hub", "garden", "hub"))
        val hub = builder(FakeSitemapRoamRepository(links = hubLinks), hubRoutes)
            .build(localhost = false).nodes.single()
        assertEquals(20.0, hub.attributes.size)
    }

    @Test
    fun `json is cached until the content key changes`() = runBlocking {
        val roam = FakeSitemapRoamRepository(files = listOf(orgFile("a.org", "hash-1")))
        val publishing = MutableRoutesPublishingRepository(listOf(route("a", "garden", "one")))
        val graph = SitemapGraphBuilder(roam, publishing, domainMap)

        val (body1, etag1) = graph.json(localhost = false)
        val (body2, etag2) = graph.json(localhost = false)
        assertEquals(body1, body2)
        assertEquals(etag1, etag2)
        assertTrue(etag1.startsWith("\""))
        assertTrue(etag1.endsWith("\""))

        // The body round-trips through the graphology payload shape.
        val payload = Json.decodeFromString<SigmaGraphPayload>(body1)
        assertEquals(listOf("a"), payload.nodes.map { it.key })

        // A re-index that adds a route changes the content key and rebuilds.
        publishing.routes = publishing.routes + route("b", "garden", "two")
        val (body3, etag3) = graph.json(localhost = false)
        assertNotEquals(body1, body3)
        assertNotEquals(etag1, etag3)
    }

TagIndexService tests

kotlin#+name: tag-index-test
class TagIndexServiceTest {

    private val domainMap: DomainMap = DomainMap.parse("""
        {
          "sites": [
            {"key":"garden","title":"Garden","linkColor":"#4060a6","domains":["arcology.garden"]},
            {"key":"lionsrear","title":"Lions Rear","linkColor":"#a64040","domains":["thelionsrear.com"]}
          ]
        }
    """.trimIndent())

    private fun route(
        nodeId: String, site: String, path: String, file: String = "$nodeId.org",
        title: String? = nodeId, isDraft: Boolean = false
    ): RouteEntry = RouteEntry(
        nodeId = nodeId, site = site, path = path, file = file, title = title,
        timestamp = null, isDraft = isDraft, isArchived = false
    )

    private fun link(from: String, to: String?): OrgLink =
        OrgLink(position = 0, fromNode = from, toNode = to, type = "id")

    @Test
    fun `allTags counts visible pages and drops export tags`() = runBlocking {
        val routes = listOf(
            route("a", "garden", "one"),
            route("b", "garden", "two"),
            route("d", "garden", "draft", isDraft = true)
        )
        val roam = FakeSitemapRoamRepository(tagsByNode = mapOf(
            "a" to listOf("tech"),
            "b" to listOf("tech", "noexport"),
            "d" to listOf("tech")
        ))
        val tags = TagIndexService(roam, FakePublishingRepository(routes), domainMap)
            .allTags(localhost = false)
        assertEquals(listOf("tech" to 2), tags)
    }

    @Test
    fun `localhost allTags counts draft pages too`() = runBlocking {
        val routes = listOf(
            route("a", "garden", "one"),
            route("d", "garden", "draft", isDraft = true)
        )
        val roam = FakeSitemapRoamRepository(tagsByNode = mapOf(
            "a" to listOf("tech"),
            "d" to listOf("tech")
        ))
        val tags = TagIndexService(roam, FakePublishingRepository(routes), domainMap)
            .allTags(localhost = true)
        assertEquals(listOf("tech" to 2), tags)
    }

    @Test
    fun `tagPages weights by backlinks and links to the page's own site`() = runBlocking {
        val routes = listOf(
            route("a", "garden", "one", title = "A"),
            route("b", "lionsrear", "two", title = "B"),
            route("d", "garden", "draft", isDraft = true, title = "D")
        )
        val roam = FakeSitemapRoamRepository(
            links = listOf(link("x1", "b"), link("x2", "b"), link("x3", "b")),
            tagsByNode = mapOf(
                "a" to listOf("tech"),
                "b" to listOf("tech"),
                "d" to listOf("tech")
            )
        )
        val pages = TagIndexService(roam, FakePublishingRepository(routes), domainMap)
            .tagPages("tech", localhost = false)
        assertEquals(listOf("B", "A"), pages.map { it.title })
        assertEquals(4, pages.first().weight)
        assertEquals("//thelionsrear.com/two", pages.first().href)
        assertEquals("//arcology.garden/one", pages.last().href)
    }

    @Test
    fun `localhost tagPages hrefs use the full site path key`() = runBlocking {
        val routes = listOf(route("a", "garden", "one", title = "A"))
        val roam = FakeSitemapRoamRepository(tagsByNode = mapOf("a" to listOf("tech")))
        val pages = TagIndexService(roam, FakePublishingRepository(routes), domainMap)
            .tagPages("tech", localhost = true)
        assertEquals("/garden/one", pages.single().href)
    }
}

FakeSitemapRoamRepository

Data-backed for the queries the builders use, empty for everything else.

kotlin#+name: fake-sitemap-roam-repo
/**
 * [RoamRepository] fake for the sitemap tests: data-backed for getAllFiles /
 * getLinksFrom / getLinksTo / the tag queries, empty for everything else,
 * following the EmptyRoamRepository pattern from server.org's tests. The
 * fields are `var` so tests can simulate a re-index changing file hashes.
 */
class FakeSitemapRoamRepository(
    var files: List<OrgFile> = emptyList(),
    var links: List<OrgLink> = emptyList(),
    var tagsByNode: Map<String, List<String>> = emptyMap()
) : RoamRepository {
    override suspend fun getAllFiles(): List<OrgFile> = files
    override suspend fun getFileByPath(path: String): OrgFile? = files.firstOrNull { it.path == path }
    override suspend fun insertFile(file: OrgFile) {}
    override suspend fun deleteFile(path: String) {}
    override suspend fun getAllNodes(): List<OrgNode> = emptyList()
    override suspend fun getNodeById(id: String): OrgNode? = null
    override suspend fun getNodesByFile(file: String): List<OrgNode> = emptyList()
    override suspend fun getRecentNodes(limit: Long): List<OrgNode> = emptyList()
    override suspend fun searchNodesByTitle(query: String): List<OrgNode> = emptyList()
    override suspend fun searchNodesByFilePath(query: String): List<OrgNode> = emptyList()
    override suspend fun insertNode(node: OrgNode) {}
    override suspend fun deleteNode(id: String) {}
    override suspend fun insertNodeAncestor(nodeId: String, ancestorId: String) {}
    override suspend fun deleteNodeAncestorsByFile(file: String) {}
    override suspend fun getNodeAncestors(nodeId: String): List<String> = emptyList()
    override suspend fun getLinksFrom(nodeId: String): List<OrgLink> = links.filter { it.fromNode == nodeId }
    override suspend fun getLinksTo(nodeId: String): List<OrgLink> = links.filter { it.toNode == nodeId }
    override suspend fun insertLink(link: OrgLink) {}
    override suspend fun deleteLinksByFile(file: String) {}
    override suspend fun getTagsByNode(nodeId: String): List<String> = tagsByNode[nodeId] ?: emptyList()
    override suspend fun getTagsByNodes(nodeIds: List<String>): Map<String, List<String>> {
        val ids = nodeIds.toSet()
        return tagsByNode.filterKeys { it in ids }
    }
    override suspend fun getNodesByTag(tag: String): List<String> =
        tagsByNode.filterValues { tag in it }.keys.toList()
    override suspend fun getAllTags(): List<String> = tagsByNode.values.flatten().distinct()
    override suspend fun getTagsWithCount(): List<Pair<String, Long>> =
        getAllTags().map { it to getNodesByTag(it).size.toLong() }
    override suspend fun insertTag(tag: OrgTag) {}
    override suspend fun deleteTagsByFile(file: String) {}
    override suspend fun getRefsByNode(nodeId: String): List<Pair<String, String>> = emptyList()
    override suspend fun getRefsByNodes(nodeIds: List<String>): Map<String, List<String>> = emptyMap()
    override suspend fun insertRef(ref: OrgRef) {}
    override suspend fun deleteRefsByFile(file: String) {}
    override suspend fun getAliasesByNode(nodeId: String): List<String> = emptyList()
    override suspend fun getNodesByAlias(alias: String): List<String> = emptyList()
    override suspend fun insertAlias(alias: OrgAlias) {}
    override suspend fun deleteAliasesByFile(file: String) {}
    override suspend fun getHeadingProperties(nodeId: String): Map<String, String?> = emptyMap()
    override suspend fun getHeadingProperty(nodeId: String, key: String): String? = null
    override suspend fun insertHeadingProperty(property: NodeProperty) {}
    override suspend fun deleteHeadingProperty(nodeId: String, key: String) {}
    override suspend fun deleteHeadingPropertiesByFile(file: String) {}
    override suspend fun getFileProperties(file: String): Map<String, String?> = emptyMap()
    override suspend fun insertFileProperty(property: FileProperty) {}
    override suspend fun getNodesByPropertyKey(key: String): List<Pair<String, String?>> = emptyList()
    override suspend fun getNodesWithLocation(): List<Pair<OrgNode, GeoCoordinate>> = emptyList()
    override suspend fun searchNodes(query: String): List<OrgNode> = emptyList()
    override suspend fun searchNodesByTitles(query: String, limit: Long): List<String> = emptyList()
    override suspend fun searchNodesByContent(query: String, limit: Long): List<String> = emptyList()
    override suspend fun searchNodesByTitlesBM25(query: String, limit: Long): List<FtsSearchResult> = emptyList()
    override suspend fun searchNodesByContentBM25(query: String, limit: Long): List<FtsSearchResult> = emptyList()
    override suspend fun insertNodeToFts(node: OrgNode, tags: List<String>, aliases: List<String>, content: String) {}
    override suspend fun updateNodeInFts(node: OrgNode, tags: List<String>, aliases: List<String>, content: String) {}
    override suspend fun deleteNodeFromFts(nodeId: String) {}
    override suspend fun getFtsContentCount(): Long = 0L
    override suspend fun getFailedFile(path: String): FailedFile? = null
    override suspend fun getAllFailedFiles(): List<FailedFile> = emptyList()
    override suspend fun insertFailedFile(failedFile: FailedFile) {}
    override suspend fun updateFailedFile(path: String, errorMessage: String, fileHash: String?) {}
    override suspend fun deleteFailedFile(path: String) {}
    override suspend fun deleteAllFailedFiles() {}
    override suspend fun insertFtsStaging(nodeId: String, title: String, tags: String, aliases: String, content: String) {}
    override suspend fun getFtsStagingBatch(limit: Long, offset: Long): List<FtsStagingEntry> = emptyList()
    override suspend fun getFtsStagingCount(): Long = 0L
    override suspend fun clearFtsStaging() {}
    override suspend fun clearAllTitleFts() {}
    override suspend fun clearAllContentFts() {}
    override suspend fun bulkInsertTitleFts(entries: List<FtsTitleEntry>) {}
    override suspend fun bulkInsertContentFts(entries: List<FtsContentEntry>) {}
    override suspend fun <T> transaction(block: suspend () -> T): T = block()
    override suspend fun getAttachmentsByNode(nodeId: String): List<OrgAttachment> = emptyList()
    override suspend fun getAttachmentsByType(type: String): List<OrgAttachment> = emptyList()
    override suspend fun insertAttachment(attachment: OrgAttachment) {}
    override suspend fun deleteAttachmentsByNode(nodeId: String) {}
    override suspend fun getAllDiscoveryCache(): List<FileDiscoveryCache> = emptyList()
    override suspend fun getDiscoveryCacheByUri(uri: String): FileDiscoveryCache? = null
    override suspend fun getDiscoveryCacheModifiedSince(timestamp: Long): List<FileDiscoveryCache> = emptyList()
    override suspend fun insertDiscoveryCache(entry: FileDiscoveryCache) {}
    override suspend fun updateDiscoveryCacheHash(uri: String, contentHash: String?, lastSeenAt: Long) {}
    override suspend fun deleteDiscoveryCacheByUri(uri: String) {}
    override suspend fun deleteStaleDiscoveryCache(beforeTimestamp: Long) {}
    override suspend fun clearDiscoveryCache() {}
    override suspend fun countDiscoveryCache(): Long = 0L
}

MutableRoutesPublishingRepository

The cache-invalidation test needs to mutate the route list after the builder has already cached a graph — [FakePublishingRepository] (commonTest) takes an immutable list, and server.org's mutable variant is nested inside its test class, so this file carries its own.

kotlin#+name: mutable-routes-publishing-repo
/**
 * [PublishingRepository] fake whose backing route list is mutable, so tests
 * can simulate the indexer writing a new route after the builder has cached
 * a graph — the scenario the content key exists to invalidate.
 */
class MutableRoutesPublishingRepository(
    var routes: List<RouteEntry>
) : PublishingRepository {
    override suspend fun getAllRoutes(): List<RouteEntry> = routes
    override suspend fun getRoutesByPath(path: String): List<RouteEntry> = routes.filter { it.path == path }
    override suspend fun getPublishedRoutes(): List<RouteEntry> = routes.filter { !it.isDraft && !it.isArchived }
    override suspend fun getPublishedRoutesByPath(path: String): List<RouteEntry> =
        routes.filter { it.path == path && !it.isDraft && !it.isArchived }
    override suspend fun insertRoute(entry: RouteEntry) {}
    override suspend fun deleteRoutesByFile(file: String) {}
    override suspend fun countRoutes(): Long = routes.size.toLong()
    override suspend fun getAttachmentsByNode(nodeId: String): List<PublishedAttachment> = emptyList()
    override suspend fun getAttachmentsBySourcePath(sourcePath: String): List<PublishedAttachment> = emptyList()
    override suspend fun getAttachmentsByBasename(basename: String): List<PublishedAttachment> = emptyList()
    override suspend fun insertAttachment(entry: PublishedAttachment) {}
    override suspend fun deleteAttachmentsByFile(file: String) {}
    override suspend fun countAttachments(): Long = 0
}
kotlin#+name: sitemap-test-end
}

Tangle Targets

SitemapGraph.kt

kotlin#+name: sitemap-graph-assembly:tangle ../src/jvmMain/kotlin/computer/whatthefuck/arcology/publishing/SitemapGraph.kt:noweb yes
package computer.whatthefuck.arcology.publishing

import computer.whatthefuck.arcology.database.RoamRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.security.MessageDigest

<<sigma-payload>>

<<sitemap-graph-builder>>

<<sitemap-href>>

TagIndex.kt

kotlin#+name: tag-index-assembly:tangle ../src/jvmMain/kotlin/computer/whatthefuck/arcology/publishing/TagIndex.kt:noweb yes
package computer.whatthefuck.arcology.publishing

import computer.whatthefuck.arcology.database.RoamRepository

<<tag-page-model>>

<<tag-index-service>>

SitemapTest.kt

kotlin#+name: sitemap-test-assembly:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arcology/publishing/SitemapTest.kt:noweb yes
<<sitemap-test-prelude>>

<<sitemap-graph-test>>

<<sitemap-test-end>>

<<tag-index-test>>

<<fake-sitemap-roam-repo>>

<<mutable-routes-publishing-repo>>

Server Wiring

The HTTP routes live in Arcology's Web Server's routing module: GET /sitemap renders graph.peb, GET /sitemap.json responds with the cached body + strong ETag (the ConditionalHeaders plugin turns If-None-Match into a 304), and GET tags GET /tags/{tag} serve the tag index, switching to the tag-list.peb fragment when htmx sends HX-Request: true. The handlers call the builders as suspend functions directly — no runBlocking — and the builders live as lazy members of ArcologyServer so the in-memory cache persists across requests.

Related Modules