GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.
Obsidian users expect backlinks. GitBasedDocs already resolved every wikilink and relative link while rendering, so the information existed. The question was where to compute it and how to keep it honest.
View time, not sync time
The task card said "record links at sync time", which means a links table, a migration, and sync code that rewrites a page's outgoing links whenever it changes.
The doc page already builds a generation key for its render cache: page count, newest update time and a hash of the asset paths. Any page added, changed or removed in a project changes it. So backlinks compute from page bodies on a cache miss and store the whole project's link graph under that key:
const key = `${project.id}:${opts.generation}:${opts.includeDrafts ? "d" : "v"}`
No migration, no sync change, and renames are handled for free because the key already moves. The ceiling is in the comment: a miss reads every page body in the project. If that shows up on a large vault, the table from the card is the upgrade.
One set of link rules
The risk with computing backlinks separately is two implementations of "what does this link point at". If they drift, a page shows up under Linked from while its link renders dead, or the other way round.
So instead of writing new resolution code, I pulled three functions out of the renderer and exported them: resolveRelative for relative paths, findPage for matching a repo path with or without .md, and wikiIndex for wikilink names.
That turned up a small inefficiency on the way. findPage used to build a Map of every page on each call, which meant once per link on every render. It now takes the map, built once per page.
The backlinks code reads Markdown with regexes rather than rendering it, after stripping code:
// Code never links: fenced blocks first, then inline spans.
const CODE = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g
A link inside a code sample is an example, not a link. Images are skipped too, even when the image path happens to be a page.
Drafts stay invisible
A viewer does not see drafts in the sidebar or search. A backlink from a draft would still tell them a draft exists and what it is called. So the query filters drafts for viewers, and the cache key keeps the two graphs apart.
I checked this over HTTP on a scratch database with an admin and a viewer who was a member of the project. On the Setup page the admin saw Deploy, Home and the draft. The viewer saw Deploy and Home. A page whose only link sat inside a code fence appeared for neither.
Aliases
Obsidian lets a note list other names in frontmatter:
aliases: [Getting started, Onboarding]
Sync now stores them as a JSON list on the page row, accepting a list, a single string, or the older alias key. wikiIndex adds them after file names, and an alias never takes a name a real file already has. Backlinks use the same index, so a link by alias counts.
Frontmatter is only read when a file is fetched. Existing pages had no aliases recorded, so the migration forgets every blob sha once and the next sync reads each page again. That also repeats the commit lookups, which the per-run budget spreads across several syncs.
PDF embeds open as links
![[spec.pdf]] used to render as a dimmed "no page with this name" link, because embeds only handled images. It now renders a link to the cached file that opens in a new tab.
An inline viewer was the obvious alternative. The asset route serves every file with a sandboxed Content Security Policy, which exists so an SVG in the repo cannot run script on the app's origin. A sandboxed frame also stops the browser's built-in PDF viewer. Keeping the protection won over the inline view.
A route that cannot shadow a page
The same round added a Recent changes page for each project. The obvious URL, /p/acme/changes, would sit next to the catch-all route that serves doc pages, and a static route wins. Anyone with a changes.md in their vault would lose that page.
It lives at /p/acme/-/changes instead. A folder named - in a docs repo is unlikely, and the HTTP check confirmed a doc called changes.md still renders at its own address. It is the same trick GitLab uses for its own pages under a project.