SWIFTPOINT//HUB
Support

Guides system — how `/docs` is built

The guides are markdown files in docs/, rendered by lib/docs/index.js — an "observestyle" engine with wikilinks, callouts, tabs, heading anchors, a bidirectiona

The guides are markdown files in docs/, rendered by lib/docs/index.js — an "observe-style" engine with wikilinks, callouts, tabs, heading anchors, a bidirectional link graph and a Discord text export. This page covers that engine, the URL rules, the three-column reader, the tree and tag filter, the notes map, and the admin editor that writes the files. The markdown syntax itself is in 12 The markdown reference and 13 The markdown blocks.

What the guides are

Markdown files under docs/, served by the application itself at /docs/<path>. There is no docs/wiki/ any more: the folder was one level that never appeared in a URL, and every guide's address — /docs/profile-builder/testing — was already the path below it. The tree is now:

docs/
  testing.md            one guide at the root
  profile-builder/       the eleven Player guides (README + ten)
  recordings/            every .rec.json there is, and no guides
  about/                 this documentation — inside the tree, closed by a visibility rule

The central design decision is stated in the root README.md:

Warum der Weg über eine API und nicht über ein eigenes CMS: die Guides sind diese Dateien. lib/docs/index.js liest docs/, und dieselbe Datei wird gerendert, in der sitemap geführt und in der Suche gefunden. Ein zweiter Speicher würde bedeuten: zwei Orte, an denen eine Guide stehen kann, und ein Import, der irgendwann vergessen wird.

There is one store. The same file that is rendered is the one listed in the sitemap and found by the search, and what the admin panel saves is a file, live at /docs/<path> immediately, with its folder, breadcrumb, tags, position in the tree and preview image. No import step, no second database table, no restart.

The engine

lib/docs/index.js, 1281 lines. Everything is generated server-side and ships with zero inline scripts, which is what keeps it CSP-safe under script-src 'self'.

Feature Mechanism
YAML front matter title, description, tags (inline [a, b] or a block list), order
Wikilinks note, label, #section, label, resolved by preprocess()
Relative links resolveDocHref() — a .md target or a folder README.md becomes an absolute /docs/<stem> URL
Relative images image renderer rule — class="d-zoom", loading="lazy", referrerpolicy="no-referrer", resolved to /docs/<rel>
Obsidian callouts > [!note|info|tip|warning|danger|success|example] rewritten to ::: containers
Changelog blocks ::: added / changed / fixed / removed / security, imported from lib/markdown.js so there is one list of names across the forum, the updates and the guides
Tab blocks ::: tabs / ::: tab "Title" … ::: as a block rule, nested tabs refused at depth > 2
::: spoiler A closed <details>, no JavaScript
::: video The same videoEmbed() the forum uses, with the d- class prefix
::: steps Header-only embed of a .rec.json; the body between fences is consumed and ignored
Syntax highlighting Server-side highlight.js into <pre class="hljs"><code>
Heading anchors slugify() on the heading text, tabindex="-1", duplicates suffixed -1, -2
Table of contents Captured by the heading_open rule into env.toc
Link graph extractLinks() per doc, resolved against the three lookup maps, de-duplicated into outgoing/incoming sets
Discord export toDiscord() — tables to monospaced blocks, wikilinks to absolute links, callouts to bold

Exports

Export Used by
load() Everything. The mtime-cached index.
resolveSlug(slug) rawFor() and the panel's path handling
resolveDoc(slug) renderDoc() and the route's 301 decision
docUrlForRel(rel) Prev/next cards, the breadcrumb, /api/guides
resolveAsset(slug) GET /docs/<image>
resolveRec(slug) GET /docs/<slug>.rec.json
renderDoc(slug) The reader page
renderFragment(src, ctx) The admin preview
treeHtml(activeSlug, opts) #d-tree
graphData() GET /api/docs-graph
apiTree() GET /api/docs-tree
tagsStats() The rail's tag list with counts
rawFor(slug) GET /api/docs/<slug>/raw and /discord
toDiscord(src, resolveName) GET /api/docs/<slug>/discord
slugify(text) Anchor ids, folder-name slugification in lib/docs/write.js
siteUrl(p), SITE_URL, DOCS_ROOT, escHtml(s) URL construction and escaping

The index

load() walks docs/ and caches the result against a map of every .md file's mtimeMs. If no file's mtime changed, the cached index is returned as-is; a single save anywhere rebuilds the whole thing.

Ordering, in walkTree():

  1. folders first, localeCompare with sensitivity: 'base' — case-insensitive
  2. then files, by front-matter order (missing order sorts as 999), then by name, case-insensitively
  3. README.md is not special-cased here — a folder's README.md sorts on its own order, and in practice the shipped ones carry order: 0

walkFiles() separately sorts README.md to the front of each folder before the front-matter pass, so the index's flat array and the reader both treat a folder's index as first.

