Markdown: nested definitions resolve, images in styles and links stay images, CR soft breaks, reference links highlight
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 26m21s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-11 23:19:28 -04:00
1 parent fcad0ff8e9
commit 508cc55af6
3 files changed
+254 -86

No files matched your search

+24 -5
View File
@@ -6,7 +6,7 @@ use unicode_segmentation::UnicodeSegmentation;
use crate::{forms::Highlight, theme::ActiveTheme}; use crate::{forms::Highlight, theme::ActiveTheme};
/// The markdown this crate reads: GitHub's flavor, and math between dollars. /// GitHub's flavor, and math between dollars.
pub(crate) fn options() -> ParseOptions { pub(crate) fn options() -> ParseOptions {
ParseOptions { ParseOptions {
constructs: Constructs { constructs: Constructs {
@@ -62,7 +62,7 @@ pub(crate) fn edge(span: &str, mark: Mark) -> usize {
} }
/// Every node under `node`, itself first, in source order. /// Every node under `node`, itself first, in source order.
fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) { pub(crate) fn walk<'a>(node: &'a mdast::Node, out: &mut Vec<&'a mdast::Node>) {
out.push(node); out.push(node);
for child in node.children().into_iter().flatten() { for child in node.children().into_iter().flatten() {
walk(child, out); walk(child, out);
@@ -93,9 +93,10 @@ pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
continue; continue;
} }
match node { match node {
mdast::Node::Link(link) => { mdast::Node::Link(mdast::Link { children, .. })
| mdast::Node::LinkReference(mdast::LinkReference { children, .. }) => {
out.push((range.clone(), Mark::Link)); out.push((range.clone(), Mark::Link));
if let (Some(first), Some(last)) = (link.children.first(), link.children.last()) { if let (Some(first), Some(last)) = (children.first(), children.last()) {
let inner = span(first).start..span(last).end; let inner = span(first).start..span(last).end;
out.push((range.start..inner.start, Mark::Syntax)); out.push((range.start..inner.start, Mark::Syntax));
out.push((inner.end..range.end, Mark::Syntax)); out.push((inner.end..range.end, Mark::Syntax));
@@ -185,7 +186,7 @@ pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)>
}) })
} }
/// The document's top-level blocks, as ranges of its source; link definitions are none. /// Top-level blocks as source ranges; definitions excluded.
pub fn block_ranges(text: &str) -> Vec<Range<usize>> { pub fn block_ranges(text: &str) -> Vec<Range<usize>> {
let root = tree(text); let root = tree(text);
root.children() root.children()
@@ -258,6 +259,24 @@ mod tests {
assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]); assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]);
} }
#[test]
fn reference_links_mark_their_syntax() {
use Mark::*;
let defined = "\n\n[site]: https://ely.dev\n";
for (link, open, close) in [
("[the site][site]", "[", "][site]"),
("[site][]", "[", "][]"),
("[site]", "[", "]"),
] {
let text = format!("{link}{defined}");
assert_eq!(
found(&text),
[(link, Link), (open, Syntax), (close, Syntax)],
"{link}"
);
}
}
#[test] #[test]
fn a_link_gives_its_range_text_and_address() { fn a_link_gives_its_range_text_and_address() {
let text = "see [the docs](https://ely.dev) or <https://x.dev>"; let text = "see [the docs](https://ely.dev) or <https://x.dev>";
+40 -24
View File
@@ -11,7 +11,7 @@ use super::tree::{Inline, Node, Style, parse};
use crate::{ use crate::{
editor::code_colors, editor::code_colors,
navigation::{Anchor, Sections}, navigation::{Anchor, Sections},
primitives::{Icon, IconName}, primitives::{FocusRing as _, Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize}, theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::Latex, typography::Latex,
}; };
@@ -146,12 +146,7 @@ impl Draw<'_> {
let on_link = self.on_link.clone(); let on_link = self.on_link.clone();
InteractiveText::new(self.key("links"), styled) InteractiveText::new(self.key("links"), styled)
.on_click(ranges, move |ix, window, cx| { .on_click(ranges, move |ix, window, cx| {
let url = &urls[ix]; open_link(&on_link, &urls[ix], window, cx)
log::info!("markdown: open {url}");
match &on_link {
Some(on_link) => on_link(url, window, cx),
None => cx.open_url(url),
}
}) })
.into_any_element() .into_any_element()
} }
@@ -348,23 +343,35 @@ impl Draw<'_> {
.into_any_element(), .into_any_element(),
}) })
.into_any_element(), .into_any_element(),
Node::Image { alt, url } => div() Node::Image { alt, url, link } => {
.flex() let picture = div()
.items_center() .id(self.key("image"))
.gap_2() .flex()
.p_3() .items_center()
.rounded(theme.radius(Radius::Md)) .gap_2()
.border_1() .p_3()
.border_color(colors.border) .rounded(theme.radius(Radius::Md))
.text_color(colors.fg_muted) .border_1()
.child( .border_color(colors.border)
Icon::new(IconName::Image) .text_color(colors.fg_muted)
.size(IconSize::Sm) .child(
.color(colors.fg_subtle), Icon::new(IconName::Image)
) .size(IconSize::Sm)
.child(div().flex_1().child(alt.clone())) .color(colors.fg_subtle),
.child(div().text_color(colors.fg_subtle).child(url.clone())) )
.into_any_element(), .child(div().flex_1().child(alt.clone()))
.child(div().text_color(colors.fg_subtle).child(url.clone()));
let Some(link) = link.clone() else {
return picture.into_any_element();
};
let on_link = self.on_link.clone();
picture
.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_click(move |_, window, cx| open_link(&on_link, &link, window, cx))
.into_any_element()
}
Node::Footnote { label, body } => div() Node::Footnote { label, body } => div()
.flex() .flex()
.gap_2() .gap_2()
@@ -383,6 +390,15 @@ impl Draw<'_> {
} }
} }
/// Opens `url` through the owner, else the system.
fn open_link(on_link: &Option<OnLink>, url: &str, window: &mut Window, cx: &mut App) {
log::info!("markdown: open {url}");
match on_link {
Some(on_link) => on_link(url, window, cx),
None => cx.open_url(url),
}
}
/// The anchor of the document's `ordinal`th heading. /// The anchor of the document's `ordinal`th heading.
fn anchor(ordinal: usize) -> SharedString { fn anchor(ordinal: usize) -> SharedString {
format!("heading-{ordinal}").into() format!("heading-{ordinal}").into()
+190 -57
View File
@@ -2,7 +2,7 @@ use std::{collections::HashMap, ops::Range};
use markdown::mdast::{self, AlignKind}; use markdown::mdast::{self, AlignKind};
use super::markdown::{span, tree}; use super::markdown::{span, tree, walk};
/// How a stretch of inline text prints. /// How a stretch of inline text prints.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -46,9 +46,11 @@ pub enum Node {
}, },
Rule, Rule,
Math(String), Math(String),
/// A picture, and the link around it.
Image { Image {
alt: String, alt: String,
url: String, url: String,
link: Option<String>,
}, },
Footnote { Footnote {
label: String, label: String,
@@ -62,13 +64,44 @@ struct Reader<'a> {
urls: HashMap<String, String>, urls: HashMap<String, String>,
} }
/// Inline text being built, and the containers open around it.
#[derive(Default)]
struct Run {
text: Inline,
open: Vec<(usize, Style, Option<String>)>,
}
impl Run {
/// Text so far; open styles close here, reopen at zero.
fn cut(&mut self) -> Inline {
let mut done = std::mem::take(&mut self.text);
let end = done.text.len();
for (start, style, url) in self.open.iter_mut().rev() {
if *start < end {
done.styles.push((*start..end, *style));
if let Some(url) = url {
done.links.push((*start..end, url.clone()));
}
}
*start = 0;
}
done
}
/// The innermost open link's address.
fn link(&self) -> Option<String> {
self.open.iter().rev().find_map(|(_, _, url)| url.clone())
}
}
/// `markdown` as blocks. /// `markdown` as blocks.
pub fn parse(markdown: &str) -> Vec<Node> { pub fn parse(markdown: &str) -> Vec<Node> {
let root = tree(markdown); let root = tree(markdown);
let blocks = root.children().expect("the root holds the blocks"); let mut nodes = Vec::new();
walk(&root, &mut nodes);
let mut urls = HashMap::new(); let mut urls = HashMap::new();
for block in blocks { for node in nodes {
if let mdast::Node::Definition(definition) = block { if let mdast::Node::Definition(definition) = node {
urls.entry(definition.identifier.clone()) urls.entry(definition.identifier.clone())
.or_insert_with(|| definition.url.clone()); .or_insert_with(|| definition.url.clone());
} }
@@ -78,7 +111,10 @@ pub fn parse(markdown: &str) -> Vec<Node> {
urls, urls,
}; };
let mut out = Vec::new(); let mut out = Vec::new();
reader.blocks(blocks, &mut out); reader.blocks(
root.children().expect("the root holds the blocks"),
&mut out,
);
out out
} }
@@ -95,7 +131,7 @@ impl Reader<'_> {
out out
} }
/// The address a reference names: the parser makes references to definitions alone. /// A reference's address; the parser checked its definition.
fn url(&self, identifier: &str) -> String { fn url(&self, identifier: &str) -> String {
self.urls self.urls
.get(identifier) .get(identifier)
@@ -107,7 +143,12 @@ impl Reader<'_> {
match node { match node {
mdast::Node::Paragraph(paragraph) => self.paragraph(node, &paragraph.children, out), mdast::Node::Paragraph(paragraph) => self.paragraph(node, &paragraph.children, out),
mdast::Node::Heading(heading) => { mdast::Node::Heading(heading) => {
out.push(Node::Heading(heading.depth, self.inline(&heading.children))) let (mut run, mut after) = (Run::default(), Vec::new());
for child in &heading.children {
self.inline_into(child, &mut run, &mut Images::After(&mut after));
}
out.push(Node::Heading(heading.depth, run.cut()));
out.extend(after);
} }
mdast::Node::Blockquote(quote) => out.push(Node::Quote(self.nested(&quote.children))), mdast::Node::Blockquote(quote) => out.push(Node::Quote(self.nested(&quote.children))),
mdast::Node::Code(code) => out.push(Node::Code { mdast::Node::Code(code) => out.push(Node::Code {
@@ -156,53 +197,36 @@ impl Reader<'_> {
} }
} }
/// A paragraph's text split around its images; `$$` math written alone stands as a block. /// Text split at its images; lone `$$` math is block.
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) { fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
if let [mdast::Node::InlineMath(math)] = children if let [mdast::Node::InlineMath(math)] = children
&& self.source[span(node)].starts_with("$$") && self.source[span(node)].starts_with("$$")
{ {
return out.push(Node::Math(math.value.clone())); return out.push(Node::Math(math.value.clone()));
} }
let mut run = Inline::default(); let mut run = Run::default();
for child in children { for child in children {
let image = match child { self.inline_into(child, &mut run, &mut Images::Split(out));
mdast::Node::Image(image) => Some((image.alt.clone(), image.url.clone())),
mdast::Node::ImageReference(image) => {
Some((image.alt.clone(), self.url(&image.identifier)))
}
_ => None,
};
match image {
Some((alt, url)) => {
text_block(std::mem::take(&mut run), out);
out.push(Node::Image { alt, url });
}
None => self.inline_into(child, &mut run),
}
} }
text_block(run, out); text_block(run.cut(), out);
} }
/// Inline text where images draw as their alt text.
fn inline(&self, nodes: &[mdast::Node]) -> Inline { fn inline(&self, nodes: &[mdast::Node]) -> Inline {
let mut text = Inline::default(); let mut run = Run::default();
for node in nodes { for node in nodes {
self.inline_into(node, &mut text); self.inline_into(node, &mut run, &mut Images::Alt);
} }
text run.cut()
} }
/// `nodes` into `text`; their stretch of it, styled. fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) {
fn styled_children(&self, nodes: &[mdast::Node], text: &mut Inline) -> Range<usize> { let text = &mut run.text;
let start = text.text.len(); let (children, style, url) = match node {
for node in nodes { mdast::Node::Text(words) => {
self.inline_into(node, text); let soft = words.value.replace("\r\n", " ").replace(['\r', '\n'], " ");
} return text.text.push_str(&soft);
start..text.text.len() }
}
fn inline_into(&self, node: &mdast::Node, text: &mut Inline) {
let (children, style) = match node {
mdast::Node::Text(words) => return text.text.push_str(&words.value.replace('\n', " ")),
mdast::Node::Break(_) => return text.text.push('\n'), mdast::Node::Break(_) => return text.text.push('\n'),
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code), mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math), mdast::Node::InlineMath(math) => return styled(text, &math.value, Style::Math),
@@ -210,28 +234,61 @@ impl Reader<'_> {
let label = note.label.as_ref().unwrap_or(&note.identifier); let label = note.label.as_ref().unwrap_or(&note.identifier);
return styled(text, &format!("[{label}]"), Style::Footnote); return styled(text, &format!("[{label}]"), Style::Footnote);
} }
mdast::Node::Image(image) => return text.text.push_str(&image.alt), mdast::Node::Image(_) | mdast::Node::ImageReference(_) => {
mdast::Node::ImageReference(image) => return text.text.push_str(&image.alt), let (alt, url) = match node {
mdast::Node::Image(image) => (image.alt.clone(), image.url.clone()),
mdast::Node::ImageReference(image) => {
(image.alt.clone(), self.url(&image.identifier))
}
_ => unreachable!("matched as an image"),
};
let link = run.link();
return match images {
Images::Alt => run.text.text.push_str(&alt),
Images::Split(out) => {
text_block(run.cut(), out);
out.push(Node::Image { alt, url, link })
}
Images::After(out) => out.push(Node::Image { alt, url, link }),
};
}
mdast::Node::Html(_) => return, mdast::Node::Html(_) => return,
mdast::Node::Strong(strong) => (&strong.children, Style::Strong), mdast::Node::Strong(strong) => (&strong.children, Style::Strong, None),
mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis), mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis, None),
mdast::Node::Delete(delete) => (&delete.children, Style::Strike), mdast::Node::Delete(delete) => (&delete.children, Style::Strike, None),
mdast::Node::Link(link) => (&link.children, Style::Link), mdast::Node::Link(link) => (&link.children, Style::Link, Some(link.url.clone())),
mdast::Node::LinkReference(link) => (&link.children, Style::Link), mdast::Node::LinkReference(link) => (
&link.children,
Style::Link,
Some(self.url(&link.identifier)),
),
other => unreachable!("{other:?} is no inline outside MDX"), other => unreachable!("{other:?} is no inline outside MDX"),
}; };
let range = self.styled_children(children, text); run.open.push((run.text.text.len(), style, url));
text.styles.push((range.clone(), style)); for child in children {
match node { self.inline_into(child, run, images);
mdast::Node::Link(link) => text.links.push((range, link.url.clone())), }
mdast::Node::LinkReference(link) => { let (start, style, url) = run.open.pop().expect("the container opened above");
text.links.push((range, self.url(&link.identifier))) let end = run.text.text.len();
if start < end {
run.text.styles.push((start..end, style));
if let Some(url) = url {
run.text.links.push((start..end, url));
} }
_ => {}
} }
} }
} }
/// Where an inline image goes.
enum Images<'a> {
/// Its alt text stays in the line.
Alt,
/// It splits the text into blocks.
Split(&'a mut Vec<Node>),
/// It follows the text, as a heading's.
After(&'a mut Vec<Node>),
}
/// Text left between images, unless it is blank. /// Text left between images, unless it is blank.
fn text_block(text: Inline, out: &mut Vec<Node>) { fn text_block(text: Inline, out: &mut Vec<Node>) {
if !text.text.trim().is_empty() { if !text.text.trim().is_empty() {
@@ -287,7 +344,8 @@ mod tests {
parse("![outer ![inner](inner.png)](outer.png)"), parse("![outer ![inner](inner.png)](outer.png)"),
[Node::Image { [Node::Image {
alt: "outer ".into(), alt: "outer ".into(),
url: "outer.png".into() url: "outer.png".into(),
link: None
}] }]
); );
} }
@@ -301,7 +359,8 @@ mod tests {
nodes[1], nodes[1],
Node::Image { Node::Image {
alt: "a".into(), alt: "a".into(),
url: "a.png".into() url: "a.png".into(),
link: None
} }
); );
let Node::Paragraph(after) = &nodes[2] else { let Node::Paragraph(after) = &nodes[2] else {
@@ -314,8 +373,82 @@ mod tests {
#[test] #[test]
fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() { fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() {
let nodes = parse("one\ntwo \nthree"); for (source, shown) in [
assert!(matches!(&nodes[0], Node::Paragraph(text) if text.text == "one two\nthree")); ("one\ntwo \nthree", "one two\nthree"),
("one\r\ntwo \r\nthree", "one two\nthree"),
("one\rtwo \rthree", "one two\nthree"),
] {
let nodes = parse(source);
assert!(
matches!(&nodes[0], Node::Paragraph(text) if text.text == shown),
"{source:?} reads {nodes:?}"
);
}
}
#[test]
fn definitions_nested_in_quotes_and_lists_resolve() {
for source in [
"[site] ![pic]\n\n> [site]: https://ely.dev\n> [pic]: a.png\n",
"[site] ![pic]\n\n- [site]: https://ely.dev\n- [pic]: a.png\n",
] {
let nodes = parse(source);
let Node::Paragraph(text) = &nodes[0] else {
panic!("a paragraph, not {:?}", nodes[0])
};
assert_eq!(text.links, [(0..4, "https://ely.dev".to_string())]);
assert_eq!(
nodes[1],
Node::Image {
alt: "pic".into(),
url: "a.png".into(),
link: None
}
);
}
}
#[test]
fn an_image_inside_styles_and_links_stays_an_image() {
let nodes = parse("*a ![x](x.png) b* [![y](y.png)](https://ely.dev)");
let Node::Paragraph(before) = &nodes[0] else {
panic!("a paragraph, not {:?}", nodes[0])
};
assert_eq!(
(before.text.as_str(), &before.styles[..]),
("a ", &[(0..2, Style::Emphasis)][..])
);
assert_eq!(
nodes[1],
Node::Image {
alt: "x".into(),
url: "x.png".into(),
link: None
}
);
let Node::Paragraph(after) = &nodes[2] else {
panic!("a paragraph, not {:?}", nodes[2])
};
assert_eq!(
(after.text.as_str(), &after.styles[..]),
(" b ", &[(0..2, Style::Emphasis)][..])
);
assert_eq!(
nodes[3],
Node::Image {
alt: "y".into(),
url: "y.png".into(),
link: Some("https://ely.dev".into())
}
);
assert_eq!(nodes.len(), 4, "{nodes:?}");
}
#[test]
fn a_heading_keeps_its_images_after_it() {
let nodes = parse("# Lift ![x](x.png)");
assert!(matches!(&nodes[0], Node::Heading(1, text) if text.text == "Lift "));
assert!(matches!(&nodes[1], Node::Image { url, .. } if url == "x.png"));
} }
#[test] #[test]