Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b2b8b7
test(markdown_parser): pin html fragment parsing behavior before refa…
BunsDev May 20, 2026
4506fc3
refactor(markdown_parser): expose pub(crate) html fragment helpers
BunsDev May 20, 2026
a892132
feat(markdown_parser): render details/kbd/sub/sup/del/raw table/img html
BunsDev May 20, 2026
ee84b45
feat(markdown_parser): add gfm html span lexer with safe-list
BunsDev May 20, 2026
1fc08cd
feat(markdown_parser): dispatch block html in markdown body
BunsDev May 20, 2026
0095b4d
docs(specs): add gfm markdown preview spec and implementation plan
BunsDev May 20, 2026
2d44e0d
feat(markdown_parser): dispatch inline html via html5ever phrasing he…
BunsDev May 20, 2026
e43d47c
test(markdown_parser): cover inline html br and stripped tags
BunsDev May 20, 2026
50d34ac
feat(markdown_parser): extract footnote definitions from markdown source
BunsDev May 20, 2026
931359a
feat(markdown_parser): resolve footnote references and append section
BunsDev May 20, 2026
16ba4ec
test(markdown_parser): add gfm smoketest fixture for codeview preview
BunsDev May 20, 2026
07d7351
docs(design-changes): record gfm html and footnote preview support
BunsDev May 20, 2026
89019b6
chore(markdown_parser): drop unused HtmlSpan and FootnoteContext helpers
BunsDev May 20, 2026
2f68db5
fix(markdown_parser): inline <br> emits hard break + review nits
BunsDev May 20, 2026
3ae55a9
Potential fix for pull request finding
BunsDev May 20, 2026
3b8f896
Potential fix for pull request finding
BunsDev May 20, 2026
080171b
Potential fix for pull request finding
BunsDev May 20, 2026
c39adcd
fix(markdown_parser): preserve close-only html tags
BunsDev May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DESIGN-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ crates.

## Applied (this PR)

### GitHub-Flavored Markdown preview

CodeView's `.md` Rendered preview now renders the GFM HTML safe-list
(`<details>`, `<summary>`, `<kbd>`, `<sub>`, `<sup>`, `<del>`, raw `<table>`,
`<img>`, plus inline phrasing tags) and GFM footnotes (`[^id]` references with
`[^id]:` definitions). `<details>` blocks render always-expanded with a `▾`
glyph; interactive collapse is not provided in v1. Mermaid blocks render as
SVG diagrams in the preview, unchanged from prior behavior. Inline HTML is
parsed via the existing `html5ever` pipeline in `markdown_parser`; no new
dependencies were added.

### Brand rebrand sweep

- `app/src/drive/index.rs:109` — `WARP_DRIVE_TITLE` literal updated from
Expand Down
209 changes: 209 additions & 0 deletions crates/markdown_parser/src/footnotes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! Footnote pre/post-processing for the markdown parser.
//!
//! GFM footnotes have two parts:
//! - Definitions of the form `[^id]: text` (block-level, may continue on
//! subsequent indented lines).
//! - References of the form `[^id]` (inline).
//!
//! This module pre-extracts definitions from the raw markdown (returning the
//! source minus the definition lines), and post-rewrites references in the
//! parsed `FormattedText`, appending a footnotes section if any references
//! were resolved.

use std::collections::HashMap;

use crate::{
FormattedIndentTextInline, FormattedText, FormattedTextFragment, FormattedTextLine,
FormattedTextStyles, Hyperlink, OrderedFormattedIndentTextInline,
};

#[derive(Debug, Clone)]
pub(crate) struct FootnoteDef {
pub(crate) id: String,
pub(crate) content: String,
}

pub(crate) struct FootnoteContext {
pub(crate) definitions: HashMap<String, FootnoteDef>,
/// Resolved id → assigned number (1-based, in order of first reference).
pub(crate) numbers: HashMap<String, usize>,
/// Definitions used at least once, in number order.
pub(crate) used: Vec<FootnoteDef>,
}

