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.<ext> 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<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.`, + ); + }, +); + +// 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