From fbc33c35d0d5a39ebc0a28a82ae32a71e051ef55 Mon Sep 17 00:00:00 2001 From: Rens Date: Tue, 8 Sep 2026 10:54:00 +0200 Subject: [PATCH] Add inline link URL rewriting to rewrite and md2gfm --- py/src/lib.rs | 11 +++++++++++ python/mdhtml/__init__.py | 4 ++-- python/mdhtml/md.py | 12 ++++++++---- src/block.rs | 2 +- src/inline.rs | 29 ++++++++++++++++++----------- src/wrap.rs | 2 +- tests/test_export.py | 13 +++++++++++++ tests/test_python.py | 11 +++++++++++ 8 files changed, 65 insertions(+), 19 deletions(-) diff --git a/py/src/lib.rs b/py/src/lib.rs index 0a02713..cc976a7 100644 --- a/py/src/lib.rs +++ b/py/src/lib.rs @@ -200,6 +200,17 @@ fn edit_nodes(py: Python<'_>, markdown: &str, math: &str, templates: Option { + d.set_item("type", "link")?; + d.set_item("form", "inline")?; + d.set_item("source", &markdown[range.clone()])?; + d.set_item("start", range.start)?; + d.set_item("end", range.end)?; + d.set_item("url", url)?; + d.set_item("title", title)?; + d.set_item("_url_start", url_range.start)?; + d.set_item("_url_end", url_range.end)?; + } EditNode::Math { range, delimiter, tex } => { d.set_item("type", "math_inline")?; d.set_item("source", &markdown[range.clone()])?; diff --git a/python/mdhtml/__init__.py b/python/mdhtml/__init__.py index 9da68b5..4da46c3 100644 --- a/python/mdhtml/__init__.py +++ b/python/mdhtml/__init__.py @@ -138,12 +138,12 @@ def rewrite(markdown: str, callbacks: dict, *, math: str = "brackets") -> str: edits.append((start, end, replacement)) continue if not isinstance(replacement, dict): raise TypeError(f"{raw['type']} callback must return None, str, or dict") - allowed = {"url"} if raw["type"] == "image" else {"tex"} + allowed = {"url"} if raw["type"] in ("image", "link") else {"tex"} unknown = replacement.keys() - allowed if unknown: raise ValueError(f"unknown {raw['type'].replace('_inline', '')} replacement field: {sorted(unknown)[0]}") if any(not isinstance(value, str) for value in replacement.values()): raise TypeError(f"{raw['type']} replacement fields must be strings") - if raw["type"] == "image" and "url" in replacement: + if raw["type"] in ("image", "link") and "url" in replacement: edits.append((offsets[internal["_url_start"]], offsets[internal["_url_end"]], replacement["url"])) if raw["type"] == "math_inline" and "tex" in replacement: n = len(raw["delimiter"]) diff --git a/python/mdhtml/md.py b/python/mdhtml/md.py index b2f1e5f..ffe2bb4 100644 --- a/python/mdhtml/md.py +++ b/python/mdhtml/md.py @@ -62,11 +62,12 @@ def _is_caption(line): class _GfmExporter: def __init__(self, reftypes, number_headings, math, implicit_figures, templates=None, tmpl=None, - raw=("md",), imgdir=None, imgbase=None): + raw=("md",), imgdir=None, imgbase=None, link=None): self.res = Resolver(reftypes) self.number_headings, self.math, self.implicit_figures = number_headings, math, implicit_figures self.templates, self.tmpl = [astuple(t) if is_dataclass(t) else tuple(t) for t in templates or []], tmpl self.raw, self.imgdir, self.imgbase = raw, imgdir, imgbase + self.link = link self.warnings, self.inline, self.block, self.rebuilt = [], [], [], [] def run(self, src): @@ -86,6 +87,9 @@ def run(self, src): elif n["type"] == "template_token" and self.tmpl: self.inline.append((n["start"], n["end"], self.tmpl(n))) elif n["type"] == "image" and self.imgdir and ";base64," in n["url"] and n["url"].startswith("data:"): self.inline.append((n["_url_start"], n["_url_end"], self._extract_img(n["url"]))) + elif self.link is not None and n["type"] in ("image", "link"): + new = self.link(n["url"]) + if new is not None: self.inline.append((n["_url_start"], n["_url_end"], new)) for x, parsed in self.xrefs: self._xref(x, parsed) for b in spans: self._block(b) keep = [e for e in self.inline if not any(s <= e[0] and e[1] <= t for s, t in self.rebuilt)] @@ -235,13 +239,13 @@ def _table(self, b, s, e): self.block.append((p, p, f"\n{label} {n}\n")) def md2gfm(src, dest=None, reftypes: dict | None = None, number_headings=None, math: str = "brackets", - implicit_figures: bool = False, templates=None, tmpl=None, raw: tuple = ("md",), imgdir=None) -> Md: + implicit_figures: bool = False, templates=None, tmpl=None, raw: tuple = ("md",), imgdir=None, link=None) -> Md: """Lower Markdown to portable GFM-plus-footnotes by rewriting mdhtml-specific constructs in place: cross-references become plain text, headings and captions are numbered, attribute lists and definitions are stripped, and raw data in the formats named by `raw` is spliced (all other formats drop; `('md', 'html')` suits targets that render inline HTML, like GFM). With `imgdir`, each base64 data-URI image is written to a content-hashed file in that - directory and its src rewritten relative to `dest`'s directory (or the cwd). With `templates`, + directory and its src rewritten relative to `dest`'s directory (or the cwd). With `link`, each inline link or image URL is passed to the callback and replaced when it returns a non-`None` string. With `templates`, each template token is rewritten to whatever the `tmpl` callable `(node) -> str` returns: the node dict carries `body`, `syntax`, `form`, scanner classification (`kind`, `name`, `inverted`), and spans (`mustache_code` is a ready-made recipe; @@ -250,7 +254,7 @@ def md2gfm(src, dest=None, reftypes: dict | None = None, number_headings=None, m normalized, offsets = _normalize_offsets(src) imgbase = Path(dest).parent if dest is not None else Path(".") ex = _GfmExporter(reftypes, number_headings, math, implicit_figures, templates, tmpl, - raw=raw, imgdir=None if imgdir is None else Path(imgdir), imgbase=imgbase) + raw=raw, imgdir=None if imgdir is None else Path(imgdir), imgbase=imgbase, link=link) edits = ex.run(normalized) for start, end, repl in reversed(edits): src = src[:offsets[start]] + repl + src[offsets[end]:] res = Md(src, ex.warnings) diff --git a/src/block.rs b/src/block.rs index 9f8c633..be250d3 100644 --- a/src/block.rs +++ b/src/block.rs @@ -786,7 +786,7 @@ fn edit_nodes_for_regions(source: &Source<'_>, regions: &[(usize, usize, RegionK } } out.sort_by_key(|node| match node { - EditNode::Image { range, .. } + EditNode::Image { range, .. } | EditNode::Link { range, .. } | EditNode::Math { range, .. } | EditNode::Xref { range, .. } | EditNode::Attrs { range, .. } diff --git a/src/inline.rs b/src/inline.rs index 176df6d..2840a52 100644 --- a/src/inline.rs +++ b/src/inline.rs @@ -60,6 +60,7 @@ pub(crate) fn inline_events(src: &str, ctx: &InlineContext<'_>) -> Vec, url_range: Range, alt: String, url: String, title: Option }, + Link { range: Range, url_range: Range, url: String, title: Option }, Math { range: Range, delimiter: &'static str, tex: String }, Xref { range: Range, refs: Vec, tokens: Option }, Attrs { id: Option, range: Range }, @@ -73,7 +74,7 @@ pub struct XrefSeg { pub target: String, pub bare: bool, pub prefix: Option { + Self::Image { range, url_range, .. } | Self::Link { range, url_range, .. } => { range.start += offset; range.end += offset; url_range.start += offset; @@ -153,9 +154,8 @@ pub fn find_edit_nodes(src: &str, ctx: &InlineContext<'_>) -> Vec { && let Some((label, label_len)) = scan_link_label(&src[i..]) { let after = i + label_len; - if starts(src, after, "(") - && let Some((_, next)) = paren_content(src, after + 1, ctx.options.max_link_paren_depth) - { + if let Some((node, next)) = inline_link_edit_node(src, i, label_len, ctx) { + out.push(node); i = next + attr_after(src, next, &mut out); continue; } @@ -211,22 +211,29 @@ fn ref_attr(src: &str, at: usize) -> (Option, usize) { match trailing_attr(&src[at..]) { Some((attr, n)) => (attr.pairs.iter().find(|(k, _)| k == "ref").map(|(_, v)| v.clone()), n), None => (None, 0) } } -fn inline_image_edit_node(src: &str, i: usize, ctx: &InlineContext<'_>) -> Option<(EditNode, usize)> { - let (alt, label_len) = scan_link_label(&src[i + 1..])?; - let after = i + 1 + label_len; +fn inline_url(src: &str, after: usize, max_parens: usize) -> Option<(Range, String, Option, usize)> { if after >= src.len() || !starts(src, after, "(") { return None; } - let max_parens = ctx.options.max_link_paren_depth; let (inside, next) = paren_content(src, after + 1, max_parens)?; let trimmed = trim_link_space(inside); let raw_url = if trimmed.is_empty() { &trim_link_space_start(inside)[..0] } else { parse_link_destination(trimmed, max_parens)?.0 }; let (url, title) = parse_link_destination_title(inside, max_parens)?; - let inside_start = after + 1; - let url_start = inside_start + raw_url.as_ptr() as usize - inside.as_ptr() as usize; - let url_range = url_start..url_start + raw_url.len(); + let url_start = after + 1 + raw_url.as_ptr() as usize - inside.as_ptr() as usize; + Some((url_start..url_start + raw_url.len(), url, title, next)) +} + +fn inline_image_edit_node(src: &str, i: usize, ctx: &InlineContext<'_>) -> Option<(EditNode, usize)> { + let (alt, label_len) = scan_link_label(&src[i + 1..])?; + let after = i + 1 + label_len; + let (url_range, url, title, next) = inline_url(src, after, ctx.options.max_link_paren_depth)?; let alt = crate::render::plain(&parse_inlines(&alt, ctx)); Some((EditNode::Image { range: i..next, url_range, alt, url, title }, next)) } +fn inline_link_edit_node(src: &str, i: usize, label_len: usize, ctx: &InlineContext<'_>) -> Option<(EditNode, usize)> { + let (url_range, url, title, next) = inline_url(src, i + label_len, ctx.options.max_link_paren_depth)?; + Some((EditNode::Link { range: i..next, url_range, url, title }, next)) +} + pub fn parse_inlines(src: &str, ctx: &InlineContext<'_>) -> Vec { coalesce(parse_inner(src, ctx)) } fn parse_inner(src: &str, ctx: &InlineContext<'_>) -> Vec { diff --git a/src/wrap.rs b/src/wrap.rs index cc2d559..15af8dc 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -84,7 +84,7 @@ fn words(src: &str, ctx: &InlineContext<'_>) -> Vec { .map(|event| event.start..event.end) .collect(); protected.extend(crate::inline::find_edit_nodes(src, ctx).into_iter().map(|node| match node { - EditNode::Image { range, .. } + EditNode::Image { range, .. } | EditNode::Link { range, .. } | EditNode::Math { range, .. } | EditNode::Xref { range, .. } | EditNode::Attrs { range, .. } diff --git a/tests/test_export.py b/tests/test_export.py index d2c9efd..0fc0953 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -325,6 +325,19 @@ def test_md2gfm_imgdir(tmp_path): assert files[0].read_bytes() == b64.b64decode(png_b64) assert dest.read_text() == out + +def test_md2gfm_relink(): + seen = [] + def link(url): + seen.append(url) + return {'docs/a_(b).md#part': 'docs/a_(b).html#part', 'img/plot_(1).png': 'assets/plot.png'}.get(url) + md = ('See [guide](docs/a_(b).md#part "Read") and [web](https://example.com).\n\n' + '![Plot](img/plot_(1).png "Chart") and ![keep](keep.png).\n') + out = md2gfm(md, link=link) + assert out == ('See [guide](docs/a_(b).html#part "Read") and [web](https://example.com).\n\n' + '![Plot](assets/plot.png "Chart") and ![keep](keep.png).\n') + assert seen == ['docs/a_(b).md#part', 'https://example.com', 'img/plot_(1).png', 'keep.png'] + def test_md2gfm_passthrough(): md = ('Text[^1] with $x$ math and | pipes |.\n\n[^1]: A note.\n\n' '| A | B |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n\n[ref link][r]\n\n[r]: /url\n') diff --git a/tests/test_python.py b/tests/test_python.py index 6151a84..f407ce1 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -731,6 +731,17 @@ def math(node): dict(type="math_inline", source="$x^2$", start=55, end=60, delimiter="$", display=False, tex="x^2")] +def test_rewrite_link_url(): + from mdhtml import rewrite + seen = [] + src = 'Before [guide](docs/a_(b).md#part "Read") after.' + got = rewrite(src, {'link': lambda node: seen.append(node) or {'url': 'guide.html#part'}}) + assert got == 'Before [guide](guide.html#part "Read") after.' + node, = seen + assert (node['type'], node['form'], node['source'], node['url'], node['title']) == ( + 'link', 'inline', '[guide](docs/a_(b).md#part "Read")', 'docs/a_(b).md#part', 'Read') + + def test_rewrite_skips_code_and_fenced_blocks(): from mdhtml import rewrite src = "`$code$ ![x](bad)` [label](https://x/$url$) and $math$\n\n- before\n ```\n $fenced$ ![x](bad)\n ```\n- ![x](data:x)\n"