mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
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:
1 parent
9ec89f0735
commit
fee41a65b5
2 files changed
+118
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user