use prov::ContentFormat;
pub fn render_body(body: &str, format: ContentFormat) -> String {
let html = render_markup(body, format);
#[cfg(feature = "syntax-highlighting")]
let html = crate::syntax::highlight_code_blocks(&html, crate::syntax::Syntaxes::bundled());
html
}
#[cfg(feature = "syntax-highlighting")]
pub fn render_body_with(
body: &str,
format: ContentFormat,
syntaxes: &crate::syntax::Syntaxes,
) -> String {
crate::syntax::highlight_code_blocks(&render_markup(body, format), syntaxes)
}
fn render_markup(body: &str, format: ContentFormat) -> String {
let mut preprocessed = preprocess_custom_syntax(body, format);
if !preprocessed.ends_with('\n') {
preprocessed.push('\n');
}
let rendered = match format {
ContentFormat::Markdown => render_markdown(&preprocessed),
_ => prov::render_html(&preprocessed, format),
};
rendered.unwrap_or_else(|_| {
format!(
"<pre class=\"diaryx-unrendered\">{}</pre>\n",
html_escape(body)
)
})
}
fn render_markdown(source: &str) -> prov::Result<String> {
use prov::twig::{ContainerOrigin, DirectiveForm, Document, Format, Kind, MarkdownExtensions};
let extensions = MarkdownExtensions {
directives: true,
..MarkdownExtensions::default()
};
let parse = |text: &str| {
Document::parse_str_with(text, Format::Markdown, extensions)
.map_err(|e| prov::Error::Content(format!("twig parse: {e}")))
};
let mut doc = parse(source)?;
let bare: Vec<usize> = doc
.nodes()
.map_err(|e| prov::Error::Content(format!("twig nodes: {e}")))?
.iter()
.filter(|n| {
matches!(n.kind, Kind::Container)
&& matches!(n.origin, Some(ContainerOrigin::Directive))
&& matches!(n.directive_form, Some(DirectiveForm::Text))
&& n.attrs.is_empty()
&& n.content_span.as_ref().is_none_or(|c| c.is_empty())
&& source.as_bytes().get(n.span.start) == Some(&b':')
})
.map(|n| n.span.start)
.collect();
if !bare.is_empty() {
let mut escaped = source.to_string();
for at in bare.into_iter().rev() {
escaped.insert(at, '\\');
}
doc = parse(&escaped)?;
}
let html = doc
.render_html()
.map_err(|e| prov::Error::Content(format!("twig render: {e}")))?;
String::from_utf8(html)
.map_err(|e| prov::Error::Content(format!("twig produced non-UTF-8 HTML: {e}")))
}
pub fn preprocess_custom_syntax(source: &str, format: ContentFormat) -> String {
if format == ContentFormat::Html {
return source.to_string();
}
let markdown = source;
let bytes = markdown.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len);
let mut i = 0;
let code = prov::code_spans(source, format).unwrap_or_default();
let mut next_code = 0;
while i < len {
while next_code < code.len() && code[next_code].end <= i {
next_code += 1;
}
if let Some(span) = code.get(next_code)
&& span.start <= i
{
out.push_str(&markdown[i..span.end]);
i = span.end;
continue;
}
if bytes[i] == b'\\'
&& let Some(next) = bytes.get(i + 1)
&& matches!(next, b'\\' | b'!' | b'=' | b'|')
{
out.push_str(&markdown[i..i + 2]);
i += 2;
continue;
}
if bytes[i] == b'!'
&& i + 1 < len
&& bytes[i + 1] == b'['
&& let Some((html, consumed)) = try_parse_html_embed(&markdown[i..])
{
out.push_str(&raw_inline(&html, format));
i += consumed;
continue;
}
if i + 1 < len
&& bytes[i] == b'='
&& bytes[i + 1] == b'='
&& let Some((html, consumed)) = try_parse_highlight(&markdown[i..])
{
out.push_str(&raw_inline(&html, format));
i += consumed;
continue;
}
if i + 1 < len
&& bytes[i] == b'|'
&& bytes[i + 1] == b'|'
&& let Some((html, consumed)) = try_parse_spoiler(&markdown[i..])
{
out.push_str(&raw_inline(&html, format));
i += consumed;
continue;
}
out.push(markdown[i..].chars().next().unwrap());
i += markdown[i..].chars().next().unwrap().len_utf8();
}
out
}
fn raw_inline(html: &str, format: ContentFormat) -> String {
if format != ContentFormat::Djot {
return html.to_string();
}
let longest = html
.split(|c| c != '`')
.map(|run| run.len())
.max()
.unwrap_or(0);
let fence = "`".repeat(longest + 1);
let pad = if html.starts_with('`') || html.ends_with('`') {
" "
} else {
""
};
format!("{fence}{pad}{html}{pad}{fence}{{=html}}")
}
fn try_parse_highlight(s: &str) -> Option<(String, usize)> {
const VALID_COLORS: &[&str] = &[
"red", "orange", "yellow", "green", "cyan", "blue", "violet", "pink", "brown", "grey",
];
if !s.starts_with("==") {
return None;
}
let after_open = &s[2..];
if after_open.is_empty() || after_open.starts_with("==") {
return None;
}
let (color, content_start) = if after_open.starts_with('{') {
let close_brace = after_open.find('}')?;
let color_name = &after_open[1..close_brace];
if !VALID_COLORS.contains(&color_name) {
return None;
}
(color_name, close_brace + 1)
} else {
("yellow", 0)
};
let content_region = &after_open[content_start..];
let close_pos = content_region.find("==")?;
if close_pos == 0 {
return None;
}
let content = &content_region[..close_pos];
if content.contains('\n') {
return None;
}
let total_consumed = 2 + content_start + close_pos + 2;
let html = format!(
r#"<mark data-highlight-color="{color}" class="highlight-mark highlight-{color}">{content}</mark>"#,
color = color,
content = html_escape(content),
);
Some((html, total_consumed))
}
fn try_parse_spoiler(s: &str) -> Option<(String, usize)> {
if !s.starts_with("||") {
return None;
}
let after_open = &s[2..];
if after_open.is_empty() || after_open.starts_with("||") {
return None;
}
let close_pos = after_open.find("||")?;
if close_pos == 0 {
return None;
}
let content = &after_open[..close_pos];
if content.contains('|') || content.contains('\n') {
return None;
}
let total_consumed = 2 + close_pos + 2;
let html = format!(
r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">{content}</span>"#,
content = html_escape(content),
);
Some((html, total_consumed))
}
const ISLAND_MIN_HEIGHT: u32 = 200;
const ISLAND_MAX_HEIGHT: u32 = 4000;
fn try_parse_html_embed(s: &str) -> Option<(String, usize)> {
if !s.starts_with("![") {
return None;
}
let after_bang = &s[2..];
let close_bracket = after_bang.find(']')?;
let alt = &after_bang[..close_bracket];
let after_bracket = &after_bang[close_bracket + 1..];
if !after_bracket.starts_with('(') {
return None;
}
let after_paren = &after_bracket[1..];
let close_paren = after_paren.find(')')?;
let path = after_paren[..close_paren].trim();
let lower = path.to_lowercase();
if !lower.ends_with(".html") && !lower.ends_with(".htm") {
return None;
}
let mut total_consumed = 2 + close_bracket + 1 + 1 + close_paren + 1;
let mut min_height = ISLAND_MIN_HEIGHT;
let after_embed = &s[total_consumed..];
if after_embed.starts_with('{') {
let close_brace = after_embed.find('}')?;
min_height = parse_island_height(&after_embed[1..close_brace])?;
total_consumed += close_brace + 1;
}
let html = format!(
r#"<iframe src="{}" title="{}" class="diaryx-island" sandbox="allow-scripts" loading="lazy" style="width:100%;min-height:{}px;border:none;"></iframe>"#,
html_escape(path),
html_escape(alt),
min_height,
);
Some((html, total_consumed))
}
fn parse_island_height(attributes: &str) -> Option<u32> {
let value = attributes.trim().strip_prefix("height")?.trim_start();
let value = value.strip_prefix('=')?.trim();
let height: u32 = value.parse().ok()?;
Some(height.clamp(ISLAND_MIN_HEIGHT, ISLAND_MAX_HEIGHT))
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
fn preprocess(source: &str) -> String {
preprocess_custom_syntax(source, ContentFormat::Markdown)
}
fn md(source: &str) -> String {
render_body(source, ContentFormat::Markdown)
}
#[test]
fn a_generic_directive_renders_as_an_element() {
let html = md(":::article{class=\"cover tone-blue\"}\n[Lake](lake.html)\n:::\n");
assert!(
html.contains("<article class=\"cover tone-blue\">"),
"{html}"
);
assert!(html.contains("<a href=\"lake.html\">Lake</a>"), "{html}");
assert!(html.contains("</article>"), "{html}");
let html = md("::cover[Label]{.x}\n\nA :swatch[see]{.red} b\n");
assert!(html.contains("<cover class=\"x\">Label</cover>"), "{html}");
assert!(
html.contains("<swatch class=\"red\">see</swatch>"),
"{html}"
);
}
#[test]
fn a_bare_colon_word_stays_prose() {
let html = md("Party :tada: at 12:30pm, a:b, http://x.y/z and `c:d`.\n");
assert!(html.contains("Party :tada: at 12:30pm, a:b,"), "{html}");
assert!(html.contains("<code>c:d</code>"), "{html}");
assert!(!html.contains("<tada"), "{html}");
assert!(!html.contains("<b>"), "{html}");
let html = md(":one and :two\n\n:three.\n");
assert!(html.contains(":one and :two"), "{html}");
assert!(html.contains(":three."), "{html}");
}
#[test]
fn a_directive_in_a_code_fence_is_quoted() {
let html = md("```\n:::note{.x}\nhi\n:::\n```\n");
assert!(html.contains(":::note{.x}"), "{html}");
assert!(!html.contains("<note"), "{html}");
}
#[test]
fn highlight_default_color() {
let out = preprocess("a ==hi== b");
assert_eq!(
out,
r#"a <mark data-highlight-color="yellow" class="highlight-mark highlight-yellow">hi</mark> b"#
);
}
#[test]
fn highlight_named_color() {
let out = preprocess("=={red}danger==");
assert!(out.contains(r#"data-highlight-color="red""#));
assert!(out.contains("highlight-red"));
assert!(out.contains(">danger<"));
}
#[test]
fn highlight_invalid_color_is_left_alone() {
let out = preprocess("=={mauve}x==");
assert_eq!(out, "=={mauve}x==");
}
#[test]
fn spoiler_basic() {
let out = preprocess("||secret||");
assert_eq!(
out,
r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">secret</span>"#
);
}
#[test]
fn html_embed_becomes_iframe() {
let out = preprocess("");
assert!(out.contains(r#"<iframe src="island.html""#));
assert!(out.contains(r#"title="demo""#));
assert!(out.contains(r#"class="diaryx-island""#));
}
#[test]
fn html_embed_takes_an_authored_height() {
let out = preprocess("{height=520}");
assert!(out.contains("min-height:520px"), "got {out}");
assert!(
!out.contains("{height=520}"),
"the block is consumed: {out}"
);
}
#[test]
fn an_authored_height_is_clamped_to_the_bridges_range() {
assert!(preprocess("{height=10}").contains("min-height:200px"));
assert!(preprocess("{height=99999}").contains("min-height:4000px"));
}
#[test]
fn an_unknown_island_attribute_leaves_the_embed_alone() {
let source = "{wdith=400}";
assert_eq!(preprocess(source), source);
assert_eq!(
preprocess("{height=tall}"),
"{height=tall}"
);
}
#[test]
fn an_escaped_embed_is_not_an_island() {
let out = preprocess(r"Write \ to embed one.");
assert_eq!(out, r"Write \ to embed one.");
assert!(!render_body(&out, ContentFormat::Markdown).contains("<iframe"));
assert_eq!(preprocess(r"\==not a highlight=="), r"\==not a highlight==");
assert_eq!(preprocess(r"\||not a spoiler||"), r"\||not a spoiler||");
assert!(preprocess(r"\\==yes==").contains("highlight-mark"));
}
#[test]
fn inline_code_is_untouched() {
let out = preprocess("`==not a highlight==`");
assert_eq!(out, "`==not a highlight==`");
}
#[test]
fn fenced_code_is_untouched() {
let input = "```\n==no==\n||no||\n```";
let out = preprocess(input);
assert_eq!(out, input);
}
#[test]
fn every_spelling_of_code_is_untouched() {
for input in [
"~~~\n==no==\n~~~",
"para\n\n ==no==\n \n\npost",
"a ``==no==`` b",
"- item\n\n ```\n ==no==\n ```\n",
] {
assert_eq!(preprocess(input), input, "input: {input:?}");
}
}
#[test]
fn djot_fenced_code_is_untouched() {
let input = "```\n==no==\n||no||\n```\n";
assert_eq!(
preprocess_custom_syntax(input, ContentFormat::Djot),
input,
"a djot fence is code too"
);
let out = preprocess_custom_syntax("```\n==no==\n```\n\n==yes==\n", ContentFormat::Djot);
assert!(out.contains("```\n==no==\n```"), "fence intact: {out}");
assert!(
out.contains("highlight-mark"),
"prose still rewritten: {out}"
);
}
#[test]
fn escapes_content() {
let out = preprocess("==<b>&\"==");
assert!(out.contains("<b>&""));
}
#[test]
fn markdown_renders_basics() {
let html = render_body("# Title\n\n~~struck~~", ContentFormat::Markdown);
assert!(html.contains("<h1>"));
assert!(html.contains("<del>struck</del>"));
}
#[test]
fn markdown_still_covers_what_comrak_was_configured_for() {
let src = "~~struck~~\n\n\
| a | b |\n|---|---|\n| 1 | 2 |\n\n\
- [ ] todo\n- [x] done\n\n\
A note.[^1]\n\n[^1]: The note.\n\n\
<div class=\"raw\">passed through</div>\n\n\
https://example.test\n\n```rust\nlet x = 1;\n```\n";
let html = render_body(src, ContentFormat::Markdown);
assert!(html.contains("<del>struck</del>"), "strikethrough");
assert!(
html.contains("<table>") && html.contains("<th>a</th>"),
"tables"
);
assert!(html.contains("type=\"checkbox\""), "tasklists");
assert!(html.contains("checked"), "a checked tasklist item");
assert!(html.contains("The note."), "footnote text");
assert!(html.contains("<div class=\"raw\">"), "raw HTML passthrough");
assert!(
html.contains("<a href=\"https://example.test\""),
"autolinks"
);
assert!(html.contains("language-rust"), "fenced code language");
}
#[cfg(feature = "syntax-highlighting")]
#[test]
fn fenced_code_is_highlighted_in_every_grammar() {
for (format, src) in [
(ContentFormat::Markdown, "```rust\nlet x = 1;\n```\n"),
(ContentFormat::Djot, "```rust\nlet x = 1;\n```\n"),
(
ContentFormat::Html,
"<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
),
] {
let html = render_body(src, format);
assert!(
html.contains(crate::syntax::HIGHLIGHTED_CLASS),
"{format:?} left it uncoloured: {html}"
);
assert!(html.contains("plates-storage"), "{format:?}: {html}");
}
}
#[cfg(feature = "syntax-highlighting")]
#[test]
fn highlighting_does_not_unescape_the_page() {
let html = render_body(
"```rust\nlet s = \"<b>&</b>\";\n```\n",
ContentFormat::Markdown,
);
assert!(!html.contains("<b>"), "a tag reached the page: {html}");
assert!(html.contains("<b>"), "still escaped: {html}");
}
#[cfg(feature = "syntax-highlighting")]
#[test]
fn a_site_grammar_reaches_a_rendered_body() {
let syntaxes = crate::syntax::Syntaxes::with_custom([(
"wat.sublime-syntax",
"name: Wat\nfile_extensions: [wat]\nscope: source.wat\ncontexts:\n main:\n - match: ';;.*$'\n scope: comment.line.wat\n",
)]);
let html = render_body_with(
"```wat\n;; a note\n```\n",
ContentFormat::Markdown,
&syntaxes,
);
assert!(html.contains("plates-comment"), "{html}");
}
#[test]
fn markdown_passes_preprocessed_raw_html_through() {
let html = render_body("==hi==", ContentFormat::Markdown);
assert!(html.contains("<mark"), "got {html}");
}
#[test]
fn djot_custom_syntax_survives_as_raw_html() {
let html = render_body("a ==hi== and ||shh|| b", ContentFormat::Djot);
assert!(
html.contains("<mark"),
"highlight reached the output: {html}"
);
assert!(html.contains("data-spoiler"), "spoiler too: {html}");
assert!(!html.contains("<mark"), "and was not escaped: {html}");
}
#[test]
fn djot_renders_its_own_grammar() {
let html = render_body("_emph_ and {=native=}\n", ContentFormat::Djot);
assert!(html.contains("<em>emph</em>"));
assert!(html.contains("<mark>native</mark>"));
}
#[test]
fn djot_raw_span_outruns_backticks_in_the_content() {
let out = preprocess_custom_syntax("==a ` b==", ContentFormat::Djot);
assert!(out.starts_with("``"), "fence outgrew the content: {out}");
assert!(out.ends_with("{=html}"), "and is a raw span: {out}");
let html = render_body("==a ` b==", ContentFormat::Djot);
assert!(html.contains("<mark"), "still a highlight: {html}");
}
#[test]
fn html_bodies_are_left_alone() {
let src = "<p>a == b || c</p>";
assert_eq!(preprocess_custom_syntax(src, ContentFormat::Html), src);
let html = render_body(src, ContentFormat::Html);
assert!(html.contains("a == b || c"), "got {html}");
assert!(!html.contains("<mark"));
}
}