fix(html): match vimwiki export output for headings, code fences, tags, anchors (#6)
Exported HTML diverged from upstream vimwiki's :VimwikiAll2HTML in ways that break custom templates' CSS/JS and deep-links. Bring the renderer to parity on the structural differences reported in #6: 1. Headings — restore vimwiki's structure: a wrapping `<div id="{hierarchical}">` (parent anchors joined by `-`), `class="header"`, and an in-heading `<a href="#{hierarchical}">` self-link. The flat `id` stays on the heading element so intra-page TOC / `#anchor` links keep resolving. Ancestor path is tracked while walking top-level headings. 2. Code fences — emit vimwiki's `<pre {raw-attrs}>` (the verbatim text after `{{{`, e.g. `<pre python>`) instead of `<pre><code class="language-X">`, so highlighters wired for vimwiki output apply. The fence text is now preserved verbatim through the lexer/AST (PreformattedNode.attrs). 3. Inline tag ids — drop the `tag-` prefix (`id="wiki"`, not `id="tag-wiki"`). Also fixes a real inconsistency: the LSP validated `[[Page#wiki]]` as resolvable while the HTML emitted `id="tag-wiki"`, so the exported link was broken. 4. Same-page anchors — `[[#Section]]` resolves to `index.html#Section` (current page filename + fragment), matching vimwiki, rather than a bare `#Section`. Also ship vimwiki's stock style.css verbatim as the default stylesheet (was a ~7-line minimal one) so exports look identical out of the box and the `.header`/`.tag`/`.toc`/`done*` classes are styled. Centered headings and the `<div class="toc">` Contents wrapper keep their existing form. Tests updated across core + lsp to the new output; added coverage for hierarchical ids, raw fence attrs, bare tag ids, and the same-page anchor filename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,10 @@ pub struct PreformattedNode {
|
||||
pub span: Span,
|
||||
pub content: 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)]
|
||||
|
||||
@@ -278,10 +278,26 @@ impl HtmlRenderer {
|
||||
// quotes) go through `render_block` and are left unnumbered, matching
|
||||
// upstream which only numbers document-level headers.
|
||||
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 {
|
||||
if let BlockNode::Heading(n) = block {
|
||||
let number = numberer.prefix(n.level.clamp(1, 6));
|
||||
self.render_heading(n, &number, w)?;
|
||||
let level = n.level.clamp(1, 6);
|
||||
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 {
|
||||
self.render_block(block, w)?;
|
||||
}
|
||||
@@ -291,7 +307,7 @@ impl HtmlRenderer {
|
||||
|
||||
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
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::HorizontalRule(n) => self.render_hr(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<()> {
|
||||
// `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\">")?;
|
||||
for (i, name) in n.tags.iter().enumerate() {
|
||||
if i > 0 {
|
||||
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)?;
|
||||
w.write_all(b"\">")?;
|
||||
write_escaped(name, w)?;
|
||||
@@ -324,24 +341,38 @@ impl HtmlRenderer {
|
||||
|
||||
/// Render a heading. `number` is the optional section-number prefix
|
||||
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
|
||||
/// pass `""` for no numbering.
|
||||
fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
|
||||
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id
|
||||
/// (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 anchor = crate::ast::inline_text(&n.children);
|
||||
|
||||
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
|
||||
// `<div class="toc">`, matching upstream's HTML so vimwiki's stylesheet
|
||||
// (`.toc { … }`) applies. The class goes on the wrapping div, not the
|
||||
// heading element.
|
||||
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
|
||||
// stylesheet rules apply. Kept in its simple form.
|
||||
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
|
||||
if is_toc {
|
||||
w.write_all(b"<div class=\"toc\">")?;
|
||||
write!(w, "<h{level} id=\"")?;
|
||||
write_escaped(&anchor, w)?;
|
||||
write!(w, "\">")?;
|
||||
if !number.is_empty() {
|
||||
write_escaped(number, w)?;
|
||||
}
|
||||
let class = if n.centered {
|
||||
" class=\"centered\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
write!(w, "<h{level}{class} id=\"")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
write!(w, "</h{level}></div>")?;
|
||||
return writeln!(w);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -349,9 +380,26 @@ impl HtmlRenderer {
|
||||
}
|
||||
self.render_inlines(&n.children, w)?;
|
||||
write!(w, "</h{level}>")?;
|
||||
if is_toc {
|
||||
w.write_all(b"</div>")?;
|
||||
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(&n.children, w)?;
|
||||
write!(w, "</a></h{level}></div>")?;
|
||||
writeln!(w)
|
||||
}
|
||||
|
||||
@@ -379,16 +427,16 @@ impl HtmlRenderer {
|
||||
}
|
||||
|
||||
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match &n.language {
|
||||
Some(lang) => {
|
||||
w.write_all(b"<pre><code class=\"language-")?;
|
||||
write_escaped(lang, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
}
|
||||
None => w.write_all(b"<pre><code>")?,
|
||||
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
|
||||
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
|
||||
// We mirror that so highlighters wired for vimwiki output still apply.
|
||||
if n.attrs.is_empty() {
|
||||
w.write_all(b"<pre>")?;
|
||||
} else {
|
||||
write!(w, "<pre {}>", n.attrs)?;
|
||||
}
|
||||
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<()> {
|
||||
|
||||
@@ -81,6 +81,9 @@ pub enum VimwikiTokenKind {
|
||||
PreformattedOpen {
|
||||
language: Option<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,
|
||||
PreformattedLine(String),
|
||||
@@ -421,7 +424,14 @@ impl<'src> LexState<'src> {
|
||||
// pairs separated by spaces. Keep parsing forgiving.
|
||||
let after = trimmed[3..].trim();
|
||||
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));
|
||||
self.push(kind, span);
|
||||
self.mode = BlockMode::Preformatted;
|
||||
|
||||
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
|
||||
let open = self.advance().unwrap();
|
||||
let span_start = open.span.start;
|
||||
let mut span_end = open.span.end;
|
||||
let language = match &open.kind {
|
||||
K::PreformattedOpen { language, .. } => language.clone(),
|
||||
let (language, attrs) = match &open.kind {
|
||||
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut content = String::new();
|
||||
@@ -390,6 +390,7 @@ impl<'a> ParseState<'a> {
|
||||
span: Span::new(span_start, span_end),
|
||||
content,
|
||||
language,
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
K::PreformattedLine(s) => {
|
||||
@@ -413,6 +414,7 @@ impl<'a> ParseState<'a> {
|
||||
span: Span::new(span_start, span_end),
|
||||
content,
|
||||
language,
|
||||
attrs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,11 @@ fn heading_levels() {
|
||||
for level in 1..=6 {
|
||||
let bars = "=".repeat(level);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +98,26 @@ fn blockquote_lines_become_paragraphs() {
|
||||
}
|
||||
|
||||
#[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");
|
||||
assert!(out.contains("<pre><code class=\"language-rust\">"));
|
||||
assert!(out.contains("<pre rust>"), "got: {out}");
|
||||
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]
|
||||
@@ -244,7 +263,10 @@ fn heading_id_matches_anchor_link_href() {
|
||||
// for "exported HTML anchor links don't work".
|
||||
let out = render("= My Section =\n\n[[#My Section]]\n");
|
||||
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}"
|
||||
);
|
||||
assert!(
|
||||
@@ -276,7 +298,13 @@ fn non_toc_heading_not_wrapped() {
|
||||
.with_toc_header("Contents")
|
||||
.render_to_string(&doc)
|
||||
.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}");
|
||||
}
|
||||
|
||||
@@ -342,7 +370,7 @@ fn template_substitutes_content_and_title() {
|
||||
);
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
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]
|
||||
@@ -351,7 +379,7 @@ fn template_with_missing_title_substitutes_empty_string() {
|
||||
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
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]
|
||||
@@ -367,7 +395,7 @@ fn template_substitutes_vimwiki_percent_placeholders() {
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
||||
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}"
|
||||
);
|
||||
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
||||
@@ -461,7 +489,7 @@ fn x() {}
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
// A few sanity checks; the full output is exercised piece-by-piece above.
|
||||
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>",
|
||||
"<em>italic</em>",
|
||||
"<code>code</code>",
|
||||
@@ -469,7 +497,7 @@ fn x() {}
|
||||
"<blockquote>",
|
||||
"<hr>",
|
||||
"<table>",
|
||||
"<pre><code class=\"language-rust\">",
|
||||
"<pre rust>",
|
||||
] {
|
||||
assert!(
|
||||
out.contains(needle),
|
||||
|
||||
@@ -179,7 +179,9 @@ fn preformatted_block_parses_language() {
|
||||
#[test]
|
||||
fn preformatted_block_parses_attrs() {
|
||||
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");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("rust"));
|
||||
@@ -187,6 +189,8 @@ fn preformatted_block_parses_attrs() {
|
||||
expected.insert("class".into(), "hl".into());
|
||||
expected.insert("key".into(), "val".into());
|
||||
assert_eq!(attrs, &expected);
|
||||
// The verbatim fence text is preserved for the HTML renderer.
|
||||
assert_eq!(raw, "rust class=\"hl\" key=val");
|
||||
}
|
||||
|
||||
// ===== Math block =====
|
||||
|
||||
@@ -190,8 +190,9 @@ fn file_tags_are_deduped_in_metadata() {
|
||||
fn renderer_emits_div_with_tag_spans() {
|
||||
let html = render(":todo:done:\n");
|
||||
assert!(html.contains("<div class=\"tags\">"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
|
||||
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
|
||||
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+209
-14
@@ -227,17 +227,31 @@ pub fn render_page_html(
|
||||
}
|
||||
|
||||
let mut r = HtmlRenderer::new();
|
||||
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
|
||||
{
|
||||
// Same-page anchor links (`[[#Section]]`) resolve to the *current*
|
||||
// page's html file plus the fragment (`index.html#Section`), matching
|
||||
// vimwiki — rather than a bare `#Section`.
|
||||
let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
|
||||
// Strip the wiki extension from page links before the default resolver
|
||||
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
|
||||
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
|
||||
// file:/local: paths keep their literal extension.
|
||||
let ext = ext.to_string();
|
||||
let ext = wiki_extension
|
||||
.filter(|e| !e.trim_start_matches('.').is_empty())
|
||||
.map(|e| e.to_string());
|
||||
r = r.with_link_resolver(move |target| {
|
||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
||||
return match &target.anchor {
|
||||
Some(a) => format!("{current_html}#{a}"),
|
||||
None => current_html.clone(),
|
||||
};
|
||||
}
|
||||
let mut t = target.clone();
|
||||
if let Some(ext) = &ext {
|
||||
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
|
||||
if let Some(p) = t.path.take() {
|
||||
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
|
||||
t.path = Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
nuwiki_core::render::html::default_link_resolver(&t)
|
||||
@@ -382,14 +396,195 @@ pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
|
||||
cfg.html_path.join(&cfg.css_name)
|
||||
}
|
||||
|
||||
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
|
||||
/// file exists yet at [`css_path`]. Deliberately tiny — users are
|
||||
/// expected to replace it with their own.
|
||||
pub const DEFAULT_CSS: &str =
|
||||
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
|
||||
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
|
||||
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
|
||||
code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
|
||||
table{border-collapse:collapse}\
|
||||
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
|
||||
ul.toc{padding-left:1.5em}\n";
|
||||
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
|
||||
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
|
||||
/// licensed) so exports look identical to upstream out of the box and the
|
||||
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
|
||||
pub const DEFAULT_CSS: &str = r#"body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
|
||||
margin: 2em 4em 2em 4em;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: bold;
|
||||
line-height:100%;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h1 {font-size: 2em; color: #000000;}
|
||||
h2 {font-size: 1.8em; color: #404040;}
|
||||
h3 {font-size: 1.6em; color: #707070;}
|
||||
h4 {font-size: 1.4em; color: #909090;}
|
||||
h5 {font-size: 1.2em; color: #989898;}
|
||||
h6 {font-size: 1em; color: #9c9c9c;}
|
||||
|
||||
p, pre, blockquote, table, ul, ol, dl {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
ul ul, ul ol, ol ol, ol ul {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
li { margin: 0.3em auto; }
|
||||
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
dt { font-weight: bold; }
|
||||
|
||||
img { border: none; }
|
||||
|
||||
pre {
|
||||
border-left: 5px solid #dcdcdc;
|
||||
background-color: #f5f5f5;
|
||||
padding-left: 1em;
|
||||
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
|
||||
font-size: 0.8em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
p > a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.7em;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background-color: #1e90ff;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p > a:hover {
|
||||
color: #dcdcdc;
|
||||
background-color: #484848;
|
||||
}
|
||||
|
||||
li > a {
|
||||
color: #1e90ff;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
li > a:hover { color: #ff4500; }
|
||||
|
||||
blockquote {
|
||||
color: #686868;
|
||||
font-size: 0.8em;
|
||||
line-height: 120%;
|
||||
padding: 0.8em;
|
||||
border-left: 5px solid #dcdcdc;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.3em;
|
||||
}
|
||||
|
||||
th { background-color: #f0f0f0; }
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: line-through;
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
.toc li { list-style-type: none; }
|
||||
|
||||
.todo {
|
||||
font-weight: bold;
|
||||
background-color: #ff4500 ;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.justleft { text-align: left; }
|
||||
.justright { text-align: right; }
|
||||
.justcenter { text-align: center; }
|
||||
|
||||
.center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: #eeeeee;
|
||||
font-family: monospace;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.header a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* classes for items of todo lists */
|
||||
|
||||
.rejected {
|
||||
/* list-style: none; */
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .2em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done0 {
|
||||
/* list-style: none; */
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .2em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done1 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done2 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done3 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.done4 {
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 .15em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
|
||||
-webkit-border-radius: 1px;
|
||||
-moz-border-radius: 1px;
|
||||
border-radius: 1px;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding-box;
|
||||
background-clip: padding-box;
|
||||
padding: 0px 3px;
|
||||
display: inline-block;
|
||||
color: #52595d;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -385,19 +385,27 @@ fn render_page_html_numbers_headers_when_enabled() {
|
||||
)
|
||||
.unwrap();
|
||||
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}"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
@@ -419,14 +427,45 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
|
||||
)
|
||||
.unwrap();
|
||||
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}"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
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]
|
||||
fn render_page_html_same_page_anchor_uses_current_filename() {
|
||||
// vimwiki parity (#4): `[[#Section]]` resolves to `<page>.html#Section`,
|
||||
// not a bare `#Section`, where <page> is the file being rendered.
|
||||
let ast = parse("= Contents =\n\n[[#Contents]]\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
None,
|
||||
"index",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.contains("<a href=\"index.html#Contents\">"),
|
||||
"got: {html}"
|
||||
);
|
||||
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -443,8 +482,18 @@ fn render_page_html_no_header_numbering_by_default() {
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
|
||||
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "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(
|
||||
"<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"
|
||||
),
|
||||
"got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user