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 prov::render_html(&preprocessed, format).unwrap_or_else(|_| {
83 format!(
84 "<pre class=\"diaryx-unrendered\">{}</pre>\n",
85 html_escape(body)
86 )
87 })
88}
89
90pub fn preprocess_custom_syntax(source: &str, format: ContentFormat) -> String {
104 if format == ContentFormat::Html {
105 return source.to_string();
106 }
107 let markdown = source;
108 let bytes = markdown.as_bytes();
109 let len = bytes.len();
110 let mut out = String::with_capacity(len);
111 let mut i = 0;
112 let code = prov::code_spans(source, format).unwrap_or_default();
123 let mut next_code = 0;
124
125 while i < len {
126 while next_code < code.len() && code[next_code].end <= i {
127 next_code += 1;
128 }
129 if let Some(span) = code.get(next_code)
130 && span.start <= i
131 {
132 out.push_str(&markdown[i..span.end]);
133 i = span.end;
134 continue;
135 }
136
137 if bytes[i] == b'\\'
144 && let Some(next) = bytes.get(i + 1)
145 && matches!(next, b'\\' | b'!' | b'=' | b'|')
146 {
147 out.push_str(&markdown[i..i + 2]);
148 i += 2;
149 continue;
150 }
151
152 if bytes[i] == b'!'
154 && i + 1 < len
155 && bytes[i + 1] == b'['
156 && let Some((html, consumed)) = try_parse_html_embed(&markdown[i..])
157 {
158 out.push_str(&raw_inline(&html, format));
159 i += consumed;
160 continue;
161 }
162
163 if i + 1 < len
165 && bytes[i] == b'='
166 && bytes[i + 1] == b'='
167 && let Some((html, consumed)) = try_parse_highlight(&markdown[i..])
168 {
169 out.push_str(&raw_inline(&html, format));
170 i += consumed;
171 continue;
172 }
173
174 if i + 1 < len
176 && bytes[i] == b'|'
177 && bytes[i + 1] == b'|'
178 && let Some((html, consumed)) = try_parse_spoiler(&markdown[i..])
179 {
180 out.push_str(&raw_inline(&html, format));
181 i += consumed;
182 continue;
183 }
184
185 out.push(markdown[i..].chars().next().unwrap());
186 i += markdown[i..].chars().next().unwrap().len_utf8();
187 }
188
189 out
190}
191
192fn raw_inline(html: &str, format: ContentFormat) -> String {
202 if format != ContentFormat::Djot {
203 return html.to_string();
204 }
205 let longest = html
206 .split(|c| c != '`')
207 .map(|run| run.len())
208 .max()
209 .unwrap_or(0);
210 let fence = "`".repeat(longest + 1);
211 let pad = if html.starts_with('`') || html.ends_with('`') {
214 " "
215 } else {
216 ""
217 };
218 format!("{fence}{pad}{html}{pad}{fence}{{=html}}")
219}
220
221fn try_parse_highlight(s: &str) -> Option<(String, usize)> {
223 const VALID_COLORS: &[&str] = &[
224 "red", "orange", "yellow", "green", "cyan", "blue", "violet", "pink", "brown", "grey",
225 ];
226
227 if !s.starts_with("==") {
228 return None;
229 }
230
231 let after_open = &s[2..];
232 if after_open.is_empty() || after_open.starts_with("==") {
233 return None;
234 }
235
236 let (color, content_start) = if after_open.starts_with('{') {
237 let close_brace = after_open.find('}')?;
238 let color_name = &after_open[1..close_brace];
239 if !VALID_COLORS.contains(&color_name) {
240 return None;
241 }
242 (color_name, close_brace + 1)
243 } else {
244 ("yellow", 0)
245 };
246
247 let content_region = &after_open[content_start..];
248 let close_pos = content_region.find("==")?;
249 if close_pos == 0 {
250 return None;
251 }
252
253 let content = &content_region[..close_pos];
254 if content.contains('\n') {
255 return None;
256 }
257
258 let total_consumed = 2 + content_start + close_pos + 2;
259 let html = format!(
260 r#"<mark data-highlight-color="{color}" class="highlight-mark highlight-{color}">{content}</mark>"#,
261 color = color,
262 content = html_escape(content),
263 );
264
265 Some((html, total_consumed))
266}
267
268fn try_parse_spoiler(s: &str) -> Option<(String, usize)> {
270 if !s.starts_with("||") {
271 return None;
272 }
273
274 let after_open = &s[2..];
275 if after_open.is_empty() || after_open.starts_with("||") {
276 return None;
277 }
278
279 let close_pos = after_open.find("||")?;
280 if close_pos == 0 {
281 return None;
282 }
283
284 let content = &after_open[..close_pos];
285 if content.contains('|') || content.contains('\n') {
286 return None;
287 }
288
289 let total_consumed = 2 + close_pos + 2;
290 let html = format!(
291 r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">{content}</span>"#,
292 content = html_escape(content),
293 );
294
295 Some((html, total_consumed))
296}
297
298const ISLAND_MIN_HEIGHT: u32 = 200;
306const ISLAND_MAX_HEIGHT: u32 = 4000;
307
308fn try_parse_html_embed(s: &str) -> Option<(String, usize)> {
322 if !s.starts_with("![") {
323 return None;
324 }
325
326 let after_bang = &s[2..];
327 let close_bracket = after_bang.find(']')?;
328 let alt = &after_bang[..close_bracket];
329
330 let after_bracket = &after_bang[close_bracket + 1..];
331 if !after_bracket.starts_with('(') {
332 return None;
333 }
334
335 let after_paren = &after_bracket[1..];
336 let close_paren = after_paren.find(')')?;
337 let path = after_paren[..close_paren].trim();
338
339 let lower = path.to_lowercase();
341 if !lower.ends_with(".html") && !lower.ends_with(".htm") {
342 return None;
343 }
344
345 let mut total_consumed = 2 + close_bracket + 1 + 1 + close_paren + 1;
346 let mut min_height = ISLAND_MIN_HEIGHT;
347 let after_embed = &s[total_consumed..];
348 if after_embed.starts_with('{') {
349 let close_brace = after_embed.find('}')?;
350 min_height = parse_island_height(&after_embed[1..close_brace])?;
351 total_consumed += close_brace + 1;
352 }
353
354 let html = format!(
355 r#"<iframe src="{}" title="{}" class="diaryx-island" sandbox="allow-scripts" loading="lazy" style="width:100%;min-height:{}px;border:none;"></iframe>"#,
356 html_escape(path),
357 html_escape(alt),
358 min_height,
359 );
360
361 Some((html, total_consumed))
362}
363
364fn parse_island_height(attributes: &str) -> Option<u32> {
367 let value = attributes.trim().strip_prefix("height")?.trim_start();
368 let value = value.strip_prefix('=')?.trim();
369 let height: u32 = value.parse().ok()?;
370 Some(height.clamp(ISLAND_MIN_HEIGHT, ISLAND_MAX_HEIGHT))
371}
372
373fn html_escape(s: &str) -> String {
375 s.replace('&', "&")
376 .replace('<', "<")
377 .replace('>', ">")
378 .replace('"', """)
379 .replace('\'', "'")
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn preprocess(source: &str) -> String {
389 preprocess_custom_syntax(source, ContentFormat::Markdown)
390 }
391
392 #[test]
393 fn highlight_default_color() {
394 let out = preprocess("a ==hi== b");
395 assert_eq!(
396 out,
397 r#"a <mark data-highlight-color="yellow" class="highlight-mark highlight-yellow">hi</mark> b"#
398 );
399 }
400
401 #[test]
402 fn highlight_named_color() {
403 let out = preprocess("=={red}danger==");
404 assert!(out.contains(r#"data-highlight-color="red""#));
405 assert!(out.contains("highlight-red"));
406 assert!(out.contains(">danger<"));
407 }
408
409 #[test]
410 fn highlight_invalid_color_is_left_alone() {
411 let out = preprocess("=={mauve}x==");
412 assert_eq!(out, "=={mauve}x==");
413 }
414
415 #[test]
416 fn spoiler_basic() {
417 let out = preprocess("||secret||");
418 assert_eq!(
419 out,
420 r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">secret</span>"#
421 );
422 }
423
424 #[test]
425 fn html_embed_becomes_iframe() {
426 let out = preprocess("");
427 assert!(out.contains(r#"<iframe src="island.html""#));
428 assert!(out.contains(r#"title="demo""#));
429 assert!(out.contains(r#"class="diaryx-island""#));
430 }
431
432 #[test]
435 fn html_embed_takes_an_authored_height() {
436 let out = preprocess("{height=520}");
437 assert!(out.contains("min-height:520px"), "got {out}");
438 assert!(
439 !out.contains("{height=520}"),
440 "the block is consumed: {out}"
441 );
442 }
443
444 #[test]
447 fn an_authored_height_is_clamped_to_the_bridges_range() {
448 assert!(preprocess("{height=10}").contains("min-height:200px"));
449 assert!(preprocess("{height=99999}").contains("min-height:4000px"));
450 }
451
452 #[test]
456 fn an_unknown_island_attribute_leaves_the_embed_alone() {
457 let source = "{wdith=400}";
458 assert_eq!(preprocess(source), source);
459 assert_eq!(
460 preprocess("{height=tall}"),
461 "{height=tall}"
462 );
463 }
464
465 #[test]
470 fn an_escaped_embed_is_not_an_island() {
471 let out = preprocess(r"Write \ to embed one.");
472 assert_eq!(out, r"Write \ to embed one.");
473 assert!(!render_body(&out, ContentFormat::Markdown).contains("<iframe"));
474
475 assert_eq!(preprocess(r"\==not a highlight=="), r"\==not a highlight==");
478 assert_eq!(preprocess(r"\||not a spoiler||"), r"\||not a spoiler||");
479 assert!(preprocess(r"\\==yes==").contains("highlight-mark"));
480 }
481
482 #[test]
483 fn inline_code_is_untouched() {
484 let out = preprocess("`==not a highlight==`");
485 assert_eq!(out, "`==not a highlight==`");
486 }
487
488 #[test]
489 fn fenced_code_is_untouched() {
490 let input = "```\n==no==\n||no||\n```";
491 let out = preprocess(input);
492 assert_eq!(out, input);
493 }
494
495 #[test]
499 fn every_spelling_of_code_is_untouched() {
500 for input in [
501 "~~~\n==no==\n~~~",
502 "para\n\n ==no==\n \n\npost",
503 "a ``==no==`` b",
504 "- item\n\n ```\n ==no==\n ```\n",
505 ] {
506 assert_eq!(preprocess(input), input, "input: {input:?}");
507 }
508 }
509
510 #[test]
513 fn djot_fenced_code_is_untouched() {
514 let input = "```\n==no==\n||no||\n```\n";
515 assert_eq!(
516 preprocess_custom_syntax(input, ContentFormat::Djot),
517 input,
518 "a djot fence is code too"
519 );
520 let out = preprocess_custom_syntax("```\n==no==\n```\n\n==yes==\n", ContentFormat::Djot);
521 assert!(out.contains("```\n==no==\n```"), "fence intact: {out}");
522 assert!(
523 out.contains("highlight-mark"),
524 "prose still rewritten: {out}"
525 );
526 }
527
528 #[test]
529 fn escapes_content() {
530 let out = preprocess("==<b>&\"==");
531 assert!(out.contains("<b>&""));
532 }
533
534 #[test]
535 fn markdown_renders_basics() {
536 let html = render_body("# Title\n\n~~struck~~", ContentFormat::Markdown);
537 assert!(html.contains("<h1>"));
538 assert!(html.contains("<del>struck</del>"));
539 }
540
541 #[test]
546 fn markdown_still_covers_what_comrak_was_configured_for() {
547 let src = "~~struck~~\n\n\
548 | a | b |\n|---|---|\n| 1 | 2 |\n\n\
549 - [ ] todo\n- [x] done\n\n\
550 A note.[^1]\n\n[^1]: The note.\n\n\
551 <div class=\"raw\">passed through</div>\n\n\
552 https://example.test\n\n```rust\nlet x = 1;\n```\n";
553 let html = render_body(src, ContentFormat::Markdown);
554 assert!(html.contains("<del>struck</del>"), "strikethrough");
555 assert!(
556 html.contains("<table>") && html.contains("<th>a</th>"),
557 "tables"
558 );
559 assert!(html.contains("type=\"checkbox\""), "tasklists");
560 assert!(html.contains("checked"), "a checked tasklist item");
561 assert!(html.contains("The note."), "footnote text");
562 assert!(html.contains("<div class=\"raw\">"), "raw HTML passthrough");
563 assert!(
564 html.contains("<a href=\"https://example.test\""),
565 "autolinks"
566 );
567 assert!(html.contains("language-rust"), "fenced code language");
568 }
569
570 #[cfg(feature = "syntax-highlighting")]
574 #[test]
575 fn fenced_code_is_highlighted_in_every_grammar() {
576 for (format, src) in [
577 (ContentFormat::Markdown, "```rust\nlet x = 1;\n```\n"),
578 (ContentFormat::Djot, "```rust\nlet x = 1;\n```\n"),
579 (
580 ContentFormat::Html,
581 "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
582 ),
583 ] {
584 let html = render_body(src, format);
585 assert!(
586 html.contains(crate::syntax::HIGHLIGHTED_CLASS),
587 "{format:?} left it uncoloured: {html}"
588 );
589 assert!(html.contains("plates-storage"), "{format:?}: {html}");
590 }
591 }
592
593 #[cfg(feature = "syntax-highlighting")]
596 #[test]
597 fn highlighting_does_not_unescape_the_page() {
598 let html = render_body(
599 "```rust\nlet s = \"<b>&</b>\";\n```\n",
600 ContentFormat::Markdown,
601 );
602 assert!(!html.contains("<b>"), "a tag reached the page: {html}");
603 assert!(html.contains("<b>"), "still escaped: {html}");
604 }
605
606 #[cfg(feature = "syntax-highlighting")]
609 #[test]
610 fn a_site_grammar_reaches_a_rendered_body() {
611 let syntaxes = crate::syntax::Syntaxes::with_custom([(
612 "wat.sublime-syntax",
613 "name: Wat\nfile_extensions: [wat]\nscope: source.wat\ncontexts:\n main:\n - match: ';;.*$'\n scope: comment.line.wat\n",
614 )]);
615 let html = render_body_with(
616 "```wat\n;; a note\n```\n",
617 ContentFormat::Markdown,
618 &syntaxes,
619 );
620 assert!(html.contains("plates-comment"), "{html}");
621 }
622
623 #[test]
624 fn markdown_passes_preprocessed_raw_html_through() {
625 let html = render_body("==hi==", ContentFormat::Markdown);
626 assert!(html.contains("<mark"), "got {html}");
627 }
628
629 #[test]
633 fn djot_custom_syntax_survives_as_raw_html() {
634 let html = render_body("a ==hi== and ||shh|| b", ContentFormat::Djot);
635 assert!(
636 html.contains("<mark"),
637 "highlight reached the output: {html}"
638 );
639 assert!(html.contains("data-spoiler"), "spoiler too: {html}");
640 assert!(!html.contains("<mark"), "and was not escaped: {html}");
641 }
642
643 #[test]
644 fn djot_renders_its_own_grammar() {
645 let html = render_body("_emph_ and {=native=}\n", ContentFormat::Djot);
646 assert!(html.contains("<em>emph</em>"));
647 assert!(html.contains("<mark>native</mark>"));
648 }
649
650 #[test]
652 fn djot_raw_span_outruns_backticks_in_the_content() {
653 let out = preprocess_custom_syntax("==a ` b==", ContentFormat::Djot);
654 assert!(out.starts_with("``"), "fence outgrew the content: {out}");
655 assert!(out.ends_with("{=html}"), "and is a raw span: {out}");
656 let html = render_body("==a ` b==", ContentFormat::Djot);
657 assert!(html.contains("<mark"), "still a highlight: {html}");
658 }
659
660 #[test]
661 fn html_bodies_are_left_alone() {
662 let src = "<p>a == b || c</p>";
664 assert_eq!(preprocess_custom_syntax(src, ContentFormat::Html), src);
665 let html = render_body(src, ContentFormat::Html);
666 assert!(html.contains("a == b || c"), "got {html}");
667 assert!(!html.contains("<mark"));
668 }
669}