use super::grid_parts::GridParts;
use super::shortcode::{GridShortcode, Shortcode};
use super::url::{ResolvedUrl, UrlKind};
pub trait RenderHooks {
fn render_link(
&self,
out: &mut String,
url: &ResolvedUrl,
is_wikilink: bool,
content: &str,
) {
let want_wikilink_class = is_wikilink || matches!(url.kind, UrlKind::Wikilink);
let want_newtab = matches!(url.kind, UrlKind::AssetNewtab | UrlKind::External);
out.push_str(r#"<a"#);
if want_wikilink_class {
out.push_str(r#" class="wikilink""#);
}
if want_newtab {
out.push_str(r#" target="_blank" rel="noopener""#);
}
out.push_str(r#" href=""#);
out.push_str(&escape_attr(&url.href));
out.push_str(r#"">"#);
out.push_str(content);
out.push_str("</a>");
}
fn render_image(
&self,
out: &mut String,
src: &ResolvedUrl,
alt: &str,
title: Option<&str>,
img_style: Option<&str>,
width: Option<&str>,
) {
let _ = width;
push_bare_img(out, src, alt, title, img_style);
}
fn begin_grid_cells(&self, columns: u32, data_width: Option<&str>) {
let _ = (columns, data_width);
}
fn end_grid_cells(&self) {}
fn render_math(&self, out: &mut String, tex: &str, display: bool, fallback_html: &str) {
let _ = (tex, display);
out.push_str(fallback_html);
}
fn gallery_assets(&self) -> Option<&crate::asset_snapshot::AssetSnapshot> {
None
}
fn render_shortcode(&self, out: &mut String, sc: &Shortcode, source_line: Option<usize>) {
match sc {
Shortcode::Subscribe(args) => {
let placeholder = args.placeholder.as_deref().unwrap_or("you@example.com");
out.push_str(r#"<div class="moss-subscribe">"#);
out.push_str(r#"<div class="moss-subscribe-form">"#);
out.push_str(r#"<input type="email" placeholder=""#);
out.push_str(&escape_attr(placeholder));
out.push_str(r#"" />"#);
out.push_str("<button>");
out.push_str(&escape_text(args.button.as_deref().unwrap_or("Subscribe")));
out.push_str("</button>");
out.push_str("</div>");
out.push_str("</div>");
}
Shortcode::Gallery(args) => {
let mut class_attr = String::from("moss-gallery");
if !args.classes.is_empty() {
class_attr.push(' ');
class_attr.push_str(&args.classes);
}
let count_attrs = match args.columns {
Some(n) => format!(r#" data-columns="{n}" style="--moss-gallery-columns: {n}""#),
None => String::new(),
};
out.push_str(r#"<div class=""#);
out.push_str(&escape_attr(&class_attr));
out.push('"');
out.push_str(&count_attrs);
if let Some(w) = &args.width {
out.push_str(r#" data-width=""#);
out.push_str(w);
out.push('"');
}
out.push('>');
let empty_snapshot = crate::asset_snapshot::AssetSnapshot::new();
let assets_for_synth = self.gallery_assets().unwrap_or(&empty_snapshot);
for item in &args.items {
let src_str = match &item.src {
crate::ast::url::Url::Resolved(r) => r.href.clone(),
crate::ast::url::Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached render_shortcode \
— visit_urls_mut missing for Gallery"
);
s.clone()
}
};
out.push_str(r#"<div class="moss-gallery-item">"#);
let media_style =
crate::media::parse_media_attrs(&item.attrs).to_inline_style();
let style_frag = media_style.map(|s| format!(r#"style="{}""#, s));
let opts = crate::render::image::ImageRenderOptions {
eager: false,
extra_attrs: style_frag.as_deref(),
..Default::default()
};
let item_html = crate::render::image::synthesize_image_html(
&src_str,
&item.alt,
assets_for_synth,
crate::render::image::ImageContext::GalleryThumb,
&opts,
);
out.push_str(&item_html);
out.push_str("</div>");
}
out.push_str("</div>");
}
Shortcode::Buttons(args) => {
if args.items.is_empty() {
return;
}
let mut data_style: Option<&str> = None;
let mut extra_classes_vec: Vec<&str> = Vec::new();
for token in args.classes.split_ascii_whitespace() {
if token == "inverted" {
data_style = Some("inverted");
} else {
extra_classes_vec.push(token);
}
}
let mut class_attr = String::from("moss-buttons");
if !extra_classes_vec.is_empty() {
class_attr.push(' ');
class_attr.push_str(&extra_classes_vec.join(" "));
}
out.push_str(r#"<div class=""#);
out.push_str(&escape_attr(&class_attr));
if let Some(style) = data_style {
out.push_str(r#"" data-style=""#);
out.push_str(style);
}
out.push_str(r#"">"#);
for (i, item) in args.items.iter().enumerate() {
let data_role = if i == 0 { "primary" } else { "secondary" };
let track = item
.text
.to_lowercase()
.replace(|c: char| !c.is_alphanumeric(), "-")
.trim_matches('-')
.to_string();
let resolved = match &item.url {
crate::ast::url::Url::Resolved(r) => r,
crate::ast::url::Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached render_shortcode \
— visit_urls_mut missing for Buttons"
);
out.push_str(r#"<a href=""#);
out.push_str(&escape_attr(s));
out.push_str(r#"" class="moss-btn" data-role=""#);
out.push_str(data_role);
out.push_str(r#"" data-track=""#);
out.push_str(&escape_attr(&track));
out.push_str(r#"">"#);
out.push_str(&escape_text(&item.text));
out.push_str("</a>");
continue;
}
};
out.push_str(r#"<a href=""#);
out.push_str(&escape_attr(&resolved.href));
out.push_str(r#"" class="moss-btn"#);
if matches!(resolved.kind, crate::ast::url::UrlKind::Wikilink) {
out.push_str(" wikilink");
}
out.push_str(r#"" data-role=""#);
out.push_str(data_role);
out.push_str(r#"" data-track=""#);
out.push_str(&escape_attr(&track));
out.push_str(r#"""#);
if matches!(resolved.kind, crate::ast::url::UrlKind::AssetNewtab) {
out.push_str(r#" target="_blank" rel="noopener""#);
}
out.push('>');
out.push_str(&escape_text(&item.text));
out.push_str("</a>");
}
out.push_str("</div>");
}
Shortcode::Hero(args) => {
let mut class_attr = String::from("moss-hero");
if !args.classes.is_empty() {
class_attr.push(' ');
class_attr.push_str(&args.classes);
}
out.push_str(r#"<section class=""#);
out.push_str(&escape_attr(&class_attr));
out.push('"');
let hero_extras: &[crate::ast::url::Url] =
&args.extra_images[..args.extra_images.len().min(5)];
if args.image.is_some() && !hero_extras.is_empty() {
out.push_str(r#" data-slides=""#);
out.push_str(&(hero_extras.len() + 1).to_string());
out.push('"');
}
if let Some(w) = &args.width {
out.push_str(r#" data-width=""#);
out.push_str(w);
out.push('"');
}
if let Some(n) = source_line {
out.push_str(r#" data-source-range=""#);
out.push_str(&n.to_string());
out.push('-');
out.push_str(&n.to_string());
out.push('"');
}
out.push('>');
if let Some(image) = &args.image {
let src = match image {
crate::ast::url::Url::Resolved(r) => r.href.clone(),
crate::ast::url::Url::Unresolved(s) => {
debug_assert!(
false,
"Url::Unresolved({s:?}) reached render_shortcode \
— visit_urls_mut missing for Hero"
);
s.clone()
}
};
let empty_snapshot = crate::asset_snapshot::AssetSnapshot::new();
let (snap, ctx) = match self.gallery_assets() {
Some(s) => (s, crate::render::image::ImageContext::Hero),
None => (&empty_snapshot, crate::render::image::ImageContext::HeroBare),
};
let opts = crate::render::image::ImageRenderOptions::default();
let hero_media = |href: &str| -> String {
let ext = href.rsplit('.').next().unwrap_or("");
if matches!(
crate::resolve::ext_kind::reference_kind_for_ext(ext),
crate::resolve::ext_kind::ExtKind::Video
) {
let mut params = crate::resolve::title_params::TitleParams::default();
params.params.insert("loop".into(), "1".into());
crate::render::video::synthesize_video_html(¶ms, href, snap)
} else {
crate::render::image::synthesize_image_html(
href,
"",
snap,
ctx.clone(),
&opts,
)
}
};
let img_html = hero_media(&src);
if hero_extras.is_empty() {
out.push_str(&img_html);
} else {
out.push_str(r#"<div class="moss-hero-slides"><div class="moss-hero-slide">"#);
out.push_str(&img_html);
out.push_str("</div>");
for extra in hero_extras {
let href = match extra {
crate::ast::url::Url::Resolved(r) => r.href.clone(),
crate::ast::url::Url::Unresolved(s) => s.clone(),
};
let extra_html = hero_media(&href);
out.push_str(r#"<div class="moss-hero-slide">"#);
out.push_str(&extra_html);
out.push_str("</div>");
}
out.push_str("</div>");
}
}
if !args.overlay.is_empty() {
let mut overlay_html = String::new();
render_hero_overlay_blocks(
self.gallery_assets(),
&args.overlay,
&mut overlay_html,
);
let collapsed = collapse_tag_adjacent_newlines(&overlay_html);
out.push_str(r#"<div class="moss-hero-content">"#);
out.push_str(collapsed.trim());
out.push_str("</div>");
}
out.push_str("</section>");
}
Shortcode::Grid(args) => {
out.push_str(&self.render_grid_parts(args, source_line).to_html());
}
Shortcode::Recent(_args) => {
out.push_str(r#"<div class="moss-recent"></div>"#);
}
Shortcode::Apply(args) => {
let placeholder = args.placeholder.as_deref().unwrap_or("your@email.com");
out.push_str(r#"<div class="moss-apply">"#);
out.push_str(r#"<form class="moss-subscribe-form moss-apply-form" data-position="apply" data-revert="false">"#);
out.push_str(r#"<input type="email" placeholder=""#);
out.push_str(&escape_attr(placeholder));
out.push_str(r#"" />"#);
out.push_str("<button>");
out.push_str(&escape_text(args.button.as_deref().unwrap_or("申请")));
out.push_str("</button>");
out.push_str("</form>");
out.push_str("</div>");
}
}
}
fn render_grid_parts(&self, args: &GridShortcode, source_line: Option<usize>) -> GridParts {
super::grid_parts::render_grid_parts(self, args, source_line)
}
fn emit_footnote_anchors(&self) -> bool {
true
}
fn emit_heading_anchors(&self) -> bool {
true
}
fn permalink_section_label(&self) -> &str {
"Permalink to this section"
}
fn render_heading(
&self,
out: &mut String,
level: u8,
id: Option<&str>,
source_line: Option<usize>,
content: &str,
) {
out.push('<');
out.push('h');
out.push((b'0' + level) as char);
if let Some(id) = id {
out.push_str(r#" id=""#);
out.push_str(&escape_attr(id));
out.push('"');
}
if let Some(n) = source_line {
use std::fmt::Write as _;
let _ = write!(out, r#" data-source-line="{}""#, n);
}
out.push('>');
out.push_str(content);
if let Some(id) = id.filter(|_| self.emit_heading_anchors()) {
out.push_str(r##"<a class="moss-heading-anchor" href="#"##);
out.push_str(&escape_attr(id));
out.push_str(r##"" aria-label=""##);
out.push_str(&escape_attr(self.permalink_section_label()));
out.push_str(r##""><span aria-hidden="true">#</span></a>"##);
}
out.push_str("</h");
out.push((b'0' + level) as char);
out.push('>');
}
}
#[derive(Debug, Default)]
pub struct DefaultHooks<'a> {
assets: Option<&'a crate::asset_snapshot::AssetSnapshot>,
grid_cell_sizes: std::cell::RefCell<Vec<String>>,
suppress_heading_anchors: bool,
}
impl<'a> DefaultHooks<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn with_snapshot(assets: &'a crate::asset_snapshot::AssetSnapshot) -> Self {
Self {
assets: Some(assets),
..Self::default()
}
}
pub fn hero_overlay(assets: Option<&'a crate::asset_snapshot::AssetSnapshot>) -> Self {
Self {
assets,
suppress_heading_anchors: true,
..Self::default()
}
}
}
impl<'a> RenderHooks for DefaultHooks<'a> {
fn gallery_assets(&self) -> Option<&crate::asset_snapshot::AssetSnapshot> {
self.assets
}
fn emit_heading_anchors(&self) -> bool {
!self.suppress_heading_anchors
}
fn begin_grid_cells(&self, columns: u32, data_width: Option<&str>) {
self.grid_cell_sizes
.borrow_mut()
.push(crate::contract::sizes::sizes_for_grid_cell(
columns, data_width,
));
}
fn end_grid_cells(&self) {
self.grid_cell_sizes.borrow_mut().pop();
}
fn render_image(
&self,
out: &mut String,
src: &ResolvedUrl,
alt: &str,
title: Option<&str>,
img_style: Option<&str>,
width: Option<&str>,
) {
let assets = match self.assets {
Some(a) => a,
None => {
push_bare_img(out, src, alt, title, img_style);
return;
}
};
let ctx = crate::render::image::ImageContext::MarkdownInline;
let style_attr =
img_style.map(|s| format!(r#"style="{}""#, crate::media::html_escape(s)));
let cell_scope = self.grid_cell_sizes.borrow();
let sizes: Option<&str> = width
.and_then(crate::contract::sizes::sizes_for_data_width)
.or_else(|| cell_scope.last().map(String::as_str));
let opts = crate::render::image::ImageRenderOptions {
extra_attrs: style_attr.as_deref(),
sizes,
..Default::default()
};
let html = crate::render::image::synthesize_image_html(
&src.href,
alt,
assets,
ctx,
&opts,
);
out.push_str(&html);
}
}
fn push_bare_img(
out: &mut String,
src: &ResolvedUrl,
alt: &str,
title: Option<&str>,
img_style: Option<&str>,
) {
out.push_str(r#"<img src=""#);
out.push_str(&escape_attr(&src.href));
out.push_str(r#"" alt=""#);
out.push_str(&escape_attr(alt));
out.push('"');
if let Some(t) = title {
out.push_str(r#" title=""#);
out.push_str(&escape_attr(t));
out.push('"');
}
if let Some(s) = img_style {
out.push_str(r#" style=""#);
out.push_str(&escape_attr(s));
out.push('"');
}
out.push_str(" />");
}
fn render_hero_overlay_blocks(
snapshot: Option<&crate::asset_snapshot::AssetSnapshot>,
blocks: &[super::node::Block],
out: &mut String,
) {
super::render::render_blocks(&DefaultHooks::hero_overlay(snapshot), out, blocks);
}
pub fn collapse_tag_adjacent_newlines(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let chars: Vec<char> = html.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\n' {
let mut j = i + 1;
while j < chars.len() && chars[j] == '\n' {
j += 1;
}
let next_is_open_tag = j < chars.len() && chars[j] == '<';
let last = out.as_bytes();
let prev_is_close_tag = last.last() == Some(&b'>');
let collapse = prev_is_close_tag
&& next_is_open_tag
&& (ends_with_heading_close(&out) || ends_with_paragraph_close(&out));
if collapse {
i = j;
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
fn ends_with_heading_close(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 5 {
return false;
}
let n = bytes.len();
bytes[n - 5] == b'<'
&& bytes[n - 4] == b'/'
&& bytes[n - 3] == b'h'
&& matches!(bytes[n - 2], b'1' | b'2' | b'3' | b'4' | b'5' | b'6')
&& bytes[n - 1] == b'>'
}
fn ends_with_paragraph_close(s: &str) -> bool {
s.ends_with("</p>")
}
pub(super) fn escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
pub(super) fn escape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::node::Inline;
use crate::ast::url::Url;
#[test]
fn escape_attr_handles_all_unsafe_chars() {
assert_eq!(escape_attr(r#"&"<>"#), "&"<>");
}
#[test]
fn escape_text_does_not_quote_double_quote() {
assert_eq!(escape_text(r#"hi "there" & y"#), r#"hi "there" & y"#);
}
#[test]
fn default_hooks_render_heading_emits_id() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 2, Some("setup"), None, "Setup");
assert_eq!(
out,
r##"<h2 id="setup">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##
);
}
#[test]
fn default_hooks_render_heading_without_id() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 3, None, None, "Sub");
assert_eq!(out, "<h3>Sub</h3>");
}
#[test]
fn default_hooks_render_heading_emits_source_line() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 2, Some("setup"), Some(42), "Setup");
assert_eq!(
out,
r##"<h2 id="setup" data-source-line="42">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##
);
}
#[test]
fn default_hooks_render_heading_source_line_without_id() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 1, None, Some(1), "Top");
assert_eq!(out, r#"<h1 data-source-line="1">Top</h1>"#);
}
#[test]
fn default_hooks_render_heading_emits_anchor_when_id_present() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 2, Some("setup"), None, "Setup");
assert_eq!(
out,
r##"<h2 id="setup">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##
);
}
#[test]
fn default_hooks_render_heading_no_anchor_when_id_absent() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 3, None, None, "Sub");
assert_eq!(out, "<h3>Sub</h3>");
}
#[test]
fn default_hooks_render_heading_anchor_preserves_source_line_position() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 2, Some("setup"), Some(42), "Setup");
assert_eq!(
out,
r##"<h2 id="setup" data-source-line="42">Setup<a class="moss-heading-anchor" href="#setup" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##
);
}
#[test]
fn default_hooks_render_heading_anchor_escapes_id_in_href() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_heading(&mut out, 2, Some(r#"a&b"#), None, "AB");
assert_eq!(
out,
r##"<h2 id="a&b">AB<a class="moss-heading-anchor" href="#a&b" aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h2>"##
);
}
#[test]
fn default_hooks_render_link_internal_default() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new("docs/", UrlKind::Internal),
false,
"Docs",
);
assert_eq!(out, r#"<a href="docs/">Docs</a>"#);
}
#[test]
fn default_hooks_render_link_wikilink_kind_injects_class() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new("../docs/", UrlKind::Wikilink),
false,
"Docs",
);
assert_eq!(out, r#"<a class="wikilink" href="../docs/">Docs</a>"#);
}
#[test]
fn default_hooks_render_link_is_wikilink_flag_injects_class() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new("../docs/", UrlKind::Internal),
true,
"Docs",
);
assert_eq!(out, r#"<a class="wikilink" href="../docs/">Docs</a>"#);
}
#[test]
fn default_hooks_render_link_asset_newtab_injects_target() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new("file.pdf", UrlKind::AssetNewtab),
false,
"PDF",
);
assert_eq!(
out,
r#"<a target="_blank" rel="noopener" href="file.pdf">PDF</a>"#
);
}
#[test]
fn default_hooks_render_link_wikilink_and_newtab_compose() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new("file.pdf", UrlKind::AssetNewtab),
true,
"PDF",
);
assert_eq!(
out,
r#"<a class="wikilink" target="_blank" rel="noopener" href="file.pdf">PDF</a>"#
);
}
#[test]
fn default_hooks_render_image_with_alt() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_image(
&mut out,
&ResolvedUrl::new("cat.jpg", UrlKind::Asset),
"A cat",
None,
None,
None,
);
assert_eq!(out, r#"<img src="cat.jpg" alt="A cat" />"#);
}
#[test]
fn default_hooks_render_image_with_title() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_image(
&mut out,
&ResolvedUrl::new("cat.jpg", UrlKind::Asset),
"A cat",
Some("Photo"),
None,
None,
);
assert_eq!(
out,
r#"<img src="cat.jpg" alt="A cat" title="Photo" />"#
);
}
#[test]
fn default_hooks_escape_link_href() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new(r#"q=a&b="c""#, UrlKind::External),
false,
"x",
);
assert!(out.contains(r#"href="q=a&b="c"""#));
assert!(out.contains(r#"target="_blank""#), "got: {out}");
}
#[test]
fn default_hooks_render_link_external_opens_new_tab() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_link(
&mut out,
&ResolvedUrl::new("https://example.com", UrlKind::External),
false,
"Example",
);
assert_eq!(
out,
r#"<a target="_blank" rel="noopener" href="https://example.com">Example</a>"#
);
}
#[test]
fn render_link_unused_url_param_compiles() {
let _u = Url::resolved("x", UrlKind::Internal);
}
use super::super::node::Block;
use super::super::shortcode::{
GalleryItem, GalleryShortcode, GridShortcode, HeroShortcode, Shortcode,
};
fn render_shortcode_html(sc: &Shortcode) -> String {
let mut out = String::new();
DefaultHooks::new().render_shortcode(&mut out, sc, None);
out
}
#[test]
fn hero_with_width_screen_emits_data_width() {
let sc = Shortcode::Hero(HeroShortcode {
width: Some("screen".to_string()),
..Default::default()
});
let html = render_shortcode_html(&sc);
assert!(
html.contains(r#"data-width="screen""#),
"expected data-width=screen, got: {html}"
);
assert!(html.contains(r#"class="moss-hero""#), "got: {html}");
}
#[test]
fn hero_with_width_wide_emits_data_width() {
let sc = Shortcode::Hero(HeroShortcode {
width: Some("wide".to_string()),
..Default::default()
});
let html = render_shortcode_html(&sc);
assert!(html.contains(r#"data-width="wide""#), "got: {html}");
}
#[test]
fn hero_default_omits_data_width() {
let sc = Shortcode::Hero(HeroShortcode::default());
let html = render_shortcode_html(&sc);
assert!(
!html.contains("data-width"),
"default should omit data-width, got: {html}"
);
}
#[test]
fn gallery_with_width_page_emits_data_width() {
let sc = Shortcode::Gallery(GalleryShortcode {
width: Some("page".to_string()),
items: vec![GalleryItem {
src: Url::resolved("a.jpg", UrlKind::Asset),
alt: String::new(),
attrs: String::new(),
}],
..Default::default()
});
let html = render_shortcode_html(&sc);
assert!(html.contains(r#"data-width="page""#), "got: {html}");
assert!(html.contains(r#"class="moss-gallery""#), "got: {html}");
}
#[test]
fn gallery_default_omits_data_width() {
let sc = Shortcode::Gallery(GalleryShortcode::default());
let html = render_shortcode_html(&sc);
assert!(
!html.contains("data-width"),
"default should omit data-width, got: {html}"
);
}
#[test]
fn gallery_with_columns_emits_both_count_hook_and_value() {
let sc = Shortcode::Gallery(GalleryShortcode {
columns: Some(8),
items: vec![GalleryItem {
src: Url::resolved("a.jpg", UrlKind::Asset),
alt: String::new(),
attrs: String::new(),
}],
..Default::default()
});
let html = render_shortcode_html(&sc);
assert!(html.contains(r#"data-columns="8""#), "got: {html}");
assert!(html.contains(r#"--moss-gallery-columns: 8"#), "got: {html}");
}
#[test]
fn gallery_without_columns_omits_the_count_hook() {
let sc = Shortcode::Gallery(GalleryShortcode::default());
let html = render_shortcode_html(&sc);
assert!(
!html.contains("data-columns"),
"count-less gallery must stay on the auto-fill rule, got: {html}"
);
assert!(!html.contains("--moss-gallery-columns"), "got: {html}");
}
#[test]
fn grid_with_width_wide_emits_data_width() {
let sc = Shortcode::Grid(GridShortcode {
columns: 2,
width: Some("wide".to_string()),
cells: vec![
vec![Block::Paragraph(vec![super::super::node::Inline::Text("a".into())])],
vec![Block::Paragraph(vec![super::super::node::Inline::Text("b".into())])],
],
..Default::default()
});
let html = render_shortcode_html(&sc);
assert!(html.contains(r#"data-width="wide""#), "got: {html}");
assert!(html.contains(r#"class="moss-grid""#), "got: {html}");
assert!(html.contains(r#"data-columns="2""#), "got: {html}");
}
#[test]
fn grid_default_omits_data_width() {
let sc = Shortcode::Grid(GridShortcode {
columns: 1,
cells: vec![vec![Block::Paragraph(vec![
super::super::node::Inline::Text("solo".into()),
])]],
..Default::default()
});
let html = render_shortcode_html(&sc);
assert!(
!html.contains("data-width"),
"default should omit data-width, got: {html}"
);
}
use crate::asset_snapshot::{AssetSnapshot, VariantKindSet};
use std::path::PathBuf;
fn gallery_with_one_item(src: &str) -> Shortcode {
Shortcode::Gallery(GalleryShortcode {
items: vec![GalleryItem {
src: Url::resolved(src, UrlKind::Asset),
alt: "photo".to_string(),
attrs: String::new(),
}],
..Default::default()
})
}
#[test]
fn default_hooks_new_gallery_routes_through_synth_with_empty_snapshot() {
let sc = gallery_with_one_item("photos/cat.jpg");
let mut out = String::new();
DefaultHooks::new().render_shortcode(&mut out, &sc, None);
assert!(
out.contains("<picture"),
"expected <picture> wrap even without snapshot (PR1: every <img> is synth), got: {out}",
);
assert!(out.contains(r#"src="photos/cat.jpg""#), "got: {out}");
assert!(out.contains(r#"alt="photo""#), "got: {out}");
assert!(out.contains(r#"loading="lazy""#), "got: {out}");
}
#[test]
fn default_hooks_with_snapshot_gallery_emits_picture_for_registered_webp() {
let src = "photos/cat.jpg";
let mut snap = AssetSnapshot::new();
snap.variants.insert(
PathBuf::from("photos/cat"),
VariantKindSet { webp: true, avif: false },
);
let sc = gallery_with_one_item(src);
let mut out = String::new();
DefaultHooks::with_snapshot(&snap).render_shortcode(&mut out, &sc, None);
assert!(out.contains("<picture"), "expected <picture> wrap, got: {out}");
assert!(
out.contains(r#"srcset="photos/cat.webp""#),
"expected webp srcset, got: {out}",
);
assert!(out.contains(r#"src="photos/cat.jpg""#), "got: {out}");
}
#[test]
fn default_hooks_with_snapshot_gallery_uses_snapshot_dims() {
let src = "photos/cat.jpg";
let mut snap = AssetSnapshot::new();
snap.dimensions.insert(PathBuf::from(src), (800, 600));
let sc = gallery_with_one_item(src);
let mut out = String::new();
DefaultHooks::with_snapshot(&snap).render_shortcode(&mut out, &sc, None);
assert!(out.contains(r#"width="800""#), "expected width=800, got: {out}");
assert!(out.contains(r#"height="600""#), "expected height=600, got: {out}");
}
fn hero_with_image(src: &str) -> Shortcode {
Shortcode::Hero(HeroShortcode {
image: Some(Url::resolved(src, UrlKind::Asset)),
..Default::default()
})
}
#[test]
fn hero_default_routes_through_synth_with_hero_bare() {
let sc = hero_with_image("hero.jpg");
let mut out = String::new();
DefaultHooks::new().render_shortcode(&mut out, &sc, None);
assert!(
out.contains(r#"<section class="moss-hero""#),
"expected hero section wrap, got: {out}",
);
assert!(out.contains(r#"src="hero.jpg""#), "got: {out}");
assert!(
!out.contains("<picture"),
"HeroBare must NOT emit <picture>, got: {out}",
);
assert!(
!out.contains("loading="),
"HeroBare must NOT emit loading attr, got: {out}",
);
}
#[test]
fn hero_with_snapshot_routes_through_synth_picture_wrap() {
let src = "hero.jpg";
let mut snap = AssetSnapshot::new();
snap.dimensions.insert(PathBuf::from(src), (1920, 1080));
snap.variants.insert(
PathBuf::from("hero"),
VariantKindSet { webp: true, avif: false },
);
let sc = hero_with_image(src);
let mut out = String::new();
DefaultHooks::with_snapshot(&snap).render_shortcode(&mut out, &sc, None);
assert!(
out.contains(r#"<section class="moss-hero""#),
"expected hero section wrap, got: {out}",
);
assert!(out.contains("<picture"), "expected <picture> wrap, got: {out}");
assert!(
out.contains(
r#"srcset="hero.w800.webp 800w, hero.w1600.webp 1600w, hero.webp 1920w" type="image/webp" sizes="100vw""#
),
"expected webp srcset ladder, got: {out}",
);
assert!(out.contains(r#"width="1920""#), "got: {out}");
assert!(out.contains(r#"height="1080""#), "got: {out}");
}
#[test]
fn default_hooks_with_snapshot_render_image_emits_picture_no_figure_wrap() {
let src = "photos/cat.jpg";
let mut snap = AssetSnapshot::new();
snap.dimensions.insert(PathBuf::from(src), (800, 600));
snap.variants.insert(
PathBuf::from("photos/cat"),
VariantKindSet { webp: true, avif: false },
);
let hooks = DefaultHooks::with_snapshot(&snap);
let mut out = String::new();
hooks.render_image(
&mut out,
&ResolvedUrl::new(src, UrlKind::Asset),
"A cat",
None,
None,
None,
);
assert!(
!out.contains("<figure"),
"expected NO <figure> wrap (MarkdownInline), got: {out}",
);
assert!(out.contains("<picture"), "expected <picture>, got: {out}");
assert!(out.contains(r#"loading="lazy""#), "got: {out}");
assert!(out.contains(r#"alt="A cat""#), "got: {out}");
}
#[test]
fn default_hooks_with_snapshot_render_image_emits_title_attribute_when_present() {
let src = "photos/cat.jpg";
let mut snap = AssetSnapshot::new();
snap.dimensions.insert(PathBuf::from(src), (800, 600));
let hooks = DefaultHooks::with_snapshot(&snap);
let mut out = String::new();
hooks.render_image(
&mut out,
&ResolvedUrl::new(src, UrlKind::Asset),
"alt-text",
Some("Photo caption"),
None,
None,
);
assert!(
!out.contains("<figcaption"),
"expected NO figcaption (MarkdownInline), got: {out}",
);
assert!(out.contains("<picture") || out.contains("<img"), "got: {out}");
}
#[test]
fn default_hooks_new_render_image_falls_back_to_bare_img() {
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_image(
&mut out,
&ResolvedUrl::new("cat.jpg", UrlKind::Asset),
"A cat",
None,
None,
None,
);
assert_eq!(out, r#"<img src="cat.jpg" alt="A cat" />"#);
}
#[test]
fn default_hooks_hero_overlay_heading_has_no_permalink_anchor() {
use crate::ast::shortcode::{HeroShortcode, Shortcode};
use crate::ast::{parse, Url, UrlKind, ResolvedUrl};
let overlay_blocks = parse("# Understanding climate extremes\n\nSubtitle.").blocks;
let sc = Shortcode::Hero(HeroShortcode {
overlay: overlay_blocks,
image: Some(Url::Resolved(ResolvedUrl::new("header.png", UrlKind::Asset))),
..Default::default()
});
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_shortcode(&mut out, &sc, None);
assert!(
!out.contains("moss-heading-anchor"),
"hero overlay heading must not carry a permalink anchor via DefaultHooks; got: {out}"
);
assert!(
out.contains(r#"id="understanding-climate-extremes""#),
"hero overlay heading must still carry its id; got: {out}"
);
}
#[test]
fn trait_default_render_image_carries_img_style() {
use crate::ast::{ResolvedUrl, UrlKind};
struct BareHooks;
impl RenderHooks for BareHooks {}
let mut out = String::new();
BareHooks.render_image(
&mut out,
&ResolvedUrl::new("cat.jpg", UrlKind::Asset),
"A cat",
None,
Some("object-fit:cover"),
None,
);
assert_eq!(
out,
r#"<img src="cat.jpg" alt="A cat" style="object-fit:cover" />"#
);
}
#[test]
fn hero_overlay_figure_keeps_embed_img_style() {
use crate::ast::shortcode::{HeroShortcode, Shortcode};
use crate::ast::{Block, Inline, ResolvedUrl, Url, UrlKind};
let overlay_blocks = vec![Block::Figure {
image: Inline::Image {
src: Url::Resolved(ResolvedUrl::new("photo.jpg", UrlKind::Asset)),
alt: String::new(),
title: None,
is_wikilink: true,
wikilink_pothole: None,
},
caption: None,
width: None,
align: None,
class_names: Vec::new(),
img_style: Some("object-fit:cover;object-position:left".into()),
}];
let sc = Shortcode::Hero(HeroShortcode {
overlay: overlay_blocks,
image: Some(Url::Resolved(ResolvedUrl::new("header.png", UrlKind::Asset))),
..Default::default()
});
let hooks = DefaultHooks::new();
let mut out = String::new();
hooks.render_shortcode(&mut out, &sc, None);
assert!(
out.contains("object-fit:cover;object-position:left"),
"hero overlay embed must keep its sizing style; got: {out}"
);
}
#[test]
fn hero_overlay_inherits_snapshot_and_grid_cell_sizes_scope() {
use crate::ast::shortcode::{GridShortcode, HeroShortcode, Shortcode};
use crate::ast::{Block, Inline, ResolvedUrl, Url, UrlKind};
let src = "photos/cat.jpg";
let mut snap = AssetSnapshot::new();
snap.dimensions.insert(PathBuf::from(src), (2400, 1200));
snap.variants.insert(
PathBuf::from("photos/cat"),
VariantKindSet { webp: true, avif: false },
);
let grid = || {
Shortcode::Grid(GridShortcode {
columns: 3,
cells: vec![vec![Block::Paragraph(vec![Inline::Image {
src: Url::Resolved(ResolvedUrl::new(src, UrlKind::Asset)),
alt: "A cat".into(),
title: None,
is_wikilink: false,
wikilink_pothole: None,
}])]],
..Default::default()
})
};
let hooks = DefaultHooks::with_snapshot(&snap);
let mut bare = String::new();
hooks.render_shortcode(&mut bare, &grid(), None);
let mut in_hero = String::new();
hooks.render_shortcode(
&mut in_hero,
&Shortcode::Hero(HeroShortcode {
overlay: vec![Block::Shortcode(grid())],
image: Some(Url::Resolved(ResolvedUrl::new("header.png", UrlKind::Asset))),
..Default::default()
}),
None,
);
let cell_sizes = "(min-width: 48rem) calc(min(47.25rem, 100vw) / 3), 100vw";
assert!(
in_hero.contains(&format!(r#"sizes="{cell_sizes}""#)),
"grid cell inside a hero overlay must scope sizes= to the cell; got: {in_hero}"
);
assert!(
bare.contains(&format!(r#"sizes="{cell_sizes}""#)),
"same grid outside a hero must scope sizes= identically; got: {bare}"
);
}
#[test]
fn render_shortcode_accepts_source_line_param() {
use crate::ast::shortcode::{Shortcode, SubscribeShortcode};
let sc = Shortcode::Subscribe(SubscribeShortcode {
placeholder: None,
button: None,
});
let mut out = String::new();
let hooks = DefaultHooks::new();
hooks.render_shortcode(&mut out, &sc, Some(7));
let _ = out;
}
#[test]
fn render_shortcode_grid_emits_data_source_range_when_some() {
use crate::ast::shortcode::{GridShortcode, Shortcode};
let sc = Shortcode::Grid(GridShortcode {
cells: vec![vec![]],
columns: 1,
..Default::default()
});
let mut out = String::new();
DefaultHooks::new().render_shortcode(&mut out, &sc, Some(12));
assert!(
out.contains(r#"data-source-range="12-12""#),
"grid should carry a point source range; got: {out}"
);
}
#[test]
fn render_shortcode_grid_omits_data_source_range_when_none() {
use crate::ast::shortcode::{GridShortcode, Shortcode};
let sc = Shortcode::Grid(GridShortcode {
cells: vec![vec![]],
columns: 1,
..Default::default()
});
let mut out = String::new();
DefaultHooks::new().render_shortcode(&mut out, &sc, None);
assert!(
!out.contains("data-source-range"),
"grid must omit source range when no source line; got: {out}"
);
}
#[test]
fn render_shortcode_grid_ratio_is_a_custom_property_not_inline_columns() {
use crate::ast::shortcode::{GridShortcode, Shortcode};
let sc = Shortcode::Grid(GridShortcode {
cells: vec![vec![], vec![]],
columns: 2,
ratio: Some("1:2".to_string()),
..Default::default()
});
let mut out = String::new();
DefaultHooks::new().render_shortcode(&mut out, &sc, None);
assert!(
out.contains(r#"style="--moss-grid-ratio:minmax(0, 1fr) minmax(0, 2fr)""#),
"ratio should ride as --moss-grid-ratio; got: {out}"
);
assert!(
!out.contains("grid-template-columns"),
"an inline grid-template-columns can never be overridden; got: {out}"
);
}
fn ladder_snapshot(path: &str) -> crate::asset_snapshot::AssetSnapshot {
let mut s = crate::asset_snapshot::AssetSnapshot::new();
s.dimensions
.insert(std::path::PathBuf::from(path), (2000, 1200));
s
}
fn figure_block(width: Option<&str>) -> Block {
Block::Figure {
image: Inline::Image {
src: Url::Resolved(ResolvedUrl::new("photo.jpg", UrlKind::Asset)),
alt: String::new(),
title: None,
is_wikilink: true,
wikilink_pothole: None,
},
caption: None,
width: width.map(String::from),
align: None,
class_names: Vec::new(),
img_style: None,
}
}
#[test]
fn figure_block_screen_width_reaches_sizes() {
let snap = ladder_snapshot("photo.jpg");
let hooks = DefaultHooks::with_snapshot(&snap);
let mut out = String::new();
super::super::render::render_blocks(&hooks, &mut out, &[figure_block(Some("screen"))]);
assert!(
out.contains(r#"data-width="screen""#),
"figure carries the attribute; got: {out}"
);
assert!(
out.contains(r#"sizes="100vw""#),
"a |screen figure must declare full-bleed sizes, not the content column; got: {out}"
);
}
#[test]
fn figure_block_wide_width_reaches_sizes() {
let snap = ladder_snapshot("photo.jpg");
let hooks = DefaultHooks::with_snapshot(&snap);
let mut out = String::new();
super::super::render::render_blocks(&hooks, &mut out, &[figure_block(Some("wide"))]);
assert!(
out.contains(r#"sizes="(min-width: 48rem) min(63rem, 100vw), 100vw""#),
"a |wide figure declares the wide band; got: {out}"
);
}
#[test]
fn grid_cell_image_declares_cell_sizes_and_scope_pops() {
let snap = ladder_snapshot("photo.jpg");
let hooks = DefaultHooks::with_snapshot(&snap);
let grid = Shortcode::Grid(crate::ast::GridShortcode {
columns: 3,
ratio: None,
classes: String::new(),
cells: vec![vec![figure_block(None)]],
width: Some("page".to_string()),
});
let mut out = String::new();
hooks.render_shortcode(&mut out, &grid, None);
assert!(
out.contains(r#"sizes="(min-width: 48rem) calc(min(1200px, 100vw) / 3), 100vw""#),
"a widthless figure in a 3-col page grid declares the cell track; got: {out}"
);
let mut after = String::new();
super::super::render::render_blocks(&hooks, &mut after, &[figure_block(None)]);
assert!(
after.contains(r#"sizes="(min-width: 48rem) 47.25rem, 100vw""#),
"grid cell scope leaked; got: {after}"
);
}
#[test]
fn grid_cell_figure_own_width_beats_cell_scope() {
let snap = ladder_snapshot("photo.jpg");
let hooks = DefaultHooks::with_snapshot(&snap);
let grid = Shortcode::Grid(crate::ast::GridShortcode {
columns: 2,
ratio: None,
classes: String::new(),
cells: vec![vec![figure_block(Some("screen"))]],
width: None,
});
let mut out = String::new();
hooks.render_shortcode(&mut out, &grid, None);
assert!(
out.contains(r#"sizes="100vw""#),
"an explicit |screen inside a grid cell wins over the cell track; got: {out}"
);
}
}