diff --git a/crates/altmd-ast/src/lib.rs b/crates/altmd-ast/src/lib.rs index 32d8a38..694dedd 100644 --- a/crates/altmd-ast/src/lib.rs +++ b/crates/altmd-ast/src/lib.rs @@ -27,6 +27,15 @@ pub struct Span { pub struct Document { /// Top-level block nodes in document order. pub blocks: Vec, + /// 1-based source start line for each top-level block, indexed alongside + /// [`blocks`](Self::blocks). An entry is `None` when the line is unknown + /// (for example a relocated footnote definition); a shorter or empty vector + /// means no line information at all. Purely additive: the serializer and the + /// policy reader never consult it, and the renderer only emits it when + /// source positions are requested. It exists so an editor can map a rendered + /// block back to its source line for scroll-sync and click-to-source. + #[serde(default)] + pub block_lines: Vec>, } /// Block-level content. Component nodes arrive in Phase 2. diff --git a/crates/altmd-cli/src/include.rs b/crates/altmd-cli/src/include.rs index 1654124..704a92e 100644 --- a/crates/altmd-cli/src/include.rs +++ b/crates/altmd-cli/src/include.rs @@ -43,7 +43,12 @@ pub fn resolve_document(document: Document, file: &Path) -> Result { // is caught as a cycle. let mut stack = vec![canon_file]; let blocks = resolve(document.blocks, &jail, &jail, &mut stack, 0)?; - Ok(Document { blocks }) + // Splicing includes changes the top-level block list, so the source line map + // no longer applies; drop it rather than carry stale lines. + Ok(Document { + blocks, + ..Default::default() + }) } /// Resolve includes in a block sequence. `current_dir` is the directory relative diff --git a/crates/altmd-core/src/lib.rs b/crates/altmd-core/src/lib.rs index 2df317c..a481407 100644 --- a/crates/altmd-core/src/lib.rs +++ b/crates/altmd-core/src/lib.rs @@ -12,7 +12,7 @@ pub mod render; pub use error::CoreError; pub use policy::{AiPolicy, Permission, extract_policy}; -pub use render::render_document; +pub use render::{RenderOptions, render_document, render_document_with}; // One facade: downstream crates depend on these re-exports, not the sub-crates. pub use altmd_ast::{ @@ -72,6 +72,22 @@ pub fn render(source: &str) -> Result { Ok(render_document(&parse(source)?)) } +/// Render alt-markdown `source` to HTML with source positions: each top-level +/// block carries a `data-line="N"` attribute naming the source line it began on. +/// An editor uses this to map a rendered block back to its source for scroll-sync +/// and click-to-source. Otherwise identical to [`render`]. +/// +/// # Errors +/// Returns [`CoreError`] if the source contains an invalid directive. +pub fn render_with_positions(source: &str) -> Result { + Ok(render_document_with( + &parse(source)?, + RenderOptions { + source_positions: true, + }, + )) +} + /// Serialise a [`Document`] back to alt-markdown source text. Normalising: the /// output re-parses to an equal AST but is not guaranteed byte-identical to the /// original source. This is the second half of the edit round-trip. diff --git a/crates/altmd-core/src/render.rs b/crates/altmd-core/src/render.rs index 6508ff2..4fac817 100644 --- a/crates/altmd-core/src/render.rs +++ b/crates/altmd-core/src/render.rs @@ -49,6 +49,29 @@ struct RenderState { bib: HashMap, cited: Vec, cite_numbers: HashMap, + /// Footnote label to its displayed number, assigned by first-reference order. + /// Both the inline marker and the definition list read from this, so the two + /// always agree. + footnote_numbers: HashMap, + /// When true, stamp each top-level block with its source line (see + /// [`RenderOptions::source_positions`]). + source_positions: bool, + /// Source line per top-level block, indexed in document order; mirrors the + /// document's `block_lines`. Read by the top-level walk to set `pending_line`. + block_lines: Vec>, + /// The line to stamp on the next opening tag, set before each top-level block + /// and consumed by the first tag it emits so nested tags are not stamped. + pending_line: Option, +} + +/// Options controlling how a [`Document`] is rendered. +#[derive(Debug, Clone, Copy, Default)] +pub struct RenderOptions { + /// Emit a `data-line="N"` attribute on each top-level block, where `N` is the + /// 1-based source line it began on. This lets an editor map a rendered block + /// back to its source for scroll-sync and click-to-source. Off by default, so + /// the standard render output is unchanged. + pub source_positions: bool, } /// The result of the single pre-pass over the document: the heading list (for @@ -65,11 +88,21 @@ struct Prepass { cite_seen: HashSet, /// Bibliography entries (`key` to reference text) gathered from `bib` fences. bib: HashMap, + /// Footnote labels in first-reference order, and the set used to dedupe them. + /// A footnote's displayed marker is its position here, not its label. + footnote_order: Vec, + footnote_seen: HashSet, } -/// Render a [`Document`] to an HTML string. +/// Render a [`Document`] to an HTML string with the default options. #[must_use] pub fn render_document(document: &Document) -> String { + render_document_with(document, RenderOptions::default()) +} + +/// Render a [`Document`] to an HTML string with explicit [`RenderOptions`]. +#[must_use] +pub fn render_document_with(document: &Document, options: RenderOptions) -> String { let mut pre = Prepass::default(); collect_labels(&document.blocks, &mut pre); // Number the citations that have a matching bibliography entry, in the order @@ -83,6 +116,12 @@ pub fn render_document(document: &Document) -> String { cited.push(key.clone()); } } + // Number footnotes by first-reference order. comrak only produces a reference + // node when a matching definition exists, so every referenced label is real. + let mut footnote_numbers = HashMap::new(); + for name in &pre.footnote_order { + footnote_numbers.insert(name.clone(), footnote_numbers.len() as u32 + 1); + } let mut state = RenderState { headings: pre.headings, cursor: 0, @@ -91,9 +130,13 @@ pub fn render_document(document: &Document) -> String { bib: pre.bib, cited, cite_numbers, + footnote_numbers, + source_positions: options.source_positions, + block_lines: document.block_lines.clone(), + pending_line: None, }; let mut out = String::new(); - render_blocks(&document.blocks, &mut state, &mut out); + render_top_blocks(&document.blocks, &mut state, &mut out); out } @@ -121,16 +164,16 @@ fn collect_labels(blocks: &[Block], pre: &mut Prepass) { slug, text, }); - scan_citations(content, pre); + scan_inline_refs(content, pre); } - Block::Paragraph(content) => scan_citations(content, pre), + Block::Paragraph(content) => scan_inline_refs(content, pre), Block::Table(table) => { for cell in &table.header { - scan_citations(cell, pre); + scan_inline_refs(cell, pre); } for row in &table.rows { for cell in row { - scan_citations(cell, pre); + scan_inline_refs(cell, pre); } } } @@ -183,19 +226,26 @@ fn figure_kind(attrs: &Attrs) -> (&'static str, &'static str) { } } -/// Record any `[@key]` citations in an inline sequence in first-appearance order, -/// recursing into the formatted spans that can hold them. -fn scan_citations(inlines: &[Inline], pre: &mut Prepass) { +/// Record the numbered references in an inline sequence in first-appearance +/// order: `[@key]` citations and `[^label]` footnotes. Both are displayed by the +/// order they first appear, not by their key or label, so the order is captured +/// here in the pre-pass and read back at render time. Recurses into the formatted +/// spans that can hold them. +fn scan_inline_refs(inlines: &[Inline], pre: &mut Prepass) { for inline in inlines { match inline { Inline::Citation { key } if !pre.cite_seen.contains(key) => { pre.cite_seen.insert(key.clone()); pre.cite_order.push(key.clone()); } + Inline::FootnoteReference { name } if !pre.footnote_seen.contains(name) => { + pre.footnote_seen.insert(name.clone()); + pre.footnote_order.push(name.clone()); + } Inline::Emphasis(content) | Inline::Strong(content) - | Inline::Strikethrough(content) => scan_citations(content, pre), - Inline::Link { content, .. } => scan_citations(content, pre), + | Inline::Strikethrough(content) => scan_inline_refs(content, pre), + Inline::Link { content, .. } => scan_inline_refs(content, pre), _ => {} } } @@ -257,6 +307,33 @@ fn unique_slug(base: &str, seen: &mut HashMap) -> String { slug } +impl RenderState { + /// The ` data-line="N"` attribute to splice into the opening tag of the + /// current top-level block, if source positions are enabled and a line is + /// pending. Consumes the pending line so only the block's outermost tag is + /// stamped, never its nested tags or later siblings. + fn take_line_attr(&mut self) -> String { + match self.pending_line.take() { + Some(line) => format!(" data-line=\"{line}\""), + None => String::new(), + } + } +} + +/// Render the top-level blocks, setting the pending source line before each so +/// its outermost tag carries a `data-line`. Nested blocks render through +/// [`render_blocks`] with no pending line, so only top-level blocks are stamped. +fn render_top_blocks(blocks: &[Block], state: &mut RenderState, out: &mut String) { + for (index, block) in blocks.iter().enumerate() { + state.pending_line = if state.source_positions { + state.block_lines.get(index).copied().flatten() + } else { + None + }; + render_block(block, state, out); + } +} + fn render_blocks(blocks: &[Block], state: &mut RenderState, out: &mut String) { for block in blocks { render_block(block, state, out); @@ -282,29 +359,35 @@ fn render_block(block: &Block, state: &mut RenderState, out: &mut String) { let level = (*level).clamp(1, 6); let slug = state.headings.get(state.cursor).map(|h| h.slug.clone()); state.cursor += 1; + let line = state.take_line_attr(); match slug { - Some(slug) => out.push_str(&format!("", escape_attr(&slug))), - None => out.push_str(&format!("")), + Some(slug) => { + out.push_str(&format!("", escape_attr(&slug))); + } + None => out.push_str(&format!("")), } render_inlines(content, state, out); out.push_str(&format!("\n")); } Block::Paragraph(content) => { - out.push_str("

"); + let line = state.take_line_attr(); + out.push_str(&format!("")); render_inlines(content, state, out); out.push_str("

\n"); } Block::BlockQuote(blocks) => { - out.push_str("
\n"); + let line = state.take_line_attr(); + out.push_str(&format!("\n")); render_blocks(blocks, state, out); out.push_str("
\n"); } Block::List(list) => { let tag = if list.ordered { "ol" } else { "ul" }; + let line = state.take_line_attr(); if list.ordered && list.start != 1 { - out.push_str(&format!("
    \n", list.start)); + out.push_str(&format!("\n", list.start)); } else { - out.push_str(&format!("<{tag}>\n")); + out.push_str(&format!("<{tag}{line}>\n")); } for item in &list.items { match item.task { @@ -325,34 +408,60 @@ fn render_block(block: &Block, state: &mut RenderState, out: &mut String) { } Block::CodeBlock { info, literal } => { let lang = info.split_whitespace().next().unwrap_or_default(); + let line = state.take_line_attr(); if lang.is_empty() { - out.push_str("
    ");
    +                out.push_str(&format!(""));
                 } else {
                     out.push_str(&format!(
    -                    "
    ",
    +                    "",
                         escape_attr(lang)
                     ));
                 }
                 out.push_str(&escape_html(literal));
                 out.push_str("
    \n"); } - Block::ThematicBreak => out.push_str("
    \n"), - Block::Table(table) => render_table(table, state, out), + Block::ThematicBreak => { + let line = state.take_line_attr(); + out.push_str(&format!("\n")); + } + Block::Table(table) => { + let line = state.take_line_attr(); + render_table(table, &line, state, out); + } Block::FootnoteDefinition { name, blocks } => { + let line = state.take_line_attr(); + let escaped = escape_attr(name); + // The definition leads with its own number (matching the marker that + // referenced it) and a back-link to where it was cited. + let number = state.footnote_numbers.get(name).copied().unwrap_or(0); out.push_str(&format!( - "
    \n", - escape_attr(name) + "\n\ + {number}\n" )); render_blocks(blocks, state, out); out.push_str("
    \n"); } - Block::HtmlBlock(html) => out.push_str(&altmd_sanitize::sanitize(html)), - Block::Component(component) => render_component(component, state, out), + Block::HtmlBlock(html) => { + // A raw HTML block has no single wrapper tag to stamp; consume the + // pending line so it does not leak onto a later element. + let _ = state.take_line_attr(); + out.push_str(&altmd_sanitize::sanitize(html)); + } + Block::Component(component) => { + let line = state.take_line_attr(); + render_component(component, &line, state, out); + } _ => {} } } -fn render_table(table: &altmd_ast::Table, state: &mut RenderState, out: &mut String) { +fn render_table( + table: &altmd_ast::Table, + line_attr: &str, + state: &mut RenderState, + out: &mut String, +) { use altmd_ast::Alignment; let style = |col: usize| match table.alignments.get(col) { Some(Alignment::Left) => " style=\"text-align:left\"", @@ -360,7 +469,7 @@ fn render_table(table: &altmd_ast::Table, state: &mut RenderState, out: &mut Str Some(Alignment::Right) => " style=\"text-align:right\"", _ => "", }; - out.push_str("\n\n\n"); + out.push_str(&format!("\n\n\n")); for (col, cell) in table.header.iter().enumerate() { out.push_str(&format!("", style(col))); render_inlines(cell, state, out); @@ -383,7 +492,12 @@ fn render_table(table: &altmd_ast::Table, state: &mut RenderState, out: &mut Str out.push_str("
    \n"); } -fn render_component(component: &Component, state: &mut RenderState, out: &mut String) { +fn render_component( + component: &Component, + line_attr: &str, + state: &mut RenderState, + out: &mut String, +) { // Component names come from the registry, so they are already safe element // identifiers; guard anyway against an unexpected name. if !is_safe_name(&component.name) { @@ -392,11 +506,12 @@ fn render_component(component: &Component, state: &mut RenderState, out: &mut St // A figure is a numbered, captioned wrapper rendered as native semantic HTML // (no runtime needed), not an upgradeable custom element. if component.name == "figure" { - render_figure(component, state, out); + render_figure(component, line_attr, state, out); return; } // A bib fence is bibliography data consumed by the pre-pass; it has no visible - // output of its own (the formatted list is rendered by :::references). + // output of its own (the formatted list is rendered by :::references). Its + // pending line, if any, has nothing to attach to and is simply dropped. if component.name == "bib" { return; } @@ -408,6 +523,7 @@ fn render_component(component: &Component, state: &mut RenderState, out: &mut St let tag = format!("alt-{}", component.name); out.push('<'); out.push_str(&tag); + out.push_str(line_attr); render_attrs(&component.attrs, out); out.push('>'); render_fallback(component, state, out); @@ -418,12 +534,17 @@ fn render_component(component: &Component, state: &mut RenderState, out: &mut St /// number comes from a render-time per-kind counter that advances in the same /// document order as the pre-pass, so it matches the number a cross-reference to /// this figure shows. -fn render_figure(component: &Component, state: &mut RenderState, out: &mut String) { +fn render_figure( + component: &Component, + line_attr: &str, + state: &mut RenderState, + out: &mut String, +) { let (key, prefix) = figure_kind(&component.attrs); let counter = state.fig_counters.entry(key.to_owned()).or_insert(0); *counter += 1; let number = *counter; - out.push_str(" render_crossref(target, state, out), Inline::Citation { key } => render_citation(key, state, out), Inline::FootnoteReference { name } => { + // The visible marker is the footnote's number, not its label. The + // `fnref` id is the target the definition's back-link returns to. + let number = state.footnote_numbers.get(name).copied().unwrap_or(0); out.push_str(&format!( - "{}", + "{1}", escape_attr(name), - escape_html(name) + number )); } Inline::Code(text) => { @@ -818,7 +942,7 @@ fn escape_attr(text: &str) -> String { #[cfg(test)] mod tests { - use super::render_document; + use super::{RenderOptions, render_document, render_document_with}; use altmd_ast::Parser; use altmd_parser::CommonMarkParser; @@ -827,6 +951,45 @@ mod tests { render_document(&doc) } + fn render_positioned(source: &str) -> String { + let doc = CommonMarkParser::new().parse(source).expect("parse"); + render_document_with( + &doc, + RenderOptions { + source_positions: true, + }, + ) + } + + #[test] + fn source_positions_off_emit_no_data_line() { + // The default render path must be byte-for-byte free of data-line. + let html = render("# Title\n\npara\n\n- a\n- b\n\n:::callout\nx\n:::\n"); + assert!(!html.contains("data-line"), "{html}"); + } + + #[test] + fn source_positions_stamp_top_level_blocks() { + // Heading line 1, paragraph line 3, directive opener line 5. + let html = render_positioned("# Title\n\npara\n\n:::callout\nx\n:::\n"); + assert!(html.contains("

    "), "{html}"); + assert!(html.contains("

    "), "{html}"); + assert!(html.contains(" quoted\n\n- a\n- b\n"); + assert!(html.contains("

    "), "{html}"); + // The paragraph inside the blockquote has no data-line. + assert!(html.contains("
    \n

    "), "{html}"); + assert!(html.contains("

      "), "{html}"); + assert!(html.contains("
    • "), "{html}"); + assert!(!html.contains("
    • a"), - "ref missing: {html}" + html.contains( + "1" + ), + "numbered ref missing: {html}" ); assert!( html.contains("
      "), "definition missing: {html}" ); + // The definition leads with its number and a back-link to the marker. + assert!( + html.contains("1"), + "zeta should be 1: {html}" + ); + assert!( + html.contains("2"), + "alpha should be 2: {html}" + ); + // The label words never appear as visible marker text. + assert!( + !html.contains(">zeta") && !html.contains(">alpha"), + "label leaked as marker: {html}" + ); } #[test] @@ -989,8 +1181,8 @@ mod tests { // run) must still become a link, not leak as literal text. let html = render("text[^a]\n\n:::callout\nbox\n:::\n\nmore[^b]\n\n[^a]: one\n[^b]: two"); assert!( - html.contains("a") && html.contains("b"), - "refs not resolved: {html}" + html.contains("1") && html.contains("2"), + "refs not resolved or misnumbered: {html}" ); assert!( html.contains("id=\"fn-a\"") && html.contains("id=\"fn-b\""), diff --git a/crates/altmd-parser/src/lib.rs b/crates/altmd-parser/src/lib.rs index 535ae8d..af769f2 100644 --- a/crates/altmd-parser/src/lib.rs +++ b/crates/altmd-parser/src/lib.rs @@ -92,10 +92,13 @@ impl Parser for CommonMarkParser { // every run so references resolve, then collapse the duplicated // definition blocks back to one set at the document end. let footnotes = collect_footnote_defs(source); - let mut blocks = segments_to_blocks(&segments, &footnotes)?; - relocate_footnotes(&mut blocks); + let (mut blocks, mut block_lines) = segments_to_blocks_top(&segments, &footnotes)?; + relocate_footnotes(&mut blocks, &mut block_lines); recognise_references(&mut blocks); - Ok(Document { blocks }) + Ok(Document { + blocks, + block_lines, + }) } } @@ -103,6 +106,16 @@ impl Parser for CommonMarkParser { /// `footnotes` is the document's collected footnote definitions, re-supplied so /// references in this run resolve even when their definition lives elsewhere. fn parse_commonmark_blocks(source: &str, footnotes: &str) -> Result, AstError> { + Ok(parse_commonmark_blocks_with_lines(source, footnotes)?.0) +} + +/// As [`parse_commonmark_blocks`], but also return the comrak start line of each +/// top-level block (1-based, relative to the parsed text). The caller offsets +/// these by the run's starting document line to recover absolute source lines. +fn parse_commonmark_blocks_with_lines( + source: &str, + footnotes: &str, +) -> Result<(Vec, Vec), AstError> { let arena = comrak::Arena::new(); let options = gfm_options(); let owned; @@ -113,7 +126,14 @@ fn parse_commonmark_blocks(source: &str, footnotes: &str) -> Result, owned.as_str() }; let root = comrak::parse_document(&arena, text, &options); - root.children().map(|n| map_block(n, 0)).collect() + let mut blocks = Vec::new(); + let mut lines = Vec::new(); + for node in root.children() { + let line = u32::try_from(node.data.borrow().sourcepos.start.line).unwrap_or(0); + blocks.push(map_block(node, 0)?); + lines.push(line); + } + Ok((blocks, lines)) } /// Gather the text of every footnote definition (`[^name]: ...`, plus indented @@ -148,27 +168,35 @@ fn is_footnote_def(line: &str) -> bool { /// Move every footnote definition to the end of the block list, de-duplicated by /// name (first wins). Re-supplying definitions to each run makes comrak emit a /// copy wherever a footnote is referenced; this collapses them to one set in a -/// conventional place. -fn relocate_footnotes(blocks: &mut Vec) { - let mut defs: Vec = Vec::new(); - let mut kept: Vec = Vec::with_capacity(blocks.len()); +/// conventional place. The parallel `lines` vector is reordered in lockstep so +/// it stays indexed alongside `blocks`. +fn relocate_footnotes(blocks: &mut Vec, lines: &mut Vec>) { + let mut def_blocks: Vec = Vec::new(); + let mut def_lines: Vec> = Vec::new(); + let mut kept_blocks: Vec = Vec::with_capacity(blocks.len()); + let mut kept_lines: Vec> = Vec::with_capacity(blocks.len()); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for block in std::mem::take(blocks) { - let name = match &block { - Block::FootnoteDefinition { name, .. } => Some(name.clone()), - _ => None, - }; - match name { - Some(name) => { - if seen.insert(name) { - defs.push(block); + for (block, line) in std::mem::take(blocks) + .into_iter() + .zip(std::mem::take(lines)) + { + match &block { + Block::FootnoteDefinition { name, .. } => { + if seen.insert(name.clone()) { + def_blocks.push(block); + def_lines.push(line); } } - None => kept.push(block), + _ => { + kept_blocks.push(block); + kept_lines.push(line); + } } } - kept.append(&mut defs); - *blocks = kept; + kept_blocks.append(&mut def_blocks); + kept_lines.append(&mut def_lines); + *blocks = kept_blocks; + *lines = kept_lines; } /// Rewrite `[#label]` and `[@key]` runs inside text into [`Inline::CrossRef`] and @@ -649,13 +677,21 @@ fn tokenize(input: &str) -> Vec { } /// A segment of a document: either a run of plain markdown text or a container -/// directive with its own (recursively segmented) children. +/// directive with its own (recursively segmented) children. Each carries the +/// 1-based document line it begins on, so a top-level block can be traced back +/// to its source line for scroll-sync. enum Segment { - Text(String), + Text { + text: String, + /// 1-based document line of the first buffered line of this text run. + line: u32, + }, Directive { name: String, attrs: Attrs, children: Vec, + /// 1-based document line of the directive's opening `:::` fence. + line: u32, }, } @@ -666,6 +702,11 @@ struct Frame { colons: usize, segments: Vec, text: Vec, + /// Document line of this directive's opening fence (0 for the root frame). + open_line: u32, + /// Document line of the first line buffered into `text`, set on first push + /// and cleared on flush. + text_start_line: Option, } impl Frame { @@ -676,17 +717,30 @@ impl Frame { colons: 0, segments: Vec::new(), text: Vec::new(), + open_line: 0, + text_start_line: None, } } - fn new(name: String, attrs: Attrs, colons: usize) -> Self { + fn new(name: String, attrs: Attrs, colons: usize, open_line: u32) -> Self { Self { name, attrs, colons, segments: Vec::new(), text: Vec::new(), + open_line, + text_start_line: None, + } + } + + /// Buffer a body line, remembering its document line as the run's start when + /// the buffer was empty. + fn push_text_line(&mut self, line: &str, line_no: u32) { + if self.text.is_empty() { + self.text_start_line = Some(line_no); } + self.text.push(line.to_owned()); } fn flush_text(&mut self) { @@ -694,7 +748,8 @@ impl Frame { return; } let text = std::mem::take(&mut self.text).join("\n"); - self.segments.push(Segment::Text(text)); + let line = self.text_start_line.take().unwrap_or(0); + self.segments.push(Segment::Text { text, line }); } } @@ -735,7 +790,8 @@ fn parse_close(line: &str) -> Option { fn split_directives(source: &str) -> Result, AstError> { let mut stack: Vec = vec![Frame::root()]; - for line in source.lines() { + for (index, line) in source.lines().enumerate() { + let line_no = u32::try_from(index + 1).unwrap_or(u32::MAX); if let Some((colons, name, attrs)) = parse_open(line) { // The root frame occupies one slot, so the live directive depth is // stack.len() - 1; cap it before pushing to keep segments_to_blocks' @@ -748,7 +804,7 @@ fn split_directives(source: &str) -> Result, AstError> { if let Some(top) = stack.last_mut() { top.flush_text(); } - stack.push(Frame::new(name, attrs, colons)); + stack.push(Frame::new(name, attrs, colons, line_no)); continue; } @@ -763,6 +819,7 @@ fn split_directives(source: &str) -> Result, AstError> { name: frame.name, attrs: frame.attrs, children: frame.segments, + line: frame.open_line, }; if let Some(parent) = stack.last_mut() { parent.segments.push(segment); @@ -773,7 +830,7 @@ fn split_directives(source: &str) -> Result, AstError> { } if let Some(top) = stack.last_mut() { - top.text.push(line.to_owned()); + top.push_text_line(line, line_no); } } @@ -795,30 +852,80 @@ fn segments_to_blocks(segments: &[Segment], footnotes: &str) -> Result blocks.extend(parse_commonmark_blocks(text, footnotes)?), + Segment::Text { text, .. } => { + blocks.extend(parse_commonmark_blocks(text, footnotes)?); + } Segment::Directive { name, attrs, children, + .. } => { - let spec = registry::lookup(name) - .ok_or_else(|| AstError::Malformed(format!("unknown directive: {name}")))?; - if spec.kind != registry::Kind::Directive { - return Err(AstError::Malformed(format!("'{name}' is not a directive"))); - } - // Footnote definitions are re-supplied only at the top level, so - // nested runs parse without them. - blocks.push(Block::Component(Component { - name: name.clone(), - attrs: attrs.clone(), - body: ComponentBody::Children(segments_to_blocks(children, "")?), - })); + blocks.push(directive_block(name, attrs, children)?); } } } Ok(blocks) } +/// As [`segments_to_blocks`], but for the top level: also return the 1-based +/// source line of each emitted block, indexed alongside the blocks. A text run's +/// blocks take their comrak line offset by the run's starting document line; a +/// directive takes its opening fence line. Footnote definitions, which are +/// parsed from the re-supplied appendix, get `None` (they are relocated and +/// their real source line is not recovered in this version). +fn segments_to_blocks_top( + segments: &[Segment], + footnotes: &str, +) -> Result<(Vec, Vec>), AstError> { + let mut blocks = Vec::new(); + let mut lines = Vec::new(); + for segment in segments { + match segment { + Segment::Text { text, line } => { + let (segment_blocks, comrak_lines) = + parse_commonmark_blocks_with_lines(text, footnotes)?; + let offset = line.saturating_sub(1); + for (block, comrak_line) in segment_blocks.into_iter().zip(comrak_lines) { + let doc_line = if matches!(block, Block::FootnoteDefinition { .. }) { + None + } else { + Some(comrak_line.saturating_add(offset)) + }; + blocks.push(block); + lines.push(doc_line); + } + } + Segment::Directive { + name, + attrs, + children, + line, + } => { + blocks.push(directive_block(name, attrs, children)?); + lines.push(Some(*line)); + } + } + } + Ok((blocks, lines)) +} + +/// Build one directive component block, validating the name against the +/// registry. Nested children parse without re-supplied footnotes (those are a +/// top-level concern) and carry no line information (only the top level does). +fn directive_block(name: &str, attrs: &Attrs, children: &[Segment]) -> Result { + let spec = registry::lookup(name) + .ok_or_else(|| AstError::Malformed(format!("unknown directive: {name}")))?; + if spec.kind != registry::Kind::Directive { + return Err(AstError::Malformed(format!("'{name}' is not a directive"))); + } + Ok(Block::Component(Component { + name: name.to_owned(), + attrs: attrs.clone(), + body: ComponentBody::Children(segments_to_blocks(children, "")?), + })) +} + #[cfg(test)] mod tests { use super::{CommonMarkParser, render_html}; @@ -1133,6 +1240,37 @@ mod tests { assert_eq!(parse(&src).blocks, doc.blocks, "round-trip changed the AST"); } + #[test] + fn captures_top_level_source_lines() { + // Heading on line 1, paragraph on line 3, directive opener on line 5. + let doc = parse("# A\n\npara\n\n:::callout\nx\n:::\n"); + assert_eq!(doc.blocks.len(), 3); + assert_eq!(doc.block_lines, vec![Some(1), Some(3), Some(5)]); + } + + #[test] + fn block_lines_track_a_run_after_a_directive() { + // A text run that begins after a directive is offset to its true line. + let doc = parse(":::callout\nx\n:::\n\nafter\n"); + assert_eq!(doc.blocks.len(), 2); + // Directive opener on line 1; the paragraph "after" on line 5. + assert_eq!(doc.block_lines, vec![Some(1), Some(5)]); + } + + #[test] + fn block_lines_stay_parallel_to_blocks() { + let doc = parse("# H\n\npara\n\n- a\n- b\n\n```rust\nx\n```\n"); + assert_eq!(doc.block_lines.len(), doc.blocks.len()); + } + + #[test] + fn relocated_footnote_definition_has_no_line() { + let doc = parse("text[^a]\n\n[^a]: note\n"); + // The footnote definition moves to the end and loses its line. + assert_eq!(doc.block_lines.len(), doc.blocks.len()); + assert_eq!(doc.block_lines.last(), Some(&None)); + } + #[test] fn plain_markdown_is_unchanged_by_the_splitter() { let src = "# H\n\npara with *em*\n\n- a\n- b"; diff --git a/crates/altmd-wasm/src/lib.rs b/crates/altmd-wasm/src/lib.rs index 636602c..8e3f8bd 100644 --- a/crates/altmd-wasm/src/lib.rs +++ b/crates/altmd-wasm/src/lib.rs @@ -24,6 +24,20 @@ pub fn render(source: &str) -> Result { altmd_core::render(source).map_err(|err| wasm_bindgen::JsError::new(&err.to_string())) } +/// Render alt-markdown `source` to component-aware HTML with source positions: +/// each top-level block carries a `data-line="N"` attribute naming the 1-based +/// source line it began on. An editor uses these to map a rendered block back to +/// its source for scroll-sync and click-to-source. Exposed to JavaScript as +/// `render_with_positions`. +/// +/// # Errors +/// Returns a JavaScript error if the source contains an invalid directive. +#[wasm_bindgen] +pub fn render_with_positions(source: &str) -> Result { + altmd_core::render_with_positions(source) + .map_err(|err| wasm_bindgen::JsError::new(&err.to_string())) +} + /// Normalise alt-markdown `source`: parse it and serialise it back to canonical /// source text. This is the round-trip an editing host (Alexandria) uses to /// read, edit, and write a document. Exposed to JavaScript as `normalise`. diff --git a/js/demo/demo.css b/js/demo/demo.css index 51e7594..3d56b4d 100644 --- a/js/demo/demo.css +++ b/js/demo/demo.css @@ -286,6 +286,47 @@ li.task-list-item { list-style: none; } +/* Footnotes -------------------------------------------------------------- */ + +/* The inline marker: a small raised number that links down to the note. */ +.footnote-ref { + font-size: 0.75em; + line-height: 0; +} + +.footnote-ref a { + text-decoration: none; + padding: 0 0.1em; +} + +/* The notes themselves read as a quieter, smaller block. A rule above the + first note separates it from the body text. */ +.footnote { + display: flex; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--muted); +} + +.footnote:first-of-type { + margin-top: 2rem; + padding-top: 1.25rem; + border-top: 1px solid var(--line); +} + +.footnote p { + margin: 0 0 0.5rem; +} + +/* The leading number doubles as the back-link to where the note was cited. */ +.footnote-backref { + flex: none; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-decoration: none; + color: var(--accent); +} + .alt-visually-hidden { position: absolute; width: 1px; diff --git a/spec/altmd/cases.json b/spec/altmd/cases.json index b35808a..3db0e8b 100644 --- a/spec/altmd/cases.json +++ b/spec/altmd/cases.json @@ -61,8 +61,9 @@ }, { "name": "footnote", - "input": "a[^1]\n\n[^1]: a note\n", - "contains": ["1", "id=\"fn-1\""] + "input": "a[^note]\n\n[^note]: a note\n", + "contains": ["1", "id=\"fn-note\"", "class=\"footnote-backref\" href=\"#fnref-note\""], + "lacks": [">note"] }, { "name": "task-list",