From e031bb4c5f40706795b3dfa5ebb89dd2fb0130a8 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 30 Apr 2026 22:21:18 -0400 Subject: [PATCH 1/8] Fix breadcrumb URLs for folders named 'index' (#542) 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. --- emanote/CHANGELOG.md | 1 + emanote/src/Emanote/Model/Note.hs | 12 ++++++------ emanote/src/Emanote/Route/R.hs | 16 ++++++++++++++++ emanote/test/Emanote/Route/RSpec.hs | 25 +++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index b9b0461c5..fc40644f8 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -34,6 +34,7 @@ - A Markdown link whose label contains a URL or email address now renders as a single hyperlink instead of being split into the parent link plus separate autolinks for the embedded URLs. `commonmark-hs`'s autolink extension produces nested `Link` nodes for these labels (including positions wrapped in emphasis); a new `flattenNestedLinks` pass — added as the last step of `Emanote.Pandoc.BuiltinFilters.preparePandoc` — unwraps the inner `Link` so the displayed label survives but its target is dropped (closes [#349](https://github.com/srid/emanote/issues/349)). - Home Manager module: `services.emanote.package` now defaults to Emanote's own flake package when importing `emanote.homeManagerModule`, so users no longer need to set the package option just to avoid a missing `pkgs.emanote` attribute (closes [#309](https://github.com/srid/emanote/issues/309)). - Backlinks: when one note links to a target several times, the individual context cards now appear in source order — the order they show up in the source `.md` file. Previously they were sorted lexicographically by the surrounding block content, and two links that shared an identical context (e.g. `[[Foo]] and [[Foo]]` in one paragraph) were collapsed into one card by the relation-index dedup. `Rel` now carries a `_relSrcPos` pandoc-traversal index that breaks ties between rels sharing `(_relFrom, _relTo)` so source order survives `IxSet.toList` and the `groupNE` aggregation (closes [#186](https://github.com/srid/emanote/issues/186)). +- Folders named `index` no longer collapse breadcrumb / sidebar links to a higher ancestor. A note like `index/index/index/example.md` previously emitted `/index/index/` for its direct-parent breadcrumb because the folder placeholder for `index/index/index/` (LML route `("index","index","index")`) encoded to `index/index/index.html` and Ema's pretty URL stripped the trailing `index` segment. `noteHtmlRoute` now appends a trailing `index` slug whenever the LML route already ends in `index` (and isn't the lone root `index`), so the encoded HTML path becomes `index/index/index/index.html` and the URL resolves to `/index/index/index/` as expected. The same fix applies to feed (`*.xml`) URLs of index files inside index folders, which were similarly truncated. Closes [#542](https://github.com/srid/emanote/issues/542). - Cyclic note embeds (`![[a]]` in `b.md` together with `![[b]]` in `a.md`, or a self-embed) now render an inline `↺ Cyclic embed: (via …)` placeholder — naming the offending note plus the chain that closed the loop — instead of expanding the embed without a fixpoint and either hanging the live preview or producing arbitrarily deep nested output. Internally, the embed-ancestor stack is stashed in `RenderCtx`'s typed user-data slot (added upstream in [srid/heist-extra#17](https://github.com/srid/heist-extra/pull/17)) so only the embed renderer reads it; URL / callout / query renderers stop seeing the embed-only concern in their signatures. On a hit the renderer falls back to a `.emanote\:error\:cyclic-embed` div (closes [#362](https://github.com/srid/emanote/issues/362), [#684](https://github.com/srid/emanote/issues/684)). ## 1.4.0.0 (2025-08-18) diff --git a/emanote/src/Emanote/Model/Note.hs b/emanote/src/Emanote/Model/Note.hs index 285620476..cdc86bb7d 100644 --- a/emanote/src/Emanote/Model/Note.hs +++ b/emanote/src/Emanote/Model/Note.hs @@ -219,12 +219,12 @@ noteXmlRoute note -- | The HTML route intended by user for this note. noteHtmlRoute :: Note -> R 'R.Html noteHtmlRoute note@Note {..} = - -- Favour slug if one exists, otherwise use the full path. - case noteSlug note of - Nothing -> - R.withLmlRoute coerce _noteRoute - Just slugs -> - R.mkRouteFromSlugs slugs + -- Favour slug if one exists, otherwise use the full path. In both cases + -- expand a trailing "index" slug so URL encoding round-trips through + -- pretty URLs (see `R.expandIndexSlug` and #542). + R.mkRouteFromSlugs $ R.expandIndexSlug $ case noteSlug note of + Just slugs -> slugs + Nothing -> R.unRoute (R.withLmlRoute coerce _noteRoute :: R 'R.Html) lookupNotesByHtmlRoute :: R 'R.Html -> IxNote -> [Note] lookupNotesByHtmlRoute htmlRoute = diff --git a/emanote/src/Emanote/Route/R.hs b/emanote/src/Emanote/Route/R.hs index 84aa9fe1b..ba4f97bde 100644 --- a/emanote/src/Emanote/Route/R.hs +++ b/emanote/src/Emanote/Route/R.hs @@ -47,6 +47,22 @@ mkRouteFromSlugs :: NonEmpty Slug -> R ext mkRouteFromSlugs = R +{- | Re-add a trailing @"index"@ slug when the route ends in @"index"@ and has +more than one slug. Inverse of `mkRouteFromFilePath'` @True@'s "drop trailing +index" rule. + +When an LML route is converted to an `R` @'Html@ for URL emission, the +trailing @"index"@ slug must be re-added so that Ema's pretty URL strip +still leaves the folder name visible. Without this, a folder named @index@ +collides with its grandparent: @foo\/index\/index.md@ (LML route +@("foo","index")@) would encode to @foo\/index.html@ and pretty-URL to +@\/foo\/@ (the URL of @foo.md@) instead of @\/foo\/index\/@. See #542. +-} +expandIndexSlug :: NonEmpty Slug -> NonEmpty Slug +expandIndexSlug slugs + | length slugs > 1 && last slugs == "index" = slugs <> one "index" + | otherwise = slugs + -- | If the route is a single-slug URL, return the only slug. routeSlug :: R ext -> Maybe Slug routeSlug r = do diff --git a/emanote/test/Emanote/Route/RSpec.hs b/emanote/test/Emanote/Route/RSpec.hs index f561cf2d5..185854b2f 100644 --- a/emanote/test/Emanote/Route/RSpec.hs +++ b/emanote/test/Emanote/Route/RSpec.hs @@ -13,6 +13,7 @@ spec :: Spec spec = do mkRouteFromFilePathSpec routeInitsSpec + expandIndexSlugSpec mkRouteFromFilePathSpec :: Spec mkRouteFromFilePathSpec = describe "mkRouteFromFilePath" $ do @@ -34,6 +35,13 @@ mkRouteFromFilePathSpec = describe "mkRouteFromFilePath" $ do mkRouteFromFilePath' True "foo/index.md" === Just r1 it "three slugs" . hedgehog $ do mkRouteFromFilePath' True "foo/bar/index.md" === Just r2 + it "nested index folder file (#542)" . hedgehog $ do + -- foo/index/index.md keeps the folder slug; only the trailing + -- filename "index" is dropped. + mkRouteFromFilePath' True "foo/index/index.md" === Just (R $ "foo" :| ["index"]) + it "deeply nested index folders (#542)" . hedgehog $ do + mkRouteFromFilePath' True "index/index/index/example.md" + === Just (R $ "index" :| ["index", "index", "example"]) routeInitsSpec :: Spec routeInitsSpec = describe "routeInits" $ do @@ -47,6 +55,23 @@ routeInitsSpec = describe "routeInits" $ do it "three slugs returns index, first slug, second slug, and itself" . hedgehog $ do routeInits r3 === rIndex :| [r1, r2, r3] +expandIndexSlugSpec :: Spec +expandIndexSlugSpec = describe "expandIndexSlug" $ do + it "leaves the lone index slug alone" . hedgehog $ do + expandIndexSlug ("index" :| []) === "index" :| [] + it "leaves a single non-index slug alone" . hedgehog $ do + expandIndexSlug ("foo" :| []) === "foo" :| [] + it "leaves multi-slug routes ending in non-index alone" . hedgehog $ do + expandIndexSlug ("foo" :| ["bar"]) === "foo" :| ["bar"] + it "appends an index slug when a multi-slug route ends in index (#542)" . hedgehog $ do + -- LML route for `foo/index/index.md` is ("foo","index"); its HTML route + -- must encode to `foo/index/index.html` so pretty URL yields /foo/index/. + expandIndexSlug ("foo" :| ["index"]) === "foo" :| ["index", "index"] + it "appends index for nested index folders (#542)" . hedgehog $ do + -- Folder placeholder for index/index/index/. + expandIndexSlug ("index" :| ["index", "index"]) + === "index" :| ["index", "index", "index"] + r1 :: R ('LMLType 'Md) r1 = R $ "foo" :| [] From f5b06b5a14751d8f2657e3a6feb305c351e39ea9 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Thu, 30 Apr 2026 22:27:00 -0400 Subject: [PATCH 2/8] refactor(hickey): user-supplied slug bypasses index expansion 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. --- emanote/src/Emanote/Model/Note.hs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/emanote/src/Emanote/Model/Note.hs b/emanote/src/Emanote/Model/Note.hs index cdc86bb7d..561d9d022 100644 --- a/emanote/src/Emanote/Model/Note.hs +++ b/emanote/src/Emanote/Model/Note.hs @@ -219,12 +219,17 @@ noteXmlRoute note -- | The HTML route intended by user for this note. noteHtmlRoute :: Note -> R 'R.Html noteHtmlRoute note@Note {..} = - -- Favour slug if one exists, otherwise use the full path. In both cases - -- expand a trailing "index" slug so URL encoding round-trips through - -- pretty URLs (see `R.expandIndexSlug` and #542). - R.mkRouteFromSlugs $ R.expandIndexSlug $ case noteSlug note of - Just slugs -> slugs - Nothing -> R.unRoute (R.withLmlRoute coerce _noteRoute :: R 'R.Html) + case noteSlug note of + Just slugs -> + -- An explicit `slug:` is taken at face value: the user typed the URL. + R.mkRouteFromSlugs slugs + Nothing -> + -- File-path-derived route: `mkRouteFromFilePath' True` stripped a + -- trailing "index"; re-add it so a pretty URL still leaves the folder + -- slug visible (see `R.expandIndexSlug` and #542). + R.mkRouteFromSlugs + $ R.expandIndexSlug + $ R.unRoute (R.withLmlRoute coerce _noteRoute :: R 'R.Html) lookupNotesByHtmlRoute :: R 'R.Html -> IxNote -> [Note] lookupNotesByHtmlRoute htmlRoute = From e396a7c6345711a5c90de8367aa360869ebaab69 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Thu, 30 Apr 2026 22:29:03 -0400 Subject: [PATCH 3/8] =?UTF-8?q?refactor(hickey):=20introduce=20lmlToHtmlRo?= =?UTF-8?q?ute=20as=20the=20LML=E2=86=92HTML=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- emanote/src/Emanote/Model/Note.hs | 10 ++++------ emanote/src/Emanote/Route/ModelRoute.hs | 23 ++++++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/emanote/src/Emanote/Model/Note.hs b/emanote/src/Emanote/Model/Note.hs index 561d9d022..70e34bb0b 100644 --- a/emanote/src/Emanote/Model/Note.hs +++ b/emanote/src/Emanote/Model/Note.hs @@ -27,6 +27,7 @@ import Emanote.Pandoc.Markdown.Parser qualified as Markdown import Emanote.Pandoc.Markdown.Syntax.HashTag qualified as HT import Emanote.Route qualified as R import Emanote.Route.Ext (FileType (Folder)) +import Emanote.Route.ModelRoute qualified as MR import Emanote.Route.R (R) import Emanote.Source.Loc (Loc) import Network.URI.Slug (Slug) @@ -224,12 +225,9 @@ noteHtmlRoute note@Note {..} = -- An explicit `slug:` is taken at face value: the user typed the URL. R.mkRouteFromSlugs slugs Nothing -> - -- File-path-derived route: `mkRouteFromFilePath' True` stripped a - -- trailing "index"; re-add it so a pretty URL still leaves the folder - -- slug visible (see `R.expandIndexSlug` and #542). - R.mkRouteFromSlugs - $ R.expandIndexSlug - $ R.unRoute (R.withLmlRoute coerce _noteRoute :: R 'R.Html) + -- File-path-derived: route through the canonical LML→HTML conversion + -- so the trailing-index expansion isn't forgotten (see #542). + MR.lmlToHtmlRoute _noteRoute lookupNotesByHtmlRoute :: R 'R.Html -> IxNote -> [Note] lookupNotesByHtmlRoute htmlRoute = diff --git a/emanote/src/Emanote/Route/ModelRoute.hs b/emanote/src/Emanote/Route/ModelRoute.hs index 5d254a2d1..cb79aca9c 100644 --- a/emanote/src/Emanote/Route/ModelRoute.hs +++ b/emanote/src/Emanote/Route/ModelRoute.hs @@ -17,6 +17,7 @@ module Emanote.Route.ModelRoute ( possibleLmlRoutes, lmlRouteCase, withLmlRoute, + lmlToHtmlRoute, mkLMLRouteFromFilePath, mkLMLRouteFromKnownFilePath, isMdRoute, @@ -25,7 +26,7 @@ module Emanote.Route.ModelRoute ( ) where import Data.Aeson.Types (ToJSON) -import Emanote.Route.Ext (FileType (AnyExt, LMLType, Xml), HasExt, LML (Md, Org)) +import Emanote.Route.Ext (FileType (AnyExt, Html, LMLType, Xml), HasExt, LML (Md, Org)) import Emanote.Route.R (R) import Emanote.Route.R qualified as R import Relude @@ -75,6 +76,26 @@ isMdRoute = \case withLmlRoute :: (forall lmlType. (HasExt ('LMLType lmlType)) => R ('LMLType lmlType) -> r) -> LMLRoute -> r withLmlRoute f = either f f . lmlRouteCase +{- | Canonical `R 'Html` route for an LML route. + +The LML route is the *internal* identity Emanote assigns to a `.md` / `.org` +note: `mkRouteFromFilePath' True` strips a trailing `index` slug so +@foo/index.md@ and @foo.md@ share one route. Going the other way — to a URL — +is therefore not the identity. `R.expandIndexSlug` re-adds the trailing +@"index"@ slug whenever the LML route already ends in @"index"@ (and isn't +the lone root) so that the encoded HTML path survives the second @"index"@ +strip Ema applies in `UrlPretty` mode. Without this step a folder also +named @index@ collapses one real directory level — see #542. + +This is the only sanctioned way to convert an `LMLRoute` into an `R 'Html` +intended for URL emission. +-} +lmlToHtmlRoute :: LMLRoute -> R 'Html +lmlToHtmlRoute lmlR = + R.mkRouteFromSlugs + $ R.expandIndexSlug + $ R.unRoute (withLmlRoute coerce lmlR :: R 'Html) + modelRouteCase :: ModelRoute -> Either (LMLView, LMLRoute) StaticFileRoute From 1437f95a0d79b481590005f437ab71b4bbd4b845 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Thu, 30 Apr 2026 22:29:46 -0400 Subject: [PATCH 4/8] refactor(lowy): name the inverse rule in mkRouteFromFilePath' doc 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. --- emanote/src/Emanote/Route/R.hs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/emanote/src/Emanote/Route/R.hs b/emanote/src/Emanote/Route/R.hs index ba4f97bde..f3e44aadd 100644 --- a/emanote/src/Emanote/Route/R.hs +++ b/emanote/src/Emanote/Route/R.hs @@ -34,6 +34,11 @@ mkRouteFromFilePath = mkRouteFromFilePath' False {- | Like `mkRouteFromFilePath` but drops the last slug if it's "index" Behaves like `mkRouteFromFilePath` for top-level files. + +Going the other way (route → URL) is therefore not the identity: callers +emitting an HTML URL must run the slugs through `expandIndexSlug` so the +folder slug survives Ema's pretty-URL strip when a folder is itself +named @index@. See #542. -} mkRouteFromFilePath' :: forall a (ext :: FileType a). (HasExt ext) => Bool -> FilePath -> Maybe (R ext) mkRouteFromFilePath' dropIndex fp = do From 209ca022fb727ef0e7507152cf86642626d664dd Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Thu, 30 Apr 2026 22:32:37 -0400 Subject: [PATCH 5/8] fix(tests): annotate dropIndex regression cases via named routes 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. --- emanote/test/Emanote/Route/RSpec.hs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/emanote/test/Emanote/Route/RSpec.hs b/emanote/test/Emanote/Route/RSpec.hs index 185854b2f..6a92668f4 100644 --- a/emanote/test/Emanote/Route/RSpec.hs +++ b/emanote/test/Emanote/Route/RSpec.hs @@ -38,10 +38,9 @@ mkRouteFromFilePathSpec = describe "mkRouteFromFilePath" $ do it "nested index folder file (#542)" . hedgehog $ do -- foo/index/index.md keeps the folder slug; only the trailing -- filename "index" is dropped. - mkRouteFromFilePath' True "foo/index/index.md" === Just (R $ "foo" :| ["index"]) + mkRouteFromFilePath' True "foo/index/index.md" === Just r1Index it "deeply nested index folders (#542)" . hedgehog $ do - mkRouteFromFilePath' True "index/index/index/example.md" - === Just (R $ "index" :| ["index", "index", "example"]) + mkRouteFromFilePath' True "index/index/index/example.md" === Just rNested routeInitsSpec :: Spec routeInitsSpec = describe "routeInits" $ do @@ -92,3 +91,7 @@ r3Index = R $ "foo" :| ["bar", "qux", "index"] rIndex :: R ('LMLType 'Md) rIndex = R $ "index" :| [] + +-- Deeply nested index folders, exercising #542. +rNested :: R ('LMLType 'Md) +rNested = R $ "index" :| ["index", "index", "example"] From b0b1baaed15e4ccd86521b362a8f5f25b0f7a53b Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Thu, 30 Apr 2026 22:37:31 -0400 Subject: [PATCH 6/8] refactor(lowy): split mkRouteFromFilePath' boolean into named functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- emanote/src/Emanote/Route/ModelRoute.hs | 6 +++--- emanote/src/Emanote/Route/R.hs | 22 ++++++++++++---------- emanote/src/Emanote/Source/Patch.hs | 2 +- emanote/test/Emanote/Route/RSpec.hs | 18 +++++++++--------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/emanote/src/Emanote/Route/ModelRoute.hs b/emanote/src/Emanote/Route/ModelRoute.hs index cb79aca9c..cd81b76c3 100644 --- a/emanote/src/Emanote/Route/ModelRoute.hs +++ b/emanote/src/Emanote/Route/ModelRoute.hs @@ -79,7 +79,7 @@ withLmlRoute f = either f f . lmlRouteCase {- | Canonical `R 'Html` route for an LML route. The LML route is the *internal* identity Emanote assigns to a `.md` / `.org` -note: `mkRouteFromFilePath' True` strips a trailing `index` slug so +note: `mkLmlRouteFromFilePath` strips a trailing `index` slug so @foo/index.md@ and @foo.md@ share one route. Going the other way — to a URL — is therefore not the identity. `R.expandIndexSlug` re-adds the trailing @"index"@ slug whenever the LML route already ends in @"index"@ (and isn't @@ -143,5 +143,5 @@ mkLMLRouteFromFilePath fp = mkLMLRouteFromKnownFilePath :: LML -> FilePath -> Maybe LMLRoute mkLMLRouteFromKnownFilePath lmlType fp = case lmlType of - Md -> fmap LMLRoute_Md (R.mkRouteFromFilePath' True fp) - Org -> fmap LMLRoute_Org (R.mkRouteFromFilePath' True fp) + Md -> fmap LMLRoute_Md (R.mkLmlRouteFromFilePath fp) + Org -> fmap LMLRoute_Org (R.mkLmlRouteFromFilePath fp) diff --git a/emanote/src/Emanote/Route/R.hs b/emanote/src/Emanote/Route/R.hs index f3e44aadd..f4de942c8 100644 --- a/emanote/src/Emanote/Route/R.hs +++ b/emanote/src/Emanote/Route/R.hs @@ -29,22 +29,24 @@ instance (HasExt ext) => Show (R ext) where -- | Convert foo/bar.<ext> to a @R@ mkRouteFromFilePath :: forall a (ext :: FileType a). (HasExt ext) => FilePath -> Maybe (R ext) -mkRouteFromFilePath = mkRouteFromFilePath' False - -{- | Like `mkRouteFromFilePath` but drops the last slug if it's "index" +mkRouteFromFilePath fp = do + base <- withoutKnownExt @_ @ext fp + slugs <- nonEmpty $ fromString . toString . T.dropWhileEnd (== '/') . toText <$> splitPath base + pure $ R slugs -Behaves like `mkRouteFromFilePath` for top-level files. +{- | Like `mkRouteFromFilePath` but drops a trailing @"index"@ slug so that +@foo/index.md@ shares one route with @foo.md@ (the LML "folder note" +canonicalization). The lone root @index.md@ is preserved. Going the other way (route → URL) is therefore not the identity: callers emitting an HTML URL must run the slugs through `expandIndexSlug` so the folder slug survives Ema's pretty-URL strip when a folder is itself named @index@. See #542. -} -mkRouteFromFilePath' :: forall a (ext :: FileType a). (HasExt ext) => Bool -> FilePath -> Maybe (R ext) -mkRouteFromFilePath' dropIndex fp = do - base <- withoutKnownExt @_ @ext fp - slugs <- nonEmpty $ fromString . toString . T.dropWhileEnd (== '/') . toText <$> splitPath base - if dropIndex && length slugs > 1 && last slugs == "index" +mkLmlRouteFromFilePath :: forall a (ext :: FileType a). (HasExt ext) => FilePath -> Maybe (R ext) +mkLmlRouteFromFilePath fp = do + R slugs <- mkRouteFromFilePath @a @ext fp + if length slugs > 1 && last slugs == "index" then viaNonEmpty R $ init slugs else pure $ R slugs @@ -53,7 +55,7 @@ mkRouteFromSlugs = R {- | Re-add a trailing @"index"@ slug when the route ends in @"index"@ and has -more than one slug. Inverse of `mkRouteFromFilePath'` @True@'s "drop trailing +more than one slug. Inverse of `mkLmlRouteFromFilePath`'s "drop trailing index" rule. When an LML route is converted to an `R` @'Html@ for URL emission, the diff --git a/emanote/src/Emanote/Source/Patch.hs b/emanote/src/Emanote/Source/Patch.hs index 67c54ca85..2c721e153 100644 --- a/emanote/src/Emanote/Source/Patch.hs +++ b/emanote/src/Emanote/Source/Patch.hs @@ -97,7 +97,7 @@ patchModel' layers noteF storkIndexTVar scriptingEngine fpType fp action = do log $ "Removing note: " <> toText fp pure $ M.modelDeleteNote r R.Yaml -> - case R.mkRouteFromFilePath' True fp of + case R.mkLmlRouteFromFilePath fp of Nothing -> pure id Just r -> case action of diff --git a/emanote/test/Emanote/Route/RSpec.hs b/emanote/test/Emanote/Route/RSpec.hs index 6a92668f4..be18c49e2 100644 --- a/emanote/test/Emanote/Route/RSpec.hs +++ b/emanote/test/Emanote/Route/RSpec.hs @@ -16,8 +16,8 @@ spec = do expandIndexSlugSpec mkRouteFromFilePathSpec :: Spec -mkRouteFromFilePathSpec = describe "mkRouteFromFilePath" $ do - describe "basic" $ do +mkRouteFromFilePathSpec = do + describe "mkRouteFromFilePath" $ do it "index route" . hedgehog $ do mkRouteFromFilePath @_ @SomeExt "index.md" === Just indexRoute it "single slug" . hedgehog $ do @@ -26,21 +26,21 @@ mkRouteFromFilePathSpec = describe "mkRouteFromFilePath" $ do mkRouteFromFilePath "foo/bar.md" === Just r2 it "three slugs" . hedgehog $ do mkRouteFromFilePath "foo/bar/qux.md" === Just r3 - describe "dropIndex" $ do + describe "mkLmlRouteFromFilePath" $ do it "index route" . hedgehog $ do - mkRouteFromFilePath' True "index.md" === Just rIndex + mkLmlRouteFromFilePath "index.md" === Just rIndex it "single slug" . hedgehog $ do - mkRouteFromFilePath' True "foo.md" === Just r1 + mkLmlRouteFromFilePath "foo.md" === Just r1 it "two slugs" . hedgehog $ do - mkRouteFromFilePath' True "foo/index.md" === Just r1 + mkLmlRouteFromFilePath "foo/index.md" === Just r1 it "three slugs" . hedgehog $ do - mkRouteFromFilePath' True "foo/bar/index.md" === Just r2 + mkLmlRouteFromFilePath "foo/bar/index.md" === Just r2 it "nested index folder file (#542)" . hedgehog $ do -- foo/index/index.md keeps the folder slug; only the trailing -- filename "index" is dropped. - mkRouteFromFilePath' True "foo/index/index.md" === Just r1Index + mkLmlRouteFromFilePath "foo/index/index.md" === Just r1Index it "deeply nested index folders (#542)" . hedgehog $ do - mkRouteFromFilePath' True "index/index/index/example.md" === Just rNested + mkLmlRouteFromFilePath "index/index/index/example.md" === Just rNested routeInitsSpec :: Spec routeInitsSpec = describe "routeInits" $ do From 23d0eb78225f9163b1c79eefe01b83e377bb4a44 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Fri, 1 May 2026 08:06:38 -0400 Subject: [PATCH 7/8] test(e2e): regression scenario for nested-index breadcrumb URLs (#542) 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). --- tests/features/smoke.feature | 5 ++ .../notebook/index/index/index/example.md | 8 +++ tests/step_definitions/smoke_steps.ts | 52 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 tests/fixtures/notebook/index/index/index/example.md diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature index 5229e47b1..14bee19ad 100644 --- a/tests/features/smoke.feature +++ b/tests/features/smoke.feature @@ -152,3 +152,8 @@ Feature: Smoke Scenario: Backlink context wrapper does not impose a vertical scrollbar (no overflow-x:auto on the outer wrapper) When I open "/dailyhost.html" Then every backlink context wrapper has overflow-y "visible" + + 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" diff --git a/tests/fixtures/notebook/index/index/index/example.md b/tests/fixtures/notebook/index/index/index/example.md new file mode 100644 index 000000000..17b7fdcb5 --- /dev/null +++ b/tests/fixtures/notebook/index/index/index/example.md @@ -0,0 +1,8 @@ +# Example inside nested index folders (#542) + +Sole purpose: drive the regression scenario in `smoke.feature` for +[#542](https://github.com/srid/emanote/issues/542). The on-disk layout +`index/index/index/example.md` exists so that the breadcrumb generated +for this note has to traverse three folders that are themselves named +`index`. Before the fix, the immediate-parent breadcrumb's `href` +collapsed to one folder too shallow. diff --git a/tests/step_definitions/smoke_steps.ts b/tests/step_definitions/smoke_steps.ts index d07145773..d6f4bdf6b 100644 --- a/tests/step_definitions/smoke_steps.ts +++ b/tests/step_definitions/smoke_steps.ts @@ -771,6 +771,58 @@ Then( }, ); +// #542 — for a note at `index/index/index/example.md`, the immediate-parent +// breadcrumb (the folder placeholder for `index/index/index/`) used to emit +// `/index/index/` because its LML route had its trailing `index` stripped by +// the canonicalizer and Ema's URL strategy then stripped the next-trailing +// `index`/`index.html`. Two strips composed and ate one real folder level. +// The fix re-adds the trailing `index` slug at the LML→Html boundary, so the +// breadcrumb now points to `/index/index/index/...`. Asserting on a substring +// is enough — we don't care about the exact suffix shape (`/index/index/index` +// vs `/index/index/index/index.html` etc.), only that *three* `index` segments +// survive into the href. The negative assertion guards against any future +// regression that produces a strict-prefix match where the third segment is +// gone. +const IMMEDIATE_PARENT_BREADCRUMB_SEL = "#breadcrumbs a"; + +async function immediateParentBreadcrumbHref( + page: EmanoteWorld["page"], +): Promise<string> { + const link = page.locator(IMMEDIATE_PARENT_BREADCRUMB_SEL).last(); + await link.waitFor({ state: "attached", timeout: 5_000 }); + const href = await link.getAttribute("href"); + assert.ok( + href !== null && href !== undefined, + `Immediate-parent breadcrumb anchor has no href attribute. The breadcrumbs splice (Emanote.View.Common.routeBreadcrumbs) likely emitted the <a> without binding crumb:url.`, + ); + return href; +} + +Then( + "the immediate-parent breadcrumb href contains {string}", + async function (this: EmanoteWorld, needle: string) { + const href = await immediateParentBreadcrumbHref(this.page); + assert.ok( + href.includes(needle), + `Immediate-parent breadcrumb href expected to contain ${JSON.stringify(needle)}, got ${JSON.stringify(href)}. The trailing-index expansion regressed (R.expandIndexSlug or MR.lmlToHtmlRoute), so Ema's URL strip ate one folder level — see #542.`, + ); + }, +); + +Then( + "the immediate-parent breadcrumb href does not equal {string}", + async function (this: EmanoteWorld, forbidden: string) { + const href = await immediateParentBreadcrumbHref(this.page); + // Tolerate a leading "/" or "./" — what we forbid is the exact buggy path. + const stripped = href.replace(/^\.?\//, "").replace(/\.html$/, ""); + assert.notStrictEqual( + stripped, + forbidden, + `Immediate-parent breadcrumb href stripped to ${JSON.stringify(stripped)}, which equals the buggy #542 value ${JSON.stringify(forbidden)}. The expansion in noteHtmlRoute regressed.`, + ); + }, +); + // Stork dark/light theme mirror: stork.js's MutationObserver on // <html>.class flips the wrapper between stork-wrapper-edible and // stork-wrapper-edible-dark. Without one of those classes on the From d842cc56849bce06a2189543b2b72c4a2811fc4f Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <srid@srid.ca> Date: Sat, 2 May 2026 08:21:40 -0400 Subject: [PATCH 8/8] test(#542): coexistence of folder note with same-named child folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- emanote/test/Emanote/Route/RSpec.hs | 44 ++++++++++++++++ tests/features/smoke.feature | 6 +++ .../notebook/subfolder/index/example.md | 7 +++ tests/step_definitions/smoke_steps.ts | 51 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 tests/fixtures/notebook/subfolder/index/example.md diff --git a/emanote/test/Emanote/Route/RSpec.hs b/emanote/test/Emanote/Route/RSpec.hs index be18c49e2..68c40d5f7 100644 --- a/emanote/test/Emanote/Route/RSpec.hs +++ b/emanote/test/Emanote/Route/RSpec.hs @@ -14,6 +14,7 @@ spec = do mkRouteFromFilePathSpec routeInitsSpec expandIndexSlugSpec + folderNoteCoexistenceSpec mkRouteFromFilePathSpec :: Spec mkRouteFromFilePathSpec = do @@ -71,6 +72,49 @@ expandIndexSlugSpec = describe "expandIndexSlug" $ do expandIndexSlug ("index" :| ["index", "index"]) === "index" :| ["index", "index", "index"] +-- Regression suite for the coexistence of @foo/index.md@ (a folder note for +-- @foo\/@) with deeply-nested index-named folders like +-- @foo\/index\/{index.md,bar.md}@. Pre-#542 these collided onto one URL; the +-- fix sends them to distinct HTML routes. The cases below pin every link in +-- that chain — file path → LML route → HTML route slugs — so a future +-- regression in either side surfaces here, not as an "ambiguous notes" error +-- at runtime. +folderNoteCoexistenceSpec :: Spec +folderNoteCoexistenceSpec = + describe "folder note coexistence with nested index folders (#542)" $ do + it "foo/index.md and foo/index/index.md decode to distinct LML routes" . hedgehog $ do + let folderNote = mkLmlRouteFromFilePath @_ @SomeExt "foo/index.md" + nestedIndex = mkLmlRouteFromFilePath @_ @SomeExt "foo/index/index.md" + folderNote === Just r1 -- R "foo" + nestedIndex === Just r1Index -- R "foo" :| ["index"] + assert (folderNote /= nestedIndex) + it "foo/index/bar.md decodes to a third distinct LML route" . hedgehog $ do + mkLmlRouteFromFilePath @_ @SomeExt "foo/index/bar.md" + === Just (R $ "foo" :| ["index", "bar"]) + it "expandIndexSlug leaves the folder-note route alone" . hedgehog $ do + -- foo/index.md → R("foo"). Length 1, no expansion. Its URL stays /foo/ + -- (or /foo.html in direct mode). The fix must not touch this case — + -- breaking it would re-collide foo.md and foo/index.md at the + -- IxNote level. + expandIndexSlug (unRoute r1) === unRoute r1 + it "expandIndexSlug only re-extends the deeply-nested form" . hedgehog $ do + -- foo/index/index.md → R("foo","index"). Length 2 ending in "index" → + -- expansion fires. URL becomes /foo/index/, distinct from /foo/. + expandIndexSlug (unRoute r1Index) === ("foo" :| ["index", "index"]) + it "expandIndexSlug leaves foo/index/bar.md alone" . hedgehog $ do + -- Last slug "bar" is not "index", so no expansion regardless of depth. + expandIndexSlug ("foo" :| ["index", "bar"]) === ("foo" :| ["index", "bar"]) + it "all three coexisting routes produce distinct HTML route slugs" . hedgehog $ do + -- This is the load-bearing assertion: the fix's whole purpose is to + -- separate these three notes into three different IxNote keys so they + -- can coexist in one notebook without collision. + let folderNoteHtml = expandIndexSlug ("foo" :| []) -- foo/index.md + nestedIndexHtml = expandIndexSlug ("foo" :| ["index"]) -- foo/index/index.md + siblingHtml = expandIndexSlug ("foo" :| ["index", "bar"]) -- foo/index/bar.md + assert (folderNoteHtml /= nestedIndexHtml) + assert (folderNoteHtml /= siblingHtml) + assert (nestedIndexHtml /= siblingHtml) + r1 :: R ('LMLType 'Md) r1 = R $ "foo" :| [] diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature index 14bee19ad..08a80419f 100644 --- a/tests/features/smoke.feature +++ b/tests/features/smoke.feature @@ -157,3 +157,9 @@ Feature: Smoke 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" + + Scenario: Folder note coexists with a same-named child folder (regression: #542) + 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 2 diff --git a/tests/fixtures/notebook/subfolder/index/example.md b/tests/fixtures/notebook/subfolder/index/example.md new file mode 100644 index 000000000..42cecbfd3 --- /dev/null +++ b/tests/fixtures/notebook/subfolder/index/example.md @@ -0,0 +1,7 @@ +# Example inside subfolder/index/ (#542) + +Companion fixture for the regression scenario that exercises the +*coexistence* case: `subfolder/index.md` is the folder note for +`subfolder/`, and this file lives inside a deeper folder *also* named +`index`. Pre-#542 the two collapsed onto a single URL; the fix sends +them to distinct ones (`/subfolder.html` vs `/subfolder/index/...`). diff --git a/tests/step_definitions/smoke_steps.ts b/tests/step_definitions/smoke_steps.ts index d6f4bdf6b..00fd55741 100644 --- a/tests/step_definitions/smoke_steps.ts +++ b/tests/step_definitions/smoke_steps.ts @@ -823,6 +823,57 @@ Then( }, ); +// Coexistence guard for #542: when a folder note (`foo/index.md`) sits next +// to a deeper same-named folder (`foo/index/...`), each breadcrumb position +// must point at a *different* URL. Pre-fix, both collapsed onto `/foo` — +// the IxNote dedup would either drop one note or surface an "ambiguous +// notes" error. Asserting on positional hrefs is the smallest change that +// catches a re-collision: depth-1 must reach the folder note, depth-2 must +// reach the deeper placeholder, and they must not be equal. +async function breadcrumbHrefAt( + page: EmanoteWorld["page"], + depth: number, +): Promise<string> { + // Skip the root indexRoute — emanote's routeInits emits it twice for any + // route whose first slug is "index", so depth-counting from "index"-first + // routes would otherwise drift by one. We index from the first non-root + // crumb the breadcrumb component renders, which is `<a>` #N (zero-based), + // matching how a reader counts "subfolder is the first link, subfolder/index + // is the second". + const link = page.locator(IMMEDIATE_PARENT_BREADCRUMB_SEL).nth(depth); + await link.waitFor({ state: "attached", timeout: 5_000 }); + const href = await link.getAttribute("href"); + assert.ok( + href !== null && href !== undefined, + `Breadcrumb anchor at depth ${depth} has no href attribute.`, + ); + return href; +} + +Then( + "the breadcrumb at depth {int} has href containing {string}", + async function (this: EmanoteWorld, depth: number, needle: string) { + const href = await breadcrumbHrefAt(this.page, depth); + assert.ok( + href.includes(needle), + `Breadcrumb at depth ${depth} expected href to contain ${JSON.stringify(needle)}, got ${JSON.stringify(href)}. The folder-note vs nested-index-folder distinction collapsed — likely the trailing-index expansion regressed.`, + ); + }, +); + +Then( + "the breadcrumb at depth {int} has a different href from depth {int}", + async function (this: EmanoteWorld, a: number, b: number) { + const hrefA = await breadcrumbHrefAt(this.page, a); + const hrefB = await breadcrumbHrefAt(this.page, b); + assert.notStrictEqual( + hrefA, + hrefB, + `Breadcrumbs at depths ${a} and ${b} share href ${JSON.stringify(hrefA)} — the folder note at /foo/ has re-collided with the placeholder for /foo/index/ (the #542 regression at the IxNote level).`, + ); + }, +); + // Stork dark/light theme mirror: stork.js's MutationObserver on // <html>.class flips the wrapper between stork-wrapper-edible and // stork-wrapper-edible-dark. Without one of those classes on the