Three lookup maps are built from flat, and they are what wikilinks resolve against:

Map Key Value
bySlug the slug, lowercased the real slug
byName the last path segment, lowercased the real slug
byTitle the title, lowercased (first wins) the real slug

resolveNameFrom() builds candidates in this order and takes cands[0]:

Order Map Test
1 bySlug the target, .md stripped
2 bySlug <current folder>/<target>, lowercased
3 byName the last segment — only when the target has no /
4 byTitle the full title, lowercased

With the real filenames in docs/profile-builder/:

  • [Profile Builder · Quickstart — your first profile in ~5 minutes](/docs/profile-builder/quickstart) from profile-builder/tilt-and-pivot.md misses bySlug (there is no root-level quickstart), hits bySlug on profile-builder/quickstart at step 2 → slug profile-builder/quickstart.
  • Every trigger & action from profile-builder/deep-click.md misses bySlug at step 1 and step 2 (no profile-builder/profile-builder/bindings), hits byName on bindings at step 3 → slug profile-builder/bindings, label Every trigger & action.
  • [Profile Builder · The interface, your way around](/docs/profile-builder/interface) hits nothing until step 4, byTitle → slug profile-builder/interface.

An unresolvable wikilink degrades to its label, or to the bare … text if it has none — it never becomes a link to nowhere.

URLs

A guide is at /docs/<slug>. A folder's README.md is served at the folder, so /docs/profile-builder, not /docs/profile-builder/README — docUrlForRel() strips a trailing /README and returns the folder path.

resolveDoc(slug) normalises the path, refuses anything containing a .. segment, and then tries six candidates in two passes of three:

# Candidate Pass
1 the path exactly exact
2 the path plus .md exact
3 the path plus /README.md exact
4 the path exactly, case-insensitively case-insensitive
5 the path plus .md, case-insensitively case-insensitive
6 the path plus /README.md, case-insensitively case-insensitive

The first three that hit a real file win with exact: true. A hit only in the second pass returns exact: false, and the route turns that into a 301 to docUrlForRel(found.rel) with the query string preserved:

const at = req.originalUrl.indexOf('?');
const qs = at === -1 ? '' : req.originalUrl.slice(at);
return res.redirect(301, `${eng.docUrlForRel(found.rel)}${qs}`);

A fragment never reaches the server, so there is nothing else to carry over. exact: false is what separates a mis-spelled guide from a folder: /docs/profile-builder is the folder's own address and has always been.

Concretely, with docs/profile-builder/testing.md on disk:

Request Resolves to Response
/docs/profile-builder/testing profile-builder/testing.md 200
/docs/profile-builder/Testing same file, exact: false 301 → /docs/profile-builder/testing
/docs/profile-builder/Testing?tag=tilt same file 301 → /docs/profile-builder/testing?tag=tilt
/docs/profile-builder profile-builder/README.md, exact: true 200 — a folder is its own address
/docs/profile-builder/README same file 200
/docs/profile-builder/testing.md same file 200

test/docs-slug.test.js writes a temporary MixedCase.md and asserts both spellings find it and that exactly one canonical slug is reported.

Two sibling resolvers with deliberately narrow extension sets:

Resolver Accepts Rejects
resolveAsset(slug) .png .jpg .jpeg .webp .gif .svg .avif everything else, and anything with a .. segment
resolveRec(slug) *.rec.json everything else

The reader page

Server-rendered by routes/docs.js in three columns.

Column Id Contents
Explorer d-explorer #d-tags (chips or the filter notice), #d-tree, #d-nomatches
Main d-main .d-crumb breadcrumb, #d-article, .d-prevnext
Rail d-rail "On this page" #d-outline, "Tags" #d-rail-tags, "Linked from" #d-rail-links

The tool strip

The guide's own controls live in the shared header's second row (DOCS_TOOLBAR), written in the same tbar-* vocabulary as the Profile Builder's and the OLED editor's, so one sticky header instead of two bars racing for the same z-index.

Id Label Notes
d-toggle-tree ☰ aria-controls="d-explorer", toggles body.d-tree-open
d-search Search guides… <kbd>/</kbd> hint beside it
d-graph-btn Notes map Calls window.SpgGraph.open()
d-discord-btn Copy for Discord hidden until the page has a slug

Page furniture

Above the <h1>: the tag chips. Under the title: the description. Then the meta row — <folder> · Guides with hyphens turned into spaces, and <slug>.md:

<div class="d-article-meta">
  <span class="d-article-fold">profile builder · Guides</span>
  <span class="d-article-meta-n">profile-builder/testing.md</span>
</div>

The H1 is rendered into the page header and stripped from the body: renderDoc() removes a single leading # … line before calling markdown-it, so there is exactly one h1 and the header owns it.

A 404 renders the same shell with docAttrs: '', noindex,nofollow, and the body No such guide.

The extra stylesheet

