Markdown: nested definitions resolve, images in styles and links stay images, CR soft breaks, reference links highlight
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fcad0ff8e9
commit
508cc55af6
3 files changed
+254
-86
No files matched your search
@@ -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
@@ -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
@@ -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, ¶graph.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("e.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(¬e.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.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  b* [](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 ");
|
||||
assert!(matches!(&nodes[0], Node::Heading(1, text) if text.text == "Lift "));
|
||||
assert!(matches!(&nodes[1], Node::Image { url, .. } if url == "x.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user