fix(html): keyword-in-heading badge + brackets in wikilink descriptions
This commit is contained in:
@@ -53,6 +53,10 @@ pub struct PreformattedNode {
|
|||||||
pub span: Span,
|
pub span: Span,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
|
||||||
|
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
|
||||||
|
/// raw attributes on the `<pre>` tag, so we preserve it for byte-parity.
|
||||||
|
pub attrs: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ pub enum Keyword {
|
|||||||
Stopped,
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Keyword {
|
||||||
|
/// The literal source text of the keyword (e.g. `"TODO"`). Used both for
|
||||||
|
/// the rendered span and for anchor/id derivation via [`inline_text`].
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Keyword::Todo => "TODO",
|
||||||
|
Keyword::Done => "DONE",
|
||||||
|
Keyword::Started => "STARTED",
|
||||||
|
Keyword::Fixme => "FIXME",
|
||||||
|
Keyword::Fixed => "FIXED",
|
||||||
|
Keyword::Xxx => "XXX",
|
||||||
|
Keyword::Stopped => "STOPPED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum InlineNode {
|
pub enum InlineNode {
|
||||||
Text(TextNode),
|
Text(TextNode),
|
||||||
@@ -89,6 +105,7 @@ fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
|
|||||||
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
|
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
|
||||||
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
|
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
|
||||||
InlineNode::Color(c) => push_inline_text(&c.children, out),
|
InlineNode::Color(c) => push_inline_text(&c.children, out),
|
||||||
|
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
|
||||||
InlineNode::WikiLink(w) => match &w.description {
|
InlineNode::WikiLink(w) => match &w.description {
|
||||||
Some(d) => push_inline_text(d, out),
|
Some(d) => push_inline_text(d, out),
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
@@ -278,10 +278,26 @@ impl HtmlRenderer {
|
|||||||
// quotes) go through `render_block` and are left unnumbered, matching
|
// quotes) go through `render_block` and are left unnumbered, matching
|
||||||
// upstream which only numbers document-level headers.
|
// upstream which only numbers document-level headers.
|
||||||
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
|
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
|
||||||
|
// Stack of (level, anchor) for the headings enclosing the current one,
|
||||||
|
// so each heading can carry vimwiki's hierarchical id (the parent
|
||||||
|
// anchors joined by `-`, e.g. `Title-Section`).
|
||||||
|
let mut ancestors: Vec<(u8, String)> = Vec::new();
|
||||||
for block in &doc.children {
|
for block in &doc.children {
|
||||||
if let BlockNode::Heading(n) = block {
|
if let BlockNode::Heading(n) = block {
|
||||||
let number = numberer.prefix(n.level.clamp(1, 6));
|
let level = n.level.clamp(1, 6);
|
||||||
self.render_heading(n, &number, w)?;
|
let number = numberer.prefix(level);
|
||||||
|
let anchor = crate::ast::inline_text(&n.children);
|
||||||
|
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
|
||||||
|
ancestors.pop();
|
||||||
|
}
|
||||||
|
let mut hier = String::new();
|
||||||
|
for (_, a) in &ancestors {
|
||||||
|
hier.push_str(a);
|
||||||
|
hier.push('-');
|
||||||
|
}
|
||||||
|
hier.push_str(&anchor);
|
||||||
|
ancestors.push((level, anchor));
|
||||||
|
self.render_heading(n, &number, &hier, w)?;
|
||||||
} else {
|
} else {
|
||||||
self.render_block(block, w)?;
|
self.render_block(block, w)?;
|
||||||
}
|
}
|
||||||
@@ -291,7 +307,7 @@ impl HtmlRenderer {
|
|||||||
|
|
||||||
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
match block {
|
match block {
|
||||||
BlockNode::Heading(n) => self.render_heading(n, "", w),
|
BlockNode::Heading(n) => self.render_heading(n, "", "", w),
|
||||||
BlockNode::Paragraph(n) => self.render_paragraph(n, w),
|
BlockNode::Paragraph(n) => self.render_paragraph(n, w),
|
||||||
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
|
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
|
||||||
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
|
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
|
||||||
@@ -307,13 +323,14 @@ impl HtmlRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
|
// `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump
|
||||||
|
// to the rendered tag.
|
||||||
w.write_all(b"<div class=\"tags\">")?;
|
w.write_all(b"<div class=\"tags\">")?;
|
||||||
for (i, name) in n.tags.iter().enumerate() {
|
for (i, name) in n.tags.iter().enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
w.write_all(b" ")?;
|
w.write_all(b" ")?;
|
||||||
}
|
}
|
||||||
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
|
w.write_all(b"<span class=\"tag\" id=\"")?;
|
||||||
write_escaped(name, w)?;
|
write_escaped(name, w)?;
|
||||||
w.write_all(b"\">")?;
|
w.write_all(b"\">")?;
|
||||||
write_escaped(name, w)?;
|
write_escaped(name, w)?;
|
||||||
@@ -324,34 +341,70 @@ impl HtmlRenderer {
|
|||||||
|
|
||||||
/// Render a heading. `number` is the optional section-number prefix
|
/// Render a heading. `number` is the optional section-number prefix
|
||||||
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
|
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
|
||||||
/// pass `""` for no numbering.
|
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id
|
||||||
fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
|
/// (parent anchors joined by `-`); pass `""` to default it to the flat id.
|
||||||
|
fn render_heading(
|
||||||
|
&self,
|
||||||
|
n: &HeadingNode,
|
||||||
|
number: &str,
|
||||||
|
hier_id: &str,
|
||||||
|
w: &mut dyn Write,
|
||||||
|
) -> io::Result<()> {
|
||||||
let level = n.level.clamp(1, 6);
|
let level = n.level.clamp(1, 6);
|
||||||
let anchor = crate::ast::inline_text(&n.children);
|
let anchor = crate::ast::inline_text(&n.children);
|
||||||
|
// Inside a heading, render TODO/DONE/… as plain text rather than a
|
||||||
|
// keyword badge — a section title that *is* a keyword (`== TODO ==`)
|
||||||
|
// should look like a header, not an inline tag. (The keyword text is
|
||||||
|
// still part of `anchor` above, so the id stays correct.)
|
||||||
|
let body = flatten_keywords(&n.children);
|
||||||
|
|
||||||
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
||||||
// `<div class="toc">`, matching upstream's HTML so vimwiki's stylesheet
|
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
|
||||||
// (`.toc { … }`) applies. The class goes on the wrapping div, not the
|
// stylesheet rules apply. Kept in its simple form.
|
||||||
// heading element.
|
|
||||||
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
|
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
|
||||||
if is_toc {
|
if is_toc {
|
||||||
w.write_all(b"<div class=\"toc\">")?;
|
w.write_all(b"<div class=\"toc\">")?;
|
||||||
}
|
write!(w, "<h{level} id=\"")?;
|
||||||
let class = if n.centered {
|
|
||||||
" class=\"centered\""
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
write!(w, "<h{level}{class} id=\"")?;
|
|
||||||
write_escaped(&anchor, w)?;
|
write_escaped(&anchor, w)?;
|
||||||
write!(w, "\">")?;
|
write!(w, "\">")?;
|
||||||
if !number.is_empty() {
|
if !number.is_empty() {
|
||||||
write_escaped(number, w)?;
|
write_escaped(number, w)?;
|
||||||
}
|
}
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&body, w)?;
|
||||||
write!(w, "</h{level}>")?;
|
write!(w, "</h{level}></div>")?;
|
||||||
if is_toc {
|
return writeln!(w);
|
||||||
w.write_all(b"</div>")?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Centered headings keep their `class="centered"` form.
|
||||||
|
if n.centered {
|
||||||
|
write!(w, "<h{level} class=\"centered\" id=\"")?;
|
||||||
|
write_escaped(&anchor, w)?;
|
||||||
|
write!(w, "\">")?;
|
||||||
|
if !number.is_empty() {
|
||||||
|
write_escaped(number, w)?;
|
||||||
|
}
|
||||||
|
self.render_inlines(&body, w)?;
|
||||||
|
write!(w, "</h{level}>")?;
|
||||||
|
return writeln!(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular heading: vimwiki's structure — a wrapping `<div>` carrying the
|
||||||
|
// hierarchical id, `class="header"` on the heading, and an in-heading
|
||||||
|
// self-anchor linking to that id. The heading keeps the flat id so
|
||||||
|
// existing intra-page TOC / `#anchor` links still resolve.
|
||||||
|
let hier = if hier_id.is_empty() { &anchor } else { hier_id };
|
||||||
|
w.write_all(b"<div id=\"")?;
|
||||||
|
write_escaped(hier, w)?;
|
||||||
|
write!(w, "\"><h{level} id=\"")?;
|
||||||
|
write_escaped(&anchor, w)?;
|
||||||
|
w.write_all(b"\" class=\"header\"><a href=\"#")?;
|
||||||
|
write_escaped(hier, w)?;
|
||||||
|
write!(w, "\">")?;
|
||||||
|
if !number.is_empty() {
|
||||||
|
write_escaped(number, w)?;
|
||||||
|
}
|
||||||
|
self.render_inlines(&body, w)?;
|
||||||
|
write!(w, "</a></h{level}></div>")?;
|
||||||
writeln!(w)
|
writeln!(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,16 +432,16 @@ impl HtmlRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
match &n.language {
|
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
|
||||||
Some(lang) => {
|
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
|
||||||
w.write_all(b"<pre><code class=\"language-")?;
|
// We mirror that so highlighters wired for vimwiki output still apply.
|
||||||
write_escaped(lang, w)?;
|
if n.attrs.is_empty() {
|
||||||
w.write_all(b"\">")?;
|
w.write_all(b"<pre>")?;
|
||||||
}
|
} else {
|
||||||
None => w.write_all(b"<pre><code>")?,
|
write!(w, "<pre {}>", n.attrs)?;
|
||||||
}
|
}
|
||||||
write_escaped(&n.content, w)?;
|
write_escaped(&n.content, w)?;
|
||||||
w.write_all(b"</code></pre>\n")
|
w.write_all(b"</pre>\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
@@ -705,16 +758,16 @@ impl HtmlRenderer {
|
|||||||
// One class per keyword so stylesheets can colour them independently
|
// One class per keyword so stylesheets can colour them independently
|
||||||
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
|
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
|
||||||
// vimwiki `todo` class so stock vimwiki CSS still styles it.
|
// vimwiki `todo` class so stock vimwiki CSS still styles it.
|
||||||
let (label, class) = match n.keyword {
|
let class = match n.keyword {
|
||||||
Keyword::Todo => ("TODO", "todo"),
|
Keyword::Todo => "todo",
|
||||||
Keyword::Done => ("DONE", "done"),
|
Keyword::Done => "done",
|
||||||
Keyword::Started => ("STARTED", "started"),
|
Keyword::Started => "started",
|
||||||
Keyword::Fixme => ("FIXME", "fixme"),
|
Keyword::Fixme => "fixme",
|
||||||
Keyword::Fixed => ("FIXED", "fixed"),
|
Keyword::Fixed => "fixed",
|
||||||
Keyword::Xxx => ("XXX", "xxx"),
|
Keyword::Xxx => "xxx",
|
||||||
Keyword::Stopped => ("STOPPED", "stopped"),
|
Keyword::Stopped => "stopped",
|
||||||
};
|
};
|
||||||
write!(w, "<span class=\"{class}\">{label}</span>")
|
write!(w, "<span class=\"{class}\">{}</span>", n.keyword.label())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||||
@@ -891,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
|
|||||||
/// wiki pages become `path.html`; interwiki links land in sibling
|
/// wiki pages become `path.html`; interwiki links land in sibling
|
||||||
/// directories; diary entries land under `diary/`; file/local schemes
|
/// directories; diary entries land under `diary/`; file/local schemes
|
||||||
/// use their literal path; anchor-only links collapse to a fragment.
|
/// use their literal path; anchor-only links collapse to a fragment.
|
||||||
|
/// Return a copy of `nodes` with every `Keyword` replaced by its literal text.
|
||||||
|
/// Used for heading content so a keyword in a title renders as plain text (with
|
||||||
|
/// the heading's own styling) rather than as an inline keyword badge.
|
||||||
|
fn flatten_keywords(nodes: &[InlineNode]) -> Vec<InlineNode> {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|n| match n {
|
||||||
|
InlineNode::Keyword(k) => InlineNode::Text(TextNode {
|
||||||
|
span: k.span,
|
||||||
|
content: k.keyword.label().to_string(),
|
||||||
|
}),
|
||||||
|
InlineNode::Bold(b) => InlineNode::Bold(BoldNode {
|
||||||
|
span: b.span,
|
||||||
|
children: flatten_keywords(&b.children),
|
||||||
|
}),
|
||||||
|
InlineNode::Italic(i) => InlineNode::Italic(ItalicNode {
|
||||||
|
span: i.span,
|
||||||
|
children: flatten_keywords(&i.children),
|
||||||
|
}),
|
||||||
|
InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode {
|
||||||
|
span: b.span,
|
||||||
|
children: flatten_keywords(&b.children),
|
||||||
|
}),
|
||||||
|
InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode {
|
||||||
|
span: s.span,
|
||||||
|
children: flatten_keywords(&s.children),
|
||||||
|
}),
|
||||||
|
InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode {
|
||||||
|
span: s.span,
|
||||||
|
children: flatten_keywords(&s.children),
|
||||||
|
}),
|
||||||
|
InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode {
|
||||||
|
span: s.span,
|
||||||
|
children: flatten_keywords(&s.children),
|
||||||
|
}),
|
||||||
|
InlineNode::Color(c) => InlineNode::Color(ColorNode {
|
||||||
|
span: c.span,
|
||||||
|
color: c.color.clone(),
|
||||||
|
children: flatten_keywords(&c.children),
|
||||||
|
}),
|
||||||
|
other => other.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_link_resolver(target: &LinkTarget) -> String {
|
pub fn default_link_resolver(target: &LinkTarget) -> String {
|
||||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
if matches!(target.kind, LinkKind::AnchorOnly) {
|
||||||
return match &target.anchor {
|
return match &target.anchor {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ pub enum VimwikiTokenKind {
|
|||||||
PreformattedOpen {
|
PreformattedOpen {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
attrs: HashMap<String, String>,
|
attrs: HashMap<String, String>,
|
||||||
|
/// Verbatim text after `{{{` (trailing whitespace trimmed), preserved
|
||||||
|
/// so the HTML renderer can reproduce vimwiki's raw `<pre …>` attrs.
|
||||||
|
raw: String,
|
||||||
},
|
},
|
||||||
PreformattedClose,
|
PreformattedClose,
|
||||||
PreformattedLine(String),
|
PreformattedLine(String),
|
||||||
@@ -421,7 +424,14 @@ impl<'src> LexState<'src> {
|
|||||||
// pairs separated by spaces. Keep parsing forgiving.
|
// pairs separated by spaces. Keep parsing forgiving.
|
||||||
let after = trimmed[3..].trim();
|
let after = trimmed[3..].trim();
|
||||||
let (language, attrs) = parse_fence_attrs(after);
|
let (language, attrs) = parse_fence_attrs(after);
|
||||||
let kind = VimwikiTokenKind::PreformattedOpen { language, attrs };
|
// vimwiki keeps everything after `{{{` (minus trailing whitespace) and
|
||||||
|
// drops it verbatim into the `<pre>` tag, so capture it unparsed.
|
||||||
|
let raw = trimmed[3..].trim_end().to_string();
|
||||||
|
let kind = VimwikiTokenKind::PreformattedOpen {
|
||||||
|
language,
|
||||||
|
attrs,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
|
let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
|
||||||
self.push(kind, span);
|
self.push(kind, span);
|
||||||
self.mode = BlockMode::Preformatted;
|
self.mode = BlockMode::Preformatted;
|
||||||
@@ -865,8 +875,10 @@ impl<'src> LexState<'src> {
|
|||||||
flush(self, &mut buf, &mut buf_start, abs_col);
|
flush(self, &mut buf, &mut buf_start, abs_col);
|
||||||
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
|
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
|
||||||
i += 2;
|
i += 2;
|
||||||
// Lex until `]]` (absolute index into `slice`).
|
// Lex until the closing `]]`. A single `]` inside the body —
|
||||||
let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
|
// e.g. a `[TICKET-1]` in the description — is literal text, so
|
||||||
|
// we balance inner `[ ]` rather than stopping at the first `]]`.
|
||||||
|
let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
|
||||||
let inner_end = close_abs_in_slice.unwrap_or(slice.len());
|
let inner_end = close_abs_in_slice.unwrap_or(slice.len());
|
||||||
self.lex_link_body(slice, i, line_col_start, inner_end, true);
|
self.lex_link_body(slice, i, line_col_start, inner_end, true);
|
||||||
i = inner_end;
|
i = inner_end;
|
||||||
@@ -1052,6 +1064,27 @@ impl<'src> LexState<'src> {
|
|||||||
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
|
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
|
||||||
/// `lang` (single word) followed by optional `key=value` pairs separated
|
/// `lang` (single word) followed by optional `key=value` pairs separated
|
||||||
/// by spaces.
|
/// by spaces.
|
||||||
|
/// Byte offset of the closing `]]` for a wikilink body that begins at the start
|
||||||
|
/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
|
||||||
|
/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
|
||||||
|
/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
|
||||||
|
/// body text. Returns `None` if no closing `]]` is found.
|
||||||
|
fn find_wikilink_close(s: &str) -> Option<usize> {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let mut depth: u32 = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < b.len() {
|
||||||
|
match b[i] {
|
||||||
|
b'[' => depth += 1,
|
||||||
|
b']' if depth > 0 => depth -= 1, // closes an inner '['
|
||||||
|
b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
|
fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
|
||||||
let mut attrs = HashMap::new();
|
let mut attrs = HashMap::new();
|
||||||
let mut language: Option<String> = None;
|
let mut language: Option<String> = None;
|
||||||
|
|||||||
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
|
|||||||
let open = self.advance().unwrap();
|
let open = self.advance().unwrap();
|
||||||
let span_start = open.span.start;
|
let span_start = open.span.start;
|
||||||
let mut span_end = open.span.end;
|
let mut span_end = open.span.end;
|
||||||
let language = match &open.kind {
|
let (language, attrs) = match &open.kind {
|
||||||
K::PreformattedOpen { language, .. } => language.clone(),
|
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
let mut content = String::new();
|
let mut content = String::new();
|
||||||
@@ -390,6 +390,7 @@ impl<'a> ParseState<'a> {
|
|||||||
span: Span::new(span_start, span_end),
|
span: Span::new(span_start, span_end),
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
|
attrs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
K::PreformattedLine(s) => {
|
K::PreformattedLine(s) => {
|
||||||
@@ -413,6 +414,7 @@ impl<'a> ParseState<'a> {
|
|||||||
span: Span::new(span_start, span_end),
|
span: Span::new(span_start, span_end),
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
|
attrs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ fn heading_levels() {
|
|||||||
for level in 1..=6 {
|
for level in 1..=6 {
|
||||||
let bars = "=".repeat(level);
|
let bars = "=".repeat(level);
|
||||||
let out = render(&format!("{bars} Title {bars}\n"));
|
let out = render(&format!("{bars} Title {bars}\n"));
|
||||||
assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
|
// vimwiki structure: <div id="hier"><h{l} id="flat" class="header">
|
||||||
|
// <a href="#hier">…</a></h{l}></div>. A lone heading has hier == flat.
|
||||||
|
assert!(out.starts_with(&format!(
|
||||||
|
"<div id=\"Title\"><h{level} id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h{level}></div>"
|
||||||
|
)), "got: {out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +77,43 @@ fn centered_heading_gets_centered_class() {
|
|||||||
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_keyword_renders_as_plain_text_not_badge() {
|
||||||
|
// `== TODO ==` is a section title, not an inline keyword: it must render as
|
||||||
|
// a normal header (with a correct, non-empty id), not a `<span class="todo">`
|
||||||
|
// badge. Regression for the exported header losing its header styling + id.
|
||||||
|
let out = render("== TODO ==\n");
|
||||||
|
assert!(
|
||||||
|
out.contains(
|
||||||
|
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
|
||||||
|
<a href=\"#TODO\">TODO</a></h2></div>"
|
||||||
|
),
|
||||||
|
"got: {out}"
|
||||||
|
);
|
||||||
|
assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}");
|
||||||
|
|
||||||
|
// A keyword mid-title keeps its text in the id (no dropped word / double
|
||||||
|
// space) and still renders plainly.
|
||||||
|
let out2 = render("= My TODO list =\n");
|
||||||
|
assert!(out2.contains("id=\"My TODO list\""), "got: {out2}");
|
||||||
|
assert!(!out2.contains("class=\"todo\""), "got: {out2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wikilink_description_keeps_inner_brackets() {
|
||||||
|
// A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close
|
||||||
|
// the link early. Regression for descriptions with brackets being mangled.
|
||||||
|
let out = render("[[page|Task [B-1]]]\n");
|
||||||
|
assert!(
|
||||||
|
out.contains("<a href=\"page.html\">Task [B-1]</a>"),
|
||||||
|
"got: {out}"
|
||||||
|
);
|
||||||
|
// Two bracketed links on one line both resolve.
|
||||||
|
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
|
||||||
|
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
|
||||||
|
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "got: {out2}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paragraph_wraps_inline_content() {
|
fn paragraph_wraps_inline_content() {
|
||||||
let out = render("Hello world\n");
|
let out = render("Hello world\n");
|
||||||
@@ -94,11 +135,26 @@ fn blockquote_lines_become_paragraphs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preformatted_block_emits_pre_code_with_language_class() {
|
fn preformatted_block_emits_pre_with_raw_fence_attrs() {
|
||||||
|
// vimwiki drops the verbatim fence text into the <pre> tag (`{{{rust` →
|
||||||
|
// `<pre rust>`), with no inner <code> wrapper.
|
||||||
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
||||||
assert!(out.contains("<pre><code class=\"language-rust\">"));
|
assert!(out.contains("<pre rust>"), "got: {out}");
|
||||||
assert!(out.contains("fn main() {}"));
|
assert!(out.contains("fn main() {}"));
|
||||||
assert!(out.contains("</code></pre>"));
|
assert!(out.contains("</pre>"));
|
||||||
|
assert!(!out.contains("<code"), "no inner code element: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preformatted_block_without_attrs_is_bare_pre() {
|
||||||
|
let out = render("{{{\nplain\n}}}\n");
|
||||||
|
assert!(out.contains("<pre>plain"), "got: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preformatted_block_keeps_full_attr_string() {
|
||||||
|
let out = render("{{{class=\"brush: python\"\nx\n}}}\n");
|
||||||
|
assert!(out.contains("<pre class=\"brush: python\">"), "got: {out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -244,7 +300,10 @@ fn heading_id_matches_anchor_link_href() {
|
|||||||
// for "exported HTML anchor links don't work".
|
// for "exported HTML anchor links don't work".
|
||||||
let out = render("= My Section =\n\n[[#My Section]]\n");
|
let out = render("= My Section =\n\n[[#My Section]]\n");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("<h1 id=\"My Section\">My Section</h1>"),
|
out.contains(
|
||||||
|
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
|
||||||
|
<a href=\"#My Section\">My Section</a></h1></div>"
|
||||||
|
),
|
||||||
"heading id: {out}"
|
"heading id: {out}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -276,7 +335,13 @@ fn non_toc_heading_not_wrapped() {
|
|||||||
.with_toc_header("Contents")
|
.with_toc_header("Contents")
|
||||||
.render_to_string(&doc)
|
.render_to_string(&doc)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(out.contains("<h2 id=\"Intro\">Intro</h2>"), "got: {out}");
|
assert!(
|
||||||
|
out.contains(
|
||||||
|
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
|
||||||
|
<a href=\"#Intro\">Intro</a></h2></div>"
|
||||||
|
),
|
||||||
|
"got: {out}"
|
||||||
|
);
|
||||||
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
assert!(!out.contains("class=\"toc\""), "got: {out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +407,7 @@ fn template_substitutes_content_and_title() {
|
|||||||
);
|
);
|
||||||
let out = renderer.render_to_string(&doc).unwrap();
|
let out = renderer.render_to_string(&doc).unwrap();
|
||||||
assert!(out.contains("<title>My Page</title>"));
|
assert!(out.contains("<title>My Page</title>"));
|
||||||
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
|
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -351,7 +416,7 @@ fn template_with_missing_title_substitutes_empty_string() {
|
|||||||
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
||||||
let out = renderer.render_to_string(&doc).unwrap();
|
let out = renderer.render_to_string(&doc).unwrap();
|
||||||
assert!(out.contains("<title></title>"));
|
assert!(out.contains("<title></title>"));
|
||||||
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
|
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -367,7 +432,7 @@ fn template_substitutes_vimwiki_percent_placeholders() {
|
|||||||
let out = renderer.render_to_string(&doc).unwrap();
|
let out = renderer.render_to_string(&doc).unwrap();
|
||||||
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
|
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
|
||||||
"content: {out}"
|
"content: {out}"
|
||||||
);
|
);
|
||||||
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
||||||
@@ -461,7 +526,7 @@ fn x() {}
|
|||||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||||
// A few sanity checks; the full output is exercised piece-by-piece above.
|
// A few sanity checks; the full output is exercised piece-by-piece above.
|
||||||
for needle in [
|
for needle in [
|
||||||
"<h1 id=\"Heading\">Heading</h1>",
|
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
|
||||||
"<strong>bold</strong>",
|
"<strong>bold</strong>",
|
||||||
"<em>italic</em>",
|
"<em>italic</em>",
|
||||||
"<code>code</code>",
|
"<code>code</code>",
|
||||||
@@ -469,7 +534,7 @@ fn x() {}
|
|||||||
"<blockquote>",
|
"<blockquote>",
|
||||||
"<hr>",
|
"<hr>",
|
||||||
"<table>",
|
"<table>",
|
||||||
"<pre><code class=\"language-rust\">",
|
"<pre rust>",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
out.contains(needle),
|
out.contains(needle),
|
||||||
|
|||||||
@@ -179,7 +179,13 @@ fn preformatted_block_parses_language() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn preformatted_block_parses_attrs() {
|
fn preformatted_block_parses_attrs() {
|
||||||
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
|
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
|
||||||
let PreformattedOpen { language, attrs } = &lexed[0] else {
|
let PreformattedOpen {
|
||||||
|
language,
|
||||||
|
attrs,
|
||||||
|
raw,
|
||||||
|
..
|
||||||
|
} = &lexed[0]
|
||||||
|
else {
|
||||||
panic!("expected PreformattedOpen");
|
panic!("expected PreformattedOpen");
|
||||||
};
|
};
|
||||||
assert_eq!(language.as_deref(), Some("rust"));
|
assert_eq!(language.as_deref(), Some("rust"));
|
||||||
@@ -187,6 +193,8 @@ fn preformatted_block_parses_attrs() {
|
|||||||
expected.insert("class".into(), "hl".into());
|
expected.insert("class".into(), "hl".into());
|
||||||
expected.insert("key".into(), "val".into());
|
expected.insert("key".into(), "val".into());
|
||||||
assert_eq!(attrs, &expected);
|
assert_eq!(attrs, &expected);
|
||||||
|
// The verbatim fence text is preserved for the HTML renderer.
|
||||||
|
assert_eq!(raw, "rust class=\"hl\" key=val");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Math block =====
|
// ===== Math block =====
|
||||||
|
|||||||
@@ -190,8 +190,8 @@ fn file_tags_are_deduped_in_metadata() {
|
|||||||
fn renderer_emits_div_with_tag_spans() {
|
fn renderer_emits_div_with_tag_spans() {
|
||||||
let html = render(":todo:done:\n");
|
let html = render(":todo:done:\n");
|
||||||
assert!(html.contains("<div class=\"tags\">"));
|
assert!(html.contains("<div class=\"tags\">"));
|
||||||
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
|
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
|
||||||
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
|
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -385,19 +385,19 @@ fn render_page_html_numbers_headers_when_enabled() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
|
html.contains("<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"),
|
||||||
"got: {html}"
|
"got: {html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
|
html.contains("<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"),
|
||||||
"got: {html}"
|
"got: {html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
|
html.contains("<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"),
|
||||||
"got: {html}"
|
"got: {html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<h1 id=\"Results\">2. Results</h1>"),
|
html.contains("<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"),
|
||||||
"got: {html}"
|
"got: {html}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -419,14 +419,14 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<h1 id=\"Title\">Title</h1>"),
|
html.contains("<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"),
|
||||||
"h1 unnumbered, got: {html}"
|
"h1 unnumbered, got: {html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<h2 id=\"Section\">1 Section</h2>"),
|
html.contains("<div id=\"Title-Section\"><h2 id=\"Section\" class=\"header\"><a href=\"#Title-Section\">1 Section</a></h2></div>"),
|
||||||
"got: {html}"
|
"got: {html}"
|
||||||
);
|
);
|
||||||
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
|
assert!(html.contains("<div id=\"Title-Section-Sub\"><h3 id=\"Sub\" class=\"header\"><a href=\"#Title-Section-Sub\">1.1 Sub</a></h3></div>"), "got: {html}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -443,8 +443,8 @@ fn render_page_html_no_header_numbering_by_default() {
|
|||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
|
assert!(html.contains("<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">Intro</a></h1></div>"), "got: {html}");
|
||||||
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
|
assert!(html.contains("<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"), "got: {html}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user