Stop folders named index from collapsing breadcrumb URLs (#542)#694
Stop folders named index from collapsing breadcrumb URLs (#542)#694
index from collapsing breadcrumb URLs (#542)#694Conversation
A note like `index/index/index/example.md` rendered its direct-parent breadcrumb as `/index/index/` (one folder too shallow) because the folder placeholder for `index/index/index/` encoded to `index/index/index.html` and Ema's pretty URL stripped the trailing `index` segment. The fix mirrors `mkRouteFromFilePath' True`'s "drop trailing index" rule with an inverse `R.expandIndexSlug`: when an LML route already ends in `index` (and isn't the lone root), `noteHtmlRoute` re-adds a trailing `index` slug so the encoded HTML path includes the folder name and the pretty URL resolves to the correct directory.
The expansion in noteHtmlRoute compensates for `mkRouteFromFilePath' True` stripping a trailing `index` during file-path parsing. A user-supplied `slug:` field never went through that strip, so applying the expansion to it second-guesses the URL the author explicitly typed (e.g. `slug: foo/index` would emit `/foo/index/index/`, not `/foo/index/`). Split the two branches: explicit slugs are used as-is, file-path-derived slugs are expanded.
The trailing-index expansion lived loose in noteHtmlRoute, which made it
easy for a future HTML-route emission site to bypass the rule. Wrap the
LMLRoute → R 'Html conversion in a single named smart constructor in
ModelRoute.hs so the rule has one home and the call site reads as the
intent ("get the HTML route for this LML route") instead of the mechanism
("coerce, unRoute, expandIndexSlug, mkRouteFromSlugs").
Cross-reference expandIndexSlug from mkRouteFromFilePath''s docstring so a reader hitting the strip rule first can find its inverse without grepping, and explicitly name Ema's pretty-URL strip as the reason callers need to re-expand on URL emission.
The two new dropIndex tests for #542 wrote `R $ "foo" :| ["index"]` inline; without an LML/HTML extension on the right-hand side, GHC couldn't pin `mkRouteFromFilePath'`'s phantom type and rejected the test with `Ambiguous type variable 'ext1'`. Use the existing `r1Index` named route for the two-slug case and add `rNested` for the four-slug case so the type flows from the test fixture.
The Bool flag obscured the LML canonicalization rule — a `True` argument silently activated the trailing-`index` strip. Replace `mkRouteFromFilePath' True` with a dedicated `mkLmlRouteFromFilePath` whose name carries the rule, and fold the `False` body into `mkRouteFromFilePath`. Three call sites move (ModelRoute, Source/Patch, RSpec); the public surface is now two self-explaining functions instead of one with a magic flag.
Hickey/Lowy Analysis
Hickey rationaleThe original diff fragmented the index-slug invariant: Lowy rationaleThe genuine volatility here isn't "should we strip?" — that's a stable design decision — but "what does Ema's pretty-URL strip do?" The fix encapsulates the Ema-strip dependency in |
EvidenceEnd-to-end visual proof of the fix for #542 against a notebook containing Verified by reading |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 1s | forge=github; noGit=false |
| research | ✓ | 20m 29s | Identified bug in noteHtmlRoute: routes ending in index get pretty-URL-stripped a second time, collapsing nested-index folders by one level. |
| branch | ✓ | 4s | Created fix-index-folder-urls from origin/master |
| implement | ✓ | 3m 43s | Added R.expandIndexSlug helper + tests + CHANGELOG entry |
| check | ✓ | 42s | cabal build all clean |
| docs | ✓ | 28s | CHANGELOG.md updated; existing folder-note.md unchanged (routing fix, no doc rewrite needed) |
| fmt | ✓ | 25s | cabal-fmt, fourmolu, hlint, nixpkgs-fmt all pass |
| commit | ✓ | 32s | Primary feature commit pushed |
| hickey+lowy | ✓ | 8m 27s | 3 follow-up commits: split slug-metadata branch, introduced lmlToHtmlRoute smart constructor, doc cross-references |
| police | ✓ | 1m 29s | All 3 passes clean (rules / fact-check / elegance) |
| test | ✓ | 1m 20s | 56/56 unit tests pass; one type-ambiguity fix landed mid-loop |
| create-pr | ✓ | 5m 50s | PR #694 created; Hickey/Lowy analysis posted with 9-finding ledger |
| ci | ✓ | 2m 27s | vira ci signed off on b0b1baae; e2e-live 29/29, e2e-static 27/29 (2 mode-skipped), e2e-morph 29/29 |
| evidence | ✓ | 3m 31s | Before/after screenshots posted; href moved from /index/index to /index/index/index |
| Total | 50m 4s |
Slowest step: research (20m 29s)
Optimization suggestions
- Front-load the research-phase Explore subagent — research dominated the wall-clock (40% of total). The first Explore subagent returned a strong file:line map, but a chunk of time then went into manually re-walking the same code paths in the main context to re-derive the Ema-strip / LMLRoute-strip composition. A second targeted Explore (specifically: "trace
routeUrlWith UrlPrettyend-to-end on a stripped LMLRoute") would have saved several main-context reads. - Hickey/Lowy on a smaller initial diff — the structural review (8m 27s) drove 4 follow-up commits because the initial diff applied
expandIndexSlugtoo broadly (slug-metadata branch). Splitting branches up front innoteHtmlRoutewould have removed the largest finding before review even ran. - Tests caught a type-ambiguity that
cabal buildof the library missed —cabal build allbuilds the test executable but doesn't compile-check unit tests inside it untilcabal test all. Runningcabal test all --test-options=--no-create(orcabal build all --enable-tests) during thecheckstep would surface test compile errors a step earlier. - e2e suite wall-clock — e2e-live / e2e-static / e2e-morph took ~3-10s each but each pre-build emanote separately. Running them in parallel (which this run did via
run_in_background) is the right call — keep doing that on--from ci-onlyre-runs.
Workflow completed at 2026-04-30 22:48 UTC.
Re-captured evidenceThe previous shot pair looked identical at a glance because the page bodies are the same — only the colored top banner was differing. This re-capture navigates to the immediate-parent breadcrumb on each build, so the load-bearing evidence is the post-click URL.
Starting from |
Add a fixture at `index/index/index/example.md` and a Cucumber scenario that opens the note and asserts the immediate-parent breadcrumb href contains `index/index/index` (positive) and is not the buggy `index/index` value (negative). Verified that reverting `noteHtmlRoute` to master's body makes the new scenario fail with the expected diagnostic. Passes in all three modes (live / static / morph).
E2E coverage added (commit 23d0eb7)Cucumber scenario in Scenario: Folders named index don't collapse breadcrumb URLs (regression: #542)
When I open "/index/index/index/example.html"
Then the immediate-parent breadcrumb href contains "index/index/index"
And the immediate-parent breadcrumb href does not equal "index/index"Verified end-to-end:
Fixture is |
Pre-#542 the routes for `foo/index.md` (folder note for `foo/`) and `foo/index/index.md` (folder note for the deeper `foo/index/`) collapsed onto one HTML route — the IxNote dedup picked one and the other became unreachable. Lock the post-fix behaviour from both ends: * Six unit cases in `RSpec.hs` (`folderNoteCoexistenceSpec`) pin every step from file path to HTML route slugs and assert the three shapes — folder note, nested-index folder note, sibling file — produce three distinct routes. * One Cucumber scenario (`smoke.feature`) and a depth-indexed step (`smoke_steps.ts`) open `/subfolder/index/example.html` and assert the depth-1 breadcrumb (folder note `subfolder.html`) and the depth-2 breadcrumb (placeholder `subfolder/index`) carry distinct hrefs containing `subfolder` and `subfolder/index` respectively. Verified to fail on master's `noteHtmlRoute` body with the expected diagnostic ("Breadcrumb at depth 2 expected href to contain 'subfolder/index', got 'subfolder'") before restoring the fix. Passes in all three modes.
Coexistence coverage added (commit d842cc5)Per review request, locking down the case where Unit suite —
The suite asserts each step is exact and that all three shapes produce distinct HTML routes — a regression that re-collides them surfaces here, not as a runtime "ambiguous notes" error. E2E scenario — When I open "/subfolder/index/example.html"
Then the breadcrumb at depth 1 has href containing "subfolder"
And the breadcrumb at depth 2 has href containing "subfolder/index"
And the breadcrumb at depth 1 has a different href from depth 2Fixture |




A note like
index/index/index/example.mdpreviously linked its direct-parent breadcrumb to/index/index/— one folder too shallow. Two trailing-indexstrips were running back-to-back: Emanote's LML canonicalizer dropsfoo/index.md's trailingindexslug to identify it withfoo.md, then Ema's pretty-URL strategy strips the next one again on URL emit. For a folder also namedindex, that ate a real directory level. Closes #542.How the fix lines up
The drop and the re-add are now expressed as inverses, side by side in
Emanote.Route.R:mkLmlRouteFromFilePathindex(length > 1)mkRouteFromFilePathexpandIndexSlug(new)index(length > 1)MR.lmlToHtmlRoute(new)noteHtmlRoutenow goes throughlmlToHtmlRoutefor file-path-derived routes; explicitslug:metadata is taken at face value (the user typed it) and bypasses the expansion.Refinements during structural review
Hickey and Lowy passes turned up four follow-on findings; each landed as its own commit so the history reads as the diff growing inward:
expandIndexSlugto user-suppliedslug:metadata too — aslug: foo/indexwould emit/foo/index/index/, not/foo/index/.noteHtmlRoute.noteHtmlRoute, easy for a future HTML-route emitter to skip.MR.lmlToHtmlRouteas the single named LML→Html seam inModelRoute.hs.mkRouteFromFilePath'carried aBoolflag that silently activated the strip rule.mkRouteFromFilePath(no strip) andmkLmlRouteFromFilePath(strips); 3 call sites moved.mkLmlRouteFromFilePathandexpandIndexSlugwas discoverable only by hunting.Test coverage
R.expandIndexSlug× 5 — leaves single slugs / non-index endings alone, appends for the two routetree doesn't like folders named 'index' #542 shapes (foo/index/index.mdandindex/index/index/example.md).mkLmlRouteFromFilePath× 2 — regression tests for the same shapes, asserting the LML route stays the right length.Try it locally
Generated by
/doon Claude Code (modelclaude-opus-4-7).