2026-05-30 18:35:40 +00:00
|
|
|
//! HTML export — pure helpers shared by the `nuwiki.export.*`
|
2026-05-11 21:37:36 +00:00
|
|
|
//! command handlers. Side-effecting work (reading the template from
|
|
|
|
|
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
|
|
|
|
|
//! since the dispatcher is the one with a `&Backend` and async context.
|
|
|
|
|
//!
|
|
|
|
|
//! Design split:
|
|
|
|
|
//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path.
|
|
|
|
|
//! - [`template_for`] — locate the right template file on disk.
|
|
|
|
|
//! - [`fallback_template`] — minimal stand-in when nothing is found.
|
|
|
|
|
//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`).
|
|
|
|
|
//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable.
|
|
|
|
|
//! - [`compute_root_path`] — relative path from a page's output to
|
|
|
|
|
//! `html_path` for stylesheet hrefs in nested pages.
|
|
|
|
|
//! - [`is_excluded`] — glob match against `exclude_files`.
|
|
|
|
|
//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the
|
|
|
|
|
//! above wired up; returns the final HTML string.
|
|
|
|
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
2026-05-28 21:19:55 -03:00
|
|
|
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind};
|
2026-05-11 21:37:36 +00:00
|
|
|
use nuwiki_core::date::DiaryDate;
|
|
|
|
|
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
|
|
|
|
|
|
|
|
|
use crate::config::{HtmlConfig, WikiConfig};
|
|
|
|
|
|
|
|
|
|
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
|
|
|
|
|
/// output path under `html_path`. Honours
|
|
|
|
|
/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs
|
|
|
|
|
/// — when enabled, only the final path segment is slugified (so the
|
|
|
|
|
/// `diary/` subdir survives).
|
|
|
|
|
pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf {
|
|
|
|
|
let segments: Vec<String> = name
|
|
|
|
|
.split('/')
|
|
|
|
|
.map(|s| {
|
|
|
|
|
if cfg.html_filename_parameterization {
|
|
|
|
|
slugify(s)
|
|
|
|
|
} else {
|
|
|
|
|
s.to_string()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let mut p = cfg.html_path.clone();
|
|
|
|
|
for (i, seg) in segments.iter().enumerate() {
|
|
|
|
|
if i + 1 == segments.len() {
|
|
|
|
|
p.push(format!("{seg}.html"));
|
|
|
|
|
} else {
|
|
|
|
|
p.push(seg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Locate the template body for a document. Resolution order:
|
|
|
|
|
/// 1. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
|
|
|
|
|
/// 2. `<template_path>/<template_default>.<template_ext>`.
|
|
|
|
|
/// 3. [`fallback_template`].
|
|
|
|
|
///
|
|
|
|
|
/// The on-disk read is performed by the caller (`commands.rs`) via the
|
|
|
|
|
/// [`TemplateSource`] returned here. Keeps this fn pure for tests.
|
|
|
|
|
pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource {
|
|
|
|
|
let primary = doc.metadata.template.as_deref().map(|name| {
|
|
|
|
|
cfg.template_path
|
|
|
|
|
.join(format!("{name}{}", cfg.template_ext))
|
|
|
|
|
});
|
|
|
|
|
let fallback = cfg
|
|
|
|
|
.template_path
|
|
|
|
|
.join(format!("{}{}", cfg.template_default, cfg.template_ext));
|
|
|
|
|
TemplateSource { primary, fallback }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Two-tier lookup result. Caller tries `primary` first, then `fallback`,
|
|
|
|
|
/// then falls back to [`fallback_template`] when neither file exists.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct TemplateSource {
|
|
|
|
|
pub primary: Option<PathBuf>,
|
|
|
|
|
pub fallback: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Minimal valid HTML page used when no template file is found on disk.
|
|
|
|
|
/// Includes the same `{{title}}` / `{{content}}` placeholders as a real
|
|
|
|
|
/// template so the renderer's substitution still works.
|
|
|
|
|
pub fn fallback_template(css_name: &str) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
r#"<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<title>{{{{title}}}}</title>
|
|
|
|
|
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
{{{{content}}}}
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"#
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month),
|
|
|
|
|
/// `%d` (2-digit day), `%%` (literal percent). Anything else passes
|
|
|
|
|
/// through. The diary use case never produces time-of-day output, so
|
|
|
|
|
/// `%H`/`%M`/`%S` are intentionally omitted.
|
|
|
|
|
pub fn format_date(date: &DiaryDate, fmt: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(fmt.len() + 4);
|
|
|
|
|
let bytes = fmt.as_bytes();
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
while i < bytes.len() {
|
|
|
|
|
if bytes[i] == b'%' && i + 1 < bytes.len() {
|
|
|
|
|
match bytes[i + 1] {
|
|
|
|
|
b'Y' => out.push_str(&format!("{:04}", date.year)),
|
|
|
|
|
b'm' => out.push_str(&format!("{:02}", date.month)),
|
|
|
|
|
b'd' => out.push_str(&format!("{:02}", date.day)),
|
|
|
|
|
b'%' => out.push('%'),
|
|
|
|
|
other => {
|
|
|
|
|
out.push('%');
|
|
|
|
|
out.push(other as char);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += 2;
|
|
|
|
|
} else {
|
|
|
|
|
out.push(bytes[i] as char);
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Render the page's headings as a nested `<ul>` for the `{{toc}}`
|
|
|
|
|
/// template variable. Returns an empty string when the document has
|
|
|
|
|
/// no headings.
|
|
|
|
|
pub fn build_toc_html(doc: &DocumentNode) -> String {
|
|
|
|
|
let headings = collect_headings(doc);
|
|
|
|
|
if headings.is_empty() {
|
|
|
|
|
return String::new();
|
|
|
|
|
}
|
|
|
|
|
let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
|
|
|
|
|
let mut out = String::from("<ul class=\"toc\">");
|
|
|
|
|
let mut depth = 0usize;
|
|
|
|
|
for h in &headings {
|
|
|
|
|
let target = h.level.saturating_sub(min_level) as usize;
|
|
|
|
|
while depth < target {
|
|
|
|
|
out.push_str("<ul>");
|
|
|
|
|
depth += 1;
|
|
|
|
|
}
|
|
|
|
|
while depth > target {
|
|
|
|
|
out.push_str("</ul>");
|
|
|
|
|
depth -= 1;
|
|
|
|
|
}
|
|
|
|
|
let anchor = crate::index::slugify(&h.title);
|
|
|
|
|
out.push_str(&format!(
|
|
|
|
|
"<li><a href=\"#{anchor}\">{title}</a></li>",
|
|
|
|
|
title = escape_html(&h.title)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
while depth > 0 {
|
|
|
|
|
out.push_str("</ul>");
|
|
|
|
|
depth -= 1;
|
|
|
|
|
}
|
|
|
|
|
out.push_str("</ul>");
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compute the relative path back to `html_path` from the directory
|
|
|
|
|
/// holding the output file for `name`. Used as the `{{root_path}}`
|
|
|
|
|
/// prefix so a nested page's `<link>`/`<script>` hrefs still find
|
|
|
|
|
/// the shared CSS at the export root.
|
|
|
|
|
pub fn compute_root_path(name: &str) -> String {
|
|
|
|
|
let depth = name.matches('/').count();
|
|
|
|
|
if depth == 0 {
|
|
|
|
|
String::new()
|
|
|
|
|
} else {
|
|
|
|
|
let mut s = String::with_capacity(depth * 3);
|
|
|
|
|
for _ in 0..depth {
|
|
|
|
|
s.push_str("../");
|
|
|
|
|
}
|
|
|
|
|
s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// True if `name` matches any of the `exclude_files` glob patterns.
|
|
|
|
|
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
|
|
|
|
|
/// segments including `/`), `?` (one char). Anything else is literal.
|
|
|
|
|
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
|
|
|
|
|
patterns.iter().any(|p| glob_match(p, name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pure HTML render pass. The caller has already loaded the template
|
|
|
|
|
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
|
|
|
|
/// plus arbitrary extras passed via `extra_vars`.
|
2026-05-28 21:19:55 -03:00
|
|
|
///
|
|
|
|
|
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
|
|
|
|
|
/// When set, it is stripped from wiki/interwiki link targets so a link
|
|
|
|
|
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
|
|
|
|
|
/// matching how the LSP resolves the same link for in-editor navigation.
|
2026-05-31 08:13:30 -03:00
|
|
|
// Every parameter is a distinct, cohesive render input; bundling them into a
|
|
|
|
|
// struct would add an abstraction without simplifying the single caller.
|
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-05-11 21:37:36 +00:00
|
|
|
pub fn render_page_html(
|
|
|
|
|
doc: &DocumentNode,
|
|
|
|
|
template: Option<String>,
|
|
|
|
|
name: &str,
|
|
|
|
|
today: DiaryDate,
|
|
|
|
|
cfg: &HtmlConfig,
|
2026-05-28 21:19:55 -03:00
|
|
|
wiki_extension: Option<&str>,
|
2026-05-30 23:12:08 -03:00
|
|
|
list_margin: i32,
|
2026-05-11 21:37:36 +00:00
|
|
|
extra_vars: HashMap<String, String>,
|
|
|
|
|
) -> std::io::Result<String> {
|
|
|
|
|
let date_str = match doc.metadata.date.as_deref() {
|
|
|
|
|
Some(s) => match DiaryDate::parse(s) {
|
|
|
|
|
Some(d) => format_date(&d, &cfg.template_date_format),
|
|
|
|
|
None => s.to_string(),
|
|
|
|
|
},
|
|
|
|
|
None => format_date(&today, &cfg.template_date_format),
|
|
|
|
|
};
|
|
|
|
|
let root_path = compute_root_path(name);
|
|
|
|
|
let toc = build_toc_html(doc);
|
|
|
|
|
|
|
|
|
|
let mut vars: HashMap<String, String> = HashMap::new();
|
|
|
|
|
vars.insert("date".into(), date_str);
|
|
|
|
|
vars.insert("root_path".into(), root_path);
|
|
|
|
|
vars.insert("toc".into(), toc);
|
|
|
|
|
vars.insert("css".into(), cfg.css_name.clone());
|
2026-05-28 21:19:55 -03:00
|
|
|
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
|
|
|
|
|
// templates resolve. nuwiki's own templates use `{{css}}`.
|
|
|
|
|
vars.insert("wiki_css".into(), cfg.css_name.clone());
|
2026-05-11 21:37:36 +00:00
|
|
|
for (k, v) in extra_vars {
|
|
|
|
|
vars.insert(k, v);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 23:12:08 -03:00
|
|
|
let mut r = HtmlRenderer::new().with_list_margin(list_margin);
|
2026-05-28 21:19:55 -03:00
|
|
|
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
|
|
|
|
|
// 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();
|
|
|
|
|
r = r.with_link_resolver(move |target| {
|
|
|
|
|
let mut t = target.clone();
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
nuwiki_core::render::html::default_link_resolver(&t)
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-11 21:37:36 +00:00
|
|
|
if let Some(t) = template {
|
|
|
|
|
r = r.with_template(t);
|
|
|
|
|
}
|
|
|
|
|
r = r.with_vars(vars);
|
2026-05-12 14:45:39 +00:00
|
|
|
if !cfg.color_dic.is_empty() {
|
|
|
|
|
r = r.with_colors(cfg.color_dic.clone());
|
|
|
|
|
}
|
2026-06-02 00:33:01 +00:00
|
|
|
if cfg.html_header_numbering > 0 {
|
|
|
|
|
r = r.with_header_numbering(
|
|
|
|
|
cfg.html_header_numbering,
|
|
|
|
|
cfg.html_header_numbering_sym.clone(),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-11 21:37:36 +00:00
|
|
|
r.render_to_string(doc)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Internals =====
|
|
|
|
|
|
|
|
|
|
fn slugify(s: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(s.len());
|
|
|
|
|
let mut prev_dash = false;
|
|
|
|
|
for ch in s.chars() {
|
|
|
|
|
if ch.is_alphanumeric() {
|
|
|
|
|
for c in ch.to_lowercase() {
|
|
|
|
|
out.push(c);
|
|
|
|
|
}
|
|
|
|
|
prev_dash = false;
|
|
|
|
|
} else if !prev_dash && !out.is_empty() {
|
|
|
|
|
out.push('-');
|
|
|
|
|
prev_dash = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while out.ends_with('-') {
|
|
|
|
|
out.pop();
|
|
|
|
|
}
|
|
|
|
|
if out.is_empty() {
|
|
|
|
|
"untitled".into()
|
|
|
|
|
} else {
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for b in &doc.children {
|
|
|
|
|
if let BlockNode::Heading(h) = b {
|
|
|
|
|
out.push(HeadingProj {
|
|
|
|
|
level: h.level,
|
|
|
|
|
title: inline_to_text(h),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct HeadingProj {
|
|
|
|
|
level: u8,
|
|
|
|
|
title: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inline_to_text(h: &HeadingNode) -> String {
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
for n in &h.children {
|
|
|
|
|
push_inline(n, &mut s);
|
|
|
|
|
}
|
|
|
|
|
s.trim().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_inline(n: &InlineNode, out: &mut String) {
|
|
|
|
|
match n {
|
|
|
|
|
InlineNode::Text(t) => out.push_str(&t.content),
|
|
|
|
|
InlineNode::Bold(b) => b.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::Italic(i) => i.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::BoldItalic(bi) => bi.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::Strikethrough(s) => s.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::Code(c) => out.push_str(&c.content),
|
|
|
|
|
InlineNode::Superscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::Subscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::Color(c) => c.children.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
InlineNode::WikiLink(w) => match &w.description {
|
|
|
|
|
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
None => {
|
|
|
|
|
if let Some(p) = &w.target.path {
|
|
|
|
|
out.push_str(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
InlineNode::ExternalLink(e) => match &e.description {
|
|
|
|
|
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
|
|
|
|
|
None => out.push_str(&e.url),
|
|
|
|
|
},
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_html(s: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(s.len());
|
|
|
|
|
for ch in s.chars() {
|
|
|
|
|
match ch {
|
|
|
|
|
'&' => out.push_str("&"),
|
|
|
|
|
'<' => out.push_str("<"),
|
|
|
|
|
'>' => out.push_str(">"),
|
|
|
|
|
'"' => out.push_str("""),
|
|
|
|
|
_ => out.push(ch),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
|
|
|
|
/// literal characters. No character classes — those aren't needed for
|
|
|
|
|
/// "exclude foo/*.tmp" style patterns.
|
|
|
|
|
fn glob_match(pattern: &str, text: &str) -> bool {
|
|
|
|
|
glob_impl(pattern.as_bytes(), text.as_bytes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
|
|
|
|
|
let mut pi = 0;
|
|
|
|
|
let mut ti = 0;
|
|
|
|
|
let mut star: Option<(usize, usize)> = None;
|
|
|
|
|
let mut globstar = false;
|
|
|
|
|
while ti < t.len() {
|
|
|
|
|
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
|
|
|
|
|
globstar = true;
|
|
|
|
|
star = Some((pi, ti));
|
|
|
|
|
pi += 2;
|
|
|
|
|
// Allow optional `/` after globstar.
|
|
|
|
|
if pi < p.len() && p[pi] == b'/' {
|
|
|
|
|
pi += 1;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
} else if pi < p.len() && p[pi] == b'*' {
|
|
|
|
|
star = Some((pi, ti));
|
|
|
|
|
pi += 1;
|
|
|
|
|
globstar = false;
|
|
|
|
|
continue;
|
|
|
|
|
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
|
|
|
|
|
pi += 1;
|
|
|
|
|
ti += 1;
|
|
|
|
|
continue;
|
|
|
|
|
} else if let Some((s_pi, s_ti)) = star {
|
|
|
|
|
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
|
|
|
|
|
// single-star can't cross a `/`
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
|
|
|
|
|
ti = s_ti + 1;
|
|
|
|
|
star = Some((s_pi, ti));
|
|
|
|
|
continue;
|
|
|
|
|
} else {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
|
|
|
|
|
pi += 1;
|
|
|
|
|
}
|
|
|
|
|
pi == p.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
|
|
|
|
|
/// renderer references via `{{root_path}}{{css}}`.
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
/// Convenience wrapper exposed for the dispatcher: convert a wiki config
|
|
|
|
|
/// to the absolute output dir.
|
|
|
|
|
pub fn html_output_dir(cfg: &WikiConfig) -> &Path {
|
|
|
|
|
&cfg.html.html_path
|
|
|
|
|
}
|