a YC-backed documentation platform

Compiling links at publish time

Giving every heading a human-readable anchor — #getting-started instead of #k3f9q — computed during publishing rather than stored in the document, while a layered compatibility net keeps every link ever shared working, and quietly upgrades it when someone follows it.

Surface: editor inline mentions, publish worker, public-site anchor resolver · Shipped as: one PR — 51 files, +2,066 / −917 · Stack: TypeScript · React · Slate/Plate · Node.js worker · Postgres

The brief: readable anchors on top of years of opaque ones

Every block in the platform's collaborative editor has a random ID, and for years the public docs derived heading anchors from it: the URL fragment for a section was the first five characters of its block ID. Functional — the ID never changes, so links never break — but docs.example.com/setup#k3f9q tells a reader nothing, looks wrong in a shared Slack message, and wastes an SEO signal.

The ask: kebab-case anchors generated from the heading text, plus a way for writers to set a custom alias. The hard part was everything around it. Headings get renamed, so a slug derived from text is inherently unstable. Documents mention other documents — and specific headings inside them — so an anchor changing in one doc must propagate to every doc that points at it. And an installed base of published sites, bookmarks, and cross-doc links already spoke the five-character dialect; none of it was allowed to break.

The design that resolved the tension: documents store identity, published sites render addresses. Inside the editor, nothing changed — blocks and links keep pointing at each other by immutable ID. The readable slug is computed at publish time, like a compiler pass that lowers stable references into pretty URLs, and the legacy hash ships alongside it as a fallback address.

 EDITING (stable IDs)          PUBLISHING (compile pass)          VISITING (resolution)
 ────────────────────          ─────────────────────────          ─────────────────────
 heading  id: "k3f9qa8…"       inject on each heading:            #getting-started
                               publishedAnchor:                    └─ getElementById ✓
 mention stores                "getting-started"
 anchorRef: "#k3f9q"  ───────► + fallbackAnchor: "k3f9q"          #k3f9q  (old link)
 (immutable pointer)                                              ├─ getElementById ✗
                               then rewrite every mention:        ├─ [data-anchor-fallback] ✓
                               "#k3f9q" → "#getting-started"      └─ URL rewritten to
                                                                     #getting-started

01 · Mentions — links by identity, not by URL

A mention of another document is an inline void node whose payload holds no URL at all — just identity and presentation:

{
  documentId, workspaceId,        // what it points at — immutable
  anchorRef,                      // optional heading inside the target
  label, pinnedLabel,             // display text; the pinned label wins if set
  labelMode: 'live' | 'frozen',
  opensNewTab, showAsCard
}

In the editor the mention is live: it re-labels itself from the current document list, so renaming a doc updates every mention of it on next render — unless the writer pinned a custom label, which always wins. Deleting a mention reports the removed edge to the backend, keeping the platform's link graph accurate.

Clicking a mention while editing opens a popover that fetches the target document on demand, extracts its heading blocks, and offers them as anchor targets. The dropdown is where the two-address scheme is visible in one line of code — each option displays the future readable slug but stores the stable hash:

return {
  label: `${headingText} (#${futureSlug})`,     // what the writer sees
  value: `#${anchorFromBlockId(heading)}`,      // what the document stores
  readableForm: `#${futureSlug}`,               // matched when reopening
}

Storing the hash means the reference survives any amount of heading rewording. The pretty slug it will become isn't decided yet — that happens at publish, using the heading's text at that moment.

02 · Slugs — one cascade, shared by editor and server

A single 75-line module owns anchor naming, imported by both the editor package and the publish worker — the editor's preview and the published truth cannot drift because they are the same function. Resolution is a four-level cascade:

export const resolveAnchorSlug = ({ block, readableEnabled }) => {
  if (!readableEnabled) return anchorFromBlockId(block)   // internal surfaces

  return (
    data?.pinnedAnchor ||                 // 1. writer-chosen alias
    data?.publishedAnchor ||              // 2. injected at publish
    slugFromText({ text }) ||             // 3. derived on the fly
    anchorFromBlockId(block)              // 4. the old five chars
  )
}

Slug generation is deliberately boring: lowercase, strip anything outside a–z 0–9 space dash, spaces to dashes, collapse runs, cap at 50 characters. Six block types are sluggable — three heading levels, two generations of API blocks, and code drawers — and each knows what its "text" is: headings flatten their inline content (including mention labels), API blocks use the endpoint name, drawers use their title.

Level 3 is quieter than it looks. Sites published before the feature existed have no injected slug data — but the render-side fallback derives one from the text anyway, so even stale published copies show readable anchors immediately, upgraded fully on their next republish.

The custom alias

Writers can pin an anchor from a toolbar popover on any sluggable block: a switch between auto-generate and custom, an input that force-kebabs as you type, a live …#anchor-alias preview, and validation through the same slugFromText before saving. A pinned alias is content — it lives in the document and survives text edits, which makes it the writer's tool for keeping an anchor stable through a rename.

03 · Publishing — the compile pass

Publishing is a background job that copies every document into environment-prefixed rows — the published site reads its own frozen copies, never the drafts. Docs stream through in batches capped at 500 content nodes, and each doc gets a single tree traversal that does all publish-time rewriting at once: comments stripped, conditional content resolved, transcluded snippets expanded (with a five-level nesting guard and deep-cloned children so repeated snippets can carry distinct anchors), asset URLs fixed — and, for this feature, two data points injected into every sluggable block:

if (isSluggableBlock(block)) {
  const fallbackAnchor = anchorFromBlockId(block)
  const slug = uniqueSlugFor(block, takenSlugs)  // -1, -2 … on collision
  takenSlugs.add(slug)

  block.data = { ...block.data, publishedAnchor: slug, fallbackAnchor }
}

Uniqueness is document-scoped: two sections named "Overview" become #overview and #overview-1. And the legacy hash is written down explicitly rather than recomputed later — the published node carries its own history.

Mentions are compiled in the same traversal. Each one's label is refreshed from the target doc's current name, its href is baked to the target's public URL (or a preview route when publishing to the preview environment), and labelMode flips to frozen — the published page does no lookups. Cross-space mentions get their hostnames resolved against the target site's custom domain.

Cross-doc anchors: the deferred second pass

A mention pointing at a heading in another document can't be rewritten mid-batch — the target may sit in a later batch, so its readable slug may not exist yet. Instead, the traversal only records the edge: a map from each source doc to the set of target-doc + anchor pairs it references. After the final batch flushes, one deferred pass queries just the referenced target docs — now guaranteed to carry injected slug data — and builds a lookup keyed by both addresses each anchor answers to:

anchorIndex.set(`${destDocId}#${fallbackAnchor}`, slug)   // "#k3f9q" → slug
anchorIndex.set(`${destDocId}#${block.id}`, slug)         // full-ID form too

// then, in each source doc:
mention.data.anchorRef = `#${slug}`

So the published copy of a mention says #getting-started, while the draft it was compiled from still says #k3f9q — and will resolve correctly against whatever the heading is named at the next publish. One ORM subtlety earned a comment in the code: mutating JSON in place doesn't mark the column dirty, so each touched doc is explicitly flagged before saving.

Decision. Compile, don't resolve. Published pages carry final URLs and final labels — no runtime lookup service, nothing to slow down or fall over on the reader's side. The cost is paid once, in the worker.

04 · Compatibility — the net: four layers, zero migrations

No stored document was rewritten to introduce this feature. Backward compatibility lives entirely in how things render and resolve:

Layer 1 — the DOM answers to both names. Every published heading renders its readable slug as the element id and its old address as a data attribute:

<h1 id="getting-started" data-anchor-fallback="k3f9q">Getting Started</h1>

Layer 2 — the resolver tries the past. The scroll-to-anchor routine looks up the fragment by getElementById first, then falls back to [data-anchor-fallback="…"]. It retries across animation frames — up to 120 — because the target may still be streaming in or folded inside a collapsed section, and it only scrolls once the element's position has held still for two consecutive frames.

Layer 3 — old links heal themselves. When resolution succeeded via the legacy fallback, the address bar is rewritten to the canonical slug with history.replaceState — no navigation, no scroll jump. Every visit through an old bookmark ends with the reader looking at, and re-sharing, the new URL. The legacy dialect fades out through use.

Layer 4 — the blast radius is gated. Readable slugs render only on five public surfaces: the three public-site templates, the public doc renderer, and the embeddable widget. The internal editor, diff views, and PDF export still address blocks by legacy ID — surfaces where URLs aren't shared and stability matters more than beauty were simply left out of the feature.

Decision. Never migrate what you can layer. Old documents, old published copies, and old links all remain valid inputs; the new behavior is additive data plus a fallback chain. There was no flag day, and there is no rollback risk.

05 · By the numbers

ComponentMeasure
Feature PR51 files · +2,066 / −917
Slug module (shared editor + worker)75 lines · 4-level cascade
Publish anchor utils~140 lines · 2-pass resolution
Sluggable block types6 (h1–h3, 2 API blocks, code drawer)
Slug formatkebab-case · ≤50 chars
Legacy hash5-char ID prefix · full ID for drawers
Publish batching500 nodes per batch
Anchor resolver≤120 frame retries · 2-frame stability
Readable-slug surfaces5 public · 0 internal

06 · Retrospective — decisions that held up

  • Store identity, render addresses. Documents keep immutable block IDs as the only pointer; readable URLs are a projection computed at publish. Renames can't break references because references never contained names.
  • Defer what depends on global state. Per-doc slug injection happens inline; cross-doc anchor rewriting waits until every batch is done. Splitting the two made correctness independent of publish order — and kept the second pass cheap, querying only docs that are actually referenced.
  • One slug function everywhere. The picker's preview, the alias popover's validation, the publish worker's injection, and the public renderer's fallback all call the same 75-line module. The preview a writer sees is the URL a reader gets.
  • Make old links converge, not just survive. The legacy fallback alone would have been enough to not break anything. Rewriting the URL after a legacy hit means the old address space actively shrinks with every visit.
  • Ship the fallback in the data. Writing the fallback anchor into the published node — instead of recomputing it from the ID at render — cost a few bytes and bought a published artifact that fully describes both of its addresses.

All figures are measured directly from the codebase and the feature PR at the time of writing. Identifying details — product name, package names, internal constants, and all code identifiers — have been altered or omitted. Companion pieces: An AI agent that rewrites your documentation, Git-style branching for documents, and From a YAML file to a living API console.