css/docs.css is loaded by thread.html as well as by the reader, because a ::: steps embed brings its own d--prefixed styles with it. Every selector in docs.css is d-prefixed, so the page that loads it for one embed cannot be restyled by the rest of the file — public/admin.html uses the same argument for its guides preview.

The tree

treeHtml(activeSlug, { tag }) produces nested <ul>s. A folder is <li class="d-tree-dir"> with a button.d-tree-dir-h[data-dir] and a ul.d-tree-dir-b; a guide is <li class="d-tree-file"> with an <a class="d-tree-file-a" href="/docs/<slug>"> carrying a data-search haystack of title + rel path + tags.

Collapse state is persisted in localStorage under swh-docs-fold as { [dirPath]: boolean }, and a folder containing the active file is always open on load regardless of what is stored.

Search, in public/js/docs.js:

  • debounced 90 ms
  • a case-insensitive substring match against each link's data-search
  • non-matches get is-miss on the li, which docs.css styles as display: none
  • a folder body with no visible child gets is-empty on the li, which dims its heading to opacity: .45
  • #d-nomatches is unhidden with "No guides match that search."
  • Escape in the box clears it and blurs the field

A folder with nothing matching inside it is hidden entirely — asked unconditionally, not only under a tag filter. folderKeeps(st, folderRel, tag, hasTag) walks every doc and asks whether anything at or beneath that folder carries the tag; with an empty tag that is "is there any guide here at all". The comment in treeHtml() explains why:

Asked unconditionally, not only under a tag filter. A folder holds assets as well as guides — recordings/ is a directory of .rec.json files that a guide's ::: steps points at, and it has no guide in it at all — so it was listed, expanded on click, and showed an empty tree. It is still on disk and still resolves; it has simply stopped being something to navigate to.

That is docs/recordings/, which is the one directory every recording on the site lives in — testing-replays.rec.json and new-profile.rec.json today. It sits at the root of the tree rather than inside a guide's folder because a recording is a shared asset: two guides can embed the same walkthrough, and it must not belong to either of them, so ::: steps addresses it as recordings/<slug>.rec.json from anywhere. The admin still sees the folder: lib/docs/write.js's tree() walks the filesystem, not the guide index, so the panel's folder list and its Del button still work on it. See Walkthrough recorder and test/docs-empty-folders.test.js.

Tags

Tags come from the front matter and are free-form; nothing validates them against a list.

The key point, from the root README.md:

Sie sind Links auf /docs?tag=<tag>: der Filter wird beim Erzeugen der Seite angewandt, nicht nachträglich im Browser. Das ist der Grund, warum die Adresse funktioniert — sie lässt sich speichern, in einen Chat kopieren und in einem neuen Tab öffnen, und sie funktioniert auch ohne JavaScript.

The filter is applied when the page is generated — eng.treeHtml(doc.slug, { tag: currentTag }) — not afterwards in the browser. That is why the address works: it can be saved, pasted into a chat, opened in a new tab, and read with JavaScript disabled.

normaliseTag(t) trims and lowercases, so #Tilt and tilt are one tag. The rail lists the top 14 tags by count from tagsStats(), each with its count, and marks the active one is-active. The explorer above the tree shows a filter notice — Showing guides tagged #tilt with a Clear link back to /docs — instead of a second copy of the tag list.

The bug the README records

Warum das früher nicht funktioniert hat: der Filter setzte intern eine Klasse namens is-miss, für die es in keinem Stylesheet eine Regel gab. Nichts wurde ausgeblendet — der Klick hat die Suchleiste gefüllt, aber der Baum blieb unverändert.

Two halves, both now tested in test/docs-tags.test.js:

  • .d-tree-file.is-miss { display: none; } exists in docs.css, and the script still sets the class — neither half alone is enough
  • the tags are anchors to /docs?tag=, not buttons with data-tag, the bindTag handler is gone from docs.js, and tagsStats() is called exactly once in routes/docs.js

folderKeeps is the other half that is easy to get wrong: a folder stays in a filtered tree if anything deeper inside it matches, or the filter would hide the very guide that has the tag.

  • Anchor rule. slugify(text): lowercased, every run of non-letter-or-digit replaced with -, leading and trailing dashes stripped, repeated dashes collapsed, empty result becomes sec. Unicode letters and digits are kept (\p{L}\p{N}, with the u flag).
  • Duplicates. The first occurrence keeps the bare slug, the second gets -1, the third -2, counted in env.ids across the whole document.
  • The H1 is moved. A single leading # … line is removed from the body and its text printed in the header.
  • The TOC lists level >= 2 only. Headings inside a tab pane are rendered by a nested md.render(inner, { toc: [] }), so they get anchors but never an outline entry. Empty state: No sections.
  • Scroll-spy. IntersectionObserver over .d-prose h2, h3, h4 with rootMargin: '-20% 0px -70% 0px'. When the active entry falls outside the rail's box it is scrolled into view with block: 'nearest'.
  • Prev/next come from the global reading order (st.flat), not the folder, so the pager crosses folder boundaries.
  • Backlinks read as "Linked from", sorted by title. Empty state: "There are no guides that point here yet."

