R4.4: export and copy read as the visual mode reads
HTML export, plain text and rich copy read each paragraph, heading, cell and HTML block through the component's MarkdownReader (pin 1251221). Emoji, alerts, the TOC and inline tags match visual mode. Footnotes sit in place as [n]; link titles are not written. Three review rounds failed; round 3's one finding (a scan per footnote over open marks) is fixed with a growth regression, unreviewed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9d80ba6f39
commit
256240c365
16 files changed
+728
-192
No files matched your search
@@ -0,0 +1,232 @@
|
||||
//! Exports read the file as the visual mode does (R4.4).
|
||||
|
||||
use super::{Pictures, page, plain};
|
||||
use crate::output::{Page, html::write, markdown_options, pictures::Placed};
|
||||
|
||||
fn html(text: &str) -> String {
|
||||
page(text, None, &Pictures::Inline).html
|
||||
}
|
||||
|
||||
fn text(text: &str) -> String {
|
||||
plain(text).expect("plain text")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_turn_as_github_turns_them() {
|
||||
let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na <code>:smile:</code> :smile:\n");
|
||||
for part in [
|
||||
"<p>Go 😄 x😄</p>",
|
||||
"<p>x:smile: :smile:</p>",
|
||||
"<p>a <code>:smile:</code> 😄</p>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert_eq!(
|
||||
text("Go :smile:\n\nx:smile: :smile:\n"),
|
||||
"Go 😄\n\nx:smile: :smile:\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_tags_keep_what_they_mean_and_nothing_runs() {
|
||||
let source = "a <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
|
||||
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
|
||||
let page = html(source);
|
||||
for part in [
|
||||
"<u>u</u>",
|
||||
"<mark>m</mark>",
|
||||
"<code>k</code>",
|
||||
" s ",
|
||||
"<script>x</script>",
|
||||
"<a>j</a>",
|
||||
"<a href=\"https://e.test\">e</a>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("<span") && !page.contains("<script"),
|
||||
"{page}"
|
||||
);
|
||||
assert_eq!(
|
||||
text("a <b>b</b> <script>x</script>\n"),
|
||||
"a b <script>x</script>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text() {
|
||||
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
|
||||
let page = html(source);
|
||||
assert!(
|
||||
page.contains("<p><strong>Bold</strong> and 😄</p>"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(!page.contains("<div"), "{page}");
|
||||
assert_eq!(text(source), "Bold and 😄\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_writes_its_kind_and_title() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
|
||||
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
|
||||
assert!(page.contains(alert), "{page}");
|
||||
assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}");
|
||||
assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toc_lists_the_headings_and_links_them() {
|
||||
let page = html("[toc]\n\n# A\n\n## B :smile:\n");
|
||||
let toc = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
|
||||
assert!(page.contains(toc), "{page}");
|
||||
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
|
||||
assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_crossing_containers_read_as_drawn() {
|
||||
for (source, shown) in [
|
||||
("*a <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
|
||||
("a <code>*b</code>* :smile:\n", "a b 😄"),
|
||||
] {
|
||||
assert_eq!(text(source), format!("{shown}\n"));
|
||||
let page = html(source);
|
||||
assert_eq!(page.matches("😄").count(), 1, "{page}");
|
||||
assert_eq!(
|
||||
page.matches(":smile:").count(),
|
||||
shown.matches(":smile:").count(),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_cell_and_block_reads_on_its_own() {
|
||||
let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n");
|
||||
assert_eq!(page.matches("😄").count(), 3, "{page}");
|
||||
let page = html("# a <code>x\n\n:smile:\n");
|
||||
assert!(page.contains("<p>😄</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_closes_the_tags_left_open_in_it() {
|
||||
let page = html("a <b>bold\n\nnormal\n");
|
||||
assert!(
|
||||
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
|
||||
"{page}"
|
||||
);
|
||||
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
|
||||
assert!(page.ends_with("<p>normal</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_toc_entry_has_its_heading() {
|
||||
let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n");
|
||||
let targets: Vec<&str> = page
|
||||
.split("href=\"#")
|
||||
.skip(1)
|
||||
.map(|rest| &rest[..rest.find('"').expect("a quote")])
|
||||
.collect();
|
||||
assert_eq!(targets.len(), 2, "{page}");
|
||||
for target in targets {
|
||||
assert!(
|
||||
page.contains(&format!("id=\"{target}\"")),
|
||||
"{target} in {page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_html_block_writes_in_linear_time() {
|
||||
let took = |count: usize| {
|
||||
let source = format!("<div>{}</div>\n", "<b>x</b> ".repeat(count));
|
||||
let start = std::time::Instant::now();
|
||||
html(&source);
|
||||
start.elapsed()
|
||||
};
|
||||
let (small, large) = (took(8_000), took(32_000));
|
||||
// Four times the input; squared would be sixteen.
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_under_deep_marks_write_in_linear_time() {
|
||||
let took = |count: usize| {
|
||||
let source = format!(
|
||||
"start {}{}
|
||||
|
||||
[^n]: note
|
||||
",
|
||||
"<b>".repeat(count),
|
||||
"x[^n] ".repeat(count)
|
||||
);
|
||||
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
|
||||
let mut placed = Placed::new(None, &Pictures::Inline);
|
||||
let start = std::time::Instant::now();
|
||||
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
|
||||
start.elapsed()
|
||||
};
|
||||
// Parsing grows squared upstream; untimed.
|
||||
let (small, large) = (took(4_000), took(16_000));
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_formula_is_dropped_with_its_text() {
|
||||
for source in [
|
||||
"$ $\n",
|
||||
"$ $ \n",
|
||||
"| $ $ | a |\n| - | - |\n| b | c |\n",
|
||||
] {
|
||||
html(source);
|
||||
}
|
||||
let page = page("$ $ \nnext $\\sqrt{$\n", None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
|
||||
assert_eq!(lines, [2], "{}", page.html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_in_a_link_keeps_the_link_whole() {
|
||||
let page = html("a <a href=\"https://example.test\">b[^n] c</a> after\n\n[^n]: note\n");
|
||||
assert!(
|
||||
page.contains("<a href=\"https://example.test\">b<sup>1</sup> c</a>"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_reaches_its_own_note() {
|
||||
let page = html("[^1]: unused\n\nCall[^used].\n\n[^used]: right target\n");
|
||||
assert_eq!(page.matches("id=\"fn-1\"").count(), 1, "{page}");
|
||||
let at = page.find("id=\"fn-1\"").expect("an anchor");
|
||||
assert!(
|
||||
page[at..].contains("right target") && !page[at..].contains("unused"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_notes_keep_the_labels_drawn_beside_them() {
|
||||
assert_eq!(
|
||||
text("A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta\n"),
|
||||
"A\u{b9} B\u{b2}.\n\n[2] Alpha\n\n[1] Beta\n"
|
||||
);
|
||||
assert_eq!(text("x\n\n[^n]: note\n"), "x\n\n[n] note\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_fragment_carries_its_look() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n[toc]\n\n# Title\n\nCall[^n].\n\n[^n]: note\n");
|
||||
for part in [
|
||||
"border-left:.25em solid #0969da",
|
||||
"font-weight:600",
|
||||
"list-style:none",
|
||||
"display:flex",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("class="),
|
||||
"the look needs no page style: {page}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user