/// Strip footnote definitions from the source. Returns the source minus the
/// definition lines and the collected definition map.
pub(crate) fn extract_definitions(source: &str) -> (String, HashMap<String, FootnoteDef>) {
let mut definitions: HashMap<String, FootnoteDef> = HashMap::new();
let mut output = String::with_capacity(source.len());
let mut lines = source.split_inclusive('\n').peekable();

while let Some(line) = lines.next() {
let trimmed = line.trim_end_matches(['\r', '\n']);
if let Some((id, first)) = parse_definition_line(trimmed) {
let mut content = first.to_string();
// Absorb indented continuation lines (4 spaces or a tab).
while let Some(peek) = lines.peek() {
let peek_no_eol = peek.trim_end_matches(['\r', '\n']);
if peek_no_eol.starts_with(" ") || peek_no_eol.starts_with('\t') {
let cont = lines.next().unwrap();
let cont_trimmed = cont
.trim_end_matches(['\r', '\n'])
.trim_start_matches(['\t'])
.trim_start_matches(" ");
content.push(' ');
content.push_str(cont_trimmed);
} else {
break;
}
}
definitions.insert(id.clone(), FootnoteDef { id, content });
continue;
}
output.push_str(line);
}

(output, definitions)
}

fn parse_definition_line(line: &str) -> Option<(String, &str)> {
let rest = line.strip_prefix("[^")?;
let close = rest.find(']')?;
let id = &rest[..close];
if id.is_empty() || id.contains(char::is_whitespace) {
return None;
}
let after = &rest[close + 1..];
let body = after.strip_prefix(':')?.trim_start();
Some((id.to_string(), body))
}

/// Walk every inline fragment in `text` and rewrite occurrences of `[^id]` (for
/// `id` present in `defs`) into a hyperlink fragment numbered in first-reference order.
///
/// Returns the rewritten text and a `FootnoteContext` populated with the
/// definitions that were actually used (in numbered order).
pub(crate) fn rewrite_references(
mut text: FormattedText,
defs: HashMap<String, FootnoteDef>,
) -> (FormattedText, FootnoteContext) {
let mut ctx = FootnoteContext {
definitions: defs,
numbers: HashMap::new(),
used: Vec::new(),
};

for line in text.lines.iter_mut() {
rewrite_line(line, &mut ctx);
}

(text, ctx)
}

fn rewrite_line(line: &mut FormattedTextLine, ctx: &mut FootnoteContext) {
let fragments = match line {
FormattedTextLine::Line(frags) => frags,
FormattedTextLine::Heading(h) => &mut h.text,
FormattedTextLine::OrderedList(list) => &mut list.indented_text.text,
FormattedTextLine::UnorderedList(list) => &mut list.text,
FormattedTextLine::TaskList(list) => &mut list.text,
_ => return,
};
rewrite_fragments(fragments, ctx);
}

fn rewrite_fragments(
fragments: &mut Vec<FormattedTextFragment>,
ctx: &mut FootnoteContext,
) {
let mut out: Vec<FormattedTextFragment> = Vec::with_capacity(fragments.len());
for fragment in fragments.drain(..) {
if !fragment.text.contains("[^") || fragment.styles.inline_code {
out.push(fragment);
continue;
}
let original_styles = fragment.styles.clone();
let mut remaining = fragment.text.as_str();
let mut buf = String::new();
while let Some(at) = remaining.find("[^") {
buf.push_str(&remaining[..at]);
let after = &remaining[at + 2..];
if let Some(close) = after.find(']') {
let id = &after[..close];
if !id.is_empty()
&& !id.contains(char::is_whitespace)
&& ctx.definitions.contains_key(id)
{
if !buf.is_empty() {
out.push(FormattedTextFragment {
text: std::mem::take(&mut buf),
styles: original_styles.clone(),
});
}
let number = match ctx.numbers.get(id) {
Some(n) => *n,
None => {
let n = ctx.used.len() + 1;
ctx.numbers.insert(id.to_string(), n);
ctx.used.push(ctx.definitions[id].clone());
n
}
};
out.push(FormattedTextFragment {
text: number.to_string(),
styles: FormattedTextStyles {
italic: true,
hyperlink: Some(Hyperlink::Url(format!("#fn-{id}"))),
..original_styles.clone()
},
});
remaining = &after[close + 1..];
continue;
}
}
// Not a defined reference — emit the [^ literally and keep scanning.
buf.push_str("[^");
remaining = after;
}
buf.push_str(remaining);
if !buf.is_empty() {
out.push(FormattedTextFragment {
text: buf,
styles: original_styles,
});
}
}
*fragments = out;
}

