use std::{num::NonZeroUsize, ops::Range, sync::Arc};
use markdown_compiler::{
AssetRevisionReference, DigestedAsset, LogicalAssetPath, LogicalContentPath,
MarkdownDestinationKind, MarkdownDestinationOrdinal, PostDocument, PostRendererIdentity,
PostRevisionDigest, PreparedPost, ResolvedLocalAssetLookupError, ResolvedLocalAssetStore,
ResolvedPostAssets, ResolvedSiteAssets, SiteSnapshotDigest,
};
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use serde::Serialize;
use thiserror::Error;
use url::{Position, Url};
use super::{
SnapshotAssetPath,
code::CodeLanguage,
diagram::{DiagramRenderError, DiagramRenderErrorCode, MermaidDiagramRenderer},
svg::SanitizedSvg,
};
const MAX_RENDERED_HTML_BYTES: usize = 32 * 1024 * 1024;
const MAX_MERMAID_SOURCE_BYTES: usize = 256 * 1024;
const MAX_MERMAID_SVG_BYTES: usize = 16 * 1024 * 1024;
const MAX_MERMAID_BLOCKS: usize = 64;
const MAX_CODE_BLOCKS: usize = 256;
pub(super) fn render_prepared_post(
post: &PreparedPost,
site_assets: &ResolvedSiteAssets,
diagrams: &MermaidDiagramRenderer,
) -> Result<RenderedPost, MarkdownRenderError> {
render_markdown_with_renderer(
post,
site_assets,
RendererLimits::production(),
&mut |source, ordinal| diagrams.render(source, &post.view().document.metadata.id, ordinal),
)
}
fn render_markdown_with_renderer(
post: &PreparedPost,
site_assets: &ResolvedSiteAssets,
limits: RendererLimits,
render_mermaid: &mut dyn FnMut(&str, NonZeroUsize) -> Result<SanitizedSvg, DiagramRenderError>,
) -> Result<RenderedPost, MarkdownRenderError> {
let input = post.view();
let identity = PostRendererIdentity::baseline();
let article = MarkdownEventRenderer::new(
input.document,
input.assets,
site_assets,
render_mermaid,
limits,
)
.render()?;
let revision = post.finalize_revision(&identity, article.identity_html.as_bytes());
Ok(RenderedPost {
document: input.document.clone(),
assets: input.assets.clone(),
renderer: identity,
article,
revision,
})
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderedPost {
pub(crate) document: PostDocument,
pub(crate) assets: ResolvedPostAssets,
pub(crate) renderer: PostRendererIdentity,
pub(crate) article: RenderedArticle,
pub(crate) revision: PostRevisionDigest,
}
impl RenderedPost {
pub(super) fn project_for_snapshot(
&self,
snapshot: &SiteSnapshotDigest,
site_assets: &ResolvedSiteAssets,
local_assets: &ResolvedLocalAssetStore,
) -> Result<String, MarkdownRenderError> {
if !self.assets.shares_policy_with(site_assets) {
return Err(MarkdownRenderError::new(
&self.document,
MarkdownRenderLocation::Document,
MarkdownRenderErrorCode::AssetPolicyMismatch,
"rendered post was approved under a different external-asset policy",
));
}
self.article.project_for_snapshot(
&self.document.path,
snapshot,
local_assets,
MAX_RENDERED_HTML_BYTES,
)
}
pub(super) fn project_for_preview(
&self,
preview_asset_endpoint: &str,
site_assets: &ResolvedSiteAssets,
local_assets: &ResolvedLocalAssetStore,
) -> Result<String, MarkdownRenderError> {
if !self.assets.shares_policy_with(site_assets) {
return Err(MarkdownRenderError::new(
&self.document,
MarkdownRenderLocation::Document,
MarkdownRenderErrorCode::AssetPolicyMismatch,
"rendered post preview uses a different external-asset policy",
));
}
if !preview_asset_endpoint.starts_with('/')
|| preview_asset_endpoint.contains(['?', '#', '"'])
{
return Err(MarkdownRenderError::new(
&self.document,
MarkdownRenderLocation::Document,
MarkdownRenderErrorCode::RevisionIdentityRejected,
"preview asset endpoint is not a safe root-relative path",
));
}
self.article.project_with_local_asset_urls(
&self.document.path,
local_assets,
MAX_RENDERED_HTML_BYTES,
|asset| {
Ok(format!(
"{preview_asset_endpoint}?path={}",
asset.path.as_str()
))
},
)
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct MermaidBlockOrdinal(NonZeroUsize);
impl MermaidBlockOrdinal {
const fn get(self) -> usize {
self.0.get()
}
fn from_index(index: usize) -> Option<Self> {
index.checked_add(1).and_then(NonZeroUsize::new).map(Self)
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct CodeBlockOrdinal(NonZeroUsize);
impl CodeBlockOrdinal {
pub const fn get(self) -> usize {
self.0.get()
}
fn new(value: NonZeroUsize) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RenderDestinationKind {
Link,
Image,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MarkdownRenderLocation {
Document,
Destination {
ordinal: MarkdownDestinationOrdinal,
destination_kind: RenderDestinationKind,
},
CodeBlock {
ordinal: CodeBlockOrdinal,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum NavigationRejection {
Empty,
Whitespace,
ForbiddenCharacter,
ProtocolRelative,
UnsupportedScheme,
MissingAuthority,
Credentials,
Traversal,
NotRootRelative,
Malformed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MarkdownRenderErrorCode {
DestinationCountExceeded,
AssetOccurrenceMissing,
AssetOccurrenceMismatch,
AssetOccurrenceUnused,
NavigationRejected,
MermaidBlockCountExceeded,
MermaidBlockTooLarge,
MermaidRenderedOutputTooLarge,
MermaidInvalid,
MermaidRendererUnavailable,
MermaidRendererResourceLimit,
MermaidRendererTimedOut,
MermaidRendererInvalidOutput,
MermaidSvgRejected,
MermaidRendererInternal,
CodeBlockCountExceeded,
RenderedHtmlTooLarge,
UnsupportedCommonMarkEvent,
MalformedCommonMarkEvents,
AssetPolicyMismatch,
LocalAssetMissing,
LocalAssetDigestMismatch,
RevisionIdentityRejected,
}
#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize)]
#[error("{path}: {code:?}: {message}")]
pub struct MarkdownRenderError {
pub path: LogicalContentPath,
pub location: MarkdownRenderLocation,
pub code: MarkdownRenderErrorCode,
#[serde(skip_serializing_if = "Option::is_none")]
pub navigation_rejection: Option<NavigationRejection>,
pub message: Box<str>,
}
impl MarkdownRenderError {
fn new(
document: &PostDocument,
location: MarkdownRenderLocation,
code: MarkdownRenderErrorCode,
message: impl Into<Box<str>>,
) -> Self {
Self {
path: document.path.clone(),
location,
code,
navigation_rejection: None,
message: message.into(),
}
}
fn navigation(
document: &PostDocument,
ordinal: MarkdownDestinationOrdinal,
reason: NavigationRejection,
) -> Self {
Self {
path: document.path.clone(),
location: MarkdownRenderLocation::Destination {
ordinal,
destination_kind: RenderDestinationKind::Link,
},
code: MarkdownRenderErrorCode::NavigationRejected,
navigation_rejection: Some(reason),
message: format!("link destination was rejected: {reason:?}").into_boxed_str(),
}
}
}
#[derive(Clone, Copy)]
struct RendererLimits {
rendered_html_bytes: usize,
mermaid_source_bytes: usize,
mermaid_svg_bytes: usize,
mermaid_blocks: usize,
code_blocks: usize,
}
impl RendererLimits {
const fn production() -> Self {
Self {
rendered_html_bytes: MAX_RENDERED_HTML_BYTES,
mermaid_source_bytes: MAX_MERMAID_SOURCE_BYTES,
mermaid_svg_bytes: MAX_MERMAID_SVG_BYTES,
mermaid_blocks: MAX_MERMAID_BLOCKS,
code_blocks: MAX_CODE_BLOCKS,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct RenderedArticle {
pub(crate) identity_html: Arc<str>,
plan: Arc<[ArticleChunk]>,
}
impl RenderedArticle {
fn project_for_snapshot(
&self,
path: &LogicalContentPath,
snapshot: &SiteSnapshotDigest,
local_assets: &ResolvedLocalAssetStore,
limit: usize,
) -> Result<String, MarkdownRenderError> {
self.project_with_local_asset_urls(path, local_assets, limit, |asset| {
SnapshotAssetPath::new(snapshot, &asset.path)
.map(|projected| projected.as_str().to_owned())
.map_err(|error| MarkdownRenderError {
path: path.clone(),
location: MarkdownRenderLocation::Document,
code: MarkdownRenderErrorCode::RevisionIdentityRejected,
navigation_rejection: None,
message: error.to_string().into_boxed_str(),
})
})
}
fn project_with_local_asset_urls(
&self,
path: &LogicalContentPath,
local_assets: &ResolvedLocalAssetStore,
limit: usize,
mut project: impl FnMut(&DigestedAsset) -> Result<String, MarkdownRenderError>,
) -> Result<String, MarkdownRenderError> {
for chunk in &*self.plan {
if let ArticleChunk::LocalAsset(asset) = chunk {
local_assets
.resolve(asset)
.map_err(|error| local_asset_projection_error(path, error))?;
}
}
let mut output = String::with_capacity(self.identity_html.len());
for chunk in &*self.plan {
let value = match chunk {
ArticleChunk::Literal(value) => value.as_ref(),
ArticleChunk::LocalAsset(asset) => {
let projected = project(asset)?;
if output
.len()
.checked_add(projected.len())
.is_none_or(|size| size > limit)
{
return Err(rendered_html_limit_error(path));
}
output.push_str(&projected);
continue;
}
};
if output
.len()
.checked_add(value.len())
.is_none_or(|size| size > limit)
{
return Err(rendered_html_limit_error(path));
}
output.push_str(value);
}
Ok(output)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum ArticleChunk {
Literal(Arc<str>),
LocalAsset(DigestedAsset),
}
struct MarkdownEventRenderer<'input, 'renderer> {
document: &'input PostDocument,
assets: &'input ResolvedPostAssets,
site_assets: &'input ResolvedSiteAssets,
events: Vec<(Event<'input>, Range<usize>)>,
cursor: usize,
destination_count: usize,
approved_cursor: usize,
code_block_count: usize,
mermaid_count: usize,
mermaid_svg_bytes: usize,
render_mermaid:
&'renderer mut dyn FnMut(&str, NonZeroUsize) -> Result<SanitizedSvg, DiagramRenderError>,
writer: ArticleWriter,
limits: RendererLimits,
}
impl<'input, 'renderer> MarkdownEventRenderer<'input, 'renderer> {
fn new(
document: &'input PostDocument,
assets: &'input ResolvedPostAssets,
site_assets: &'input ResolvedSiteAssets,
render_mermaid: &'renderer mut dyn FnMut(
&str,
NonZeroUsize,
)
-> Result<SanitizedSvg, DiagramRenderError>,
limits: RendererLimits,
) -> Self {
let events = Parser::new_ext(document.markdown.as_str(), Options::empty())
.into_offset_iter()
.collect();
Self {
document,
assets,
site_assets,
events,
cursor: 0,
destination_count: 0,
approved_cursor: 0,
code_block_count: 0,
mermaid_count: 0,
mermaid_svg_bytes: 0,
render_mermaid,
writer: ArticleWriter::new(limits.rendered_html_bytes),
limits,
}
}
fn render(mut self) -> Result<RenderedArticle, MarkdownRenderError> {
while self.cursor < self.events.len() {
let (event, range) = self.events[self.cursor].clone();
self.cursor += 1;
self.render_event(event, range)?;
}
if let Some(unused) = self.assets.markdown_destinations.get(self.approved_cursor) {
return Err(self.error_at_destination(
unused.ordinal,
destination_kind(unused.kind),
MarkdownRenderErrorCode::AssetOccurrenceUnused,
"resolver-approved asset occurrence was not consumed by the renderer",
));
}
Ok(self.writer.finish())
}
fn render_event(
&mut self,
event: Event<'input>,
range: Range<usize>,
) -> Result<(), MarkdownRenderError> {
match event {
Event::Start(tag) => self.render_start(tag, range),
Event::End(tag) => self.render_end(tag),
Event::Text(text) => self.write_escaped_body(&text),
Event::Code(text) => {
self.write("<code>")?;
self.write_escaped_body(&text)?;
self.write("</code>")
}
Event::Html(html) | Event::InlineHtml(html) => self.write_escaped_body(&html),
Event::SoftBreak => self.write("\n"),
Event::HardBreak => self.write("<br />\n"),
Event::Rule => {
if !self.writer.end_newline {
self.write("\n")?;
}
self.write("<hr />\n")
}
Event::InlineMath(_)
| Event::DisplayMath(_)
| Event::FootnoteReference(_)
| Event::TaskListMarker(_) => Err(self.unsupported("optional CommonMark event")),
}
}
fn render_start(
&mut self,
tag: Tag<'input>,
range: Range<usize>,
) -> Result<(), MarkdownRenderError> {
match tag {
Tag::Paragraph => self.write_block_start("<p>"),
Tag::Heading {
level,
id: _,
classes: _,
attrs: _,
} => self.write_block_start(&format!("<{level}>")),
Tag::BlockQuote(None) => self.write_block_start("<blockquote>\n"),
Tag::CodeBlock(kind) => self.render_code_block(kind),
Tag::List(Some(1)) => self.write_block_start("<ol>\n"),
Tag::List(Some(start)) => {
if !self.writer.end_newline {
self.write("\n")?;
}
self.write("<ol start=\"")?;
self.write(&start.to_string())?;
self.write("\">\n")
}
Tag::List(None) => self.write_block_start("<ul>\n"),
Tag::Item => self.write_block_start("<li>"),
Tag::Emphasis => self.write("<em>"),
Tag::Strong => self.write("<strong>"),
Tag::Link {
link_type: _,
dest_url,
title,
id: _,
} => self.render_link(dest_url.as_ref(), title.as_ref(), range),
Tag::Image {
link_type: _,
dest_url,
title,
id: _,
} => self.render_image(dest_url.as_ref(), title.as_ref(), range),
Tag::HtmlBlock => Ok(()),
Tag::BlockQuote(Some(_))
| Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::Table(_)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::Strikethrough
| Tag::Superscript
| Tag::Subscript
| Tag::MetadataBlock(_) => Err(self.unsupported("optional CommonMark tag")),
}
}
fn render_end(&mut self, tag: TagEnd) -> Result<(), MarkdownRenderError> {
match tag {
TagEnd::Paragraph => self.write("</p>\n"),
TagEnd::Heading(level) => self.write(&format!("</{level}>\n")),
TagEnd::BlockQuote(None) => self.write("</blockquote>\n"),
TagEnd::List(true) => self.write("</ol>\n"),
TagEnd::List(false) => self.write("</ul>\n"),
TagEnd::Item => self.write("</li>\n"),
TagEnd::Emphasis => self.write("</em>"),
TagEnd::Strong => self.write("</strong>"),
TagEnd::Link => self.write("</a>"),
TagEnd::HtmlBlock => Ok(()),
TagEnd::CodeBlock | TagEnd::Image => Err(self.malformed()),
TagEnd::BlockQuote(Some(_))
| TagEnd::FootnoteDefinition
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::Strikethrough
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::MetadataBlock(_) => Err(self.unsupported("optional CommonMark end tag")),
}
}
fn render_code_block(
&mut self,
kind: CodeBlockKind<'input>,
) -> Result<(), MarkdownRenderError> {
let next_count = self
.code_block_count
.checked_add(1)
.ok_or_else(|| self.malformed())?;
let code_ordinal =
CodeBlockOrdinal::new(NonZeroUsize::new(next_count).ok_or_else(|| self.malformed())?);
if next_count > self.limits.code_blocks {
return Err(MarkdownRenderError::new(
self.document,
MarkdownRenderLocation::CodeBlock {
ordinal: code_ordinal,
},
MarkdownRenderErrorCode::CodeBlockCountExceeded,
"post contains more code blocks than the configured limit",
));
}
self.code_block_count = next_count;
let mermaid = matches!(&kind, CodeBlockKind::Fenced(info) if info.as_ref() == "mermaid");
let language = match &kind {
CodeBlockKind::Fenced(info) => CodeLanguage::from_fence_info(info.as_ref()),
CodeBlockKind::Indented => None,
};
let source = self.collect_code_block()?;
if mermaid {
return self.render_mermaid_block(&source, code_ordinal);
}
self.render_plain_code_block(&source, language)?;
Ok(())
}
fn render_mermaid_block(
&mut self,
source: &str,
code_ordinal: CodeBlockOrdinal,
) -> Result<(), MarkdownRenderError> {
self.validate_mermaid_source(source, code_ordinal)?;
let Some(mermaid_ordinal) = MermaidBlockOrdinal::from_index(self.mermaid_count) else {
return Err(self.malformed());
};
let svg = (self.render_mermaid)(source, mermaid_ordinal.0).map_err(|error| {
mermaid_error(
self.document,
MarkdownRenderLocation::CodeBlock {
ordinal: code_ordinal,
},
error.code(),
)
})?;
self.reserve_mermaid_output(&svg, code_ordinal)?;
let ordinal = mermaid_ordinal.get().to_string();
self.write_block_start("<div id=\"maincopy-diagram-return-")?;
self.write(&ordinal)?;
self.write("\"></div>\n")?;
self.write_block_start(
"<div class=\"mermaid-diagram\" data-maincopy-mermaid=\"v1\" data-block=\"",
)?;
self.write(&ordinal)?;
self.write("\" id=\"maincopy-diagram-")?;
self.write(&ordinal)?;
self.write("\"><div class=\"diagram-controls\"><a class=\"diagram-enlarge\" href=\"#maincopy-diagram-")?;
self.write(&ordinal)?;
self.write(
"\">Enlarge diagram</a><a class=\"diagram-close\" href=\"#maincopy-diagram-return-",
)?;
self.write(&ordinal)?;
self.write("\">Close diagram</a></div><div class=\"diagram-canvas\">")?;
self.writer
.write_sanitized_svg(&svg)
.map_err(|_| rendered_html_limit_error(&self.document.path))?;
self.write("</div></div>\n")?;
self.mermaid_count = self
.mermaid_count
.checked_add(1)
.ok_or_else(|| self.malformed())?;
Ok(())
}
fn validate_mermaid_source(
&self,
source: &str,
code_ordinal: CodeBlockOrdinal,
) -> Result<(), MarkdownRenderError> {
let location = MarkdownRenderLocation::CodeBlock {
ordinal: code_ordinal,
};
if self.mermaid_count >= self.limits.mermaid_blocks {
return Err(MarkdownRenderError::new(
self.document,
location,
MarkdownRenderErrorCode::MermaidBlockCountExceeded,
"post contains more Mermaid blocks than the configured limit",
));
}
if source.len() > self.limits.mermaid_source_bytes {
return Err(MarkdownRenderError::new(
self.document,
location,
MarkdownRenderErrorCode::MermaidBlockTooLarge,
"Mermaid source exceeds the configured byte limit",
));
}
Ok(())
}
fn reserve_mermaid_output(
&mut self,
svg: &SanitizedSvg,
code_ordinal: CodeBlockOrdinal,
) -> Result<(), MarkdownRenderError> {
self.mermaid_svg_bytes = self
.mermaid_svg_bytes
.checked_add(svg.as_str().len())
.filter(|size| *size <= self.limits.mermaid_svg_bytes)
.ok_or_else(|| {
MarkdownRenderError::new(
self.document,
MarkdownRenderLocation::CodeBlock {
ordinal: code_ordinal,
},
MarkdownRenderErrorCode::MermaidRenderedOutputTooLarge,
"rendered Mermaid SVG exceeds the per-post byte limit",
)
})?;
Ok(())
}
fn render_plain_code_block(
&mut self,
source: &str,
language: Option<CodeLanguage>,
) -> Result<(), MarkdownRenderError> {
if let Some(language) = language {
self.write_block_start("<pre class=\"article-code\"><code class=\"")?;
self.write(language.html_class())?;
self.write("\">")?;
} else {
self.write_block_start("<pre><code>")?;
}
self.write_escaped_body(source)?;
self.write("</code></pre>\n")
}
fn collect_code_block(&mut self) -> Result<String, MarkdownRenderError> {
let mut source = String::new();
while self.cursor < self.events.len() {
let (event, _) = self.events[self.cursor].clone();
self.cursor += 1;
match event {
Event::Text(text) => source.push_str(&text),
Event::End(TagEnd::CodeBlock) => return Ok(source),
_ => return Err(self.malformed()),
}
}
Err(self.malformed())
}
fn render_link(
&mut self,
authored: &str,
title: &str,
range: Range<usize>,
) -> Result<(), MarkdownRenderError> {
let target = self.validate_destination(
RenderDestinationKind::Link,
MarkdownDestinationKind::Download,
authored,
range,
false,
)?;
self.write("<a href=\"")?;
self.write_destination(&target)?;
if !title.is_empty() {
self.write("\" title=\"")?;
self.write_escaped_attribute(title)?;
}
self.write("\">")
}
fn render_image(
&mut self,
authored: &str,
title: &str,
range: Range<usize>,
) -> Result<(), MarkdownRenderError> {
let target = self.validate_destination(
RenderDestinationKind::Image,
MarkdownDestinationKind::Image,
authored,
range,
true,
)?;
let alt = self.collect_image_alt()?;
self.write("<img src=\"")?;
self.write_destination(&target)?;
self.write("\" alt=\"")?;
self.write_escaped_attribute(&alt)?;
if !title.is_empty() {
self.write("\" title=\"")?;
self.write_escaped_attribute(title)?;
}
self.write("\" />")
}
fn collect_image_alt(&mut self) -> Result<String, MarkdownRenderError> {
let mut output = String::new();
let mut depth = 0_usize;
while self.cursor < self.events.len() {
let (event, range) = self.events[self.cursor].clone();
self.cursor += 1;
match event {
Event::End(TagEnd::Image) if depth == 0 => return Ok(output),
Event::Start(Tag::Image { dest_url, .. }) => {
let _ = self.validate_destination(
RenderDestinationKind::Image,
MarkdownDestinationKind::Image,
dest_url.as_ref(),
range,
true,
)?;
self.enter_image_alt_nesting(&mut depth)?;
}
Event::Start(Tag::Link { dest_url, .. }) => {
let _ = self.validate_destination(
RenderDestinationKind::Link,
MarkdownDestinationKind::Download,
dest_url.as_ref(),
range,
false,
)?;
self.enter_image_alt_nesting(&mut depth)?;
}
Event::Start(_) => {
self.enter_image_alt_nesting(&mut depth)?;
}
Event::End(_) if depth > 0 => depth -= 1,
Event::End(_) => return Err(self.malformed()),
Event::Text(text)
| Event::Code(text)
| Event::Html(text)
| Event::InlineHtml(text) => output.push_str(&text),
Event::SoftBreak | Event::HardBreak | Event::Rule => output.push(' '),
Event::InlineMath(text) | Event::DisplayMath(text) => output.push_str(&text),
Event::FootnoteReference(name) => {
output.push('[');
output.push_str(&name);
output.push(']');
}
Event::TaskListMarker(checked) => {
output.push_str(if checked { "[x]" } else { "[ ]" });
}
}
}
Err(self.malformed())
}
fn enter_image_alt_nesting(&self, depth: &mut usize) -> Result<(), MarkdownRenderError> {
*depth = depth.checked_add(1).ok_or_else(|| self.malformed())?;
Ok(())
}
fn validate_destination(
&mut self,
render_kind: RenderDestinationKind,
asset_kind: MarkdownDestinationKind,
authored: &str,
range: Range<usize>,
asset_required: bool,
) -> Result<ValidatedDestination, MarkdownRenderError> {
self.destination_count = self.destination_count.checked_add(1).ok_or_else(|| {
MarkdownRenderError::new(
self.document,
MarkdownRenderLocation::Document,
MarkdownRenderErrorCode::DestinationCountExceeded,
"Markdown contains more destinations than this platform can address",
)
})?;
let ordinal = MarkdownDestinationOrdinal::new(
NonZeroUsize::new(self.destination_count).ok_or_else(|| self.malformed())?,
);
let approved = self.assets.markdown_destinations.get(self.approved_cursor);
if let Some(approved) = approved.filter(|approved| approved.ordinal == ordinal) {
if approved.kind != asset_kind
|| approved.source_range.start != range.start
|| approved.source_range.end != range.end
|| approved.authored.as_str() != authored
{
return Err(self.error_at_destination(
ordinal,
render_kind,
MarkdownRenderErrorCode::AssetOccurrenceMismatch,
"Markdown destination does not match its resolver-approved occurrence",
));
}
self.approved_cursor += 1;
return Ok(ValidatedDestination::Asset(approved.target.clone()));
}
if approved.is_some_and(|approved| approved.ordinal.get() < ordinal.get()) {
return Err(self.error_at_destination(
ordinal,
render_kind,
MarkdownRenderErrorCode::AssetOccurrenceUnused,
"renderer advanced past a resolver-approved asset occurrence",
));
}
if asset_required || self.looks_like_asset(authored) {
return Err(self.error_at_destination(
ordinal,
render_kind,
MarkdownRenderErrorCode::AssetOccurrenceMissing,
"asset destination has no matching resolver-approved occurrence",
));
}
normalize_navigation(authored)
.map(ValidatedDestination::Navigation)
.map_err(|reason| MarkdownRenderError::navigation(self.document, ordinal, reason))
}
fn looks_like_asset(&self, authored: &str) -> bool {
if LogicalAssetPath::parse(authored).is_ok() {
return true;
}
let Ok(url) = Url::parse(authored) else {
return false;
};
self.site_assets.allowed_origins.iter().any(|origin| {
origin.as_url().scheme() == url.scheme()
&& origin.as_url().host() == url.host()
&& origin.as_url().port_or_known_default() == url.port_or_known_default()
})
}
fn write_destination(
&mut self,
destination: &ValidatedDestination,
) -> Result<(), MarkdownRenderError> {
match destination {
ValidatedDestination::Navigation(value) => self.write_escaped_attribute(value),
ValidatedDestination::Asset(AssetRevisionReference::External(url)) => {
self.write_escaped_attribute(url.as_str())
}
ValidatedDestination::Asset(AssetRevisionReference::Local(asset)) => self
.writer
.write_local_asset(asset.clone())
.map_err(|_| rendered_html_limit_error(&self.document.path)),
}
}
fn write_block_start(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
if !self.writer.end_newline {
self.write("\n")?;
}
self.write(value)
}
fn write(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
self.writer
.write(value)
.map_err(|_| rendered_html_limit_error(&self.document.path))
}
fn write_escaped_body(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
for piece in Escaped::body(value) {
self.write(piece)?;
}
Ok(())
}
fn write_escaped_attribute(&mut self, value: &str) -> Result<(), MarkdownRenderError> {
for piece in Escaped::attribute(value) {
self.write(piece)?;
}
Ok(())
}
fn error_at_destination(
&self,
ordinal: MarkdownDestinationOrdinal,
destination_kind: RenderDestinationKind,
code: MarkdownRenderErrorCode,
message: &'static str,
) -> MarkdownRenderError {
MarkdownRenderError::new(
self.document,
MarkdownRenderLocation::Destination {
ordinal,
destination_kind,
},
code,
message,
)
}
fn unsupported(&self, event: &'static str) -> MarkdownRenderError {
MarkdownRenderError::new(
self.document,
MarkdownRenderLocation::Document,
MarkdownRenderErrorCode::UnsupportedCommonMarkEvent,
format!("the strict CommonMark parser produced an unsupported {event}"),
)
}
fn malformed(&self) -> MarkdownRenderError {
MarkdownRenderError::new(
self.document,
MarkdownRenderLocation::Document,
MarkdownRenderErrorCode::MalformedCommonMarkEvents,
"the CommonMark parser produced an unbalanced event stream",
)
}
}
enum ValidatedDestination {
Navigation(String),
Asset(AssetRevisionReference),
}
struct ArticleWriter {
identity_html: String,
literal: String,
plan: Vec<ArticleChunk>,
limit: usize,
end_newline: bool,
}
impl ArticleWriter {
fn new(limit: usize) -> Self {
Self {
identity_html: String::new(),
literal: String::new(),
plan: Vec::new(),
limit,
end_newline: true,
}
}
fn write(&mut self, value: &str) -> Result<(), RenderedHtmlLimit> {
self.reserve(value.len())?;
self.identity_html.push_str(value);
self.literal.push_str(value);
if !value.is_empty() {
self.end_newline = value.ends_with('\n');
}
Ok(())
}
fn write_sanitized_svg(&mut self, svg: &SanitizedSvg) -> Result<(), RenderedHtmlLimit> {
self.write(svg.as_str())
}
fn write_local_asset(&mut self, asset: DigestedAsset) -> Result<(), RenderedHtmlLimit> {
let path = asset.path.as_str();
let path_len = path.len();
let path_ends_newline = path.ends_with('\n');
self.reserve(path_len)?;
self.identity_html.push_str(path);
self.flush_literal();
self.plan.push(ArticleChunk::LocalAsset(asset));
if path_len > 0 {
self.end_newline = path_ends_newline;
}
Ok(())
}
fn reserve(&self, additional: usize) -> Result<(), RenderedHtmlLimit> {
if self
.identity_html
.len()
.checked_add(additional)
.is_some_and(|size| size <= self.limit)
{
Ok(())
} else {
Err(RenderedHtmlLimit)
}
}
fn flush_literal(&mut self) {
if !self.literal.is_empty() {
self.plan
.push(ArticleChunk::Literal(Arc::from(self.literal.as_str())));
self.literal.clear();
}
}
fn finish(mut self) -> RenderedArticle {
self.flush_literal();
RenderedArticle {
identity_html: Arc::from(self.identity_html),
plan: self.plan.into(),
}
}
}
#[derive(Clone, Copy, Debug)]
struct RenderedHtmlLimit;
enum Escaped<'input> {
Body(EscapePieces<'input>),
Attribute(EscapePieces<'input>),
}
impl<'input> Escaped<'input> {
fn body(value: &'input str) -> Self {
Self::Body(EscapePieces::new(value, false))
}
fn attribute(value: &'input str) -> Self {
Self::Attribute(EscapePieces::new(value, true))
}
}
impl<'input> Iterator for Escaped<'input> {
type Item = &'input str;
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Body(pieces) | Self::Attribute(pieces) => pieces.next(),
}
}
}
struct EscapePieces<'input> {
remainder: &'input str,
pending: Option<&'static str>,
attribute: bool,
}
impl<'input> EscapePieces<'input> {
const fn new(value: &'input str, attribute: bool) -> Self {
Self {
remainder: value,
pending: None,
attribute,
}
}
}
impl<'input> Iterator for EscapePieces<'input> {
type Item = &'input str;
fn next(&mut self) -> Option<Self::Item> {
if let Some(entity) = self.pending.take() {
return Some(entity);
}
let position = self
.remainder
.char_indices()
.find_map(|(index, character)| {
escape_entity(character, self.attribute)
.map(|entity| (index, character.len_utf8(), entity))
});
let Some((index, width, entity)) = position else {
return (!self.remainder.is_empty()).then(|| std::mem::take(&mut self.remainder));
};
if index == 0 {
self.remainder = &self.remainder[width..];
return Some(entity);
}
let literal = &self.remainder[..index];
self.remainder = &self.remainder[index + width..];
self.pending = Some(entity);
Some(literal)
}
}
const fn escape_entity(character: char, attribute: bool) -> Option<&'static str> {
match character {
'&' => Some("&"),
'<' => Some("<"),
'>' => Some(">"),
'"' if attribute => Some("""),
_ => None,
}
}
fn normalize_navigation(value: &str) -> Result<String, NavigationRejection> {
validate_navigation_text(value)?;
if value.starts_with('/') {
normalize_root_relative_navigation(value)
} else {
normalize_https_navigation(value)
}
}
fn validate_navigation_text(value: &str) -> Result<(), NavigationRejection> {
if value.is_empty() {
return Err(NavigationRejection::Empty);
}
if value.trim() != value {
return Err(NavigationRejection::Whitespace);
}
if value.contains('\\') || value.chars().any(char::is_control) {
return Err(NavigationRejection::ForbiddenCharacter);
}
if value.starts_with("//") {
return Err(NavigationRejection::ProtocolRelative);
}
Ok(())
}
fn normalize_root_relative_navigation(value: &str) -> Result<String, NavigationRejection> {
reject_traversal(raw_root_relative_path(value))?;
let base =
Url::parse("https://maincopy.invalid/").map_err(|_| NavigationRejection::Malformed)?;
let parsed = base
.join(value)
.map_err(|_| NavigationRejection::Malformed)?;
if parsed.origin() != base.origin()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
return Err(NavigationRejection::Malformed);
}
Ok(parsed[Position::BeforePath..].to_owned())
}
fn normalize_https_navigation(value: &str) -> Result<String, NavigationRejection> {
validate_raw_https_navigation(value)?;
let parsed = Url::parse(value).map_err(|_| NavigationRejection::Malformed)?;
if parsed.scheme() != "https" {
return Err(NavigationRejection::UnsupportedScheme);
}
if parsed.host().is_none() {
return Err(NavigationRejection::MissingAuthority);
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(NavigationRejection::Credentials);
}
Ok(parsed.into())
}
fn validate_raw_https_navigation(value: &str) -> Result<(), NavigationRejection> {
let Some((raw_scheme, remainder)) = value.split_once("://") else {
return Err(NavigationRejection::NotRootRelative);
};
if !raw_scheme.eq_ignore_ascii_case("https") {
return Err(NavigationRejection::UnsupportedScheme);
}
let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
let authority = &remainder[..authority_end];
if authority.is_empty() || authority.ends_with(':') {
return Err(NavigationRejection::MissingAuthority);
}
if authority.contains('@') {
return Err(NavigationRejection::Credentials);
}
reject_traversal(
remainder[authority_end..]
.split(['?', '#'])
.next()
.unwrap_or(""),
)
}
fn raw_root_relative_path(value: &str) -> &str {
value.split(['?', '#']).next().unwrap_or("")
}
fn reject_traversal(path: &str) -> Result<(), NavigationRejection> {
for component in path.split('/') {
if matches!(component, "." | "..") || has_encoded_path_control(component) {
return Err(NavigationRejection::Traversal);
}
}
Ok(())
}
fn has_encoded_path_control(component: &str) -> bool {
let bytes = component.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let Some(encoded) = bytes
.get(index + 1..index + 3)
.and_then(|value| decode_hex_pair(value.try_into().ok()?))
else {
return true;
};
if matches!(encoded, b'.' | b'/' | b'\\' | b'%') {
return true;
}
index += 3;
} else {
index += 1;
}
}
false
}
fn decode_hex_pair(pair: &[u8; 2]) -> Option<u8> {
let high = hex_value(pair[0])?;
let low = hex_value(pair[1])?;
Some((high << 4) | low)
}
const fn hex_value(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn destination_kind(kind: MarkdownDestinationKind) -> RenderDestinationKind {
match kind {
MarkdownDestinationKind::Image => RenderDestinationKind::Image,
MarkdownDestinationKind::Download => RenderDestinationKind::Link,
}
}
fn local_asset_projection_error(
path: &LogicalContentPath,
error: ResolvedLocalAssetLookupError,
) -> MarkdownRenderError {
let code = match error {
ResolvedLocalAssetLookupError::Missing => MarkdownRenderErrorCode::LocalAssetMissing,
ResolvedLocalAssetLookupError::DigestMismatch => {
MarkdownRenderErrorCode::LocalAssetDigestMismatch
}
};
MarkdownRenderError {
path: path.clone(),
location: MarkdownRenderLocation::Document,
code,
navigation_rejection: None,
message: error.to_string().into_boxed_str(),
}
}
fn rendered_html_limit_error(path: &LogicalContentPath) -> MarkdownRenderError {
MarkdownRenderError {
path: path.clone(),
location: MarkdownRenderLocation::Document,
code: MarkdownRenderErrorCode::RenderedHtmlTooLarge,
navigation_rejection: None,
message: "rendered article HTML exceeds the configured byte limit".into(),
}
}
fn mermaid_error(
document: &PostDocument,
location: MarkdownRenderLocation,
error: DiagramRenderErrorCode,
) -> MarkdownRenderError {
let (code, message) = match error {
DiagramRenderErrorCode::Unavailable => (
MarkdownRenderErrorCode::MermaidRendererUnavailable,
"the packaged Mermaid renderer is unavailable",
),
DiagramRenderErrorCode::InvalidDiagram => (
MarkdownRenderErrorCode::MermaidInvalid,
"the Mermaid diagram is invalid or unsupported",
),
DiagramRenderErrorCode::ResourceLimit => (
MarkdownRenderErrorCode::MermaidRendererResourceLimit,
"the Mermaid diagram exceeded a renderer resource limit",
),
DiagramRenderErrorCode::TimedOut => (
MarkdownRenderErrorCode::MermaidRendererTimedOut,
"the Mermaid diagram exceeded the renderer deadline",
),
DiagramRenderErrorCode::InvalidOutput => (
MarkdownRenderErrorCode::MermaidRendererInvalidOutput,
"the Mermaid renderer returned invalid output",
),
DiagramRenderErrorCode::UnsafeSvg => (
MarkdownRenderErrorCode::MermaidSvgRejected,
"the Mermaid renderer output failed the SVG security policy",
),
DiagramRenderErrorCode::Internal => (
MarkdownRenderErrorCode::MermaidRendererInternal,
"the Mermaid renderer failed internally",
),
};
MarkdownRenderError::new(document, location, code, message)
}
#[cfg(test)]
mod tests {
use maincopy_diagram_renderer::client::MermaidRenderError;
use super::*;
use markdown_compiler::{PostCollection, PreparedContent, prepare_content};
use crate::content_fixtures::{asset, content_tree, post, publication};
use crate::render::svg::{MermaidSvgSanitizer, SvgScope};
const POST_ID: &str = "4f054633-2d09-4b05-97d0-c6f0011a5199";
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>";
fn render_fixture_mermaid(
_source: &str,
ordinal: NonZeroUsize,
) -> Result<SanitizedSvg, DiagramRenderError> {
MermaidSvgSanitizer::sanitize(FIXTURE_SVG, SvgScope::new(POST_ID.as_bytes(), ordinal))
.map_err(DiagramRenderError::Sanitizer)
}
fn render_markdown_for_test(
content: &PreparedContent,
limits: RendererLimits,
) -> Result<RenderedPost, MarkdownRenderError> {
let content = content.view();
render_markdown_with_renderer(
&content.posts[0],
content.site_assets,
limits,
&mut render_fixture_mermaid,
)
}
fn publication_source(title: &str, origins: &[&str]) -> String {
let origins = origins
.iter()
.map(|origin| format!("{origin:?}"))
.collect::<Vec<_>>()
.join(", ");
format!(
"[site]\n\
title = {title:?}\n\
base_url = \"https://blog.example.com/\"\n\
description = \"Renderer tests.\"\n\
[author]\n\
name = \"Example Author\"\n\
[assets]\n\
allowed_https_origins = [{origins}]\n"
)
}
fn post_source(body: &str, draft: bool) -> String {
format!(
"+++\n\
id = \"{POST_ID}\"\n\
title = \"Rendered Post\"\n\
slug = \"rendered-post\"\n\
authored_at = 2026-08-29T15:00:00-04:00\n\
description = \"Renderer fixture.\"\n\
draft = {draft}\n\
+++\n\
{body}"
)
}
fn candidate(
title: &str,
origins: &[&str],
body: &str,
draft: bool,
asset_paths: &[&str],
) -> PreparedContent {
let tree = content_tree(
publication("publication.toml", publication_source(title, origins)),
vec![post(
if draft {
"drafts/rendered.md"
} else {
"posts/rendered.md"
},
if draft {
PostCollection::Drafts
} else {
PostCollection::Posts
},
post_source(body, draft),
)],
asset_paths
.iter()
.map(|path| {
asset(
LogicalAssetPath::parse(path).expect("fixture path must parse"),
format!("bytes:{path}").into_bytes(),
)
})
.collect(),
0,
);
prepare_content(&tree).expect("fixture content and assets must validate")
}
fn candidate_with_local_bytes(body: &str, path: Option<&str>, bytes: &[u8]) -> PreparedContent {
let discovered_assets = path
.map(|path| {
vec![asset(
LogicalAssetPath::parse(path).expect("fixture path must parse"),
bytes.to_vec(),
)]
})
.unwrap_or_default();
let tree = content_tree(
publication("publication.toml", publication_source("Renderer", &[])),
vec![post(
"posts/rendered.md",
PostCollection::Posts,
post_source(body, false),
)],
discovered_assets,
0,
);
prepare_content(&tree).expect("fixture content and assets must validate")
}
fn render(origins: &[&str], body: &str, asset_paths: &[&str]) -> RenderedPost {
let assets = candidate("Renderer", origins, body, false, asset_paths);
render_markdown_for_test(&assets, RendererLimits::production())
.expect("fixture must render")
}
fn render_error(body: &str) -> MarkdownRenderError {
let assets = candidate("Renderer", &[], body, false, &[]);
render_markdown_for_test(&assets, RendererLimits::production())
.expect_err("fixture must be rejected")
}
fn snapshot() -> SiteSnapshotDigest {
SiteSnapshotDigest::parse(&format!("site-b3-v1-{}", "11".repeat(32))).unwrap()
}
fn collect_and_escape_image_alt_events(
events: Vec<Event<'static>>,
) -> Result<(String, String), MarkdownRenderError> {
let assets = candidate("Renderer", &[], "body", false, &[]);
let document = assets.view().posts[0].view().document;
let mut render_mermaid = render_fixture_mermaid;
let mut renderer = MarkdownEventRenderer::new(
document,
assets.view().posts[0].view().assets,
assets.view().site_assets,
&mut render_mermaid,
RendererLimits::production(),
);
renderer.events = events.into_iter().map(|event| (event, 0..0)).collect();
let alt = renderer.collect_image_alt()?;
renderer.write_escaped_attribute(&alt)?;
Ok((alt, renderer.writer.identity_html))
}
#[test]
fn exact_commonmark_html_and_typed_asset_projection_are_deterministic() {
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";
let first = render(
&[],
body,
&["assets/images/diagram.png", "assets/files/manual.pdf"],
);
let second = render(
&[],
body,
&["assets/files/manual.pdf", "assets/images/diagram.png"],
);
let expected = "<h1>Heading</h1>\n\
<p>Raw <b>& text</b>.</p>\n\
<p><a href=\"/notes?x=1&y=2\">route</a></p>\n\
<p><img src=\"assets/images/diagram.png\" alt=\"diagram\" title=\"Diagram\" /></p>\n\
<p><a href=\"assets/files/manual.pdf\">manual</a></p>\n\
<pre class=\"article-code\"><code class=\"language-rust\"><a>&\n</code></pre>\n\
<pre><code>+---+\n| A |\n+---+\n</code></pre>\n\
<div id=\"maincopy-diagram-return-1\"></div>\n\
<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";
assert_eq!(first.article.identity_html.as_ref(), expected);
assert_eq!(second.article.identity_html.as_ref(), expected);
assert_eq!(first.revision, second.revision);
assert!(first.article.identity_html.contains("<svg "));
assert!(!first.article.identity_html.contains("graph TD"));
let projection_assets = candidate(
"Renderer",
&[],
body,
false,
&["assets/images/diagram.png", "assets/files/manual.pdf"],
);
let projected = first
.project_for_snapshot(
&snapshot(),
projection_assets.view().site_assets,
projection_assets.view().local_assets,
)
.unwrap();
assert!(projected.contains(&format!("/assets/{}/images/diagram.png", snapshot())));
assert!(!projected.contains("assets/images/diagram.png\""));
}
#[test]
fn raw_html_is_visible_text_in_blocks_inline_and_image_alt_text() {
let rendered = render(
&[],
"<script>alert(1)</script>\n\n<!-- comment -->\n\n<style>x</style>\n\n<svg><script>x</script></svg>\n\n\n",
&["assets/image.png"],
);
let html = &rendered.article.identity_html;
assert!(!html.contains("<script>"));
assert!(!html.contains("<!--"));
assert!(!html.contains("<style>"));
assert!(!html.contains("<svg>"));
assert!(html.contains("<script>alert(1)</script>"));
assert!(html.contains("alt=\"<b>alt</b>\""));
}
#[test]
fn image_alt_collects_nested_destinations_and_all_textual_event_forms() {
let rendered = render(
&[],
"  `code`<b>raw</b> \nnext](assets/outer.png)\n",
&["assets/outer.png", "assets/nested.png"],
);
assert!(
rendered
.article
.identity_html
.contains("alt=\"outer em link nested code<b>raw</b> next\"")
);
let (alt, escaped) = collect_and_escape_image_alt_events(vec![
Event::Start(Tag::Emphasis),
Event::Text("text".into()),
Event::Code("code".into()),
Event::Html("<block>&".into()),
Event::InlineHtml("<inline>".into()),
Event::SoftBreak,
Event::HardBreak,
Event::Rule,
Event::InlineMath("inline-math".into()),
Event::DisplayMath("display-math".into()),
Event::FootnoteReference("note".into()),
Event::TaskListMarker(true),
Event::TaskListMarker(false),
Event::End(TagEnd::Emphasis),
Event::End(TagEnd::Image),
])
.unwrap();
assert_eq!(
alt,
"textcode<block>&<inline> inline-mathdisplay-math[note][x][ ]"
);
assert_eq!(
escaped,
"textcode<block>&<inline> inline-mathdisplay-math[note][x][ ]"
);
}
#[test]
fn image_alt_rejects_unbalanced_events_and_nesting_overflow() {
for events in [
vec![Event::End(TagEnd::Strong), Event::End(TagEnd::Image)],
vec![Event::Start(Tag::Emphasis), Event::End(TagEnd::Image)],
vec![Event::Text("unterminated".into())],
] {
let error = collect_and_escape_image_alt_events(events).unwrap_err();
assert_eq!(
error.code,
MarkdownRenderErrorCode::MalformedCommonMarkEvents
);
}
let assets = candidate("Renderer", &[], "body", false, &[]);
let document = assets.view().posts[0].view().document;
let mut render_mermaid = render_fixture_mermaid;
let renderer = MarkdownEventRenderer::new(
document,
assets.view().posts[0].view().assets,
assets.view().site_assets,
&mut render_mermaid,
RendererLimits::production(),
);
let mut depth = usize::MAX;
let error = renderer.enter_image_alt_nesting(&mut depth).unwrap_err();
assert_eq!(
error.code,
MarkdownRenderErrorCode::MalformedCommonMarkEvents
);
assert_eq!(depth, usize::MAX);
}
#[test]
fn declared_fence_aliases_are_canonical_and_unknown_values_remain_plain() {
let expected = render(&[], "```\n<a>&\n```\n", &[]);
for label in [
"text",
"ascii",
"Mermaid",
"mermaid trailing",
"rust linenos",
"rüst",
] {
let rendered = render(&[], &format!("```{label}\n<a>&\n```\n"), &[]);
assert_eq!(
rendered.article.identity_html,
expected.article.identity_html
);
assert!(!rendered.article.identity_html.contains("mermaid-diagram"));
assert!(!rendered.article.identity_html.contains("language-"));
}
let rust = render(&[], "```RuSt\n<a>&\n```\n", &[]);
let alias = render(&[], "```rs\n<a>&\n```\n", &[]);
assert_eq!(rust.article.identity_html, alias.article.identity_html);
assert!(
rust.article
.identity_html
.contains("class=\"language-rust\"")
);
assert!(!rust.article.identity_html.contains("RuSt"));
let commonmark_decoded = render(&[], "```mermaid\na-->b\n```\n", &[]);
assert!(
commonmark_decoded
.article
.identity_html
.contains("mermaid-diagram")
);
}
#[test]
fn diagrams_have_distinct_enlarge_and_return_targets() {
let rendered = render(
&[],
"```mermaid\nflowchart LR\nA-->B\n```\n\n```mermaid\nflowchart LR\nC-->D\n```\n",
&[],
);
let html = &rendered.article.identity_html;
for ordinal in [1, 2] {
for prefix in ["maincopy-diagram", "maincopy-diagram-return"] {
let target = format!("{prefix}-{ordinal}");
assert_eq!(html.matches(&format!("id=\"{target}\"")).count(), 1);
assert_eq!(html.matches(&format!("href=\"#{target}\"")).count(), 1);
}
}
assert_eq!(html.matches("<svg ").count(), 2);
}
#[test]
fn preview_and_public_projection_reuse_exact_release_rendering() {
let body = "```rust\nfn main() {}\n```\n\n```ascii\n+---+\n| A |\n+---+\n```\n\n```mermaid\nflowchart LR\nA-->B\n```\n";
let assets = candidate("Renderer", &[], body, false, &[]);
let rendered = render_markdown_for_test(&assets, RendererLimits::production()).unwrap();
let preview = rendered
.project_for_preview(
"/api/admin/v1/preview-assets",
assets.view().site_assets,
assets.view().local_assets,
)
.unwrap();
let public = rendered
.project_for_snapshot(
&snapshot(),
assets.view().site_assets,
assets.view().local_assets,
)
.unwrap();
assert_eq!(preview, public);
assert!(public.contains("class=\"language-rust\""));
assert!(public.contains("<pre><code>+---+"));
assert!(public.contains("class=\"mermaid-diagram\""));
assert!(!public.contains("flowchart LR"));
}
#[test]
fn unsafe_navigation_variants_fail_closed_after_commonmark_unescaping() {
let rejected = [
(
"[bad](javascript:alert)",
NavigationRejection::NotRootRelative,
),
(
"[bad](javascript:alert)",
NavigationRejection::NotRootRelative,
),
(
"[bad](mailto:x@example.com)",
NavigationRejection::NotRootRelative,
),
(
"[bad](//example.com/path)",
NavigationRejection::ProtocolRelative,
),
(
"[bad](https://user@example.com/path)",
NavigationRejection::Credentials,
),
(
"[bad](https:example.com)",
NavigationRejection::NotRootRelative,
),
(
"[bad](https:///host)",
NavigationRejection::MissingAuthority,
),
("[bad](/a/../secret)", NavigationRejection::Traversal),
("[bad](/a/%2e%2e/secret)", NavigationRejection::Traversal),
(
"[bad](/a/%252e%252e/secret)",
NavigationRejection::Traversal,
),
];
for (body, reason) in rejected {
let error = render_error(body);
assert_eq!(
error.code,
MarkdownRenderErrorCode::NavigationRejected,
"{body}"
);
assert_eq!(error.navigation_rejection, Some(reason), "{body}");
}
}
#[test]
fn absolute_https_root_relative_reference_and_autolinks_are_rebuilt() {
let rendered = render(
&[],
"[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",
&[],
);
assert_eq!(
rendered.article.identity_html.as_ref(),
"<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"
);
let error = render_error("<person@example.com>");
assert_eq!(error.code, MarkdownRenderErrorCode::NavigationRejected);
}
#[test]
fn resolver_occurrences_are_unique_exact_and_exhaustive() {
let body = "[safe](/safe)\n\n\n\n[file](assets/file.pdf)\n";
let assets = candidate(
"Renderer",
&[],
body,
false,
&["assets/image.png", "assets/file.pdf"],
);
let approved = assets.view().posts[0].view().assets;
let reordered = candidate(
"Renderer",
&[],
"\n\n[safe](/safe)\n\n[file](assets/file.pdf)\n",
false,
&["assets/image.png", "assets/file.pdf"],
);
let error = MarkdownEventRenderer::new(
reordered.view().posts[0].view().document,
approved,
assets.view().site_assets,
&mut render_fixture_mermaid,
RendererLimits::production(),
)
.render()
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::AssetOccurrenceMissing);
let shifted = candidate(
"Renderer",
&[],
"[safe](/safe)\n\n \n\n\n[file](assets/file.pdf)\n",
false,
&["assets/image.png", "assets/file.pdf"],
);
let error = MarkdownEventRenderer::new(
shifted.view().posts[0].view().document,
approved,
assets.view().site_assets,
&mut render_fixture_mermaid,
RendererLimits::production(),
)
.render()
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::AssetOccurrenceMismatch);
let removed = candidate("Renderer", &[], "[safe](/safe)\n", false, &[]);
let error = MarkdownEventRenderer::new(
removed.view().posts[0].view().document,
approved,
assets.view().site_assets,
&mut render_fixture_mermaid,
RendererLimits::production(),
)
.render()
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::AssetOccurrenceUnused);
}
#[test]
fn mermaid_timeout_rejects_the_article_with_the_authored_code_block_location() {
let assets = candidate(
"Renderer",
&[],
"```text\nplain\n```\n\n```mermaid\ngraph TD; A-->B\n```\n",
false,
&[],
);
let document = assets.view().posts[0].view().document;
let error = render_markdown_with_renderer(
&assets.view().posts[0],
assets.view().site_assets,
RendererLimits::production(),
&mut |_, _| Err(DiagramRenderError::Renderer(MermaidRenderError::TimedOut)),
)
.unwrap_err();
assert_eq!(error.path, document.path);
assert_eq!(error.code, MarkdownRenderErrorCode::MermaidRendererTimedOut);
assert_eq!(
error.location,
MarkdownRenderLocation::CodeBlock {
ordinal: CodeBlockOrdinal::new(NonZeroUsize::new(2).unwrap()),
}
);
assert_eq!(
error.message.as_ref(),
"the Mermaid diagram exceeded the renderer deadline"
);
}
#[test]
fn renderer_limits_are_inclusive_and_fail_one_byte_or_block_past() {
let assets = candidate("Renderer", &[], "```mermaid\nabc\n```\n", false, &[]);
let base = RendererLimits {
rendered_html_bytes: 1_024,
mermaid_source_bytes: 4,
mermaid_svg_bytes: 1_024,
mermaid_blocks: 1,
code_blocks: 256,
};
render_markdown_for_test(&assets, base).unwrap();
let error = render_markdown_for_test(
&assets,
RendererLimits {
mermaid_source_bytes: 3,
..base
},
)
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::MermaidBlockTooLarge);
let svg_bytes = render_fixture_mermaid("abc\n", NonZeroUsize::MIN)
.unwrap()
.as_str()
.len();
render_markdown_for_test(
&assets,
RendererLimits {
mermaid_svg_bytes: svg_bytes,
..base
},
)
.unwrap();
let error = render_markdown_for_test(
&assets,
RendererLimits {
mermaid_svg_bytes: svg_bytes - 1,
..base
},
)
.unwrap_err();
assert_eq!(
error.code,
MarkdownRenderErrorCode::MermaidRenderedOutputTooLarge
);
let two_assets = candidate(
"Renderer",
&[],
"```mermaid\na\n```\n\n```mermaid\nb\n```\n",
false,
&[],
);
let error = render_markdown_for_test(&two_assets, base).unwrap_err();
assert_eq!(
error.code,
MarkdownRenderErrorCode::MermaidBlockCountExceeded
);
let two_code_assets =
candidate("Renderer", &[], "```\na\n```\n\n```\nb\n```\n", false, &[]);
let error = render_markdown_for_test(
&two_code_assets,
RendererLimits {
code_blocks: 1,
..base
},
)
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::CodeBlockCountExceeded);
let mut writer = ArticleWriter::new(3);
assert!(writer.write("abc").is_ok());
assert!(writer.write("d").is_err());
let small_assets = candidate("Renderer", &[], "a", false, &[]);
let error = render_markdown_for_test(
&small_assets,
RendererLimits {
rendered_html_bytes: 7,
..base
},
)
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::RenderedHtmlTooLarge);
}
#[test]
fn stale_policy_product_cannot_project_even_when_public_revision_is_unchanged() {
let body = "\n";
let old_assets = candidate(
"Renderer",
&["https://cdn.example.com", "https://unused.example.com"],
body,
false,
&[],
);
let old = render_markdown_for_test(&old_assets, RendererLimits::production()).unwrap();
let new_assets = candidate("Renderer", &["https://cdn.example.com"], body, false, &[]);
let fresh = render_markdown_for_test(&new_assets, RendererLimits::production()).unwrap();
assert_eq!(old.revision, fresh.revision);
let error = old
.project_for_snapshot(
&snapshot(),
new_assets.view().site_assets,
new_assets.view().local_assets,
)
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::AssetPolicyMismatch);
assert!(
fresh
.project_for_snapshot(
&snapshot(),
new_assets.view().site_assets,
new_assets.view().local_assets,
)
.unwrap()
.contains("https://cdn.example.com/cover-v1.png")
);
}
#[test]
fn projection_requires_the_candidate_store_to_match_every_local_asset_digest() {
let body = "\n";
let old_assets = candidate_with_local_bytes(body, Some("assets/cover.png"), b"old bytes");
let old = render_markdown_for_test(&old_assets, RendererLimits::production()).unwrap();
let changed_assets =
candidate_with_local_bytes(body, Some("assets/cover.png"), b"changed bytes");
let error = old
.project_for_snapshot(
&snapshot(),
changed_assets.view().site_assets,
changed_assets.view().local_assets,
)
.unwrap_err();
assert_eq!(
error.code,
MarkdownRenderErrorCode::LocalAssetDigestMismatch
);
let missing_assets = candidate_with_local_bytes("No asset.\n", None, b"");
let error = old
.project_for_snapshot(
&snapshot(),
missing_assets.view().site_assets,
missing_assets.view().local_assets,
)
.unwrap_err();
assert_eq!(error.code, MarkdownRenderErrorCode::LocalAssetMissing);
assert!(
old.project_for_snapshot(
&snapshot(),
old_assets.view().site_assets,
old_assets.view().local_assets,
)
.unwrap()
.contains(&format!("/assets/{}/cover.png", snapshot()))
);
}
#[test]
fn render_error_wire_contract_uses_typed_codes_and_ordinals() {
let error = render_error("[bad](//example.com)");
assert_eq!(
serde_json::to_value(error).unwrap(),
serde_json::json!({
"path": "posts/rendered.md",
"location": {
"kind": "destination",
"ordinal": 1,
"destination_kind": "link"
},
"code": "navigation_rejected",
"navigation_rejection": "protocol_relative",
"message": "link destination was rejected: ProtocolRelative"
})
);
}
#[test]
fn renderer_wire_types_and_ordinals_have_stable_contracts() {
let destination_ordinal = MarkdownDestinationOrdinal::new(NonZeroUsize::new(2).unwrap());
let code_ordinal = CodeBlockOrdinal::new(NonZeroUsize::new(3).unwrap());
assert_eq!(serde_json::to_value(destination_ordinal).unwrap(), 2);
assert_eq!(serde_json::to_value(code_ordinal).unwrap(), 3);
for (value, expected) in [
(RenderDestinationKind::Link, "link"),
(RenderDestinationKind::Image, "image"),
] {
assert_eq!(serde_json::to_value(value).unwrap(), expected);
}
assert_eq!(
serde_json::to_value(MarkdownRenderLocation::Document).unwrap(),
serde_json::json!({ "kind": "document" })
);
assert_eq!(
serde_json::to_value(MarkdownRenderLocation::Destination {
ordinal: destination_ordinal,
destination_kind: RenderDestinationKind::Image,
})
.unwrap(),
serde_json::json!({
"kind": "destination",
"ordinal": 2,
"destination_kind": "image"
})
);
assert_eq!(
serde_json::to_value(MarkdownRenderLocation::CodeBlock {
ordinal: code_ordinal,
})
.unwrap(),
serde_json::json!({ "kind": "code_block", "ordinal": 3 })
);
for (value, expected) in [
(NavigationRejection::Empty, "empty"),
(NavigationRejection::Whitespace, "whitespace"),
(
NavigationRejection::ForbiddenCharacter,
"forbidden_character",
),
(NavigationRejection::ProtocolRelative, "protocol_relative"),
(NavigationRejection::UnsupportedScheme, "unsupported_scheme"),
(NavigationRejection::MissingAuthority, "missing_authority"),
(NavigationRejection::Credentials, "credentials"),
(NavigationRejection::Traversal, "traversal"),
(NavigationRejection::NotRootRelative, "not_root_relative"),
(NavigationRejection::Malformed, "malformed"),
] {
assert_eq!(serde_json::to_value(value).unwrap(), expected);
}
for (value, expected) in [
(
MarkdownRenderErrorCode::DestinationCountExceeded,
"destination_count_exceeded",
),
(
MarkdownRenderErrorCode::AssetOccurrenceMissing,
"asset_occurrence_missing",
),
(
MarkdownRenderErrorCode::AssetOccurrenceMismatch,
"asset_occurrence_mismatch",
),
(
MarkdownRenderErrorCode::AssetOccurrenceUnused,
"asset_occurrence_unused",
),
(
MarkdownRenderErrorCode::NavigationRejected,
"navigation_rejected",
),
(
MarkdownRenderErrorCode::MermaidBlockCountExceeded,
"mermaid_block_count_exceeded",
),
(
MarkdownRenderErrorCode::MermaidBlockTooLarge,
"mermaid_block_too_large",
),
(
MarkdownRenderErrorCode::MermaidRenderedOutputTooLarge,
"mermaid_rendered_output_too_large",
),
(MarkdownRenderErrorCode::MermaidInvalid, "mermaid_invalid"),
(
MarkdownRenderErrorCode::MermaidRendererUnavailable,
"mermaid_renderer_unavailable",
),
(
MarkdownRenderErrorCode::MermaidRendererResourceLimit,
"mermaid_renderer_resource_limit",
),
(
MarkdownRenderErrorCode::MermaidRendererTimedOut,
"mermaid_renderer_timed_out",
),
(
MarkdownRenderErrorCode::MermaidRendererInvalidOutput,
"mermaid_renderer_invalid_output",
),
(
MarkdownRenderErrorCode::MermaidSvgRejected,
"mermaid_svg_rejected",
),
(
MarkdownRenderErrorCode::MermaidRendererInternal,
"mermaid_renderer_internal",
),
(
MarkdownRenderErrorCode::CodeBlockCountExceeded,
"code_block_count_exceeded",
),
(
MarkdownRenderErrorCode::RenderedHtmlTooLarge,
"rendered_html_too_large",
),
(
MarkdownRenderErrorCode::UnsupportedCommonMarkEvent,
"unsupported_common_mark_event",
),
(
MarkdownRenderErrorCode::MalformedCommonMarkEvents,
"malformed_common_mark_events",
),
(
MarkdownRenderErrorCode::AssetPolicyMismatch,
"asset_policy_mismatch",
),
(
MarkdownRenderErrorCode::LocalAssetMissing,
"local_asset_missing",
),
(
MarkdownRenderErrorCode::LocalAssetDigestMismatch,
"local_asset_digest_mismatch",
),
(
MarkdownRenderErrorCode::RevisionIdentityRejected,
"revision_identity_rejected",
),
] {
assert_eq!(serde_json::to_value(value).unwrap(), expected);
}
}
}