fix(markdown): resolve links to file names that contain a space

CommonMark writes a link destination with a space either in angle
brackets ([x](<My Note.md>)) or percent-encoded ([x](My%20Note.md)).
The inline and reference-definition regexes stopped the capture at the
first space inside <...>, and the destination was never decoded, so
both forms kept an id derived from a nonexistent path and the
references edge dropped at build.

Capture the whole bracketed destination in both regexes; an unclosed <
falls back to the bare form, so it does not swallow the links after it.
Percent-decode inline and reference destinations, keep a file whose name
literally contains the escape (with or without the .md completion), and
re-run the external-link guard on the decoded form. Wikilinks stay
verbatim.

Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp)
(cherry picked from commit 49ba8aafc0)
This commit is contained in:
Yyunozor authored and safishamsi committed 2026-10-07 17:26:00 +01:00
1 parent 9ec89f0735
commit fee41a65b5
2 files changed
+118 -7

No files matched your search

+56 -7
View File
@@ -6,14 +6,27 @@ import os
import unicodedata
from pathlib import Path
from urllib.parse import unquote
from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, classify_file
from graphify.extractors.base import _file_stem, _make_id, _read_source_text
from graphify.security import sanitize_metadata
_MD_INLINE_LINK_RE = re.compile(r'(?<!\!)\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+[^)]*)?\)')
# A link destination is either <bracketed>, the only CommonMark form that may
# contain spaces ([t](<My Note.md>)), or a bare run without whitespace. The
# bracketed form is tried first so a spaced name is captured whole instead of
# being cut at the first space; callers take whichever group matched. After the
# `>` it only accepts a CommonMark title ("..", '..' or (..) without a nested
# paren), so an unclosed `<` falls back to the bare form rather than swallowing
# the links that follow on the line.
_MD_INLINE_LINK_RE = re.compile(
r'(?<!\!)\[[^\]]*\]\(\s*(?:'
r'<([^<>\n]+)>\s*(?:"[^"\n]*"|\'[^\'\n]*\'|\([^()\n]*\))?\s*'
r'|<?([^)\s>]+)>?(?:\s+[^)]*)?'
r')\)')
_MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)>?')
_MD_REF_DEF_RE = re.compile(
r'^\s{0,3}\[[^\]]+\]:\s*(?:<([^<>\n]+)>(?=\s*(?:["\'(]|$))|<?([^\s>]+)>?)')
_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]+?)(?:\\?\|[^\]]*|#[^\]]*)?\]\]')
@@ -207,6 +220,35 @@ def _vault_lookup(target: str, root: Path) -> "Path | None":
return min(matches)[2]
def _percent_decoded(target: str, source_dir: Path) -> str:
"""Decode a URL-style link destination such as ``My%20Note.md``.
Inline and reference links are URLs, so a space or a non-ASCII character
in a file name is written percent-encoded (the form Obsidian writes when
wikilinks are turned off). A file whose name literally contains the
escape keeps precedence (``[x](100%25)`` beside ``100%25.md``), so a
link that resolved before still resolves to the same file. Wikilinks
carry the note name verbatim and never come through here.
"""
if "%" not in target:
return target
decoded = unquote(target)
if decoded == target:
return target
literal = target if Path(target).suffix else target + ".md"
try:
if (source_dir / literal).is_file():
return target
except OSError:
pass
return decoded
def _is_external_link(target: str) -> bool:
low = target.lower()
return "://" in target or low.startswith(("mailto:", "tel:", "//", "data:"))
def _resolve_markdown_link(raw: str, source_dir: Path,
wikilink: bool = False) -> "Path | None":
"""Resolve a markdown link target to the absolute path of a sibling document.
@@ -219,7 +261,9 @@ def _resolve_markdown_link(raw: str, source_dir: Path,
The anchor fragment (``#section``) and query (``?x=1``) are stripped before
resolution so ``./repo.md#setup`` resolves to the same node as ``./repo.md``.
Extension-less targets (typical of wikilinks) are treated as sibling ``.md``.
Inline and reference destinations are percent-decoded (``My%20Note.md``),
see _percent_decoded. Extension-less targets (typical of wikilinks) are
treated as sibling ``.md``.
A wikilink whose note name itself contains a dot (``[[note.en]]``,
``[[v1.2 release]]``) gets the same ``.md`` completion, but only when that
document exists and no file graphify indexes sits at the literal target;
@@ -238,9 +282,14 @@ def _resolve_markdown_link(raw: str, source_dir: Path,
target = target.split("#", 1)[0].split("?", 1)[0].strip()
if not target:
return None
low = target.lower()
if "://" in target or low.startswith(("mailto:", "tel:", "//", "data:")):
if _is_external_link(target):
return None
if not wikilink:
# Decoding can reveal a scheme or a protocol-relative prefix
# (%2F%2Fhost), so the external-link guard runs again.
target = _percent_decoded(target, source_dir)
if _is_external_link(target):
return None
suffix = Path(target).suffix.lower()
if suffix == "":
target = target + ".md"
@@ -484,12 +533,12 @@ def extract_markdown(path: Path) -> dict:
# non-fenced line (including heading lines, which the heading branch
# below `continue`s past) so links anywhere in the doc are captured.
for m in _MD_INLINE_LINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
add_link(m.group(1) or m.group(2), line_num)
for m in _MD_WIKILINK_RE.finditer(line_text):
add_link(m.group(1), line_num, wikilink=True)
ref_def = _MD_REF_DEF_RE.match(line_text)
if ref_def:
add_link(ref_def.group(1), line_num)
add_link(ref_def.group(1) or ref_def.group(2), line_num)
# Inside the frontmatter block a leading `#` is a YAML comment, not an
# H1. Links above are still scanned there on purpose: review workflows
+62
View File
@@ -3730,6 +3730,68 @@ def test_markdown_dotted_wikilink_literal_file_keeps_precedence(tmp_path):
f"an indexed literal file must keep precedence over its .md note: {refs}")
def test_markdown_link_destination_with_spaces(tmp_path):
"""A file name with a space is linked as <bracketed> or percent-encoded,
the two CommonMark forms (and Obsidian's markdown-style output). Both
resolve, in inline and reference-style links alike, instead of being cut
at the space or kept encoded and dropped as a ghost."""
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
docs = [vault / "My Note.md", vault / "sub" / "Deep Note.md",
vault / "Ref One.md", vault / "Ref Two.md"]
for d in docs:
d.write_text("# Note\n")
entry = vault / "entry.md"
entry.write_text(
"See [a](My%20Note.md), [b](<sub/Deep Note.md> \"title\"), [c][r1] and [d][r2].\n"
"\n"
"[r1]: <Ref One.md>\n"
"[r2]: Ref%20Two.md\n")
node_ids, refs, page_id = _vault_extract(vault, docs + [entry])
targets = {e["target"] for e in refs if e["source"] == page_id(entry)}
assert targets == {page_id(d) for d in docs}, f"spaced link lost: {refs}"
for e in refs:
assert e["target"] in node_ids, f"link target is a ghost node: {e}"
def test_markdown_link_forms_keep_their_existing_targets(tmp_path):
"""Decoding and bracket parsing take no link away from the file it already
reached: a file whose name literally contains the escape still wins, with
or without the .md ([x](100%25) beside 100%25.md); a wikilink stays
verbatim ([[My%20Note]] is not My Note.md); an unclosed < does not swallow
the links after it on the line, even inside a parenthesised aside."""
vault = tmp_path / "vault"
vault.mkdir()
literal = vault / "100%25.md"
spaced = vault / "My Note.md"
b, d, f, h = (vault / f"{n}.md" for n in "bdfh")
for doc in (literal, spaced, b, d, f, h):
doc.write_text("# Note\n")
entry = vault / "entry.md"
entry.write_text(
"See [x](100%25.md) and [[My%20Note]].\n"
"Then [a](<b.md) -> [c](d.md).\n"
"And [e](<f.md) -> (see [g](h.md)).\n")
bare = vault / "bare.md"
bare.write_text("See [y](100%25).\n")
node_ids, refs, page_id = _vault_extract(
vault, [literal, spaced, b, d, f, h, entry, bare])
targets = {e["target"] for e in refs if e["source"] == page_id(entry)}
assert targets & node_ids == {page_id(p) for p in (literal, b, d, f, h)}, (
f"a link lost the file it reached: {refs}")
bare_targets = {e["target"] for e in refs if e["source"] == page_id(bare)}
assert bare_targets == {page_id(literal)}, (
f"an extension-less link lost its literal file: {refs}")
def test_markdown_decoded_link_stays_external(tmp_path):
"""An encoded scheme or protocol-relative prefix (a UNC path on Windows)
is still an external link once decoded, never a local file lookup."""
from graphify.extractors.markdown import _resolve_markdown_link
for raw in ("%2F%2Fhost%2Fshare%2Fx.md", "https%3A%2F%2Fexample.com%2Fa.md"):
assert _resolve_markdown_link(raw, tmp_path) is None, raw
# ── Groovy ───────────────────────────────────────────────────────────────────