use super::attrs::gather_multi_line_attrs;
use super::cells::split_cells;
use super::node::Block;
use super::parser::{parse_with_config, ParseConfig};
use super::shortcode::{
ApplyShortcode, ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode,
HeroShortcode, RecentShortcode, Shortcode, SubscribeShortcode,
};
use super::url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedShortcode {
pub index: usize,
pub shortcode: Shortcode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionResult {
pub markdown_with_placeholders: String,
pub extracted: Vec<ExtractedShortcode>,
pub nonce: String,
pub warnings: Vec<String>,
}
const TYPED_KNOWN: &[&str] = &["subscribe", "buttons", "gallery", "hero", "grid", "recent", "apply"];
fn is_typed_known(name: &str) -> bool {
TYPED_KNOWN.contains(&name)
}
fn parse_shortcode_block(
name: &str,
args: &str,
body: &str,
config: &ParseConfig,
) -> (Option<Shortcode>, Vec<String>) {
match name {
"subscribe" => (Some(Shortcode::Subscribe(parse_subscribe_args(args))), vec![]),
"buttons" => (Some(Shortcode::Buttons(parse_buttons_body(args, body))), vec![]),
"gallery" => (Some(Shortcode::Gallery(parse_gallery_body(args, body))), vec![]),
"hero" => {
let (sc, used_p3) = parse_hero(args, body, config);
let mut warns = vec![];
if used_p3 {
warns.push(
"shortcode `:::hero` uses a body-image fallback (deprecated Priority 3). \
Move the image path to the `image=` attribute: \
`:::hero {image=path.jpg}`."
.to_string(),
);
}
if let Some(ref v) = sc.mobile {
if v != "overlay" {
warns.push(format!(
"shortcode `:::hero` has unrecognized `mobile={v}`. \
Only `mobile=overlay` is recognized. The attribute is ignored."
));
}
}
(Some(Shortcode::Hero(sc)), warns)
}
"grid" => {
let (sc, legacy) = parse_grid(args, body, config);
let mut warns = vec![];
if legacy {
warns.push(
"shortcode `:::grid` uses `---` cell dividers (deprecated). Migrate to `+++`.\n\
`---` support will be removed in a future release."
.to_string(),
);
}
(Some(Shortcode::Grid(sc)), warns)
}
"recent" => (Some(Shortcode::Recent(parse_recent_args(args, body))), vec![]),
"apply" => (Some(Shortcode::Apply(parse_apply_args(args))), vec![]),
_ => (None, vec![]),
}
}
pub fn parse_recent_args(args: &str, body: &str) -> RecentShortcode {
let attrs = super::attrs::parse_attrs(args).unwrap_or_default();
RecentShortcode {
since: attrs.get("since").map(str::to_string),
last: attrs.get("last").map(str::to_string),
count: attrs.get("count").and_then(|v| v.parse::<u32>().ok()),
fallback_markdown: body.trim().to_string(),
}
}
fn parse_grid(args: &str, body: &str, config: &ParseConfig) -> (GridShortcode, bool) {
let trimmed = args.trim();
let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed.find('{') {
#[allow(clippy::string_slice)]
(trimmed[..pos].trim(), &trimmed[pos..])
} else {
(trimmed, "")
};
let parsed = if attr_block.is_empty() {
Default::default()
} else {
super::attrs::parse_attrs(attr_block).unwrap_or_default()
};
let classes = parsed.class_string();
let width = parsed.width.map(str::to_string);
let mut columns: u32 = 1;
let mut ratio: Option<String> = None;
if let Some(cols_value) = parsed.get("cols") {
if cols_value.contains(':') {
ratio = Some(cols_value.to_string());
columns = cols_value.split(':').count() as u32;
} else if let Ok(n) = cols_value.parse::<u32>() {
columns = n.max(1);
}
} else {
let parts: Vec<&str> = positional.split_whitespace().collect();
if let Some(first) = parts.first() {
if first.contains(':') {
ratio = Some(first.to_string());
columns = first.split(':').count() as u32;
} else if let Ok(n) = first.parse::<u32>() {
columns = n.max(1);
if let Some(second) = parts.get(1) {
if second.contains(':') {
ratio = Some(second.to_string());
}
}
}
}
}
let (raw_cells, found_legacy_dash) = split_grid_cells(body);
let cells: Vec<Vec<Block>> = raw_cells
.iter()
.map(|raw| parse_cell_to_blocks(raw, config))
.collect();
(
GridShortcode {
columns,
ratio,
classes,
cells,
width,
},
found_legacy_dash,
)
}
fn parse_cell_to_blocks(raw: &str, config: &ParseConfig) -> Vec<Block> {
if let Some((url, inner)) = detect_compound_link(raw) {
let inner_trimmed = inner.trim();
let inner_is_plain_text = !inner_trimmed.contains('!')
&& !inner_trimmed.contains('[')
&& !inner_trimmed.contains('\n');
let is_external = url.starts_with("http://") || url.starts_with("https://");
if inner_is_plain_text && is_external {
let linkified = format!("[{}]({})", inner_trimmed, url);
return parse_with_config(&linkified, config).blocks;
}
let inner_doc = parse_with_config(inner_trimmed, config);
return vec![Block::LinkCard {
url: Url::unresolved(url),
children: inner_doc.blocks,
}];
}
if let Some(url) = detect_bare_url_cell(raw) {
let linkified = format!("[]({})", url);
let doc = parse_with_config(&linkified, config);
return doc.blocks;
}
let doc = parse_with_config(raw, config);
doc.blocks
}
fn detect_bare_url_cell(cell_text: &str) -> Option<String> {
let trimmed = cell_text.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.lines().count() > 1 {
return None;
}
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
return None;
}
if trimmed.chars().any(char::is_whitespace) {
return None;
}
Some(trimmed.to_string())
}
pub(super) fn detect_compound_link(cell_text: &str) -> Option<(String, String)> {
let stripped = cell_text.trim();
if !stripped.starts_with('[') {
return None;
}
if !stripped.ends_with(')') {
return None;
}
if stripped.len() > 1 && stripped.as_bytes()[1] == b'`' {
return None;
}
for line in stripped.lines() {
let t = line.trim();
if t.starts_with("```") || t.starts_with("~~~") {
return None;
}
}
let bytes = stripped.as_bytes();
let mut i: usize = 1;
let mut depth: usize = 1;
let mut outer_close: Option<usize> = None;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
i += 2;
continue;
}
b'`' => {
let tick_start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
let fence_len = i - tick_start;
'code_scan: while i < bytes.len() {
if bytes[i] == b'`' {
let close_start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
if i - close_start == fence_len {
break 'code_scan;
}
} else {
i += 1;
}
}
continue;
}
b'[' => {
depth += 1;
}
b']' => {
depth -= 1;
if depth == 0 {
outer_close = Some(i);
break;
}
}
_ => {}
}
i += 1;
}
let close_bracket = outer_close?;
if bytes.get(close_bracket + 1) != Some(&b'(') {
return None;
}
let mut j = close_bracket + 2;
let mut pdepth: usize = 1;
let mut paren_close: Option<usize> = None;
while j < bytes.len() {
match bytes[j] {
b'\\' => {
j += 2;
continue;
}
b'(' => pdepth += 1,
b')' => {
pdepth -= 1;
if pdepth == 0 {
paren_close = Some(j);
break;
}
}
_ => {}
}
j += 1;
}
let close_paren = paren_close?;
let tail = &stripped[close_paren + 1..];
if !tail.chars().all(|c| c.is_whitespace()) {
return None;
}
let inner = &stripped[1..close_bracket];
if inner.trim().is_empty() {
return None;
}
{
let inner_bytes = inner.as_bytes();
let mut k: usize = 0;
let mut image_stack: Vec<bool> = Vec::new();
while k < inner_bytes.len() {
match inner_bytes[k] {
b'\\' => {
k += 2;
continue;
}
b'`' => {
let tick_start = k;
while k < inner_bytes.len() && inner_bytes[k] == b'`' {
k += 1;
}
let fence_len = k - tick_start;
'inner_code: while k < inner_bytes.len() {
if inner_bytes[k] == b'`' {
let cs = k;
while k < inner_bytes.len() && inner_bytes[k] == b'`' {
k += 1;
}
if k - cs == fence_len {
break 'inner_code;
}
} else {
k += 1;
}
}
continue;
}
b'[' => {
let preceded_by_bang = k > 0 && inner_bytes[k - 1] == b'!';
image_stack.push(preceded_by_bang);
}
b']' => {
if let Some(is_image) = image_stack.pop() {
if image_stack.is_empty() && inner_bytes.get(k + 1) == Some(&b'(') {
if !is_image {
return None;
}
}
}
}
_ => {}
}
k += 1;
}
}
let url = &stripped[close_bracket + 2..close_paren];
Some((url.to_string(), inner.to_string()))
}
fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
if body.is_empty() {
return (vec![String::new()], false);
}
let mut cells = Vec::new();
let mut current = String::new();
let mut first_line_in_cell = true;
let mut found_legacy_dash = false;
for line in body.split_inclusive('\n') {
let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
let trimmed = content_no_eol.trim();
if trimmed == "+++" || trimmed == "---" {
if trimmed == "---" {
found_legacy_dash = true;
}
if let Some(stripped) = current.strip_suffix('\n') {
current.truncate(stripped.len());
}
cells.push(std::mem::take(&mut current));
first_line_in_cell = true;
continue;
}
if first_line_in_cell {
first_line_in_cell = false;
if trimmed.is_empty() {
continue;
}
}
current.push_str(line);
}
if let Some(stripped) = current.strip_suffix('\n') {
current.truncate(stripped.len());
}
cells.push(current);
(cells, found_legacy_dash)
}
fn parse_hero(args: &str, body: &str, config: &ParseConfig) -> (HeroShortcode, bool) {
let trimmed_args = args.trim();
let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
#[allow(clippy::string_slice)]
(trimmed_args[..pos].trim(), &trimmed_args[pos..])
} else {
(trimmed_args, "")
};
let parsed = if attr_block.is_empty() {
Default::default()
} else {
super::attrs::parse_attrs(attr_block).unwrap_or_default()
};
let classes = parsed.class_string();
let width = parsed.width.map(str::to_string);
let mobile = parsed.get("mobile").map(str::to_string);
if let Some(image_value) = parsed.get("image") {
let (path, attrs_str) = crate::media::split_pipe(image_value);
let overlay_text = body.trim().to_string();
let overlay = parse_overlay_to_blocks(&overlay_text, config);
return (
HeroShortcode {
image: if path.trim().is_empty() {
None
} else {
Some(Url::unresolved(path.trim().to_string()))
},
attrs: attrs_str.to_string(),
classes,
overlay,
overlay_text,
width,
mobile,
},
false,
);
}
if !positional.is_empty() {
let (path, attrs_str) = crate::media::split_pipe(positional);
let overlay_text = body.trim().to_string();
let overlay = parse_overlay_to_blocks(&overlay_text, config);
return (
HeroShortcode {
image: if path.trim().is_empty() {
None
} else {
Some(Url::unresolved(path.trim().to_string()))
},
attrs: attrs_str.to_string(),
classes,
overlay,
overlay_text,
width,
mobile,
},
false,
);
}
let mut overlay_lines: Vec<&str> = Vec::new();
let mut image_path: Option<String> = None;
let mut image_attrs = String::new();
let mut found_image = false;
let mut used_priority_3 = false;
for line in body.lines() {
if !found_image && !line.trim().is_empty() {
if let Some((path, attrs_str)) = parse_hero_media_line(line) {
image_path = Some(path);
image_attrs = attrs_str;
found_image = true;
used_priority_3 = true;
continue;
}
found_image = true;
}
overlay_lines.push(line);
}
let overlay_text = overlay_lines.join("\n").trim().to_string();
let overlay = parse_overlay_to_blocks(&overlay_text, config);
(
HeroShortcode {
image: image_path.map(Url::unresolved),
attrs: image_attrs,
classes,
overlay,
overlay_text,
width,
mobile,
},
used_priority_3,
)
}
fn parse_overlay_to_blocks(raw: &str, config: &ParseConfig) -> Vec<Block> {
if raw.is_empty() {
return Vec::new();
}
let doc = parse_with_config(raw, config);
doc.blocks
}
const HERO_MEDIA_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
];
fn is_bare_hero_media(s: &str) -> bool {
let (path_part, _) = crate::media::split_pipe(s);
let path = path_part.trim();
path.rfind('.')
.map(|dot| {
#[allow(clippy::string_slice)]
let ext = &path[dot + 1..];
HERO_MEDIA_EXTENSIONS
.iter()
.any(|e| e.eq_ignore_ascii_case(ext))
})
.unwrap_or(false)
}
fn parse_hero_media_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if let Some(inner) = trimmed
.strip_prefix("![[")
.and_then(|s| s.strip_suffix("]]"))
{
let (path, attrs_str) = crate::media::split_pipe(inner);
return Some((path.trim().to_string(), attrs_str.to_string()));
}
if trimmed.starts_with(" {
if trimmed.ends_with(')') {
#[allow(clippy::string_slice)]
let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
let (path, attrs_str) = crate::media::split_pipe(inner);
return Some((path.trim().to_string(), attrs_str.to_string()));
}
}
}
if is_bare_hero_media(trimmed) {
let (path, attrs_str) = crate::media::split_pipe(trimmed);
return Some((path.trim().to_string(), attrs_str.to_string()));
}
None
}
fn parse_gallery_body(args: &str, body: &str) -> GalleryShortcode {
let (positional, classes, width) = split_positional_classes_and_width(args);
let columns = if positional.is_empty() {
None
} else {
positional.parse::<u32>().ok()
};
let mut items: Vec<GalleryItem> = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let (src_raw, attrs) = split_pipe(trimmed);
let (src_url, alt) = match parse_markdown_image(src_raw) {
Some((alt, path)) => (path, alt),
None => (src_raw.trim().to_string(), String::new()),
};
items.push(GalleryItem {
src: Url::unresolved(src_url),
alt,
attrs: attrs.to_string(),
});
}
GalleryShortcode {
columns,
classes,
items,
width,
}
}
fn split_positional_classes_and_width(args: &str) -> (String, String, Option<String>) {
let trimmed = args.trim();
if let Some(brace_start) = trimmed.find('{') {
#[allow(clippy::string_slice)]
let after_open = &trimmed[brace_start..];
if let Some(brace_end) = after_open.find('}') {
#[allow(clippy::string_slice)]
let positional = trimmed[..brace_start].trim().to_string();
#[allow(clippy::string_slice)]
let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
return (
positional,
parsed.class_string(),
parsed.width.map(str::to_string),
);
}
#[allow(clippy::string_slice)]
let inner = &trimmed[brace_start + 1..brace_start + brace_end];
let mut classes = Vec::new();
for token in inner.split_whitespace() {
if let Some(class) = token.strip_prefix('.') {
if !class.is_empty() {
classes.push(class);
}
}
}
return (positional, classes.join(" "), None);
}
}
(trimmed.to_string(), String::new(), None)
}
fn split_positional_and_classes(args: &str) -> (String, String) {
let trimmed = args.trim();
if let Some(brace_start) = trimmed.find('{') {
#[allow(clippy::string_slice)]
let after_open = &trimmed[brace_start..];
if let Some(brace_end) = after_open.find('}') {
#[allow(clippy::string_slice)]
let positional = trimmed[..brace_start].trim().to_string();
#[allow(clippy::string_slice)]
let attr_block_str = &trimmed[brace_start..=brace_start + brace_end];
if let Ok(parsed) = super::attrs::parse_attrs(attr_block_str) {
return (positional, parsed.class_string());
}
#[allow(clippy::string_slice)]
let inner = &trimmed[brace_start + 1..brace_start + brace_end];
let mut classes = Vec::new();
for token in inner.split_whitespace() {
if let Some(class) = token.strip_prefix('.') {
if !class.is_empty() {
classes.push(class);
}
}
}
return (positional, classes.join(" "));
}
}
(trimmed.to_string(), String::new())
}
fn split_pipe(s: &str) -> (&str, &str) {
match s.split_once('|') {
Some((before, after)) => (before, after.trim()),
None => (s, ""),
}
}
fn parse_markdown_image(s: &str) -> Option<(String, String)> {
let s = s.trim();
let rest = s.strip_prefix("?;
let close_paren = after.rfind(')')?;
#[allow(clippy::string_slice)]
let path = &after[..close_paren];
if path.contains('(') {
return None;
}
Some((alt.to_string(), path.to_string()))
}
fn parse_buttons_body(args: &str, body: &str) -> ButtonsShortcode {
let (_positional, classes) = split_positional_and_classes(args);
let mut items: Vec<ButtonItem> = Vec::new();
for cell in split_cells(body) {
for line in cell.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((text, url)) = extract_markdown_link(trimmed) {
items.push(ButtonItem {
text,
url: Url::unresolved(url),
});
}
}
}
ButtonsShortcode { classes, items }
}
fn extract_markdown_link(s: &str) -> Option<(String, String)> {
let s = s.trim();
let inside = s.strip_prefix('[')?;
let (text, after) = inside.split_once(']')?;
let url = after.strip_prefix('(').and_then(|r| r.strip_suffix(')'))?;
if url.is_empty() {
return None;
}
Some((text.to_string(), url.to_string()))
}
fn parse_subscribe_args(args: &str) -> SubscribeShortcode {
let parsed = match super::attrs::parse_attrs(args) {
Ok(b) => b,
Err(_) => return SubscribeShortcode::default(),
};
let placeholder = parsed
.get("placeholder")
.filter(|s| !s.is_empty())
.map(str::to_string);
let button = parsed
.get("button")
.filter(|s| !s.is_empty())
.map(str::to_string);
SubscribeShortcode {
placeholder,
button,
}
}
pub fn parse_apply_args(args: &str) -> ApplyShortcode {
let parsed = match super::attrs::parse_attrs(args) {
Ok(b) => b,
Err(_) => return ApplyShortcode::default(),
};
let placeholder = parsed
.get("placeholder")
.filter(|s| !s.is_empty())
.map(str::to_string);
let button = parsed
.get("button")
.filter(|s| !s.is_empty())
.map(str::to_string);
ApplyShortcode {
placeholder,
button,
}
}
pub fn placeholder_for(nonce: &str, index: usize) -> String {
format!("<!--MOSS_SC_{nonce}_{index}-->")
}
pub fn parse_placeholder(nonce: &str, html: &str) -> Option<usize> {
let trim = html.trim();
let prefix = format!("<!--MOSS_SC_{nonce}_");
let inner = trim.strip_prefix(&prefix)?;
let inner = inner.strip_suffix("-->")?;
inner.parse::<usize>().ok()
}
fn compute_nonce(input: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
input.hash(&mut hasher);
let h = hasher.finish() as u32;
format!("{h:08x}")
}
pub fn extract_shortcodes(markdown: &str) -> ExtractionResult {
extract_shortcodes_with_config(markdown, &ParseConfig::default())
}
pub fn extract_shortcodes_with_config(
markdown: &str,
config: &ParseConfig,
) -> ExtractionResult {
let nonce = compute_nonce(markdown);
let mut extracted: Vec<ExtractedShortcode> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let output = extract_with_state(markdown, &nonce, &mut extracted, &mut warnings, config);
ExtractionResult {
markdown_with_placeholders: output,
extracted,
nonce,
warnings,
}
}
fn extract_with_state(
markdown: &str,
nonce: &str,
extracted: &mut Vec<ExtractedShortcode>,
warnings: &mut Vec<String>,
config: &ParseConfig,
) -> String {
let mut output = String::with_capacity(markdown.len());
let lines: Vec<&str> = markdown.lines().collect();
let mut i = 0;
let mut in_code_fence = false;
let mut fence_marker = String::new();
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if in_code_fence {
output.push_str(line);
output.push('\n');
let fence_char = fence_marker.chars().next().unwrap_or(' ');
if trimmed.starts_with(&fence_marker)
&& trimmed.trim_start_matches(fence_char).trim().is_empty()
{
in_code_fence = false;
fence_marker.clear();
}
i += 1;
continue;
}
if let Some(marker) = detect_code_fence_open(trimmed) {
in_code_fence = true;
fence_marker = marker;
output.push_str(line);
output.push('\n');
i += 1;
continue;
}
if let Some((arity, name, single_line_args)) = parse_shortcode_opener(trimmed) {
let (args_owned, opener_lines_consumed) =
gather_multi_line_attrs(single_line_args, &lines[i + 1..]);
let args: &str = args_owned.as_deref().unwrap_or(single_line_args);
let body_start = i + 1 + opener_lines_consumed;
let mut body_lines: Vec<&str> = Vec::new();
let mut j = body_start;
let mut closed = false;
while j < lines.len() {
if is_close_fence(lines[j].trim(), arity) {
closed = true;
break;
}
body_lines.push(lines[j]);
j += 1;
}
if !closed {
output.push_str(line);
output.push('\n');
i += 1;
continue;
}
let body = body_lines.join("\n");
if name.is_empty() {
let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
let body_processed = extract_with_state(&body, nonce, extracted, warnings, config);
output.push_str(&render_div_open(&parsed.classes, parsed.id.as_deref(), None));
output.push_str("\n\n");
output.push_str(&body_processed);
if !body_processed.is_empty() && !body_processed.ends_with('\n') {
output.push('\n');
}
output.push_str("\n</div>\n");
i = j + 1;
continue;
}
if is_typed_known(name) {
if let (Some(sc), parse_warnings) = parse_shortcode_block(name, args, &body, config) {
warnings.extend(parse_warnings);
let index = extracted.len();
output.push_str(&placeholder_for(&nonce, index));
output.push('\n');
for _ in 0..(j - i) {
output.push('\n');
}
extracted.push(ExtractedShortcode {
index,
shortcode: sc,
});
i = j + 1;
continue;
}
output.push_str(line);
output.push('\n');
i += 1;
continue;
}
let parsed = super::attrs::parse_attrs(args).unwrap_or_default();
warnings.push(format!("unknown shortcode `:::{}`", name));
let mut classes = vec!["moss-unknown-shortcode".to_string()];
classes.extend(parsed.classes.iter().cloned());
let extra_attrs = format!(r#" data-name="{}""#, html_escape_attr(name));
let body_processed = extract_with_state(&body, nonce, extracted, warnings, config);
output.push_str(&render_div_open(&classes, parsed.id.as_deref(), Some(&extra_attrs)));
output.push_str("\n\n");
output.push_str(&body_processed);
if !body_processed.is_empty() && !body_processed.ends_with('\n') {
output.push('\n');
}
output.push_str("\n</div>\n");
i = j + 1;
continue;
}
output.push_str(line);
output.push('\n');
i += 1;
}
output
}
fn render_div_open(classes: &[String], id: Option<&str>, extra_attrs: Option<&str>) -> String {
let mut out = String::from("<div");
if !classes.is_empty() {
out.push_str(" class=\"");
for (i, c) in classes.iter().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(&html_escape_attr(c));
}
out.push('"');
}
if let Some(id_val) = id {
out.push_str(" id=\"");
out.push_str(&html_escape_attr(id_val));
out.push('"');
}
if let Some(extra) = extra_attrs {
out.push_str(extra);
}
out.push('>');
out
}
fn html_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_str("'"),
_ => out.push(c),
}
}
out
}
fn detect_code_fence_open(trimmed: &str) -> Option<String> {
if trimmed.starts_with("```") {
Some("```".to_string())
} else if trimmed.starts_with("~~~") {
Some("~~~".to_string())
} else {
None
}
}
fn parse_shortcode_opener(trimmed: &str) -> Option<(usize, &str, &str)> {
let colons = trimmed.chars().take_while(|&c| c == ':').count();
if colons < 3 {
return None;
}
#[allow(clippy::string_slice)]
let rest = &trimmed[colons..];
let name_end = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(rest.len());
if name_end == 0 {
let after_ws = rest.trim_start();
if !after_ws.starts_with('{') {
return None;
}
return Some((colons, "", rest.trim()));
}
#[allow(clippy::string_slice)]
let name = &rest[..name_end];
#[allow(clippy::string_slice)]
let args = rest[name_end..].trim();
Some((colons, name, args))
}
fn is_close_fence(trimmed: &str, arity: usize) -> bool {
let mut chars = trimmed.chars();
for _ in 0..arity {
match chars.next() {
Some(':') => {}
_ => return false,
}
}
chars.all(char::is_whitespace)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_shortcodes_round_trips_input() {
let md = "# Heading\n\npara with [link](u).\n";
let result = extract_shortcodes(md);
assert_eq!(result.markdown_with_placeholders, md);
assert!(result.extracted.is_empty());
}
#[test]
fn extracts_subscribe_block_with_placeholder_and_button_attrs() {
let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
:::
"#;
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Subscribe(args) => {
assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
assert_eq!(args.button.as_deref(), Some("Sign me up"));
}
other => panic!("expected Subscribe, got {other:?}"),
}
assert!(result
.markdown_with_placeholders
.contains(&placeholder_for(&result.nonce, 0)));
assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
}
#[test]
fn extracts_subscribe_block_with_only_placeholder_attr() {
let md = r#":::subscribe {placeholder="hi@example.com"}
:::
"#;
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Subscribe(args) => {
assert_eq!(args.placeholder.as_deref(), Some("hi@example.com"));
assert!(args.button.is_none());
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn extracts_subscribe_block_with_no_args() {
let md = ":::subscribe\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Subscribe(args) => {
assert!(args.placeholder.is_none());
assert!(args.button.is_none());
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn extracts_subscribe_block_with_multi_line_attrs() {
let md = r#":::subscribe {
placeholder="you@domain.com"
button="Request access"
}
:::
"#;
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Subscribe(args) => {
assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
assert_eq!(args.button.as_deref(), Some("Request access"));
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn subscribe_legacy_body_keys_no_longer_parsed() {
let md = ":::subscribe\ndescription: Get updates\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Subscribe(args) => {
assert!(args.placeholder.is_none(), "old description body must not populate placeholder");
assert!(args.button.is_none());
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn subscribe_inside_code_fence_is_not_extracted() {
let md = "```\n:::subscribe\ndescription: doc\n:::\n```\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty());
assert!(result.markdown_with_placeholders.contains(":::subscribe"));
}
#[test]
fn subscribe_inside_tilde_fence_is_not_extracted() {
let md = "~~~\n:::subscribe\n:::\n~~~\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty());
}
#[test]
fn unclosed_subscribe_block_emits_verbatim() {
let md = ":::subscribe\nbutton: Go\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty());
assert!(result.markdown_with_placeholders.contains(":::subscribe"));
}
#[test]
fn extracts_hero_block_with_body_image_typed() {
let md = ":::hero\n![[bg.jpg]]\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "bg.jpg"),
_ => panic!("expected Unresolved bg.jpg"),
},
_ => panic!("expected Hero"),
}
assert!(!result.markdown_with_placeholders.contains(":::hero"));
}
#[test]
fn extracts_multiple_subscribes_with_increasing_indices() {
let md = ":::subscribe\ndescription: a\n:::\n\nsome text\n\n:::subscribe\nbutton: b\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 2);
assert_eq!(result.extracted[0].index, 0);
assert_eq!(result.extracted[1].index, 1);
assert!(result
.markdown_with_placeholders
.contains(&placeholder_for(&result.nonce, 0)));
assert!(result
.markdown_with_placeholders
.contains(&placeholder_for(&result.nonce, 1)));
}
#[test]
fn parse_placeholder_round_trips_index() {
let nonce = "deadbeef";
for index in [0, 1, 5, 99] {
let s = placeholder_for(nonce, index);
assert_eq!(parse_placeholder(nonce, &s), Some(index));
}
}
#[test]
fn parse_placeholder_rejects_non_placeholder_html() {
let nonce = "deadbeef";
assert!(parse_placeholder(nonce, "<div>hi</div>").is_none());
assert!(parse_placeholder(nonce, "<!--just a comment-->").is_none());
}
#[test]
fn parse_placeholder_rejects_wrong_nonce() {
let s = placeholder_for("aaaa1111", 5);
assert_eq!(parse_placeholder("bbbb2222", &s), None);
}
#[test]
fn extract_uses_content_derived_nonce() {
let md = ":::subscribe\n:::\n";
let r1 = extract_shortcodes(md);
let r2 = extract_shortcodes(md);
assert_eq!(r1.nonce, r2.nonce);
let r3 = extract_shortcodes(":::subscribe\ndescription: x\n:::\n");
assert_ne!(r1.nonce, r3.nonce);
}
#[test]
fn nonce_makes_authored_collision_inert() {
let md = ":::subscribe\n:::\n\nLook: <!--MOSS_SC_00000000_0-->\n";
let result = extract_shortcodes(md);
assert_ne!(result.nonce, "00000000");
assert!(result
.markdown_with_placeholders
.contains("MOSS_SC_00000000_0"));
}
#[test]
fn parse_shortcode_opener_recognizes_simple_name() {
assert_eq!(
parse_shortcode_opener(":::subscribe"),
Some((3, "subscribe", ""))
);
}
#[test]
fn parse_shortcode_opener_extracts_args() {
assert_eq!(
parse_shortcode_opener(":::grid 3 1:2:1"),
Some((3, "grid", "3 1:2:1"))
);
}
#[test]
fn parse_shortcode_opener_recognizes_quadruple_colon() {
assert_eq!(
parse_shortcode_opener("::::buttons"),
Some((4, "buttons", ""))
);
}
#[test]
fn parse_shortcode_opener_rejects_two_colons() {
assert!(parse_shortcode_opener("::name").is_none());
}
#[test]
fn extracts_quadruple_colon_buttons() {
let md = "::::buttons\n[Tickets](go/)\n::::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 1);
assert_eq!(args.items[0].text, "Tickets");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_grid_with_nested_buttons_via_arity() {
let md = ":::grid 2\n::::buttons\n[Tickets](go/)\n::::\n+++\nfooter cell\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 2);
assert_eq!(grid.cells.len(), 2);
let has_typed_buttons = grid.cells[0].iter().any(|b| matches!(
b,
Block::Shortcode(Shortcode::Buttons(args)) if args.items.len() == 1
&& args.items[0].text == "Tickets"
));
assert!(has_typed_buttons, "expected typed Buttons in cell[0]; got {:?}", grid.cells[0]);
let has_footer_para = grid.cells[1].iter().any(|b| matches!(
b,
Block::Paragraph(inlines) if inlines.iter().any(|i| matches!(
i,
super::super::node::Inline::Text(t) if t.contains("footer cell")
))
));
assert!(has_footer_para, "expected footer paragraph in cell[1]; got {:?}", grid.cells[1]);
}
other => panic!("expected Grid, got {other:?}"),
}
assert!(!result.markdown_with_placeholders.contains(":::grid 2"));
}
#[test]
fn arity_mismatch_does_not_close_block() {
let md = "::::buttons\n[t](u)\n:::\n[t2](u2)\n::::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 2);
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_buttons_block_with_one_link() {
let md = ":::buttons\n[Documentation](docs/)\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert!(args.classes.is_empty());
assert_eq!(args.items.len(), 1);
assert_eq!(args.items[0].text, "Documentation");
match &args.items[0].url {
Url::Unresolved(s) => assert_eq!(s, "docs/"),
_ => panic!("expected Unresolved"),
}
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_buttons_block_with_multiple_links() {
let md = ":::buttons\n[Docs](docs/)\n[GitHub](https://github.com)\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 2);
assert_eq!(args.items[0].text, "Docs");
assert_eq!(args.items[1].text, "GitHub");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_buttons_block_with_class_attrs() {
let md = ":::buttons {.primary .large}\n[Go](go/)\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.classes, "primary large");
assert_eq!(args.items.len(), 1);
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_buttons_with_moss_resolved_url_intact() {
let md = ":::buttons\n[Docs](moss-resolved:docs/index.md)\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => match &args.items[0].url {
Url::Unresolved(s) => assert_eq!(s, "moss-resolved:docs/index.md"),
_ => panic!("expected Unresolved"),
},
_ => panic!("expected Buttons"),
}
}
#[test]
fn buttons_skips_non_link_lines() {
let md = ":::buttons\nNot a link, just text.\n[Real](real/)\n\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 1);
assert_eq!(args.items[0].text, "Real");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn buttons_inside_code_fence_is_not_extracted() {
let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty());
}
#[test]
fn extract_markdown_link_rejects_text_with_close_bracket() {
let md = ":::buttons\n[a]b](u)\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => assert!(args.items.is_empty()),
_ => panic!("expected Buttons"),
}
}
#[test]
fn extract_markdown_link_requires_trailing_paren() {
let md = ":::buttons\n[t](u) <!-- trailing -->\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => assert!(args.items.is_empty()),
_ => panic!("expected Buttons"),
}
}
#[test]
fn close_fence_with_trailing_whitespace_is_recognized() {
let md = ":::subscribe\nbutton: x\n::: \n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
}
#[test]
fn is_close_fence_handles_multibyte_utf8_lines() {
assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 3));
assert!(!is_close_fence("[申请测试版](#青苔正在封闭测试)", 4));
assert!(!is_close_fence("中文内容", 3));
assert!(!is_close_fence("日本語", 3));
assert!(is_close_fence(":::", 3));
assert!(is_close_fence("::::", 4));
}
#[test]
fn extract_shortcodes_handles_buttons_with_cjk_link_text() {
let md = ":::buttons\n[申请测试版](#青苔正在封闭测试)\n[文档](docs/)\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 2);
assert_eq!(args.items[0].text, "申请测试版");
match &args.items[0].url {
Url::Unresolved(s) => assert_eq!(s, "#青苔正在封闭测试"),
_ => panic!("expected Unresolved"),
}
assert_eq!(args.items[1].text, "文档");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extract_shortcodes_does_not_panic_on_arbitrary_cjk_content() {
let md = "# 标题\n\n中文段落,混合 English 单词。\n\n:::buttons\n[申请测试版](#锚点)\n:::\n\n## 二级标题\n\n更多内容。\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
}
#[test]
fn close_fence_with_trailing_text_is_not_recognized() {
let md = ":::buttons\n[a](u)\n::: more text\n[b](v)\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 2);
assert_eq!(args.items[0].text, "a");
assert_eq!(args.items[1].text, "b");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_gallery_with_bare_paths() {
let md = ":::gallery\nphoto1.jpg\nphoto2.png\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => {
assert!(args.columns.is_none());
assert_eq!(args.items.len(), 2);
assert_eq!(args.items[0].alt, "");
match &args.items[0].src {
Url::Unresolved(s) => assert_eq!(s, "photo1.jpg"),
_ => panic!("expected Unresolved"),
}
}
_ => panic!("expected Gallery"),
}
}
#[test]
fn extracts_gallery_with_columns_arg() {
let md = ":::gallery 4\na.jpg\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => assert_eq!(args.columns, Some(4)),
_ => panic!("expected Gallery"),
}
}
#[test]
fn extracts_gallery_with_classes() {
let md = ":::gallery 3 {.showcase}\na.jpg\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => {
assert_eq!(args.columns, Some(3));
assert_eq!(args.classes, "showcase");
}
_ => panic!("expected Gallery"),
}
}
#[test]
fn extracts_gallery_with_markdown_image_syntax() {
let md = ":::gallery\n\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => {
assert_eq!(args.items[0].alt, "A photo");
match &args.items[0].src {
Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
_ => panic!("expected Unresolved"),
}
}
_ => panic!("expected Gallery"),
}
}
#[test]
fn extracts_gallery_with_pipe_attrs() {
let md = ":::gallery\nphoto.jpg|cover top\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => {
assert_eq!(args.items[0].attrs, "cover top");
match &args.items[0].src {
Url::Unresolved(s) => assert_eq!(s, "photo.jpg"),
_ => panic!("expected Unresolved"),
}
}
_ => panic!("expected Gallery"),
}
}
#[test]
fn gallery_skips_blank_lines() {
let md = ":::gallery\n\na.jpg\n\nb.jpg\n\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => assert_eq!(args.items.len(), 2),
_ => panic!("expected Gallery"),
}
}
#[test]
fn extracts_buttons_with_multi_line_attrs() {
let md = ":::buttons {\n .primary\n}\n[Go](go/)\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.classes, "primary");
assert_eq!(args.items.len(), 1);
assert_eq!(args.items[0].text, "Go");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn extracts_gallery_with_multi_line_attrs() {
let md = ":::gallery {\n .showcase\n}\nphoto.jpg\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Gallery(args) => {
assert_eq!(args.classes, "showcase");
assert_eq!(args.items.len(), 1);
}
_ => panic!("expected Gallery"),
}
}
#[test]
fn multi_line_attrs_with_quoted_brace_inside() {
let md = ":::buttons {\n .a\n .b\n}\n[Go](go/)\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.classes, "a b");
}
_ => panic!("expected Buttons"),
}
}
#[test]
fn css_region_unnamed_emits_div_wrapper() {
let md = ":::{.tagline}\nA new way to publish.\n:::\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty());
assert!(result
.markdown_with_placeholders
.contains("<div class=\"tagline\">"));
assert!(result
.markdown_with_placeholders
.contains("A new way to publish."));
assert!(result.markdown_with_placeholders.contains("</div>"));
}
#[test]
fn css_region_with_id_only() {
let md = ":::{#intro}\nIntro prose.\n:::\n";
let result = extract_shortcodes(md);
assert!(result
.markdown_with_placeholders
.contains("<div id=\"intro\">"));
}
#[test]
fn css_region_with_classes_and_id() {
let md = ":::{.callout #important}\nWatch out.\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("<div"));
assert!(out.contains("class=\"callout\""));
assert!(out.contains("id=\"important\""));
}
#[test]
fn css_region_emits_blank_lines_around_body_for_markdown_processing() {
let md = ":::{.foo}\n# Heading\n:::\n";
let out = extract_shortcodes(md).markdown_with_placeholders;
assert!(out.contains(">\n\n# Heading"));
assert!(out.contains("# Heading\n\n</div>"));
}
#[test]
fn css_region_no_warning_emitted() {
let md = ":::{.foo}\nbody\n:::\n";
assert!(extract_shortcodes(md).warnings.is_empty());
}
#[test]
fn unknown_name_renders_fallback_wrapper() {
let md = ":::nope {.extra}\nbody text\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("class=\"moss-unknown-shortcode extra\""));
assert!(out.contains(r#"data-name="nope""#));
assert!(out.contains("body text"));
}
#[test]
fn unknown_name_emits_build_warning() {
let md = ":::nope\n:::\n";
let warnings = extract_shortcodes(md).warnings;
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("nope"));
}
#[test]
fn unknown_name_html_escapes_data_name() {
let md = ":::weird-name\nbody\n:::\n";
let out = extract_shortcodes(md).markdown_with_placeholders;
assert!(out.contains(r#"data-name="weird-name""#));
}
#[test]
fn extracts_grid_with_positional_columns() {
let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 2);
assert!(grid.ratio.is_none());
assert_eq!(grid.cells.len(), 2);
assert_paragraph_text(&grid.cells[0], "cell A");
assert_paragraph_text(&grid.cells[1], "cell B");
}
other => panic!("expected Grid, got {other:?}"),
}
}
fn assert_paragraph_text(cell_blocks: &[Block], expected: &str) {
if cell_blocks.is_empty() && expected.is_empty() {
return;
}
let para = match cell_blocks {
[Block::Paragraph(inlines)] => inlines,
other => panic!(
"expected single Paragraph cell with text {expected:?}, got: {other:?}"
),
};
let mut text = String::new();
for inline in para {
match inline {
super::super::node::Inline::Text(t) => text.push_str(t),
super::super::node::Inline::Code(c) => text.push_str(c),
_ => {}
}
}
assert_eq!(text, expected, "cell text mismatch");
}
#[test]
fn extracts_grid_with_positional_ratio() {
let md = ":::grid 2 1:2\nleft\n---\nright\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 2);
assert_eq!(grid.ratio.as_deref(), Some("1:2"));
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_with_cols_attr_integer() {
let md = ":::grid {cols=3}\nA\n+++\nB\n+++\nC\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 3);
assert_eq!(grid.cells.len(), 3);
assert_paragraph_text(&grid.cells[0], "A");
assert_paragraph_text(&grid.cells[1], "B");
assert_paragraph_text(&grid.cells[2], "C");
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_with_cols_attr_ratio_implies_count() {
let md = ":::grid {cols=1:1:2}\nA\n+++\nB\n+++\nC\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 3, "ratio length implies column count");
assert_eq!(grid.ratio.as_deref(), Some("1:1:2"));
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_accepts_plus_plus_plus_divider() {
let md = ":::grid 2\nA\n+++\nB\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.cells.len(), 2);
assert_paragraph_text(&grid.cells[0], "A");
assert_paragraph_text(&grid.cells[1], "B");
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_with_classes() {
let md = ":::grid 3 {.work-cards .featured}\nA\n---\nB\n---\nC\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 3);
assert_eq!(grid.classes, "work-cards featured");
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_single_cell_no_separator() {
let md = ":::grid 1\nonly cell\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.columns, 1);
assert_eq!(grid.cells.len(), 1);
assert_paragraph_text(&grid.cells[0], "only cell");
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_with_empty_middle_cell() {
let md = ":::grid 3\nA\n+++\n+++\nC\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.cells.len(), 3);
assert_paragraph_text(&grid.cells[0], "A");
assert!(grid.cells[1].is_empty(), "empty cell should have no blocks");
assert_paragraph_text(&grid.cells[2], "C");
}
_ => panic!("expected Grid"),
}
}
#[test]
fn nested_grid_via_arity_is_unsupported_authoring() {
let md = "::::grid 1\n:::grid 2\nA\n+++\nB\n:::\n::::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Grid(outer) => {
assert_eq!(outer.columns, 1);
assert!(outer.cells.len() >= 2,
"outer's body got split by inner's +++, demonstrating the \
unsupported-nesting failure mode");
}
_ => panic!("expected Grid"),
}
}
#[test]
fn extracts_grid_with_compound_link_cell_typed_as_link_card() {
let md = ":::grid 2 {.work-cards}\n[![[poster.jpg]]\n#### Title\nbody](/url)\n+++\n[Card 2](/url2)\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Grid(grid) => {
assert_eq!(grid.classes, "work-cards");
assert_eq!(grid.cells.len(), 2);
match &grid.cells[0][..] {
[Block::LinkCard { url, children }] => {
match url {
Url::Unresolved(u) => assert_eq!(u, "/url"),
_ => panic!("expected Unresolved /url"),
}
assert!(!children.is_empty(), "compound-link inner blocks empty");
}
other => panic!("expected single LinkCard cell, got {other:?}"),
}
match &grid.cells[1][..] {
[Block::LinkCard { url, .. }] => match url {
Url::Unresolved(u) => assert_eq!(u, "/url2"),
_ => panic!("expected Unresolved /url2"),
},
other => panic!("expected LinkCard for cell[1], got {other:?}"),
}
}
_ => panic!("expected Grid"),
}
}
#[test]
fn toc_now_renders_as_unknown_shortcode() {
let md = ":::toc\n:::\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty(), "toc is no longer typed");
assert_eq!(result.warnings.len(), 1, "unknown-name fallback warning");
assert!(result.warnings[0].contains("toc"));
assert!(result
.markdown_with_placeholders
.contains(r#"data-name="toc""#));
}
#[test]
fn extracts_hero_block_with_no_image() {
let md = ":::hero\n# A House of Daowu\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1, "hero should be extracted");
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
assert!(args.image.is_none());
assert_eq!(args.overlay_text, "# A House of Daowu");
}
other => panic!("expected Hero, got {other:?}"),
}
assert!(!result.markdown_with_placeholders.contains(":::hero"));
}
#[test]
fn extracts_hero_block_with_wikilink_body_image() {
let md = ":::hero\n![[panorama.jpg]]\n# Welcome\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "panorama.jpg"),
other => panic!("expected Unresolved url, got {other:?}"),
}
assert_eq!(args.overlay_text, "# Welcome");
}
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn extracts_hero_block_with_image_attr() {
let md = ":::hero {image=cover.jpg}\n# Title\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
other => panic!("expected Unresolved, got {other:?}"),
}
assert_eq!(args.overlay_text, "# Title");
}
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn extracts_hero_block_with_image_attr_and_pipe_attrs() {
let md = r#":::hero {image="cover.jpg|contain top"}
:::
"#;
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "cover.jpg"),
_ => panic!("expected Unresolved"),
}
assert_eq!(args.attrs, "contain top");
}
_ => panic!("expected Hero"),
}
}
#[test]
fn extracts_hero_block_with_classes() {
let md = ":::hero {.full .center}\n# Title\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
assert_eq!(args.classes, "full center");
}
_ => panic!("expected Hero"),
}
}
#[test]
fn extracts_hero_block_with_directive_line_path() {
let md = ":::hero ./assets/header.png\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "./assets/header.png"),
other => panic!("expected Unresolved ./assets/header.png, got {other:?}"),
},
_ => panic!("expected Hero"),
}
}
#[test]
fn extracts_hero_block_with_directive_line_path_and_pipe_attrs() {
let md = ":::hero ./bg.jpg|contain top\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
_ => panic!("expected Unresolved"),
}
assert_eq!(args.attrs, "contain top");
}
_ => panic!("expected Hero"),
}
}
#[test]
fn extracts_hero_block_with_directive_line_path_and_classes() {
let md = ":::hero ./bg.jpg {.landing}\n# Welcome\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
match &args.image {
Some(Url::Unresolved(s)) => assert_eq!(s, "./bg.jpg"),
_ => panic!("expected Unresolved"),
}
assert_eq!(args.classes, "landing");
assert_eq!(args.overlay_text, "# Welcome");
}
_ => panic!("expected Hero"),
}
}
#[test]
fn nested_css_region_outer_closes_at_first_inner_close() {
let md = ":::{.outer}\n:::{.inner}\nbody\n:::\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("<div class=\"outer\""));
assert!(out.contains(":::{.inner}"));
}
#[test]
fn nested_css_region_higher_arity_outer_recurses_into_inner() {
let md = "::::{.outer}\n:::{.inner}\nbody\n:::\n::::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("<div class=\"outer\""));
assert!(out.contains("<div class=\"inner\""));
assert!(!out.contains(":::{.inner}"));
}
#[test]
fn css_region_containing_typed_subscribe_is_not_recursively_extracted() {
let md = ":::{.wrapper}\n:::subscribe\n:::\n:::\n";
let result = extract_shortcodes(md);
assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
assert!(result.extracted.is_empty());
}
#[test]
fn higher_arity_wrapper_recursively_extracts_typed_subscribe() {
let md = "::::{.wrapper}\n:::subscribe\n:::\n::::\n";
let result = extract_shortcodes(md);
assert!(result.markdown_with_placeholders.contains("<div class=\"wrapper\""));
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Subscribe(_) => {}
_ => panic!("expected Subscribe"),
}
assert!(!result.markdown_with_placeholders.contains(":::subscribe"));
}
#[test]
fn lower_arity_outer_wraps_higher_arity_typed_inner() {
let md = ":::{.support-band}\n## Title\n\n::::buttons {.inverted}\n[Support Us](/support)\n::::\n*footnote*\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("<div class=\"support-band\""));
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(args) => {
assert_eq!(args.items.len(), 1);
}
_ => panic!("expected Buttons"),
}
assert!(!out.contains("::::buttons"));
assert!(!out.contains("::::"));
}
#[test]
fn lower_arity_outer_wraps_grid_with_buttons_in_cell() {
let md = "::: {.hero-split}\n::::grid 2 {.no-cards}\nleft\n+++\nright\n::::\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("<div class=\"hero-split\""));
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Grid(_) => {}
_ => panic!("expected Grid"),
}
assert!(!out.contains("::::grid"));
}
#[test]
fn unknown_name_body_recursively_extracts_typed_inner() {
let md = ":::buttosn\n::::buttons\n[a](u)\n::::\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains("data-name=\"buttosn\""));
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Buttons(_) => {}
_ => panic!("expected Buttons"),
}
}
#[test]
fn unknown_name_with_plus_plus_plus_in_body_passes_through() {
let md = ":::buttosn\n[a](u)\n+++\n[b](v)\n:::\n";
let result = extract_shortcodes(md);
let out = &result.markdown_with_placeholders;
assert!(out.contains(r#"data-name="buttosn""#));
assert!(out.contains("[a](u)"));
assert!(out.contains("+++"));
assert!(out.contains("[b](v)"));
}
#[test]
fn parse_shortcode_opener_recognizes_empty_name_with_attrs() {
assert_eq!(
parse_shortcode_opener(":::{.tagline}"),
Some((3, "", "{.tagline}"))
);
}
#[test]
fn parse_shortcode_opener_rejects_just_colons() {
assert!(parse_shortcode_opener(":::").is_none());
assert!(parse_shortcode_opener("::: ").is_none());
}
#[test]
fn unclosed_multi_line_attrs_block_emits_verbatim() {
let md = ":::buttons {\n .primary\n[Go](go/)\n:::\n";
let result = extract_shortcodes(md);
assert!(result.extracted.is_empty() || matches!(result.extracted[0].shortcode, Shortcode::Buttons(_)));
}
#[test]
fn grid_legacy_dash_emits_deprecation_warning() {
let md = ":::grid 2\ncell A\n---\ncell B\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("deprecated"));
assert!(result.warnings[0].contains("+++"));
}
#[test]
fn grid_plus_plus_plus_no_deprecation_warning() {
let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
let result = extract_shortcodes(md);
assert!(result.warnings.is_empty());
}
#[test]
fn hero_priority3_body_image_emits_deprecation_warning() {
let md = ":::hero\nphoto.jpg\n# Title\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("deprecated"));
assert!(result.warnings[0].contains("image="));
}
#[test]
fn hero_explicit_image_attr_no_deprecation_warning() {
let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
let result = extract_shortcodes(md);
assert!(result.warnings.is_empty());
}
fn first_extracted(md: &str) -> Shortcode {
let result = extract_shortcodes(md);
result
.extracted
.into_iter()
.next()
.expect("at least one shortcode")
.shortcode
}
#[test]
fn hero_with_full_flag_sets_width_screen() {
let md = ":::hero {image=photo.jpg full}\n# Title\n:::\n";
match first_extracted(md) {
Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_with_screen_flag_sets_width_screen() {
let md = ":::hero {image=photo.jpg screen}\n# Title\n:::\n";
match first_extracted(md) {
Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("screen")),
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_with_wide_flag_sets_width_wide() {
let md = ":::hero {image=photo.jpg wide}\n# Title\n:::\n";
match first_extracted(md) {
Shortcode::Hero(h) => assert_eq!(h.width.as_deref(), Some("wide")),
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_without_width_flag_leaves_width_none() {
let md = ":::hero {image=photo.jpg}\n# Title\n:::\n";
match first_extracted(md) {
Shortcode::Hero(h) => assert!(h.width.is_none(), "got {:?}", h.width),
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_mobile_overlay_attr_is_parsed() {
let md = ":::hero {image=hero.jpg mobile=overlay}\n# Title\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
assert_eq!(args.mobile.as_deref(), Some("overlay"));
}
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_without_mobile_attr_has_none() {
let md = ":::hero {image=hero.jpg}\n# Title\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
assert!(args.mobile.is_none());
}
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_mobile_overlay_with_body_image_fallback() {
let md = ":::hero {mobile=overlay}\n![[bg.jpg]]\n# Title\n:::\n";
let result = extract_shortcodes(md);
match &result.extracted[0].shortcode {
Shortcode::Hero(args) => {
assert_eq!(args.mobile.as_deref(), Some("overlay"));
assert!(args.image.is_some());
}
other => panic!("expected Hero, got {other:?}"),
}
}
#[test]
fn hero_unknown_mobile_value_emits_warning() {
let md = ":::hero {image=hero.jpg mobile=fullscreen}\n# Title\n:::\n";
let result = extract_shortcodes(md);
assert!(
result.warnings.iter().any(|w| w.contains("unrecognized") && w.contains("fullscreen")),
"expected warning for unknown mobile value, got: {:?}",
result.warnings,
);
assert_eq!(result.extracted.len(), 1);
}
#[test]
fn placeholder_preserves_block_line_count_for_source_line_accuracy() {
let md = "# Title\n\n:::grid 3\n[\n\n](/x)\n+++\n[\n\n](/y)\n:::\n\n## After\n";
let input_lines = md.lines().count();
let result = extract_shortcodes(md);
assert_eq!(
result.markdown_with_placeholders.lines().count(),
input_lines,
"placeholder must preserve the block's line count; got:\n{}",
result.markdown_with_placeholders
);
let after_line = result
.markdown_with_placeholders
.lines()
.position(|l| l.contains("## After"))
.map(|p| p + 1);
assert_eq!(after_line, Some(13), "## After should stay on line 13");
}
#[test]
fn gallery_with_page_flag_sets_width_page() {
let md = ":::gallery 3 {page}\nphoto.jpg\n:::\n";
match first_extracted(md) {
Shortcode::Gallery(g) => assert_eq!(g.width.as_deref(), Some("page")),
other => panic!("expected Gallery, got {other:?}"),
}
}
#[test]
fn gallery_without_width_flag_leaves_width_none() {
let md = ":::gallery 3\nphoto.jpg\n:::\n";
match first_extracted(md) {
Shortcode::Gallery(g) => assert!(g.width.is_none()),
other => panic!("expected Gallery, got {other:?}"),
}
}
#[test]
fn grid_with_wide_flag_sets_width_wide() {
let md = ":::grid {cols=2 wide}\ncell A\n+++\ncell B\n:::\n";
match first_extracted(md) {
Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("wide")),
other => panic!("expected Grid, got {other:?}"),
}
}
#[test]
fn grid_with_full_flag_normalizes_to_screen() {
let md = ":::grid {cols=2 full}\ncell A\n+++\ncell B\n:::\n";
match first_extracted(md) {
Shortcode::Grid(g) => assert_eq!(g.width.as_deref(), Some("screen")),
other => panic!("expected Grid, got {other:?}"),
}
}
#[test]
fn grid_without_width_flag_leaves_width_none() {
let md = ":::grid 2\ncell A\n+++\ncell B\n:::\n";
match first_extracted(md) {
Shortcode::Grid(g) => assert!(g.width.is_none()),
other => panic!("expected Grid, got {other:?}"),
}
}
#[test]
fn parses_recent_with_since_and_count() {
let (sc, warns) = parse_shortcode_block(
"recent",
r#"{since="2026-04-01" count="5"}"#,
"",
&ParseConfig::default(),
);
assert!(warns.is_empty());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => {
assert_eq!(args.since.as_deref(), Some("2026-04-01"));
assert_eq!(args.count, Some(5));
assert!(args.last.is_none());
assert!(args.fallback_markdown.is_empty());
}
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn parses_recent_with_last_window() {
let (sc, _) = parse_shortcode_block("recent", r#"{last="month"}"#, "", &ParseConfig::default());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => {
assert_eq!(args.last.as_deref(), Some("month"));
assert!(args.since.is_none());
assert!(args.count.is_none());
}
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn captures_recent_body_as_fallback_markdown() {
let body = "No posts yet. [Follow along](/).";
let (sc, _) = parse_shortcode_block("recent", "", body, &ParseConfig::default());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => {
assert_eq!(args.fallback_markdown, body);
}
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn recent_with_no_args_yields_all_none() {
let (sc, warns) = parse_shortcode_block("recent", "", "", &ParseConfig::default());
assert!(warns.is_empty());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => {
assert!(args.since.is_none());
assert!(args.last.is_none());
assert!(args.count.is_none());
assert!(args.fallback_markdown.is_empty());
}
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn parses_recent_with_all_three_attrs() {
let (sc, warns) = parse_shortcode_block(
"recent",
r#"{since="2026-01-01" last="month" count="3"}"#,
"",
&ParseConfig::default(),
);
assert!(warns.is_empty());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => {
assert_eq!(args.since.as_deref(), Some("2026-01-01"));
assert_eq!(args.last.as_deref(), Some("month"));
assert_eq!(args.count, Some(3));
}
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn recent_with_malformed_count_yields_none_count() {
let (sc, _) = parse_shortcode_block("recent", r#"{count="lots"}"#, "", &ParseConfig::default());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => assert!(args.count.is_none()),
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn recent_body_is_trimmed() {
let (sc, _) = parse_shortcode_block("recent", "", "\n hello world \n\n", &ParseConfig::default());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Recent(args) => assert_eq!(args.fallback_markdown, "hello world"),
other => panic!("expected Recent, got {other:?}"),
}
}
#[test]
fn parses_apply_directive() {
use super::super::shortcode::ShortcodeKind;
use super::super::visit::has_shortcode_recursive;
let doc = crate::ast::parse(":::apply\n:::\n");
assert!(
has_shortcode_recursive(&doc, ShortcodeKind::Apply),
"expected an Apply shortcode"
);
}
#[test]
fn apply_parse_bare_has_none_overrides() {
let (sc, warns) = parse_shortcode_block("apply", "", "", &ParseConfig::default());
assert!(warns.is_empty());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Apply(args) => {
assert!(args.placeholder.is_none());
assert!(args.button.is_none());
}
other => panic!("expected Apply, got {other:?}"),
}
}
#[test]
fn apply_parse_with_overrides() {
let (sc, _) = parse_shortcode_block("apply", r#"{placeholder="email" button="申请"}"#, "", &ParseConfig::default());
match sc.expect("expected Some(Shortcode)") {
Shortcode::Apply(args) => {
assert_eq!(args.placeholder.as_deref(), Some("email"));
assert_eq!(args.button.as_deref(), Some("申请"));
}
other => panic!("expected Apply, got {other:?}"),
}
}
#[test]
fn extracts_recent_end_to_end_with_sentinel() {
let md = ":::recent {since=\"2026-04-01\" count=\"5\"}\nNo posts yet.\n:::\n";
let result = extract_shortcodes(md);
assert_eq!(result.extracted.len(), 1);
match &result.extracted[0].shortcode {
Shortcode::Recent(args) => {
assert_eq!(args.since.as_deref(), Some("2026-04-01"));
assert_eq!(args.count, Some(5));
assert_eq!(args.fallback_markdown, "No posts yet.");
}
other => panic!("expected Recent, got {other:?}"),
}
assert!(!result.markdown_with_placeholders.contains(":::recent"));
assert!(result
.markdown_with_placeholders
.contains(&placeholder_for(&result.nonce, 0)));
}
}