← Context / general
Maintain Path Resolution for Wikilinks
How vault paths become site routes — the index cascade, why ambiguity refuses to guess, and the rule that every judgment call is a config field rather than a decision baked into the package.
Maintain Path Resolution for Wikilinks
The split, and where this sits in it
Three layers, three owners. Keep them separate.
| Layer | File | Owns |
|---|---|---|
| Syntax | src/plugins/remark-lfm-wikilinks.ts | the regex, the MDAST splice, the link node shape |
| Mechanics | src/utils/resolve-path.ts | normalisation, the index cascade, relative paths, templates, ambiguity |
| Destinations | the consuming site | a config object, and nothing else |
The plugin has always delegated destinations, correctly — they are per-site. What it did not delegate was mechanics, so every site reimplemented them badly. This module is that missing middle.
It is a utility, not a plugin. It lives in src/utils/ beside classify-link.ts, so the naming rule in Naming-Plugins-Against-the-Remark-Ecosystem.md does not apply to it.
Measure before changing anything here
Every default in this module was chosen from a measurement, not from taste. Re-measure before overriding one — node scripts/measure-vault.mjs reproduces every row below and is committed for exactly that reason. The 2026-09-08 baseline, across content/ — 4,702 files, 13,556 navigational wikilinks (![[embeds]] excluded):
| Fact | Value | What it decided |
|---|---|---|
| Wikilinks with no folder | 3,624 — 26.7% | there is an index at all |
| Files with a unique basename | 4,563 of 4,702 — 97.0% | basename is in the default cascade |
| Colliding basenames | 66 (139 files) | ambiguity refuses to guess |
| Bare links hitting a collision | 17 — 0.5% | the refusal costs almost nothing |
| Exact-path hits among pathed links | 91.7% | exact is tier one |
| Case drift | Tooling 3,572 / tooling 38 | caseSensitive: false default |
| Separator drift | lost-in-public 179 / Lost in Public 8 | looseSeparators: true default |
Literal ../ links | 0 | relative support exists but is never assumed |
Reproduce with node scripts/measure-vault.mjs [vaultDir] (add --json for machine-readable output). It takes about a second and it is worth doing before any argument about defaults. Earlier drafts of this table were produced by uncommitted throwaway scripts; that is what the committed script exists to prevent.
The two invariants
1. Ambiguity terminates resolution. It does not fall through.
A tier matching several files stops the cascade, records a diagnostic, and resolves to nothing. It does not try the next tier, and it does not reach the route tier — which matters because a site with a * catch-all would otherwise emit a confidently-wrong link for precisely the case where we know that we do not know.
This was a real bug caught by the test suite, not a hypothetical. If you refactor resolve(), keep the explicit lastFailure?.reason === 'ambiguous' guard, and keep the test named a catch-all route does NOT rescue a collision.
2. Nothing is decided on the consumer’s behalf.
If you find yourself writing an if that encodes a preference — which tier to trust, what a slug means, what happens on a collision, whether the author’s |Display wins — it is a config field, not a branch. The count today is fifteen options and zero baked-in policy. Adding the sixteenth is cheaper than defending a default that is wrong for one site.
cascade is deliberately a list, not a threshold. The tiers are not one confidence axis: a site can rationally trust basename and distrust suffix. ['exact', 'basename'] expresses that; minConfidence: 'suffix' cannot. An earlier draft used the threshold and it was wrong.
Performance — say the number, do not hand-wave
The recurring worry is that build-time path resolution means a grep per link. It does not. The index is three Maps built once; each resolution is a few Map.get() calls.
fs walk (the site does this) : 16.7 ms
index build (once) : 21.9 ms
resolve ALL 13,556 wikilinks : 103.7 ms → 7.65 µs per link
Keep it that way. Any change that makes resolution O(vault) per link — fuzzy matching, Levenshtein fallback, a bySuffix walk that scans rather than looks up — is a regression even if it raises the hit rate. The bySuffix map is already the expensive one to build (O(segments) entries per file); that cost is paid once and is fine. Paying it per link would not be.
When deferral is the right answer, and when it is not
deferred is not a performance workaround, and it should never be recommended as one. It exists for destinations an index genuinely cannot know: behind an API, in a sibling site’s route table, or in content not yet built. It emits a placeholder URL — point it at an SSR route to settle at request time — and collects a deduplicated, frequency-sorted worklist via resolver.deferred() for a post-build rewrite pass.
Read the queue after the build has walked every document. It is a running tally, not a snapshot.
Not wikilink-specific, on purpose
resolve() takes a string and returns a plain object. Image src attributes, plain link targets, and frontmatter cross-references have the same problem. toWikilinkResolver() is an adapter. Resist any change that makes the core assume it is being called from the wikilink plugin.
See also
changelog/2026-08-23_01.md— the release, with the full measurement tablecontext-v/blueprints/Naming-Plugins-Against-the-Remark-Ecosystem.md— why this isutils/, notplugins/test/resolve-path.test.mjs— 39 assertions; the two invariants above are pinned therescripts/measure-vault.mjs— regenerates every number in this document