/// Append a footnotes section to `text` based on `ctx.used`.
pub(crate) fn append_section(text: &mut FormattedText, ctx: &FootnoteContext) {
if ctx.used.is_empty() {
return;
}
text.lines.push_back(FormattedTextLine::HorizontalRule);
for (index, def) in ctx.used.iter().enumerate() {
let number = index + 1;
let body_fragments = crate::parse_inline_markdown(&def.content);
let mut content_fragments: Vec<FormattedTextFragment> = body_fragments.into_iter().collect();
content_fragments.push(FormattedTextFragment {
text: " ↩".to_string(),
styles: FormattedTextStyles {
hyperlink: Some(Hyperlink::Url(format!("#fnref-{}", def.id))),
..Default::default()
},
});
text.lines
.push_back(FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: Some(number),
indented_text: FormattedIndentTextInline {
indent_level: 0,
text: content_fragments,
},
}));
}
}

#[cfg(test)]
#[path = "footnotes_tests.rs"]
mod tests;
101 changes: 101 additions & 0 deletions crates/markdown_parser/src/footnotes_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use super::*;
use crate::{parse_markdown};

#[test]
fn rewrite_single_reference_appends_section() {
let parsed = parse_markdown("Some claim[^x].\n\n[^x]: Because reasons.\n")
.expect("parse");
// Structural assertions:
assert!(parsed.lines.iter().any(|l| matches!(l, FormattedTextLine::HorizontalRule)));
let has_back_ref = parsed
.lines
.iter()
.filter_map(|line| match line {
FormattedTextLine::OrderedList(list) => Some(&list.indented_text.text),
_ => None,
})
.flatten()
.any(|f| f.text == " ↩" && matches!(&f.styles.hyperlink, Some(Hyperlink::Url(u)) if u.contains("fnref")));
assert!(has_back_ref, "expected back-reference fragment");
// The reference itself is now a hyperlink "1"
let reference_hyperlink_present = parsed
.lines
.iter()
.filter_map(|line| match line {
FormattedTextLine::Line(frags) => Some(frags),
_ => None,
})
.flatten()
.any(|f| f.text == "1" && matches!(&f.styles.hyperlink, Some(Hyperlink::Url(u)) if u == "#fn-x"));
assert!(reference_hyperlink_present, "expected #fn-x reference");
}

#[test]
fn unused_definition_dropped() {
let parsed = parse_markdown("Plain text.\n\n[^never]: Unused.\n").expect("parse");
assert!(parsed.lines.iter().all(|l| !matches!(l, FormattedTextLine::HorizontalRule)));
}

#[test]
fn undefined_reference_passes_through() {
let parsed = parse_markdown("Some claim[^missing] here.\n").expect("parse");
let joined: String = parsed
.lines
.iter()
.filter_map(|line| match line {
FormattedTextLine::Line(frags) => Some(frags.iter().map(|f| f.text.clone()).collect::<String>()),
_ => None,
})
.collect();
assert!(joined.contains("[^missing]"), "expected literal pass-through: {joined:?}");
}

#[test]
fn repeated_references_share_number() {
let parsed = parse_markdown("A[^x] B[^x]\n\n[^x]: D\n").expect("parse");
let count = parsed
.lines
.iter()
.filter_map(|line| match line {
FormattedTextLine::Line(frags) => Some(frags),
_ => None,
})
.flatten()
.filter(|f| f.text == "1")
.count();
assert_eq!(count, 2, "both references should be number 1");
}

#[test]
fn extract_single_definition() {
let source = "claim[^x]\n\n[^x]: defn\n";
let (out, defs) = extract_definitions(source);
assert_eq!(out, "claim[^x]\n\n");
assert_eq!(defs.len(), 1);
assert_eq!(defs.get("x").unwrap().content, "defn");
}

#[test]
fn extract_no_definitions_passthrough() {
let source = "plain text\n";
let (out, defs) = extract_definitions(source);
assert_eq!(out, "plain text\n");
assert!(defs.is_empty());
}

#[test]
fn extract_continuation_line() {
let source = "[^x]: first\n continued\n";
let (out, defs) = extract_definitions(source);
assert_eq!(out, "");
assert_eq!(defs.get("x").unwrap().content, "first continued");
}

#[test]
fn extract_id_with_space_skipped() {
// GFM ids don't allow whitespace; the line falls through as a regular paragraph.
let source = "[^bad id]: defn\n";
let (out, defs) = extract_definitions(source);
assert_eq!(out, source);
assert!(defs.is_empty());
}
Loading