From e44958f662c2ba8d7d0b4018516d3dfa5b0cb756 Mon Sep 17 00:00:00 2001 From: Daniel Iwugo <121883945+elementmerc@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:22:58 +0100 Subject: [PATCH] Number footnotes by reference order, and carry source positions A footnote marker was rendering the footnote's internal label (the word after the caret) as its visible text, so a reference to [^scripts] showed up as "scripts" jammed onto the end of the sentence instead of a small superscript number. Readers expect the GFM convention: a number assigned by the order each footnote is first referenced, mirrored on the note at the bottom, with a link back to where it was cited. Numbering is done in the existing pre-pass, the same way citations are already numbered, so the inline marker and the definition list can never disagree. The demo theme gains the matching footnote styling it never had. Folded in alongside it: the renderer can now stamp each top-level block with a data-line attribute giving its 1-based source line, gated behind RenderOptions so default output stays byte-identical. The parser captures those lines through directive segmentation and footnote relocation, and the wasm boundary exposes a positions-on entry point. This is what lets an editor map a rendered block back to its source for scroll-sync. --- crates/altmd-ast/src/lib.rs | 9 ++ crates/altmd-cli/src/include.rs | 7 +- crates/altmd-core/src/lib.rs | 18 ++- crates/altmd-core/src/render.rs | 270 +++++++++++++++++++++++++++----- crates/altmd-parser/src/lib.rs | 218 +++++++++++++++++++++----- crates/altmd-wasm/src/lib.rs | 14 ++ js/demo/demo.css | 41 +++++ spec/altmd/cases.json | 5 +- 8 files changed, 499 insertions(+), 83 deletions(-) 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",