Resolve relative links from index.md against its own folder#651
Conversation
#608) Notes loaded from `<dir>/index.md` had their canonicalized route stripped of the trailing `index` slug (so `subfolder/index.md` got route `subfolder`). The route's parent — used as the base for resolving relative URLs — was thus the wrong folder (the parent of `subfolder`, not `subfolder/` itself), so `[foo](./foo.md)` inside `subfolder/index.md` resolved to `foo.md` instead of `subfolder/foo.md`. Derive the base from the note's on-disk source filepath rather than from its canonicalized route. Adds `noteResolveLinkBase` and the pure helper `resolveLinkBaseFromFilePath` in `Emanote.Model.Note`; updates the four callsites in `Rel.noteRels`, `Url.urlResolvingSplice`, and the three embed splices in `Embed`.
The post-implement Hickey review flagged that the algorithm "look up the note for `r`; if found, use `noteResolveLinkBase`, otherwise fall back to `R.routeParent r`" was inlined identically at four splice callsites (one in `Url`, three in `Embed`). The rule lived only as a social contract between sites — a future change would require updating all four. Extract `modelResolveLinkBase :: ModelT f -> LMLRoute -> Maybe (R 'Folder)` in `Emanote.Model.Type` so the four callsites collapse to a single function call.
Hickey/Lowy Analysis
Hickey rationaleThe unenforced rule "when resolving links for a note identified by Lowy rationaleVolatility is correctly encapsulated. |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 0s | git fetch ok; forge=github; noGit=false |
| research | ✓ | 5m 28s | Located bug at Rel.hs:74 + Url.hs:32 + Embed.hs:34/51/62. Fix: derive parent from _noteSource filepath instead of canonicalized route. |
| branch | ✓ | 4s | On branch fix-index-relative-links from origin/master. |
| implement | ✓ | 7m 36s | Added noteResolveLinkBase + resolveLinkBaseFromFilePath in Note.hs; updated 4 callsites; 4 unit tests in RelSpec.hs all pass. |
| check | ✓ | 5s | cabal build all: up to date, no errors. |
| docs | ✓ | 12s | Existing docs accurate; no update needed. |
| fmt | ✓ | 25s | pre-commit: cabal-fmt, fourmolu, hlint, nixpkgs-fmt all pass. |
| commit | ✓ | 21s | Commit feec272 pushed. |
| hickey+lowy | ✓ | 6m 23s | Hickey: 1 finding (4-site dedup) → fixed in af887c4. Lowy: no findings. |
| police | ✓ | 3m 59s | Rules clean (7/7); fact-check clean; 1 deferred efficiency concern. |
| test | ✓ | 10s | 28/28 tests pass. |
| create-pr | ✓ | 41s | Draft PR #651 with hickey/lowy comment. |
| ci | ✓ | 4m 27s | All 3 CI checks green on HEAD af887c4. |
| Total | 31m 47s |
Slowest step: implement (7m 36s)
Optimization suggestions
implementdominated: research and implement together took 13 minutes. Splitting the bug fix from the test scaffolding into separate iterations (write the failing test, then write the smallest possible fix) might shave time, but for a bug-fix-with-helper-extraction this is roughly the floor.hickey+lowy(6m 23s): ran sequentially rather than in parallel. Future runs should emit bothAgenttool calls in a single response — easily halves this step.ci(4m 27s): includes Playwright browser cache hit + e2e tests. For followup pushes on this branch,--from ci-onlywould skip everything before CI.police(3m 59s): three lenses ran serially against the diff./simplifyalready runs its three lenses in parallel; the rules+fact-check passes are bounded by main-context work. Acceptable.
Add a `subfolder/index.md` + `subfolder/sibling.md` to the e2e fixture notebook, plus a smoke scenario that opens `/subfolder.html` and asserts the article-body link to `./sibling.md` resolves to a href containing `subfolder/sibling`. The selector is scoped to <article> because the sidebar nav surfaces a child link to the same target — and that path is auto-generated from the route hierarchy, so it stays correct even when the bug is live. Only the article anchor exercises the relative-URL resolver we're testing. Also note the fix in CHANGELOG.md under 1.6.0.0 → Bug fixes.
Relative URLs inside
<dir>/index.mdnow resolve against<dir>/, not its parent. Closes #608. Previously, Emanote canonicalizedsubfolder/index.mdto routesubfolder(dropping the trailingindexslug), and then derived the link-resolution base by taking that route's parent — which landed one folder too high. So[foo](./foo.md)insidesubfolder/index.mdresolved tofoo.mdat the root instead ofsubfolder/foo.md.The fix derives the base from the note's on-disk source filepath (
takeDirectory) rather than its canonicalized route. A newnoteResolveLinkBase :: Note -> Maybe (R 'Folder)lives inEmanote.Model.Note, andmodelResolveLinkBase :: Model -> LMLRoute -> Maybe (R 'Folder)inEmanote.Model.Typewraps the model lookup so the four splice callsites (Url.urlResolvingSpliceand three inPandoc.Renderer.Embed) collapse to a single function call. Synthesized notes (placeholders, missing-note pages) lack an on-disk path and fall back tonoteParent— they don't trigger the bug since they have no canonicalized source to drift from.Five new unit tests in
RelSpeccover top-level files, subfolder index files, deeply nested index files, sibling non-index files, and an end-to-endparseUnresolvedRelTargetresolution fromsubfolder/index.md. An additional e2e scenario runs in bothliveandstaticmodes against asubfolder/index.md+subfolder/sibling.mdfixture, asserting the article-body anchor's href resolves tosubfolder/sibling.html. The selector is scoped to<article>because the sidebar nav surfaces a child link to the same target — and that path is auto-generated from the route hierarchy, so it stays correct even when the bug is live.