The caveat. extractLinks() is regex-based and not fence-aware. A link inside a fenced code block still produces an edge in the graph and an entry in the target's "Linked from" list. The shipped guides document their own vocabulary, and a guide about linking would therefore acquire a phantom backlink — a small, known imprecision rather than a design decision.

Reading affordances

public/js/docs.js, 288 lines, no inline scripts. The page is fully server-rendered; this file only wires behaviour.

Affordance Behaviour
Copy button Every <pre> with a <code> child gets a Copy button. On success the label becomes Copied! with a d-copied class for 1300 ms. A document.execCommand('copy') fallback runs when the Clipboard API rejects.
Lightbox Every image carries d-zoom and opens #d-lightbox. The caption comes from title, falling back to alt when it is not the URL. Closes on the backdrop, #d-lightbox-close or Escape.
/ Focuses #d-search. Ignored in INPUT and TEXTAREA, and ignored with any of meta, ctrl or alt held.
Escape Closes the notes map, then clears and blurs the search, then closes the lightbox.
Anchors Every a[href^="#"] smooth-scrolls and calls history.replaceState(null, '', '#' + id).
Header height --hdr-total-h is measured from .hdr.offsetHeight and kept in sync by a ResizeObserver (falling back to a resize listener). The sticky explorer and rail sit below the header, and css/hdr.css can only estimate its height — a wrapping tool strip or a zoomed viewport would otherwise leave a column stuck underneath.
Mobile drawer #d-toggle-tree toggles body.d-tree-open and sets aria-expanded; a click outside #d-explorer and the toggle closes it.
Toasts #d-toast, hidden again after 2200 ms.

The notes map

public/js/docs-graph.js (675 lines) plus GET /api/docs-graph.

The data

graphData() returns:

  • one node per guide: id (the slug), title, a short label, group (the folder, or 'Guides'), order, isIndex, href
  • one edge per resolved cross-reference, de-duplicated on source|target

dominantPrefix(docs) finds the <folder> · head that nearly every guide in a folder shares, counting only non-README guides, and returns it only when the best head covers at least 80 % of them. A single odd title out of eleven is not a convention, and dropping a real part of a title is worse than a long label. nodeLabel(title, prefix) then strips that prefix and everything from the first em dash, because a map of eleven page-heading sentences is unreadable and a map that is reshaped every time it is opened cannot be read. A folder README is labelled Overview.

With the guides currently shipped, the dominant prefix is Profile Builder · (10 of 11 non-index guides), and the labels come out as Quickstart, Your first binding, Bindings, Deep-click & pressure zones, Tilt & pivot, Nesting, The OLED screen, Settings & DPI, Save & publish, plus Overview and Test. test/notes-map.test.js asserts no label exceeds 44 characters and that none still carries the folder prefix.

The rendering

Three parts, each answering a question the others cannot.

The list. #d-graph-list is an <ol> with a real <a> per note, its order number, and its link count ("1 link" / "N links"). The current note gets is-here. Nothing to guess at: if it is in the docs it is in the list, in words, at 100 % zoom, with a keyboard — so the feature still works with the canvas hidden.

The map. #d-graph-canvas, laid out in two shapes, both built from measured text width rather than from the viewport:

Shape When Geometry
ring default The index card at the centre; the guides around an ellipse with aspect = 1.7, starting at the top (-π/2) and going clockwise. The radius is grown in 0.1 steps until Ramanujan's ellipse perimeter holds every card plus an 18-unit gap (CARD_GAP), then never shrunk below what the centre card needs. Two titles therefore cannot overlap.
flow ring.length <= 2, st.w < widest * 2.1, or cols >= ring.length The index on top, the guides wrapped into as many columns as fit. Eleven cards in a ring on a phone is a scale of 0.4, and a map nobody can read is the thing this replaced.

fit() clamps the scale to 0.45–1 and centres the result in the space the panels leave.

Each link is one bowed quadratic arc, with the bow fanning out within a near-clique:

l.bow = 0.1 + ((i - 1) - (m - 1) / 2) * 0.09;

The chord control point is offset perpendicular to the line by dist * bow, so parallel chords separate instead of lying on top of each other.

The panel. #d-graph-links sits at a fixed height (172 px on a desktop, 140 / 156 in the narrow breakpoints) — because the map is fitted to the space the panel leaves, and a panel that grew and shrank would move the map under the pointer every time you crossed a note. It shows N out · N in and one row per link with a → / ← marker and an openable <a>. With nothing focused: "Nothing focused", N notes, and the placeholder row.

Interaction

