use super::attrs::gather_multi_line_attrs;
use super::cells::split_cells;
use super::node::Block;
use super::parser::{parse_fragment_with_config, ParseConfig};
use super::shortcode::{
ApplyShortcode, ButtonItem, ButtonsShortcode, GalleryItem, GalleryShortcode, GridShortcode,
RecentShortcode, Shortcode, SubscribeShortcode,
};
use super::url::Url;
use crate::resolve::md_extract::{line_table, AssetPathSpan, MediaLineSpan, PathContainer};
#[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, fragment_warnings) = super::extract_hero::parse_hero(args, body, config);
let mut warns = fragment_warnings;
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, fragment_warnings) = parse_grid(args, body, config);
let mut warns = fragment_warnings;
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, Vec<String>) {
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 mut fragment_warnings: Vec<String> = Vec::new();
let cells: Vec<Vec<Block>> = raw_cells
.iter()
.map(|raw| {
let (blocks, warns) = parse_cell_to_blocks(raw, config);
fragment_warnings.extend(warns);
blocks
})
.collect();
(
GridShortcode {
columns,
ratio,
classes,
cells,
width,
},
found_legacy_dash,
fragment_warnings,
)
}
fn parse_cell_to_blocks(raw: &str, config: &ParseConfig) -> (Vec<Block>, Vec<String>) {
if let Some((url, inner, trailing)) = detect_compound_link(raw) {
let inner_trimmed = inner.trim();
let trailing_trimmed = trailing.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);
let doc = parse_fragment_with_config(&linkified, config);
return (doc.blocks, doc.warnings);
}
let inner_doc = parse_fragment_with_config(inner_trimmed, config);
let mut warnings = inner_doc.warnings;
let mut blocks = vec![Block::LinkCard {
url: Url::unresolved(url),
children: inner_doc.blocks,
}];
if !trailing_trimmed.is_empty() {
let trailing_doc = parse_fragment_with_config(trailing_trimmed, config);
blocks.extend(trailing_doc.blocks);
warnings.extend(trailing_doc.warnings);
}
return (blocks, warnings);
}
if let Some(url) = detect_bare_url_cell(raw) {
let linkified = format!("[]({})", url);
let doc = parse_fragment_with_config(&linkified, config);
return (doc.blocks, doc.warnings);
}
let doc = parse_fragment_with_config(raw, config);
(doc.blocks, doc.warnings)
}
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, String)> {
let stripped = cell_text.trim();
if !stripped.starts_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.get(close_paren + 1..)?;
let inner = stripped.get(1..close_bracket)?;
let trailing = if tail.chars().all(char::is_whitespace) {
""
} else {
let blank_line_before_trailing = {
let mut newlines = 0;
for c in tail.chars() {
if c == '\n' {
newlines += 1;
} else if !c.is_whitespace() {
break;
}
}
newlines >= 2
};
let candidate = tail.trim();
if !blank_line_before_trailing || !inner.trim_start().starts_with("![[") {
return None;
}
candidate
};
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.get(close_bracket + 2..close_paren)?;
Some((url.to_string(), inner.to_string(), trailing.to_string()))
}
fn split_grid_cells(body: &str) -> (Vec<String>, bool) {
if body.is_empty() {
return (vec![String::new()], false);
}
let inert = crate::inert_regions::inert_lines(body);
let mut cells = Vec::new();
let mut current = String::new();
let mut first_line_in_cell = true;
let mut found_legacy_dash = false;
let mut nested_arities: Vec<usize> = Vec::new();
for (idx, line) in body.split_inclusive('\n').enumerate() {
let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
let trimmed = content_no_eol.trim();
let live = !inert.get(idx).copied().unwrap_or(false);
if live {
if let Some((inner_arity, _, _)) = parse_shortcode_opener(trimmed) {
nested_arities.push(inner_arity);
} else if let Some(&innermost) = nested_arities.last() {
if is_close_fence(trimmed, innermost) {
nested_arities.pop();
}
} else 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)
}
pub fn shortcode_asset_spans(source: &str) -> Vec<AssetPathSpan> {
let mask = crate::inert_regions::mask_inert(source);
let table = line_table(source);
let mask_lines: Vec<&str> = mask.lines().collect();
let mut out = Vec::new();
let mut i = 0;
while i < table.len() {
let Some(mline) = mask_lines.get(i) else { break };
let Some((arity, name, single_line_args)) = parse_shortcode_opener(mline.trim()) else {
i += 1;
continue;
};
let (_, opener_lines_consumed) =
gather_multi_line_attrs(single_line_args, &mask_lines[i + 1..]);
let body_start = i + 1 + opener_lines_consumed;
let mut close = None;
for j in body_start..table.len() {
if is_close_fence(mask_lines.get(j).map_or("", |l| l.trim()), arity) {
close = Some(j);
break;
}
}
let Some(j) = close else {
i += 1;
continue;
};
match name {
"gallery" => {
for k in body_start..j {
if let Some(span) = gallery_body_span(source, &table, k) {
out.push(span);
}
}
i = j + 1;
}
"hero" => {
super::extract_hero::hero_asset_spans(source, &mask, &table, i, body_start, j, &mut out);
i = j + 1;
}
_ => i += 1,
}
}
out
}
fn gallery_body_span(
source: &str,
table: &[(usize, usize, usize)],
k: usize,
) -> Option<AssetPathSpan> {
let (base, content_len, term_len) = *table.get(k)?;
#[allow(clippy::string_slice)]
let line = &source[base..base + content_len];
let it = gallery_item_span(line)?;
if !it.is_token && !super::extract_hero::is_bare_hero_media(&it.path) {
return None;
}
Some(AssetPathSpan {
path: crate::media::strip_wikilink(&it.path).to_string(),
attrs: it.value_attrs,
quote: None,
value: base + it.value.start..base + it.value.end,
outer: base..base + content_len + term_len,
container: PathContainer::GalleryBody,
})
}
pub(crate) fn gallery_item_span(line: &str) -> Option<MediaLineSpan> {
let lead = line.len() - line.trim_start().len();
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Some(inner) = trimmed.strip_prefix("![[").and_then(|s| s.strip_suffix("]]")) {
let (src_raw, attrs) = split_pipe(inner);
let inner_start = lead + 3;
return Some(MediaLineSpan {
path: src_raw.trim().to_string(),
alt: String::new(),
attrs: attrs.to_string(),
value: inner_start..inner_start + inner.len(),
value_attrs: attrs.to_string(),
is_token: true,
});
}
let (src_raw, attrs) = split_pipe(trimmed);
match parse_markdown_image(src_raw) {
Some((alt, path)) => {
let s2 = src_raw.trim();
let s2_lead = lead + (src_raw.len() - src_raw.trim_start().len());
let path_start = s2_lead + s2.find("](").map_or(0, |i| i + 2);
Some(MediaLineSpan {
value: path_start..path_start + path.len(),
path,
alt,
attrs: attrs.to_string(),
value_attrs: String::new(),
is_token: true,
})
}
None => {
let path = src_raw.trim().to_string();
let path_start = lead + (src_raw.len() - src_raw.trim_start().len());
let value_end = if attrs.is_empty() {
path_start + path.len()
} else {
lead + trimmed.len()
};
Some(MediaLineSpan {
value: path_start..value_end,
path,
alt: String::new(),
attrs: attrs.to_string(),
value_attrs: attrs.to_string(),
is_token: false,
})
}
}
}
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() {
if let Some(it) = gallery_item_span(line) {
items.push(GalleryItem {
src: Url::unresolved(it.path),
alt: it.alt,
attrs: it.attrs,
});
}
}
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 inert = crate::inert_regions::inert_lines(markdown);
let is_inert = |idx: usize| inert.get(idx).copied().unwrap_or(false);
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if is_inert(i) {
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;
let mut nested_same_arity: Option<&str> = None;
while j < lines.len() {
let body_trimmed = lines[j].trim();
if !is_inert(j) && is_close_fence(body_trimmed, arity) {
closed = true;
break;
}
if nested_same_arity.is_none() && !is_inert(j) {
if let Some((inner_arity, _, _)) = parse_shortcode_opener(body_trimmed) {
if inner_arity == arity {
nested_same_arity = Some(body_trimmed);
}
}
}
body_lines.push(lines[j]);
j += 1;
}
if !closed {
output.push_str(line);
output.push('\n');
i += 1;
continue;
}
if let Some(inner) = nested_same_arity {
warnings.push(nested_arity_warning(arity, trimmed, inner));
}
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 nested_arity_warning(arity: usize, outer_line: &str, inner_line: &str) -> String {
let colons = ":".repeat(arity);
let wider = ":".repeat(arity + 1);
let outer_short = clip_for_warning(outer_line);
let inner_short = clip_for_warning(inner_line);
format!(
"shortcode `{outer_short}` ends at the nested `{inner_short}` block's closing fence \
instead of its own, so everything after that point falls outside the block.\n\
A nested fence needs FEWER colons than the block around it: write `:{outer_short}` … \
`{wider}` for the outer block and leave the nested one as `{colons}`."
)
}
fn clip_for_warning(line: &str) -> String {
let mut s: String = line.chars().take(48).collect();
if line.chars().count() > 48 {
s.push('…');
}
s
}
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
}
pub(crate) 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)]
#[path = "shortcode_extract_tests.rs"]
mod tests;