Skip to content
1 change: 1 addition & 0 deletions emanote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <title> (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)
Expand Down
9 changes: 6 additions & 3 deletions emanote/src/Emanote/Model/Note.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 =
Expand Down
27 changes: 24 additions & 3 deletions emanote/src/Emanote/Route/ModelRoute.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module Emanote.Route.ModelRoute (
possibleLmlRoutes,
lmlRouteCase,
withLmlRoute,
lmlToHtmlRoute,
mkLMLRouteFromFilePath,
mkLMLRouteFromKnownFilePath,
isMdRoute,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
39 changes: 31 additions & 8 deletions emanote/src/Emanote/Route/R.hs
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,47 @@ 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

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
Expand Down
2 changes: 1 addition & 1 deletion emanote/src/Emanote/Source/Patch.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 79 additions & 7 deletions emanote/test/Emanote/Route/RSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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" :| []

Expand All @@ -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"]
11 changes: 11 additions & 0 deletions tests/features/smoke.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions tests/fixtures/notebook/index/index/index/example.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions tests/fixtures/notebook/subfolder/index/example.md
Original file line number Diff line number Diff line change
@@ -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/...`).
Loading
Loading