1use prov::ContentFormat;
32
33pub fn render_body(body: &str, format: ContentFormat) -> String {
46 let html = render_markup(body, format);
47 #[cfg(feature = "syntax-highlighting")]
50 let html = crate::syntax::highlight_code_blocks(&html, crate::syntax::Syntaxes::bundled());
51 html
52}
53
54#[cfg(feature = "syntax-highlighting")]
61pub fn render_body_with(
62 body: &str,
63 format: ContentFormat,
64 syntaxes: &crate::syntax::Syntaxes,
65) -> String {
66 crate::syntax::highlight_code_blocks(&render_markup(body, format), syntaxes)
67}
68
69fn render_markup(body: &str, format: ContentFormat) -> String {
72 let mut preprocessed = preprocess_custom_syntax(body, format);
73 if !preprocessed.ends_with('\n') {
80 preprocessed.push('\n');
81 }
82 let rendered = match format {
83 ContentFormat::Markdown => render_markdown(&preprocessed),
84 _ => prov::render_html(&preprocessed, format),
85 };
86 rendered.unwrap_or_else(|_| {
87 format!(
88 "<pre class=\"diaryx-unrendered\">{}</pre>\n",
89 html_escape(body)
90 )
91 })
92}
93
94fn render_markdown(source: &str) -> prov::Result<String> {
114 use prov::twig::{ContainerOrigin, DirectiveForm, Document, Format, Kind, MarkdownExtensions};
115
116 let extensions = MarkdownExtensions {
117 directives: true,
118 ..MarkdownExtensions::default()
119 };
120 let parse = |text: &str| {
121 Document::parse_str_with(text, Format::Markdown, extensions)
122 .map_err(|e| prov::Error::Content(format!("twig parse: {e}")))
123 };
124 let mut doc = parse(source)?;
125
126 let bare: Vec<usize> = doc
127 .nodes()
128 .map_err(|e| prov::Error::Content(format!("twig nodes: {e}")))?
129 .iter()
130 .filter(|n| {
131 matches!(n.kind, Kind::Container)
132 && matches!(n.origin, Some(ContainerOrigin::Directive))
133 && matches!(n.directive_form, Some(DirectiveForm::Text))
134 && n.attrs.is_empty()
135 && n.content_span.as_ref().is_none_or(|c| c.is_empty())
136 && source.as_bytes().get(n.span.start) == Some(&b':')
137 })
138 .map(|n| n.span.start)
139 .collect();
140
141 if !bare.is_empty() {
142 let mut escaped = source.to_string();
144 for at in bare.into_iter().rev() {
145 escaped.insert(at, '\\');
146 }
147 doc = parse(&escaped)?;
148 }
149
150 let html = doc
151 .render_html()
152 .map_err(|e| prov::Error::Content(format!("twig render: {e}")))?;
153 String::from_utf8(html)
154 .map_err(|e| prov::Error::Content(format!("twig produced non-UTF-8 HTML: {e}")))
155}
156
157pub fn preprocess_custom_syntax(source: &str, format: ContentFormat) -> String {
171 if format == ContentFormat::Html {
172 return source.to_string();
173 }
174 let markdown = source;
175 let bytes = markdown.as_bytes();
176 let len = bytes.len();
177 let mut out = String::with_capacity(len);
178 let mut i = 0;
179 let code = prov::code_spans(source, format).unwrap_or_default();
190 let mut next_code = 0;
191
192 while i < len {
193 while next_code < code.len() && code[next_code].end <= i {
194 next_code += 1;
195 }
196 if let Some(span) = code.get(next_code)
197 && span.start <= i
198 {
199 out.push_str(&markdown[i..span.end]);
200 i = span.end;
201 continue;
202 }
203
204 if bytes[i] == b'\\'
211 && let Some(next) = bytes.get(i + 1)
212 && matches!(next, b'\\' | b'!' | b'=' | b'|')
213 {
214 out.push_str(&markdown[i..i + 2]);
215 i += 2;
216 continue;
217 }
218
219 if bytes[i] == b'!'
221 && i + 1 < len
222 && bytes[i + 1] == b'['
223 && let Some((html, consumed)) = try_parse_html_embed(&markdown[i..])
224 {
225 out.push_str(&raw_inline(&html, format));
226 i += consumed;
227 continue;
228 }
229
230 if i + 1 < len
232 && bytes[i] == b'='
233 && bytes[i + 1] == b'='
234 && let Some((html, consumed)) = try_parse_highlight(&markdown[i..])
235 {
236 out.push_str(&raw_inline(&html, format));
237 i += consumed;
238 continue;
239 }
240
241 if i + 1 < len
243 && bytes[i] == b'|'
244 && bytes[i + 1] == b'|'
245 && let Some((html, consumed)) = try_parse_spoiler(&markdown[i..])
246 {
247 out.push_str(&raw_inline(&html, format));
248 i += consumed;
249 continue;
250 }
251
252 out.push(markdown[i..].chars().next().unwrap());
253 i += markdown[i..].chars().next().unwrap().len_utf8();
254 }
255
256 out
257}
258
259fn raw_inline(html: &str, format: ContentFormat) -> String {
269 if format != ContentFormat::Djot {
270 return html.to_string();
271 }
272 let longest = html
273 .split(|c| c != '`')
274 .map(|run| run.len())
275 .max()
276 .unwrap_or(0);
277 let fence = "`".repeat(longest + 1);
278 let pad = if html.starts_with('`') || html.ends_with('`') {
281 " "
282 } else {
283 ""
284 };
285 format!("{fence}{pad}{html}{pad}{fence}{{=html}}")
286}
287
288fn try_parse_highlight(s: &str) -> Option<(String, usize)> {
290 const VALID_COLORS: &[&str] = &[
291 "red", "orange", "yellow", "green", "cyan", "blue", "violet", "pink", "brown", "grey",
292 ];
293
294 if !s.starts_with("==") {
295 return None;
296 }
297
298 let after_open = &s[2..];
299 if after_open.is_empty() || after_open.starts_with("==") {
300 return None;
301 }
302
303 let (color, content_start) = if after_open.starts_with('{') {
304 let close_brace = after_open.find('}')?;
305 let color_name = &after_open[1..close_brace];
306 if !VALID_COLORS.contains(&color_name) {
307 return None;
308 }
309 (color_name, close_brace + 1)
310 } else {
311 ("yellow", 0)
312 };
313
314 let content_region = &after_open[content_start..];
315 let close_pos = content_region.find("==")?;
316 if close_pos == 0 {
317 return None;
318 }
319
320 let content = &content_region[..close_pos];
321 if content.contains('\n') {
322 return None;
323 }
324
325 let total_consumed = 2 + content_start + close_pos + 2;
326 let html = format!(
327 r#"<mark data-highlight-color="{color}" class="highlight-mark highlight-{color}">{content}</mark>"#,
328 color = color,
329 content = html_escape(content),
330 );
331
332 Some((html, total_consumed))
333}
334
335fn try_parse_spoiler(s: &str) -> Option<(String, usize)> {
337 if !s.starts_with("||") {
338 return None;
339 }
340
341 let after_open = &s[2..];
342 if after_open.is_empty() || after_open.starts_with("||") {
343 return None;
344 }
345
346 let close_pos = after_open.find("||")?;
347 if close_pos == 0 {
348 return None;
349 }
350
351 let content = &after_open[..close_pos];
352 if content.contains('|') || content.contains('\n') {
353 return None;
354 }
355
356 let total_consumed = 2 + close_pos + 2;
357 let html = format!(
358 r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">{content}</span>"#,
359 content = html_escape(content),
360 );
361
362 Some((html, total_consumed))
363}
364
365const ISLAND_MIN_HEIGHT: u32 = 200;
373const ISLAND_MAX_HEIGHT: u32 = 4000;
374
375fn try_parse_html_embed(s: &str) -> Option<(String, usize)> {
389 if !s.starts_with("![") {
390 return None;
391 }
392
393 let after_bang = &s[2..];
394 let close_bracket = after_bang.find(']')?;
395 let alt = &after_bang[..close_bracket];
396
397 let after_bracket = &after_bang[close_bracket + 1..];
398 if !after_bracket.starts_with('(') {
399 return None;
400 }
401
402 let after_paren = &after_bracket[1..];
403 let close_paren = after_paren.find(')')?;
404 let path = after_paren[..close_paren].trim();
405
406 let lower = path.to_lowercase();
408 if !lower.ends_with(".html") && !lower.ends_with(".htm") {
409 return None;
410 }
411
412 let mut total_consumed = 2 + close_bracket + 1 + 1 + close_paren + 1;
413 let mut min_height = ISLAND_MIN_HEIGHT;
414 let after_embed = &s[total_consumed..];
415 if after_embed.starts_with('{') {
416 let close_brace = after_embed.find('}')?;
417 min_height = parse_island_height(&after_embed[1..close_brace])?;
418 total_consumed += close_brace + 1;
419 }
420
421 let html = format!(
422 r#"<iframe src="{}" title="{}" class="diaryx-island" sandbox="allow-scripts" loading="lazy" style="width:100%;min-height:{}px;border:none;"></iframe>"#,
423 html_escape(path),
424 html_escape(alt),
425 min_height,
426 );
427
428 Some((html, total_consumed))
429}
430
431fn parse_island_height(attributes: &str) -> Option<u32> {
434 let value = attributes.trim().strip_prefix("height")?.trim_start();
435 let value = value.strip_prefix('=')?.trim();
436 let height: u32 = value.parse().ok()?;
437 Some(height.clamp(ISLAND_MIN_HEIGHT, ISLAND_MAX_HEIGHT))
438}
439
440fn html_escape(s: &str) -> String {
442 s.replace('&', "&")
443 .replace('<', "<")
444 .replace('>', ">")
445 .replace('"', """)
446 .replace('\'', "'")
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn preprocess(source: &str) -> String {
456 preprocess_custom_syntax(source, ContentFormat::Markdown)
457 }
458
459 fn md(source: &str) -> String {
460 render_body(source, ContentFormat::Markdown)
461 }
462
463 #[test]
465 fn a_generic_directive_renders_as_an_element() {
466 let html = md(":::article{class=\"cover tone-blue\"}\n[Lake](lake.html)\n:::\n");
467 assert!(
468 html.contains("<article class=\"cover tone-blue\">"),
469 "{html}"
470 );
471 assert!(html.contains("<a href=\"lake.html\">Lake</a>"), "{html}");
472 assert!(html.contains("</article>"), "{html}");
473
474 let html = md("::cover[Label]{.x}\n\nA :swatch[see]{.red} b\n");
475 assert!(html.contains("<cover class=\"x\">Label</cover>"), "{html}");
476 assert!(
477 html.contains("<swatch class=\"red\">see</swatch>"),
478 "{html}"
479 );
480 }
481
482 #[test]
485 fn a_bare_colon_word_stays_prose() {
486 let html = md("Party :tada: at 12:30pm, a:b, http://x.y/z and `c:d`.\n");
487 assert!(html.contains("Party :tada: at 12:30pm, a:b,"), "{html}");
488 assert!(html.contains("<code>c:d</code>"), "{html}");
489 assert!(!html.contains("<tada"), "{html}");
490 assert!(!html.contains("<b>"), "{html}");
491
492 let html = md(":one and :two\n\n:three.\n");
495 assert!(html.contains(":one and :two"), "{html}");
496 assert!(html.contains(":three."), "{html}");
497 }
498
499 #[test]
502 fn a_directive_in_a_code_fence_is_quoted() {
503 let html = md("```\n:::note{.x}\nhi\n:::\n```\n");
504 assert!(html.contains(":::note{.x}"), "{html}");
505 assert!(!html.contains("<note"), "{html}");
506 }
507
508 #[test]
509 fn highlight_default_color() {
510 let out = preprocess("a ==hi== b");
511 assert_eq!(
512 out,
513 r#"a <mark data-highlight-color="yellow" class="highlight-mark highlight-yellow">hi</mark> b"#
514 );
515 }
516
517 #[test]
518 fn highlight_named_color() {
519 let out = preprocess("=={red}danger==");
520 assert!(out.contains(r#"data-highlight-color="red""#));
521 assert!(out.contains("highlight-red"));
522 assert!(out.contains(">danger<"));
523 }
524
525 #[test]
526 fn highlight_invalid_color_is_left_alone() {
527 let out = preprocess("=={mauve}x==");
528 assert_eq!(out, "=={mauve}x==");
529 }
530
531 #[test]
532 fn spoiler_basic() {
533 let out = preprocess("||secret||");
534 assert_eq!(
535 out,
536 r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">secret</span>"#
537 );
538 }
539
540 #[test]
541 fn html_embed_becomes_iframe() {
542 let out = preprocess("");
543 assert!(out.contains(r#"<iframe src="island.html""#));
544 assert!(out.contains(r#"title="demo""#));
545 assert!(out.contains(r#"class="diaryx-island""#));
546 }
547
548 #[test]
551 fn html_embed_takes_an_authored_height() {
552 let out = preprocess("{height=520}");
553 assert!(out.contains("min-height:520px"), "got {out}");
554 assert!(
555 !out.contains("{height=520}"),
556 "the block is consumed: {out}"
557 );
558 }
559
560 #[test]
563 fn an_authored_height_is_clamped_to_the_bridges_range() {
564 assert!(preprocess("{height=10}").contains("min-height:200px"));
565 assert!(preprocess("{height=99999}").contains("min-height:4000px"));
566 }
567
568 #[test]
572 fn an_unknown_island_attribute_leaves_the_embed_alone() {
573 let source = "{wdith=400}";
574 assert_eq!(preprocess(source), source);
575 assert_eq!(
576 preprocess("{height=tall}"),
577 "{height=tall}"
578 );
579 }
580
581 #[test]
586 fn an_escaped_embed_is_not_an_island() {
587 let out = preprocess(r"Write \ to embed one.");
588 assert_eq!(out, r"Write \ to embed one.");
589 assert!(!render_body(&out, ContentFormat::Markdown).contains("<iframe"));
590
591 assert_eq!(preprocess(r"\==not a highlight=="), r"\==not a highlight==");
594 assert_eq!(preprocess(r"\||not a spoiler||"), r"\||not a spoiler||");
595 assert!(preprocess(r"\\==yes==").contains("highlight-mark"));
596 }
597
598 #[test]
599 fn inline_code_is_untouched() {
600 let out = preprocess("`==not a highlight==`");
601 assert_eq!(out, "`==not a highlight==`");
602 }
603
604 #[test]
605 fn fenced_code_is_untouched() {
606 let input = "```\n==no==\n||no||\n```";
607 let out = preprocess(input);
608 assert_eq!(out, input);
609 }
610
611 #[test]
615 fn every_spelling_of_code_is_untouched() {
616 for input in [
617 "~~~\n==no==\n~~~",
618 "para\n\n ==no==\n \n\npost",
619 "a ``==no==`` b",
620 "- item\n\n ```\n ==no==\n ```\n",
621 ] {
622 assert_eq!(preprocess(input), input, "input: {input:?}");
623 }
624 }
625
626 #[test]
629 fn djot_fenced_code_is_untouched() {
630 let input = "```\n==no==\n||no||\n```\n";
631 assert_eq!(
632 preprocess_custom_syntax(input, ContentFormat::Djot),
633 input,
634 "a djot fence is code too"
635 );
636 let out = preprocess_custom_syntax("```\n==no==\n```\n\n==yes==\n", ContentFormat::Djot);
637 assert!(out.contains("```\n==no==\n```"), "fence intact: {out}");
638 assert!(
639 out.contains("highlight-mark"),
640 "prose still rewritten: {out}"
641 );
642 }
643
644 #[test]
645 fn escapes_content() {
646 let out = preprocess("==<b>&\"==");
647 assert!(out.contains("<b>&""));
648 }
649
650 #[test]
651 fn markdown_renders_basics() {
652 let html = render_body("# Title\n\n~~struck~~", ContentFormat::Markdown);
653 assert!(html.contains("<h1>"));
654 assert!(html.contains("<del>struck</del>"));
655 }
656
657 #[test]
662 fn markdown_still_covers_what_comrak_was_configured_for() {
663 let src = "~~struck~~\n\n\
664 | a | b |\n|---|---|\n| 1 | 2 |\n\n\
665 - [ ] todo\n- [x] done\n\n\
666 A note.[^1]\n\n[^1]: The note.\n\n\
667 <div class=\"raw\">passed through</div>\n\n\
668 https://example.test\n\n```rust\nlet x = 1;\n```\n";
669 let html = render_body(src, ContentFormat::Markdown);
670 assert!(html.contains("<del>struck</del>"), "strikethrough");
671 assert!(
672 html.contains("<table>") && html.contains("<th>a</th>"),
673 "tables"
674 );
675 assert!(html.contains("type=\"checkbox\""), "tasklists");
676 assert!(html.contains("checked"), "a checked tasklist item");
677 assert!(html.contains("The note."), "footnote text");
678 assert!(html.contains("<div class=\"raw\">"), "raw HTML passthrough");
679 assert!(
680 html.contains("<a href=\"https://example.test\""),
681 "autolinks"
682 );
683 assert!(html.contains("language-rust"), "fenced code language");
684 }
685
686 #[cfg(feature = "syntax-highlighting")]
690 #[test]
691 fn fenced_code_is_highlighted_in_every_grammar() {
692 for (format, src) in [
693 (ContentFormat::Markdown, "```rust\nlet x = 1;\n```\n"),
694 (ContentFormat::Djot, "```rust\nlet x = 1;\n```\n"),
695 (
696 ContentFormat::Html,
697 "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
698 ),
699 ] {
700 let html = render_body(src, format);
701 assert!(
702 html.contains(crate::syntax::HIGHLIGHTED_CLASS),
703 "{format:?} left it uncoloured: {html}"
704 );
705 assert!(html.contains("plates-storage"), "{format:?}: {html}");
706 }
707 }
708
709 #[cfg(feature = "syntax-highlighting")]
712 #[test]
713 fn highlighting_does_not_unescape_the_page() {
714 let html = render_body(
715 "```rust\nlet s = \"<b>&</b>\";\n```\n",
716 ContentFormat::Markdown,
717 );
718 assert!(!html.contains("<b>"), "a tag reached the page: {html}");
719 assert!(html.contains("<b>"), "still escaped: {html}");
720 }
721
722 #[cfg(feature = "syntax-highlighting")]
725 #[test]
726 fn a_site_grammar_reaches_a_rendered_body() {
727 let syntaxes = crate::syntax::Syntaxes::with_custom([(
728 "wat.sublime-syntax",
729 "name: Wat\nfile_extensions: [wat]\nscope: source.wat\ncontexts:\n main:\n - match: ';;.*$'\n scope: comment.line.wat\n",
730 )]);
731 let html = render_body_with(
732 "```wat\n;; a note\n```\n",
733 ContentFormat::Markdown,
734 &syntaxes,
735 );
736 assert!(html.contains("plates-comment"), "{html}");
737 }
738
739 #[test]
740 fn markdown_passes_preprocessed_raw_html_through() {
741 let html = render_body("==hi==", ContentFormat::Markdown);
742 assert!(html.contains("<mark"), "got {html}");
743 }
744
745 #[test]
749 fn djot_custom_syntax_survives_as_raw_html() {
750 let html = render_body("a ==hi== and ||shh|| b", ContentFormat::Djot);
751 assert!(
752 html.contains("<mark"),
753 "highlight reached the output: {html}"
754 );
755 assert!(html.contains("data-spoiler"), "spoiler too: {html}");
756 assert!(!html.contains("<mark"), "and was not escaped: {html}");
757 }
758
759 #[test]
760 fn djot_renders_its_own_grammar() {
761 let html = render_body("_emph_ and {=native=}\n", ContentFormat::Djot);
762 assert!(html.contains("<em>emph</em>"));
763 assert!(html.contains("<mark>native</mark>"));
764 }
765
766 #[test]
768 fn djot_raw_span_outruns_backticks_in_the_content() {
769 let out = preprocess_custom_syntax("==a ` b==", ContentFormat::Djot);
770 assert!(out.starts_with("``"), "fence outgrew the content: {out}");
771 assert!(out.ends_with("{=html}"), "and is a raw span: {out}");
772 let html = render_body("==a ` b==", ContentFormat::Djot);
773 assert!(html.contains("<mark"), "still a highlight: {html}");
774 }
775
776 #[test]
777 fn html_bodies_are_left_alone() {
778 let src = "<p>a == b || c</p>";
780 assert_eq!(preprocess_custom_syntax(src, ContentFormat::Html), src);
781 let html = render_body(src, ContentFormat::Html);
782 assert!(html.contains("a == b || c"), "got {html}");
783 assert!(!html.contains("<mark"));
784 }
785}