Action Result
Hover a card or a list row Isolates it: non-neighbour cards drop to globalAlpha 0.26, non-incident arcs go to rgba(255,255,255,0.045), the focus card gets its group colour at full weight
Click a card Opens the guide. A drag of more than 4 px between pointerdown and pointerup is a drag, not a click
Drag a card Moves that card only
Drag the background Pans (viewX / viewY), and the overlay gets d-graph-panning
Wheel Zooms about the cursor, ×1.1 per notch, clamped to 0.3–2.4
Arrow keys Step through the non-index notes, wrapping in both directions
Enter Opens the focused note
Escape / the close button Closes and restores focus to whatever was focused before

The data is fetched once on first open and cached in the closure; if the fetch fails the overlay closes rather than being left open and broken.

window.SpgGraph.state() is exposed for tests, returning mode, canvas, scale, view, stage, links and every node's x, y, w, h plus its computed left/right/top/bottom. The comment says why:

The one property of the layout that cannot be checked by reading the code is that no two cards overlap … Exposed so a test can ask, and so a screenshot in a bug report is not the only evidence that it holds.

Readout, legend, hint

#d-graph-readout shows N guides · N cross-references, or <title> · N links for the focused note. #d-graph-legend is one chip per group from a six-colour palette:

#c6f442  #79b4ff  #43d9b6  #b78af8  #ff9f43  #ff6b5e

#d-graph-hint swaps between two texts so it describes the layout actually on screen — the canvas version mentions dragging and zooming, the list-only version says "Every note, in reading order…". See Testing for test/notes-map.test.js.

Who may read a guide

Most guides are public. Some are not: about/ is the inside of this project — the format notes, the known gaps, how the editor is put together — and that is not reading material for the public feed.

The files do not move. A guide's URL is written into other guides, into the sitemap and into the sitemap's own index, so moving them would break every one of those addresses; what changes is who they are served to. Visibility is therefore a row in doc_visibility (scope = folder | doc, target = the folder path or the guide slug, level = staff | admin), read and written only by lib/docs/visibility.js.

The rules, in the order they matter:

  • The strictest rule wins. An admin-only page inside a staff-only folder is admin-only. That is the only combination that behaves the way somebody looking at two tick boxes expects.
  • An admin sees everything, without asking the table. Otherwise one ticked box can lock out the person who needs to untick it.
  • A missing row means public. A folder nobody has restricted is fully open, so the default cannot hide a guide by accident.
  • No viewer means the public view. treeHtml, graphData, apiTree, tagsStats, renderDoc, resolveDoc and rawFor all take an optional viewer and default to anonymous — a forgotten argument hides a closed guide rather than publishing it.

viewerFor(req) (routes/auth.js) is asked on every request in routes/docs.js. It re-reads the allowlist rather than the session, for the same reason every gate in this codebase does: a session written a week ago is a record of who signed in, not of what they may do now.

What that answers, everywhere:

Surface Public answer for a closed guide
/docs/<slug> 404 — the page body is the same answer a missing guide gets, and the landing page picks the first guide the reader may open rather than redirecting everybody into a 404
treeHtml() the folder and the guides inside it are not rendered — not greyed out, because the tree is also the search index and data-search carries the title
graphData() no node and no edge. A node is a title and an arc says "this guide says that about that one", so a map of the closed section is an inventory of it
apiTree() / tagsStats() gone, and tag counts are recounted over the readable guides, so a tag only staff guides carry does not appear in the rail with a count nobody can reach
rawFor() 404 for the raw source and the Discord copy
/docs/og/<slug>.png 404 — the card draws the title and the description
sitemap.xml absent. No viewer is passed, because a crawler is the public

Prev/next and the backlinks rail are filtered the same way: Previous must not step onto a 404, and a closed guide that links here must not appear in the rail of a public one.

In the panel, the tick box on a folder row and on a guide row is that row's own rule. A guide closed by a folder above it shows the box unticked with a line saying which folder closed it and why ticking it there only gets stricter. Renaming a folder or a guide carries the rule along (visibility.renameTarget), and deleting one drops it — a closed folder that is deleted and later restored from git would otherwise come back closed with no trace of why.

The about/ rule is seeded once on first boot, so the folder is closed from the first request rather than from the moment somebody remembers to tick the box, and the panel shows it ticked, so the state is visible rather than invisible and unchangeable. ON CONFLICT DO NOTHING means an admin who unticks it keeps it unticked across every restart. See Auth & roles and test/docs-visibility.test.js.

Authoring a guide

From the admin panel — see 16 Admin panel for the full editor.

  1. Pick a destination. Click a folder's + to create a guide inside it, or New guide at the top of the tree to use the currently open guide's parent folder. New folder does the same for a folder.
  2. Set the fields. Title in #g-title, then the Details disclosure holding #g-desc (300), #g-tags (comma separated) and #g-order (0–999).
  3. Write. The editor is SPMarkdown.create with rows: 18, max: 32000 and blocks: true, mounted on #g-editor.
  4. Preview. It posts to /api/admin/docs/preview and renders with previewClass: 'd-prose'.
  5. Save. PUT /api/admin/docs/{slug}, then View opens the live page.

