1use std::{num::NonZeroUsize, ops::Range, sync::Arc};
2
3use markdown_compiler::{
4 AssetRevisionReference, DigestedAsset, LogicalAssetPath, LogicalContentPath,
5 MarkdownDestinationKind, MarkdownDestinationOrdinal, PostDocument, PostRendererIdentity,
6 PostRevisionDigest, PreparedPost, ResolvedLocalAssetLookupError, ResolvedLocalAssetStore,
7 ResolvedPostAssets, ResolvedSiteAssets, SiteSnapshotDigest,
8};
9use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
10use serde::Serialize;
11use thiserror::Error;
12use url::{Position, Url};
13
14use super::{
15 SnapshotAssetPath,
16 code::CodeLanguage,
17 diagram::{DiagramRenderError, DiagramRenderErrorCode, MermaidDiagramRenderer},
18 svg::SanitizedSvg,
19};
20
21const MAX_RENDERED_HTML_BYTES: usize = 32 * 1024 * 1024;
22const MAX_MERMAID_SOURCE_BYTES: usize = 256 * 1024;
23const MAX_MERMAID_SVG_BYTES: usize = 16 * 1024 * 1024;
24const MAX_MERMAID_BLOCKS: usize = 64;
25const MAX_CODE_BLOCKS: usize = 256;
26
27pub(super) fn render_prepared_post(
29 post: &PreparedPost,
30 site_assets: &ResolvedSiteAssets,
31 diagrams: &MermaidDiagramRenderer,
32) -> Result<RenderedPost, MarkdownRenderError> {
33 render_markdown_with_renderer(
34 post,
35 site_assets,
36 RendererLimits::production(),
37 &mut |source, ordinal| diagrams.render(source, &post.view().document.metadata.id, ordinal),
38 )
39}
40
41fn render_markdown_with_renderer(
42 post: &PreparedPost,
43 site_assets: &ResolvedSiteAssets,
44 limits: RendererLimits,
45 render_mermaid: &mut dyn FnMut(&str, NonZeroUsize) -> Result<SanitizedSvg, DiagramRenderError>,
46) -> Result<RenderedPost, MarkdownRenderError> {
47 let input = post.view();
48 let identity = PostRendererIdentity::baseline();
49 let article = MarkdownEventRenderer::new(
50 input.document,
51 input.assets,
52 site_assets,
53 render_mermaid,
54 limits,
55 )
56 .render()?;
57 let revision = post.finalize_revision(&identity, article.identity_html.as_bytes());
58
59 Ok(RenderedPost {
60 document: input.document.clone(),
61 assets: input.assets.clone(),
62 renderer: identity,
63 article,
64 revision,
65 })
66}
67
68#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct RenderedPost {
71 pub(crate) document: PostDocument,
72 pub(crate) assets: ResolvedPostAssets,
73 pub(crate) renderer: PostRendererIdentity,
74 pub(crate) article: RenderedArticle,
75 pub(crate) revision: PostRevisionDigest,
76}
77
78impl RenderedPost {
79 pub(super) fn project_for_snapshot(
80 &self,
81 snapshot: &SiteSnapshotDigest,
82 site_assets: &ResolvedSiteAssets,
83 local_assets: &ResolvedLocalAssetStore,
84 ) -> Result<String, MarkdownRenderError> {
85 if !self.assets.shares_policy_with(site_assets) {
86 return Err(MarkdownRenderError::new(
87 &self.document,
88 MarkdownRenderLocation::Document,
89 MarkdownRenderErrorCode::AssetPolicyMismatch,
90 "rendered post was approved under a different external-asset policy",
91 ));
92 }
93 self.article.project_for_snapshot(
94 &self.document.path,
95 snapshot,
96 local_assets,
97 MAX_RENDERED_HTML_BYTES,
98 )
99 }
100
101 pub(super) fn project_for_preview(
102 &self,
103 preview_asset_endpoint: &str,
104 site_assets: &ResolvedSiteAssets,
105 local_assets: &ResolvedLocalAssetStore,
106 ) -> Result<String, MarkdownRenderError> {
107 if !self.assets.shares_policy_with(site_assets) {
108 return Err(MarkdownRenderError::new(
109 &self.document,
110 MarkdownRenderLocation::Document,
111 MarkdownRenderErrorCode::AssetPolicyMismatch,
112 "rendered post preview uses a different external-asset policy",
113 ));
114 }
115 if !preview_asset_endpoint.starts_with('/')
116 || preview_asset_endpoint.contains(['?', '#', '"'])
117 {
118 return Err(MarkdownRenderError::new(
119 &self.document,
120 MarkdownRenderLocation::Document,
121 MarkdownRenderErrorCode::RevisionIdentityRejected,
122 "preview asset endpoint is not a safe root-relative path",
123 ));
124 }
125 self.article.project_with_local_asset_urls(
126 &self.document.path,
127 local_assets,
128 MAX_RENDERED_HTML_BYTES,
129 |asset| {
130 Ok(format!(
131 "{preview_asset_endpoint}?path={}",
132 asset.path.as_str()
133 ))
134 },
135 )
136 }
137}
138
139#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
140struct MermaidBlockOrdinal(NonZeroUsize);
141
142impl MermaidBlockOrdinal {
143 const fn get(self) -> usize {
144 self.0.get()
145 }
146
147 fn from_index(index: usize) -> Option<Self> {
148 index.checked_add(1).and_then(NonZeroUsize::new).map(Self)
149 }
150}
151
152#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
153#[serde(transparent)]
154pub struct CodeBlockOrdinal(NonZeroUsize);
155
156impl CodeBlockOrdinal {
157 pub const fn get(self) -> usize {
158 self.0.get()
159 }
160
161 fn new(value: NonZeroUsize) -> Self {
162 Self(value)
163 }
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
167#[serde(rename_all = "snake_case")]
168pub enum RenderDestinationKind {
169 Link,
170 Image,
171}
172
173#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum MarkdownRenderLocation {
176 Document,
177 Destination {
178 ordinal: MarkdownDestinationOrdinal,
179 destination_kind: RenderDestinationKind,
180 },
181 CodeBlock {
182 ordinal: CodeBlockOrdinal,
183 },
184}
185
186#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
187#[serde(rename_all = "snake_case")]
188pub enum NavigationRejection {
189 Empty,
190 Whitespace,
191 ForbiddenCharacter,
192 ProtocolRelative,
193 UnsupportedScheme,
194 MissingAuthority,
195 Credentials,
196 Traversal,
197 NotRootRelative,
198 Malformed,
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
202#[serde(rename_all = "snake_case")]
203pub enum MarkdownRenderErrorCode {
204 DestinationCountExceeded,
205 AssetOccurrenceMissing,
206 AssetOccurrenceMismatch,
207 AssetOccurrenceUnused,
208 NavigationRejected,
209 MermaidBlockCountExceeded,
210 MermaidBlockTooLarge,
211 MermaidRenderedOutputTooLarge,
212 MermaidInvalid,
213 MermaidRendererUnavailable,
214 MermaidRendererResourceLimit,
215 MermaidRendererTimedOut,
216 MermaidRendererInvalidOutput,
217 MermaidSvgRejected,
218 MermaidRendererInternal,
219 CodeBlockCountExceeded,
220 RenderedHtmlTooLarge,
221 UnsupportedCommonMarkEvent,
222 MalformedCommonMarkEvents,
223 AssetPolicyMismatch,
224 LocalAssetMissing,
225 LocalAssetDigestMismatch,
226 RevisionIdentityRejected,
227}
228
229#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize)]
230#[error("{path}: {code:?}: {message}")]
231pub struct MarkdownRenderError {
232 pub path: LogicalContentPath,
233 pub location: MarkdownRenderLocation,
234 pub code: MarkdownRenderErrorCode,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub navigation_rejection: Option<NavigationRejection>,
237 pub message: Box<str>,
238}
239
240impl MarkdownRenderError {
241 fn new(
242 document: &PostDocument,
243 location: MarkdownRenderLocation,
244 code: MarkdownRenderErrorCode,
245 message: impl Into<Box<str>>,
246 ) -> Self {
247 Self {
248 path: document.path.clone(),
249 location,
250 code,
251 navigation_rejection: None,
252 message: message.into(),
253 }
254 }
255
256 fn navigation(
257 document: &PostDocument,
258 ordinal: MarkdownDestinationOrdinal,
259 reason: NavigationRejection,
260 ) -> Self {
261 Self {
262 path: document.path.clone(),
263 location: MarkdownRenderLocation::Destination {
264 ordinal,
265 destination_kind: RenderDestinationKind::Link,
266 },
267 code: MarkdownRenderErrorCode::NavigationRejected,
268 navigation_rejection: Some(reason),
269 message: format!("link destination was rejected: {reason:?}").into_boxed_str(),
270 }
271 }
272}
273
274#[derive(Clone, Copy)]
275struct RendererLimits {
276 rendered_html_bytes: usize,
277 mermaid_source_bytes: usize,
278 mermaid_svg_bytes: usize,
279 mermaid_blocks: usize,
280 code_blocks: usize,
281}
282
283impl RendererLimits {
284 const fn production() -> Self {
285 Self {
286 rendered_html_bytes: MAX_RENDERED_HTML_BYTES,
287 mermaid_source_bytes: MAX_MERMAID_SOURCE_BYTES,
288 mermaid_svg_bytes: MAX_MERMAID_SVG_BYTES,
289 mermaid_blocks: MAX_MERMAID_BLOCKS,
290 code_blocks: MAX_CODE_BLOCKS,
291 }
292 }
293}
294
295#[derive(Clone, Debug, Eq, PartialEq)]
296pub(crate) struct RenderedArticle {
297 pub(crate) identity_html: Arc<str>,
298 plan: Arc<[ArticleChunk]>,
299}
300
301impl RenderedArticle {
302 fn project_for_snapshot(
303 &self,
304 path: &LogicalContentPath,
305 snapshot: &SiteSnapshotDigest,
306 local_assets: &ResolvedLocalAssetStore,
307 limit: usize,
308 ) -> Result<String, MarkdownRenderError> {
309 self.project_with_local_asset_urls(path, local_assets, limit, |asset| {
310 SnapshotAssetPath::new(snapshot, &asset.path)
311 .map(|projected| projected.as_str().to_owned())
312 .map_err(|error| MarkdownRenderError {
313 path: path.clone(),
314 location: MarkdownRenderLocation::Document,
315 code: MarkdownRenderErrorCode::RevisionIdentityRejected,
316 navigation_rejection: None,
317 message: error.to_string().into_boxed_str(),
318 })
319 })
320 }
321
322 fn project_with_local_asset_urls(
323 &self,
324 path: &LogicalContentPath,
325 local_assets: &ResolvedLocalAssetStore,
326 limit: usize,
327 mut project: impl FnMut(&DigestedAsset) -> Result<String, MarkdownRenderError>,
328 ) -> Result<String, MarkdownRenderError> {
329 for chunk in &*self.plan {
330 if let ArticleChunk::LocalAsset(asset) = chunk {
331 local_assets
332 .resolve(asset)
333 .map_err(|error| local_asset_projection_error(path, error))?;
334 }
335 }
336 let mut output = String::with_capacity(self.identity_html.len());
337 for chunk in &*self.plan {
338 let value = match chunk {
339 ArticleChunk::Literal(value) => value.as_ref(),
340 ArticleChunk::LocalAsset(asset) => {
341 let projected = project(asset)?;
342 if output
343 .len()
344 .checked_add(projected.len())
345 .is_none_or(|size| size > limit)
346 {
347 return Err(rendered_html_limit_error(path));
348 }
349 output.push_str(&projected);
350 continue;
351 }
352 };
353 if output
354 .len()
355 .checked_add(value.len())
356 .is_none_or(|size| size > limit)
357 {
358 return Err(rendered_html_limit_error(path));
359 }
360 output.push_str(value);
361 }
362 Ok(output)
363 }
364}
365
366#[derive(Clone, Debug, Eq, PartialEq)]
367enum ArticleChunk {
368 Literal(Arc<str>),
369 LocalAsset(DigestedAsset),
370}
371
372struct MarkdownEventRenderer<'input, 'renderer> {
373 document: &'input PostDocument,
374 assets: &'input ResolvedPostAssets,
375 site_assets: &'input ResolvedSiteAssets,
376 events: Vec<(Event<'input>, Range<usize>)>,
377 cursor: usize,
378 destination_count: usize,
379 approved_cursor: usize,
380 code_block_count: usize,
381 mermaid_count: usize,
382 mermaid_svg_bytes: usize,
383 render_mermaid:
384 &'renderer mut dyn FnMut(&str, NonZeroUsize) -> Result<SanitizedSvg, DiagramRenderError>,
385 writer: ArticleWriter,
386 limits: RendererLimits,
387}
388
389impl<'input, 'renderer> MarkdownEventRenderer<'input, 'renderer> {
390 fn new(
391 document: &'input PostDocument,
392 assets: &'input ResolvedPostAssets,
393 site_assets: &'input ResolvedSiteAssets,
394 render_mermaid: &'renderer mut dyn FnMut(
395 &str,
396 NonZeroUsize,
397 )
398 -> Result<SanitizedSvg, DiagramRenderError>,
399 limits: RendererLimits,
400 ) -> Self {
401 let events = Parser::new_ext(document.markdown.as_str(), Options::empty())
402 .into_offset_iter()
403 .collect();
404 Self {
405 document,
406 assets,
407 site_assets,
408 events,
409 cursor: 0,
410 destination_count: 0,
411 approved_cursor: 0,
412 code_block_count: 0,
413 mermaid_count: 0,
414 mermaid_svg_bytes: 0,
415 render_mermaid,
416 writer: ArticleWriter::new(limits.rendered_html_bytes),
417 limits,
418 }
419 }
420
421 fn render(mut self) -> Result<RenderedArticle, MarkdownRenderError> {
422 while self.cursor < self.events.len() {
423 let (event, range) = self.events[self.cursor].clone();
424 self.cursor += 1;
425 self.render_event(event, range)?;
426 }
427 if let Some(unused) = self.assets.markdown_destinations.get(self.approved_cursor) {
428 return Err(self.error_at_destination(
429 unused.ordinal,
430 destination_kind(unused.kind),
431 MarkdownRenderErrorCode::AssetOccurrenceUnused,
432 "resolver-approved asset occurrence was not consumed by the renderer",
433 ));
434 }
435 Ok(self.writer.finish())
436 }
437
438 fn render_event(
439 &mut self,
440 event: Event<'input>,
441 range: Range<usize>,
442 ) -> Result<(), MarkdownRenderError> {
443 match event {
444 Event::Start(tag) => self.render_start(tag, range),
445 Event::End(tag) => self.render_end(tag),
446 Event::Text(text) => self.write_escaped_body(&text),
447 Event::Code(text) => {
448 self.write("<code>")?;
449 self.write_escaped_body(&text)?;
450 self.write("</code>")
451 }
452 Event::Html(html) | Event::InlineHtml(html) => self.write_escaped_body(&html),
453 Event::SoftBreak => self.write("\n"),
454 Event::HardBreak => self.write("<br />\n"),
455 Event::Rule => {
456 if !self.writer.end_newline {
457 self.write("\n")?;
458 }
459 self.write("<hr />\n")
460 }
461 Event::InlineMath(_)
462 | Event::DisplayMath(_)
463 | Event::FootnoteReference(_)
464 | Event::TaskListMarker(_) => Err(self.unsupported("optional CommonMark event")),
465 }
466 }
467
468 fn render_start(
469 &mut self,
470 tag: Tag<'input>,
471 range: Range<usize>,
472 ) -> Result<(), MarkdownRenderError> {
473 match tag {
474 Tag::Paragraph => self.write_block_start("<p>"),
475 Tag::Heading {
476 level,
477 id: _,
478 classes: _,
479 attrs: _,
480 } => self.write_block_start(&format!("<{level}>")),
481 Tag::BlockQuote(None) => self.write_block_start("<blockquote>\n"),
482 Tag::CodeBlock(kind) => self.render_code_block(kind),
483 Tag::List(Some(1)) => self.write_block_start("<ol>\n"),
484 Tag::List(Some(start)) => {
485 if !self.writer.end_newline {
486 self.write("\n")?;
487 }
488 self.write("<ol start=\"")?;
489 self.write(&start.to_string())?;
490 self.write("\">\n")
491 }
492 Tag::List(None) => self.write_block_start("<ul>\n"),
493 Tag::Item => self.write_block_start("<li>"),
494 Tag::Emphasis => self.write("<em>"),
495 Tag::Strong => self.write("<strong>"),
496 Tag::Link {
497 link_type: _,
498 dest_url,
499 title,
500 id: _,
501 } => self.render_link(dest_url.as_ref(), title.as_ref(), range),
502 Tag::Image {
503 link_type: _,
504 dest_url,
505 title,
506 id: _,
507 } => self.render_image(dest_url.as_ref(), title.as_ref(), range),
508 Tag::HtmlBlock => Ok(()),
509 Tag::BlockQuote(Some(_))
510 | Tag::FootnoteDefinition(_)
511 | Tag::DefinitionList
512 | Tag::DefinitionListTitle
513 | Tag::DefinitionListDefinition
514 | Tag::Table(_)
515 | Tag::TableHead
516 | Tag::TableRow
517 | Tag::TableCell
518 | Tag::Strikethrough
519 | Tag::Superscript
520 | Tag::Subscript
521 | Tag::MetadataBlock(_) => Err(self.unsupported("optional CommonMark tag")),
522 }
523 }
524
525 fn render_end(&mut self, tag: TagEnd) -> Result<(), MarkdownRenderError> {
526 match tag {
527 TagEnd::Paragraph => self.write("</p>\n"),
528 TagEnd::Heading(level) => self.write(&format!("</{level}>\n")),
529 TagEnd::BlockQuote(None) => self.write("</blockquote>\n"),
530 TagEnd::List(true) => self.write("</ol>\n"),
531 TagEnd::List(false) => self.write("</ul>\n"),
532 TagEnd::Item => self.write("</li>\n"),
533 TagEnd::Emphasis => self.write("</em>"),
534 TagEnd::Strong => self.write("</strong>"),
535 TagEnd::Link => self.write("</a>"),
536 TagEnd::HtmlBlock => Ok(()),
537 TagEnd::CodeBlock | TagEnd::Image => Err(self.malformed()),
538 TagEnd::BlockQuote(Some(_))
539 | TagEnd::FootnoteDefinition
540 | TagEnd::DefinitionList
541 | TagEnd::DefinitionListTitle
542 | TagEnd::DefinitionListDefinition
543 | TagEnd::Table
544 | TagEnd::TableHead
545 | TagEnd::TableRow
546 | TagEnd::TableCell
547 | TagEnd::Strikethrough
548 | TagEnd::Superscript
549 | TagEnd::Subscript
550 | TagEnd::MetadataBlock(_) => Err(self.unsupported("optional CommonMark end tag")),
551 }
552 }
553
554 fn render_code_block(
555 &mut self,
556 kind: CodeBlockKind<'input>,
557 ) -> Result<(), MarkdownRenderError> {
558 let next_count = self
559 .code_block_count
560 .checked_add(1)
561 .ok_or_else(|| self.malformed())?;
562 let code_ordinal =
563 CodeBlockOrdinal::new(NonZeroUsize::new(next_count).ok_or_else(|| self.malformed())?);
564 if next_count > self.limits.code_blocks {
565 return Err(MarkdownRenderError::new(
566 self.document,
567 MarkdownRenderLocation::CodeBlock {
568 ordinal: code_ordinal,
569 },
570 MarkdownRenderErrorCode::CodeBlockCountExceeded,
571 "post contains more code blocks than the configured limit",
572 ));
573 }
574 self.code_block_count = next_count;
575 let mermaid = matches!(&kind, CodeBlockKind::Fenced(info) if info.as_ref() == "mermaid");
576 let language = match &kind {
577 CodeBlockKind::Fenced(info) => CodeLanguage::from_fence_info(info.as_ref()),
578 CodeBlockKind::Indented => None,
579 };
580 let source = self.collect_code_block()?;
581 if mermaid {
582 return self.render_mermaid_block(&source, code_ordinal);
583 }
584 self.render_plain_code_block(&source, language)?;
585 Ok(())
586 }
587
588 fn render_mermaid_block(
589 &mut self,
590 source: &str,
591 code_ordinal: CodeBlockOrdinal,
592 ) -> Result<(), MarkdownRenderError> {
593 self.validate_mermaid_source(source, code_ordinal)?;
594 let Some(mermaid_ordinal) = MermaidBlockOrdinal::from_index(self.mermaid_count) else {
595 return Err(self.malformed());
596 };
597 let svg = (self.render_mermaid)(source, mermaid_ordinal.0).map_err(|error| {
598 mermaid_error(
599 self.document,
600 MarkdownRenderLocation::CodeBlock {
601 ordinal: code_ordinal,
602 },
603 error.code(),
604 )
605 })?;
606 self.reserve_mermaid_output(&svg, code_ordinal)?;
607 let ordinal = mermaid_ordinal.get().to_string();
608 self.write_block_start("<div id=\"maincopy-diagram-return-")?;
609 self.write(&ordinal)?;
610 self.write("\"></div>\n")?;
611 self.write_block_start(
612 "<div class=\"mermaid-diagram\" data-maincopy-mermaid=\"v1\" data-block=\"",
613 )?;
614 self.write(&ordinal)?;
615 self.write("\" id=\"maincopy-diagram-")?;
616 self.write(&ordinal)?;
617 self.write("\"><div class=\"diagram-controls\"><a class=\"diagram-enlarge\" href=\"#maincopy-diagram-")?;
618 self.write(&ordinal)?;
619 self.write(
620 "\">Enlarge diagram</a><a class=\"diagram-close\" href=\"#maincopy-diagram-return-",
621 )?;
622 self.write(&ordinal)?;
623 self.write("\">Close diagram</a></div><div class=\"diagram-canvas\">")?;
624 self.writer
625 .write_sanitized_svg(&svg)
626 .map_err(|_| rendered_html_limit_error(&self.document.path))?;
627 self.write("</div></div>\n")?;
628 self.mermaid_count = self
629 .mermaid_count
630 .checked_add(1)
631 .ok_or_else(|| self.malformed())?;
632 Ok(())
633 }
634
635 fn validate_mermaid_source(
636 &self,
637 source: &str,
638 code_ordinal: CodeBlockOrdinal,
639 ) -> Result<(), MarkdownRenderError> {
640 let location = MarkdownRenderLocation::CodeBlock {
641 ordinal: code_ordinal,
642 };
643 if self.mermaid_count >= self.limits.mermaid_blocks {
644 return Err(MarkdownRenderError::new(
645 self.document,
646 location,
647 MarkdownRenderErrorCode::MermaidBlockCountExceeded,
648 "post contains more Mermaid blocks than the configured limit",
649 ));
650 }
651 if source.len() > self.limits.mermaid_source_bytes {
652 return Err(MarkdownRenderError::new(
653 self.document,
654 location,
655 MarkdownRenderErrorCode::MermaidBlockTooLarge,
656 "Mermaid source exceeds the configured byte limit",
657 ));
658 }
659 Ok(())
660 }
661
662 fn reserve_mermaid_output(
663 &mut self,
664 svg: &SanitizedSvg,
665 code_ordinal: CodeBlockOrdinal,
666 ) -> Result<(), MarkdownRenderError> {
667 self.mermaid_svg_bytes = self
668 .mermaid_svg_bytes
669 .checked_add(svg.as_str().len())
670 .filter(|size| *size <= self.limits.mermaid_svg_bytes)
671 .ok_or_else(|| {
672 MarkdownRenderError::new(
673 self.document,
674 MarkdownRenderLocation::CodeBlock {
675 ordinal: code_ordinal,
676 },
677 MarkdownRenderErrorCode::MermaidRenderedOutputTooLarge,
678 "rendered Mermaid SVG exceeds the per-post byte limit",
679 )
680 })?;
681 Ok(())
682 }
683
684 fn render_plain_code_block(
685 &mut self,
686 source: &str,
687 language: Option<CodeLanguage>,
688 ) -> Result<(), MarkdownRenderError> {
689 if let Some(language) = language {
690 self.write_block_start("<pre class=\"article-code\"><code class=\"")?;
691 self.write(language.html_class())?;
692 self.write("\">")?;
693 } else {
694 self.write_block_start("<pre><code>")?;
695 }
696 self.write_escaped_body(source)?;
697 self.write("</code></pre>\n")
698 }
699
700 fn collect_code_block(&mut self) -> Result<String, MarkdownRenderError> {
701 let mut source = String::new();
702 while self.cursor < self.events.len() {
703 let (event, _) = self.events[self.cursor].clone();
704 self.cursor += 1;
705 match event {
706 Event::Text(text) => source.push_str(&text),
707 Event::End(TagEnd::CodeBlock) => return Ok(source),
708 _ => return Err(self.malformed()),
709 }
710 }
711 Err(self.malformed())
712 }
713
714 fn render_link(
715 &mut self,
716 authored: &str,
717 title: &str,
718 range: Range<usize>,
719 ) -> Result<(), MarkdownRenderError> {
720 let target = self.validate_destination(
721 RenderDestinationKind::Link,
722 MarkdownDestinationKind::Download,
723 authored,
724 range,
725 false,
726 )?;
727 self.write("<a href=\"")?;
728 self.write_destination(&target)?;
729 if !title.is_empty() {
730 self.write("\" title=\"")?;
731 self.write_escaped_attribute(title)?;
732 }
733 self.write("\">")
734 }
735
736 fn render_image(
737 &mut self,
738 authored: &str,
739 title: &str,
740 range: Range<usize>,
741 ) -> Result<(), MarkdownRenderError> {
742 let target = self.validate_destination(
743 RenderDestinationKind::Image,
744 MarkdownDestinationKind::Image,
745 authored,
746 range,
747 true,
748 )?;
749 let alt = self.collect_image_alt()?;
750 self.write("<img src=\"")?;
751 self.write_destination(&target)?;
752 self.write("\" alt=\"")?;
753 self.write_escaped_attribute(&alt)?;
754 if !title.is_empty() {
755 self.write("\" title=\"")?;
756 self.write_escaped_attribute(title)?;
757 }
758 self.write("\" />")
759 }
760
761 fn collect_image_alt(&mut self) -> Result<String, MarkdownRenderError> {
762 let mut output = String::new();
763 let mut depth = 0_usize;
764 while self.cursor < self.events.len() {
765 let (event, range) = self.events[self.cursor].clone();
766 self.cursor += 1;
767 match event {
768 Event::End(TagEnd::Image) if depth == 0 => return Ok(output),
769 Event::Start(Tag::Image { dest_url, .. }) => {
770 let _ = self.validate_destination(
771 RenderDestinationKind::Image,
772 MarkdownDestinationKind::Image,
773 dest_url.as_ref(),
774 range,
775 true,
776 )?;
777 self.enter_image_alt_nesting(&mut depth)?;
778 }
779 Event::Start(Tag::Link { dest_url, .. }) => {
780 let _ = self.validate_destination(
781 RenderDestinationKind::Link,
782 MarkdownDestinationKind::Download,
783 dest_url.as_ref(),
784 range,
785 false,
786 )?;
787 self.enter_image_alt_nesting(&mut depth)?;
788 }
789 Event::Start(_) => {
790 self.enter_image_alt_nesting(&mut depth)?;
791 }
792 Event::End(_) if depth > 0 => depth -= 1,
793 Event::End(_) => return Err(self.malformed()),
794 Event::Text(text)
795 | Event::Code(text)
796 | Event::Html(text)
797 | Event::InlineHtml(text) => output.push_str(&text),
798 Event::SoftBreak | Event::HardBreak | Event::Rule => output.push(' '),
799 Event::InlineMath(text) | Event::DisplayMath(text) => output.push_str(&text),
800 Event::FootnoteReference(name) => {
801 output.push('[');
802 output.push_str(&name);
803 output.push(']');
804 }
805 Event::TaskListMarker(checked) => {
806 output.push_str(if checked { "[x]" } else { "[ ]" });
807 }
808 }
809 }
810 Err(self.malformed())
811 }
812
813 fn enter_image_alt_nesting(&self, depth: &mut usize) -> Result<(), MarkdownRenderError> {
814 *depth = depth.checked_add(1).ok_or_else(|| self.malformed())?;
815 Ok(())
816 }
817
818 fn validate_destination(
819 &mut self,
820 render_kind: RenderDestinationKind,
821 asset_kind: MarkdownDestinationKind,
822 authored: &str,
823 range: Range<usize>,
824 asset_required: bool,
825 ) -> Result<ValidatedDestination, MarkdownRenderError> {
826 self.destination_count = self.destination_count.checked_add(1).ok_or_else(|| {
827 MarkdownRenderError::new(
828 self.document,
829 MarkdownRenderLocation::Document,
830 MarkdownRenderErrorCode::DestinationCountExceeded,
831 "Markdown contains more destinations than this platform can address",
832 )
833 })?;
834 let ordinal = MarkdownDestinationOrdinal::new(
835 NonZeroUsize::new(self.destination_count).ok_or_else(|| self.malformed())?,
836 );
837 let approved = self.assets.markdown_destinations.get(self.approved_cursor);
838 if let Some(approved) = approved.filter(|approved| approved.ordinal == ordinal) {
839 if approved.kind != asset_kind
840 || approved.source_range.start != range.start
841 || approved.source_range.end != range.end
842 || approved.authored.as_str() != authored
843 {
844 return Err(self.error_at_destination(
845 ordinal,
846 render_kind,
847 MarkdownRenderErrorCode::AssetOccurrenceMismatch,
848 "Markdown destination does not match its resolver-approved occurrence",
849 ));
850 }
851 self.approved_cursor += 1;
852 return Ok(ValidatedDestination::Asset(approved.target.clone()));
853 }
854 if approved.is_some_and(|approved| approved.ordinal.get() < ordinal.get()) {
855 return Err(self.error_at_destination(
856 ordinal,
857 render_kind,
858 MarkdownRenderErrorCode::AssetOccurrenceUnused,
859 "renderer advanced past a resolver-approved asset occurrence",
860 ));
861 }
862 if asset_required || self.looks_like_asset(authored) {
863 return Err(self.error_at_destination(
864 ordinal,
865 render_kind,
866 MarkdownRenderErrorCode::AssetOccurrenceMissing,
867 "asset destination has no matching resolver-approved occurrence",
868 ));
869 }
870 normalize_navigation(authored)
871 .map(ValidatedDestination::Navigation)
872 .map_err(|reason| MarkdownRenderError::navigation(self.document, ordinal, reason))
873 }
874
875 fn looks_like_asset(&self, authored: &str) -> bool {
876 if LogicalAssetPath::parse(authored).is_ok() {
877 return true;
878 }
879 let Ok(url) = Url::parse(authored) else {
880 return false;
881 };
882 self.site_assets.allowed_origins.iter().any(|origin| {
883 origin.as_url().scheme() == url.scheme()
884 && origin.as_url().host() == url.host()
885 && origin.as_url().port_or_known_default() == url.port_or_known_default()
886 })
887 }
888
889 fn write_destination(
890 &mut self,
891 destination: &ValidatedDestination,
892 ) -> Result<(), MarkdownRenderError> {
893 match destination {
894 ValidatedDestination::Navigation(value) => self.write_escaped_attribute(value),
895 ValidatedDestination::Asset(AssetRevisionReference::External(url)) => {
896 self.write_escaped_attribute(url.as_str())
897 }
898 ValidatedDestination::Asset(AssetRevisionReference::Local(asset)) => self
899 .writer
900 .write_local_asset(asset.clone())
901 .map_err(|_| rendered_html_limit_error(&self.document.path)),
902 }
903 }
904
905 fn write_block_start(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
906 if !self.writer.end_newline {
907 self.write("\n")?;
908 }
909 self.write(value)
910 }
911
912 fn write(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
913 self.writer
914 .write(value)
915 .map_err(|_| rendered_html_limit_error(&self.document.path))
916 }
917
918 fn write_escaped_body(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
919 for piece in Escaped::body(value) {
920 self.write(piece)?;
921 }
922 Ok(())
923 }
924
925 fn write_escaped_attribute(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
926 for piece in Escaped::attribute(value) {
927 self.write(piece)?;
928 }
929 Ok(())
930 }
931
932 fn error_at_destination(
933 &self,
934 ordinal: MarkdownDestinationOrdinal,
935 destination_kind: RenderDestinationKind,
936 code: MarkdownRenderErrorCode,
937 message: &'static str,
938 ) -> MarkdownRenderError {
939 MarkdownRenderError::new(
940 self.document,
941 MarkdownRenderLocation::Destination {
942 ordinal,
943 destination_kind,
944 },
945 code,
946 message,
947 )
948 }
949
950 fn unsupported(&self, event: &'static str) -> MarkdownRenderError {
951 MarkdownRenderError::new(
952 self.document,
953 MarkdownRenderLocation::Document,
954 MarkdownRenderErrorCode::UnsupportedCommonMarkEvent,
955 format!("the strict CommonMark parser produced an unsupported {event}"),
956 )
957 }
958
959 fn malformed(&self) -> MarkdownRenderError {
960 MarkdownRenderError::new(
961 self.document,
962 MarkdownRenderLocation::Document,
963 MarkdownRenderErrorCode::MalformedCommonMarkEvents,
964 "the CommonMark parser produced an unbalanced event stream",
965 )
966 }
967}
968
969enum ValidatedDestination {
970 Navigation(String),
971 Asset(AssetRevisionReference),
972}
973
974struct ArticleWriter {
975 identity_html: String,
976 literal: String,
977 plan: Vec<ArticleChunk>,
978 limit: usize,
979 end_newline: bool,
980}
981
982impl ArticleWriter {
983 fn new(limit: usize) -> Self {
984 Self {
985 identity_html: String::new(),
986 literal: String::new(),
987 plan: Vec::new(),
988 limit,
989 end_newline: true,
990 }
991 }
992
993 fn write(&mut self, value: &str) -> Result<(), RenderedHtmlLimit> {
994 self.reserve(value.len())?;
995 self.identity_html.push_str(value);
996 self.literal.push_str(value);
997 if !value.is_empty() {
998 self.end_newline = value.ends_with('\n');
999 }
1000 Ok(())
1001 }
1002
1003 fn write_sanitized_svg(&mut self, svg: &SanitizedSvg) -> Result<(), RenderedHtmlLimit> {
1004 self.write(svg.as_str())
1005 }
1006
1007 fn write_local_asset(&mut self, asset: DigestedAsset) -> Result<(), RenderedHtmlLimit> {
1008 let path = asset.path.as_str();
1009 let path_len = path.len();
1010 let path_ends_newline = path.ends_with('\n');
1011 self.reserve(path_len)?;
1012 self.identity_html.push_str(path);
1013 self.flush_literal();
1014 self.plan.push(ArticleChunk::LocalAsset(asset));
1015 if path_len > 0 {
1016 self.end_newline = path_ends_newline;
1017 }
1018 Ok(())
1019 }
1020
1021 fn reserve(&self, additional: usize) -> Result<(), RenderedHtmlLimit> {
1022 if self
1023 .identity_html
1024 .len()
1025 .checked_add(additional)
1026 .is_some_and(|size| size <= self.limit)
1027 {
1028 Ok(())
1029 } else {
1030 Err(RenderedHtmlLimit)
1031 }
1032 }
1033
1034 fn flush_literal(&mut self) {
1035 if !self.literal.is_empty() {
1036 self.plan
1037 .push(ArticleChunk::Literal(Arc::from(self.literal.as_str())));
1038 self.literal.clear();
1039 }
1040 }
1041
1042 fn finish(mut self) -> RenderedArticle {
1043 self.flush_literal();
1044 RenderedArticle {
1045 identity_html: Arc::from(self.identity_html),
1046 plan: self.plan.into(),
1047 }
1048 }
1049}
1050
1051#[derive(Clone, Copy, Debug)]
1052struct RenderedHtmlLimit;
1053
1054enum Escaped<'input> {
1055 Body(EscapePieces<'input>),
1056 Attribute(EscapePieces<'input>),
1057}
1058
1059impl<'input> Escaped<'input> {
1060 fn body(value: &'input str) -> Self {
1061 Self::Body(EscapePieces::new(value, false))
1062 }
1063
1064 fn attribute(value: &'input str) -> Self {
1065 Self::Attribute(EscapePieces::new(value, true))
1066 }
1067}
1068
1069impl<'input> Iterator for Escaped<'input> {
1070 type Item = &'input str;
1071
1072 fn next(&mut self) -> Option<Self::Item> {
1073 match self {
1074 Self::Body(pieces) | Self::Attribute(pieces) => pieces.next(),
1075 }
1076 }
1077}
1078
1079struct EscapePieces<'input> {
1080 remainder: &'input str,
1081 pending: Option<&'static str>,
1082 attribute: bool,
1083}
1084
1085impl<'input> EscapePieces<'input> {
1086 const fn new(value: &'input str, attribute: bool) -> Self {
1087 Self {
1088 remainder: value,
1089 pending: None,
1090 attribute,
1091 }
1092 }
1093}
1094
1095impl<'input> Iterator for EscapePieces<'input> {
1096 type Item = &'input str;
1097
1098 fn next(&mut self) -> Option<Self::Item> {
1099 if let Some(entity) = self.pending.take() {
1100 return Some(entity);
1102 }
1103 let position = self
1104 .remainder
1105 .char_indices()
1106 .find_map(|(index, character)| {
1107 escape_entity(character, self.attribute)
1108 .map(|entity| (index, character.len_utf8(), entity))
1109 });
1110 let Some((index, width, entity)) = position else {
1111 return (!self.remainder.is_empty()).then(|| std::mem::take(&mut self.remainder));
1112 };
1113 if index == 0 {
1114 self.remainder = &self.remainder[width..];
1115 return Some(entity);
1116 }
1117 let literal = &self.remainder[..index];
1118 self.remainder = &self.remainder[index + width..];
1119 self.pending = Some(entity);
1120 Some(literal)
1121 }
1122}
1123
1124const fn escape_entity(character: char, attribute: bool) -> Option<&'static str> {
1125 match character {
1126 '&' => Some("&"),
1127 '<' => Some("<"),
1128 '>' => Some(">"),
1129 '"' if attribute => Some("""),
1130 _ => None,
1131 }
1132}
1133
1134fn normalize_navigation(value: &str) -> Result<String, NavigationRejection> {
1135 validate_navigation_text(value)?;
1136 if value.starts_with('/') {
1137 normalize_root_relative_navigation(value)
1138 } else {
1139 normalize_https_navigation(value)
1140 }
1141}
1142
1143fn validate_navigation_text(value: &str) -> Result<(), NavigationRejection> {
1144 if value.is_empty() {
1145 return Err(NavigationRejection::Empty);
1146 }
1147 if value.trim() != value {
1148 return Err(NavigationRejection::Whitespace);
1149 }
1150 if value.contains('\\') || value.chars().any(char::is_control) {
1151 return Err(NavigationRejection::ForbiddenCharacter);
1152 }
1153 if value.starts_with("//") {
1154 return Err(NavigationRejection::ProtocolRelative);
1155 }
1156 Ok(())
1157}
1158
1159fn normalize_root_relative_navigation(value: &str) -> Result<String, NavigationRejection> {
1160 reject_traversal(raw_root_relative_path(value))?;
1161 let base =
1162 Url::parse("https://maincopy.invalid/").map_err(|_| NavigationRejection::Malformed)?;
1163 let parsed = base
1164 .join(value)
1165 .map_err(|_| NavigationRejection::Malformed)?;
1166 if parsed.origin() != base.origin()
1167 || !parsed.username().is_empty()
1168 || parsed.password().is_some()
1169 {
1170 return Err(NavigationRejection::Malformed);
1171 }
1172 Ok(parsed[Position::BeforePath..].to_owned())
1173}
1174
1175fn normalize_https_navigation(value: &str) -> Result<String, NavigationRejection> {
1176 validate_raw_https_navigation(value)?;
1177 let parsed = Url::parse(value).map_err(|_| NavigationRejection::Malformed)?;
1178 if parsed.scheme() != "https" {
1179 return Err(NavigationRejection::UnsupportedScheme);
1180 }
1181 if parsed.host().is_none() {
1182 return Err(NavigationRejection::MissingAuthority);
1183 }
1184 if !parsed.username().is_empty() || parsed.password().is_some() {
1185 return Err(NavigationRejection::Credentials);
1186 }
1187 Ok(parsed.into())
1188}
1189
1190fn validate_raw_https_navigation(value: &str) -> Result<(), NavigationRejection> {
1191 let Some((raw_scheme, remainder)) = value.split_once("://") else {
1192 return Err(NavigationRejection::NotRootRelative);
1193 };
1194 if !raw_scheme.eq_ignore_ascii_case("https") {
1195 return Err(NavigationRejection::UnsupportedScheme);
1196 }
1197 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
1198 let authority = &remainder[..authority_end];
1199 if authority.is_empty() || authority.ends_with(':') {
1200 return Err(NavigationRejection::MissingAuthority);
1201 }
1202 if authority.contains('@') {
1203 return Err(NavigationRejection::Credentials);
1204 }
1205 reject_traversal(
1206 remainder[authority_end..]
1207 .split(['?', '#'])
1208 .next()
1209 .unwrap_or(""),
1210 )
1211}
1212
1213fn raw_root_relative_path(value: &str) -> &str {
1214 value.split(['?', '#']).next().unwrap_or("")
1215}
1216
1217fn reject_traversal(path: &str) -> Result<(), NavigationRejection> {
1218 for component in path.split('/') {
1219 if matches!(component, "." | "..") || has_encoded_path_control(component) {
1220 return Err(NavigationRejection::Traversal);
1221 }
1222 }
1223 Ok(())
1224}
1225
1226fn has_encoded_path_control(component: &str) -> bool {
1227 let bytes = component.as_bytes();
1228 let mut index = 0;
1229 while index < bytes.len() {
1230 if bytes[index] == b'%' {
1231 let Some(encoded) = bytes
1232 .get(index + 1..index + 3)
1233 .and_then(|value| decode_hex_pair(value.try_into().ok()?))
1234 else {
1235 return true;
1236 };
1237 if matches!(encoded, b'.' | b'/' | b'\\' | b'%') {
1238 return true;
1239 }
1240 index += 3;
1241 } else {
1242 index += 1;
1243 }
1244 }
1245 false
1246}
1247
1248fn decode_hex_pair(pair: &[u8; 2]) -> Option<u8> {
1249 let high = hex_value(pair[0])?;
1250 let low = hex_value(pair[1])?;
1251 Some((high << 4) | low)
1252}
1253
1254const fn hex_value(value: u8) -> Option<u8> {
1255 match value {
1256 b'0'..=b'9' => Some(value - b'0'),
1257 b'a'..=b'f' => Some(value - b'a' + 10),
1258 b'A'..=b'F' => Some(value - b'A' + 10),
1259 _ => None,
1260 }
1261}
1262
1263fn destination_kind(kind: MarkdownDestinationKind) -> RenderDestinationKind {
1264 match kind {
1265 MarkdownDestinationKind::Image => RenderDestinationKind::Image,
1266 MarkdownDestinationKind::Download => RenderDestinationKind::Link,
1267 }
1268}
1269
1270fn local_asset_projection_error(
1271 path: &LogicalContentPath,
1272 error: ResolvedLocalAssetLookupError,
1273) -> MarkdownRenderError {
1274 let code = match error {
1275 ResolvedLocalAssetLookupError::Missing => MarkdownRenderErrorCode::LocalAssetMissing,
1276 ResolvedLocalAssetLookupError::DigestMismatch => {
1277 MarkdownRenderErrorCode::LocalAssetDigestMismatch
1278 }
1279 };
1280 MarkdownRenderError {
1281 path: path.clone(),
1282 location: MarkdownRenderLocation::Document,
1283 code,
1284 navigation_rejection: None,
1285 message: error.to_string().into_boxed_str(),
1286 }
1287}
1288
1289fn rendered_html_limit_error(path: &LogicalContentPath) -> MarkdownRenderError {
1290 MarkdownRenderError {
1291 path: path.clone(),
1292 location: MarkdownRenderLocation::Document,
1293 code: MarkdownRenderErrorCode::RenderedHtmlTooLarge,
1294 navigation_rejection: None,
1295 message: "rendered article HTML exceeds the configured byte limit".into(),
1296 }
1297}
1298
1299fn mermaid_error(
1300 document: &PostDocument,
1301 location: MarkdownRenderLocation,
1302 error: DiagramRenderErrorCode,
1303) -> MarkdownRenderError {
1304 let (code, message) = match error {
1305 DiagramRenderErrorCode::Unavailable => (
1306 MarkdownRenderErrorCode::MermaidRendererUnavailable,
1307 "the packaged Mermaid renderer is unavailable",
1308 ),
1309 DiagramRenderErrorCode::InvalidDiagram => (
1310 MarkdownRenderErrorCode::MermaidInvalid,
1311 "the Mermaid diagram is invalid or unsupported",
1312 ),
1313 DiagramRenderErrorCode::ResourceLimit => (
1314 MarkdownRenderErrorCode::MermaidRendererResourceLimit,
1315 "the Mermaid diagram exceeded a renderer resource limit",
1316 ),
1317 DiagramRenderErrorCode::TimedOut => (
1318 MarkdownRenderErrorCode::MermaidRendererTimedOut,
1319 "the Mermaid diagram exceeded the renderer deadline",
1320 ),
1321 DiagramRenderErrorCode::InvalidOutput => (
1322 MarkdownRenderErrorCode::MermaidRendererInvalidOutput,
1323 "the Mermaid renderer returned invalid output",
1324 ),
1325 DiagramRenderErrorCode::UnsafeSvg => (
1326 MarkdownRenderErrorCode::MermaidSvgRejected,
1327 "the Mermaid renderer output failed the SVG security policy",
1328 ),
1329 DiagramRenderErrorCode::Internal => (
1330 MarkdownRenderErrorCode::MermaidRendererInternal,
1331 "the Mermaid renderer failed internally",
1332 ),
1333 };
1334 MarkdownRenderError::new(document, location, code, message)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use maincopy_diagram_renderer::client::MermaidRenderError;
1340
1341 use super::*;
1342 use markdown_compiler::{PostCollection, PreparedContent, prepare_content};
1343
1344 use crate::content_fixtures::{asset, content_tree, post, publication};
1345 use crate::render::svg::{MermaidSvgSanitizer, SvgScope};
1346
1347 const POST_ID: &str = "4f054633-2d09-4b05-97d0-c6f0011a5199";
1348 const FIXTURE_SVG: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\"><text x=\"0\" y=\"1\">diagram</text></svg>";
1349
1350 fn render_fixture_mermaid(
1351 _source: &str,
1352 ordinal: NonZeroUsize,
1353 ) -> Result<SanitizedSvg, DiagramRenderError> {
1354 MermaidSvgSanitizer::sanitize(FIXTURE_SVG, SvgScope::new(POST_ID.as_bytes(), ordinal))
1355 .map_err(DiagramRenderError::Sanitizer)
1356 }
1357
1358 fn render_markdown_for_test(
1359 content: &PreparedContent,
1360 limits: RendererLimits,
1361 ) -> Result<RenderedPost, MarkdownRenderError> {
1362 let content = content.view();
1363 render_markdown_with_renderer(
1364 &content.posts[0],
1365 content.site_assets,
1366 limits,
1367 &mut render_fixture_mermaid,
1368 )
1369 }
1370
1371 fn publication_source(title: &str, origins: &[&str]) -> String {
1372 let origins = origins
1373 .iter()
1374 .map(|origin| format!("{origin:?}"))
1375 .collect::<Vec<_>>()
1376 .join(", ");
1377 format!(
1378 "[site]\n\
1379 title = {title:?}\n\
1380 base_url = \"https://blog.example.com/\"\n\
1381 description = \"Renderer tests.\"\n\
1382 [author]\n\
1383 name = \"Example Author\"\n\
1384 [assets]\n\
1385 allowed_https_origins = [{origins}]\n"
1386 )
1387 }
1388
1389 fn post_source(body: &str, draft: bool) -> String {
1390 format!(
1391 "+++\n\
1392 id = \"{POST_ID}\"\n\
1393 title = \"Rendered Post\"\n\
1394 slug = \"rendered-post\"\n\
1395 authored_at = 2026-08-29T15:00:00-04:00\n\
1396 description = \"Renderer fixture.\"\n\
1397 draft = {draft}\n\
1398 +++\n\
1399 {body}"
1400 )
1401 }
1402
1403 fn candidate(
1404 title: &str,
1405 origins: &[&str],
1406 body: &str,
1407 draft: bool,
1408 asset_paths: &[&str],
1409 ) -> PreparedContent {
1410 let tree = content_tree(
1411 publication("publication.toml", publication_source(title, origins)),
1412 vec![post(
1413 if draft {
1414 "drafts/rendered.md"
1415 } else {
1416 "posts/rendered.md"
1417 },
1418 if draft {
1419 PostCollection::Drafts
1420 } else {
1421 PostCollection::Posts
1422 },
1423 post_source(body, draft),
1424 )],
1425 asset_paths
1426 .iter()
1427 .map(|path| {
1428 asset(
1429 LogicalAssetPath::parse(path).expect("fixture path must parse"),
1430 format!("bytes:{path}").into_bytes(),
1431 )
1432 })
1433 .collect(),
1434 0,
1435 );
1436 prepare_content(&tree).expect("fixture content and assets must validate")
1437 }
1438
1439 fn candidate_with_local_bytes(body: &str, path: Option<&str>, bytes: &[u8]) -> PreparedContent {
1440 let discovered_assets = path
1441 .map(|path| {
1442 vec![asset(
1443 LogicalAssetPath::parse(path).expect("fixture path must parse"),
1444 bytes.to_vec(),
1445 )]
1446 })
1447 .unwrap_or_default();
1448 let tree = content_tree(
1449 publication("publication.toml", publication_source("Renderer", &[])),
1450 vec![post(
1451 "posts/rendered.md",
1452 PostCollection::Posts,
1453 post_source(body, false),
1454 )],
1455 discovered_assets,
1456 0,
1457 );
1458 prepare_content(&tree).expect("fixture content and assets must validate")
1459 }
1460
1461 fn render(origins: &[&str], body: &str, asset_paths: &[&str]) -> RenderedPost {
1462 let assets = candidate("Renderer", origins, body, false, asset_paths);
1463 render_markdown_for_test(&assets, RendererLimits::production())
1464 .expect("fixture must render")
1465 }
1466
1467 fn render_error(body: &str) -> MarkdownRenderError {
1468 let assets = candidate("Renderer", &[], body, false, &[]);
1469 render_markdown_for_test(&assets, RendererLimits::production())
1470 .expect_err("fixture must be rejected")
1471 }
1472
1473 fn snapshot() -> SiteSnapshotDigest {
1474 SiteSnapshotDigest::parse(&format!("site-b3-v1-{}", "11".repeat(32))).unwrap()
1475 }
1476
1477 fn collect_and_escape_image_alt_events(
1478 events: Vec<Event<'static>>,
1479 ) -> Result<(String, String), MarkdownRenderError> {
1480 let assets = candidate("Renderer", &[], "body", false, &[]);
1481 let document = assets.view().posts[0].view().document;
1482 let mut render_mermaid = render_fixture_mermaid;
1483 let mut renderer = MarkdownEventRenderer::new(
1484 document,
1485 assets.view().posts[0].view().assets,
1486 assets.view().site_assets,
1487 &mut render_mermaid,
1488 RendererLimits::production(),
1489 );
1490 renderer.events = events.into_iter().map(|event| (event, 0..0)).collect();
1491 let alt = renderer.collect_image_alt()?;
1492 renderer.write_escaped_attribute(&alt)?;
1493 Ok((alt, renderer.writer.identity_html))
1494 }
1495
1496 #[test]
1497 fn exact_commonmark_html_and_typed_asset_projection_are_deterministic() {
1498 let body = "# Heading\n\nRaw <b>& text</b>.\n\n[route](/notes?x=1&y=2)\n\n\n\n[manual](assets/files/manual.pdf)\n\n```rust\n<a>&\n```\n\n```ascii\n+---+\n| A |\n+---+\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n";
1499 let first = render(
1500 &[],
1501 body,
1502 &["assets/images/diagram.png", "assets/files/manual.pdf"],
1503 );
1504 let second = render(
1505 &[],
1506 body,
1507 &["assets/files/manual.pdf", "assets/images/diagram.png"],
1508 );
1509 let expected = "<h1>Heading</h1>\n\
1510 <p>Raw <b>& text</b>.</p>\n\
1511 <p><a href=\"/notes?x=1&y=2\">route</a></p>\n\
1512 <p><img src=\"assets/images/diagram.png\" alt=\"diagram\" title=\"Diagram\" /></p>\n\
1513 <p><a href=\"assets/files/manual.pdf\">manual</a></p>\n\
1514 <pre class=\"article-code\"><code class=\"language-rust\"><a>&\n</code></pre>\n\
1515 <pre><code>+---+\n| A |\n+---+\n</code></pre>\n\
1516 <div id=\"maincopy-diagram-return-1\"></div>\n\
1517 <div class=\"mermaid-diagram\" data-maincopy-mermaid=\"v1\" data-block=\"1\" id=\"maincopy-diagram-1\"><div class=\"diagram-controls\"><a class=\"diagram-enlarge\" href=\"#maincopy-diagram-1\">Enlarge diagram</a><a class=\"diagram-close\" href=\"#maincopy-diagram-return-1\">Close diagram</a></div><div class=\"diagram-canvas\"><svg viewBox=\"0 0 1 1\" xmlns=\"http://www.w3.org/2000/svg\"><text x=\"0\" y=\"1\">diagram</text></svg></div></div>\n";
1518 assert_eq!(first.article.identity_html.as_ref(), expected);
1519 assert_eq!(second.article.identity_html.as_ref(), expected);
1520 assert_eq!(first.revision, second.revision);
1521 assert!(first.article.identity_html.contains("<svg "));
1522 assert!(!first.article.identity_html.contains("graph TD"));
1523
1524 let projection_assets = candidate(
1525 "Renderer",
1526 &[],
1527 body,
1528 false,
1529 &["assets/images/diagram.png", "assets/files/manual.pdf"],
1530 );
1531 let projected = first
1532 .project_for_snapshot(
1533 &snapshot(),
1534 projection_assets.view().site_assets,
1535 projection_assets.view().local_assets,
1536 )
1537 .unwrap();
1538 assert!(projected.contains(&format!("/assets/{}/images/diagram.png", snapshot())));
1539 assert!(!projected.contains("assets/images/diagram.png\""));
1540 }
1541
1542 #[test]
1543 fn raw_html_is_visible_text_in_blocks_inline_and_image_alt_text() {
1544 let rendered = render(
1545 &[],
1546 "<script>alert(1)</script>\n\n<!-- comment -->\n\n<style>x</style>\n\n<svg><script>x</script></svg>\n\n\n",
1547 &["assets/image.png"],
1548 );
1549 let html = &rendered.article.identity_html;
1550 assert!(!html.contains("<script>"));
1551 assert!(!html.contains("<!--"));
1552 assert!(!html.contains("<style>"));
1553 assert!(!html.contains("<svg>"));
1554 assert!(html.contains("<script>alert(1)</script>"));
1555 assert!(html.contains("alt=\"<b>alt</b>\""));
1556 }
1557
1558 #[test]
1559 fn image_alt_collects_nested_destinations_and_all_textual_event_forms() {
1560 let rendered = render(
1561 &[],
1562 "  `code`<b>raw</b> \nnext](assets/outer.png)\n",
1563 &["assets/outer.png", "assets/nested.png"],
1564 );
1565 assert!(
1566 rendered
1567 .article
1568 .identity_html
1569 .contains("alt=\"outer em link nested code<b>raw</b> next\"")
1570 );
1571
1572 let (alt, escaped) = collect_and_escape_image_alt_events(vec![
1573 Event::Start(Tag::Emphasis),
1574 Event::Text("text".into()),
1575 Event::Code("code".into()),
1576 Event::Html("<block>&".into()),
1577 Event::InlineHtml("<inline>".into()),
1578 Event::SoftBreak,
1579 Event::HardBreak,
1580 Event::Rule,
1581 Event::InlineMath("inline-math".into()),
1582 Event::DisplayMath("display-math".into()),
1583 Event::FootnoteReference("note".into()),
1584 Event::TaskListMarker(true),
1585 Event::TaskListMarker(false),
1586 Event::End(TagEnd::Emphasis),
1587 Event::End(TagEnd::Image),
1588 ])
1589 .unwrap();
1590 assert_eq!(
1591 alt,
1592 "textcode<block>&<inline> inline-mathdisplay-math[note][x][ ]"
1593 );
1594 assert_eq!(
1595 escaped,
1596 "textcode<block>&<inline> inline-mathdisplay-math[note][x][ ]"
1597 );
1598 }
1599
1600 #[test]
1601 fn image_alt_rejects_unbalanced_events_and_nesting_overflow() {
1602 for events in [
1603 vec![Event::End(TagEnd::Strong), Event::End(TagEnd::Image)],
1604 vec![Event::Start(Tag::Emphasis), Event::End(TagEnd::Image)],
1605 vec![Event::Text("unterminated".into())],
1606 ] {
1607 let error = collect_and_escape_image_alt_events(events).unwrap_err();
1608 assert_eq!(
1609 error.code,
1610 MarkdownRenderErrorCode::MalformedCommonMarkEvents
1611 );
1612 }
1613
1614 let assets = candidate("Renderer", &[], "body", false, &[]);
1615 let document = assets.view().posts[0].view().document;
1616 let mut render_mermaid = render_fixture_mermaid;
1617 let renderer = MarkdownEventRenderer::new(
1618 document,
1619 assets.view().posts[0].view().assets,
1620 assets.view().site_assets,
1621 &mut render_mermaid,
1622 RendererLimits::production(),
1623 );
1624 let mut depth = usize::MAX;
1625 let error = renderer.enter_image_alt_nesting(&mut depth).unwrap_err();
1626 assert_eq!(
1627 error.code,
1628 MarkdownRenderErrorCode::MalformedCommonMarkEvents
1629 );
1630 assert_eq!(depth, usize::MAX);
1631 }
1632
1633 #[test]
1634 fn declared_fence_aliases_are_canonical_and_unknown_values_remain_plain() {
1635 let expected = render(&[], "```\n<a>&\n```\n", &[]);
1636 for label in [
1637 "text",
1638 "ascii",
1639 "Mermaid",
1640 "mermaid trailing",
1641 "rust linenos",
1642 "rĂ¼st",
1643 ] {
1644 let rendered = render(&[], &format!("```{label}\n<a>&\n```\n"), &[]);
1645 assert_eq!(
1646 rendered.article.identity_html,
1647 expected.article.identity_html
1648 );
1649 assert!(!rendered.article.identity_html.contains("mermaid-diagram"));
1650 assert!(!rendered.article.identity_html.contains("language-"));
1651 }
1652 let rust = render(&[], "```RuSt\n<a>&\n```\n", &[]);
1653 let alias = render(&[], "```rs\n<a>&\n```\n", &[]);
1654 assert_eq!(rust.article.identity_html, alias.article.identity_html);
1655 assert!(
1656 rust.article
1657 .identity_html
1658 .contains("class=\"language-rust\"")
1659 );
1660 assert!(!rust.article.identity_html.contains("RuSt"));
1661 let commonmark_decoded = render(&[], "```mermaid\na-->b\n```\n", &[]);
1662 assert!(
1663 commonmark_decoded
1664 .article
1665 .identity_html
1666 .contains("mermaid-diagram")
1667 );
1668 }
1669
1670 #[test]
1671 fn diagrams_have_distinct_enlarge_and_return_targets() {
1672 let rendered = render(
1673 &[],
1674 "```mermaid\nflowchart LR\nA-->B\n```\n\n```mermaid\nflowchart LR\nC-->D\n```\n",
1675 &[],
1676 );
1677 let html = &rendered.article.identity_html;
1678 for ordinal in [1, 2] {
1679 for prefix in ["maincopy-diagram", "maincopy-diagram-return"] {
1680 let target = format!("{prefix}-{ordinal}");
1681 assert_eq!(html.matches(&format!("id=\"{target}\"")).count(), 1);
1682 assert_eq!(html.matches(&format!("href=\"#{target}\"")).count(), 1);
1683 }
1684 }
1685 assert_eq!(html.matches("<svg ").count(), 2);
1686 }
1687
1688 #[test]
1689 fn preview_and_public_projection_reuse_exact_release_rendering() {
1690 let body = "```rust\nfn main() {}\n```\n\n```ascii\n+---+\n| A |\n+---+\n```\n\n```mermaid\nflowchart LR\nA-->B\n```\n";
1691 let assets = candidate("Renderer", &[], body, false, &[]);
1692 let rendered = render_markdown_for_test(&assets, RendererLimits::production()).unwrap();
1693
1694 let preview = rendered
1695 .project_for_preview(
1696 "/api/admin/v1/preview-assets",
1697 assets.view().site_assets,
1698 assets.view().local_assets,
1699 )
1700 .unwrap();
1701 let public = rendered
1702 .project_for_snapshot(
1703 &snapshot(),
1704 assets.view().site_assets,
1705 assets.view().local_assets,
1706 )
1707 .unwrap();
1708
1709 assert_eq!(preview, public);
1710 assert!(public.contains("class=\"language-rust\""));
1711 assert!(public.contains("<pre><code>+---+"));
1712 assert!(public.contains("class=\"mermaid-diagram\""));
1713 assert!(!public.contains("flowchart LR"));
1714 }
1715
1716 #[test]
1717 fn unsafe_navigation_variants_fail_closed_after_commonmark_unescaping() {
1718 let rejected = [
1719 (
1720 "[bad](javascript:alert)",
1721 NavigationRejection::NotRootRelative,
1722 ),
1723 (
1724 "[bad](javascript:alert)",
1725 NavigationRejection::NotRootRelative,
1726 ),
1727 (
1728 "[bad](mailto:x@example.com)",
1729 NavigationRejection::NotRootRelative,
1730 ),
1731 (
1732 "[bad](//example.com/path)",
1733 NavigationRejection::ProtocolRelative,
1734 ),
1735 (
1736 "[bad](https://user@example.com/path)",
1737 NavigationRejection::Credentials,
1738 ),
1739 (
1740 "[bad](https:example.com)",
1741 NavigationRejection::NotRootRelative,
1742 ),
1743 (
1744 "[bad](https:///host)",
1745 NavigationRejection::MissingAuthority,
1746 ),
1747 ("[bad](/a/../secret)", NavigationRejection::Traversal),
1748 ("[bad](/a/%2e%2e/secret)", NavigationRejection::Traversal),
1749 (
1750 "[bad](/a/%252e%252e/secret)",
1751 NavigationRejection::Traversal,
1752 ),
1753 ];
1754 for (body, reason) in rejected {
1755 let error = render_error(body);
1756 assert_eq!(
1757 error.code,
1758 MarkdownRenderErrorCode::NavigationRejected,
1759 "{body}"
1760 );
1761 assert_eq!(error.navigation_rejection, Some(reason), "{body}");
1762 }
1763 }
1764
1765 #[test]
1766 fn absolute_https_root_relative_reference_and_autolinks_are_rebuilt() {
1767 let rendered = render(
1768 &[],
1769 "[root](/a/b?q=1#part) [absolute](HTTPS://EXAMPLE.COM:443/a?q=1&x=2) [reference][target] <https://example.net/read>\n\n[target]: /reference\n",
1770 &[],
1771 );
1772 assert_eq!(
1773 rendered.article.identity_html.as_ref(),
1774 "<p><a href=\"/a/b?q=1#part\">root</a> <a href=\"https://example.com/a?q=1&x=2\">absolute</a> <a href=\"/reference\">reference</a> <a href=\"https://example.net/read\">https://example.net/read</a></p>\n"
1775 );
1776
1777 let error = render_error("<person@example.com>");
1778 assert_eq!(error.code, MarkdownRenderErrorCode::NavigationRejected);
1779 }
1780
1781 #[test]
1782 fn resolver_occurrences_are_unique_exact_and_exhaustive() {
1783 let body = "[safe](/safe)\n\n\n\n[file](assets/file.pdf)\n";
1784 let assets = candidate(
1785 "Renderer",
1786 &[],
1787 body,
1788 false,
1789 &["assets/image.png", "assets/file.pdf"],
1790 );
1791 let approved = assets.view().posts[0].view().assets;
1792
1793 let reordered = candidate(
1794 "Renderer",
1795 &[],
1796 "\n\n[safe](/safe)\n\n[file](assets/file.pdf)\n",
1797 false,
1798 &["assets/image.png", "assets/file.pdf"],
1799 );
1800 let error = MarkdownEventRenderer::new(
1801 reordered.view().posts[0].view().document,
1802 approved,
1803 assets.view().site_assets,
1804 &mut render_fixture_mermaid,
1805 RendererLimits::production(),
1806 )
1807 .render()
1808 .unwrap_err();
1809 assert_eq!(error.code, MarkdownRenderErrorCode::AssetOccurrenceMissing);
1810
1811 let shifted = candidate(
1812 "Renderer",
1813 &[],
1814 "[safe](/safe)\n\n \n\n\n[file](assets/file.pdf)\n",
1815 false,
1816 &["assets/image.png", "assets/file.pdf"],
1817 );
1818 let error = MarkdownEventRenderer::new(
1819 shifted.view().posts[0].view().document,
1820 approved,
1821 assets.view().site_assets,
1822 &mut render_fixture_mermaid,
1823 RendererLimits::production(),
1824 )
1825 .render()
1826 .unwrap_err();
1827 assert_eq!(error.code, MarkdownRenderErrorCode::AssetOccurrenceMismatch);
1828
1829 let removed = candidate("Renderer", &[], "[safe](/safe)\n", false, &[]);
1830 let error = MarkdownEventRenderer::new(
1831 removed.view().posts[0].view().document,
1832 approved,
1833 assets.view().site_assets,
1834 &mut render_fixture_mermaid,
1835 RendererLimits::production(),
1836 )
1837 .render()
1838 .unwrap_err();
1839 assert_eq!(error.code, MarkdownRenderErrorCode::AssetOccurrenceUnused);
1840 }
1841
1842 #[test]
1843 fn mermaid_timeout_rejects_the_article_with_the_authored_code_block_location() {
1844 let assets = candidate(
1845 "Renderer",
1846 &[],
1847 "```text\nplain\n```\n\n```mermaid\ngraph TD; A-->B\n```\n",
1848 false,
1849 &[],
1850 );
1851 let document = assets.view().posts[0].view().document;
1852 let error = render_markdown_with_renderer(
1853 &assets.view().posts[0],
1854 assets.view().site_assets,
1855 RendererLimits::production(),
1856 &mut |_, _| Err(DiagramRenderError::Renderer(MermaidRenderError::TimedOut)),
1857 )
1858 .unwrap_err();
1859 assert_eq!(error.path, document.path);
1860 assert_eq!(error.code, MarkdownRenderErrorCode::MermaidRendererTimedOut);
1861 assert_eq!(
1862 error.location,
1863 MarkdownRenderLocation::CodeBlock {
1864 ordinal: CodeBlockOrdinal::new(NonZeroUsize::new(2).unwrap()),
1865 }
1866 );
1867 assert_eq!(
1868 error.message.as_ref(),
1869 "the Mermaid diagram exceeded the renderer deadline"
1870 );
1871 }
1872
1873 #[test]
1874 fn renderer_limits_are_inclusive_and_fail_one_byte_or_block_past() {
1875 let assets = candidate("Renderer", &[], "```mermaid\nabc\n```\n", false, &[]);
1876 let base = RendererLimits {
1877 rendered_html_bytes: 1_024,
1878 mermaid_source_bytes: 4,
1879 mermaid_svg_bytes: 1_024,
1880 mermaid_blocks: 1,
1881 code_blocks: 256,
1882 };
1883 render_markdown_for_test(&assets, base).unwrap();
1884 let error = render_markdown_for_test(
1885 &assets,
1886 RendererLimits {
1887 mermaid_source_bytes: 3,
1888 ..base
1889 },
1890 )
1891 .unwrap_err();
1892 assert_eq!(error.code, MarkdownRenderErrorCode::MermaidBlockTooLarge);
1893
1894 let svg_bytes = render_fixture_mermaid("abc\n", NonZeroUsize::MIN)
1895 .unwrap()
1896 .as_str()
1897 .len();
1898 render_markdown_for_test(
1899 &assets,
1900 RendererLimits {
1901 mermaid_svg_bytes: svg_bytes,
1902 ..base
1903 },
1904 )
1905 .unwrap();
1906 let error = render_markdown_for_test(
1907 &assets,
1908 RendererLimits {
1909 mermaid_svg_bytes: svg_bytes - 1,
1910 ..base
1911 },
1912 )
1913 .unwrap_err();
1914 assert_eq!(
1915 error.code,
1916 MarkdownRenderErrorCode::MermaidRenderedOutputTooLarge
1917 );
1918
1919 let two_assets = candidate(
1920 "Renderer",
1921 &[],
1922 "```mermaid\na\n```\n\n```mermaid\nb\n```\n",
1923 false,
1924 &[],
1925 );
1926 let error = render_markdown_for_test(&two_assets, base).unwrap_err();
1927 assert_eq!(
1928 error.code,
1929 MarkdownRenderErrorCode::MermaidBlockCountExceeded
1930 );
1931
1932 let two_code_assets =
1933 candidate("Renderer", &[], "```\na\n```\n\n```\nb\n```\n", false, &[]);
1934 let error = render_markdown_for_test(
1935 &two_code_assets,
1936 RendererLimits {
1937 code_blocks: 1,
1938 ..base
1939 },
1940 )
1941 .unwrap_err();
1942 assert_eq!(error.code, MarkdownRenderErrorCode::CodeBlockCountExceeded);
1943
1944 let mut writer = ArticleWriter::new(3);
1945 assert!(writer.write("abc").is_ok());
1946 assert!(writer.write("d").is_err());
1947 let small_assets = candidate("Renderer", &[], "a", false, &[]);
1948 let error = render_markdown_for_test(
1949 &small_assets,
1950 RendererLimits {
1951 rendered_html_bytes: 7,
1952 ..base
1953 },
1954 )
1955 .unwrap_err();
1956 assert_eq!(error.code, MarkdownRenderErrorCode::RenderedHtmlTooLarge);
1957 }
1958
1959 #[test]
1960 fn stale_policy_product_cannot_project_even_when_public_revision_is_unchanged() {
1961 let body = "\n";
1962 let old_assets = candidate(
1963 "Renderer",
1964 &["https://cdn.example.com", "https://unused.example.com"],
1965 body,
1966 false,
1967 &[],
1968 );
1969 let old = render_markdown_for_test(&old_assets, RendererLimits::production()).unwrap();
1970 let new_assets = candidate("Renderer", &["https://cdn.example.com"], body, false, &[]);
1971 let fresh = render_markdown_for_test(&new_assets, RendererLimits::production()).unwrap();
1972 assert_eq!(old.revision, fresh.revision);
1973 let error = old
1974 .project_for_snapshot(
1975 &snapshot(),
1976 new_assets.view().site_assets,
1977 new_assets.view().local_assets,
1978 )
1979 .unwrap_err();
1980 assert_eq!(error.code, MarkdownRenderErrorCode::AssetPolicyMismatch);
1981 assert!(
1982 fresh
1983 .project_for_snapshot(
1984 &snapshot(),
1985 new_assets.view().site_assets,
1986 new_assets.view().local_assets,
1987 )
1988 .unwrap()
1989 .contains("https://cdn.example.com/cover-v1.png")
1990 );
1991 }
1992
1993 #[test]
1994 fn projection_requires_the_candidate_store_to_match_every_local_asset_digest() {
1995 let body = "\n";
1996 let old_assets = candidate_with_local_bytes(body, Some("assets/cover.png"), b"old bytes");
1997 let old = render_markdown_for_test(&old_assets, RendererLimits::production()).unwrap();
1998 let changed_assets =
1999 candidate_with_local_bytes(body, Some("assets/cover.png"), b"changed bytes");
2000 let error = old
2001 .project_for_snapshot(
2002 &snapshot(),
2003 changed_assets.view().site_assets,
2004 changed_assets.view().local_assets,
2005 )
2006 .unwrap_err();
2007 assert_eq!(
2008 error.code,
2009 MarkdownRenderErrorCode::LocalAssetDigestMismatch
2010 );
2011
2012 let missing_assets = candidate_with_local_bytes("No asset.\n", None, b"");
2013 let error = old
2014 .project_for_snapshot(
2015 &snapshot(),
2016 missing_assets.view().site_assets,
2017 missing_assets.view().local_assets,
2018 )
2019 .unwrap_err();
2020 assert_eq!(error.code, MarkdownRenderErrorCode::LocalAssetMissing);
2021
2022 assert!(
2023 old.project_for_snapshot(
2024 &snapshot(),
2025 old_assets.view().site_assets,
2026 old_assets.view().local_assets,
2027 )
2028 .unwrap()
2029 .contains(&format!("/assets/{}/cover.png", snapshot()))
2030 );
2031 }
2032
2033 #[test]
2034 fn render_error_wire_contract_uses_typed_codes_and_ordinals() {
2035 let error = render_error("[bad](//example.com)");
2036 assert_eq!(
2037 serde_json::to_value(error).unwrap(),
2038 serde_json::json!({
2039 "path": "posts/rendered.md",
2040 "location": {
2041 "kind": "destination",
2042 "ordinal": 1,
2043 "destination_kind": "link"
2044 },
2045 "code": "navigation_rejected",
2046 "navigation_rejection": "protocol_relative",
2047 "message": "link destination was rejected: ProtocolRelative"
2048 })
2049 );
2050 }
2051
2052 #[test]
2053 fn renderer_wire_types_and_ordinals_have_stable_contracts() {
2054 let destination_ordinal = MarkdownDestinationOrdinal::new(NonZeroUsize::new(2).unwrap());
2055 let code_ordinal = CodeBlockOrdinal::new(NonZeroUsize::new(3).unwrap());
2056 assert_eq!(serde_json::to_value(destination_ordinal).unwrap(), 2);
2057 assert_eq!(serde_json::to_value(code_ordinal).unwrap(), 3);
2058
2059 for (value, expected) in [
2060 (RenderDestinationKind::Link, "link"),
2061 (RenderDestinationKind::Image, "image"),
2062 ] {
2063 assert_eq!(serde_json::to_value(value).unwrap(), expected);
2064 }
2065
2066 assert_eq!(
2067 serde_json::to_value(MarkdownRenderLocation::Document).unwrap(),
2068 serde_json::json!({ "kind": "document" })
2069 );
2070 assert_eq!(
2071 serde_json::to_value(MarkdownRenderLocation::Destination {
2072 ordinal: destination_ordinal,
2073 destination_kind: RenderDestinationKind::Image,
2074 })
2075 .unwrap(),
2076 serde_json::json!({
2077 "kind": "destination",
2078 "ordinal": 2,
2079 "destination_kind": "image"
2080 })
2081 );
2082 assert_eq!(
2083 serde_json::to_value(MarkdownRenderLocation::CodeBlock {
2084 ordinal: code_ordinal,
2085 })
2086 .unwrap(),
2087 serde_json::json!({ "kind": "code_block", "ordinal": 3 })
2088 );
2089
2090 for (value, expected) in [
2091 (NavigationRejection::Empty, "empty"),
2092 (NavigationRejection::Whitespace, "whitespace"),
2093 (
2094 NavigationRejection::ForbiddenCharacter,
2095 "forbidden_character",
2096 ),
2097 (NavigationRejection::ProtocolRelative, "protocol_relative"),
2098 (NavigationRejection::UnsupportedScheme, "unsupported_scheme"),
2099 (NavigationRejection::MissingAuthority, "missing_authority"),
2100 (NavigationRejection::Credentials, "credentials"),
2101 (NavigationRejection::Traversal, "traversal"),
2102 (NavigationRejection::NotRootRelative, "not_root_relative"),
2103 (NavigationRejection::Malformed, "malformed"),
2104 ] {
2105 assert_eq!(serde_json::to_value(value).unwrap(), expected);
2106 }
2107
2108 for (value, expected) in [
2109 (
2110 MarkdownRenderErrorCode::DestinationCountExceeded,
2111 "destination_count_exceeded",
2112 ),
2113 (
2114 MarkdownRenderErrorCode::AssetOccurrenceMissing,
2115 "asset_occurrence_missing",
2116 ),
2117 (
2118 MarkdownRenderErrorCode::AssetOccurrenceMismatch,
2119 "asset_occurrence_mismatch",
2120 ),
2121 (
2122 MarkdownRenderErrorCode::AssetOccurrenceUnused,
2123 "asset_occurrence_unused",
2124 ),
2125 (
2126 MarkdownRenderErrorCode::NavigationRejected,
2127 "navigation_rejected",
2128 ),
2129 (
2130 MarkdownRenderErrorCode::MermaidBlockCountExceeded,
2131 "mermaid_block_count_exceeded",
2132 ),
2133 (
2134 MarkdownRenderErrorCode::MermaidBlockTooLarge,
2135 "mermaid_block_too_large",
2136 ),
2137 (
2138 MarkdownRenderErrorCode::MermaidRenderedOutputTooLarge,
2139 "mermaid_rendered_output_too_large",
2140 ),
2141 (MarkdownRenderErrorCode::MermaidInvalid, "mermaid_invalid"),
2142 (
2143 MarkdownRenderErrorCode::MermaidRendererUnavailable,
2144 "mermaid_renderer_unavailable",
2145 ),
2146 (
2147 MarkdownRenderErrorCode::MermaidRendererResourceLimit,
2148 "mermaid_renderer_resource_limit",
2149 ),
2150 (
2151 MarkdownRenderErrorCode::MermaidRendererTimedOut,
2152 "mermaid_renderer_timed_out",
2153 ),
2154 (
2155 MarkdownRenderErrorCode::MermaidRendererInvalidOutput,
2156 "mermaid_renderer_invalid_output",
2157 ),
2158 (
2159 MarkdownRenderErrorCode::MermaidSvgRejected,
2160 "mermaid_svg_rejected",
2161 ),
2162 (
2163 MarkdownRenderErrorCode::MermaidRendererInternal,
2164 "mermaid_renderer_internal",
2165 ),
2166 (
2167 MarkdownRenderErrorCode::CodeBlockCountExceeded,
2168 "code_block_count_exceeded",
2169 ),
2170 (
2171 MarkdownRenderErrorCode::RenderedHtmlTooLarge,
2172 "rendered_html_too_large",
2173 ),
2174 (
2175 MarkdownRenderErrorCode::UnsupportedCommonMarkEvent,
2176 "unsupported_common_mark_event",
2177 ),
2178 (
2179 MarkdownRenderErrorCode::MalformedCommonMarkEvents,
2180 "malformed_common_mark_events",
2181 ),
2182 (
2183 MarkdownRenderErrorCode::AssetPolicyMismatch,
2184 "asset_policy_mismatch",
2185 ),
2186 (
2187 MarkdownRenderErrorCode::LocalAssetMissing,
2188 "local_asset_missing",
2189 ),
2190 (
2191 MarkdownRenderErrorCode::LocalAssetDigestMismatch,
2192 "local_asset_digest_mismatch",
2193 ),
2194 (
2195 MarkdownRenderErrorCode::RevisionIdentityRejected,
2196 "revision_identity_rejected",
2197 ),
2198 ] {
2199 assert_eq!(serde_json::to_value(value).unwrap(), expected);
2200 }
2201 }
2202}