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};
/// The markdown this crate reads: GitHub's flavor, and math between dollars.
/// GitHub's flavor, and math between dollars.
pub(crate) fn options() -> ParseOptions {
ParseOptions {
constructs: Constructs {
@@ -62,7 +62,7 @@ pub(crate) fn edge(span: &str, mark: Mark) -> usize {
}
/// 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);
for child in node.children().into_iter().flatten() {
walk(child, out);
@@ -93,9 +93,10 @@ pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
continue;
}
match node {
mdast::Node::Link(link) => {
mdast::Node::Link(mdast::Link { children, .. })
| mdast::Node::LinkReference(mdast::LinkReference { children, .. }) => {
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;
out.push((range.start..inner.start, 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>> {
let root = tree(text);
root.children()
@@ -258,6 +259,24 @@ mod tests {
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]
fn a_link_gives_its_range_text_and_address() {
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::{
editor::code_colors,
navigation::{Anchor, Sections},
primitives::{Icon, IconName},
primitives::{FocusRing as _, Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::Latex,
};
@@ -146,12 +146,7 @@ impl Draw<'_> {
let on_link = self.on_link.clone();
InteractiveText::new(self.key("links"), styled)
.on_click(ranges, move |ix, window, cx| {
let url = &urls[ix];
log::info!("markdown: open {url}");
match &on_link {
Some(on_link) => on_link(url, window, cx),
None => cx.open_url(url),
}
open_link(&on_link, &urls[ix], window, cx)
})
.into_any_element()
}
@@ -348,23 +343,35 @@ impl Draw<'_> {
.into_any_element(),
})
.into_any_element(),
Node::Image { alt, url } => div()
.flex()
.items_center()
.gap_2()
.p_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::Image)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.child(div().flex_1().child(alt.clone()))
.child(div().text_color(colors.fg_subtle).child(url.clone()))
.into_any_element(),
Node::Image { alt, url, link } => {
let picture = div()
.id(self.key("image"))
.flex()
.items_center()
.gap_2()
.p_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::Image)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.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()
.flex()
.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.
fn anchor(ordinal: usize) -> SharedString {
format!("heading-{ordinal}").into()
+190 -57
View File
@@ -2,7 +2,7 @@ use std::{collections::HashMap, ops::Range};
use markdown::mdast::{self, AlignKind};
use super::markdown::{span, tree};
use super::markdown::{span, tree, walk};
/// How a stretch of inline text prints.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -46,9 +46,11 @@ pub enum Node {
},
Rule,
Math(String),
/// A picture, and the link around it.
Image {
alt: String,
url: String,
link: Option<String>,
},
Footnote {
label: String,
@@ -62,13 +64,44 @@ struct Reader<'a> {
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.
pub fn parse(markdown: &str) -> Vec<Node> {
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();
for block in blocks {
if let mdast::Node::Definition(definition) = block {
for node in nodes {
if let mdast::Node::Definition(definition) = node {
urls.entry(definition.identifier.clone())
.or_insert_with(|| definition.url.clone());
}
@@ -78,7 +111,10 @@ pub fn parse(markdown: &str) -> Vec<Node> {
urls,
};
let mut out = Vec::new();
reader.blocks(blocks, &mut out);
reader.blocks(
root.children().expect("the root holds the blocks"),
&mut out,
);
out
}
@@ -95,7 +131,7 @@ impl Reader<'_> {
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 {
self.urls
.get(identifier)
@@ -107,7 +143,12 @@ impl Reader<'_> {
match node {
mdast::Node::Paragraph(paragraph) => self.paragraph(node, &paragraph.children, out),
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::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>) {
if let [mdast::Node::InlineMath(math)] = children
&& self.source[span(node)].starts_with("$$")
{
return out.push(Node::Math(math.value.clone()));
}
let mut run = Inline::default();
let mut run = Run::default();
for child in children {
let image = match child {
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),
}
self.inline_into(child, &mut run, &mut Images::Split(out));
}
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 {
let mut text = Inline::default();
let mut run = Run::default();
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 styled_children(&self, nodes: &[mdast::Node], text: &mut Inline) -> Range<usize> {
let start = text.text.len();
for node in nodes {
self.inline_into(node, text);
}
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', " ")),
fn inline_into(&self, node: &mdast::Node, run: &mut Run, images: &mut Images) {
let text = &mut run.text;
let (children, style, url) = match node {
mdast::Node::Text(words) => {
let soft = words.value.replace("\r\n", " ").replace(['\r', '\n'], " ");
return text.text.push_str(&soft);
}
mdast::Node::Break(_) => return text.text.push('\n'),
mdast::Node::InlineCode(code) => return styled(text, &code.value, Style::Code),
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);
return styled(text, &format!("[{label}]"), Style::Footnote);
}
mdast::Node::Image(image) => return text.text.push_str(&image.alt),
mdast::Node::ImageReference(image) => return text.text.push_str(&image.alt),
mdast::Node::Image(_) | mdast::Node::ImageReference(_) => {
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::Strong(strong) => (&strong.children, Style::Strong),
mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis),
mdast::Node::Delete(delete) => (&delete.children, Style::Strike),
mdast::Node::Link(link) => (&link.children, Style::Link),
mdast::Node::LinkReference(link) => (&link.children, Style::Link),
mdast::Node::Strong(strong) => (&strong.children, Style::Strong, None),
mdast::Node::Emphasis(emphasis) => (&emphasis.children, Style::Emphasis, None),
mdast::Node::Delete(delete) => (&delete.children, Style::Strike, None),
mdast::Node::Link(link) => (&link.children, Style::Link, Some(link.url.clone())),
mdast::Node::LinkReference(link) => (
&link.children,
Style::Link,
Some(self.url(&link.identifier)),
),
other => unreachable!("{other:?} is no inline outside MDX"),
};
let range = self.styled_children(children, text);
text.styles.push((range.clone(), style));
match node {
mdast::Node::Link(link) => text.links.push((range, link.url.clone())),
mdast::Node::LinkReference(link) => {
text.links.push((range, self.url(&link.identifier)))
run.open.push((run.text.text.len(), style, url));
for child in children {
self.inline_into(child, run, images);
}
let (start, style, url) = run.open.pop().expect("the container opened above");
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.
fn text_block(text: Inline, out: &mut Vec<Node>) {
if !text.text.trim().is_empty() {
@@ -287,7 +344,8 @@ mod tests {
parse("![outer ![inner](inner.png)](outer.png)"),
[Node::Image {
alt: "outer ".into(),
url: "outer.png".into()
url: "outer.png".into(),
link: None
}]
);
}
@@ -301,7 +359,8 @@ mod tests {
nodes[1],
Node::Image {
alt: "a".into(),
url: "a.png".into()
url: "a.png".into(),
link: None
}
);
let Node::Paragraph(after) = &nodes[2] else {
@@ -314,8 +373,82 @@ mod tests {
#[test]
fn a_soft_break_reads_as_a_space_and_a_hard_one_as_a_line() {
let nodes = parse("one\ntwo \nthree");
assert!(matches!(&nodes[0], Node::Paragraph(text) if text.text == "one two\nthree"));
for (source, shown) in [
("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]