Skip to main content

maincopy_server/render/
markdown.rs

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
27/// Render the source and assets paired by compiler preparation.
28pub(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/// One fully rendered, identity-bound post revision.
69#[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            // The entities are static and therefore live for at least `'input`.
1101            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("&amp;"),
1127        '<' => Some("&lt;"),
1128        '>' => Some("&gt;"),
1129        '"' if attribute => Some("&quot;"),
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![diagram](assets/images/diagram.png \"Diagram\")\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 &lt;b&gt;&amp; text&lt;/b&gt;.</p>\n\
1511                        <p><a href=\"/notes?x=1&amp;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\">&lt;a&gt;&amp;\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![<b>alt</b>](assets/image.png)\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("&lt;script&gt;alert(1)&lt;/script&gt;"));
1555        assert!(html.contains("alt=\"&lt;b&gt;alt&lt;/b&gt;\""));
1556    }
1557
1558    #[test]
1559    fn image_alt_collects_nested_destinations_and_all_textual_event_forms() {
1560        let rendered = render(
1561            &[],
1562            "![outer *em* [link](/inside) ![nested](assets/nested.png) `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&lt;b&gt;raw&lt;/b&gt; 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&lt;block&gt;&amp;&lt;inline&gt;   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(&[], "```merm&#x61;id\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](jav&#x61;script: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&amp;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![image](assets/image.png)\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            "![image](assets/image.png)\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![image](assets/image.png)\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 = "![cover](https://cdn.example.com/cover-v1.png)\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 = "![cover](assets/cover.png)\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}