Linuxknowledge-base

Publish a Markdown vault to Wiki.js with parity (delete-cascade)

Generate a publishable manifest from a Git vault, reconcile Wiki.js to it — create, update, AND delete — then assert exact parity and that the projection reports the source commit.

Distrosdebian, ubuntu, opensuse
Shellbash
Updated
Script
bash
# ─────────────────────────────────────────────────────────────
# Publish a Git + Markdown vault to Wiki.js as a read-only
# projection. The importer must CREATE, UPDATE, and DELETE so the
# published set exactly equals the source manifest.
# ─────────────────────────────────────────────────────────────

# 1. Generate the publishable manifest from the vault (records the commit)
cd ~/wiki
COMMIT=$(git rev-parse HEAD)
node scripts/wiki-publishable-manifest.mjs . > /tmp/manifest.json
echo "manifest built at vault commit $COMMIT"

# 2. Guard: refuse to proceed if the manifest is suspiciously small.
#    A truncated manifest + a delete pass = mass deletion. Fail closed.
COUNT=$(jq '.pages | length' /tmp/manifest.json)
MIN=10                       # set to a floor well below your real page count
if [ "$COUNT" -lt "$MIN" ]; then
  echo "manifest has only $COUNT pages (< $MIN) — refusing to import"; exit 1
fi

# 3. Reconcile Wiki.js to the manifest: create, update, delete-cascade
node tools/wiki/update-pages.mjs /tmp/manifest.json
#   create : pages new in the manifest
#   update : pages whose content hash changed
#   delete : published pages ABSENT from the manifest  (actual − manifest)
#   assert : published set == manifest set  (throws if not)

# 4. Verify parity + that the projection reports the source commit
node tools/wiki/verify-projection.mjs /tmp/manifest.json
#   → published pages == manifest pages
#   → projection recorded commit == $COMMIT

# ─────────────────────────────────────────────────────────────
# Reference: the delete-cascade + parity assertion, in ~40 lines.
# Uses the Wiki.js GraphQL API. Set WIKIJS_URL and WIKIJS_TOKEN.
# ─────────────────────────────────────────────────────────────
# Pseudocode of update-pages.mjs core:
#
#   const manifest = JSON.parse(read('/tmp/manifest.json')).pages;  // [{path, content}]
#   const wantPaths = new Set(manifest.map(p => p.path));
#   const live = await gql('{ pages { list { id path } } }');       // current wiki pages
#
#   // create / update
#   for (const p of manifest) {
#     const existing = live.find(l => l.path === p.path);
#     if (!existing)                 await gql(CREATE, p);
#     else if (changed(existing, p)) await gql(UPDATE, { id: existing.id, ...p });
#   }
#
#   // delete-cascade: remove anything published but not in the manifest
#   for (const l of live) {
#     if (!wantPaths.has(l.path)) await gql(DELETE, { id: l.id });
#   }
#
#   // final exact-parity assertion
#   const after = await gql('{ pages { list { path } } }');
#   const livePaths = new Set(after.map(p => p.path));
#   assert(livePaths.size === wantPaths.size, 'parity: count mismatch');
#   for (const path of wantPaths) assert(livePaths.has(path), `parity: missing ${path}`);

What this does

Publishes a Git + Markdown vault to Wiki.js as a one-directional, read-only projection. It generates a publishable manifest from the vault (recording the source commit), then reconciles Wiki.js to that manifest with three passes — create, update, and a delete-cascade — followed by an exact-parity assertion and a check that the projection reports the source commit. The delete-cascade is the pass a naive importer omits, which is why deleted vault pages otherwise linger forever in the wiki.

Why this approach

Publishing a knowledge base to a wiki has an obvious happy path: read the files, call the API, done. The problem nobody talks about until they’ve already built the naive version is what happens when you delete a page from the source. A create-and-update importer never removes anything, so deleted pages linger in the wiki forever — visible to visitors, surfaced by search, and presenting information you’ve explicitly decided to remove. I ran into this three weeks after setting up the initial Wiki.js integration: a page I’d reorganized and removed from the vault was still showing up in search results because the importer had never been told to delete it.

The three-pass reconciliation in this playbook (create, update, delete-cascade) treats Wiki.js as a projection that must exactly equal the source manifest after every import. That’s a different mental model from “sync, which pushes changes forward.” A projection is one-directional: the source is always right, and the target is made to match it — including deletions. This is the same model Kubernetes uses for object management (you declare desired state, a reconciler enforces it) and for the same reason: reconciling toward a declared target is more reliable than applying imperative one-off changes and hoping they converge.

The guard on the minimum page count exists because the delete-cascade is only as safe as the manifest driving it. If the manifest generator has a bug and produces an empty or truncated list, a cascade would delete your entire wiki. A floor check refuses to run the import if the manifest is suspiciously small — set it well below your real page count but above zero. This is a simple check that prevented a potentially catastrophic mistake during a debugging session when I was temporarily pointing the importer at the wrong vault path.

The exact-parity assertion at the end is the step that makes the whole thing trustworthy. “All source pages are published” is not parity — it’s one direction. Parity is “published set equals source set,” which also catches the cases where you published something you shouldn’t have, or where the cascade failed to remove a page. Assert both directions and throw loudly if they disagree.

Prerequisites

  • A running Wiki.js instance with the GraphQL API enabled and an API token (WIKIJS_URL, WIKIJS_TOKEN).
  • A Git vault whose publishable pages are selectable into a manifest (jq installed for the guard).
  • Node.js 18+.

Notes

  • Substitute your own values before running any line — this playbook uses example stand-ins. Replace WIKIJS_URL and WIKIJS_TOKEN (your instance and API token), ~/wiki (your repo path), and the $MIN floor (set it below your real page count).
  • The delete-cascade is the whole point. Create+update alone leaves orphans: a page deleted in the vault stays published forever because nothing removes it. The cascade removes every published page absent from the manifest (actual − manifest).
  • Guard the cascade against a bad manifest. A truncated or mis-scoped manifest plus a delete pass equals mass deletion. The $MIN floor refuses to import a suspiciously small manifest — set it well below your real page count.
  • Assert exact parity at the end. After reconciling, verify the published set equals the manifest set (count and membership) and throw loudly if not. Parity you don’t assert is parity you don’t have.
  • Record and verify the commit. The projection should report the vault commit it was built from so staleness is detectable at a glance. If it doesn’t match vault HEAD, the wiki is stale — rebuild before trusting it.
  • One direction only. Never edit content in Wiki.js directly; it is a projection. All edits happen in the vault, then the projection is rebuilt.
  • Mark tested: true only after running the full cycle (including a deletion) against your own Wiki.js.