Skip to content
Draft
4 changes: 3 additions & 1 deletion docs/authoring/wikilinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ A wiki-link can resolve to a [[folder-note|folder note]] — a `foo.md` placed n

## Anchors

Wiki-links [do not yet](https://github.com/srid/emanote/discussions/105) support anchor links. Regular Markdown links do — for example, [example link](./markdown.md#lists).
Append `#heading-id` to a wiki-link to jump to a specific heading on the target page — e.g. `[[markdown#wikilink]]` renders as [[markdown#wikilink]] and lands on the Wiki Links section of [[markdown]]. The fragment matches Pandoc's auto-generated heading id (the slugified, lowercased form), the same as regular Markdown anchor links. A heading carrying a Pandoc `{#custom-id}` attribute uses that custom id instead of the auto-slug, so the wiki-link must spell it the same way. Custom titles still work: `[[markdown#wikilink|jump to wiki-links]]` renders as [[markdown#wikilink|jump to wiki-links]].

Same-file anchor wiki-links (`[[#heading]]`) are not yet supported — use the regular Markdown form `[heading](#heading)` for now.

## Regular Markdown note links

Expand Down
1 change: 1 addition & 0 deletions emanote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- **`.emanoteignore`** (closes [#228](https://github.com/srid/emanote/issues/228)): each notebook layer may now ship a top-level `.emanoteignore` listing `FilePattern` entries (one per line; blanks and `#`-comment lines skipped) to exclude files from the model. Patterns are scoped to the layer they live in — a pattern in layer A's file does not affect layer B — and are merged with Emanote's universal ignores (`**/.*/**`, `**/*~`, `-/**`, and `**/.emanoteignore` itself). Built on per-source ignore support added to `unionmount`. **Behavior change:** `flake.nix` and `flake.lock` are no longer ignored by default — users who run Emanote from inside a Nix flake notebook should add those entries to their own `.emanoteignore`.
- **MCP server**: new `emanote run --mcp-port PORT` flag runs an in-process Model Context Protocol HTTP endpoint beside the live server. Notebook data is exposed as one read-only resource — `emanote://export/metadata` (JSON, the discovery surface for note source paths) — and three query tools — `find_notes` (case-insensitive search by title or path), `get_backlinks`, and `resolve_wikilink`. Each tool result carries the note's notebook-relative `path`; clients read the underlying file with their own filesystem tools. Subscriptions follow in a later phase. See [[mcp]] ([#645](https://github.com/srid/emanote/issues/645), [#649](https://github.com/srid/emanote/pull/649))
- Default template chrome can now be localized through `page.lang` and `template.i18n`. English remains the fallback language, and French and Chinese strings are included for the built-in navigation, search, copy buttons, labels, and error chrome (closes [#486](https://github.com/srid/emanote/issues/486), [#722](https://github.com/srid/emanote/pull/722)).
- **Heading anchors in wikilinks** ([discussion #105](https://github.com/srid/emanote/discussions/105)): `[[note#heading]]` now lands on the matching heading on the target page — see [[wikilinks]] (Anchors).
- Callouts: support **nested** and **foldable** Obsidian-style callouts (`> [!type]+` / `[!type]-`), rendering as `<details>`/`<summary>` ([#465](https://github.com/srid/emanote/issues/465), [#652](https://github.com/srid/emanote/pull/652))
- **Tailwind v3 → v4 migration** with CSS-variable design tokens ([#633](https://github.com/srid/emanote/pull/633))
- Built-in static syntax highlighting using skylighting, replacing client-side JS highlighters ([#624](https://github.com/srid/emanote/pull/624))
Expand Down
13 changes: 9 additions & 4 deletions emanote/src/Emanote/MCP/ToolCatalog.hs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,13 @@ resolveWikilink wlText mFromPath model = do
-- Internal helpers
-- ---------------------------------------------------------------------------

-- | Parse a slash-separated wikilink target (e.g. "foo/bar") into a 'WL.WikiLink'.
{- | Parse a slash-separated wikilink target (e.g. @"foo/bar"@) into a
'WL.WikiLink', stripping any @#anchor@ via 'WL.dropUrlAnchor' so the MCP
resolver agrees with the HTML renderer on @"note#heading"@ → note @"note"@.
-}
parseWikiLinkText :: Text -> Maybe WL.WikiLink
parseWikiLinkText s
| T.null s = Nothing
| otherwise = viaNonEmpty WL.mkWikiLinkFromSlugs (Slug.decodeSlug <$> T.splitOn "/" s)
parseWikiLinkText raw
| T.null wlPart = Nothing
| otherwise = viaNonEmpty WL.mkWikiLinkFromSlugs (Slug.decodeSlug <$> T.splitOn "/" wlPart)
where
(wlPart, _mAnchor) = WL.dropUrlAnchor raw

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we upstream this to commonmark-wikilink?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstreamed as WL.parseWikiLinkUrl :: Text -> Maybe (WikiLink, Maybe Anchor) in srid/commonmark-wikilink@a3e5938. It's the non-Pandoc counterpart to delineateLink — does the slash-split, slug-decode, and anchor-strip in one call.

Emanote MCP is now a one-liner that just discards the anchor (commit 7a1b242 on this PR), with a comment naming why the asymmetry from the HTML renderer (which keeps the anchor) is intentional.

6 changes: 5 additions & 1 deletion emanote/src/Emanote/Pandoc/Renderer/Embed.hs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ embedBlockWikiLinkResolvingSplice model nr ctx noteRoute node = do
(inlRef, (_, _, otherAttrs), _, (url, tit)) <- Link.parseInlineRef inl
guard $ inlRef == Link.InlineLink
let parentR = M.modelResolveLinkBase model noteRoute
-- TODO: Use anchor to embed a section?
-- TODO(srid/emanote#105 follow-up): the parser preserves an embed's
-- `#anchor` (so `![[note#section]]` reaches this site with `_mAnchor`
-- populated) but section-scoped embed is not implemented yet — the
-- whole target note is inlined. Same applies to the two `_mAnchor`
-- discards in the inline-wikilink and regular-link embed splices below.
(Rel.URTWikiLink (WL.WikiLinkEmbed, wl), _mAnchor) <-
Rel.parseUnresolvedRelTarget parentR (otherAttrs <> one ("title", tit)) url
let rRel = Resolve.resolveWikiLinkMustExist model noteRoute wl
Expand Down
10 changes: 10 additions & 0 deletions emanote/test/Emanote/MCP/ToolCatalogSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ spec = do
ResolvedNote nm -> path nm `shouldBe` "guide/neuron.md"
other -> expectationFailure $ "Unexpected resolve result: " <> show other

it "ignores #anchor on the wikilink target (discussion #105)" $ do
-- MCP resolution agrees with the HTML renderer that "note#heading"
-- still resolves to "note"; the anchor is metadata about which
-- section the user is pointing at, not part of the note identity.
case resolveWikilink "guide/neuron#alpha-section" Nothing notebook of
Left e -> expectationFailure $ "Expected Right but got Left: " <> show e
Right res -> case res of
ResolvedNote nm -> path nm `shouldBe` "guide/neuron.md"
other -> expectationFailure $ "Unexpected resolve result: " <> show other

it "reports missing when no candidate exists" $ do
resolveWikilink "no-such-note" Nothing notebook `shouldBe` Right UnresolvedMissing

Expand Down
13 changes: 13 additions & 0 deletions emanote/test/Emanote/Model/Link/RelSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ spec = do
]
)
got === want
describe "parseUnresolvedRelTarget anchors (discussion #105)" $ do
let wlAttrs = [("data-wikilink-type", "WikiLinkNormal")]
it "wikilink without anchor → no anchor returned" . hedgehog $ do
let manc = snd =<< parseUnresolvedRelTarget Nothing wlAttrs "note"
manc === Nothing
it "wikilink with anchor → anchor returned alongside target" . hedgehog $ do
let res = parseUnresolvedRelTarget Nothing wlAttrs "note#heading"
manc = WL.anchorSuffix . snd <$> res
manc === Just "#heading"
it "regular link with anchor → anchor returned alongside resource" . hedgehog $ do
let res = parseUnresolvedRelTarget Nothing [] "guide#section"
manc = WL.anchorSuffix . snd <$> res
manc === Just "#section"
describe "noteRels source order (issue #186)" $ do
it "orders rels by source position, not by lexicographic Ord on context" $ do
-- 'Z' sorts last lexicographically but comes first in source; 'A'
Expand Down
8 changes: 4 additions & 4 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
unionmount.flake = false;
commonmark-simple.url = "github:srid/commonmark-simple/0.2.0.0";
commonmark-simple.flake = false;
commonmark-wikilink.url = "github:srid/commonmark-wikilink/master";
commonmark-wikilink.url = "github:srid/commonmark-wikilink/heading-link";
commonmark-wikilink.flake = false;

emanote-template.url = "github:srid/emanote-template";
Expand Down
5 changes: 5 additions & 0 deletions tests/features/smoke.feature
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ Feature: Smoke
When I open "/wikilink-entities.html"
Then the first article link has HTML containing "Spivak&nbsp;(2014)"

Scenario: Wiki link with #anchor renders the fragment on the href (discussion #105)
When I open "/wikilink-anchor.html"
Then the article link with text "Heading target" has href containing "#alpha-section"
And the article link with text "jump to alpha" has href containing "#alpha-section"

Scenario: URL-bearing link labels render as one hyperlink (regression: #349)
When I open "/issue-349.html"
Then the article link with text "https://issue349-case1.example.com" has href containing "issue349-case1.example.com#anchor"
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/notebook/heading-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Heading target

Intro paragraph.

## Alpha Section

This section is the wikilink-anchor jump target.

## Beta Section

This section exists so the auto-id slugs distinguish two headings.
5 changes: 5 additions & 0 deletions tests/fixtures/notebook/wikilink-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Wikilink heading anchor

A cross-file heading link: [[heading-target#alpha-section]].

With a custom title: [[heading-target#alpha-section|jump to alpha]].
Loading