Skip to content

Commit 96c6d55

Browse files
authored
🔀 Merge pull request #153 from davep/wikilinks
Add a plugin for handling wikilinks-style links
2 parents f5748ee + 4934e7d commit 96c6d55

6 files changed

Lines changed: 140 additions & 13 deletions

File tree

ChangeLog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Hike ChangeLog
22

3+
## Unreleased
4+
5+
**Released: WiP**
6+
7+
- Added basic support for wikilink style links.
8+
([#153](https://github.com/davep/hike/pull/153))
9+
310
## v1.3.0
411

512
**Released: 2026-03-03**

src/hike/markdown/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Markdown-related extensions for Hike."""
2+
3+
##############################################################################
4+
# Local imports.
5+
from .wikilinks import wikilink_plugin
6+
7+
##############################################################################
8+
# Exports.
9+
__all__ = ["wikilink_plugin"]
10+
11+
### __init__.py ends here

src/hike/markdown/wikilinks.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""A MarkdownIt plugin for parsing [[WikiLinks]]."""
2+
3+
##############################################################################
4+
# Python imports.
5+
from pathlib import PurePosixPath
6+
from re import Pattern, compile
7+
from typing import Final
8+
9+
##############################################################################
10+
# MarkdownIt imports.
11+
from markdown_it import MarkdownIt
12+
from markdown_it.rules_inline import StateInline
13+
14+
##############################################################################
15+
_DETECT_WIKILINK: Final[Pattern[str]] = compile(r"\[\[(?P<content>[^\]]+)\]\]")
16+
"""Regular expression for detecting wikilinks."""
17+
18+
19+
##############################################################################
20+
def _make_href(target: str) -> str:
21+
"""Make a URL for the given wikilink target.
22+
23+
Args:
24+
target: The target of the wikilink.
25+
26+
Returns:
27+
A URL for the given wikilink target.
28+
"""
29+
filename, _, anchor = target.strip().partition("#")
30+
if filename and not PurePosixPath(filename).suffix:
31+
filename = f"{filename}.md"
32+
if filename and not anchor:
33+
return filename
34+
if anchor and not filename:
35+
return f"#{anchor}"
36+
return f"{filename}#{anchor}"
37+
38+
39+
##############################################################################
40+
def _handle_wikilink(state: StateInline, silent: bool) -> bool:
41+
"""MarkdownIt rule for parsing wikilinks.
42+
43+
Args:
44+
state: The current inline parser state.
45+
silent: Should we only work in detect mode?
46+
47+
Returns:
48+
Whether a wikilink was successfully parsed.
49+
"""
50+
if (link := _DETECT_WIKILINK.match(state.src, state.pos)) is None:
51+
return False
52+
if not (content := link["content"].strip()):
53+
return False
54+
target, _, text = content.partition("|")
55+
if "\n" in target:
56+
return False
57+
if silent:
58+
return True
59+
token = state.push("link_open", "a", 1)
60+
token.attrs = {"href": _make_href(target)}
61+
token.markup = "[["
62+
token.info = "wikilink"
63+
display_token = state.push("text", "", 0)
64+
display_token.content = (text or target).strip()
65+
state.push("link_close", "a", -1)
66+
state.pos = link.end()
67+
return True
68+
69+
70+
##############################################################################
71+
def wikilink_plugin(markdown: MarkdownIt) -> None:
72+
"""MarkdownIt plugin to add wikilink support.
73+
74+
Args:
75+
md: The MarkdownIt instance to extend.
76+
"""
77+
markdown.inline.ruler.push("wikilink", _handle_wikilink)
78+
79+
80+
### wikilinks.py ends here

src/hike/messages/opening.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ class OpenLocation(Message):
3737
to_open: HikeLocation
3838
"""The location to open."""
3939

40+
anchor: str | None = None
41+
"""An optional anchor to scroll to."""
42+
4043

4144
##############################################################################
4245
@dataclass

src/hike/screens/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,9 @@ async def _open_markdown(self, message: OpenLocation) -> None:
256256
if maybe_markdown(message.to_open) or await can_be_negotiated_to_markdown(
257257
message.to_open
258258
):
259-
self.query_one(Viewer).location = message.to_open
259+
self.query_one(Viewer).goto_anchor_after_load(
260+
message.anchor
261+
).location = message.to_open
260262
if load_configuration().focus_viewer_on_load:
261263
self.query_one(Viewer).focus()
262264
else:

src/hike/widgets/viewer.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from ..commands import JumpToCommandLine
4949
from ..data import is_editable, load_configuration, looks_urllike
5050
from ..editor import Editor
51+
from ..markdown import wikilink_plugin
5152
from ..messages import CopyToClipboard, OpenLocation
5253
from ..support import is_copy_request_click, view_in_browser
5354
from ..types import HikeHistory, HikeLocation
@@ -175,8 +176,10 @@ def __init__(self) -> None:
175176
"""Initialise the widget."""
176177
super().__init__(
177178
open_links=False,
178-
parser_factory=lambda: MarkdownIt("gfm-like").use(
179-
front_matter.front_matter_plugin
179+
parser_factory=lambda: (
180+
MarkdownIt("gfm-like")
181+
.use(front_matter.front_matter_plugin)
182+
.use(wikilink_plugin)
180183
),
181184
)
182185

@@ -249,6 +252,9 @@ class Viewer(Vertical, can_focus=False):
249252
_source: var[str] = var("")
250253
"""The source of the Markdown we're viewing."""
251254

255+
_seek_anchor: var[str | None] = var(None)
256+
"""An optional anchor to seek to once the document is loaded."""
257+
252258
def compose(self) -> ComposeResult:
253259
"""Compose the content of the viewer."""
254260
yield ViewerTitle()
@@ -456,6 +462,9 @@ async def _update_markdown(self, message: Loaded) -> None:
456462
if front_matter_had_focus and not hikedown.front_matter:
457463
self.query_one(MarkdownScroll).focus()
458464
self.query_one(FrontMatter).front_matter = hikedown.front_matter
465+
if self._seek_anchor:
466+
self.query_one(HikeDown).goto_anchor(self._seek_anchor)
467+
self._seek_anchor = None
459468
if (
460469
message.remember
461470
and self.location
@@ -476,6 +485,18 @@ def reload(self) -> None:
476485
"""Reload the current document."""
477486
self._visit(self.location, remember=False, preserve_position=True)
478487

488+
def goto_anchor_after_load(self, anchor: str | None) -> Self:
489+
"""Go to an anchor after the document is loaded.
490+
491+
Args:
492+
anchor: The anchor to go to.
493+
494+
Returns:
495+
Self.
496+
"""
497+
self._seek_anchor = anchor
498+
return self
499+
479500
def goto(self, history_location: int) -> None:
480501
"""Go to a specific location in history."""
481502
if self.history.current_location != history_location:
@@ -538,25 +559,28 @@ def _handle_link(self, message: Markdown.LinkClicked) -> None:
538559
)
539560
return
540561

562+
# Having eliminated URLs, it's likely something more local. First
563+
# off, on the off-chance that there's an anchor involved still...
564+
file_name, _, anchor = message.href.partition("#")
565+
566+
# Some sort of internal anchor perhaps?
567+
if anchor and not file_name:
568+
message.markdown.goto_anchor(anchor)
569+
return
570+
541571
# A local file that exists?
542-
if (local_file := Path(message.href).expanduser()).exists():
543-
self.post_message(OpenLocation(local_file.resolve()))
572+
if (local_file := Path(file_name).expanduser()).exists():
573+
self.post_message(OpenLocation(local_file.resolve(), anchor))
544574
return
545575

546576
# A local file relative to the current location?
547577
if (
548578
isinstance(self.location, Path)
549-
and (local_file := self.location.parent / Path(message.href))
579+
and (local_file := self.location.parent / Path(file_name))
550580
.absolute()
551581
.exists()
552582
):
553-
self.post_message(OpenLocation(local_file))
554-
return
555-
556-
# Some sort of internal anchor perhaps?
557-
if message.href.startswith("#") and message.markdown.goto_anchor(
558-
message.href[1:]
559-
):
583+
self.post_message(OpenLocation(local_file, anchor))
560584
return
561585

562586
self.notify(

0 commit comments

Comments
 (0)