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..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)
@@ -219,12 +220,14 @@ 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 ->
+ -- An explicit `slug:` is taken at face value: the user typed the URL.
R.mkRouteFromSlugs slugs
+ Nothing ->
+ -- 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..cd81b76c3 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: `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
+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
@@ -122,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 84aa9fe1b..f4de942c8 100644
--- a/emanote/src/Emanote/Route/R.hs
+++ b/emanote/src/Emanote/Route/R.hs
@@ -29,17 +29,24 @@ instance (HasExt ext) => Show (R ext) where
-- | Convert foo/bar. to a @R@
mkRouteFromFilePath :: forall a (ext :: FileType a). (HasExt ext) => FilePath -> Maybe (R ext)
-mkRouteFromFilePath = mkRouteFromFilePath' False
+mkRouteFromFilePath fp = do
+ base <- withoutKnownExt @_ @ext fp
+ slugs <- nonEmpty $ fromString . toString . T.dropWhileEnd (== '/') . toText <$> splitPath base
+ pure $ R slugs
-{- | Like `mkRouteFromFilePath` but drops the last slug if it's "index"
+{- | 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.
-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
- 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
@@ -47,6 +54,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 `mkLmlRouteFromFilePath`'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/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 f561cf2d5..68c40d5f7 100644
--- a/emanote/test/Emanote/Route/RSpec.hs
+++ b/emanote/test/Emanote/Route/RSpec.hs
@@ -13,10 +13,12 @@ spec :: Spec
spec = do
mkRouteFromFilePathSpec
routeInitsSpec
+ expandIndexSlugSpec
+ folderNoteCoexistenceSpec
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
@@ -25,15 +27,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.
+ mkLmlRouteFromFilePath "foo/index/index.md" === Just r1Index
+ it "deeply nested index folders (#542)" . hedgehog $ do
+ mkLmlRouteFromFilePath "index/index/index/example.md" === Just rNested
routeInitsSpec :: Spec
routeInitsSpec = describe "routeInits" $ do
@@ -47,6 +55,66 @@ 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"]
+
+-- 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" :| []
@@ -67,3 +135,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"]
diff --git a/tests/features/smoke.feature b/tests/features/smoke.feature
index 5229e47b1..08a80419f 100644
--- a/tests/features/smoke.feature
+++ b/tests/features/smoke.feature
@@ -152,3 +152,14 @@ 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"
+
+ 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/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/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 d07145773..00fd55741 100644
--- a/tests/step_definitions/smoke_steps.ts
+++ b/tests/step_definitions/smoke_steps.ts
@@ -771,6 +771,109 @@ 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 {
+ 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 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.`,
+ );
+ },
+);
+
+// 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 {
+ // 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 `` #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
// .class flips the wrapper between stork-wrapper-edible and
// stork-wrapper-edible-dark. Without one of those classes on the