commit 5d3afabd2453c2cf78dc7db2b27e8a74a627ede0 Author: Lukas Stancik Date: Sat Apr 4 09:40:28 2026 +0200 Shallow clone of https://github.com/wooorm/markdown-rs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9e5f571 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.rs] +indent_size = 4 + +[tests/commonmark.rs] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..151575c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +*.log +*.lock +coverage/ +target/ +commonmark-data.txt +unicode-data.txt +fuzz/target +fuzz/corpus +fuzz/artifacts +fuzz/hfuzz_target +fuzz/hfuzz_workspace diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5462ac3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[[bench]] +harness = false +name = "bench" +path = "benches/bench.rs" + +[dependencies] +log = { optional = true, version = "0.4" } +serde = { features = ["derive"], optional = true, version = "1" } +unicode-id = { features = ["no_std"], version = "0.3" } + +[dev-dependencies] +criterion = "0.5" +env_logger = "0.11" +pretty_assertions = { workspace = true } +serde_json = { version = "1" } +swc_core = { version = "22", features = [ + "common", + "ecma_ast", + "ecma_parser", + "ecma_visit", +] } + +[features] +default = [] +json = ["serde"] +log = ["dep:log"] +serde = ["dep:serde"] + +[package] +authors = ["Titus Wormer "] +categories = ["compilers", "encoding", "parser-implementations", "parsing", "text-processing"] +description = "CommonMark compliant markdown parser in Rust with ASTs and extensions" +edition = "2018" +homepage = "https://github.com/wooorm/markdown-rs" +include = ["src/", "license"] +keywords = ["commonmark", "markdown", "parse", "render", "tokenize"] +license = "MIT" +name = "markdown" +repository = "https://github.com/wooorm/markdown-rs" +rust-version = "1.56" +version = "1.0.0" + +[workspace] +members = ["generate", "mdast_util_to_markdown"] + +[workspace.dependencies] +pretty_assertions = "1" diff --git a/Untitled.txt b/Untitled.txt new file mode 100644 index 0000000..fb1e53c --- /dev/null +++ b/Untitled.txt @@ -0,0 +1,31 @@ +micromark.js: unquoted: is `completeAttributeValueUnquoted`s case for `completeAttributeNameAfter` missing a `/`?. I’ve added it here. +micromark.js: `]` case in cdata_end does not need to consume, it can defer to `cdata_close`, which should save 1 line +micromark.js: should `tagOpenAttributeValueUnquoted` also support a slash? +micromark.js: `atLineEnding` in html (text) should always eat arbitrary whitespace? code (indented) has no effect on html (text)? + +```rs +// --------------------- +// Useful helper: +extern crate std; +use std::println; +use alloc::string::String; + + let mut index = 0; + let mut balance = 0; + println!("before: {:?}", tokenizer.events.len()); + while index < tokenizer.events.len() { + let event = &tokenizer.events[index]; + if event.kind == Kind::Exit { + balance -= 1; + } + let prefix = String::from_utf8(vec![b' '; balance * 2]).unwrap(); + println!( + "ev: {}{:?}:{:?} ({:?}): {:?}", + prefix, event.kind, event.name, index, event.link, + ); + if event.kind == Kind::Enter { + balance += 1; + } + index += 1; + } +``` diff --git a/benches/bench.rs b/benches/bench.rs new file mode 100644 index 0000000..0eb9f0b --- /dev/null +++ b/benches/bench.rs @@ -0,0 +1,24 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use std::fs; + +fn readme(c: &mut Criterion) { + let doc = fs::read_to_string("readme.md").unwrap(); + + c.bench_with_input(BenchmarkId::new("readme", "readme"), &doc, |b, s| { + b.iter(|| markdown::to_html(s)); + }); +} + +// fn one_and_a_half_mb(c: &mut Criterion) { +// let doc = fs::read_to_string("../a-dump-of-markdown/markdown.md").unwrap(); +// let mut group = c.benchmark_group("giant"); +// group.sample_size(10); +// group.bench_with_input(BenchmarkId::new("giant", "1.5 mb"), &doc, |b, s| { +// b.iter(|| markdown::to_html(s)); +// }); +// group.finish(); +// } +// , one_and_a_half_mb + +criterion_group!(benches, readme); +criterion_main!(benches); diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..6dfe577 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + patch: false + project: + default: + informational: true diff --git a/examples/lib.rs b/examples/lib.rs new file mode 100644 index 0000000..0687b15 --- /dev/null +++ b/examples/lib.rs @@ -0,0 +1,44 @@ +fn main() -> Result<(), markdown::message::Message> { + // Turn on debugging. + // You can show it with `RUST_LOG=debug cargo run --features log --example lib` + env_logger::init(); + + // Safely turn (untrusted?) markdown into HTML. + println!("{:?}", markdown::to_html("## Hello, *world*!")); + + // Turn trusted markdown into HTML. + println!( + "{:?}", + markdown::to_html_with_options( + "
\n\n# Hi, *Saturn*! 🪐\n\n
", + &markdown::Options { + compile: markdown::CompileOptions { + allow_dangerous_html: true, + allow_dangerous_protocol: true, + ..markdown::CompileOptions::default() + }, + ..markdown::Options::default() + } + ) + ); + + // Support GFM extensions. + println!( + "{}", + markdown::to_html_with_options( + "* [x] contact ~Mercury~Venus at hi@venus.com!", + &markdown::Options::gfm() + )? + ); + + // Access syntax tree and support MDX extensions: + println!( + "{:?}", + markdown::to_mdast( + "# , {username}!", + &markdown::ParseOptions::mdx() + )? + ); + + Ok(()) +} diff --git a/funding.yml b/funding.yml new file mode 100644 index 0000000..dee132d --- /dev/null +++ b/funding.yml @@ -0,0 +1 @@ +github: wooorm diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..6a50fd2 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "markdown-fuzz" +version = "0.0.0" +authors = ["Automatically generated"] +publish = false +edition = "2018" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +honggfuzz = "0.5" + +[dependencies.markdown] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[[bin]] +name = "markdown_libfuzz" +path = "fuzz_targets/markdown_libfuzz.rs" +test = false +doc = false + +[[bin]] +name = "markdown_honggfuzz" +path = "fuzz_targets/markdown_honggfuzz.rs" +test = false +doc = false \ No newline at end of file diff --git a/fuzz/fuzz_targets/markdown_honggfuzz.rs b/fuzz/fuzz_targets/markdown_honggfuzz.rs new file mode 100644 index 0000000..6aa3c79 --- /dev/null +++ b/fuzz/fuzz_targets/markdown_honggfuzz.rs @@ -0,0 +1,15 @@ +use honggfuzz::fuzz; + +fn main() { + loop { + fuzz!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let _ = markdown::to_html(s); + let _ = markdown::to_html_with_options(s, &markdown::Options::gfm()); + let _ = markdown::to_mdast(s, &markdown::ParseOptions::default()); + let _ = markdown::to_mdast(s, &markdown::ParseOptions::gfm()); + let _ = markdown::to_mdast(s, &markdown::ParseOptions::mdx()); + } + }); + } +} diff --git a/fuzz/fuzz_targets/markdown_libfuzz.rs b/fuzz/fuzz_targets/markdown_libfuzz.rs new file mode 100644 index 0000000..ca6d86b --- /dev/null +++ b/fuzz/fuzz_targets/markdown_libfuzz.rs @@ -0,0 +1,12 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let _ = markdown::to_html(s); + let _ = markdown::to_html_with_options(s, &markdown::Options::gfm()); + let _ = markdown::to_mdast(s, &markdown::ParseOptions::default()); + let _ = markdown::to_mdast(s, &markdown::ParseOptions::gfm()); + let _ = markdown::to_mdast(s, &markdown::ParseOptions::mdx()); + } +}); diff --git a/generate/Cargo.toml b/generate/Cargo.toml new file mode 100644 index 0000000..0a48f11 --- /dev/null +++ b/generate/Cargo.toml @@ -0,0 +1,11 @@ +[dependencies] +regex = "1" +reqwest = "0.12" +tokio = { features = ["full"], version = "1" } + +[package] +authors = ["Titus Wormer "] +edition = "2018" +name = "markdown-generate" +publish = false +version = "0.0.0" diff --git a/generate/src/main.rs b/generate/src/main.rs new file mode 100644 index 0000000..1a0b898 --- /dev/null +++ b/generate/src/main.rs @@ -0,0 +1,165 @@ +// To regenerate, run the following from the repository root: +// +// ```sh +// cargo run --manifest-path generate/Cargo.toml +// ``` + +use regex::Regex; +use std::fs; + +#[tokio::main] +async fn main() { + commonmark().await; + punctuation().await; +} + +async fn commonmark() { + let url = "https://raw.githubusercontent.com/commonmark/commonmark-spec/0.31.2/spec.txt"; + let data_url = "commonmark-data.txt"; + let code_url = "tests/commonmark.rs"; + + let value = if let Ok(value) = fs::read_to_string(data_url) { + value + } else { + let value = reqwest::get(url).await.unwrap().text().await.unwrap(); + + fs::write(data_url, value.clone()).unwrap(); + + value + }; + + let re = Regex::new(r"(?m)(?:^`{32} example\n[\s\S]*?\n`{32}$|^#{1,6} *(.*)$)").unwrap(); + let re_heading_prefix = Regex::new(r"#{1,6} ").unwrap(); + let re_in_out = Regex::new(r"\n\.(?:\n|$)").unwrap(); + let mut current_heading = None; + let mut number = 1; + + let value = Regex::new(r"[\s\S]*") + .unwrap() + .replace(&value, ""); + let value = Regex::new(r"→").unwrap().replace_all(&value, "\t"); + let mut cases = vec![]; + + for mat in re.find_iter(&value) { + let mut lines = mat.as_str().lines().collect::>(); + + if lines.len() == 1 { + current_heading = Some(re_heading_prefix.replace(lines[0], "").to_string()); + } else { + lines.remove(0); + lines.pop(); + let section = current_heading.as_ref().unwrap(); + let case = lines.join("\n"); + let parts = re_in_out.split(&case).collect::>(); + let input = format!("{}\n", parts[0]); + let output = if parts[1].is_empty() { + "".into() + } else { + format!("{}\n", parts[1]) + }; + + let test = format!(" assert_eq!(\n to_html_with_options(\n r###\"{}\"###,\n &danger\n )?,\n r###\"{}\"###,\n r###\"{} ({})\"###\n);", input, output, section, number); + + cases.push(test); + + number += 1; + } + } + + let doc = format!( + "//! `CommonMark` test suite. + +// > 👉 **Important**: this module is generated by `generate/src/main.rs`. +// > It is generate from the latest CommonMark website. + +use markdown::{{message, to_html_with_options, CompileOptions, Options}}; +use pretty_assertions::assert_eq; + +#[rustfmt::skip] +#[test] +fn commonmark() -> Result<(), message::Message> {{ + let danger = Options {{ + compile: CompileOptions {{ + allow_dangerous_html: true, + allow_dangerous_protocol: true, + ..CompileOptions::default() + }}, + ..Options::default() + }}; + +{} + + Ok(()) +}} +", + cases.join("\n\n") + ); + + fs::write(code_url, doc).unwrap(); +} + +async fn punctuation() { + let url = "https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt"; + let data_url = "unicode-data.txt"; + let code_url = "src/util/unicode.rs"; + + let value = if let Ok(value) = fs::read_to_string(data_url) { + value + } else { + let value = reqwest::get(url).await.unwrap().text().await.unwrap(); + + fs::write(data_url, value.clone()).unwrap(); + + value + }; + + let search = [ + "Pc", // Punctuation, Connector + "Pd", // Punctuation, Dash + "Pe", // Punctuation, Close + "Pf", // Punctuation, FinalQuote + "Pi", // Punctuation, InitialQuote + "Po", // Punctuation, Other + "Ps", // Punctuation, Open + "Sc", // Symbol, Currency + "Sk", // Symbol, Modifier + "Sm", // Symbol, Math + "So", // Symbol, Other + ]; + + let found = value + .lines() + .map(|line| line.split(';').collect::>()) + .map(|cells| (cells[0], cells[2])) + .filter(|c| search.contains(&c.1)) + .map(|c| c.0) + .collect::>(); + + let doc = format!( + "//! Info on Unicode. + +/// List of characters that are considered punctuation. +/// +/// > 👉 **Important**: this module is generated by `generate/src/main.rs`. +/// > It is generate from the latest Unicode data. +/// +/// Rust does not contain an `is_punctuation` method on `char`, while it does +/// support [`is_ascii_alphanumeric`](char::is_ascii_alphanumeric). +/// +/// `CommonMark` handles attention (emphasis, strong) markers based on what +/// comes before or after them. +/// One such difference is if those characters are Unicode punctuation. +/// +/// ## References +/// +/// * [*§ 2.1 Characters and lines* in `CommonMark`](https://spec.commonmark.org/0.31.2/#unicode-punctuation-character) +pub static PUNCTUATION: [char; {}] = [ +{} +]; +", + found.len(), + found.iter().map(|d| format!(" '\\u{{{}}}',", d)).collect::>().join("\n") + ); + + fs::write(code_url, doc).unwrap(); +} diff --git a/license b/license new file mode 100644 index 0000000..9ac1e96 --- /dev/null +++ b/license @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2022 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/mdast_util_to_markdown/Cargo.toml b/mdast_util_to_markdown/Cargo.toml new file mode 100644 index 0000000..17484f1 --- /dev/null +++ b/mdast_util_to_markdown/Cargo.toml @@ -0,0 +1,13 @@ +[dependencies] +markdown = { path = "../", version = "1.0.0" } +regex = { version = "1" } + +[dev-dependencies] +pretty_assertions = { workspace = true } + +[package] +description = "Markdown to AST" +edition = "2018" +license = "MIT" +name = "mdast_util_to_markdown" +version = "0.0.2" diff --git a/mdast_util_to_markdown/src/association.rs b/mdast_util_to_markdown/src/association.rs new file mode 100644 index 0000000..b9fb67c --- /dev/null +++ b/mdast_util_to_markdown/src/association.rs @@ -0,0 +1,41 @@ +//! Traits for . +//! +//! JS equivalent: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/70e1a4f/types/mdast/index.d.ts#L48. + +use alloc::string::String; +use markdown::mdast::{Definition, ImageReference, LinkReference}; + +pub trait Association { + fn identifier(&self) -> &String; + fn label(&self) -> &Option; +} + +impl Association for Definition { + fn identifier(&self) -> &String { + &self.identifier + } + + fn label(&self) -> &Option { + &self.label + } +} + +impl Association for ImageReference { + fn identifier(&self) -> &String { + &self.identifier + } + + fn label(&self) -> &Option { + &self.label + } +} + +impl Association for LinkReference { + fn identifier(&self) -> &String { + &self.identifier + } + + fn label(&self) -> &Option { + &self.label + } +} diff --git a/mdast_util_to_markdown/src/configure.rs b/mdast_util_to_markdown/src/configure.rs new file mode 100644 index 0000000..f80be73 --- /dev/null +++ b/mdast_util_to_markdown/src/configure.rs @@ -0,0 +1,103 @@ +//! Configuration. +//! +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/fd6a508/lib/types.js#L307. + +#[derive(Clone, Copy)] +/// Configuration for indent of lists. +pub enum IndentOptions { + /// Depends on the item and its parent list: uses `IndentOptions::One` if + /// the item and list are tight and `IndentOptions::Tab` otherwise. + Mixed, + /// The size of the bullet plus one space. + One, + /// Tab stop. + Tab, +} + +/// Configuration. +pub struct Options { + /// Marker to use for bullets of items in unordered lists (`'*'`, `'+'`, or + /// `'-'`, default: `'*'`). + pub bullet: char, + /// Marker to use for bullets of items in ordered lists (`'.'` or `')'`, + /// default: `'.'`). + pub bullet_ordered: char, + /// Marker to use in certain cases where the primary bullet doesn’t work + /// (`'*'`, `'+'`, or `'-'`, default: `'-'` when bullet is `'*'`, `'*'` + /// otherwise). + pub bullet_other: char, + /// Whether to add the same number of number signs (`#`) at the end of an + /// ATX heading as the opening sequence (`bool`, default: `false`). + pub close_atx: bool, + /// Marker to use for emphasis (`'*'` or `'_'`, default: `'*'`). + pub emphasis: char, + /// Marker to use for fenced code (``'`'`` or `'~'`, default: ``'`'``). + pub fence: char, + /// Whether to use fenced code always (`bool`, default: `true`). + /// The default is to use fenced code if there is a language defined, + /// if the code is empty, + /// or if it starts or ends in blank lines. + pub fences: bool, + /// Whether to increment the counter of ordered lists items (`bool`, + /// default: `true`). + pub increment_list_marker: bool, + /// How to indent the content of list items (default: `IndentOptions::One`). + pub list_item_indent: IndentOptions, + /// Marker to use for titles (`'"'` or `"'"`, default: `'"'`). + pub quote: char, + /// Whether to always use resource links (`bool`, default: `false`). + /// The default is to use autolinks (``) when possible + /// and resource links (`[text](url)`) otherwise. + pub resource_link: bool, + /// Marker to use for thematic breaks (`'*'`, `'-'`, or `'_'`, default: + /// `'*'`). + pub rule: char, + /// Number of markers to use for thematic breaks (`u32`, default: `3`, min: + /// `3`). + pub rule_repetition: u32, + /// Whether to add spaces between markers in thematic breaks (`bool`, + /// default: `false`). + pub rule_spaces: bool, + /// Whether to use setext headings when possible (`bool`, default: + /// `false`). + /// The default is to always use ATX headings (`# heading`) instead of + /// setext headings (`heading\n=======`). + /// Setext headings cannot be used for empty headings or headings with a + /// rank of three or more. + pub setext: bool, + /// Whether to support math (text) with a single dollar (`bool`, default: `true`). + /// Single dollars work in Pandoc and many other places, but often interfere with “normal” + /// dollars in text. + /// If you turn this off, you can still use two or more dollars for text math. + pub single_dollar_text_math: bool, + /// Marker to use for strong (`'*'` or `'_'`, default: `'*'`). + pub strong: char, + /// Whether to join definitions without a blank line (`bool`, default: + /// `false`). + pub tight_definitions: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + bullet: '*', + bullet_ordered: '.', + bullet_other: '-', + close_atx: false, + emphasis: '*', + fence: '`', + fences: true, + increment_list_marker: true, + list_item_indent: IndentOptions::One, + quote: '"', + resource_link: false, + rule: '*', + rule_repetition: 3, + rule_spaces: false, + setext: false, + single_dollar_text_math: true, + strong: '*', + tight_definitions: false, + } + } +} diff --git a/mdast_util_to_markdown/src/construct_name.rs b/mdast_util_to_markdown/src/construct_name.rs new file mode 100644 index 0000000..21e5734 --- /dev/null +++ b/mdast_util_to_markdown/src/construct_name.rs @@ -0,0 +1,253 @@ +//! Names of the things being serialized. +//! +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/fd6a508/index.d.ts#L18. + +#[derive(Clone, PartialEq)] +pub enum ConstructName { + /// Whole autolink. + /// + /// ```markdown + /// > | and + /// ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ + /// ``` + Autolink, + /// Whole block quote. + /// + /// ```markdown + /// > | > a + /// ^^^ + /// > | b + /// ^ + /// ``` + Blockquote, + /// Whole code (fenced). + /// + /// ````markdown + /// > | ```js + /// ^^^^^ + /// > | console.log(1) + /// ^^^^^^^^^^^^^^ + /// > | ``` + /// ^^^ + /// ```` + CodeFenced, + /// Code (fenced) language, when fenced with grave accents. + /// + /// ````markdown + /// > | ```js + /// ^^ + /// | console.log(1) + /// | ``` + /// ```` + CodeFencedLangGraveAccent, + /// Code (fenced) language, when fenced with tildes. + /// + /// ````markdown + /// > | ~~~js + /// ^^ + /// | console.log(1) + /// | ~~~ + /// ```` + CodeFencedLangTilde, + /// Code (fenced) meta string, when fenced with grave accents. + /// + /// ````markdown + /// > | ```js eval + /// ^^^^ + /// | console.log(1) + /// | ``` + /// ```` + CodeFencedMetaGraveAccent, + /// Code (fenced) meta string, when fenced with tildes. + /// + /// ````markdown + /// > | ~~~js eval + /// ^^^^ + /// | console.log(1) + /// | ~~~ + /// ```` + CodeFencedMetaTilde, + /// Whole code (indented). + /// + /// ```markdown + /// ␠␠␠␠console.log(1) + /// ^^^^^^^^^^^^^^^^^^ + /// ``` + CodeIndented, + /// Whole definition. + /// + /// ```markdown + /// > | [a]: b "c" + /// ^^^^^^^^^^ + /// ``` + Definition, + /// Destination (literal) (occurs in definition, image, link). + /// + /// ```markdown + /// > | [a]: "c" + /// ^^^ + /// > | a ![b]( "d") e + /// ^^^ + /// ``` + DestinationLiteral, + /// Destination (raw) (occurs in definition, image, link). + /// + /// ```markdown + /// > | [a]: b "c" + /// ^ + /// > | a ![b](c "d") e + /// ^ + /// ``` + DestinationRaw, + /// Emphasis. + /// + /// ```markdown + /// > | *a* + /// ^^^ + /// ``` + Emphasis, + /// Whole heading (atx). + /// + /// ```markdown + /// > | # alpha + /// ^^^^^^^ + /// ``` + HeadingAtx, + /// Whole heading (setext). + /// + /// ```markdown + /// > | alpha + /// ^^^^^ + /// > | ===== + /// ^^^^^ + /// ``` + HeadingSetext, + /// Whole image. + /// + /// ```markdown + /// > | ![a](b) + /// ^^^^^^^ + /// > | ![c] + /// ^^^^ + /// ``` + Image, + /// Whole image reference. + /// + /// ```markdown + /// > | ![a] + /// ^^^^ + /// ``` + ImageReference, + /// Label (occurs in definitions, image reference, image, link reference, + /// link). + /// + /// ```markdown + /// > | [a]: b "c" + /// ^^^ + /// > | a [b] c + /// ^^^ + /// > | a ![b][c] d + /// ^^^^ + /// > | a [b](c) d + /// ^^^ + /// ``` + Label, + /// Whole link. + /// + /// ```markdown + /// > | [a](b) + /// ^^^^^^ + /// > | [c] + /// ^^^ + /// ``` + Link, + /// Whole link reference. + /// + /// ```markdown + /// > | [a] + /// ^^^ + /// ``` + LinkReference, + /// List. + /// + /// ```markdown + /// > | * a + /// ^^^ + /// > | 1. b + /// ^^^^ + /// ``` + List, + /// List item. + /// + /// ```markdown + /// > | * a + /// ^^^ + /// > | 1. b + /// ^^^^ + /// ``` + ListItem, + /// Math (flow). + /// + /// ```markdown + /// > | $$ + /// ^^ + /// > | a + /// ^ + /// > | $$ + /// ^^ + /// ``` + MathFlow, + /// Math (flow) meta flag. + /// + /// ```markdown + /// > | $$a + /// ^ + /// | b + /// | $$ + /// ``` + MathFlowMeta, + /// Paragraph. + /// + /// ```markdown + /// > | a b + /// ^^^ + /// > | c. + /// ^^ + /// ``` + Paragraph, + /// Phrasing (occurs in headings, paragraphs, etc). + /// + /// ```markdown + /// > | a + /// ^ + /// ``` + Phrasing, + /// Reference (occurs in image, link). + /// + /// ```markdown + /// > | [a][] + /// ^^ + /// ``` + Reference, + /// Strong. + /// + /// ```markdown + /// > | **a** + /// ^^^^^ + /// ``` + Strong, + /// Title using single quotes (occurs in definition, image, link). + /// + /// ```markdown + /// > | [a](b 'c') + /// ^^^ + /// ``` + TitleApostrophe, + /// Title using double quotes (occurs in definition, image, link). + /// + /// ```markdown + /// > | [a](b "c") + /// ^^^ + /// ``` + TitleQuote, +} diff --git a/mdast_util_to_markdown/src/handle/blockquote.rs b/mdast_util_to_markdown/src/handle/blockquote.rs new file mode 100644 index 0000000..c7ed952 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/blockquote.rs @@ -0,0 +1,39 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/blockquote.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, +}; +use alloc::string::String; +use markdown::{ + mdast::{Blockquote, Node}, + message::Message, +}; + +impl Handle for Blockquote { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::Blockquote); + let value = state.container_flow(node)?; + let value = state.indent_lines(&value, map); + state.exit(); + Ok(value) + } +} + +fn map(line: &str, _index: usize, blank: bool) -> String { + let mut result = String::with_capacity(2 + line.len()); + let marker = ">"; + result.push_str(marker); + if !blank { + result.push(' '); + } + result.push_str(line); + result +} diff --git a/mdast_util_to_markdown/src/handle/break.rs b/mdast_util_to_markdown/src/handle/break.rs new file mode 100644 index 0000000..e63a3eb --- /dev/null +++ b/mdast_util_to_markdown/src/handle/break.rs @@ -0,0 +1,38 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/break.js + +use super::Handle; +use crate::{ + state::{Info, State}, + util::pattern_in_scope::pattern_in_scope, +}; +use alloc::string::ToString; +use markdown::{ + mdast::{Break, Node}, + message::Message, +}; + +impl Handle for Break { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + for pattern in state.r#unsafe.iter() { + // If we can’t put eols in this construct (setext headings, tables), use a + // space instead. + if pattern.character == '\n' && pattern_in_scope(&state.stack, pattern) { + let space_or_tab = info.before.chars().any(|c| c == '\t' || c == ' '); + + if space_or_tab { + return Ok("".to_string()); + } + + return Ok(" ".to_string()); + } + } + + Ok("\\\n".to_string()) + } +} diff --git a/mdast_util_to_markdown/src/handle/code.rs b/mdast_util_to_markdown/src/handle/code.rs new file mode 100644 index 0000000..d92a143 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/code.rs @@ -0,0 +1,93 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/code.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::{ + check_fence::check_fence, format_code_as_indented::format_code_as_indented, + longest_char_streak::longest_char_streak, safe::SafeConfig, + }, +}; +use alloc::{ + format, + string::{String, ToString}, +}; +use markdown::{ + mdast::{Code, Node}, + message::Message, +}; + +impl Handle for Code { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let marker = check_fence(state)?; + + if format_code_as_indented(self, state) { + state.enter(ConstructName::CodeIndented); + let value = state.indent_lines(&self.value, map); + state.exit(); + return Ok(value); + } + + let sequence = marker + .to_string() + .repeat((longest_char_streak(&self.value, marker) + 1).max(3)); + + state.enter(ConstructName::CodeFenced); + let mut value = sequence.clone(); + + if let Some(lang) = &self.lang { + let code_fenced_lang_construct = if marker == '`' { + ConstructName::CodeFencedLangGraveAccent + } else { + ConstructName::CodeFencedLangTilde + }; + state.enter(code_fenced_lang_construct); + + value.push_str(&state.safe(lang, &SafeConfig::new(&value, " ", Some('`')))); + + state.exit(); + + if let Some(meta) = &self.meta { + let code_fenced_meta_construct = if marker == '`' { + ConstructName::CodeFencedMetaGraveAccent + } else { + ConstructName::CodeFencedMetaTilde + }; + + state.enter(code_fenced_meta_construct); + value.push(' '); + + value.push_str(&state.safe(meta, &SafeConfig::new(&value, "\n", Some('`')))); + + state.exit(); + } + } + + value.push('\n'); + + if !self.value.is_empty() { + value.push_str(&self.value); + value.push('\n'); + } + + value.push_str(&sequence); + state.exit(); + + Ok(value) + } +} + +fn map(line: &str, _index: usize, blank: bool) -> String { + if blank { + String::new() + } else { + format!(" {}", line) + } +} diff --git a/mdast_util_to_markdown/src/handle/definition.rs b/mdast_util_to_markdown/src/handle/definition.rs new file mode 100644 index 0000000..3338027 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/definition.rs @@ -0,0 +1,78 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/definition.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::{ + check_quote::check_quote, contains_control_or_whitespace::contains_control_or_whitespace, + safe::SafeConfig, + }, +}; +use alloc::string::String; +use markdown::{ + mdast::{Definition, Node}, + message::Message, +}; + +impl Handle for Definition { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let quote = check_quote(state)?; + + state.enter(ConstructName::Definition); + state.enter(ConstructName::Label); + + let mut value = String::from('['); + + value.push_str(&state.safe( + &state.association(self), + &SafeConfig::new(&value, "]", None), + )); + + value.push_str("]: "); + + state.exit(); + + if self.url.is_empty() || contains_control_or_whitespace(&self.url) { + state.enter(ConstructName::DestinationLiteral); + value.push('<'); + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, ">", None))); + value.push('>'); + } else { + state.enter(ConstructName::DestinationRaw); + let after = if self.title.is_some() { " " } else { ")" }; + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, after, None))); + } + + state.exit(); + + if let Some(title) = &self.title { + let title_construct_name = if quote == '"' { + ConstructName::TitleQuote + } else { + ConstructName::TitleApostrophe + }; + + state.enter(title_construct_name); + value.push(' '); + value.push(quote); + + let mut before_buffer = [0u8; 4]; + let before = quote.encode_utf8(&mut before_buffer); + value.push_str(&state.safe(title, &SafeConfig::new(&self.url, before, None))); + + value.push(quote); + state.exit(); + } + + state.exit(); + + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/emphasis.rs b/mdast_util_to_markdown/src/handle/emphasis.rs new file mode 100644 index 0000000..85f75f4 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/emphasis.rs @@ -0,0 +1,38 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/emphasis.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::check_emphasis::check_emphasis, +}; +use alloc::format; +use markdown::{ + mdast::{Emphasis, Node}, + message::Message, +}; + +impl Handle for Emphasis { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let marker = check_emphasis(state)?; + + state.enter(ConstructName::Emphasis); + + let mut value = format!("{}{}", marker, state.container_phrasing(node, info)?); + value.push(marker); + + state.exit(); + + Ok(value) + } +} + +pub fn peek_emphasis(state: &State) -> char { + state.options.emphasis +} diff --git a/mdast_util_to_markdown/src/handle/heading.rs b/mdast_util_to_markdown/src/handle/heading.rs new file mode 100644 index 0000000..7dd6738 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/heading.rs @@ -0,0 +1,81 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/heading.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::format_heading_as_setext::format_heading_as_setext, +}; +use alloc::format; +use markdown::{ + mdast::{Heading, Node}, + message::Message, +}; + +impl Handle for Heading { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let rank = self.depth.clamp(1, 6); + + if format_heading_as_setext(self, state) { + state.enter(ConstructName::HeadingSetext); + state.enter(ConstructName::Phrasing); + let mut value = state.container_phrasing(node, &Info::new("\n", "\n"))?; + + state.exit(); + state.exit(); + + let underline_char = if rank == 1 { "=" } else { "-" }; + let last_line_rank = value + .rfind('\n') + .unwrap_or(0) + .max(value.rfind('\r').unwrap_or(0)); + + let last_line_rank = if last_line_rank > 0 { + last_line_rank + 1 + } else { + 0 + }; + + let setext_underline = underline_char.repeat(value.len() - last_line_rank); + value.push('\n'); + value.push_str(&setext_underline); + + return Ok(value); + } + + let sequence = "#".repeat(rank as usize); + state.enter(ConstructName::HeadingAtx); + state.enter(ConstructName::Phrasing); + + let mut value = state.container_phrasing(node, &Info::new("# ", "\n"))?; + + if let Some(first_char) = value.chars().nth(0) { + if first_char == ' ' || first_char == '\t' { + let hex_code = u32::from(first_char); + value = format!("&#x{:X};{}", hex_code, &value[1..]) + } + } + + if value.is_empty() { + value.push_str(&sequence); + } else { + value = format!("{} {}", &sequence, value); + } + + if state.options.close_atx { + value.push(' '); + value.push_str(&sequence); + } + + state.exit(); + state.exit(); + + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/html.rs b/mdast_util_to_markdown/src/handle/html.rs new file mode 100644 index 0000000..383e838 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/html.rs @@ -0,0 +1,24 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/html.js + +use super::Handle; +use crate::state::{Info, State}; +use markdown::{ + mdast::{Html, Node}, + message::Message, +}; + +impl Handle for Html { + fn handle( + &self, + _state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + Ok(self.value.clone()) + } +} + +pub fn peek_html() -> char { + '<' +} diff --git a/mdast_util_to_markdown/src/handle/image.rs b/mdast_util_to_markdown/src/handle/image.rs new file mode 100644 index 0000000..6eae7dc --- /dev/null +++ b/mdast_util_to_markdown/src/handle/image.rs @@ -0,0 +1,81 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/image.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::{ + check_quote::check_quote, contains_control_or_whitespace::contains_control_or_whitespace, + safe::SafeConfig, + }, +}; +use alloc::string::String; +use markdown::{ + mdast::{Image, Node}, + message::Message, +}; + +impl Handle for Image { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let quote = check_quote(state)?; + state.enter(ConstructName::Image); + state.enter(ConstructName::Label); + + let mut value = String::new(); + + value.push_str("!["); + + value.push_str(&state.safe(&self.alt, &SafeConfig::new(value.as_str(), "]", None))); + + value.push_str("]("); + state.exit(); + + if self.url.is_empty() && self.title.is_some() || contains_control_or_whitespace(&self.url) + { + state.enter(ConstructName::DestinationLiteral); + value.push('<'); + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, ">", None))); + value.push('>'); + } else { + state.enter(ConstructName::DestinationRaw); + let after = if self.title.is_some() { " " } else { ")" }; + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, after, None))); + } + + state.exit(); + + if let Some(title) = &self.title { + let title_construct_name = if quote == '"' { + ConstructName::TitleQuote + } else { + ConstructName::TitleApostrophe + }; + + state.enter(title_construct_name); + value.push(' '); + value.push(quote); + + let mut before_buffer = [0u8; 4]; + let before = quote.encode_utf8(&mut before_buffer); + value.push_str(&state.safe(title, &SafeConfig::new(&self.url, before, None))); + + value.push(quote); + state.exit(); + } + + value.push(')'); + state.exit(); + + Ok(value) + } +} + +pub fn peek_image() -> char { + '!' +} diff --git a/mdast_util_to_markdown/src/handle/image_reference.rs b/mdast_util_to_markdown/src/handle/image_reference.rs new file mode 100644 index 0000000..02d205c --- /dev/null +++ b/mdast_util_to_markdown/src/handle/image_reference.rs @@ -0,0 +1,63 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/image-reference.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::safe::SafeConfig, +}; +use alloc::string::String; +use core::mem; +use markdown::{ + mdast::{ImageReference, Node, ReferenceKind}, + message::Message, +}; + +impl Handle for ImageReference { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + state.enter(ConstructName::ImageReference); + state.enter(ConstructName::Label); + + let mut value = String::from("!["); + let alt = state.safe(&self.alt, &SafeConfig::new(&value, "]", None)); + + value.push_str(&alt); + value.push_str("]["); + + state.exit(); + + let old_stack = mem::take(&mut state.stack); + state.enter(ConstructName::Reference); + + let reference = state.safe( + &state.association(self), + &SafeConfig::new(&value, "]", None), + ); + + state.exit(); + state.stack = old_stack; + state.exit(); + + if matches!(self.reference_kind, ReferenceKind::Full) || alt.is_empty() || alt != reference + { + value.push_str(&reference); + value.push(']'); + } else if matches!(self.reference_kind, ReferenceKind::Shortcut) { + value.pop(); + } else { + value.push(']'); + } + + Ok(value) + } +} + +pub fn peek_image_reference() -> char { + '!' +} diff --git a/mdast_util_to_markdown/src/handle/inline_code.rs b/mdast_util_to_markdown/src/handle/inline_code.rs new file mode 100644 index 0000000..ccd2439 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/inline_code.rs @@ -0,0 +1,73 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/inline-code.js + +use super::Handle; +use crate::state::{Info, State}; +use alloc::{format, string::String}; +use markdown::{ + mdast::{InlineCode, Node}, + message::Message, +}; +use regex::Regex; + +impl Handle for InlineCode { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let mut value = self.value.clone(); + let mut sequence = String::from('`'); + let mut grave_accent_match = Regex::new(&format!(r"(^|[^`]){}([^`]|$)", sequence)).unwrap(); + while grave_accent_match.is_match(&value) { + sequence.push('`'); + grave_accent_match = Regex::new(&format!(r"(^|[^`]){}([^`]|$)", sequence)).unwrap(); + } + + let no_whitespaces = !value.chars().all(char::is_whitespace); + let starts_with_whitespace = value.starts_with(char::is_whitespace); + let ends_with_whitespace = value.ends_with(char::is_whitespace); + let starts_with_tick = value.starts_with('`'); + let ends_with_tick = value.ends_with('`'); + + if no_whitespaces + && ((starts_with_whitespace && ends_with_whitespace) + || starts_with_tick + || ends_with_tick) + { + value = format!("{}{}{}", ' ', value, ' '); + } + + for pattern in &mut state.r#unsafe { + if !pattern.at_break { + continue; + } + + State::compile_pattern(pattern); + + if let Some(regex) = &pattern.compiled { + while let Some(m) = regex.find(&value) { + let position = m.start(); + + let position = if position > 0 + && &value[position..m.len()] == "\n" + && &value[position - 1..position] == "\r" + { + position - 1 + } else { + position + }; + + value.replace_range(position..m.start() + 1, " "); + } + } + } + + Ok(format!("{}{}{}", sequence, value, sequence)) + } +} + +pub fn peek_inline_code() -> char { + '`' +} diff --git a/mdast_util_to_markdown/src/handle/inline_math.rs b/mdast_util_to_markdown/src/handle/inline_math.rs new file mode 100644 index 0000000..7608599 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/inline_math.rs @@ -0,0 +1,82 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-math/blob/main/lib/index.js#L241 + +use super::Handle; +use crate::state::{Info, State}; +use alloc::format; +use markdown::{ + mdast::{InlineMath, Node}, + message::Message, +}; +use regex::Regex; + +impl Handle for InlineMath { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let mut size: usize = if !state.options.single_dollar_text_math { + 2 + } else { + 1 + }; + + let pattern = format!("(^|[^$]){}([^$]|$)", "\\$".repeat(size)); + let mut dollar_sign_match = Regex::new(&pattern).unwrap(); + while dollar_sign_match.is_match(&self.value) { + size += 1; + let pattern = format!("(^|[^$]){}([^$]|$)", "\\$".repeat(size)); + dollar_sign_match = Regex::new(&pattern).unwrap(); + } + + let sequence = "$".repeat(size); + + let no_whitespaces = !self.value.chars().all(char::is_whitespace); + let starts_with_whitespace = self.value.starts_with(char::is_whitespace); + let ends_with_whitespace = self.value.ends_with(char::is_whitespace); + let starts_with_dollar = self.value.starts_with('$'); + let ends_with_dollar = self.value.ends_with('$'); + + let mut value = self.value.clone(); + if no_whitespaces + && ((starts_with_whitespace && ends_with_whitespace) + || starts_with_dollar + || ends_with_dollar) + { + value = format!(" {} ", value); + } + + for pattern in &mut state.r#unsafe { + if !pattern.at_break { + continue; + } + + State::compile_pattern(pattern); + + if let Some(regex) = &pattern.compiled { + while let Some(m) = regex.find(&value) { + let position = m.start(); + + let position = if position > 0 + && &value[position..m.len()] == "\n" + && &value[position - 1..position] == "\r" + { + position - 1 + } else { + position + }; + + value.replace_range(position..m.start() + 1, " "); + } + } + } + + Ok(format!("{}{}{}", sequence, value, sequence)) + } +} + +pub fn peek_inline_math() -> char { + '$' +} diff --git a/mdast_util_to_markdown/src/handle/link.rs b/mdast_util_to_markdown/src/handle/link.rs new file mode 100644 index 0000000..6cb24c3 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/link.rs @@ -0,0 +1,93 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/link.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::{ + check_quote::check_quote, contains_control_or_whitespace::contains_control_or_whitespace, + format_link_as_auto_link::format_link_as_auto_link, safe::SafeConfig, + }, +}; +use alloc::string::String; +use core::mem; +use markdown::{ + mdast::{Link, Node}, + message::Message, +}; + +impl Handle for Link { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let quote = check_quote(state)?; + + if format_link_as_auto_link(self, node, state) { + let old_stack = mem::take(&mut state.stack); + state.enter(ConstructName::Autolink); + let mut value = String::from("<"); + value.push_str(&state.container_phrasing(node, &Info::new(&value, ">"))?); + value.push('>'); + state.exit(); + state.stack = old_stack; + return Ok(value); + } + + state.enter(ConstructName::Link); + state.enter(ConstructName::Label); + let mut value = String::from("["); + value.push_str(&state.container_phrasing(node, &Info::new(&value, "]("))?); + value.push_str("]("); + state.exit(); + + if self.url.is_empty() && self.title.is_some() || contains_control_or_whitespace(&self.url) + { + state.enter(ConstructName::DestinationLiteral); + value.push('<'); + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, ">", None))); + value.push('>'); + } else { + state.enter(ConstructName::DestinationRaw); + let after = if self.title.is_some() { " " } else { ")" }; + value.push_str(&state.safe(&self.url, &SafeConfig::new(&value, after, None))) + } + + state.exit(); + + if let Some(title) = &self.title { + let title_construct_name = if quote == '"' { + ConstructName::TitleQuote + } else { + ConstructName::TitleApostrophe + }; + + state.enter(title_construct_name); + value.push(' '); + value.push(quote); + + let mut before_buffer = [0u8; 4]; + let before = quote.encode_utf8(&mut before_buffer); + value.push_str(&state.safe(title, &SafeConfig::new(&self.url, before, None))); + + value.push(quote); + state.exit(); + } + + value.push(')'); + state.exit(); + + Ok(value) + } +} + +pub fn peek_link(link: &Link, node: &Node, state: &State) -> char { + if format_link_as_auto_link(link, node, state) { + '>' + } else { + '[' + } +} diff --git a/mdast_util_to_markdown/src/handle/link_reference.rs b/mdast_util_to_markdown/src/handle/link_reference.rs new file mode 100644 index 0000000..2adda03 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/link_reference.rs @@ -0,0 +1,65 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/link-reference.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::safe::SafeConfig, +}; +use alloc::string::String; +use core::mem; +use markdown::{ + mdast::{LinkReference, Node, ReferenceKind}, + message::Message, +}; + +impl Handle for LinkReference { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::LinkReference); + state.enter(ConstructName::Label); + + let mut value = String::from("["); + let text = state.container_phrasing(node, &Info::new(&value, "]"))?; + + value.push_str(&text); + value.push_str("]["); + + state.exit(); + + let old_stack = mem::take(&mut state.stack); + state.enter(ConstructName::Reference); + + let reference = state.safe( + &state.association(self), + &SafeConfig::new(&value, "]", None), + ); + + state.exit(); + state.stack = old_stack; + state.exit(); + + if matches!(self.reference_kind, ReferenceKind::Full) + || text.is_empty() + || text != reference + { + value.push_str(&reference); + value.push(']'); + } else if matches!(self.reference_kind, ReferenceKind::Shortcut) { + value.pop(); + } else { + value.push(']'); + } + + Ok(value) + } +} + +pub fn peek_link_reference() -> char { + '[' +} diff --git a/mdast_util_to_markdown/src/handle/list.rs b/mdast_util_to_markdown/src/handle/list.rs new file mode 100644 index 0000000..ee3ad3a --- /dev/null +++ b/mdast_util_to_markdown/src/handle/list.rs @@ -0,0 +1,99 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/list.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::{ + check_bullet::check_bullet, check_bullet_ordered::check_bullet_ordered, + check_bullet_other::check_bullet_other, check_rule::check_rule, + }, +}; +use markdown::{ + mdast::{List, Node}, + message::Message, +}; + +impl Handle for List { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::List); + let bullet_current = state.bullet_current; + + let mut bullet = if self.ordered { + check_bullet_ordered(state)? + } else { + check_bullet(state)? + }; + + let bullet_other = if self.ordered { + if bullet == '.' { + ')' + } else { + '.' + } + } else { + check_bullet_other(state)? + }; + + let mut use_different_marker = false; + if let Some(bullet_last_used) = state.bullet_last_used { + use_different_marker = bullet == bullet_last_used; + } + + if !self.ordered { + let is_valid_bullet = bullet == '*' || bullet == '-'; + let is_within_bounds = state.stack.len() >= 4 && state.index_stack.len() >= 3; + + let first_list_item_has_no_children = !self.children.is_empty() + && self.children[0] + .children() + .map(|inner| inner.is_empty()) + .expect("There's at least one list item."); + + if is_valid_bullet + && is_within_bounds + && first_list_item_has_no_children + && state.stack[state.stack.len() - 1] == ConstructName::List + && state.stack[state.stack.len() - 2] == ConstructName::ListItem + && state.stack[state.stack.len() - 3] == ConstructName::List + && state.stack[state.stack.len() - 4] == ConstructName::ListItem + && state.index_stack[state.index_stack.len() - 1] == 0 + && state.index_stack[state.index_stack.len() - 2] == 0 + && state.index_stack[state.index_stack.len() - 3] == 0 + { + use_different_marker = true; + } + + if check_rule(state)? == bullet { + for child in self.children.iter() { + if let Some(child_children) = child.children() { + if !child_children.is_empty() + && matches!(child, Node::ListItem(_)) + && matches!(child_children[0], Node::ThematicBreak(_)) + { + use_different_marker = true; + break; + } + } + } + } + } + + if use_different_marker { + bullet = bullet_other; + } + + state.bullet_current = Some(bullet); + let value = state.container_flow(node)?; + state.bullet_last_used = Some(bullet); + state.bullet_current = bullet_current; + state.exit(); + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/list_item.rs b/mdast_util_to_markdown/src/handle/list_item.rs new file mode 100644 index 0000000..0f47b75 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/list_item.rs @@ -0,0 +1,107 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/list-item.js + +use super::Handle; +use crate::{ + configure::IndentOptions, + construct_name::ConstructName, + state::{Info, State}, + util::check_bullet::check_bullet, +}; +use alloc::{ + format, + string::{String, ToString}, +}; +use markdown::{ + mdast::{ListItem, Node}, + message::Message, +}; + +impl Handle for ListItem { + fn handle( + &self, + state: &mut State, + _info: &Info, + parent: Option<&Node>, + node: &Node, + ) -> Result { + let list_item_indent = state.options.list_item_indent; + let mut bullet = state + .bullet_current + .unwrap_or(check_bullet(state)?) + .to_string(); + + if let Some(Node::List(list)) = parent { + if list.ordered { + let bullet_number = if let Some(start) = list.start { + start as usize + } else { + 1 + }; + + if state.options.increment_list_marker { + if let Some(position_node) = list.children.iter().position(|x| *x == *node) { + bullet = format!("{}{}", bullet_number + position_node, bullet); + } + } else { + bullet = format!("{}{}", bullet_number, bullet); + } + } + } + + let mut size = bullet.len() + 1; + + let should_compute_size = match list_item_indent { + IndentOptions::Mixed => { + if let Some(Node::List(list)) = parent { + list.spread || self.spread + } else { + self.spread + } + } + IndentOptions::Tab => true, + _ => false, + }; + + if should_compute_size { + size = compute_size(size); + } + + state.enter(ConstructName::ListItem); + + let value = state.container_flow(node)?; + let value = state.indent_lines(&value, |line, index, blank| { + if index > 0 { + if blank { + String::from(line) + } else { + let blank = " ".repeat(size); + let mut result = String::with_capacity(blank.len() + line.len()); + result.push_str(&blank); + result.push_str(line); + result + } + } else if blank { + let mut result = String::with_capacity(bullet.len() + line.len()); + result.push_str(&bullet); + result.push_str(line); + result + } else { + // size - bullet.len() will never panic because size > bullet.len() always. + let blank = " ".repeat(size - bullet.len()); + let mut result = String::with_capacity(blank.len() + line.len() + bullet.len()); + result.push_str(&bullet); + result.push_str(&blank); + result.push_str(line); + result + } + }); + state.exit(); + + Ok(value) + } +} + +fn compute_size(a: usize) -> usize { + // `a.div_ceil(4)` is `((a + 4 - 1) / 4)` + a.div_ceil(4) * 4 +} diff --git a/mdast_util_to_markdown/src/handle/math.rs b/mdast_util_to_markdown/src/handle/math.rs new file mode 100644 index 0000000..9e3bb73 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/math.rs @@ -0,0 +1,46 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-math/blob/main/lib/index.js#L204 + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::{longest_char_streak::longest_char_streak, safe::SafeConfig}, +}; +use alloc::string::String; +use markdown::{ + mdast::{Math, Node}, + message::Message, +}; + +impl Handle for Math { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let sequence = "$".repeat((longest_char_streak(&self.value, '$') + 1).max(2)); + state.enter(ConstructName::MathFlow); + + let mut value = String::new(); + value.push_str(&sequence); + + if let Some(meta) = &self.meta { + state.enter(ConstructName::MathFlowMeta); + value.push_str(&state.safe(meta, &SafeConfig::new(&value, "\n", Some('$')))); + state.exit(); + } + + value.push('\n'); + + if !self.value.is_empty() { + value.push_str(&self.value); + value.push('\n'); + } + + value.push_str(&sequence); + state.exit(); + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/mod.rs b/mdast_util_to_markdown/src/handle/mod.rs new file mode 100644 index 0000000..8e5e0c0 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/mod.rs @@ -0,0 +1,35 @@ +use crate::{state::Info, State}; +use alloc::string::String; +use markdown::{mdast::Node, message::Message}; + +mod blockquote; +mod r#break; +mod code; +mod definition; +pub mod emphasis; +mod heading; +pub mod html; +pub mod image; +pub mod image_reference; +pub mod inline_code; +pub mod inline_math; +pub mod link; +pub mod link_reference; +mod list; +mod list_item; +mod math; +mod paragraph; +mod root; +pub mod strong; +mod text; +mod thematic_break; + +pub trait Handle { + fn handle( + &self, + state: &mut State, + info: &Info, + parent: Option<&Node>, + node: &Node, + ) -> Result; +} diff --git a/mdast_util_to_markdown/src/handle/paragraph.rs b/mdast_util_to_markdown/src/handle/paragraph.rs new file mode 100644 index 0000000..429e6ca --- /dev/null +++ b/mdast_util_to_markdown/src/handle/paragraph.rs @@ -0,0 +1,28 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/paragraph.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, +}; +use markdown::{ + mdast::{Node, Paragraph}, + message::Message, +}; + +impl Handle for Paragraph { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + state.enter(ConstructName::Paragraph); + state.enter(ConstructName::Phrasing); + let value = state.container_phrasing(node, info)?; + state.exit(); + state.exit(); + Ok(value) + } +} diff --git a/mdast_util_to_markdown/src/handle/root.rs b/mdast_util_to_markdown/src/handle/root.rs new file mode 100644 index 0000000..6ab8a95 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/root.rs @@ -0,0 +1,45 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/root.js + +use super::Handle; +use crate::state::{Info, State}; +use alloc::string::String; +use markdown::{ + mdast::{Node, Root}, + message::Message, +}; + +impl Handle for Root { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let has_phrasing = self.children.iter().any(phrasing); + + if has_phrasing { + state.container_phrasing(node, info) + } else { + state.container_flow(node) + } + } +} + +// JS: . +fn phrasing(child: &Node) -> bool { + // Note: `html` nodes are ambiguous. + matches!( + *child, + Node::Break(_) + | Node::Emphasis(_) + | Node::Image(_) + | Node::ImageReference(_) + | Node::InlineCode(_) + | Node::InlineMath(_) + | Node::Link(_) + | Node::LinkReference(_) + | Node::Strong(_) + | Node::Text(_) + ) +} diff --git a/mdast_util_to_markdown/src/handle/strong.rs b/mdast_util_to_markdown/src/handle/strong.rs new file mode 100644 index 0000000..b4f503b --- /dev/null +++ b/mdast_util_to_markdown/src/handle/strong.rs @@ -0,0 +1,44 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/strong.js + +use super::Handle; +use crate::{ + construct_name::ConstructName, + state::{Info, State}, + util::check_strong::check_strong, +}; +use alloc::format; +use markdown::{ + mdast::{Node, Strong}, + message::Message, +}; + +impl Handle for Strong { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + node: &Node, + ) -> Result { + let marker = check_strong(state)?; + + state.enter(ConstructName::Strong); + + let mut value = format!( + "{}{}{}", + marker, + marker, + state.container_phrasing(node, info)? + ); + value.push(marker); + value.push(marker); + + state.exit(); + + Ok(value) + } +} + +pub fn peek_strong(state: &State) -> char { + state.options.strong +} diff --git a/mdast_util_to_markdown/src/handle/text.rs b/mdast_util_to_markdown/src/handle/text.rs new file mode 100644 index 0000000..b3b116b --- /dev/null +++ b/mdast_util_to_markdown/src/handle/text.rs @@ -0,0 +1,23 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/text.js + +use super::Handle; +use crate::{ + state::{Info, State}, + util::safe::SafeConfig, +}; +use markdown::{ + mdast::{Node, Text}, + message::Message, +}; + +impl Handle for Text { + fn handle( + &self, + state: &mut State, + info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + Ok(state.safe(&self.value, &SafeConfig::new(info.before, info.after, None))) + } +} diff --git a/mdast_util_to_markdown/src/handle/thematic_break.rs b/mdast_util_to_markdown/src/handle/thematic_break.rs new file mode 100644 index 0000000..98da006 --- /dev/null +++ b/mdast_util_to_markdown/src/handle/thematic_break.rs @@ -0,0 +1,35 @@ +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/thematic-break.js + +use super::Handle; +use crate::{ + state::{Info, State}, + util::{check_rule::check_rule, check_rule_repetition::check_rule_repetition}, +}; +use alloc::format; +use markdown::{ + mdast::{Node, ThematicBreak}, + message::Message, +}; + +impl Handle for ThematicBreak { + fn handle( + &self, + state: &mut State, + _info: &Info, + _parent: Option<&Node>, + _node: &Node, + ) -> Result { + let marker = check_rule(state)?; + let space = if state.options.rule_spaces { " " } else { "" }; + let mut value = + format!("{}{}", marker, space).repeat(check_rule_repetition(state)? as usize); + + if state.options.rule_spaces { + // Remove the last space. + value.pop(); + Ok(value) + } else { + Ok(value) + } + } +} diff --git a/mdast_util_to_markdown/src/lib.rs b/mdast_util_to_markdown/src/lib.rs new file mode 100644 index 0000000..8ce47b5 --- /dev/null +++ b/mdast_util_to_markdown/src/lib.rs @@ -0,0 +1,39 @@ +//! API. +//! +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/index.js. + +#![no_std] + +use alloc::string::String; +pub use configure::{IndentOptions, Options}; +use markdown::{mdast::Node, message::Message}; +use state::{Info, State}; + +extern crate alloc; +mod association; +mod configure; +mod construct_name; +mod handle; +mod state; +mod r#unsafe; +mod util; + +/// Turn an mdast syntax tree into markdown. +pub fn to_markdown(tree: &Node) -> Result { + to_markdown_with_options(tree, &Options::default()) +} + +/// Turn an mdast syntax tree, with options, into markdown. +pub fn to_markdown_with_options(tree: &Node, options: &Options) -> Result { + let mut state = State::new(options); + let mut result = state.handle(tree, &Info::new("\n", "\n"), None)?; + + if !result.is_empty() { + let last_char = result.chars().last().unwrap(); + if last_char != '\n' && last_char != '\r' { + result.push('\n'); + } + } + + Ok(result) +} diff --git a/mdast_util_to_markdown/src/state.rs b/mdast_util_to_markdown/src/state.rs new file mode 100644 index 0000000..56648ac --- /dev/null +++ b/mdast_util_to_markdown/src/state.rs @@ -0,0 +1,574 @@ +//! State. +//! +//! JS equivalent: https://github.com/syntax-tree/mdast-util-to-markdown/blob/fd6a508/lib/types.js#L195. + +use crate::{ + association::Association, + construct_name::ConstructName, + handle::{ + emphasis::peek_emphasis, html::peek_html, image::peek_image, + image_reference::peek_image_reference, inline_code::peek_inline_code, + inline_math::peek_inline_math, link::peek_link, link_reference::peek_link_reference, + strong::peek_strong, Handle, + }, + r#unsafe::Unsafe, + util::{ + format_code_as_indented::format_code_as_indented, + format_heading_as_setext::format_heading_as_setext, + pattern_in_scope::pattern_in_scope, + safe::{escape_backslashes, EscapeInfos, SafeConfig}, + }, + Options, +}; +use alloc::{ + boxed::Box, + collections::BTreeMap, + format, + string::{String, ToString}, + vec::Vec, +}; +use markdown::{mdast::Node, message::Message}; +use regex::{Captures, Regex, RegexBuilder}; + +pub struct Info<'a> { + pub after: &'a str, + pub before: &'a str, +} + +#[derive(Debug)] +/// Different ways to join two (container, flow) nodes. +enum Join { + /// Join the two nodes with `1` blank line. + Break, + /// Join the two nodes with an HTML comment. + HtmlComment, + /// Join the two nodes with `d` blank lines. + Lines(usize), +} + +pub struct State<'a> { + pub bullet_current: Option, + pub bullet_last_used: Option, + pub index_stack: Vec, + pub options: &'a Options, + pub stack: Vec, + pub r#unsafe: Vec>, +} + +impl<'a> Info<'a> { + pub fn new(before: &'a str, after: &'a str) -> Self { + Info { after, before } + } +} + +impl<'a> State<'a> { + /// JS equivalent: . + pub fn association(&self, node: &impl Association) -> String { + if node.label().is_some() || node.identifier().is_empty() { + return node.label().clone().unwrap_or_default(); + } + + let character_escape_or_reference = + RegexBuilder::new(r"\\([!-/:-@\[-`{-~])|&(#(?:\d{1,7}|x[\da-f]{1,6})|[\da-z]{1,31});") + .case_insensitive(true) + .build() + .unwrap(); + + character_escape_or_reference + .replace_all(node.identifier(), Self::decode) + .into_owned() + } + + /// JS equivalent: . + fn between(&self, left: &Node, right: &Node, parent: &Node, results: &mut String) { + if self.options.tight_definitions { + Self::set_between(&self.tight_definition(left, right), results) + } else { + Self::set_between(&self.join_defaults(left, right, parent), results) + } + } + + /// JS equivalent: . + pub fn compile_pattern(pattern: &mut Unsafe) { + if pattern.compiled.is_none() { + let mut pattern_to_compile = String::new(); + + if let Some(pattern_before) = pattern.before { + pattern_to_compile.push('('); + if pattern.at_break { + pattern_to_compile.push_str("[\\r\\n][\\t ]*"); + } + pattern_to_compile.push_str("(?:"); + pattern_to_compile.push_str(pattern_before); + pattern_to_compile.push(')'); + pattern_to_compile.push(')'); + } else if pattern.at_break { + pattern_to_compile.push('('); + pattern_to_compile.push_str("[\\r\\n][\\t ]*"); + pattern_to_compile.push(')'); + } + + if matches!( + pattern.character, + '|' | '\\' + | '{' + | '}' + | '(' + | ')' + | '[' + | ']' + | '^' + | '$' + | '+' + | '*' + | '?' + | '.' + | '-' + ) { + pattern_to_compile.push('\\'); + } + + pattern_to_compile.push(pattern.character); + + if let Some(pattern_after) = pattern.after { + pattern_to_compile.push_str("(?:"); + pattern_to_compile.push_str(pattern_after); + pattern_to_compile.push(')'); + } + + pattern.set_compiled( + Regex::new(&pattern_to_compile).expect("A valid unsafe regex pattern"), + ); + } + } + + /// JS equivalent: . + pub fn container_flow(&mut self, parent: &Node) -> Result { + let children = parent.children().expect("The node to be a flow parent."); + + if children.is_empty() { + return Ok(String::new()); + } + + let mut results: String = String::new(); + let mut children_iter = children.iter().peekable(); + let mut index = 0; + + self.index_stack.push(0); + + while let Some(child) = children_iter.next() { + if index > 0 { + let top = self + .index_stack + .last_mut() + .expect("The stack is populated with at least one child position"); + *top = index; + } + + if !matches!(child, Node::List(_)) { + self.bullet_last_used = None; + } + + results.push_str(&self.handle(child, &Info::new("\n", "\n"), Some(parent))?); + + if let Some(next_child) = children_iter.peek() { + self.between(child, next_child, parent, &mut results); + } + + index += 1; + } + + self.index_stack.pop(); + + Ok(results) + } + + /// JS equivalent: . + pub fn container_phrasing(&mut self, parent: &Node, info: &Info) -> Result { + let children = parent + .children() + .expect("The node to be a phrasing parent."); + + if children.is_empty() { + return Ok(String::new()); + } + + let mut results: String = String::new(); + let mut index = 0; + let mut children_iter = children.iter().peekable(); + + self.index_stack.push(0); + + while let Some(child) = children_iter.next() { + if index > 0 { + let top = self + .index_stack + .last_mut() + .expect("The stack is populated with at least one child position"); + *top = index; + } + + let mut new_info = Info::new(info.before, info.after); + let mut buffer = [0u8; 4]; + if let Some(child) = children_iter.peek() { + if let Some(first_char) = self.peek_node(child) { + new_info.after = first_char.encode_utf8(&mut buffer); + } else { + new_info.after = self + .handle(child, &Info::new("", ""), Some(parent))? + .chars() + .nth(0) + .unwrap_or_default() + .encode_utf8(&mut buffer); + } + } + + // In some cases, html (text) can be found in phrasing right after an eol. + // When we’d serialize that, in most cases that would be seen as html + // (flow). + // As we can’t escape or so to prevent it from happening, we take a somewhat + // reasonable approach: replace that eol with a space. + // See: + if !results.is_empty() { + if info.before == "\r" || info.before == "\n" && matches!(child, Node::Html(_)) { + // TODO Remove this check here it might not be needed since we're + // checking for the before info. + if results.ends_with('\n') || results.ends_with('\r') { + results.pop(); + if results.ends_with('\r') { + results.pop(); + } + } + results.push(' '); + new_info.before = " "; + } else { + new_info.before = &results; + } + } + + results.push_str(&self.handle(child, &new_info, Some(parent))?); + index += 1; + } + + self.index_stack.pop(); + + Ok(results) + } + + /// JS equvialent: . + fn decode(caps: &Captures) -> String { + if let Some(first_cap) = caps.get(1) { + return String::from(first_cap.as_str()); + } + + if let Some(head) = &caps[2].chars().nth(0) { + if *head == '#' { + let radix = match caps[2].chars().nth(1) { + Some('x') | Some('X') => 16, + _ => 10, + }; + let capture = &caps[2]; + let numeric_encoded = if radix == 16 { + &capture[2..] + } else { + &capture[1..] + }; + return markdown::decode_numeric(numeric_encoded, radix); + } + } + + markdown::decode_named(&caps[2], true).unwrap_or(caps[0].to_string()) + } + + /// No real JS equivalent, it’s written inline. + fn encode_char(character: char) -> String { + let hex_code = u32::from(character); + format!("&#x{:X};", hex_code) + } + + pub fn enter(&mut self, name: ConstructName) { + self.stack.push(name); + } + + pub fn exit(&mut self) { + self.stack.pop(); + } + + /// No JS equivalent. + pub fn handle( + &mut self, + node: &Node, + info: &Info, + parent: Option<&Node>, + ) -> Result { + match node { + Node::Break(r#break) => r#break.handle(self, info, parent, node), + Node::Blockquote(block_quote) => block_quote.handle(self, info, parent, node), + Node::Code(code) => code.handle(self, info, parent, node), + Node::Definition(definition) => definition.handle(self, info, parent, node), + Node::Emphasis(emphasis) => emphasis.handle(self, info, parent, node), + Node::Heading(heading) => heading.handle(self, info, parent, node), + Node::Html(html) => html.handle(self, info, parent, node), + Node::ImageReference(image_reference) => { + image_reference.handle(self, info, parent, node) + } + Node::Image(image) => image.handle(self, info, parent, node), + Node::InlineCode(inline_code) => inline_code.handle(self, info, parent, node), + Node::LinkReference(link_reference) => link_reference.handle(self, info, parent, node), + Node::Link(link) => link.handle(self, info, parent, node), + Node::ListItem(list_item) => list_item.handle(self, info, parent, node), + Node::List(list) => list.handle(self, info, parent, node), + Node::Paragraph(paragraph) => paragraph.handle(self, info, parent, node), + Node::Root(root) => root.handle(self, info, parent, node), + Node::Strong(strong) => strong.handle(self, info, parent, node), + Node::Text(text) => text.handle(self, info, parent, node), + Node::ThematicBreak(thematic_break) => thematic_break.handle(self, info, parent, node), + Node::Math(math) => math.handle(self, info, parent, node), + Node::InlineMath(inline_math) => inline_math.handle(self, info, parent, node), + _ => Err(Message { + place: None, + reason: format!("Unexpected node type `{:?}`", node), + rule_id: Box::new("unexpected-node".into()), + source: Box::new("mdast-util-to-markdown".into()), + }), + } + } + + /// JS equivalent: . + pub fn indent_lines(&self, value: &str, map: impl Fn(&str, usize, bool) -> String) -> String { + let mut result = String::new(); + let mut start = 0; + let mut line = 0; + let eol = Regex::new(r"\r?\n|\r").unwrap(); + + for m in eol.captures_iter(value) { + let full_match = m.get(0).unwrap(); + let value_slice = &value[start..full_match.start()]; + result.push_str(&map(value_slice, line, value_slice.is_empty())); + result.push_str(full_match.as_str()); + start = full_match.start() + full_match.len(); + line += 1; + } + + result.push_str(&map(&value[start..], line, value.is_empty())); + result + } + + /// No real JS equivalent, but see: + /// . + fn join_defaults(&self, left: &Node, right: &Node, parent: &Node) -> Join { + if let Node::Code(code) = right { + if format_code_as_indented(code, self) && matches!(left, Node::List(_)) { + return Join::HtmlComment; + } + + if let Node::Code(code) = left { + if format_code_as_indented(code, self) { + return Join::HtmlComment; + } + } + } + + if matches!(parent, Node::ListItem(_) | Node::List(_)) { + if matches!(left, Node::Paragraph(_)) { + if matches!(right, Node::Paragraph(_)) { + return Join::Break; + } + + if matches!(right, Node::Definition(_)) { + return Join::Break; + } + + if let Node::Heading(heading) = right { + if format_heading_as_setext(heading, self) { + return Join::Break; + } + } + } + + let spread = if let Node::List(list) = parent { + list.spread + } else if let Node::ListItem(list_item) = parent { + list_item.spread + } else { + false + }; + + if spread { + return Join::Lines(1); + } + + return Join::Lines(0); + } + + Join::Break + } + + /// No JS equivalent. + pub fn new(options: &'a Options) -> Self { + State { + bullet_current: None, + bullet_last_used: None, + index_stack: Vec::new(), + options, + stack: Vec::new(), + r#unsafe: Unsafe::get_default_unsafe(options), + } + } + + /// No JS equivalent. + fn peek_node(&self, node: &Node) -> Option { + match node { + Node::Emphasis(_) => Some(peek_emphasis(self)), + Node::Html(_) => Some(peek_html()), + Node::ImageReference(_) => Some(peek_image_reference()), + Node::Image(_) => Some(peek_image()), + Node::InlineCode(_) => Some(peek_inline_code()), + Node::LinkReference(_) => Some(peek_link_reference()), + Node::Link(link) => Some(peek_link(link, node, self)), + Node::Strong(_) => Some(peek_strong(self)), + Node::InlineMath(_) => Some(peek_inline_math()), + _ => None, + } + } + + /// JS equivalent: . + pub fn safe(&mut self, input: &str, config: &SafeConfig) -> String { + let value = format!("{}{}{}", config.before, input, config.after); + let mut positions: Vec = Vec::new(); + let mut result: String = String::new(); + let mut infos: BTreeMap = BTreeMap::new(); + + for pattern in &mut self.r#unsafe { + if !pattern_in_scope(&self.stack, pattern) { + continue; + } + + Self::compile_pattern(pattern); + + if let Some(regex) = &pattern.compiled { + for m in regex.captures_iter(&value) { + let full_match = m.get(0).expect("Guaranteed to have a match"); + let captured_group_len = m + .get(1) + .map(|captured_group| captured_group.len()) + .unwrap_or(0); + let before = pattern.before.is_some() || pattern.at_break; + let after = pattern.after.is_some(); + let position = full_match.start() + if before { captured_group_len } else { 0 }; + + if positions.contains(&position) { + if let Some(entry) = infos.get_mut(&position) { + if entry.before && !before { + entry.before = false; + } + if entry.after && !after { + entry.after = false; + } + } + } else { + infos.insert(position, EscapeInfos { after, before }); + positions.push(position); + } + } + } + } + + positions.sort_unstable(); + + let mut start = config.before.len(); + let end = value.len() - config.after.len(); + + for (index, position) in positions.iter().enumerate() { + if *position < start || *position >= end { + continue; + } + + // If this character is supposed to be escaped because it has a condition on + // the next character, and the next character is definitly being escaped, + // then skip this escape. + // This will never panic because the bounds are properly checked, and we + // guarantee that the positions are already keys in the `infos` map before this + // point in execution. + if index + 1 < positions.len() + && position + 1 < end + && positions[index + 1] == position + 1 + && infos[position].after + && !infos[&(position + 1)].before + && !infos[&(position + 1)].after + || index > 0 + && positions[index - 1] == position - 1 + && infos[position].before + && !infos[&(position - 1)].before + && !infos[&(position - 1)].after + { + continue; + } + + if start != *position { + result.push_str(&escape_backslashes(&value[start..*position], r"\")); + } + start = *position; + + let char_at_pos = value.chars().nth(*position); + match char_at_pos { + Some('!'..='/') | Some(':'..='@') | Some('['..='`') | Some('{'..='~') => { + if let Some(encode) = &config.encode { + let character = char_at_pos.expect("To be a valid char"); + if *encode != character { + result.push('\\'); + } else { + let encoded_char = Self::encode_char(character); + result.push_str(&encoded_char); + start += character.len_utf8(); + } + } else { + result.push('\\'); + } + } + Some(character) => { + let encoded_char = Self::encode_char(character); + result.push_str(&encoded_char); + start += character.len_utf8(); + } + _ => (), + }; + } + + // Some of the operations above seem to end up right in a utf8 boundary + // (see GH-170 for more info). + // Move back. + while !value.is_char_boundary(start) { + start -= 1; + } + result.push_str(&escape_backslashes(&value[start..end], config.after)); + + result + } + + /// No real JS equivalent, but see: + /// . + fn set_between(join: &Join, results: &mut String) { + if let Join::Break = join { + results.push_str("\n\n"); + } else if let Join::Lines(n) = join { + if *n == 1 { + results.push_str("\n\n"); + return; + } + results.push_str("\n".repeat(1 + n).as_ref()); + } else if let Join::HtmlComment = join { + results.push_str("\n\n\n\n"); + } + } + + /// No real JS equivalent, but see: + /// . + fn tight_definition(&self, left: &Node, right: &Node) -> Join { + if matches!(left, Node::Definition(_)) && matches!(right, Node::Definition(_)) { + return Join::Lines(0); + } + + Join::Break + } +} diff --git a/mdast_util_to_markdown/src/unsafe.rs b/mdast_util_to_markdown/src/unsafe.rs new file mode 100644 index 0000000..062076c --- /dev/null +++ b/mdast_util_to_markdown/src/unsafe.rs @@ -0,0 +1,340 @@ +//! Unsafe patterns. +//! +//! JS equivalent: . +//! Also: . + +use crate::{construct_name::ConstructName, Options}; +use alloc::{vec, vec::Vec}; +use regex::Regex; + +#[derive(Default)] +pub struct Unsafe<'a> { + pub after: Option<&'a str>, + pub at_break: bool, + pub before: Option<&'a str>, + pub character: char, + pub(crate) compiled: Option, + pub in_construct: Vec, + pub not_in_construct: Vec, +} + +impl<'a> Unsafe<'a> { + pub fn new( + character: char, + before: Option<&'a str>, + after: Option<&'a str>, + in_construct: Vec, + not_in_construct: Vec, + at_break: bool, + ) -> Self { + Unsafe { + after, + at_break, + before, + character, + compiled: None, + in_construct, + not_in_construct, + } + } + + pub fn get_default_unsafe(options: &Options) -> Vec { + let full_phrasing_spans = vec![ + ConstructName::Autolink, + ConstructName::DestinationLiteral, + ConstructName::DestinationRaw, + ConstructName::Reference, + ConstructName::TitleApostrophe, + ConstructName::TitleQuote, + ]; + + vec![ + Self::new( + '\t', + None, + "[\\r\\n]".into(), + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + '\t', + "[\\r\\n]".into(), + None, + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + '\t', + None, + None, + vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ], + vec![], + false, + ), + Self::new( + '\r', + None, + None, + vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ConstructName::CodeFencedMetaGraveAccent, + ConstructName::CodeFencedMetaTilde, + ConstructName::DestinationLiteral, + ConstructName::HeadingAtx, + ConstructName::MathFlowMeta, + ], + vec![], + false, + ), + Self::new( + '\n', + None, + None, + vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ConstructName::CodeFencedMetaGraveAccent, + ConstructName::CodeFencedMetaTilde, + ConstructName::DestinationLiteral, + ConstructName::HeadingAtx, + ConstructName::MathFlowMeta, + ], + vec![], + false, + ), + Self::new( + ' ', + None, + "[\\r\\n]".into(), + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + ' ', + "[\\r\\n]".into(), + None, + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + ' ', + None, + None, + vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedLangTilde, + ], + vec![], + false, + ), + Self::new( + '!', + None, + "\\[".into(), + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new( + '\"', + None, + None, + vec![ConstructName::TitleQuote], + vec![], + false, + ), + Self::new('#', None, None, vec![], vec![], true), + Self::new( + '#', + None, + "(?:[\r\n]|$)".into(), + vec![ConstructName::HeadingAtx], + vec![], + false, + ), + Self::new( + '&', + None, + "[#A-Za-z]".into(), + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + '\'', + None, + None, + vec![ConstructName::TitleApostrophe], + vec![], + false, + ), + Self::new( + '(', + None, + None, + vec![ConstructName::DestinationRaw], + vec![], + false, + ), + Self::new( + '(', + "\\]".into(), + None, + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new(')', "\\d+".into(), None, vec![], vec![], true), + Self::new( + ')', + None, + None, + vec![ConstructName::DestinationRaw], + vec![], + false, + ), + Self::new('*', None, "(?:[ \t\r\n*])".into(), vec![], vec![], true), + Self::new( + '*', + None, + None, + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new('+', None, "(?:[ \t\r\n])".into(), vec![], vec![], true), + Self::new('-', None, "(?:[ \t\r\n-])".into(), vec![], vec![], true), + Self::new( + '.', + "\\d+".into(), + "(?:[ \t\r\n]|$)".into(), + vec![], + vec![], + true, + ), + Self::new('<', None, "[!/?A-Za-z]".into(), vec![], vec![], true), + Self::new( + '<', + None, + "[!/?A-Za-z]".into(), + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new( + '<', + None, + None, + vec![ConstructName::DestinationLiteral], + vec![], + false, + ), + Self::new('=', None, None, vec![], vec![], true), + Self::new('>', None, None, vec![], vec![], true), + Self::new( + '>', + None, + None, + vec![ConstructName::DestinationLiteral], + vec![], + false, + ), + Self::new('[', None, None, vec![], vec![], true), + Self::new( + '[', + None, + None, + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new( + '[', + None, + None, + vec![ConstructName::Label, ConstructName::Reference], + vec![], + false, + ), + Self::new( + '\\', + None, + "[\\r\\n]".into(), + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + ']', + None, + None, + vec![ConstructName::Label, ConstructName::Reference], + vec![], + false, + ), + Self::new('_', None, None, vec![], vec![], true), + Self::new( + '_', + None, + None, + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new('`', None, None, vec![], vec![], true), + Self::new( + '`', + None, + None, + vec![ + ConstructName::CodeFencedLangGraveAccent, + ConstructName::CodeFencedMetaGraveAccent, + ], + vec![], + false, + ), + Self::new( + '`', + None, + None, + vec![ConstructName::Phrasing], + full_phrasing_spans.clone(), + false, + ), + Self::new('~', None, None, vec![], vec![], true), + Self::new( + '$', + None, + if options.single_dollar_text_math { + None + } else { + "\\$".into() + }, + vec![ConstructName::Phrasing], + vec![], + false, + ), + Self::new( + '$', + None, + None, + vec![ConstructName::MathFlowMeta], + vec![], + false, + ), + Self::new('$', None, "\\$".into(), vec![], vec![], true), + ] + } + + pub(crate) fn set_compiled(&mut self, regex_pattern: Regex) { + self.compiled = Some(regex_pattern); + } +} diff --git a/mdast_util_to_markdown/src/util/check_bullet.rs b/mdast_util_to_markdown/src/util/check_bullet.rs new file mode 100644 index 0000000..b6af979 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_bullet.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-bullet.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_bullet(state: &mut State) -> Result { + let marker = state.options.bullet; + + if marker != '*' && marker != '+' && marker != '-' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize items with `{}` for `options.bullet`, expected `*`, `+`, or `-`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_bullet_ordered.rs b/mdast_util_to_markdown/src/util/check_bullet_ordered.rs new file mode 100644 index 0000000..b4f3860 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_bullet_ordered.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-bullet-ordered.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_bullet_ordered(state: &mut State) -> Result { + let marker = state.options.bullet_ordered; + + if marker != '.' && marker != ')' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize items with `{}` for `options.bullet_ordered`, expected `.` or `)`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_bullet_other.rs b/mdast_util_to_markdown/src/util/check_bullet_other.rs new file mode 100644 index 0000000..960dbaf --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_bullet_other.rs @@ -0,0 +1,41 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-bullet-other.js + +use super::check_bullet::check_bullet; +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_bullet_other(state: &mut State) -> Result { + let bullet = check_bullet(state)?; + let mut bullet_other = state.options.bullet_other; + + if bullet != '*' { + bullet_other = '*'; + } + + if bullet_other != '*' && bullet_other != '+' && bullet_other != '-' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize items with `{}` for `options.bullet_other`, expected `*`, `+`, or `-`", + bullet_other + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + if bullet_other == bullet { + return Err(Message { + place: None, + reason: format!( + "Expected `bullet` (`{}`) and `bullet_other` (`{}`) to be different", + bullet, bullet_other + ), + rule_id: Box::new("bullet-match-bullet_other".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(bullet_other) +} diff --git a/mdast_util_to_markdown/src/util/check_emphasis.rs b/mdast_util_to_markdown/src/util/check_emphasis.rs new file mode 100644 index 0000000..730b755 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_emphasis.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-emphasis.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_emphasis(state: &State) -> Result { + let marker = state.options.emphasis; + + if marker != '*' && marker != '_' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize emphasis with `{}` for `options.emphasis`, expected `*`, or `_`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_fence.rs b/mdast_util_to_markdown/src/util/check_fence.rs new file mode 100644 index 0000000..5118ce9 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_fence.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-fence.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_fence(state: &mut State) -> Result { + let marker = state.options.fence; + + if marker != '`' && marker != '~' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize code with `{}` for `options.fence`, expected `` ` `` or `~`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_quote.rs b/mdast_util_to_markdown/src/util/check_quote.rs new file mode 100644 index 0000000..e70c270 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_quote.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-quote.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_quote(state: &State) -> Result { + let marker = state.options.quote; + + if marker != '"' && marker != '\'' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize title with `{}` for `options.quote`, expected `\"`, or `'`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_rule.rs b/mdast_util_to_markdown/src/util/check_rule.rs new file mode 100644 index 0000000..a0f1885 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_rule.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-rule.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_rule(state: &State) -> Result { + let marker = state.options.rule; + + if marker != '*' && marker != '-' && marker != '_' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize rules with `{}` for `options.rule`, expected `*`, `-`, or `_`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/check_rule_repetition.rs b/mdast_util_to_markdown/src/util/check_rule_repetition.rs new file mode 100644 index 0000000..90f9c91 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_rule_repetition.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-rule-repetition.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_rule_repetition(state: &State) -> Result { + let repetition = state.options.rule_repetition; + + if repetition < 3 { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize rules with repetition `{}` for `options.rule_repetition`, expected `3` or more", + repetition + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(repetition) +} diff --git a/mdast_util_to_markdown/src/util/check_strong.rs b/mdast_util_to_markdown/src/util/check_strong.rs new file mode 100644 index 0000000..ae315b7 --- /dev/null +++ b/mdast_util_to_markdown/src/util/check_strong.rs @@ -0,0 +1,23 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/check-strong.js + +use crate::state::State; +use alloc::{boxed::Box, format}; +use markdown::message::Message; + +pub fn check_strong(state: &State) -> Result { + let marker = state.options.strong; + + if marker != '*' && marker != '_' { + return Err(Message { + place: None, + reason: format!( + "Cannot serialize strong with `{}` for `options.strong`, expected `*`, or `_`", + marker + ), + rule_id: Box::new("unexpected-marker".into()), + source: Box::new("mdast-util-to-markdown".into()), + }); + } + + Ok(marker) +} diff --git a/mdast_util_to_markdown/src/util/contains_control_or_whitespace.rs b/mdast_util_to_markdown/src/util/contains_control_or_whitespace.rs new file mode 100644 index 0000000..fe7f7a8 --- /dev/null +++ b/mdast_util_to_markdown/src/util/contains_control_or_whitespace.rs @@ -0,0 +1,3 @@ +pub fn contains_control_or_whitespace(value: &str) -> bool { + value.chars().any(|c| c.is_whitespace() || c.is_control()) +} diff --git a/mdast_util_to_markdown/src/util/format_code_as_indented.rs b/mdast_util_to_markdown/src/util/format_code_as_indented.rs new file mode 100644 index 0000000..fd828f5 --- /dev/null +++ b/mdast_util_to_markdown/src/util/format_code_as_indented.rs @@ -0,0 +1,16 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/format-code-as-indented.js + +use crate::state::State; +use markdown::mdast::Code; +use regex::Regex; + +pub fn format_code_as_indented(code: &Code, state: &State) -> bool { + let non_whitespace = code.value.chars().any(|c| !c.is_whitespace()); + let blank = Regex::new(r"^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$").unwrap(); + + !state.options.fences + && !code.value.is_empty() + && code.lang.is_none() + && non_whitespace + && !blank.is_match(&code.value) +} diff --git a/mdast_util_to_markdown/src/util/format_heading_as_setext.rs b/mdast_util_to_markdown/src/util/format_heading_as_setext.rs new file mode 100644 index 0000000..773400b --- /dev/null +++ b/mdast_util_to_markdown/src/util/format_heading_as_setext.rs @@ -0,0 +1,59 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/format-heading-as-setext.js + +use alloc::string::{String, ToString}; +use markdown::mdast::{Heading, Node}; +use regex::Regex; + +use crate::state::State; + +pub fn format_heading_as_setext(heading: &Heading, state: &State) -> bool { + let line_break = Regex::new(r"\r?\n|\r").unwrap(); + let mut literal_with_line_break = false; + + for child in &heading.children { + if include_literal_with_line_break(child, &line_break) { + literal_with_line_break = true; + break; + } + } + + heading.depth < 3 + && !to_string(&heading.children).is_empty() + && (state.options.setext || literal_with_line_break) +} + +/// See: . +fn include_literal_with_line_break(node: &Node, regex: &Regex) -> bool { + match node { + Node::Break(_) => true, + // Literals. + Node::Code(x) => regex.is_match(&x.value), + Node::Html(x) => regex.is_match(&x.value), + Node::InlineCode(x) => regex.is_match(&x.value), + Node::InlineMath(x) => regex.is_match(&x.value), + Node::Math(x) => regex.is_match(&x.value), + Node::MdxFlowExpression(x) => regex.is_match(&x.value), + Node::MdxTextExpression(x) => regex.is_match(&x.value), + Node::MdxjsEsm(x) => regex.is_match(&x.value), + Node::Text(x) => regex.is_match(&x.value), + Node::Toml(x) => regex.is_match(&x.value), + Node::Yaml(x) => regex.is_match(&x.value), + // Anything else. + _ => { + if let Some(children) = node.children() { + for child in children { + if include_literal_with_line_break(child, regex) { + return true; + } + } + } + + false + } + } +} + +/// Tiny version of `mdast-util-to-string`. +fn to_string(children: &[Node]) -> String { + children.iter().map(ToString::to_string).collect() +} diff --git a/mdast_util_to_markdown/src/util/format_link_as_auto_link.rs b/mdast_util_to_markdown/src/util/format_link_as_auto_link.rs new file mode 100644 index 0000000..1809513 --- /dev/null +++ b/mdast_util_to_markdown/src/util/format_link_as_auto_link.rs @@ -0,0 +1,37 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/format-link-as-autolink.js + +use crate::state::State; +use alloc::{format, string::ToString}; +use markdown::mdast::{Link, Node}; +use regex::RegexBuilder; + +pub fn format_link_as_auto_link(link: &Link, node: &Node, state: &State) -> bool { + let raw = node.to_string(); + + if let Some(children) = node.children() { + if children.len() != 1 { + return false; + } + + let mailto = format!("mailto:{}", raw); + let start_with_protocol = RegexBuilder::new("^[a-z][a-z+.-]+:") + .case_insensitive(true) + .build() + .unwrap(); + + return !state.options.resource_link + && !link.url.is_empty() + && link.title.is_none() + && matches!(children[0], Node::Text(_)) + && (raw == link.url || mailto == link.url) + && start_with_protocol.is_match(&link.url) + && is_valid_url(&link.url); + } + + false +} + +fn is_valid_url(url: &str) -> bool { + !url.chars() + .any(|c| c.is_control() || c.is_whitespace() || c == '<' || c == '>') +} diff --git a/mdast_util_to_markdown/src/util/longest_char_streak.rs b/mdast_util_to_markdown/src/util/longest_char_streak.rs new file mode 100644 index 0000000..8b8b94a --- /dev/null +++ b/mdast_util_to_markdown/src/util/longest_char_streak.rs @@ -0,0 +1,46 @@ +//! JS equivalent https://github.com/wooorm/longest-streak/blob/main/index.js + +pub fn longest_char_streak(haystack: &str, needle: char) -> usize { + let mut max = 0; + let mut chars = haystack.chars(); + + while let Some(char) = chars.next() { + if char == needle { + let mut count = 1; + for char in chars.by_ref() { + if char == needle { + count += 1; + } else { + break; + } + } + max = count.max(max); + } + } + + max +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn longest_streak_tests() { + assert_eq!(longest_char_streak("", 'f'), 0); + assert_eq!(longest_char_streak("foo", 'o'), 2); + assert_eq!(longest_char_streak("fo foo fo", 'o'), 2); + assert_eq!(longest_char_streak("fo foo foo", 'o'), 2); + + assert_eq!(longest_char_streak("fo fooo fo", 'o'), 3); + assert_eq!(longest_char_streak("fo fooo foo", 'o'), 3); + assert_eq!(longest_char_streak("ooo", 'o'), 3); + assert_eq!(longest_char_streak("fo fooo fooooo", 'o'), 5); + + assert_eq!(longest_char_streak("fo fooooo fooo", 'o'), 5); + assert_eq!(longest_char_streak("fo fooooo fooooo", 'o'), 5); + + assert_eq!(longest_char_streak("'`'", '`'), 1); + assert_eq!(longest_char_streak("'`'", '`'), 1); + } +} diff --git a/mdast_util_to_markdown/src/util/mod.rs b/mdast_util_to_markdown/src/util/mod.rs new file mode 100644 index 0000000..faffeac --- /dev/null +++ b/mdast_util_to_markdown/src/util/mod.rs @@ -0,0 +1,16 @@ +pub mod check_bullet; +pub mod check_bullet_ordered; +pub mod check_bullet_other; +pub mod check_emphasis; +pub mod check_fence; +pub mod check_quote; +pub mod check_rule; +pub mod check_rule_repetition; +pub mod check_strong; +pub mod contains_control_or_whitespace; +pub mod format_code_as_indented; +pub mod format_heading_as_setext; +pub mod format_link_as_auto_link; +pub mod longest_char_streak; +pub mod pattern_in_scope; +pub mod safe; diff --git a/mdast_util_to_markdown/src/util/pattern_in_scope.rs b/mdast_util_to_markdown/src/util/pattern_in_scope.rs new file mode 100644 index 0000000..13c7821 --- /dev/null +++ b/mdast_util_to_markdown/src/util/pattern_in_scope.rs @@ -0,0 +1,24 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/util/pattern-in-scope.js + +use crate::{construct_name::ConstructName, r#unsafe::Unsafe}; + +/// JS: . +fn list_in_scope(stack: &[ConstructName], list: &[ConstructName], none: bool) -> bool { + if list.is_empty() { + return none; + } + + for construct_name in list { + if stack.contains(construct_name) { + return true; + } + } + + false +} + +/// JS: . +pub fn pattern_in_scope(stack: &[ConstructName], pattern: &Unsafe) -> bool { + list_in_scope(stack, &pattern.in_construct, true) + && !list_in_scope(stack, &pattern.not_in_construct, false) +} diff --git a/mdast_util_to_markdown/src/util/safe.rs b/mdast_util_to_markdown/src/util/safe.rs new file mode 100644 index 0000000..a2dd15e --- /dev/null +++ b/mdast_util_to_markdown/src/util/safe.rs @@ -0,0 +1,49 @@ +//! JS equivalent https://github.com/syntax-tree/mdast-util-to-markdown/blob/fd6a508/lib/util/safe.js + +use alloc::{format, string::String, vec::Vec}; +use regex::Regex; + +pub struct EscapeInfos { + pub after: bool, + pub before: bool, +} + +pub struct SafeConfig<'a> { + pub after: &'a str, + pub before: &'a str, + pub encode: Option, +} + +impl<'a> SafeConfig<'a> { + pub(crate) fn new(before: &'a str, after: &'a str, encode: Option) -> Self { + SafeConfig { + after, + before, + encode, + } + } +} + +/// JS: . +pub fn escape_backslashes(value: &str, after: &str) -> String { + let expression = Regex::new(r"\\[!-/:-@\[-`{-~]").unwrap(); + let mut results: String = String::new(); + let whole = format!("{}{}", value, after); + + let positions: Vec = expression.find_iter(&whole).map(|m| m.start()).collect(); + let mut start = 0; + + for position in &positions { + if start != *position { + results.push_str(&value[start..*position]); + } + + results.push('\\'); + + start = *position; + } + + results.push_str(&value[start..]); + + results +} diff --git a/mdast_util_to_markdown/tests/blockquote.rs b/mdast_util_to_markdown/tests/blockquote.rs new file mode 100644 index 0000000..ac3c8f8 --- /dev/null +++ b/mdast_util_to_markdown/tests/blockquote.rs @@ -0,0 +1,583 @@ +use markdown::mdast::{ + Blockquote, Break, Code, Definition, Emphasis, Heading, Html, Image, ImageReference, + InlineCode, Link, LinkReference, List, ListItem, Node, Paragraph, ReferenceKind, Strong, Text, + ThematicBreak, +}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn block_quote() { + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![], + position: None, + })) + .unwrap(), + ">\n", + "should support a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + })) + .unwrap(), + "> a\n", + "should support a block quote w/ a child" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::ThematicBreak(ThematicBreak { position: None }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None + }), + ], + position: None, + })) + .unwrap(), + "> a\n>\n> ***\n>\n> b\n", + "should support a block quote w/ children" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None + }),], + position: None, + })) + .unwrap(), + "> a\n> b\n", + "should support text w/ a line ending in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Text(Text { + value: String::from("b"), + position: None + }) + ], + position: None + }),], + position: None, + })) + .unwrap(), + "> ab\n", + "should support adjacent texts in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None + }), + Node::Blockquote(Blockquote { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::InlineCode(InlineCode { + value: String::from("b\nc"), + position: None + }), + Node::Text(Text { + value: String::from("\nd"), + position: None + }), + ], + position: None + }), + Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a b"), + position: None + })], + position: None, + depth: 1 + }) + ], + position: None + }), + ], + position: None, + })) + .unwrap(), + "> a\n> b\n>\n> > a\n> > `b\n> > c`\n> > d\n> >\n> > # a b\n", + "should support a block quote in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }) + ], + position: None + }),], + position: None, + })) + .unwrap(), + "> a\\\n> b\n", + "should support a break in a block quote" + ); + + assert_eq!( + to_md_with_opts( + &Node::Blockquote(Blockquote { + children: vec![Node::Code(Code { + value: String::from("a\nb\n\nc"), + position: None, + lang: None, + meta: None + })], + position: None, + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + "> a\n> b\n>\n> c\n", + "should support code (flow, indented) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Code(Code { + value: String::from("c\nd\n\ne"), + position: None, + lang: String::from("a\nb").into(), + meta: None + })], + position: None, + })) + .unwrap(), + "> ```a b\n> c\n> d\n>\n> e\n> ```\n", + "should support code (flow, fenced) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::InlineCode(InlineCode { + value: String::from("b\nc"), + position: None + }), + Node::Text(Text { + value: String::from("\nd"), + position: None + }) + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> `b\n> c`\n> d\n", + "should support code (text) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![ + Node::Definition(Definition { + position: None, + title: Some("e\nf".into()), + url: "c\nd".into(), + identifier: "a\nb".into(), + label: None + }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None + }) + ], + position: None, + })) + .unwrap(), + "> [a\n> b]: \"e\n> f\"\n>\n> a\n> b\n", + "should support a definition in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("c\nd"), + position: None + }),], + position: None + }), + Node::Text(Text { + value: String::from("\nd"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> *c\n> d*\n> d\n", + "should support an emphasis in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + }),], + position: None, + depth: 3 + })], + position: None, + })) + .unwrap(), + "> ### a b\n", + "should support a heading (atx) in a block quote" + ); + + assert_eq!( + to_md_with_opts( + &Node::Blockquote(Blockquote { + children: vec![Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + }),], + position: None, + depth: 1 + })], + position: None, + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "> a\n> b\n> =\n", + "should support a heading (setext) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Html(Html { + value: String::from(""), + position: None + })], + position: None, + })) + .unwrap(), + "> hidden>\n", + "should support html (flow) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Html(Html { + value: String::from(""), + position: None + }), + Node::Text(Text { + value: String::from("\nb"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a hidden>\n> b\n", + "should support html (text) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::Image(Image { + position: None, + alt: String::from("d\ne"), + url: String::from("b\nc"), + title: Some(String::from("f\ng")) + }), + Node::Text(Text { + value: String::from("\nh"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> ![d\n> e]( \"f\n> g\")\n> h\n", + "should support an image (resource) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::ImageReference(ImageReference { + position: None, + alt: String::from("b\nc"), + label: Some(String::from("d\ne")), + reference_kind: ReferenceKind::Collapsed, + identifier: String::from("f"), + }), + Node::Text(Text { + value: String::from("\ng"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> ![b\n> c][d\n> e]\n> g\n", + "should support an image (reference) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("d\ne"), + position: None + })], + position: None, + url: String::from("b\nc"), + title: Some(String::from("f\ng")) + }), + Node::Text(Text { + value: String::from("\nh"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> [d\n> e]( \"f\n> g\")\n> h\n", + "should support a link (resource) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::LinkReference(LinkReference { + children: vec![Node::Text(Text { + value: String::from("b\nc"), + position: None + }),], + position: None, + reference_kind: ReferenceKind::Collapsed, + identifier: String::from("f"), + label: Some(String::from("d\ne")) + }), + Node::Text(Text { + value: String::from("\ng"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> [b\n> c][d\n> e]\n> g\n", + "should support a link (reference) in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None + }), + Node::List(List { + children: vec![ + Node::ListItem(ListItem { + children: vec![Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("c\nd"), + position: None + })], + position: None + })], + position: None, + spread: false, + checked: None + }), + Node::ListItem(ListItem { + children: vec![Node::ThematicBreak(ThematicBreak { position: None })], + position: None, + spread: false, + checked: None + }), + Node::ListItem(ListItem { + children: vec![Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("e\nf"), + position: None + })], + position: None + })], + position: None, + spread: false, + checked: None + }), + ], + position: None, + ordered: false, + start: None, + spread: false + }) + ], + position: None, + })) + .unwrap(), + "> a\n> b\n>\n> - c\n> d\n> - ***\n> - e\n> f\n", + "should support a list in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a\n"), + position: None + }), + Node::Strong(Strong { + children: vec![Node::Text(Text { + value: String::from("c\nd"), + position: None + })], + position: None + }), + Node::Text(Text { + value: String::from("\nd"), + position: None + }), + ], + position: None + })], + position: None, + })) + .unwrap(), + "> a\n> **c\n> d**\n> d\n", + "should support a strong in a block quote" + ); + + assert_eq!( + to(&Node::Blockquote(Blockquote { + children: vec![ + Node::ThematicBreak(ThematicBreak { position: None }), + Node::ThematicBreak(ThematicBreak { position: None }) + ], + position: None, + })) + .unwrap(), + "> ***\n>\n> ***\n", + "should support a thematic break in a block quote" + ); +} diff --git a/mdast_util_to_markdown/tests/break.rs b/mdast_util_to_markdown/tests/break.rs new file mode 100644 index 0000000..68a43c3 --- /dev/null +++ b/mdast_util_to_markdown/tests/break.rs @@ -0,0 +1,72 @@ +use markdown::{ + mdast::{Break, Heading, Node, Text}, + to_mdast as from, +}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn r#break() { + assert_eq!( + to(&Node::Break(Break { position: None })).unwrap(), + "\\\n", + "should support a break" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None, + depth: 3 + })) + .unwrap(), + "### a b\n", + "should serialize breaks in heading (atx) as a space" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![ + Node::Text(Text { + value: String::from("a "), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None, + depth: 3 + })) + .unwrap(), + "### a b\n", + "should serialize breaks in heading (atx) as a space" + ); + + assert_eq!( + to_md_with_opts( + &from("a \nb\n=\n", &Default::default()).unwrap(), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\\\nb\n=\n", + "should serialize breaks in heading (setext)" + ); +} diff --git a/mdast_util_to_markdown/tests/code.rs b/mdast_util_to_markdown/tests/code.rs new file mode 100644 index 0000000..f68c3b6 --- /dev/null +++ b/mdast_util_to_markdown/tests/code.rs @@ -0,0 +1,326 @@ +use markdown::mdast::{Code, Node}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::from("a"), + position: None, + lang: None, + meta: None + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + " a\n", + "should support code w/ a value (indent)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("a"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\na\n```\n", + "should support code w/ a value (fences)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a".to_string()), + meta: None + })) + .unwrap(), + "```a\n```\n", + "should support code w/ a lang" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: None, + meta: Some("a".to_string()) + })) + .unwrap(), + "```\n```\n", + "should support (ignore) code w/ only a meta" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a".to_string()), + meta: Some("b".to_string()) + })) + .unwrap(), + "```a b\n```\n", + "should support code w/ lang and meta" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a b".to_string()), + meta: None + })) + .unwrap(), + "```a b\n```\n", + "should encode a space in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a\nb".to_string()), + meta: None + })) + .unwrap(), + "```a b\n```\n", + "should encode a line ending in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a`b".to_string()), + meta: None + })) + .unwrap(), + "```a`b\n```\n", + "should encode a grave accent in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a\\-b".to_string()), + meta: None + })) + .unwrap(), + "```a\\\\-b\n```\n", + "should escape a backslash in `lang`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a b".to_string()) + })) + .unwrap(), + "```x a b\n```\n", + "should not encode a space in `meta`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a\nb".to_string()) + })) + .unwrap(), + "```x a b\n```\n", + "should encode a line ending in `meta`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a`b".to_string()) + })) + .unwrap(), + "```x a`b\n```\n", + "should encode a grave accent in `meta`" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::new(), + position: None, + lang: Some("x".to_string()), + meta: Some("a\\-b".to_string()) + })) + .unwrap(), + "```x a\\\\-b\n```\n", + "should escape a backslash in `meta`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::new(), + position: None, + lang: None, + meta: None + }), + &Options { + fence: '~', + ..Default::default() + } + ) + .unwrap(), + "~~~\n~~~\n", + "should support fenced code w/ tildes when `fence: \"~\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::new(), + position: None, + lang: Some("a`b".to_string()), + meta: None + }), + &Options { + fence: '~', + ..Default::default() + } + ) + .unwrap(), + "~~~a`b\n~~~\n", + "should not encode a grave accent when using tildes for fences" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("```\nasd\n```"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "````\n```\nasd\n```\n````\n", + "should use more grave accents for fences if there are streaks of grave accents in the value (fences)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::from("~~~\nasd\n~~~"), + position: None, + lang: None, + meta: None + }), + &Options { + fence: '~', + ..Default::default() + } + ) + .unwrap(), + "~~~~\n~~~\nasd\n~~~\n~~~~\n", + "should use more tildes for fences if there are streaks of tildes in the value (fences)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("b"), + position: None, + lang: Some("a".to_string()), + meta: None + })) + .unwrap(), + "```a\nb\n```\n", + "should use a fence if there is an info" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from(" "), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\n \n```\n", + "should use a fence if there is only whitespace" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("\na"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\n\na\n```\n", + "should use a fence if there first line is blank (void)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from(" \na"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\n \na\n```\n", + "should use a fence if there first line is blank (filled)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("a\n"), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\na\n\n```\n", + "should use a fence if there last line is blank (void)" + ); + + assert_eq!( + to(&Node::Code(Code { + value: String::from("a\n "), + position: None, + lang: None, + meta: None + })) + .unwrap(), + "```\na\n \n```\n", + "should use a fence if there last line is blank (filled)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Code(Code { + value: String::from(" a\n\n b"), + position: None, + lang: None, + meta: None + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + " a\n\n b\n", + "should use an indent if the value is indented" + ); +} diff --git a/mdast_util_to_markdown/tests/core.rs b/mdast_util_to_markdown/tests/core.rs new file mode 100644 index 0000000..09c14f5 --- /dev/null +++ b/mdast_util_to_markdown/tests/core.rs @@ -0,0 +1,399 @@ +use markdown::mdast::{ + Break, Code, Definition, Heading, List, ListItem, Node, Paragraph, Root, Text, ThematicBreak, +}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn core() { + assert_eq!( + to(&Node::Root(Root { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::ThematicBreak(ThematicBreak { position: None }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None + }), + ], + position: None + })) + .unwrap(), + "a\n\n***\n\nb\n", + "should support root" + ); + + assert_eq!( + to(&Node::Root(Root { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None + })) + .unwrap(), + "a\\\nb\n", + "should not use blank lines between nodes when given phrasing" + ); + + assert_eq!( + to(&Node::Root(Root { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::Definition(Definition { + position: None, + url: String::new(), + title: None, + identifier: String::from("b"), + label: None + }), + Node::Definition(Definition { + position: None, + url: String::new(), + title: None, + identifier: String::from("c"), + label: None + }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("d"), + position: None + })], + position: None + }), + ], + position: None + })) + .unwrap(), + "a\n\n[b]: <>\n\n[c]: <>\n\nd\n", + "should support adjacent definitions" + ); + + assert_eq!( + to_md_with_opts( + &Node::Root(Root { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::Definition(Definition { + position: None, + url: String::new(), + title: None, + identifier: String::from("b"), + label: None + }), + Node::Definition(Definition { + position: None, + url: String::new(), + title: None, + identifier: String::from("c"), + label: None + }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("d"), + position: None + })], + position: None + }), + ], + position: None + }), + &Options { + tight_definitions: true, + ..Default::default() + } + ) + .unwrap(), + "a\n\n[b]: <>\n[c]: <>\n\nd\n", + "should support tight adjacent definitions when `tight_definitions: true`" + ); + + assert_eq!( + to(&Node::Root(Root { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: false, + start: None, + spread: false + }), + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: false, + start: None, + spread: false + }), + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: true, + start: None, + spread: false + }), + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: true, + start: None, + spread: false + }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("d"), + position: None + })], + position: None + }), + ], + position: None + })) + .unwrap(), + "a\n\n*\n\n-\n\n1.\n\n1)\n\nd\n", + "should use a different marker for adjacent lists" + ); + + assert_eq!( + to_md_with_opts( + &Node::Root(Root { + children: vec![ + Node::Code(Code { + value: String::from("a"), + position: None, + lang: None, + meta: None + }), + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: false, + start: None, + spread: false + }), + Node::Code(Code { + value: String::from("b"), + position: None, + lang: None, + meta: None + }), + ], + position: None + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + " a\n\n*\n\n\n\n b\n", + "should inject HTML comments between lists and an indented code" + ); + + assert_eq!( + to_md_with_opts( + &Node::Root(Root { + children: vec![ + Node::Code(Code { + value: String::from("a"), + position: None, + lang: None, + meta: None + }), + Node::Code(Code { + value: String::from("b"), + position: None, + lang: None, + meta: None + }), + ], + position: None + }), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + " a\n\n\n\n b\n", + "should inject HTML comments between adjacent indented code" + ); + + assert_eq!( + to(&Node::ListItem(ListItem { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None + }), + ], + position: None, + spread: false, + checked: None + })) + .unwrap(), + "* a\n\n b\n", + "should not honour `spread: false` for two paragraphs" + ); + + assert_eq!( + to(&Node::ListItem(ListItem { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::Definition(Definition { + position: None, + url: String::from("d"), + title: None, + identifier: String::from("b"), + label: Some(String::from("c")) + }), + ], + position: None, + spread: false, + checked: None + })) + .unwrap(), + "* a\n\n [c]: d\n", + "should not honour `spread: false` for a paragraph and a definition" + ); + + assert_eq!( + to(&Node::ListItem(ListItem { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None, + depth: 1 + }) + ], + position: None, + spread: false, + checked: None + })) + .unwrap(), + "* a\n # b\n", + "should honour `spread: false` for a paragraph and a heading" + ); + + assert_eq!( + to_md_with_opts( + &Node::ListItem(ListItem { + children: vec![ + Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }), + Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("b"), + position: None + })], + position: None, + depth: 1 + }) + ], + position: None, + spread: false, + checked: None + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "* a\n\n b\n =\n", + "should not honour `spread: false` for a paragraph and a setext heading" + ); +} diff --git a/mdast_util_to_markdown/tests/definition.rs b/mdast_util_to_markdown/tests/definition.rs new file mode 100644 index 0000000..143ea9a --- /dev/null +++ b/mdast_util_to_markdown/tests/definition.rs @@ -0,0 +1,345 @@ +use markdown::mdast::{Definition, Node}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn defintion() { + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: None + })) + .unwrap(), + "[]: <>\n", + "should support a definition w/o label" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("a")) + })) + .unwrap(), + "[a]: <>\n", + "should support a definition w/ label" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("\\")) + })) + .unwrap(), + "[\\\\]: <>\n", + "should escape a backslash in `label`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("[")) + })) + .unwrap(), + "[\\[]: <>\n", + "should escape an opening bracket in `label`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::new(), + position: None, + label: Some(String::from("]")) + })) + .unwrap(), + "[\\]]: <>\n", + "should escape a closing bracket in `label`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <>\n", + "should support a definition w/ identifier" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from(r"\\"), + position: None, + label: None + })) + .unwrap(), + "[\\\\]: <>\n", + "should escape a backslash in `identifier`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from("["), + position: None, + label: None + })) + .unwrap(), + "[\\[]: <>\n", + "should escape an opening bracket in `identifier`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: None, + identifier: String::from("]"), + position: None, + label: None + })) + .unwrap(), + "[\\]]: <>\n", + "should escape a closing bracket in `identifier`" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\n", + "should support a definition w/ url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: \n", + "should support a definition w/ enclosed url w/ whitespace in url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b \n", + "should escape an opening angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b >c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: c>\n", + "should escape a closing angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b \\.c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: \n", + "should escape a backslash in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b\nc"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: \n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("\x0C"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <\x0C>\n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b(c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\\(c\n", + "should escape an opening paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b)c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\\)c\n", + "should escape a closing paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b\\?c"), + title: None, + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b\\\\?c\n", + "should escape a backslash in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: String::from("b").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <> \"b\"\n", + "should support a definition w/ title" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::from("b"), + title: String::from("c").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: b \"c\"\n", + "should support a definition w/ url & title" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: String::from("\"").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <> \"\\\"\"\n", + "should escape a quote in `title` in a title" + ); + + assert_eq!( + to(&Node::Definition(Definition { + url: String::new(), + title: String::from("\\").into(), + identifier: String::from("a"), + position: None, + label: None + })) + .unwrap(), + "[a]: <> \"\\\\\"\n", + "should escape a backslash in `title` in a title" + ); + + assert_eq!( + to_md_with_opts( + &Node::Definition(Definition { + url: String::new(), + title: String::from("b").into(), + identifier: String::from("a"), + position: None, + label: None + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[a]: <> 'b'\n", + "should support a definition w/ title when `quote: \"\'\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Definition(Definition { + url: String::new(), + title: String::from("'").into(), + identifier: String::from("a"), + position: None, + label: None + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[a]: <> '\\''\n", + "should escape a quote in `title` in a title when `quote: \"\'\"`" + ); +} diff --git a/mdast_util_to_markdown/tests/emphasis.rs b/mdast_util_to_markdown/tests/emphasis.rs new file mode 100644 index 0000000..abb30c1 --- /dev/null +++ b/mdast_util_to_markdown/tests/emphasis.rs @@ -0,0 +1,94 @@ +use markdown::mdast::{Emphasis, Node, Paragraph, Text}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn emphasis() { + assert_eq!( + to(&Node::Emphasis(Emphasis { + children: Vec::new(), + position: None + })) + .unwrap(), + "**\n", + "should support an empty emphasis" + ); + + assert_eq!( + to(&Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + })) + .unwrap(), + "*a*\n", + "should support an emphasis w/ children" + ); + + assert_eq!( + to_md_with_opts( + &Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + }), + &Options { + emphasis: '_', + ..Default::default() + } + ) + .unwrap(), + "_a_\n", + "should support an emphasis w/ underscores when `emphasis: \"_\"`" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("𝄞"), + position: None + }), + Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + }) + ], + position: None + })) + .unwrap(), + "𝄞*a*\n", + "should support non-ascii before emphasis" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Emphasis(Emphasis { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + }), + Node::Text(Text { + value: String::from("𝄞"), + position: None + }), + ], + position: None + })) + .unwrap(), + "*a*𝄞\n", + "should support non-ascii after emphasis" + ); +} diff --git a/mdast_util_to_markdown/tests/heading.rs b/mdast_util_to_markdown/tests/heading.rs new file mode 100644 index 0000000..164d3b0 --- /dev/null +++ b/mdast_util_to_markdown/tests/heading.rs @@ -0,0 +1,507 @@ +use markdown::mdast::{Break, Heading, Html, Node, Text}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn heading() { + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 1 + })) + .unwrap(), + "#\n", + "should serialize a heading w/ rank 1" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 6 + })) + .unwrap(), + "######\n", + "should serialize a heading w/ rank 6" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 7 + })) + .unwrap(), + "######\n", + "should serialize a heading w/ rank 7 as 6" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![], + position: None, + depth: 0 + })) + .unwrap(), + "#\n", + "should serialize a heading w/ rank 0 as 1" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a\n", + "should serialize a heading w/ content" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 1 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\n=\n", + "should serialize a heading w/ rank 1 as setext when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 2 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\n-\n", + "should serialize a heading w/ rank 2 as setext when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 3 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "### a\n", + "should serialize a heading w/ rank 3 as atx when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("aa\rb"), + position: None + })], + position: None, + depth: 2 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "aa\rb\n-\n", + "should serialize a setext underline as long as the last line (1)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\r\nbbb"), + position: None + })], + position: None, + depth: 1 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "a\r\nbbb\n===\n", + "should serialize a setext underline as long as the last line (2)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![], + position: None, + depth: 1 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "#\n", + "should serialize an empty heading w/ rank 1 as atx when `setext: true`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![], + position: None, + depth: 2 + }), + &Options { + setext: true, + ..Default::default() + } + ) + .unwrap(), + "##\n", + "should serialize an empty heading w/ rank 1 as atx when `setext: true`" + ); + + //assert_eq!( + // to(&Node::Heading(Heading { + // children: vec![], + // position: None, + // depth: 1 + // }),) + // .unwrap(), + // "`\n`\n=\n", + // "should serialize an heading w/ rank 1 and code w/ a line ending as setext" + //); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Html(Html { + value: "".to_string(), + position: None + })], + position: None, + depth: 1 + }),) + .unwrap(), + "\n==\n", + "should serialize an heading w/ rank 1 and html w/ a line ending as setext" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "a\nb\n=\n", + "should serialize an heading w/ rank 1 and text w/ a line ending as setext" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![ + Node::Text(Text { + value: String::from("a"), + position: None + }), + Node::Break(Break { position: None }), + Node::Text(Text { + value: String::from("b"), + position: None + }), + ], + position: None, + depth: 1 + })) + .unwrap(), + "a\\\nb\n=\n", + "should serialize an heading w/ rank 1 and a break as setext" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![], + position: None, + depth: 1 + }), + &Options { + close_atx: true, + ..Default::default() + } + ) + .unwrap(), + "# #\n", + "should serialize a heading with a closing sequence when `closeAtx` (empty)" + ); + + assert_eq!( + to_md_with_opts( + &Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + depth: 3 + }), + &Options { + close_atx: true, + ..Default::default() + } + ) + .unwrap(), + "### a ###\n", + "should serialize a with a closing sequence when `closeAtx` (content)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("# a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## # a\n", + "should not escape a `#` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("1) a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## 1) a\n", + "should not escape a `1)` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("+ a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## + a\n", + "should not escape a `+` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("- a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## - a\n", + "should not escape a `-` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("= a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## = a\n", + "should not escape a `=` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("> a"), + position: None + })], + position: None, + depth: 2 + })) + .unwrap(), + "## > a\n", + "should not escape a `>` at the start of phrasing in a heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a #"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a \\#\n", + "should escape a `#` at the end of a heading (1)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a ##"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a #\\#\n", + "should escape a `#` at the end of a heading (2)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a # b"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a # b\n", + "should not escape a `#` in a heading (2)" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from(" a"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a\n", + "should encode a space at the start of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("\t\ta"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# \ta\n", + "should encode a tab at the start of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a "), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a \n", + "should encode a space at the end of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a\t\t"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "# a\t \n", + "should encode a tab at the end of an atx heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a \n b"), + position: None + })], + position: None, + depth: 1 + })) + .unwrap(), + "a \n b\n=======\n", + "should encode spaces around a line ending in a setext heading" + ); + + assert_eq!( + to(&Node::Heading(Heading { + children: vec![Node::Text(Text { + value: String::from("a \n b"), + position: None + })], + position: None, + depth: 3 + })) + .unwrap(), + "### a b\n", + "should not need to encode spaces around a line ending in an atx heading (because the line ending is encoded)" + ); +} diff --git a/mdast_util_to_markdown/tests/html.rs b/mdast_util_to_markdown/tests/html.rs new file mode 100644 index 0000000..c59fae9 --- /dev/null +++ b/mdast_util_to_markdown/tests/html.rs @@ -0,0 +1,102 @@ +use markdown::mdast::{Html, Node, Paragraph, Text}; +use mdast_util_to_markdown::to_markdown as to; +use pretty_assertions::assert_eq; + +#[test] +fn html() { + assert_eq!( + to(&Node::Html(Html { + value: String::new(), + position: None + })) + .unwrap(), + "", + "should support an empty html" + ); + + assert_eq!( + to(&Node::Html(Html { + value: String::from("a\nb"), + position: None + })) + .unwrap(), + "a\nb\n", + "should support html" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: "a\n".to_string(), + position: None + }), + Node::Html(Html { + value: "
".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a
\n", + "should prevent html (text) from becoming html (flow) (1)" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: "a\r".to_string(), + position: None + }), + Node::Html(Html { + value: "
".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a
\n", + "should prevent html (text) from becoming html (flow) (2)" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: "a\r\n".to_string(), + position: None + }), + Node::Html(Html { + value: "
".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a
\n", + "should prevent html (text) from becoming html (flow) (3)" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Html(Html { + value: "".to_string(), + position: None + }), + Node::Text(Text { + value: "a".to_string(), + position: None + }) + ], + position: None + })) + .unwrap(), + "a\n", + "should serialize html (text)" + ); +} diff --git a/mdast_util_to_markdown/tests/image.rs b/mdast_util_to_markdown/tests/image.rs new file mode 100644 index 0000000..1c247e6 --- /dev/null +++ b/mdast_util_to_markdown/tests/image.rs @@ -0,0 +1,236 @@ +use markdown::mdast::{Image, Node}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn image() { + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: None + })) + .unwrap(), + "![]()\n", + "should support an image" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::from("a"), + url: String::new(), + title: None + })) + .unwrap(), + "![a]()\n", + "should support `alt`" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("a"), + title: None + })) + .unwrap(), + "![](a)\n", + "should support a url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("a")) + })) + .unwrap(), + "![](<> \"a\")\n", + "should support a title" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("a"), + title: Some(String::from("b")) + })) + .unwrap(), + "![](a \"b\")\n", + "should support a url and title" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b c"), + title: None + })) + .unwrap(), + "![]()\n", + "should support an image w/ enclosed url w/ whitespace in url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b )\n", + "should escape an opening angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b >c"), + title: None + })) + .unwrap(), + "![](c>)\n", + "should escape a closing angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b \\+c"), + title: None + })) + .unwrap(), + "![]()\n", + "should escape a backslash in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b\nc"), + title: None + })) + .unwrap(), + "![]()\n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b(c"), + title: None + })) + .unwrap(), + "![](b\\(c)\n", + "should escape an opening paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b)c"), + title: None + })) + .unwrap(), + "![](b\\)c)\n", + "should escape a closing paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("b\\+c"), + title: None + })) + .unwrap(), + "![](b\\\\+c)\n", + "should escape a backslash in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::from("\x0C"), + title: None + })) + .unwrap(), + "![](<\x0C>)\n", + "should support control characters in images" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("b\"c")) + })) + .unwrap(), + "![](<> \"b\\\"c\")\n", + "should escape a double quote in `title`" + ); + + assert_eq!( + to(&Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("b\\.c")) + })) + .unwrap(), + "![](<> \"b\\\\.c\")\n", + "should escape a backslash in `title`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("b")) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "![](<> 'b')\n", + "should support an image w/ title when `quote: \"\'\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Image(Image { + position: None, + alt: String::new(), + url: String::new(), + title: Some(String::from("'")) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "![](<> '\\'')\n", + "should escape a quote in `title` in a title when `quote: \"\'\"`" + ); +} diff --git a/mdast_util_to_markdown/tests/image_reference.rs b/mdast_util_to_markdown/tests/image_reference.rs new file mode 100644 index 0000000..1575987 --- /dev/null +++ b/mdast_util_to_markdown/tests/image_reference.rs @@ -0,0 +1,165 @@ +use markdown::mdast::{ImageReference, Node, Paragraph, ReferenceKind}; +use mdast_util_to_markdown::to_markdown as to; +use pretty_assertions::assert_eq; + +#[test] +fn image_reference() { + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::new(), + reference_kind: ReferenceKind::Full, + identifier: String::new(), + label: None + })) + .unwrap(), + "![][]\n", + "should support a link reference (nonsensical)" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("a"), + reference_kind: ReferenceKind::Full, + identifier: String::new(), + label: None + })) + .unwrap(), + "![a][]\n", + "should support `alt`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::new(), + reference_kind: ReferenceKind::Full, + identifier: String::from("a"), + label: None + })) + .unwrap(), + "![][a]\n", + "should support an `identifier` (nonsensical)" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::new(), + reference_kind: ReferenceKind::Full, + identifier: String::new(), + label: String::from("a").into() + })) + .unwrap(), + "![][a]\n", + "should support a `label` (nonsensical)" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("A"), + reference_kind: ReferenceKind::Shortcut, + identifier: String::from("A"), + label: None + })) + .unwrap(), + "![A]\n", + "should support `reference_kind: \"ReferenceKind::Shortcut\"`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("A"), + reference_kind: ReferenceKind::Collapsed, + identifier: String::from("A"), + label: None + })) + .unwrap(), + "![A][]\n", + "should support `reference_kind: \"ReferenceKind::Collapsed\"`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("A"), + reference_kind: ReferenceKind::Full, + identifier: String::from("A"), + label: None + })) + .unwrap(), + "![A][A]\n", + "should support `reference_kind: \"ReferenceKind::Full\"`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + alt: String::from("&"), + label: String::from("&").into(), + reference_kind: ReferenceKind::Full, + identifier: String::from("&"), + })) + .unwrap(), + "![&][&]\n", + "should prefer label over identifier" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("&"), + reference_kind: ReferenceKind::Full, + identifier: String::from("&"), + })) + .unwrap(), + "![&][&]\n", + "should decode `identifier` if w/o `label`" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("&a;"), + reference_kind: ReferenceKind::Full, + identifier: String::from("&b;"), + })], + position: None + })) + .unwrap(), + "![\\&a;][&b;]\n", + "should support incorrect character references" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("+"), + reference_kind: ReferenceKind::Full, + identifier: String::from("\\+"), + })) + .unwrap(), + "![+][+]\n", + "should unescape `identifier` if w/o `label`" + ); + + assert_eq!( + to(&Node::ImageReference(ImageReference { + position: None, + label: None, + alt: String::from("a"), + reference_kind: ReferenceKind::Collapsed, + identifier: String::from("b"), + })) + .unwrap(), + "![a][b]\n", + "should use a full reference if w/o `ReferenceKind` and the label does not match the reference" + ); +} diff --git a/mdast_util_to_markdown/tests/inline_code.rs b/mdast_util_to_markdown/tests/inline_code.rs new file mode 100644 index 0000000..99da9a6 --- /dev/null +++ b/mdast_util_to_markdown/tests/inline_code.rs @@ -0,0 +1,186 @@ +use markdown::mdast::{InlineCode, Node}; +use mdast_util_to_markdown::to_markdown as to; +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::new(), + position: None + })) + .unwrap(), + "``\n", + "should support an empty code text" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a"), + position: None + })) + .unwrap(), + "`a`\n", + "should support a code text" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" "), + position: None + })) + .unwrap(), + "` `\n", + "should support a space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("\n"), + position: None + })) + .unwrap(), + "`\n`\n", + "should support an eol" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" "), + position: None + })) + .unwrap(), + "` `\n", + "should support several spaces" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a`b"), + position: None + })) + .unwrap(), + "``a`b``\n", + "should use a fence of two grave accents if the value contains one" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a``b"), + position: None + })) + .unwrap(), + "`a``b`\n", + "should use a fence of one grave accent if the value contains two" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a``b`c"), + position: None + })) + .unwrap(), + "```a``b`c```\n", + "should use a fence of three grave accents if the value contains two and one" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("`a"), + position: None + })) + .unwrap(), + "`` `a ``\n", + "should pad w/ a space if the value starts w/ a grave accent" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a`"), + position: None + })) + .unwrap(), + "`` a` ``\n", + "should pad w/ a space if the value ends w/ a grave accent" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" a "), + position: None + })) + .unwrap(), + "` a `\n", + "should pad w/ a space if the value starts and ends w/ a space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from(" a"), + position: None + })) + .unwrap(), + "` a`\n", + "should not pad w/ spaces if the value ends w/ a non-space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a "), + position: None + })) + .unwrap(), + "`a `\n", + "should not pad w/ spaces if the value starts w/ a non-space" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\n- b"), + position: None + })) + .unwrap(), + "`a - b`\n", + "should prevent breaking out of code (-)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\n#"), + position: None + })) + .unwrap(), + "`a #`\n", + "should prevent breaking out of code (#)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\n1. "), + position: None + })) + .unwrap(), + "`a 1. `\n", + "should prevent breaking out of code (\\d\\.)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\r- b"), + position: None + })) + .unwrap(), + "`a - b`\n", + "should prevent breaking out of code (cr)" + ); + + assert_eq!( + to(&Node::InlineCode(InlineCode { + value: String::from("a\r\n- b"), + position: None + })) + .unwrap(), + "`a - b`\n", + "should prevent breaking out of code (crlf)" + ); +} diff --git a/mdast_util_to_markdown/tests/link.rs b/mdast_util_to_markdown/tests/link.rs new file mode 100644 index 0000000..b8497e9 --- /dev/null +++ b/mdast_util_to_markdown/tests/link.rs @@ -0,0 +1,392 @@ +use markdown::mdast::{Link, Node, Text}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: None + })) + .unwrap(), + "[]()\n", + "should support a link" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + url: String::new(), + title: None + })) + .unwrap(), + "[a]()\n", + "should support children" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("a"), + title: None + })) + .unwrap(), + "[](a)\n", + "should support a url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some(String::from("a")) + })) + .unwrap(), + "[](<> \"a\")\n", + "should support a title" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("a"), + title: Some(String::from("b")) + })) + .unwrap(), + "[](a \"b\")\n", + "should support a url and title" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b c"), + title: None + })) + .unwrap(), + "[]()\n", + "should support a link w/ enclosed url w/ whitespace in url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b )\n", + "should escape an opening angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b >c"), + title: None + })) + .unwrap(), + "[](c>)\n", + "should escape a closing angle bracket in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b \\+c"), + title: None + })) + .unwrap(), + "[]()\n", + "should escape a backslash in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b\nc"), + title: None + })) + .unwrap(), + "[]()\n", + "should encode a line ending in `url` in an enclosed url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b(c"), + title: None + })) + .unwrap(), + "[](b\\(c)\n", + "should escape an opening paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b)c"), + title: None + })) + .unwrap(), + "[](b\\)c)\n", + "should escape a closing paren in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("b\\.c"), + title: None + })) + .unwrap(), + "[](b\\\\.c)\n", + "should escape a backslash in `url` in a raw url" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::from("\x0C"), + title: None + })) + .unwrap(), + "[](<\x0C>)\n", + "should support control characters in links" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some(String::from("b\\-c")) + })) + .unwrap(), + "[](<> \"b\\\\-c\")\n", + "should escape a backslash in `title`" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: None + })) + .unwrap(), + "\n", + "should use an autolink for nodes w/ a value similar to the url and a protocol" + ); + + assert_eq!( + to_md_with_opts( + &Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: None + }), + &Options { + resource_link: true, + ..Default::default() + } + ) + .unwrap(), + "[tel:123](tel:123)\n", + "should use a resource link (`resourceLink: true`)" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None, + url: String::from("a"), + title: None + }),) + .unwrap(), + "[a](a)\n", + "should use a normal link for nodes w/ a value similar to the url w/o a protocol" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: None + }),) + .unwrap(), + "\n", + "should use an autolink for nodes w/ a value similar to the url and a protocol" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("tel:123"), + position: None + })], + position: None, + url: String::from("tel:123"), + title: Some(String::from("a")) + }),) + .unwrap(), + "[tel:123](tel:123 \"a\")\n", + "should use a normal link for nodes w/ a value similar to the url w/ a title" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a@b.c"), + position: None + })], + position: None, + url: String::from("mailto:a@b.c"), + title: None + }),) + .unwrap(), + "\n", + "should use an autolink for nodes w/ a value similar to the url and a protocol (email)" + ); + + assert_eq!( + to(&Node::Link(Link { + children: vec![Node::Text(Text { + value: String::from("a.b-c_d@a.b"), + position: None + })], + position: None, + url: String::from("mailto:a.b-c_d@a.b"), + title: None + }),) + .unwrap(), + "\n", + "should not escape in autolinks" + ); + + assert_eq!( + to_md_with_opts( + &Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some("b".to_string()) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[](<> 'b')\n", + "should support a link w/ title when `quote: \"\'\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Link(Link { + children: Vec::new(), + position: None, + url: String::new(), + title: Some("'".to_string()) + }), + &Options { + quote: '\'', + ..Default::default() + } + ) + .unwrap(), + "[](<> '\\'')\n", + "should escape a quote in `title` in a title when `quote: \"\'\"`'" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: "a b![c](d*e_f[g_h`i".to_string(), + title: None + })) + .unwrap(), + "[]()\n", + "should not escape unneeded characters in a `DestinationLiteral`" + ); + + assert_eq!( + to(&Node::Link(Link { + children: Vec::new(), + position: None, + url: "a![b](c*d_e[f_g`h>(None))), + &Options { + bullet_other: '+', + ..Default::default() + } + ) + .unwrap(), + "* * +\n", + "should support `bullet_other`" + ); + + assert_eq!( + to_md_with_opts( + &create_list(create_list(create_list::>(None))), + &Options { + bullet: '-', + ..Default::default() + } + ) + .unwrap(), + "- - *\n", + "should default to an `bullet_other` different from `bullet` (1)" + ); + + assert_eq!( + to_md_with_opts( + &create_list(create_list(create_list::>(None))), + &Options { + bullet: '*', + ..Default::default() + } + ) + .unwrap(), + "* * -\n", + "should default to an `bullet_other` different from `bullet` (2)" + ); + + assert_eq!( + to(&Node::List(List { + children: vec![ + Node::ListItem(ListItem { + children: vec![Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + }),], + position: None, + spread: false, + checked: None + }), + Node::ListItem(ListItem { + children: vec![Node::ThematicBreak(ThematicBreak { position: None })], + position: None, + spread: false, + checked: None + }) + ], + position: None, + ordered: false, + start: None, + spread: false + })) + .unwrap(), + "- a\n- ***\n", + "should use a different bullet than a thematic rule marker, if the first child of a list item is a thematic break (2)" + ); + + assert_eq!( + to(&create_list(create_list::>(None))).unwrap(), + "* *\n", + "should *not* use a different bullet for an empty list item in two lists" + ); + + assert_eq!( + to(&create_list(create_list(create_list::>(None)))).unwrap(), + "* * -\n", + "should use a different bullet for an empty list item in three lists (1)" + ); + + assert_eq!( + to(&Node::List(List { + children: vec![ + Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + }), + Node::ListItem(ListItem { + children: vec![create_list(create_list::>(None))], + position: None, + spread: false, + checked: None + }) + ], + position: None, + ordered: false, + start: None, + spread: false + })) + .unwrap(), + "*\n* * -\n", + "should use a different bullet for an empty list item in three lists (2)" + ); + + assert_eq!( + to_md_with_opts( + &create_list(create_list(create_list::>(None))), + &Options { + bullet: '+', + ..Default::default() + } + ) + .unwrap(), + "+ + +\n", + "should not use a different bullet for an empty list item in three lists if `bullet` isn’t a thematic rule marker" + ); + + assert_eq!( + to(&create_list(create_list(create_list(create_list::< + Option, + >(None))))) + .unwrap(), + "* * * -\n", + "should use a different bullet for an empty list item in four lists" + ); + + assert_eq!( + to(&create_list(create_list(create_list(create_list( + create_list::>(None) + ))))) + .unwrap(), + "* * * * -\n", + "should use a different bullet for an empty list item in five lists" + ); + + assert_eq!( + to(&create_list(create_list(vec![ + create_list(Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None + })], + position: None + })), + create_list::>(None) + ]))) + .unwrap(), + "* * * a\n -\n", + "should not use a different bullet for an empty list item at non-head in two lists" + ); + + assert_eq!( + to_md_with_opts( + &Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: true, + start: None, + spread: false + }), + &Options { + bullet_ordered: ')', + ..Default::default() + } + ) + .unwrap(), + "1)\n", + "should support `bullet_ordered`" + ); + + assert_eq!( + to_md_with_opts( + &Node::Root(Root { + children: vec![ + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: true, + start: None, + spread: false + }), + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: vec![], + position: None, + spread: false, + checked: None + })], + position: None, + ordered: true, + start: None, + spread: false + }), + ], + position: None + }), + &Options { + bullet_ordered: ')', + ..Default::default() + } + ) + .unwrap(), + "1)\n\n1.\n", + "should use a different bullet for adjacent ordered lists" + ); +} + +trait IntoVecNode { + fn into_vec(self) -> Vec; +} + +impl IntoVecNode for Node { + fn into_vec(self) -> Vec { + vec![self] + } +} + +impl IntoVecNode for Option { + fn into_vec(self) -> Vec { + self.map(|n| vec![n]).unwrap_or_default() + } +} + +impl IntoVecNode for Vec { + fn into_vec(self) -> Vec { + self + } +} + +fn create_list(d: T) -> Node +where + T: IntoVecNode, +{ + Node::List(List { + children: vec![Node::ListItem(ListItem { + children: d.into_vec(), + position: None, + spread: false, + checked: None, + })], + position: None, + ordered: false, + start: None, + spread: false, + }) +} diff --git a/mdast_util_to_markdown/tests/math.rs b/mdast_util_to_markdown/tests/math.rs new file mode 100644 index 0000000..d6c8b3c --- /dev/null +++ b/mdast_util_to_markdown/tests/math.rs @@ -0,0 +1,307 @@ +use markdown::mdast::{Definition, InlineMath, Math, Node, Paragraph, Text}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn math() { + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a"), + position: None + })) + .unwrap(), + "$a$\n", + "should serialize math (text)" + ); + + assert_eq!( + to_md_with_opts( + &Node::InlineMath(InlineMath { + value: String::from("a"), + position: None + }), + &Options { + single_dollar_text_math: false, + ..Default::default() + } + ) + .unwrap(), + "$$a$$\n", + "should serialize math (text) with at least 2 dollars w/ `single_dollar_text_math: false`" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::new(), + position: None + })) + .unwrap(), + "$$\n", + "should serialize math (text) w/o `value`" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a \\$ b"), + position: None + })) + .unwrap(), + "$$a \\$ b$$\n", + "should serialize math (text) w/ two dollar signs when including a dollar" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a \\$"), + position: None + })) + .unwrap(), + "$$ a \\$ $$\n", + "should serialize math (text) w/ padding when ending in a dollar sign" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("$ a"), + position: None + })) + .unwrap(), + "$$ $ a $$\n", + "should serialize math (text) w/ padding when starting in a dollar sign" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from(" a "), + position: None + })) + .unwrap(), + "$ a $\n", + "should pad w/ a space if the value starts and ends w/ a space" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from(" a"), + position: None + })) + .unwrap(), + "$ a$\n", + "should not pad w/ spaces if the value ends w/ a non-space" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a "), + position: None + })) + .unwrap(), + "$a $\n", + "should not pad w/ spaces if the value starts w/ a non-space" + ); + + assert_eq!( + to(&Node::Math(Math { + value: String::from("a"), + position: None, + meta: None + })) + .unwrap(), + "$$\na\n$$\n", + "should serialize math (flow)" + ); + + assert_eq!( + to(&Node::Math(Math { + value: String::new(), + position: None, + meta: None + })) + .unwrap(), + "$$\n$$\n", + "should serialize math (flow) w/o `value`" + ); + + assert_eq!( + to(&Node::Math(Math { + value: String::new(), + position: None, + meta: String::from("a").into() + })) + .unwrap(), + "$$a\n$$\n", + "should serialize math (flow) w/ `meta`" + ); + + assert_eq!( + to(&Node::Math(Math { + value: String::from("$$"), + position: None, + meta: None + })) + .unwrap(), + "$$$\n$$\n$$$\n", + "should serialize math (flow) w/ more dollars than occur together in `value`" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a $ b"), + position: None + })], + position: None + })) + .unwrap(), + "a \\$ b\n", + "should escape `$` in phrasing" + ); + + assert_eq!( + to_md_with_opts( + &Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a $ b"), + position: None + })], + position: None + }), + &Options { + single_dollar_text_math: false, + ..Default::default() + } + ) + .unwrap(), + "a $ b\n", + "should not escape a single dollar in phrasing w/ `single_dollar_text_math: false`'" + ); + + assert_eq!( + to_md_with_opts( + &Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a $$ b"), + position: None + })], + position: None + }), + &Options { + single_dollar_text_math: false, + ..Default::default() + } + ) + .unwrap(), + "a \\$$ b\n", + "should escape two dollars in phrasing w/ `single_dollar_text_math: false`" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![ + Node::Text(Text { + value: String::from("a $"), + position: None + }), + Node::InlineMath(InlineMath { + value: String::from("b"), + position: None + }), + Node::Text(Text { + value: String::from("$ c"), + position: None + }), + ], + position: None + })) + .unwrap(), + "a \\$$b$\\$ c\n", + "should escape `$` around math (text)" + ); + + assert_eq!( + to(&Node::Definition(Definition { + position: None, + url: String::from("b"), + title: String::from("a\n$\nb").into(), + identifier: String::from("a"), + label: String::from("a").into(), + })) + .unwrap(), + "[a]: b \"a\n$\nb\"\n", + "should not escape `$` at the start of a line" + ); + + assert_eq!( + to(&Node::Math(Math { + value: String::new(), + position: None, + meta: String::from("a\rb\nc").into() + })) + .unwrap(), + "$$a b c\n$$\n", + "should escape `\\r`, `\\n` when in `meta` of math (flow)" + ); + + assert_eq!( + to(&Node::Math(Math { + value: String::new(), + position: None, + meta: String::from("a$b").into() + })) + .unwrap(), + "$$a$b\n$$\n", + "should escape `$` when in `meta` of math (flow)" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a\n- b"), + position: None + })) + .unwrap(), + "$a - b$\n", + "should prevent breaking out of code (-)" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a\n#"), + position: None + })) + .unwrap(), + "$a #$\n", + "should prevent breaking out of code (#)" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a\n1. "), + position: None + })) + .unwrap(), + "$a 1. $\n", + "should prevent breaking out of code (\\d\\.)" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a\r- b"), + position: None + })) + .unwrap(), + "$a - b$\n", + "should prevent breaking out of code (cr)" + ); + + assert_eq!( + to(&Node::InlineMath(InlineMath { + value: String::from("a\n- b"), + position: None + })) + .unwrap(), + "$a - b$\n", + "should prevent breaking out of code (crlf)" + ); +} diff --git a/mdast_util_to_markdown/tests/paragraph.rs b/mdast_util_to_markdown/tests/paragraph.rs new file mode 100644 index 0000000..b880103 --- /dev/null +++ b/mdast_util_to_markdown/tests/paragraph.rs @@ -0,0 +1,120 @@ +use markdown::mdast::{Node, Paragraph, Text}; +use mdast_util_to_markdown::to_markdown as to; +use pretty_assertions::assert_eq; + +#[test] +fn paragraph() { + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![], + position: None + })) + .unwrap(), + "", + "should support an empty paragraph" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\nb"), + position: None + })], + position: None + })) + .unwrap(), + "a\nb\n", + "should support a paragraph" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from(" a"), + position: None + })], + position: None + })) + .unwrap(), + " a\n", + "should encode spaces at the start of paragraphs" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a "), + position: None + })], + position: None + })) + .unwrap(), + "a \n", + "should encode spaces at the end of paragraphs" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("\t\ta"), + position: None + })], + position: None + })) + .unwrap(), + " \ta\n", + "should encode tabs at the start of paragraphs" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\t\t"), + position: None + })], + position: None + })) + .unwrap(), + "a\t \n", + "should encode tabs at the end of paragraphs" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a \n b"), + position: None + })], + position: None + })) + .unwrap(), + "a \n b\n", + "should encode spaces around line endings in paragraphs" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("a\t\t\n\t\tb"), + position: None + })], + position: None + })) + .unwrap(), + "a\t \n \tb\n", + "should encode spaces around line endings in paragraphs" + ); + + assert_eq!( + to(&Node::Paragraph(Paragraph { + children: vec![Node::Text(Text { + value: String::from("я_я"), + position: None + })], + position: None + })) + .unwrap(), + "яяя\n", + "should support escaping around non-ascii" + ); +} diff --git a/mdast_util_to_markdown/tests/roundtrip.rs b/mdast_util_to_markdown/tests/roundtrip.rs new file mode 100644 index 0000000..c3402c9 --- /dev/null +++ b/mdast_util_to_markdown/tests/roundtrip.rs @@ -0,0 +1,414 @@ +use markdown::{mdast::Node, to_mdast as from}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn roundtrip() { + let doc: String = document(vec![ + "> * Lorem ipsum dolor sit amet", + ">", + "> * consectetur adipisicing elit", + "", + ]); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = document(vec![ + "* Lorem ipsum dolor sit amet", + "", + " 1. consectetur adipisicing elit", + "", + " 2. sed do eiusmod tempor incididunt", + "", + ]); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = document(vec![ + "* 1. Lorem ipsum dolor sit amet", + "", + " 2. consectetur adipisicing elit", + "", + ]); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = document(vec![ + "* hello", + " * world", + " how", + "", + " are", + " you", + "", + " * today", + "* hi", + "", + ]); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = "An autolink: .\n".to_string(); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = document(vec![ + "A [primary][toString], [secondary][constructor], and [tertiary][__proto__] link.", + "", + "[toString]: http://primary.com", + "", + "[__proto__]: http://tertiary.com", + "", + "[constructor]: http://secondary.com", + "", + ]); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = document(vec![ + "* foo", + "", + "*", + "", + "* bar", + "", + "* baz", + "", + "*", + "", + "* qux quux", + "", + ]); + + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = "* a\n\n\n\n* b\n".to_string(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = document(vec![ + "

