diff --git a/examples/s-expr.rs b/examples/s-expr.rs index ecee3eb5..dc1d2e52 100644 --- a/examples/s-expr.rs +++ b/examples/s-expr.rs @@ -86,6 +86,8 @@ fn dump(source: &str) -> io::Result<()> { .multiline_block_quotes(true) .math_dollars(true) .math_code(true) + .wikilinks_title_after_pipe(true) + .wikilinks_title_before_pipe(true) .build() .unwrap(); diff --git a/fuzz/fuzz_targets/all_options.rs b/fuzz/fuzz_targets/all_options.rs index 6b2b6df8..af9ed045 100644 --- a/fuzz/fuzz_targets/all_options.rs +++ b/fuzz/fuzz_targets/all_options.rs @@ -23,7 +23,9 @@ fuzz_target!(|s: &str| { extension.math_code = true; extension.front_matter_delimiter = Some("---".to_string()); extension.shortcodes = true; - + extension.wikilinks_title_after_pipe = true; + extension.wikilinks_title_before_pipe = true; + let mut parse = ParseOptions::default(); parse.smart = true; parse.default_info_string = Some("rust".to_string()); diff --git a/fuzz/fuzz_targets/quadratic.rs b/fuzz/fuzz_targets/quadratic.rs index f7afb1b0..4fcbe47a 100644 --- a/fuzz/fuzz_targets/quadratic.rs +++ b/fuzz/fuzz_targets/quadratic.rs @@ -196,6 +196,8 @@ struct FuzzExtensionOptions { math_dollars: bool, math_code: bool, shortcodes: bool, + wikilinks_title_after_pipe: bool, + wikilinks_title_before_pipe: bool, } impl FuzzExtensionOptions { @@ -213,6 +215,8 @@ impl FuzzExtensionOptions { extension.math_dollars = self.math_dollars; extension.math_code = self.math_code; extension.shortcodes = self.shortcodes; + extension.wikilinks_title_after_pipe = self.wikilinks_title_after_pipe; + extension.wikilinks_title_before_pipe = self.wikilinks_title_before_pipe; extension.front_matter_delimiter = None; extension.header_ids = None; extension diff --git a/script/cibuild b/script/cibuild index 503c7124..2df91085 100755 --- a/script/cibuild +++ b/script/cibuild @@ -40,6 +40,10 @@ python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/math_dol || failed=1 python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/math_code.md "$PROGRAM_ARG -e math-code" \ || failed=1 +python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/wikilinks_title_after_pipe.md "$PROGRAM_ARG -e wikilinks-title-after-pipe" \ + || failed=1 +python3 spec_tests.py --no-normalize --spec ../../../src/tests/fixtures/wikilinks_title_before_pipe.md "$PROGRAM_ARG -e wikilinks-title-before-pipe" \ + || failed=1 python3 spec_tests.py --no-normalize --spec regression.txt "$PROGRAM_ARG" \ || failed=1 diff --git a/src/cm.rs b/src/cm.rs index 192d2aa2..9b8ba51b 100644 --- a/src/cm.rs +++ b/src/cm.rs @@ -2,7 +2,7 @@ use crate::ctype::{isalpha, isdigit, ispunct, isspace}; use crate::nodes::TableAlignment; use crate::nodes::{ AstNode, ListDelimType, ListType, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeLink, - NodeMath, NodeTable, NodeValue, + NodeMath, NodeTable, NodeValue, NodeWikiLink, }; #[cfg(feature = "shortcodes")] use crate::parser::shortcodes::NodeShortCode; @@ -385,6 +385,7 @@ impl<'a, 'o> CommonMarkFormatter<'a, 'o> { // noop - automatic escaping is already being done } NodeValue::Math(ref math) => self.format_math(math, allow_wrap, entering), + NodeValue::WikiLink(ref nl) => return self.format_wikilink(nl, entering), }; true } @@ -689,6 +690,24 @@ impl<'a, 'o> CommonMarkFormatter<'a, 'o> { true } + fn format_wikilink(&mut self, nl: &NodeWikiLink, entering: bool) -> bool { + if entering { + write!(self, "[[").unwrap(); + if self.options.extension.wikilinks_title_after_pipe { + self.output(nl.url.as_bytes(), false, Escaping::Url); + write!(self, "|").unwrap(); + } + } else { + if self.options.extension.wikilinks_title_before_pipe { + write!(self, "|").unwrap(); + self.output(nl.url.as_bytes(), false, Escaping::Url); + } + write!(self, "]]").unwrap(); + } + + true + } + fn format_image(&mut self, nl: &NodeLink, allow_wrap: bool, entering: bool) { if entering { write!(self, "![").unwrap(); diff --git a/src/html.rs b/src/html.rs index c78a229b..025565d2 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1038,6 +1038,21 @@ impl<'o> HtmlFormatter<'o> { self.render_math_inline(node, literal, display_math, dollar_math)?; } } + NodeValue::WikiLink(ref nl) => { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } } Ok(false) } diff --git a/src/main.rs b/src/main.rs index a259232b..3caebc9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,6 +159,8 @@ enum Extension { MultilineBlockQuotes, MathDollars, MathCode, + WikilinksTitleAfterPipe, + WikilinksTitleBeforePipe, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -238,6 +240,8 @@ fn main() -> Result<(), Box> { .multiline_block_quotes(exts.contains(&Extension::MultilineBlockQuotes)) .math_dollars(exts.contains(&Extension::MathDollars)) .math_code(exts.contains(&Extension::MathCode)) + .wikilinks_title_after_pipe(exts.contains(&Extension::WikilinksTitleAfterPipe)) + .wikilinks_title_before_pipe(exts.contains(&Extension::WikilinksTitleBeforePipe)) .front_matter_delimiter(cli.front_matter_delimiter); #[cfg(feature = "shortcodes")] diff --git a/src/nodes.rs b/src/nodes.rs index 0e073263..79bbfd7e 100644 --- a/src/nodes.rs +++ b/src/nodes.rs @@ -182,6 +182,9 @@ pub enum NodeValue { /// **Inline**. A character that has been [escaped](https://github.github.com/gfm/#backslash-escapes) Escaped, + + /// **Inline**. A wikilink to some URL. + WikiLink(NodeWikiLink), } /// Alignment of a single table cell. @@ -253,6 +256,13 @@ pub struct NodeLink { pub title: String, } +/// The details of a wikilink's destination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeWikiLink { + /// The URL for the link destination. + pub url: String, +} + /// The metadata of a list; the kind of list, the delimiter used and so on. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct NodeList { @@ -489,6 +499,7 @@ impl NodeValue { NodeValue::MultilineBlockQuote(_) => "multiline_block_quote", NodeValue::Escaped => "escaped", NodeValue::Math(..) => "math", + NodeValue::WikiLink(..) => "wikilink", } } } @@ -639,7 +650,8 @@ pub fn can_contain_type<'a>(node: &'a AstNode<'a>, child: &NodeValue) -> bool { | NodeValue::Emph | NodeValue::Strong | NodeValue::Link(..) - | NodeValue::Image(..) => !child.block(), + | NodeValue::Image(..) + | NodeValue::WikiLink(..) => !child.block(), NodeValue::Table(..) => matches!(*child, NodeValue::TableRow(..)), @@ -657,6 +669,7 @@ pub fn can_contain_type<'a>(node: &'a AstNode<'a>, child: &NodeValue) -> bool { | NodeValue::Strikethrough | NodeValue::HtmlInline(..) | NodeValue::Math(..) + | NodeValue::WikiLink(..) ), #[cfg(feature = "shortcodes")] @@ -672,6 +685,7 @@ pub fn can_contain_type<'a>(node: &'a AstNode<'a>, child: &NodeValue) -> bool { | NodeValue::Strikethrough | NodeValue::HtmlInline(..) | NodeValue::Math(..) + | NodeValue::WikiLink(..) ), NodeValue::MultilineBlockQuote(_) => { diff --git a/src/parser/inlines.rs b/src/parser/inlines.rs index c582b309..92c8a6ab 100644 --- a/src/parser/inlines.rs +++ b/src/parser/inlines.rs @@ -2,7 +2,8 @@ use crate::arena_tree::Node; use crate::ctype::{isdigit, ispunct, isspace}; use crate::entity; use crate::nodes::{ - Ast, AstNode, NodeCode, NodeFootnoteReference, NodeLink, NodeMath, NodeValue, Sourcepos, + Ast, AstNode, NodeCode, NodeFootnoteReference, NodeLink, NodeMath, NodeValue, NodeWikiLink, + Sourcepos, }; #[cfg(feature = "shortcodes")] use crate::parser::shortcodes::NodeShortCode; @@ -105,6 +106,12 @@ struct Bracket<'a> { bracket_after: bool, } +#[derive(Clone, Copy)] +struct WikilinkComponents<'i> { + url: &'i [u8], + link_label: Option<(&'i [u8], usize, usize)>, +} + impl<'a, 'r, 'o, 'd, 'i, 'c, 'subj> Subject<'a, 'r, 'o, 'd, 'i, 'c, 'subj> { pub fn new( arena: &'a Arena>, @@ -183,11 +190,30 @@ impl<'a, 'r, 'o, 'd, 'i, 'c, 'subj> Subject<'a, 'r, 'o, 'd, 'i, 'c, 'subj> { '.' => Some(self.handle_period()), '[' => { self.pos += 1; - let inl = - self.make_inline(NodeValue::Text("[".to_string()), self.pos - 1, self.pos - 1); - self.push_bracket(false, inl); - self.within_brackets = true; - Some(inl) + + let mut wikilink_inl = None; + + if (self.options.extension.wikilinks_title_after_pipe + || self.options.extension.wikilinks_title_before_pipe) + && !self.within_brackets + && self.peek_char() == Some(&(b'[')) + { + wikilink_inl = self.handle_wikilink(); + } + + if wikilink_inl.is_none() { + let inl = self.make_inline( + NodeValue::Text("[".to_string()), + self.pos - 1, + self.pos - 1, + ); + self.push_bracket(false, inl); + self.within_brackets = true; + + Some(inl) + } else { + wikilink_inl + } } ']' => { self.within_brackets = false; @@ -1548,6 +1574,127 @@ impl<'a, 'r, 'o, 'd, 'i, 'c, 'subj> Subject<'a, 'r, 'o, 'd, 'i, 'c, 'subj> { } } + // Handles wikilink syntax + // [[link text|url]] + // [[url|link text]] + pub fn handle_wikilink(&mut self) -> Option<&'a AstNode<'a>> { + let startpos = self.pos; + let component = self.wikilink_url_link_label()?; + let url_clean = strings::clean_url(component.url); + let (link_label, link_label_start_column, link_label_end_column) = + match component.link_label { + Some((label, sc, ec)) => (entity::unescape_html(label), sc, ec), + None => ( + entity::unescape_html(component.url), + startpos + 1, + self.pos - 3, + ), + }; + + let nl = NodeWikiLink { + url: String::from_utf8(url_clean).unwrap(), + }; + let inl = self.make_inline(NodeValue::WikiLink(nl), startpos - 1, self.pos - 1); + inl.append(self.make_inline( + NodeValue::Text(String::from_utf8(link_label).unwrap()), + link_label_start_column, + link_label_end_column, + )); + + Some(inl) + } + + fn wikilink_url_link_label(&mut self) -> Option> { + let left_startpos = self.pos; + + if self.peek_char() != Some(&(b'[')) { + return None; + } + + let found_left = self.wikilink_component(); + + if !found_left { + self.pos = left_startpos; + return None; + } + + let left = strings::trim_slice(&self.input[left_startpos + 1..self.pos]); + + if self.peek_char() == Some(&(b']')) && self.peek_char_n(1) == Some(&(b']')) { + self.pos += 2; + return Some(WikilinkComponents { + url: left, + link_label: None, + }); + } else if self.peek_char() != Some(&(b'|')) { + self.pos = left_startpos; + return None; + } + + let right_startpos = self.pos; + let found_right = self.wikilink_component(); + + if !found_right { + self.pos = left_startpos; + return None; + } + + let right = strings::trim_slice(&self.input[right_startpos + 1..self.pos]); + + if self.peek_char() == Some(&(b']')) && self.peek_char_n(1) == Some(&(b']')) { + self.pos += 2; + + if self.options.extension.wikilinks_title_after_pipe { + Some(WikilinkComponents { + url: left, + link_label: Some((right, right_startpos + 1, self.pos - 3)), + }) + } else { + Some(WikilinkComponents { + url: right, + link_label: Some((left, left_startpos + 1, right_startpos - 1)), + }) + } + } else { + self.pos = left_startpos; + None + } + } + + // Locates the edge of a wikilink component (link label or url), and sets the + // self.pos to it's end if it's found. + fn wikilink_component(&mut self) -> bool { + let startpos = self.pos; + + if self.peek_char() != Some(&(b'[')) && self.peek_char() != Some(&(b'|')) { + return false; + } + + self.pos += 1; + + let mut length = 0; + let mut c = 0; + while unwrap_into_copy(self.peek_char(), &mut c) && c != b'[' && c != b']' && c != b'|' { + if c == b'\\' { + self.pos += 1; + length += 1; + if self.peek_char().map_or(false, |&c| ispunct(c)) { + self.pos += 1; + length += 1; + } + } else { + self.pos += 1; + length += 1; + } + if length > MAX_LINK_LABEL_LENGTH { + self.pos = startpos; + return false; + } + } + + true + } + pub fn spnl(&mut self) { self.skip_spaces(); if self.skip_line_end() { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index c5c10f4c..bddd69f5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -423,6 +423,36 @@ pub struct ExtensionOptions { /// "

Happy Friday! 😄

\n"); /// ``` pub shortcodes: bool, + + /// Enables wikilinks using title after pipe syntax + /// + /// ```` md + /// [[url|link label]] + /// ```` + /// + /// ``` + /// # use comrak::{markdown_to_html, Options}; + /// let mut options = Options::default(); + /// options.extension.wikilinks_title_after_pipe = true; + /// assert_eq!(markdown_to_html("[[url|link label]]", &options), + /// "

link label

\n"); + /// ``` + pub wikilinks_title_after_pipe: bool, + + /// Enables wikilinks using title before pipe syntax + /// + /// ```` md + /// [[link label|url]] + /// ```` + /// + /// ``` + /// # use comrak::{markdown_to_html, Options}; + /// let mut options = Options::default(); + /// options.extension.wikilinks_title_before_pipe = true; + /// assert_eq!(markdown_to_html("[[link label|url]]", &options), + /// "

link label

\n"); + /// ``` + pub wikilinks_title_before_pipe: bool, } #[non_exhaustive] diff --git a/src/tests.rs b/src/tests.rs index a3467bf9..986cc83c 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -25,6 +25,7 @@ mod superscript; mod table; mod tagfilter; mod tasklist; +mod wikilinks; mod xml; #[track_caller] @@ -141,6 +142,8 @@ macro_rules! html_opts { math_code: true, front_matter_delimiter: Some("---".to_string()), shortcodes: true, + wikilinks_title_after_pipe: true, + wikilinks_title_before_pipe: true, }, parse: $crate::ParseOptions { smart: true, diff --git a/src/tests/api.rs b/src/tests/api.rs index f89125b7..16adef8d 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -50,6 +50,8 @@ fn exercise_full_api() { extension.front_matter_delimiter(None); #[cfg(feature = "shortcodes")] extension.shortcodes(true); + extension.wikilinks_title_after_pipe(true); + extension.wikilinks_title_before_pipe(true); let mut parse = ParseOptionsBuilder::default(); parse.smart(false); @@ -226,5 +228,8 @@ fn exercise_full_api() { let _: bool = math.dollar_math; let _: String = math.literal; } + nodes::NodeValue::WikiLink(nl) => { + let _: String = nl.url; + } } } diff --git a/src/tests/commonmark.rs b/src/tests/commonmark.rs index 3f297e02..6f2f5a48 100644 --- a/src/tests/commonmark.rs +++ b/src/tests/commonmark.rs @@ -55,3 +55,12 @@ fn math(markdown: &str, cm: &str) { commonmark(markdown, cm, Some(&options)); } + +#[test_case("This [[url]] that", "This [[url|url]] that\n")] +#[test_case("This [[url|link label]] that", "This [[url|link%20label]] that\n")] +fn wikilinks(markdown: &str, cm: &str) { + let mut options = Options::default(); + options.extension.wikilinks_title_before_pipe = true; + + commonmark(markdown, cm, Some(&options)); +} diff --git a/src/tests/fixtures/wikilinks_title_after_pipe.md b/src/tests/fixtures/wikilinks_title_after_pipe.md new file mode 100644 index 00000000..7c645ccf --- /dev/null +++ b/src/tests/fixtures/wikilinks_title_after_pipe.md @@ -0,0 +1,47 @@ +--- +title: Wikilinks +based_on: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/wikilinks_title_after_pipe.md +--- + +# Wikilinks, title after pipe + +Wikilinks can have one of the following forms: + + [[https://example.org]] + [[https://example.org|title]] + [[name of page]] + [[name of page|title]] + +With this version of wikilinks, the title comes after the pipe. + +```````````````````````````````` example +[[https://example.org]] +. +

https://example.org

+```````````````````````````````` + +```````````````````````````````` example +[[https://example.org|title]] +. +

title

+```````````````````````````````` + +```````````````````````````````` example +[[Name of page]] +. +

Name of page

+```````````````````````````````` + +```````````````````````````````` example +[[Name of page|Title]] +. +

Title

+```````````````````````````````` + +HTML entities are recognized both in the name of page and in the link title. + +```````````````````````````````` example +[[Geschütztes Leerzeichen|Über &nbsp;]] +. +

Ãœber &nbsp;

+```````````````````````````````` \ No newline at end of file diff --git a/src/tests/fixtures/wikilinks_title_before_pipe.md b/src/tests/fixtures/wikilinks_title_before_pipe.md new file mode 100644 index 00000000..e430380c --- /dev/null +++ b/src/tests/fixtures/wikilinks_title_before_pipe.md @@ -0,0 +1,55 @@ +--- +title: Wikilinks +based_on: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/wikilinks_title_before_pipe.md +--- + +# Wikilinks, title before pipe + +Wikilinks can have one of the following forms: + + [[https://example.org]] + [[title|https://example.org]] + [[name of page]] + [[title|name of page]] + +With this version of wikilinks, the title comes before the pipe. + +```````````````````````````````` example +[[https://example.org]] +. +

https://example.org

+```````````````````````````````` + +```````````````````````````````` example +[[title|https://example.org]] +. +

title

+```````````````````````````````` + +```````````````````````````````` example +[[Name of page]] +. +

Name of page

+```````````````````````````````` + +```````````````````````````````` example +[[Title|Name of page]] +. +

Title

+```````````````````````````````` + +Regular links should still work! + +```````````````````````````````` example +[Title](Name%20of%20page) +. +

Title

+```````````````````````````````` + +HTML entities are recognized both in the name of page and in the link title. + +```````````````````````````````` example +[[Über &nbsp;|Geschütztes Leerzeichen]] +. +

Ãœber &nbsp;

+```````````````````````````````` \ No newline at end of file diff --git a/src/tests/wikilinks.rs b/src/tests/wikilinks.rs new file mode 100644 index 00000000..6b0c94c3 --- /dev/null +++ b/src/tests/wikilinks.rs @@ -0,0 +1,213 @@ +use super::*; + +// html_opts! does a roundtrip check unless sourcepos is set. +// These cases don't work roundtrip, because converting to commonmark +// automatically escapes certain characters. +#[test] +fn wikilinks_does_not_unescape_html_entities_in_link_label() { + html_opts!( + [extension.wikilinks_title_after_pipe, render.sourcepos], + concat!("This is [[<script>alert(0)</script>|a <link]]",), + concat!("

This is a <link

\n"), + ); + + html_opts!( + [extension.wikilinks_title_before_pipe, render.sourcepos], + concat!("This is [[a <link|<script>alert(0)</script>]]",), + concat!("

This is a <link

\n"), + ); +} + +#[test] +fn wikilinks_sanitizes_the_href_attribute_case_1() { + html_opts!( + [extension.wikilinks_title_after_pipe], + concat!("[[http:\'\"injected=attribute><img/src=\"0\"onerror=\"alert(0)\">https://example.com|a]]",), + concat!("

a

\n"), + ); + + html_opts!( + [extension.wikilinks_title_before_pipe], + concat!("[[a|http:\'\"injected=attribute><img/src=\"0\"onerror=\"alert(0)\">https://example.com]]",), + concat!("

a

\n"), + ); +} + +#[test] +fn wikilinks_sanitizes_the_href_attribute_case_2() { + html_opts!( + [extension.wikilinks_title_after_pipe], + concat!("[[\'\"><svg><i/class=gl-show-field-errors><input/title=\"<script>alert(0)</script>\"/></svg>https://example.com|a]]",), + concat!("

a

\n"), + ); + + html_opts!( + [extension.wikilinks_title_before_pipe], + concat!("[[a|\'\"><svg><i/class=gl-show-field-errors><input/title=\"<script>alert(0)</script>\"/></svg>https://example.com]]",), + concat!("

a

\n"), + ); +} + +#[test] +fn wikilinks_supercedes_relaxed_autolinks() { + html_opts!( + [ + extension.wikilinks_title_after_pipe, + parse.relaxed_autolinks + ], + concat!("[[http://example.com]]",), + concat!( + "

http://example.com

\n" + ), + ); + + html_opts!( + [ + extension.wikilinks_title_before_pipe, + parse.relaxed_autolinks + ], + concat!("[[http://example.com]]",), + concat!( + "

http://example.com

\n" + ), + ); +} + +#[test] +fn wikilinks_only_url_in_tables() { + html_opts!( + [extension.wikilinks_title_after_pipe, extension.table], + concat!("| header |\n", "| ------- |\n", "| [[url]] |\n",), + concat!( + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
header
url
\n", + ), + ); + + html_opts!( + [extension.wikilinks_title_before_pipe, extension.table], + concat!("| header |\n", "| ------- |\n", "| [[url]] |\n",), + concat!( + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
header
url
\n", + ), + ); +} + +#[test] +fn wikilinks_full_in_tables_not_supported() { + html_opts!( + [extension.wikilinks_title_after_pipe, extension.table], + concat!("| header |\n", "| ------- |\n", "| [[url|link label]] |\n",), + concat!( + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
header
[[url
\n", + ), + ); + + html_opts!( + [extension.wikilinks_title_before_pipe, extension.table], + concat!("| header |\n", "| ------- |\n", "| [[link label|url]] |\n",), + concat!( + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
header
[[link label
\n", + ), + ); +} + +#[test] +fn wikilinks_exceeds_label_limit() { + let long_label = format!("[[{:b<1100}]]", "a"); + let expected = format!("

{}

\n", long_label); + + html_opts!( + [extension.wikilinks_title_after_pipe], + &long_label, + &expected, + ); +} + +#[test] +fn sourcepos() { + assert_ast_match!( + [extension.wikilinks_title_after_pipe], + "This [[http://example.com|link label]] that\n", + (document (1:1-1:43) [ + (paragraph (1:1-1:43) [ + (text (1:1-1:5) "This ") + (wikilink (1:6-1:38) [ + (text (1:27-1:36) "link label") + ]) + (text (1:39-1:43) " that") + ]) + ]) + ); + + assert_ast_match!( + [extension.wikilinks_title_before_pipe], + "This [[link label|http://example.com]] that\n", + (document (1:1-1:43) [ + (paragraph (1:1-1:43) [ + (text (1:1-1:5) "This ") + (wikilink (1:6-1:38) [ + (text (1:8-1:17) "link label") + ]) + (text (1:39-1:43) " that") + ]) + ]) + ); + + assert_ast_match!( + [extension.wikilinks_title_before_pipe], + "This [[http://example.com]] that\n", + (document (1:1-1:32) [ + (paragraph (1:1-1:32) [ + (text (1:1-1:5) "This ") + (wikilink (1:6-1:27) [ + (text (1:8-1:25) "http://example.com") + ]) + (text (1:28-1:32) " that") + ]) + ]) + ); +} diff --git a/src/xml.rs b/src/xml.rs index dd90ecf0..3d2e3ebd 100644 --- a/src/xml.rs +++ b/src/xml.rs @@ -273,6 +273,11 @@ impl<'o> XmlFormatter<'o> { write!(self.output, " { + self.output.write_all(b" destination=\"")?; + self.escape(nl.url.as_bytes())?; + self.output.write_all(b"\"")?; + } } if node.first_child().is_some() {