← Changelog

Wikilinks stop needing a hand-written resolver

createPathResolver turns Obsidian path resolution into configuration instead of code — an index cascade that rescues the 28% of wikilinks written with no folder at all, a collision policy that refuses to guess, and 5µs per link so it never becomes the reason a build is slow.

Wikilinks stop needing a hand-written resolver

Why Care?

remarkLfmWikilinks has always demanded that the consuming site supply a resolver function, on the grounds that wikilink destinations are inherently per-site. That reasoning still holds. What it quietly produced is four sites that each wrote the same resolver by hand — a chain of if (path.startsWith(…)) branches covering the prefixes its author happened to think of, and silently dropping everything else.

The dropping is the problem, and it is bigger than it looks. 28% of the wikilinks in the Lossless vault have no folder at all[[DevOps]], not [[concepts/DevOps]]. No amount of prefix matching can see them. Every hand-written resolver in the family fails that quarter of the corpus, and fails it invisibly, because an unresolved wikilink renders as ordinary prose rather than as an error.

What changed

createPathResolver(config) — a resolution policy layer, expressed as data, that produces the function the plugin already wanted. Three layers, three owners: the plugin owns syntax, the resolver owns mechanics, the site owns a config object and nothing else.

It is opt-in in exactly the way codeFences and ogFetch are. Omit it and nothing changes.

parseMarkdown(content, {
  wikilinks: {
    paths: {
      index: vaultPaths,
      routes: [
        { match: ['concepts', 'vocabulary', 'organizations', 'sources'],
          to: 'https://www.lossless.group/more-about/{slug}' },
        { match: 'tooling', to: '/tools/{slug}' },
      ],
    },
  },
});

The existing resolver option still works and still wins when both are present — a hand-written resolver is a deliberate act and should not be overridden by config that happens to also be there.

The measurements the design is built on

[!warning] Corrected 2026-09-08 — these numbers were never reproducible The table below was produced by throwaway scripts that were never committed, and it counted ![[embeds]] as navigational links. scripts/measure-vault.mjs now regenerates every row and is committed. Re-derived against the same vault: 13,556 navigational wikilinks, 26.7% bare, 97.0% unique basenames, 0.5% of bare links hitting a collision, 91.7% exact among pathed, 7.65 µs per link. Every conclusion below still holds — the index, the basename tier, and the refusal to guess all still follow. Only the digits moved. See README.md for the current table; this entry is left as the dated record of what was claimed.

Taken across content/ on 2026-08-23: 4,702 markdown files, 13,846 wikilinks written against them.

FactValue
Wikilinks with no folder at all3,839 — 28%
Basenames globally unique in the vault4,622 of 4,702 — 98.4%
Basenames that collide73 (153 files)
Bare wikilinks that actually hit a collision25 — 0.7%
Pathed links hitting an exact vault path8,956 of 9,973 — 89.8%
Case drift on segment oneTooling 3,591 vs tooling 38
Separator driftlost-in-public 179 vs Lost in Public 8
Literal ../ links0

Two of those decided the shape of the thing. The 28% is why there is an index at all rather than only prefix rules. The 98.4% is why basename resolution is the default rather than an exotic opt-in — and the 73 collisions are why it refuses to guess.

The cascade, and what it refuses to do

Four tiers, each more speculative than the last, and every result reports which one answered it so a site can trust the exact hits and audit the rest: exactsuffixbasenameroute.

A tier that matches more than one file does not fall through to the next one. It stops, records an ambiguous diagnostic with its candidates, and resolves to nothing — so the wikilink renders as plain text with no anchor. That is deliberate and it is the whole ethic of the feature: a link that is wrong in a way nobody notices is worse than no link.

Writing the tests found a real bug in exactly that path. An ambiguous result was falling through to the route tier, which meant a site with a * catch-all route would emit a confidently-wrong link for precisely the collision case where we know that we do not know. Ambiguity now terminates resolution.

Sites that want the other behaviour can have it — onAmbiguous takes 'plain' (default), 'first' (deterministic, sorted, still a guess), or a function implementing the site’s own disambiguation rule.

It is a Map, not a grep

The obvious objection to resolving paths at build time is that per-link globbing or fuzzy matching would make a build crawl. Nothing here scans per link. The index is three Maps built once, and each resolution is a handful of Map.get() calls.

fs walk (the site does this)  : 15.5 ms
index build (once)            : 13.4 ms
resolve ALL 13,812 wikilinks  : 70.3 ms   →  5.09 µs per link

Roughly 100ms added to a whole-corpus build. There is no queue here because none is needed to make it fast.

There is a queue, though, for the case an index genuinely cannot answer — a destination behind an API, in a sibling site’s route table, or in content that has not been built yet. deferred emits a placeholder URL (point it at an SSR route to settle at request time) and collects a deduplicated, frequency-sorted worklist that resolver.deferred() hands back for a post-build rewrite pass.

Everything is configuration

The hard part of this feature was not the cascade; it was resisting the urge to decide anything on a consumer’s behalf. Thirteen options, no baked-in policy: routes, index, cascade, onAmbiguous, slugFrom (per resolver and per route), tokens, base, looseSeparators, caseSensitive, extensions, slugify, display, preferAuthorDisplay, deferred, onDiagnostic.

cascade is a list rather than a confidence threshold on purpose. The tiers are not one axis — a site may well trust basename (Obsidian’s own behaviour, 98.4% unique here) while distrusting suffix (which can match a path nobody ever wrote). ['exact', 'basename'] says that; a threshold cannot.

resolve() takes a plain string and returns a plain result. The same a human wrote a vault path, where does that live on the web? problem shows up for image src attributes, plain markdown link targets, and frontmatter cross-references. toWikilinkResolver() is a thin adapter, not the main interface.

Result

Against the real corpus with four routes and no catch-all: 79% of 13,812 wikilinks resolve — 8,346 exact, 2,115 by bare name, 445 by route, 44 collisions correctly rendered as plain text. The remainder are reported as not-in-index (mostly genuinely dangling links) or no-route (folders those four rules do not claim), both available through onDiagnostic for an audit report.

39 new assertions, 221 passing overall, no new dependencies.