Header 3

", + "", + "
", + "

This is a blockquote.

", + " ", + "

This is the second paragraph in the blockquote.

", + " ", + "

This is an H2 in a blockquote

", + "
", + "", + ]); + + assert_eq!( + to_md_with_opts( + &from(&doc, &Default::default()).unwrap(), + &Options { + fences: false, + ..Default::default() + } + ) + .unwrap(), + doc + ); + + let doc: String = "> a\n\n> b\n".to_string(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = "[**https://unifiedjs.com/**](https://unifiedjs.com/)\n".to_string(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let step1 = "\\ \\\\ \\\\\\ \\\\\\\\"; + let step2 = "\\ \\ \\\\\\ \\\\\\\\\n"; + assert_eq!( + to(&from(step1, &Default::default()).unwrap()).unwrap(), + step2 + ); + assert_eq!( + to(&from(step2, &Default::default()).unwrap()).unwrap(), + step2 + ); + + let doc = "\\\\\\*a\n"; + assert_eq!(to(&from(doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc = "\\\\*a\\\\\\*"; + assert_eq!( + remove_pos(&mut from(doc, &Default::default()).unwrap()), + remove_pos( + &mut from( + &to(&from(doc, &Default::default()).unwrap()).unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let doc = "```\n \n```\n"; + assert_eq!(to(&from(doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc = "* * -\n"; + assert_eq!(to(&from(doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc = "- ***\n"; + assert_eq!(to(&from(doc, &Default::default()).unwrap()).unwrap(), doc); + + let mut tree = from("* a\n- b", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos( + &mut from( + &to_md_with_opts( + &tree, + &Options { + bullet: '*', + bullet_other: '-', + ..Default::default() + } + ) + .unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let mut tree = from("* ---\n- - +\n+ b", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos( + &mut from( + &to_md_with_opts( + &tree, + &Options { + bullet: '*', + bullet_other: '-', + ..Default::default() + } + ) + .unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let mut tree = from("- - +\n* ---\n+ b", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos( + &mut from( + &to_md_with_opts( + &tree, + &Options { + bullet: '*', + bullet_other: '-', + ..Default::default() + } + ) + .unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let mut tree = from("- - +\n- -", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos( + &mut from( + &to_md_with_opts( + &tree, + &Options { + bullet: '*', + bullet_other: '-', + ..Default::default() + } + ) + .unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let mut tree = from("* - +\n *\n -\n +", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos( + &mut from( + &to_md_with_opts( + &tree, + &Options { + bullet: '*', + bullet_other: '-', + ..Default::default() + } + ) + .unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let mut tree = from("- +\n- *\n -\n +", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos( + &mut from( + &to_md_with_opts( + &tree, + &Options { + bullet: '*', + bullet_other: '-', + ..Default::default() + } + ) + .unwrap(), + &Default::default() + ) + .unwrap() + ) + ); + + let mut tree = from("1. a\n1) b", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let mut tree = from("1. ---\n1) 1. 1)\n1. b", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let mut tree = from("1. 1. 1)\n1) ---\n1. b", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let mut tree = from("1. 1. 1)\n1. 1.", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let mut tree = from("1. 1) 1.\n 1.\n 1)\n 1.", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let mut tree = from("1. 1) 1.\n 1) 1.\n 1)\n 1.", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let mut tree = from("1. 1)\n1. 1.\n 1)\n 1.", &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut tree), + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()) + ); + + let doc: String = " \n".to_string(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = " \n".to_string(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = " a \n \tb\t \n".to_string(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = "Separate paragraphs: + +a * is this emphasis? * + +a ** is this emphasis? ** + +a *** is this emphasis? *** + +a *\\* is this emphasis? *\\* + +a \\** is this emphasis? \\** + +a **\\* is this emphasis? **\\* + +a *\\** is this emphasis? *\\** + +One paragraph: + +a * is this emphasis? * +a ** is this emphasis? ** +a *** is this emphasis? *** +a *\\* is this emphasis? *\\* +a \\** is this emphasis? \\** +a **\\* is this emphasis? **\\* +a *\\** is this emphasis? *\\**" + .to_string(); + let mut tree = from(&doc, &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()), + remove_pos(&mut tree), + ); + + let doc: String = "Separate paragraphs: + +a _ is this emphasis? _ + +a __ is this emphasis? __ + +a ___ is this emphasis? ___ + +a _\\_ is this emphasis? _\\_ + +a \\__ is this emphasis? \\__ + +a __\\_ is this emphasis? __\\_ + +a _\\__ is this emphasis? _\\__ + +One paragraph: + +a _ is this emphasis? _ +a __ is this emphasis? __ +a ___ is this emphasis? ___ +a _\\_ is this emphasis? _\\_ +a \\__ is this emphasis? \\__ +a __\\_ is this emphasis? __\\_ +a _\\__ is this emphasis? _\\__" + .to_string(); + let mut tree = from(&doc, &Default::default()).unwrap(); + assert_eq!( + remove_pos(&mut from(&to(&tree).unwrap(), &Default::default()).unwrap()), + remove_pos(&mut tree), + ); + + let doc: String = to(&from("(____", &Default::default()).unwrap()).unwrap(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); + + let doc: String = to(&from( + "Once activated, a service worker ______, then transitions to idle…", + &Default::default(), + ) + .unwrap()) + .unwrap(); + assert_eq!(to(&from(&doc, &Default::default()).unwrap()).unwrap(), doc); +} + +fn remove_pos(node: &mut Node) { + node.position_set(None); + if let Some(children) = node.children_mut() { + for child in children { + remove_pos(child); + } + } +} + +fn document(doc: Vec<&str>) -> String { + doc.join("\n") +} diff --git a/mdast_util_to_markdown/tests/strong.rs b/mdast_util_to_markdown/tests/strong.rs new file mode 100644 index 0000000..6babb31 --- /dev/null +++ b/mdast_util_to_markdown/tests/strong.rs @@ -0,0 +1,50 @@ +use markdown::mdast::{Node, Strong, Text}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn strong() { + assert_eq!( + to(&Node::Strong(Strong { + children: Vec::new(), + position: None + })) + .unwrap(), + "****\n", + "should support an empty strong" + ); + + assert_eq!( + to(&Node::Strong(Strong { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + })) + .unwrap(), + "**a**\n", + "should support a strong w/ children" + ); + + assert_eq!( + to_md_with_opts( + &Node::Strong(Strong { + children: vec![Node::Text(Text { + value: String::from("a"), + position: None, + })], + position: None + }), + &Options { + strong: '_', + ..Default::default() + } + ) + .unwrap(), + "__a__\n", + "should support a strong w/ underscores when `emphasis: \"_\"`" + ); +} diff --git a/mdast_util_to_markdown/tests/text.rs b/mdast_util_to_markdown/tests/text.rs new file mode 100644 index 0000000..7021720 --- /dev/null +++ b/mdast_util_to_markdown/tests/text.rs @@ -0,0 +1,26 @@ +use markdown::mdast::{Node, Text}; +use mdast_util_to_markdown::to_markdown as to; +use pretty_assertions::assert_eq; + +#[test] +fn text() { + assert_eq!( + to(&Node::Text(Text { + value: String::new(), + position: None, + })) + .unwrap(), + "", + "should support an empty text" + ); + + assert_eq!( + to(&Node::Text(Text { + value: String::from("a\nb"), + position: None, + })) + .unwrap(), + "a\nb\n", + "should support text" + ); +} diff --git a/mdast_util_to_markdown/tests/thematic_break.rs b/mdast_util_to_markdown/tests/thematic_break.rs new file mode 100644 index 0000000..3c00bf8 --- /dev/null +++ b/mdast_util_to_markdown/tests/thematic_break.rs @@ -0,0 +1,66 @@ +use markdown::mdast::{Node, ThematicBreak}; +use mdast_util_to_markdown::{ + to_markdown as to, to_markdown_with_options as to_md_with_opts, Options, +}; +use pretty_assertions::assert_eq; + +#[test] +fn thematic_break() { + assert_eq!( + to(&Node::ThematicBreak(ThematicBreak { position: None })).unwrap(), + "***\n", + "should support a thematic break" + ); + + assert_eq!( + to_md_with_opts( + &Node::ThematicBreak(ThematicBreak { position: None }), + &Options { + rule: '-', + ..Default::default() + } + ) + .unwrap(), + "---\n", + "should support a thematic break w/ dashes when `rule: \"-\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::ThematicBreak(ThematicBreak { position: None }), + &Options { + rule: '_', + ..Default::default() + } + ) + .unwrap(), + "___\n", + "should support a thematic break w/ underscores when `rule: \"_\"`" + ); + + assert_eq!( + to_md_with_opts( + &Node::ThematicBreak(ThematicBreak { position: None }), + &Options { + rule_repetition: 5, + ..Default::default() + } + ) + .unwrap(), + "*****\n", + "should support a thematic break w/ more repetitions w/ `rule_repetition`" + ); + + assert_eq!( + to_md_with_opts( + &Node::ThematicBreak(ThematicBreak { position: None }), + &Options { + rule_spaces: true, + ..Default::default() + } + ) + .unwrap(), + "* * *\n", + "should support a thematic break w/ spaces w/ `rule_spaces`" + ); +} diff --git a/media/logo-chromatic.svg b/media/logo-chromatic.svg new file mode 100644 index 0000000..7117307 --- /dev/null +++ b/media/logo-chromatic.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media/logo-grayscale-auto.svg b/media/logo-grayscale-auto.svg new file mode 100644 index 0000000..1305ec2 --- /dev/null +++ b/media/logo-grayscale-auto.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/media/logo-grayscale.svg b/media/logo-grayscale.svg new file mode 100644 index 0000000..72bcde6 --- /dev/null +++ b/media/logo-grayscale.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media/logo-monochromatic.svg b/media/logo-monochromatic.svg new file mode 100644 index 0000000..954843e --- /dev/null +++ b/media/logo-monochromatic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f3710ba --- /dev/null +++ b/readme.md @@ -0,0 +1,443 @@ +

+
+ +
+
+
+

+ +# markdown-rs + +[![Build][badge-build-image]][badge-build-url] +[![Coverage][badge-coverage-image]][badge-coverage-url] + +CommonMark compliant markdown parser in Rust with ASTs and extensions. + +## Feature highlights + +* [x] **[compliant][commonmark]** + (100% to CommonMark) +* [x] **[extensions][]** + (100% GFM, 100% MDX, frontmatter, math) +* [x] **[safe][security]** + (100% safe Rust, also 100% safe HTML by default) +* [x] **[robust][test]** + (2300+ tests, 100% coverage, fuzz testing) +* [x] **[ast][mdast]** + (mdast) + +## Links + +* [GitHub: `wooorm/markdown-rs`][repo] +* [`crates.io`: `markdown`][crate] +* [`docs.rs`: `markdown`][docs] + +## When should I use this? + +* if you *just* want to turn markdown into HTML (with maybe a few extensions) +* if you want to do *really complex things* with markdown + +## What is this? + +`markdown-rs` is an open source markdown parser written in Rust. +It’s implemented as a state machine (`#![no_std]` + `alloc`) that emits +concrete tokens, +so that every byte is accounted for, +with positional info. +The API then exposes this information as an AST, +which is easier to work with, +or it compiles directly to HTML. + +While most markdown parsers work towards compliancy with CommonMark (or GFM), +this project goes further by following how the reference parsers (`cmark`, +`cmark-gfm`) work, +which is confirmed with thousands of extra tests. + +Other than CommonMark and GFM, +this project also supports common extensions to markdown such as +MDX, math, and frontmatter. + +This Rust crate has a sibling project in JavaScript: +[`micromark`][micromark] +(and [`mdast-util-from-markdown`][mdast-util-from-markdown] for the AST). + +P.S. if you want to *compile* MDX, +use [`mdxjs-rs`][mdxjs-rs]. + +## Questions + +* to learn markdown, + see this [cheatsheet and tutorial][cheat] +* for the API, + see the [crate docs][docs] +* for questions, + see [Discussions][] +* to help, + see [contribute][] or [sponsor][] below + +## Contents + +* [Install](#install) +* [Use](#use) +* [API](#api) +* [Extensions](#extensions) +* [Project](#project) + * [Overview](#overview) + * [File structure](#file-structure) + * [Test](#test) + * [Version](#version) + * [Security](#security) + * [Contribute](#contribute) + * [Sponsor](#sponsor) + * [Thanks](#thanks) +* [Related](#related) +* [License](#license) + +## Install + +With [Rust][] +(rust edition 2018+, ±version 1.56+), +install with `cargo`: + +```sh +cargo add markdown +``` + +## Use + +```rs +fn main() { + println!("{}", markdown::to_html("## Hi, *Saturn*! 🪐")); +} +``` + +Yields: + +```html +

Hi, Saturn! 🪐

+``` + +Extensions (in this case GFM): + +```rs +fn main() -> Result<(), markdown::message::Message> { + println!( + "{}", + markdown::to_html_with_options( + "* [x] contact ~Mercury~Venus at hi@venus.com!", + &markdown::Options::gfm() + )? + ); + + Ok(()) +} +``` + +Yields: + +```html +
+``` + +Syntax tree ([mdast][]): + +```rs +fn main() -> Result<(), markdown::message::Message> { + println!( + "{:?}", + markdown::to_mdast("# Hi *Earth*!", &markdown::ParseOptions::default())? + ); + + Ok(()) +} +``` + +Yields: + +```text +Root { children: [Heading { children: [Text { value: "Hi ", position: Some(1:3-1:6 (2-5)) }, Emphasis { children: [Text { value: "Earth", position: Some(1:7-1:12 (6-11)) }], position: Some(1:6-1:13 (5-12)) }, Text { value: "!", position: Some(1:13-1:14 (12-13)) }], position: Some(1:1-1:14 (0-13)), depth: 1 }], position: Some(1:1-1:14 (0-13)) } +``` + +## API + +`markdown-rs` exposes +[`to_html`](https://docs.rs/markdown/latest/markdown/fn.to_html.html), +[`to_html_with_options`](https://docs.rs/markdown/latest/markdown/fn.to_html_with_options.html), +[`to_mdast`](https://docs.rs/markdown/latest/markdown/fn.to_mdast.html), +[`Options`](https://docs.rs/markdown/latest/markdown/struct.Options.html), +and a few other structs and enums. + +See the [crate docs][docs] for more info. + +## Extensions + +`markdown-rs` supports extensions to `CommonMark`. +These extensions are maintained in this project. +They are not enabled by default but can be turned on with options. + +* GFM + * autolink literal + * footnote + * strikethrough + * table + * tagfilter + * task list item +* MDX + * ESM + * expressions + * JSX +* frontmatter +* math + +It is not a goal of this project to support lots of different extensions. +It’s instead a goal to support very common and mostly standardized extensions. + +## Project + +`markdown-rs` is maintained as a single monolithic crate. + +### Overview + +The process to parse markdown looks like this: + +```txt + markdown-rs ++-------------------------------------------------+ +| +-------+ +---------+--html- | +| -markdown->+ parse +-events->+ compile + | +| +-------+ +---------+-mdast- | ++-------------------------------------------------+ +``` + +### File structure + +The files in `src/` are as follows: + +* `construct/*.rs` + — CommonMark, GFM, and other extension constructs used in markdown +* `util/*.rs` + — helpers often needed when parsing markdown +* `event.rs` + — things with meaning happening somewhere +* `lib.rs` + — public API +* `mdast.rs` + — syntax tree +* `parser.rs` + — turn a string of markdown into events +* `resolve.rs` + — steps to process events +* `state.rs` + — steps of the state machine +* `subtokenize.rs` + — handle content in other content +* `to_html.rs` + — turns events into a string of HTML +* `to_mdast.rs` + — turns events into a syntax tree +* `tokenizer.rs` + — glue the states of the state machine together +* `unist.rs` + — point and position, used in mdast + +### Test + +`markdown-rs` is tested with the \~650 CommonMark tests and more than 1k extra +tests confirmed with CM reference parsers. +Then there’s even more tests for GFM and other extensions. +These tests reach all branches in the code, +which means that this project has 100% code coverage. +Fuzz testing is used to check for things that might fall through coverage. + +The following bash scripts are useful when working on this project: + +* generate code (latest CM tests and Unicode info): + ```sh + cargo run --manifest-path generate/Cargo.toml + ``` +* run examples: + ```sh + RUST_BACKTRACE=1 RUST_LOG=trace cargo run --example lib --features log + ``` +* format: + ```sh + cargo fmt && cargo fix --all-features --all-targets --workspace + ``` +* lint: + ```sh + cargo fmt --check && cargo clippy --all-features --all-targets --workspace + ``` +* test: + ```sh + RUST_BACKTRACE=1 cargo test --all-features --workspace + ``` +* docs: + ```sh + cargo doc --document-private-items --examples --workspace + ``` +* fuzz: + ```sh + cargo install cargo-fuzz + cargo install honggfuzz + cargo +nightly fuzz run markdown_libfuzz + cargo hfuzz run markdown_honggfuzz + ``` + +### Version + +`markdown-rs` follows [SemVer](https://semver.org). + +### Security + +The typical security aspect discussed for markdown is [cross-site scripting +(XSS)][xss] attacks. +Markdown itself is safe if it does not include embedded HTML or dangerous +protocols in links/images (such as `javascript:`). +`markdown-rs` makes any markdown safe by default, +even if HTML is embedded or dangerous protocols are used, +as it encodes or drops them. + +Turning on the `allow_dangerous_html` or `allow_dangerous_protocol` options for +user-provided markdown opens you up to XSS attacks. + +Additionnally, +you should be able to set `allow_any_img_src` safely. +The default is to allow only `http:`, `https:`, and relative images, +which is what GitHub does. +But it should be safe to allow any value on `src`. + +The [HTML specification][whatwg-html-image] prohibits dangerous scripts in +images and all modern browsers respect this and are thus safe. +Opera 12 (from 2012) is a notable browser that did not respect this. + +An aspect related to XSS for security is syntax errors: +markdown itself has no syntax errors. +Some syntax extensions +(specifically, only MDX) +do include syntax errors. +For that reason, +`to_html_with_options` returns `Result`, +of which the error is a struct indicating where the problem happened, +what occurred, +and what was expected instead. +Make sure to handle your errors when using MDX. + +Another security aspect is DDoS attacks. +For example, +an attacker could throw a 100mb file at `markdown-rs`, +in which case it’s going to take a long while to finish. +It is also possible to crash `markdown-rs` with smaller payloads, +notably when thousands of +links, images, emphasis, or strong +are opened but not closed. +It is wise to cap the accepted size of input (500kb can hold a big book) and to +process content in a different thread so that it can be stopped when needed. + +For more information on markdown sanitation, +see +[`improper-markup-sanitization.md`][improper] by [**@chalker**][chalker]. + +### Contribute + +See [`contributing.md`][contributing] for ways to help. +See [`support.md`][support] for ways to get help. +See [`code-of-conduct.md`][coc] for how to communicate in and around this +project. + +### Sponsor + +Support this effort and give back by sponsoring: + +* [GitHub Sponsors](https://github.com/sponsors/wooorm) + (personal; monthly or one-time) +* [OpenCollective](https://opencollective.com/unified) or + [GitHub Sponsors](https://github.com/sponsors/unifiedjs) + (unified; monthly or one-time) + +### Thanks + +Special thanks go out to: + +* [Vercel][] for funding the initial development +* [**@Murderlon**][murderlon] for the design of the logo +* [**@johannhof**][johannhof] for the crate name + +## Related + +* [`micromark`][micromark] + — same as `markdown-rs` but in JavaScript +* [`mdxjs-rs`][mdxjs-rs] + — wraps `markdown-rs` to *compile* MDX to JavaScript + +## License + +[MIT][license] © [Titus Wormer][author] + +[badge-build-image]: https://github.com/wooorm/markdown-rs/workflows/main/badge.svg + +[badge-build-url]: https://github.com/wooorm/markdown-rs/actions + +[badge-coverage-image]: https://img.shields.io/codecov/c/github/wooorm/markdown-rs.svg + +[badge-coverage-url]: https://codecov.io/github/wooorm/markdown-rs + +[docs]: https://docs.rs/markdown/latest/markdown/ + +[crate]: https://crates.io/crates/markdown + +[repo]: https://github.com/wooorm/markdown-rs + +[discussions]: https://github.com/wooorm/markdown-rs/discussions + +[commonmark]: https://spec.commonmark.org + +[cheat]: https://commonmark.org/help/ + +[rust]: https://www.rust-lang.org + +[xss]: https://en.wikipedia.org/wiki/Cross-site_scripting + +[improper]: https://github.com/ChALkeR/notes/blob/master/Improper-markup-sanitization.md + +[chalker]: https://github.com/ChALkeR + +[license]: license + +[author]: https://wooorm.com + +[mdast]: https://github.com/syntax-tree/mdast + +[micromark]: https://github.com/micromark/micromark + +[mdxjs-rs]: https://github.com/wooorm/mdxjs-rs + +[mdast-util-from-markdown]: https://github.com/syntax-tree/mdast-util-from-markdown + +[vercel]: https://vercel.com + +[murderlon]: https://github.com/murderlon + +[johannhof]: https://github.com/johannhof + +[contribute]: #contribute + +[sponsor]: #sponsor + +[extensions]: #extensions + +[security]: #security + +[test]: #test + +[contributing]: .github/contribute.md + +[support]: .github/support.md + +[coc]: .github/code-of-conduct.md + +[whatwg-html-image]: https://html.spec.whatwg.org/multipage/images.html#images-processing-model diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..50acc6e --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base", ":preserveSemverRanges"] +} diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..4f0281f --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,1543 @@ +use crate::util::{ + line_ending::LineEnding, + mdx::{EsmParse as MdxEsmParse, ExpressionParse as MdxExpressionParse}, +}; +use alloc::{boxed::Box, fmt, string::String}; + +/// Control which constructs are enabled. +/// +/// Not all constructs can be configured. +/// Notably, blank lines and paragraphs cannot be turned off. +/// +/// ## Examples +/// +/// ``` +/// use markdown::Constructs; +/// # fn main() { +/// +/// // Use the default trait to get `CommonMark` constructs: +/// let commonmark = Constructs::default(); +/// +/// // To turn on all of GFM, use the `gfm` method: +/// let gfm = Constructs::gfm(); +/// +/// // Or, mix and match: +/// let custom = Constructs { +/// math_flow: true, +/// math_text: true, +/// ..Constructs::gfm() +/// }; +/// # } +/// ``` +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "camelCase") +)] +pub struct Constructs { + /// Attention. + /// + /// ```markdown + /// > | a *b* c **d**. + /// ^^^ ^^^^^ + /// ``` + pub attention: bool, + /// Autolink. + /// + /// ```markdown + /// > | a b . + /// ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ + /// ``` + pub autolink: bool, + /// Block quote. + /// + /// ```markdown + /// > | > a + /// ^^^ + /// ``` + pub block_quote: bool, + /// Character escape. + /// + /// ```markdown + /// > | a \* b + /// ^^ + /// ``` + pub character_escape: bool, + /// Character reference. + /// + /// ```markdown + /// > | a & b + /// ^^^^^ + /// ``` + pub character_reference: bool, + /// Code (indented). + /// + /// ```markdown + /// > | a + /// ^^^^^ + /// ``` + pub code_indented: bool, + /// Code (fenced). + /// + /// ```markdown + /// > | ~~~js + /// ^^^^^ + /// > | console.log(1) + /// ^^^^^^^^^^^^^^ + /// > | ~~~ + /// ^^^ + /// ``` + pub code_fenced: bool, + /// Code (text). + /// + /// ```markdown + /// > | a `b` c + /// ^^^ + /// ``` + pub code_text: bool, + /// Definition. + /// + /// ```markdown + /// > | [a]: b "c" + /// ^^^^^^^^^^ + /// ``` + pub definition: bool, + /// Frontmatter. + /// + /// ````markdown + /// > | --- + /// ^^^ + /// > | title: Neptune + /// ^^^^^^^^^^^^^^ + /// > | --- + /// ^^^ + /// ```` + pub frontmatter: bool, + /// GFM: autolink literal. + /// + /// ```markdown + /// > | https://example.com + /// ^^^^^^^^^^^^^^^^^^^ + /// ``` + pub gfm_autolink_literal: bool, + /// GFM: footnote definition. + /// + /// ```markdown + /// > | [^a]: b + /// ^^^^^^^ + /// ``` + pub gfm_footnote_definition: bool, + /// GFM: footnote label start. + /// + /// ```markdown + /// > | a[^b] + /// ^^ + /// ``` + pub gfm_label_start_footnote: bool, + /// + /// ```markdown + /// > | a ~b~ c. + /// ^^^ + /// ``` + pub gfm_strikethrough: bool, + /// GFM: table. + /// + /// ```markdown + /// > | | a | + /// ^^^^^ + /// > | | - | + /// ^^^^^ + /// > | | b | + /// ^^^^^ + /// ``` + pub gfm_table: bool, + /// GFM: task list item. + /// + /// ```markdown + /// > | * [x] y. + /// ^^^ + /// ``` + pub gfm_task_list_item: bool, + /// Hard break (escape). + /// + /// ```markdown + /// > | a\ + /// ^ + /// | b + /// ``` + pub hard_break_escape: bool, + /// Hard break (trailing). + /// + /// ```markdown + /// > | a␠␠ + /// ^^ + /// | b + /// ``` + pub hard_break_trailing: bool, + /// Heading (atx). + /// + /// ```markdown + /// > | # a + /// ^^^ + /// ``` + pub heading_atx: bool, + /// Heading (setext). + /// + /// ```markdown + /// > | a + /// ^^ + /// > | == + /// ^^ + /// ``` + pub heading_setext: bool, + /// HTML (flow). + /// + /// ```markdown + /// > |
+ /// ^^^^^ + /// ``` + pub html_flow: bool, + /// HTML (text). + /// + /// ```markdown + /// > | a c + /// ^^^ + /// ``` + pub html_text: bool, + /// Label start (image). + /// + /// ```markdown + /// > | a ![b](c) d + /// ^^ + /// ``` + pub label_start_image: bool, + /// Label start (link). + /// + /// ```markdown + /// > | a [b](c) d + /// ^ + /// ``` + pub label_start_link: bool, + /// Label end. + /// + /// ```markdown + /// > | a [b](c) d + /// ^^^^ + /// ``` + pub label_end: bool, + /// List items. + /// + /// ```markdown + /// > | * a + /// ^^^ + /// ``` + pub list_item: bool, + /// Math (flow). + /// + /// ```markdown + /// > | $$ + /// ^^ + /// > | \frac{1}{2} + /// ^^^^^^^^^^^ + /// > | $$ + /// ^^ + /// ``` + pub math_flow: bool, + /// Math (text). + /// + /// ```markdown + /// > | a $b$ c + /// ^^^ + /// ``` + pub math_text: bool, + /// MDX: ESM. + /// + /// ```markdown + /// > | import a from 'b' + /// ^^^^^^^^^^^^^^^^^ + /// ``` + /// + /// > 👉 **Note**: to support ESM, you *must* pass + /// > [`mdx_esm_parse`][MdxEsmParse] in [`ParseOptions`][] too. + /// > Otherwise, ESM is treated as normal markdown. + pub mdx_esm: bool, + /// MDX: expression (flow). + /// + /// ```markdown + /// > | {Math.PI} + /// ^^^^^^^^^ + /// ``` + /// + /// > 👉 **Note**: You *can* pass + /// > [`mdx_expression_parse`][MdxExpressionParse] in [`ParseOptions`][] + /// > too, to parse expressions according to a certain grammar (typically, + /// > a programming language). + /// > Otherwise, expressions are parsed with a basic algorithm that only + /// > cares about braces. + pub mdx_expression_flow: bool, + /// MDX: expression (text). + /// + /// ```markdown + /// > | a {Math.PI} c + /// ^^^^^^^^^ + /// ``` + /// + /// > 👉 **Note**: You *can* pass + /// > [`mdx_expression_parse`][MdxExpressionParse] in [`ParseOptions`][] + /// > too, to parse expressions according to a certain grammar (typically, + /// > a programming language). + /// > Otherwise, expressions are parsed with a basic algorithm that only + /// > cares about braces. + pub mdx_expression_text: bool, + /// MDX: JSX (flow). + /// + /// ```markdown + /// > | + /// ^^^^^^^^^^^^^ + /// ``` + /// + /// > 👉 **Note**: You *must* pass `html_flow: false` to use this, + /// > as it’s preferred when on over `mdx_jsx_flow`. + /// + /// > 👉 **Note**: You *can* pass + /// > [`mdx_expression_parse`][MdxExpressionParse] in [`ParseOptions`][] + /// > too, to parse expressions in JSX according to a certain grammar + /// > (typically, a programming language). + /// > Otherwise, expressions are parsed with a basic algorithm that only + /// > cares about braces. + pub mdx_jsx_flow: bool, + /// MDX: JSX (text). + /// + /// ```markdown + /// > | a c + /// ^^^^^^^^^^^^^ + /// ``` + /// + /// > 👉 **Note**: You *must* pass `html_text: false` to use this, + /// > as it’s preferred when on over `mdx_jsx_text`. + /// + /// > 👉 **Note**: You *can* pass + /// > [`mdx_expression_parse`][MdxExpressionParse] in [`ParseOptions`][] + /// > too, to parse expressions in JSX according to a certain grammar + /// > (typically, a programming language). + /// > Otherwise, expressions are parsed with a basic algorithm that only + /// > cares about braces. + pub mdx_jsx_text: bool, + /// Thematic break. + /// + /// ```markdown + /// > | *** + /// ^^^ + /// ``` + pub thematic_break: bool, +} + +impl Default for Constructs { + /// `CommonMark`. + /// + /// `CommonMark` is a relatively strong specification of how markdown + /// works. + /// Most markdown parsers try to follow it. + /// + /// For more information, see the `CommonMark` specification: + /// . + fn default() -> Self { + Self { + attention: true, + autolink: true, + block_quote: true, + character_escape: true, + character_reference: true, + code_indented: true, + code_fenced: true, + code_text: true, + definition: true, + frontmatter: false, + gfm_autolink_literal: false, + gfm_label_start_footnote: false, + gfm_footnote_definition: false, + gfm_strikethrough: false, + gfm_table: false, + gfm_task_list_item: false, + hard_break_escape: true, + hard_break_trailing: true, + heading_atx: true, + heading_setext: true, + html_flow: true, + html_text: true, + label_start_image: true, + label_start_link: true, + label_end: true, + list_item: true, + math_flow: false, + math_text: false, + mdx_esm: false, + mdx_expression_flow: false, + mdx_expression_text: false, + mdx_jsx_flow: false, + mdx_jsx_text: false, + thematic_break: true, + } + } +} + +impl Constructs { + /// GFM. + /// + /// GFM stands for **GitHub flavored markdown**. + /// GFM extends `CommonMark` and adds support for autolink literals, + /// footnotes, strikethrough, tables, and tasklists. + /// + /// For more information, see the GFM specification: + /// . + pub fn gfm() -> Self { + Self { + gfm_autolink_literal: true, + gfm_footnote_definition: true, + gfm_label_start_footnote: true, + gfm_strikethrough: true, + gfm_table: true, + gfm_task_list_item: true, + ..Self::default() + } + } + + /// MDX. + /// + /// This turns on `CommonMark`, turns off some conflicting constructs + /// (autolinks, code (indented), and HTML), and turns on MDX (ESM, + /// expressions, and JSX). + /// + /// For more information, see the MDX website: + /// . + /// + /// > 👉 **Note**: to support ESM, you *must* pass + /// > [`mdx_esm_parse`][MdxEsmParse] in [`ParseOptions`][] too. + /// > Otherwise, ESM is treated as normal markdown. + /// > + /// > You *can* pass + /// > [`mdx_expression_parse`][MdxExpressionParse] + /// > to parse expressions according to a certain grammar (typically, a + /// > programming language). + /// > Otherwise, expressions are parsed with a basic algorithm that only + /// > cares about braces. + pub fn mdx() -> Self { + Self { + autolink: false, + code_indented: false, + html_flow: false, + html_text: false, + mdx_esm: true, + mdx_expression_flow: true, + mdx_expression_text: true, + mdx_jsx_flow: true, + mdx_jsx_text: true, + ..Self::default() + } + } +} + +/// Configuration that describes how to compile to HTML. +/// +/// You likely either want to turn on the dangerous options +/// (`allow_dangerous_html`, `allow_dangerous_protocol`) when dealing with +/// input you trust, or want to customize how GFM footnotes are compiled +/// (typically because the input markdown is not in English). +/// +/// ## Examples +/// +/// ``` +/// use markdown::CompileOptions; +/// # fn main() { +/// +/// // Use the default trait to get safe defaults: +/// let safe = CompileOptions::default(); +/// +/// // Live dangerously / trust the author: +/// let danger = CompileOptions { +/// allow_dangerous_html: true, +/// allow_dangerous_protocol: true, +/// ..CompileOptions::default() +/// }; +/// +/// // In French: +/// let enFrançais = CompileOptions { +/// gfm_footnote_back_label: Some("Arrière".into()), +/// gfm_footnote_label: Some("Notes de bas de page".into()), +/// ..CompileOptions::default() +/// }; +/// # } +/// ``` +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Default)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(default, rename_all = "camelCase") +)] +pub struct CompileOptions { + /// Whether to allow all values in images. + /// + /// The default is `false`, + /// which lets `allow_dangerous_protocol` control protocol safety for + /// both links and images. + /// + /// Pass `true` to allow all values as `src` on images, + /// regardless of `allow_dangerous_protocol`. + /// This is safe because the + /// [HTML specification][whatwg-html-image-processing] + /// does not allow executable code in images. + /// + /// [whatwg-html-image-processing]: https://html.spec.whatwg.org/multipage/images.html#images-processing-model + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // By default, some protocols in image sources are dropped: + /// assert_eq!( + /// to_html_with_options( + /// "![](data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==)", + /// &Options::default() + /// )?, + /// "

\"\"

" + /// ); + /// + /// // Turn `allow_any_img_src` on to allow all values as `src` on images. + /// // This is safe because browsers do not execute code in images. + /// assert_eq!( + /// to_html_with_options( + /// "![](javascript:alert(1))", + /// &Options { + /// compile: CompileOptions { + /// allow_any_img_src: true, + /// ..CompileOptions::default() + /// }, + /// ..Options::default() + /// } + /// )?, + /// "

\"\"

" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub allow_any_img_src: bool, + + /// Whether to allow (dangerous) HTML. + /// + /// The default is `false`, which still parses the HTML according to + /// `CommonMark` but shows the HTML as text instead of as elements. + /// + /// Pass `true` for trusted content to get actual HTML elements. + /// + /// When using GFM, make sure to also turn off `gfm_tagfilter`. + /// Otherwise, some dangerous HTML is still ignored. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html, to_html_with_options, CompileOptions, Options}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `markdown-rs` is safe by default: + /// assert_eq!( + /// to_html("Hi, venus!"), + /// "

Hi, <i>venus</i>!

" + /// ); + /// + /// // Turn `allow_dangerous_html` on to allow potentially dangerous HTML: + /// assert_eq!( + /// to_html_with_options( + /// "Hi, venus!", + /// &Options { + /// compile: CompileOptions { + /// allow_dangerous_html: true, + /// ..CompileOptions::default() + /// }, + /// ..Options::default() + /// } + /// )?, + /// "

Hi, venus!

" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub allow_dangerous_html: bool, + + /// Whether to allow dangerous protocols in links and images. + /// + /// The default is `false`, which drops URLs in links and images that use + /// dangerous protocols. + /// + /// Pass `true` for trusted content to support all protocols. + /// + /// URLs that have no protocol (which means it’s relative to the current + /// page, such as `./some/page.html`) and URLs that have a safe protocol + /// (for images: `http`, `https`; for links: `http`, `https`, `irc`, + /// `ircs`, `mailto`, `xmpp`), are safe. + /// All other URLs are dangerous and dropped. + /// + /// When the option `allow_all_protocols_in_img` is enabled, + /// `allow_dangerous_protocol` only applies to links. + /// + /// This is safe because the + /// [HTML specification][whatwg-html-image-processing] + /// does not allow executable code in images. + /// All modern browsers respect this. + /// + /// [whatwg-html-image-processing]: https://html.spec.whatwg.org/multipage/images.html#images-processing-model + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html, to_html_with_options, CompileOptions, Options}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `markdown-rs` is safe by default: + /// assert_eq!( + /// to_html(""), + /// "

javascript:alert(1)

" + /// ); + /// + /// // Turn `allow_dangerous_protocol` on to allow potentially dangerous protocols: + /// assert_eq!( + /// to_html_with_options( + /// "", + /// &Options { + /// compile: CompileOptions { + /// allow_dangerous_protocol: true, + /// ..CompileOptions::default() + /// }, + /// ..Options::default() + /// } + /// )?, + /// "

javascript:alert(1)

" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub allow_dangerous_protocol: bool, + + // To do: `doc_markdown` is broken. + #[allow(clippy::doc_markdown)] + /// Default line ending to use when compiling to HTML, for line endings not + /// in `value`. + /// + /// Generally, `markdown-rs` copies line endings (`\r`, `\n`, `\r\n`) in + /// the markdown document over to the compiled HTML. + /// In some cases, such as `> a`, CommonMark requires that extra line + /// endings are added: `
\n

a

\n
`. + /// + /// To create that line ending, the document is checked for the first line + /// ending that is used. + /// If there is no line ending, `default_line_ending` is used. + /// If that isn’t configured, `\n` is used. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html, to_html_with_options, CompileOptions, LineEnding, Options}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `markdown-rs` uses `\n` by default: + /// assert_eq!( + /// to_html("> a"), + /// "
\n

a

\n
" + /// ); + /// + /// // Define `default_line_ending` to configure the default: + /// assert_eq!( + /// to_html_with_options( + /// "> a", + /// &Options { + /// compile: CompileOptions { + /// default_line_ending: LineEnding::CarriageReturnLineFeed, + /// ..CompileOptions::default() + /// }, + /// ..Options::default() + /// } + /// )?, + /// "
\r\n

a

\r\n
" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub default_line_ending: LineEnding, + + /// Textual label to describe the backreference back to footnote calls. + /// + /// The default value is `"Back to content"`. + /// Change it when the markdown is not in English. + /// + /// This label is used in the `aria-label` attribute on each backreference + /// (the `↩` links). + /// It affects users of assistive technology. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `"Back to content"` is used by default: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options::gfm() + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// + /// // Pass `gfm_footnote_back_label` to use something else: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options { + /// parse: ParseOptions::gfm(), + /// compile: CompileOptions { + /// gfm_footnote_back_label: Some("Arrière".into()), + /// ..CompileOptions::gfm() + /// } + /// } + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub gfm_footnote_back_label: Option, + + /// Prefix to use before the `id` attribute on footnotes to prevent them + /// from *clobbering*. + /// + /// The default is `"user-content-"`. + /// Pass `Some("".into())` for trusted markdown and when you are careful + /// with polyfilling. + /// You could pass a different prefix. + /// + /// DOM clobbering is this: + /// + /// ```html + ///

+ /// + /// ``` + /// + /// The above example shows that elements are made available by browsers, + /// by their ID, on the `window` object. + /// This is a security risk because you might be expecting some other + /// variable at that place. + /// It can also break polyfills. + /// Using a prefix solves these problems. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `"user-content-"` is used by default: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options::gfm() + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// + /// // Pass `gfm_footnote_clobber_prefix` to use something else: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options { + /// parse: ParseOptions::gfm(), + /// compile: CompileOptions { + /// gfm_footnote_clobber_prefix: Some("".into()), + /// ..CompileOptions::gfm() + /// } + /// } + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub gfm_footnote_clobber_prefix: Option, + + /// Attributes to use on the footnote label. + /// + /// The default value is `"class=\"sr-only\""`. + /// Change it to show the label and add other attributes. + /// + /// This label is typically hidden visually (assuming a `sr-only` CSS class + /// is defined that does that), and thus affects screen readers only. + /// If you do have such a class, but want to show this section to everyone, + /// pass an empty string. + /// You can also add different attributes. + /// + /// > 👉 **Note**: `id="footnote-label"` is always added, because footnote + /// > calls use it with `aria-describedby` to provide an accessible label. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `"class=\"sr-only\""` is used by default: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options::gfm() + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// + /// // Pass `gfm_footnote_label_attributes` to use something else: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options { + /// parse: ParseOptions::gfm(), + /// compile: CompileOptions { + /// gfm_footnote_label_attributes: Some("class=\"footnote-heading\"".into()), + /// ..CompileOptions::gfm() + /// } + /// } + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub gfm_footnote_label_attributes: Option, + + /// HTML tag name to use for the footnote label element. + /// + /// The default value is `"h2"`. + /// Change it to match your document structure. + /// + /// This label is typically hidden visually (assuming a `sr-only` CSS class + /// is defined that does that), and thus affects screen readers only. + /// If you do have such a class, but want to show this section to everyone, + /// pass different attributes with the `gfm_footnote_label_attributes` + /// option. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `"h2"` is used by default: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options::gfm() + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// + /// // Pass `gfm_footnote_label_tag_name` to use something else: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options { + /// parse: ParseOptions::gfm(), + /// compile: CompileOptions { + /// gfm_footnote_label_tag_name: Some("h1".into()), + /// ..CompileOptions::gfm() + /// } + /// } + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub gfm_footnote_label_tag_name: Option, + + /// Textual label to use for the footnotes section. + /// + /// The default value is `"Footnotes"`. + /// Change it when the markdown is not in English. + /// + /// This label is typically hidden visually (assuming a `sr-only` CSS class + /// is defined that does that), and thus affects screen readers only. + /// If you do have such a class, but want to show this section to everyone, + /// pass different attributes with the `gfm_footnote_label_attributes` + /// option. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // `"Footnotes"` is used by default: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options::gfm() + /// )?, + /// "

1

\n

Footnotes

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// + /// // Pass `gfm_footnote_label` to use something else: + /// assert_eq!( + /// to_html_with_options( + /// "[^a]\n\n[^a]: b", + /// &Options { + /// parse: ParseOptions::gfm(), + /// compile: CompileOptions { + /// gfm_footnote_label: Some("Notes de bas de page".into()), + /// ..CompileOptions::gfm() + /// } + /// } + /// )?, + /// "

1

\n

Notes de bas de page

\n
    \n
  1. \n

    b

    \n
  2. \n
\n
\n" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub gfm_footnote_label: Option, + + /// Whether or not GFM task list html `` items are enabled. + /// + /// This determines whether or not the user of the browser is able + /// to click and toggle generated checkbox items. The default is false. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // With `gfm_task_list_item_checkable`, generated `` + /// // tags do not contain the attribute `disabled=""` and are thus toggleable by + /// // browser users. + /// assert_eq!( + /// to_html_with_options( + /// "* [x] y.", + /// &Options { + /// parse: ParseOptions::gfm(), + /// compile: CompileOptions { + /// gfm_task_list_item_checkable: true, + /// ..CompileOptions::gfm() + /// } + /// } + /// )?, + /// "
    \n
  • y.
  • \n
" + /// ); + /// # Ok(()) + /// # } + /// ``` + pub gfm_task_list_item_checkable: bool, + + /// Whether to support the GFM tagfilter. + /// + /// This option does nothing if `allow_dangerous_html` is not turned on. + /// The default is `false`, which does not apply the GFM tagfilter to HTML. + /// Pass `true` for output that is a bit closer to GitHub’s actual output. + /// + /// The tagfilter is kinda weird and kinda useless. + /// The tag filter is a naïve attempt at XSS protection. + /// You should use a proper HTML sanitizing algorithm instead. + /// + /// ## Examples + /// + /// ``` + /// use markdown::{to_html_with_options, CompileOptions, Options, ParseOptions}; + /// # fn main() -> Result<(), markdown::message::Message> { + /// + /// // With `allow_dangerous_html`, `markdown-rs` passes HTML through untouched: + /// assert_eq!( + /// to_html_with_options( + /// "