State lives in #g-state, showing Saved, Unsaved or No changes. A beforeunload guard fires while dirty.

Drafts persist in localStorage under sp:admin:guide. On open, loadDraft() restores the body and the panel says "Restored an unsaved draft from last time." clearDraft() runs after a successful save.

The Link to… button fetches /api/forum/threads?limit=8&sort=activity and /api/forum/updates?limit=8, lists them as 1. Update · <title> / 2. Thread · <title> in one prompt, asks for the link text, and inserts a markdown link. Deliberately a link and never an embed:

Forum content changes and is deleted; a guide that quotes a ticket is a guide that is wrong next month, and one that links to it is one click from the source.

The recorder page has its own versions of both halves: the embed snippet ::: steps "<title>" recordings/<slug>.rec.json is built in one place in records.js together with the Edit-in-editor address /editor?record=docs/recordings/<slug>.rec.json, and rebuilt whenever the slug field changes so a rename is visible before it is saved. recorder.js's export panel writes a whole guide with front matter, an > [!tip] callout, the step list, the embed and a "Related guides" section of wikilinks. See Walkthrough recorder.

Four things the writing API gets right

These are the four bullets in the root README.md, expanded.

1. Path safety

Every operation in lib/docs/write.js goes through one function:

function absFor(rel) {
  const raw = String(rel == null ? '' : rel);
  if (raw.includes('\0')) return null;
  if (raw.includes('\\')) return null;                  // windows separators
  if (raw.startsWith('/')) return null;                 // absolute
  const parts = raw.split('/').filter((p) => p !== '');
  if (parts.some((p) => p === '..' || p === '.' || !p.trim())) return null;
  const root = path.resolve(DOCS_ROOT);
  if (!parts.length) return root;
  const abs = path.resolve(root, parts.join('/'));
  if (abs !== root && !abs.startsWith(root + path.sep)) return null;
  return abs;
}

Four refusals — .., absolute, backslashes, whitespace-only segments — plus the backstop that the resolved result is inside docs/. The backstop alone would be enough to be safe; the first three are there so the answer is "no" rather than a resolve-and-compare.

The second half is subtler: a folder can never have the same name as a guide. createFolder() checks isFile(${abs}.md) and refuses, because resolveSlug finds the .md first and the folder URL would not exist — a folder with no URL is worse than a refusal:

if (isFile(`${abs}.md`)) {
  return { error: `There is already a guide at ${rel ? `${rel}/` : ''}${base}.md. A folder cannot have the same name.` };
}

segment() slugifies every name and slices to MAX_NAME, so a folder name is always a valid URL segment; a name that slugs to nothing (or to sec) is refused.

2. Front matter is preserved

description, tags and order decide where a guide appears and how it is found, and they are not in the text somebody edits. splitFront() separates the block into fields (title, description, order, tags), body and other — every key the module does not own, verbatim and in its original order. buildFront() writes title, then description if set, then a block tags: list, then order if it is a finite number, then other, then the closing fence.

A guide somebody wrote by hand with three custom keys does not lose them because it was opened in a web editor. tags are read as raw block-list lines and written back unquoted, precisely so re-quoting cannot turn one tag into two.

3. Atomic writes

function writeAtomic(abs, text) {
  const dir = path.dirname(abs);
  const tmp = path.join(dir, `.${path.basename(abs)}.${process.pid}.tmp`);
  fs.writeFileSync(tmp, text, 'utf8');
  fs.renameSync(tmp, abs);
}

A temp file beside the target and a rename. A half-written guide is never what the renderer reads — it would be served to a reader, and it would be the one they see until somebody noticed.

4. Deleting a folder is a question

The first call refuses and counts. deleteFolder() without recursive: true on a non-empty folder returns:

{ "error": "That folder is not empty.", "needsConfirm": true, "files": 1, "folders": 0, "countsGuides": 1 }

and routes/docs-write.js turns needsConfirm into a 409. Only a second call with recursive=true deletes. The count is the point: a folder here holds recordings and screenshots, and the answer to "are you sure" has to be the number of files, not a yes/no the reader cannot know. The root itself can be neither deleted nor renamed from the panel.

The limits

Constant Value Enforced
MAX_NAME 60 segment() — folder and file names
MAX_TITLE 140 #g-title maxlength, and the front-matter write
MAX_DESCRIPTION 300 #g-desc maxlength
MAX_TAGS 12 yamlList() — items past 12 are dropped
MAX_BODY 200000 createDoc(), saveDoc(), the preview route
Panel editor 32000 SPMarkdown.create({ max: 32000 })

GET /api/admin/docs returns limits: { name, title, description, body } so the panel could read them rather than hard-code them — MAX_TAGS is not in that payload and not in lib/docs/write.js's module.exports either.

