1use prov::ContentFormat;
27
28pub fn render_body(body: &str, format: ContentFormat) -> String {
41 let mut preprocessed = preprocess_custom_syntax(body, format);
42 if !preprocessed.ends_with('\n') {
49 preprocessed.push('\n');
50 }
51 prov::render_html(&preprocessed, format).unwrap_or_else(|_| {
52 format!(
53 "<pre class=\"diaryx-unrendered\">{}</pre>\n",
54 html_escape(body)
55 )
56 })
57}
58
59pub fn preprocess_custom_syntax(source: &str, format: ContentFormat) -> String {
72 if format == ContentFormat::Html {
73 return source.to_string();
74 }
75 let markdown = source;
76 let bytes = markdown.as_bytes();
77 let len = bytes.len();
78 let mut out = String::with_capacity(len);
79 let mut i = 0;
80
81 while i < len {
82 if i + 2 < len && bytes[i] == b'`' && bytes[i + 1] == b'`' && bytes[i + 2] == b'`' {
84 let fence_start = i;
85 i += 3;
86 while i < len && bytes[i] != b'\n' {
87 i += 1;
88 }
89 loop {
90 if i >= len {
91 out.push_str(&markdown[fence_start..]);
92 return out;
93 }
94 if bytes[i] == b'\n'
95 && i + 3 < len
96 && bytes[i + 1] == b'`'
97 && bytes[i + 2] == b'`'
98 && bytes[i + 3] == b'`'
99 {
100 i += 4;
101 while i < len && bytes[i] != b'\n' {
102 i += 1;
103 }
104 break;
105 }
106 i += 1;
107 }
108 out.push_str(&markdown[fence_start..i]);
109 continue;
110 }
111
112 if bytes[i] == b'`' {
114 let start = i;
115 i += 1;
116 while i < len && bytes[i] != b'`' {
117 i += 1;
118 }
119 if i < len {
120 i += 1;
121 }
122 out.push_str(&markdown[start..i]);
123 continue;
124 }
125
126 if bytes[i] == b'\\'
133 && let Some(next) = bytes.get(i + 1)
134 && matches!(next, b'\\' | b'!' | b'=' | b'|')
135 {
136 out.push_str(&markdown[i..i + 2]);
137 i += 2;
138 continue;
139 }
140
141 if bytes[i] == b'!'
143 && i + 1 < len
144 && bytes[i + 1] == b'['
145 && let Some((html, consumed)) = try_parse_html_embed(&markdown[i..])
146 {
147 out.push_str(&raw_inline(&html, format));
148 i += consumed;
149 continue;
150 }
151
152 if i + 1 < len
154 && bytes[i] == b'='
155 && bytes[i + 1] == b'='
156 && let Some((html, consumed)) = try_parse_highlight(&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_spoiler(&markdown[i..])
168 {
169 out.push_str(&raw_inline(&html, format));
170 i += consumed;
171 continue;
172 }
173
174 out.push(markdown[i..].chars().next().unwrap());
175 i += markdown[i..].chars().next().unwrap().len_utf8();
176 }
177
178 out
179}
180
181fn raw_inline(html: &str, format: ContentFormat) -> String {
191 if format != ContentFormat::Djot {
192 return html.to_string();
193 }
194 let longest = html
195 .split(|c| c != '`')
196 .map(|run| run.len())
197 .max()
198 .unwrap_or(0);
199 let fence = "`".repeat(longest + 1);
200 let pad = if html.starts_with('`') || html.ends_with('`') {
203 " "
204 } else {
205 ""
206 };
207 format!("{fence}{pad}{html}{pad}{fence}{{=html}}")
208}
209
210fn try_parse_highlight(s: &str) -> Option<(String, usize)> {
212 const VALID_COLORS: &[&str] = &[
213 "red", "orange", "yellow", "green", "cyan", "blue", "violet", "pink", "brown", "grey",
214 ];
215
216 if !s.starts_with("==") {
217 return None;
218 }
219
220 let after_open = &s[2..];
221 if after_open.is_empty() || after_open.starts_with("==") {
222 return None;
223 }
224
225 let (color, content_start) = if after_open.starts_with('{') {
226 let close_brace = after_open.find('}')?;
227 let color_name = &after_open[1..close_brace];
228 if !VALID_COLORS.contains(&color_name) {
229 return None;
230 }
231 (color_name, close_brace + 1)
232 } else {
233 ("yellow", 0)
234 };
235
236 let content_region = &after_open[content_start..];
237 let close_pos = content_region.find("==")?;
238 if close_pos == 0 {
239 return None;
240 }
241
242 let content = &content_region[..close_pos];
243 if content.contains('\n') {
244 return None;
245 }
246
247 let total_consumed = 2 + content_start + close_pos + 2;
248 let html = format!(
249 r#"<mark data-highlight-color="{color}" class="highlight-mark highlight-{color}">{content}</mark>"#,
250 color = color,
251 content = html_escape(content),
252 );
253
254 Some((html, total_consumed))
255}
256
257fn try_parse_spoiler(s: &str) -> Option<(String, usize)> {
259 if !s.starts_with("||") {
260 return None;
261 }
262
263 let after_open = &s[2..];
264 if after_open.is_empty() || after_open.starts_with("||") {
265 return None;
266 }
267
268 let close_pos = after_open.find("||")?;
269 if close_pos == 0 {
270 return None;
271 }
272
273 let content = &after_open[..close_pos];
274 if content.contains('|') || content.contains('\n') {
275 return None;
276 }
277
278 let total_consumed = 2 + close_pos + 2;
279 let html = format!(
280 r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">{content}</span>"#,
281 content = html_escape(content),
282 );
283
284 Some((html, total_consumed))
285}
286
287const ISLAND_MIN_HEIGHT: u32 = 200;
295const ISLAND_MAX_HEIGHT: u32 = 4000;
296
297fn try_parse_html_embed(s: &str) -> Option<(String, usize)> {
311 if !s.starts_with("![") {
312 return None;
313 }
314
315 let after_bang = &s[2..];
316 let close_bracket = after_bang.find(']')?;
317 let alt = &after_bang[..close_bracket];
318
319 let after_bracket = &after_bang[close_bracket + 1..];
320 if !after_bracket.starts_with('(') {
321 return None;
322 }
323
324 let after_paren = &after_bracket[1..];
325 let close_paren = after_paren.find(')')?;
326 let path = after_paren[..close_paren].trim();
327
328 let lower = path.to_lowercase();
330 if !lower.ends_with(".html") && !lower.ends_with(".htm") {
331 return None;
332 }
333
334 let mut total_consumed = 2 + close_bracket + 1 + 1 + close_paren + 1;
335 let mut min_height = ISLAND_MIN_HEIGHT;
336 let after_embed = &s[total_consumed..];
337 if after_embed.starts_with('{') {
338 let close_brace = after_embed.find('}')?;
339 min_height = parse_island_height(&after_embed[1..close_brace])?;
340 total_consumed += close_brace + 1;
341 }
342
343 let html = format!(
344 r#"<iframe src="{}" title="{}" class="diaryx-island" sandbox="allow-scripts" loading="lazy" style="width:100%;min-height:{}px;border:none;"></iframe>"#,
345 html_escape(path),
346 html_escape(alt),
347 min_height,
348 );
349
350 Some((html, total_consumed))
351}
352
353fn parse_island_height(attributes: &str) -> Option<u32> {
356 let value = attributes.trim().strip_prefix("height")?.trim_start();
357 let value = value.strip_prefix('=')?.trim();
358 let height: u32 = value.parse().ok()?;
359 Some(height.clamp(ISLAND_MIN_HEIGHT, ISLAND_MAX_HEIGHT))
360}
361
362fn html_escape(s: &str) -> String {
364 s.replace('&', "&")
365 .replace('<', "<")
366 .replace('>', ">")
367 .replace('"', """)
368 .replace('\'', "'")
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn preprocess(source: &str) -> String {
378 preprocess_custom_syntax(source, ContentFormat::Markdown)
379 }
380
381 #[test]
382 fn highlight_default_color() {
383 let out = preprocess("a ==hi== b");
384 assert_eq!(
385 out,
386 r#"a <mark data-highlight-color="yellow" class="highlight-mark highlight-yellow">hi</mark> b"#
387 );
388 }
389
390 #[test]
391 fn highlight_named_color() {
392 let out = preprocess("=={red}danger==");
393 assert!(out.contains(r#"data-highlight-color="red""#));
394 assert!(out.contains("highlight-red"));
395 assert!(out.contains(">danger<"));
396 }
397
398 #[test]
399 fn highlight_invalid_color_is_left_alone() {
400 let out = preprocess("=={mauve}x==");
401 assert_eq!(out, "=={mauve}x==");
402 }
403
404 #[test]
405 fn spoiler_basic() {
406 let out = preprocess("||secret||");
407 assert_eq!(
408 out,
409 r#"<span data-spoiler="" class="spoiler-mark spoiler-hidden">secret</span>"#
410 );
411 }
412
413 #[test]
414 fn html_embed_becomes_iframe() {
415 let out = preprocess("");
416 assert!(out.contains(r#"<iframe src="island.html""#));
417 assert!(out.contains(r#"title="demo""#));
418 assert!(out.contains(r#"class="diaryx-island""#));
419 }
420
421 #[test]
424 fn html_embed_takes_an_authored_height() {
425 let out = preprocess("{height=520}");
426 assert!(out.contains("min-height:520px"), "got {out}");
427 assert!(
428 !out.contains("{height=520}"),
429 "the block is consumed: {out}"
430 );
431 }
432
433 #[test]
436 fn an_authored_height_is_clamped_to_the_bridges_range() {
437 assert!(preprocess("{height=10}").contains("min-height:200px"));
438 assert!(preprocess("{height=99999}").contains("min-height:4000px"));
439 }
440
441 #[test]
445 fn an_unknown_island_attribute_leaves_the_embed_alone() {
446 let source = "{wdith=400}";
447 assert_eq!(preprocess(source), source);
448 assert_eq!(
449 preprocess("{height=tall}"),
450 "{height=tall}"
451 );
452 }
453
454 #[test]
459 fn an_escaped_embed_is_not_an_island() {
460 let out = preprocess(r"Write \ to embed one.");
461 assert_eq!(out, r"Write \ to embed one.");
462 assert!(!render_body(&out, ContentFormat::Markdown).contains("<iframe"));
463
464 assert_eq!(preprocess(r"\==not a highlight=="), r"\==not a highlight==");
467 assert_eq!(preprocess(r"\||not a spoiler||"), r"\||not a spoiler||");
468 assert!(preprocess(r"\\==yes==").contains("highlight-mark"));
469 }
470
471 #[test]
472 fn inline_code_is_untouched() {
473 let out = preprocess("`==not a highlight==`");
474 assert_eq!(out, "`==not a highlight==`");
475 }
476
477 #[test]
478 fn fenced_code_is_untouched() {
479 let input = "```\n==no==\n||no||\n```";
480 let out = preprocess(input);
481 assert_eq!(out, input);
482 }
483
484 #[test]
485 fn escapes_content() {
486 let out = preprocess("==<b>&\"==");
487 assert!(out.contains("<b>&""));
488 }
489
490 #[test]
491 fn markdown_renders_basics() {
492 let html = render_body("# Title\n\n~~struck~~", ContentFormat::Markdown);
493 assert!(html.contains("<h1>"));
494 assert!(html.contains("<del>struck</del>"));
495 }
496
497 #[test]
502 fn markdown_still_covers_what_comrak_was_configured_for() {
503 let src = "~~struck~~\n\n\
504 | a | b |\n|---|---|\n| 1 | 2 |\n\n\
505 - [ ] todo\n- [x] done\n\n\
506 A note.[^1]\n\n[^1]: The note.\n\n\
507 <div class=\"raw\">passed through</div>\n\n\
508 https://example.test\n\n```rust\nlet x = 1;\n```\n";
509 let html = render_body(src, ContentFormat::Markdown);
510 assert!(html.contains("<del>struck</del>"), "strikethrough");
511 assert!(
512 html.contains("<table>") && html.contains("<th>a</th>"),
513 "tables"
514 );
515 assert!(html.contains("type=\"checkbox\""), "tasklists");
516 assert!(html.contains("checked"), "a checked tasklist item");
517 assert!(html.contains("The note."), "footnote text");
518 assert!(html.contains("<div class=\"raw\">"), "raw HTML passthrough");
519 assert!(
520 html.contains("<a href=\"https://example.test\""),
521 "autolinks"
522 );
523 assert!(html.contains("language-rust"), "fenced code language");
524 }
525
526 #[test]
527 fn markdown_passes_preprocessed_raw_html_through() {
528 let html = render_body("==hi==", ContentFormat::Markdown);
529 assert!(html.contains("<mark"), "got {html}");
530 }
531
532 #[test]
536 fn djot_custom_syntax_survives_as_raw_html() {
537 let html = render_body("a ==hi== and ||shh|| b", ContentFormat::Djot);
538 assert!(
539 html.contains("<mark"),
540 "highlight reached the output: {html}"
541 );
542 assert!(html.contains("data-spoiler"), "spoiler too: {html}");
543 assert!(!html.contains("<mark"), "and was not escaped: {html}");
544 }
545
546 #[test]
547 fn djot_renders_its_own_grammar() {
548 let html = render_body("_emph_ and {=native=}\n", ContentFormat::Djot);
549 assert!(html.contains("<em>emph</em>"));
550 assert!(html.contains("<mark>native</mark>"));
551 }
552
553 #[test]
555 fn djot_raw_span_outruns_backticks_in_the_content() {
556 let out = preprocess_custom_syntax("==a ` b==", ContentFormat::Djot);
557 assert!(out.starts_with("``"), "fence outgrew the content: {out}");
558 assert!(out.ends_with("{=html}"), "and is a raw span: {out}");
559 let html = render_body("==a ` b==", ContentFormat::Djot);
560 assert!(html.contains("<mark"), "still a highlight: {html}");
561 }
562
563 #[test]
564 fn html_bodies_are_left_alone() {
565 let src = "<p>a == b || c</p>";
567 assert_eq!(preprocess_custom_syntax(src, ContentFormat::Html), src);
568 let html = render_body(src, ContentFormat::Html);
569 assert!(html.contains("a == b || c"), "got {html}");
570 assert!(!html.contains("<mark"));
571 }
572}