The body is capped in exactly one place: lib/docs/write.js slices it with .slice(0, MAX_BODY) in createDoc(), saveDoc() and the preview route. routes/docs-write.js declares const LIMIT = 220_000 with a comment about refusing a huge body before it is parsed, but never uses it — the effective transport cap is the app-wide express.json({ limit: '64mb' }) in server.js, which exists for recordings and is far larger than MAX_BODY.

The panel editor caps at 32000 while the server keeps 200000, so a guide can be longer than the panel can edit — that is a deliberate headroom, not an inconsistency, but it does mean a 100 KB guide cannot be maintained from the browser.

renderFragment(src, { slug }) renders with the guides engine and the real slug, so slugDir is the folder the guide will live in and relative images and ::: steps resolve as they will after saving. That is the whole reason the preview has its own endpoint.

But preprocess() is called with () => null as its resolver, so wikilinks do not resolve in the preview. A […](/docs/profile-builder/bindings) shows its label and no link. Relative paths and embeds are honest; wikilinks are not, and the resolver is the reason.

The preview endpoint reads the source from content or body, and refuses an empty request with Nothing to preview.:

This endpoint used to accept only the first — so when the editor sent the second it rendered an empty document and the Preview tab was blank for every guide ever written. A preview that silently shows nothing is worse than one that errors.

The Discord export

#d-discord-btn fetches GET /api/docs/<slug>/discord as text/plain, copies it and toasts "Copied — ready to paste into Discord", then scrolls to the top. A failure toasts "Could not fetch the Discord text".

toDiscord(src, resolveName) applies these transformations in this order:

  1. ::: tabs lines removed
  2. ::: tab "Title" → **Title**
  3. every bare ::: removed
  4. > [!type] rest → > **TYPE** — rest (the em dash and the rest only when rest is non-empty)
  5. tables → fenced ```md blocks with padded cells, │ a │ b │ rows, joined with │, cells padEnd-ed to the widest in the column with a minimum of 3
  6. wikilinks → [label](https://mecke.dev/swiftpoint/docs/<slug>), anchors slugified
  7. relative .md links → absolute URLs on the same hard-coded domain
  8. ####, #####, ###### → bold
  9. --- → a 27-character rule ('───'.repeat(9))
  10. every <...> construct stripped — which also removes autolinks
  11. runs of 2+ spaces collapse to one, runs of 3+ newlines become 2

The delimiter row stays in the table as a row of dashes, and the alignment markers are not preserved.

The domain in steps 6 and 7 is hard-coded to https://mecke.dev/swiftpoint, not derived from BASE_URL or SITE_URL.

Three caveats an author needs

  1. A callout written as ::: note … ::: is not converted, and the opening fence survives as literal text ::: note. The same is true of ::: changed and every other changelog kind, of ::: steps and of ::: video — only the Obsidian > [!note] form is rewritten, because that is the only one the regex matches. Write guides in the > [!note] form when the page has to paste cleanly.
  2. The .md rewrite regex matches any link whose target ends in .md, and does not resolve it against the guide's folder. In docs/profile-builder/testing.md the line [deep-click.md](../deep-click) becomes https://mecke.dev/swiftpoint/docs/deep-click — a docs URL that does not exist, because the real slug is profile-builder/deep-click. An external https://x.com/a.md becomes a nonsense docs URL for the same reason.
  3. Spacing-based alignment is lost. Table padding is preserved inside the code block, but nothing about the original column widths survives.

The guide endpoints

Read

Route Returns
GET /docs The first guide in reading order (st.flat[0]), or the 404 shell
GET /docs/<slug> The reader page, or a 301 to the canonical URL
GET /docs/<slug>.rec.json The recording JSON, falling back to the recordings table
GET /docs/<image> The asset, only for the seven allowed image extensions
GET /docs/og/<slug>.png A per-guide 1200×630 OG card from lib/docs-og.js, Cache-Control: public, max-age=3600
GET /api/docs-tree [{ slug, title, description, folder, tags, path }]
GET /api/guides { guides: [{ title, docTitle, docUrl, url, steps }] } — the published walkthroughs
GET /api/docs-graph { nodes, links } for the notes map
GET /api/docs/<slug>/raw The original markdown, text/plain
GET /api/docs/<slug>/discord The Discord-ready text, text/plain

GET /api/guides is public and read-only on purpose. It carries no file paths and no author ids, and a recording is only offered when the file or the table row actually exists — an empty link to a recording that is not there is worse than no link.

See 03 Pages and routes and 18 API reference.

Write — every route requireAdmin

Route Body Notes
GET /api/admin/docs — folders, docs, limits
GET /api/admin/docs/:slug — raw, body, front
POST /api/admin/docs/folders { parent, name } 201
POST /api/admin/docs/documents { parent, title, description, tags, order, content } 201, refuses to overwrite
PUT /api/admin/docs/:slug { content, title, description, tags, order } { ok, unchanged?, slug, bytes? }
PATCH /api/admin/docs/:slug { title } Renames the file; adds linkedGuides
DELETE /api/admin/docs/:slug — Removes only that guide
PATCH /api/admin/docs/folders/:slug { name } Moves everything inside; adds linkedGuides
DELETE /api/admin/docs/folders/:slug ?recursive=true 409 with a count first, then deletes
POST /api/admin/docs/preview { content } or { body }, { slug } { html }, 400 when both are absent

The gate is on each route rather than on the sub-router, and routes/admin.js mounts docs-write.js explicitly so the check in test/admin-access.test.js can see the routes.

The shipped guides

docs/ as it stands:

File Title order Tags
profile-builder/README.md Swiftpoint Z3 · Profile Builder — Guides 0 overview, index
profile-builder/quickstart.md Profile Builder · Quickstart — your first profile in ~5 minutes 10 getting-started, quickstart
profile-builder/testing.md Profile Builder · Your first binding — watched step by step 15 getting-started, bindings, interactive
profile-builder/interface.md Profile Builder · The interface, your way around 20 interface, ui
profile-builder/bindings.md Profile Builder · Bindings — every trigger & action 30 bindings, triggers, actions, reference
profile-builder/deep-click.md Profile Builder · Deep-click & pressure zones 40 deep-click, pressure, zones
profile-builder/tilt-and-pivot.md Profile Builder · Tilt & pivot — using the IMU 50 tilt, imu, gesture
profile-builder/nesting.md Profile Builder · Nesting — one input gated behind another 60 nesting, bindings, gaming
profile-builder/oled-actions.md Profile Builder · The OLED screen 70 oled, display, visuals
profile-builder/settings-and-dpi.md Profile Builder · Settings & DPI 80 settings, dpi, polling
profile-builder/save-and-publish.md Profile Builder · Save & publish — getting profiles out 90 export, spcf, sharing
testing.md New Profile — your first binding, recorded 100 getting-started, bindings, interactive
about/*.md 25 files — closed to the public
recordings/testing-replays.rec.json — — —
recordings/new-profile.rec.json — — —

Twelve public guides, plus the closed about/ section. docs/testing.md is the one guide at the root of the tree; it exists because the new-profile recording needed a home, and it is also the test that the recordings rule holds: a guide outside profile-builder/ embedding recordings/new-profile.rec.json gets the same player as one inside it.

collectGuides() reads the ::: steps embeds out of the markdown at index time — not out of the rendered page — so the list exists without rendering. Two guides are currently published: profile-builder/testing.md, pointing at recordings/testing-replays.rec.json, and testing.md, pointing at recordings/new-profile.rec.json. Because the resolver normalises to the one recordings directory, both list the same way from either folder.

That same list powers three surfaces from one line of markdown:

  • the reader page's own embed, which fetches the .rec.json from /docs/
  • the front page (landingGuides() in routes/seo.js), which shows the first four with a step-count badge and links to /editor?record=<url>
  • the Profile Builder's first-run panel, which fetches GET /api/guides and offers the first one

How this is tested

File What it protects
test/docs-slug.test.js Canonical URLs, the case-insensitive fallback, and that exactly one canonical slug is reported
test/docs-tags.test.js The filter, the case/space-insensitive comparison, the is-miss rule actually existing and actually being set, tags being links, one tag list per page
test/docs-write.test.js Nothing escapes the root, names are slugified, front matter survives a round trip, unchanged saves do not write, folder deletion asks first, the root cannot be deleted
test/docs-empty-folders.test.js A folder with no guide beneath it is not navigation, the admin still lists it, and hiding it does not stop it resolving
test/docs-steps.test.js ::: steps embeds a recording and resolves it to the one global URL; a missing recording falls back to a warning callout that names the address it wanted
test/docs-recordings-root.test.js The recordings directory is global: the same recording resolves from a guide in profile-builder/, from one at the root of docs/ and from one three folders down; the old profile-builder/recordings/… source form still resolves; an address and a query are accepted; a climbing path and a non-recording are still refused; the step count follows the recording and not the guide
test/docs-api.test.js Folder routes matched before guide routes, every write route admin-only, the 409 two-step folder delete, traversal, the preview field and its admin gate
test/notes-map.test.js Every guide is a named node, labels are short and prefix-free, the layout is the reading order and deterministic, the size comes from the text, every note is drawn and listed, one pair is one line
test/docs-player.test.js The live launch is public, hub-absolute, and distinct from the admin recording link
test/footer.test.js Every non-app-shell page carries the footer in its own markup
test/header.test.js The header and command palette on the guides page
test/legal-pages.test.js The generated legal pages, which list the docs store among the things the site keeps

See Testing.


Related: 03 Pages and routes, 08 Walkthrough recorder, 12 Markdown reference, 13 The markdown blocks, 16 Admin panel, 18 API reference, 22 Testing.