use serde::{Deserialize, Serialize};
use crate::render_md;
use crate::types::{
Block, CalloutType, DecisionStatus, EmbedType, FormFieldType, RowState, SurfDoc,
ToolbarItem, Trend,
};
const MAX_SECTION_DEPTH: u32 = 8;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NativeBlock {
Markdown { content: String },
Callout {
callout_type: String,
title: Option<String>,
content: String,
},
Code {
language: Option<String>,
file_path: Option<String>,
content: String,
},
DataTable {
headers: Vec<String>,
rows: Vec<Vec<String>>,
sortable: bool,
},
Tasks { items: Vec<NativeTaskItem> },
Decision {
status: String,
date: Option<String>,
deciders: Vec<String>,
content: String,
},
Metric {
label: String,
value: String,
trend: Option<String>,
unit: Option<String>,
},
Summary { content: String },
Figure {
src: String,
caption: Option<String>,
alt: Option<String>,
},
Tabs { tabs: Vec<NativeTabPanel> },
Columns { columns: Vec<NativeColumnContent> },
Quote {
content: String,
attribution: Option<String>,
},
Cta {
label: String,
href: String,
primary: bool,
},
Testimonial {
content: String,
author: Option<String>,
role: Option<String>,
company: Option<String>,
},
Faq { items: Vec<NativeFaqItem> },
Details {
title: Option<String>,
open: bool,
content: String,
},
Divider { label: Option<String> },
Hero {
headline: Option<String>,
subtitle: Option<String>,
badge: Option<String>,
align: String,
image: Option<String>,
buttons: Vec<NativeHeroButton>,
content: String,
},
Features { cards: Vec<NativeFeatureCard> },
Steps { steps: Vec<NativeStepItem> },
Stats { items: Vec<NativeStatItem> },
Comparison {
headers: Vec<String>,
rows: Vec<Vec<String>>,
highlight: Option<String>,
},
Toc {
depth: u32,
entries: Vec<NativeTocEntry>,
},
BeforeAfter {
before_items: Vec<NativeBeforeAfterItem>,
after_items: Vec<NativeBeforeAfterItem>,
transition: Option<String>,
},
Pipeline { steps: Vec<NativePipelineStep> },
Form {
fields: Vec<NativeFormField>,
submit_label: String,
},
Gallery {
items: Vec<NativeGalleryItem>,
columns: u32,
},
SectionContainer {
bg: Option<String>,
headline: Option<String>,
subtitle: Option<String>,
children: Vec<NativeBlock>,
},
AppShell {
layout: String,
children: Vec<NativeBlock>,
},
Sidebar {
position: String,
collapsible: bool,
width: Option<u32>,
children: Vec<NativeBlock>,
},
Panel {
position: String,
resizable: bool,
height: Option<u32>,
desktop_only: bool,
children: Vec<NativeBlock>,
},
TabBar {
active: Option<String>,
items: Vec<NativeTabBarItem>,
},
TabContent {
tab: String,
children: Vec<NativeBlock>,
},
Toolbar {
items: Vec<NativeToolbarItem>,
},
Drawer {
name: String,
position: String,
width: Option<u32>,
trigger: Option<String>,
children: Vec<NativeBlock>,
},
Modal {
name: String,
title: Option<String>,
children: Vec<NativeBlock>,
},
CommandPalette {
trigger: Option<String>,
items: Vec<NativeCommandItem>,
},
CodeEditor {
lang: Option<String>,
source: Option<String>,
line_numbers: bool,
content: String,
},
BlockEditor {
source: Option<String>,
},
Terminal {
shell: Option<String>,
cwd: Option<String>,
},
NavTree {
source: Option<String>,
on_select: Option<String>,
on_rename: Option<String>,
on_delete: Option<String>,
},
Badge {
value: String,
color: Option<String>,
},
SuggestionChips {
source: Option<String>,
max: Option<u32>,
dismissible: bool,
},
ChatThread {
source: Option<String>,
on_action: Option<String>,
},
ChatInputSimple {
placeholder: Option<String>,
action: Option<String>,
},
Progress {
source: Option<String>,
steps: Vec<NativeProgressStep>,
},
LogStream {
source: Option<String>,
tail: Option<u32>,
},
ProblemList {
source: Option<String>,
},
Site {
name: Option<String>,
description: Option<String>,
accent: Option<String>,
font: Option<String>,
domain: Option<String>,
extras: Vec<String>,
},
Page {
route: String,
title: Option<String>,
layout: Option<String>,
children: Vec<NativeBlock>,
},
Nav {
logo: Option<String>,
items: Vec<NativeNavItem>,
},
HeroImage {
src: String,
alt: Option<String>,
},
Footer {
copyright: Option<String>,
sections: Vec<NativeFooterSection>,
social: Vec<NativeSocialLink>,
},
Embed {
src: String,
title: Option<String>,
embed_type: String,
},
PricingTable {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
ProductCard {
title: String,
subtitle: Option<String>,
badge: Option<String>,
badge_color: Option<String>,
body: String,
features: Vec<String>,
cta_label: Option<String>,
cta_href: Option<String>,
},
Chart {
chart_type: String,
source: String,
period: Option<String>,
},
Row {
icon: String,
title: String,
description: String,
href: Option<String>,
state: String,
},
InfoCard {
intent: String,
title: String,
subtitle: String,
summary: String,
image: Option<String>,
facts: Vec<NativeInfoFact>,
steps: Vec<String>,
state: String,
},
Diagram {
diagram_type: String,
title: Option<String>,
content: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeTaskItem {
pub done: bool,
pub text: String,
pub assignee: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeTabPanel {
pub label: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeColumnContent {
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeFaqItem {
pub question: String,
pub answer: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeInfoFact {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeFeatureCard {
pub title: String,
pub icon: Option<String>,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeStepItem {
pub title: String,
pub time: Option<String>,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeStatItem {
pub value: String,
pub label: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeTocEntry {
pub text: String,
pub id: String,
pub level: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeBeforeAfterItem {
pub label: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativePipelineStep {
pub label: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeFormField {
pub label: String,
pub name: String,
pub field_type: String,
pub required: bool,
pub placeholder: Option<String>,
pub options: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeGalleryItem {
pub src: String,
pub caption: Option<String>,
pub alt: Option<String>,
pub category: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeTabBarItem {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeToolbarItem {
pub kind: String,
pub label: Option<String>,
pub action: Option<String>,
pub icon: Option<String>,
pub style: Option<String>,
pub disabled: bool,
pub value: Option<String>,
pub color: Option<String>,
pub options: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeCommandItem {
pub label: String,
pub description: Option<String>,
pub action: Option<String>,
pub icon: Option<String>,
pub group: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeProgressStep {
pub label: String,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeNavItem {
pub label: String,
pub href: String,
pub icon: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeFooterSection {
pub heading: String,
pub links: Vec<NativeNavItem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeSocialLink {
pub platform: String,
pub href: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeHeroButton {
pub label: String,
pub href: String,
pub primary: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeTheme {
pub pack_id: String,
pub accent: String,
pub on_accent: String,
pub accent_ink_light: String,
pub accent_ink_dark: String,
pub font_display: Option<String>,
pub font_body: Option<String>,
pub radius_card: f64,
pub radius_btn: f64,
pub radius_chip: f64,
pub radius_img: f64,
pub border_w: f64,
pub border_style: String,
pub shadow: String,
pub shadow_hover: String,
pub bg_texture: String,
pub hero_bg: String,
pub hero_btn_radius: f64,
pub banner_btn_radius: f64,
pub cta_radius: f64,
pub form_submit_radius: f64,
pub control_radius: f64,
pub feature_card_radius: f64,
pub feature_card_pad: f64,
pub feature_card_hover_transform: String,
pub feature_card_bg: String,
pub tile_surface_bg: String,
pub post_card_bg: String,
pub post_card_radius: f64,
pub pg_card_bg: String,
pub pg_card_radius: f64,
pub pg_tile_radius: f64,
pub details_bg: String,
pub details_radius: f64,
pub form_input_bg: String,
pub doc_page_bg: String,
pub doc_page_radius: f64,
pub drawer_link_size: String,
pub drawer_link_weight: String,
}
fn px_value(css: &str, fallback: f64) -> f64 {
css.trim()
.strip_suffix("px")
.and_then(|n| n.trim().parse::<f64>().ok())
.unwrap_or(fallback)
}
impl From<&crate::resolve::ResolvedTheme> for NativeTheme {
fn from(t: &crate::resolve::ResolvedTheme) -> Self {
let radius_card = px_value(t.tokens.radius_card, 14.0);
let radius_btn = px_value(t.tokens.radius_btn, 999.0);
NativeTheme {
pack_id: t.pack_id.clone(),
accent: t.accent.clone(),
on_accent: t.on_accent.clone(),
accent_ink_light: t.accent_ink_light.clone(),
accent_ink_dark: t.accent_ink_dark.clone(),
font_display: t.font_display.clone(),
font_body: t.font_body.clone(),
radius_card,
radius_btn,
radius_chip: px_value(t.tokens.radius_chip, 999.0),
radius_img: px_value(t.tokens.radius_img, 10.0),
border_w: px_value(t.tokens.border_w, 1.0),
border_style: t.tokens.border_style.to_string(),
shadow: t.tokens.shadow.to_string(),
shadow_hover: t.tokens.shadow_hover.to_string(),
bg_texture: t.tokens.bg_texture.to_string(),
hero_bg: t.tokens.hero_bg.to_string(),
hero_btn_radius: px_value(t.tokens.hero_btn_radius, radius_btn),
banner_btn_radius: px_value(t.tokens.banner_btn_radius, 2.0),
cta_radius: px_value(t.tokens.cta_radius, radius_btn),
form_submit_radius: px_value(
t.tokens.form_submit_radius,
px_value(t.tokens.control_radius, 2.0),
),
control_radius: px_value(t.tokens.control_radius, 8.0),
feature_card_radius: px_value(t.tokens.feature_card_radius, radius_card),
feature_card_pad: px_value(t.tokens.feature_card_pad, 24.0),
feature_card_hover_transform: t.tokens.feature_card_hover_transform.to_string(),
feature_card_bg: t.tokens.feature_card_bg.to_string(),
tile_surface_bg: t.tokens.tile_surface_bg.to_string(),
post_card_bg: t.tokens.post_card_bg.to_string(),
post_card_radius: px_value(t.tokens.post_card_radius, radius_card),
pg_card_bg: t.tokens.pg_card_bg.to_string(),
pg_card_radius: px_value(t.tokens.pg_card_radius, 20.0),
pg_tile_radius: px_value(t.tokens.pg_tile_radius, 0.0),
details_bg: t.tokens.details_bg.to_string(),
details_radius: px_value(t.tokens.details_radius, 2.0),
form_input_bg: t.tokens.form_input_bg.to_string(),
doc_page_bg: t.tokens.doc_page_bg.to_string(),
doc_page_radius: px_value(t.tokens.doc_page_radius, radius_card),
drawer_link_size: t.tokens.drawer_link_size.to_string(),
drawer_link_weight: t.tokens.drawer_link_weight.to_string(),
}
}
}
pub const NATIVE_DOC_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct NativeDoc {
pub schema_version: u32,
pub theme: NativeTheme,
pub blocks: Vec<NativeBlock>,
}
pub fn to_native_blocks(doc: &SurfDoc) -> Vec<NativeBlock> {
let _cite_scope = crate::citation::install_context(crate::citation::build_context(
&doc.blocks,
doc.front_matter.as_ref().and_then(|fm| fm.format),
));
doc.blocks.iter().flat_map(|b| convert_block_flat(b, 0)).collect()
}
fn convert_children(children: &[Block], depth: u32) -> Vec<NativeBlock> {
children
.iter()
.flat_map(|c| convert_block_flat(c, depth))
.collect()
}
fn convert_block_flat(block: &Block, depth: u32) -> Vec<NativeBlock> {
match block {
Block::Markdown { content, .. } => expand_markdown_tables(content),
other => vec![convert_block(other, depth)],
}
}
pub(crate) fn expand_markdown_tables(content: &str) -> Vec<NativeBlock> {
let lines: Vec<&str> = content.split('\n').collect();
let has_table = lines
.windows(2)
.any(|w| is_table_row(w[0]) && is_delimiter_row(w[1]));
if !has_table {
return vec![NativeBlock::Markdown {
content: content.to_string(),
}];
}
let mut out: Vec<NativeBlock> = Vec::new();
let mut prose: Vec<&str> = Vec::new();
let mut i = 0;
let flush_prose = |prose: &mut Vec<&str>, out: &mut Vec<NativeBlock>| {
if prose.iter().any(|l| !l.trim().is_empty()) {
let start = prose.iter().position(|l| !l.trim().is_empty()).unwrap();
let end = prose.iter().rposition(|l| !l.trim().is_empty()).unwrap();
out.push(NativeBlock::Markdown {
content: prose[start..=end].join("\n"),
});
}
prose.clear();
};
while i < lines.len() {
if i + 1 < lines.len() && is_table_row(lines[i]) && is_delimiter_row(lines[i + 1]) {
let headers = split_table_cells(lines[i]);
let ncols = headers.len();
let mut rows: Vec<Vec<String>> = Vec::new();
let mut j = i + 2;
while j < lines.len() && is_table_row(lines[j]) {
let mut cells = split_table_cells(lines[j]);
if cells.len() < ncols {
cells.resize(ncols, String::new());
} else {
cells.truncate(ncols);
}
rows.push(cells);
j += 1;
}
flush_prose(&mut prose, &mut out);
out.push(NativeBlock::DataTable {
headers,
rows,
sortable: false,
});
i = j;
continue;
}
prose.push(lines[i]);
i += 1;
}
flush_prose(&mut prose, &mut out);
if out.is_empty() {
out.push(NativeBlock::Markdown {
content: content.to_string(),
});
}
out
}
fn is_table_row(line: &str) -> bool {
let t = line.trim();
if t.is_empty() {
return false;
}
let mut escaped = false;
for c in t.chars() {
match c {
'\\' => escaped = !escaped,
'|' if !escaped => return true,
_ => escaped = false,
}
}
false
}
fn is_delimiter_row(line: &str) -> bool {
let t = line.trim();
if !is_table_row(t) {
return false;
}
let cells = split_table_cells(t);
if cells.is_empty() {
return false;
}
cells.iter().all(|cell| {
let c = cell.trim();
if c.is_empty() {
return false;
}
let inner = c.trim_start_matches(':').trim_end_matches(':');
!inner.is_empty() && inner.chars().all(|ch| ch == '-')
})
}
fn split_table_cells(line: &str) -> Vec<String> {
let mut t = line.trim();
if let Some(stripped) = t.strip_prefix('|') {
t = stripped;
}
if let Some(stripped) = t.strip_suffix('|') {
if !t.ends_with("\\|") {
t = stripped;
}
}
let mut cells: Vec<String> = Vec::new();
let mut cur = String::new();
let mut escaped = false;
for c in t.chars() {
if escaped {
if c == '|' {
cur.push('|');
} else {
cur.push('\\');
cur.push(c);
}
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '|' {
cells.push(cur.trim().to_string());
cur = String::new();
} else {
cur.push(c);
}
}
if escaped {
cur.push('\\');
}
cells.push(cur.trim().to_string());
cells
}
fn row_state_str(state: &RowState) -> &'static str {
match state {
RowState::Default => "default",
RowState::Loading => "loading",
RowState::Empty => "empty",
}
}
fn convert_block(block: &Block, depth: u32) -> NativeBlock {
match block {
Block::Markdown { content, .. } => NativeBlock::Markdown {
content: content.clone(),
},
Block::Callout {
callout_type,
title,
content,
..
} => NativeBlock::Callout {
callout_type: callout_type_str(*callout_type),
title: title.clone(),
content: content.clone(),
},
Block::Code {
lang,
file,
content,
..
} => NativeBlock::Code {
language: lang.clone(),
file_path: file.clone(),
content: content.clone(),
},
Block::Data {
headers,
rows,
sortable,
..
} => NativeBlock::DataTable {
headers: headers.clone(),
rows: rows.clone(),
sortable: *sortable,
},
Block::Tasks { items, .. } => NativeBlock::Tasks {
items: items
.iter()
.map(|i| NativeTaskItem {
done: i.done,
text: i.text.clone(),
assignee: i.assignee.clone(),
})
.collect(),
},
Block::Decision {
status,
date,
deciders,
content,
..
} => NativeBlock::Decision {
status: decision_status_str(*status),
date: date.clone(),
deciders: deciders.clone(),
content: content.clone(),
},
Block::Metric {
label,
value,
trend,
unit,
..
} => NativeBlock::Metric {
label: label.clone(),
value: value.clone(),
trend: trend.map(trend_str),
unit: unit.clone(),
},
Block::Summary { content, .. } => NativeBlock::Summary {
content: content.clone(),
},
Block::Figure {
src,
caption,
alt,
..
} => NativeBlock::Figure {
src: src.clone(),
caption: caption.clone(),
alt: alt.clone(),
},
Block::Diagram {
diagram_type,
title,
content,
..
} => NativeBlock::Diagram {
diagram_type: diagram_type.clone(),
title: title.clone(),
content: content.clone(),
},
Block::ProductCard {
title,
subtitle,
badge,
badge_color,
body,
features,
cta_label,
cta_href,
..
} => NativeBlock::ProductCard {
title: title.clone(),
subtitle: subtitle.clone(),
badge: badge.clone(),
badge_color: badge_color.clone(),
body: body.clone(),
features: features.clone(),
cta_label: cta_label.clone(),
cta_href: cta_href.clone(),
},
Block::Chart {
chart_type,
source,
period,
..
} => NativeBlock::Chart {
chart_type: crate::render_html::chart_type_str(*chart_type).to_string(),
source: source.clone(),
period: period.clone(),
},
Block::Row {
icon,
title,
description,
href,
state,
..
} => NativeBlock::Row {
icon: icon.clone(),
title: title.clone(),
description: description.clone(),
href: href.clone(),
state: row_state_str(state).to_string(),
},
Block::InfoCard {
intent,
title,
subtitle,
summary,
image,
facts,
steps,
state,
..
} => NativeBlock::InfoCard {
intent: intent.clone(),
title: title.clone(),
subtitle: subtitle.clone(),
summary: summary.clone(),
image: image.clone(),
facts: facts
.iter()
.map(|f| NativeInfoFact {
label: f[0].clone(),
value: f[1].clone(),
})
.collect(),
steps: steps.clone(),
state: row_state_str(state).to_string(),
},
Block::Tabs { tabs, .. } => NativeBlock::Tabs {
tabs: tabs
.iter()
.map(|t| NativeTabPanel {
label: t.label.clone(),
content: t.content.clone(),
})
.collect(),
},
Block::Columns { columns, .. } => NativeBlock::Columns {
columns: columns
.iter()
.map(|c| NativeColumnContent {
content: c.content.clone(),
})
.collect(),
},
Block::Quote {
content,
attribution,
..
} => NativeBlock::Quote {
content: content.clone(),
attribution: attribution.clone(),
},
Block::Cta {
label,
href,
primary,
..
} => NativeBlock::Cta {
label: label.clone(),
href: href.clone(),
primary: *primary,
},
Block::Testimonial {
content,
author,
role,
company,
..
} => NativeBlock::Testimonial {
content: content.clone(),
author: author.clone(),
role: role.clone(),
company: company.clone(),
},
Block::Faq { items, .. } => NativeBlock::Faq {
items: items
.iter()
.map(|i| NativeFaqItem {
question: i.question.clone(),
answer: i.answer.clone(),
})
.collect(),
},
Block::Details {
title,
open,
content,
..
} => NativeBlock::Details {
title: title.clone(),
open: *open,
content: content.clone(),
},
Block::Divider { label, .. } => NativeBlock::Divider {
label: label.clone(),
},
Block::Hero {
headline,
subtitle,
badge,
align,
image,
buttons,
content,
..
} => NativeBlock::Hero {
headline: headline.clone(),
subtitle: subtitle.clone(),
badge: badge.clone(),
align: align.clone(),
image: image.clone(),
buttons: buttons
.iter()
.map(|b| NativeHeroButton {
label: b.label.clone(),
href: b.href.clone(),
primary: b.primary,
})
.collect(),
content: content.clone(),
},
Block::Features { cards, .. } => NativeBlock::Features {
cards: cards
.iter()
.map(|c| NativeFeatureCard {
title: c.title.clone(),
icon: c.icon.clone(),
body: c.body.clone(),
})
.collect(),
},
Block::Steps { steps, .. } => NativeBlock::Steps {
steps: steps
.iter()
.map(|s| NativeStepItem {
title: s.title.clone(),
time: s.time.clone(),
body: s.body.clone(),
})
.collect(),
},
Block::Stats { items, .. } => NativeBlock::Stats {
items: items
.iter()
.map(|i| NativeStatItem {
value: i.value.clone(),
label: i.label.clone(),
color: i.color.clone(),
})
.collect(),
},
Block::Comparison {
headers,
rows,
highlight,
..
} => NativeBlock::Comparison {
headers: headers.clone(),
rows: rows.clone(),
highlight: highlight.clone(),
},
Block::Toc {
depth, entries, ..
} => NativeBlock::Toc {
depth: *depth,
entries: entries
.iter()
.map(|e| NativeTocEntry {
text: e.text.clone(),
id: e.id.clone(),
level: e.level,
})
.collect(),
},
Block::BeforeAfter {
before_items,
after_items,
transition,
..
} => NativeBlock::BeforeAfter {
before_items: before_items
.iter()
.map(|i| NativeBeforeAfterItem {
label: i.label.clone(),
detail: i.detail.clone(),
})
.collect(),
after_items: after_items
.iter()
.map(|i| NativeBeforeAfterItem {
label: i.label.clone(),
detail: i.detail.clone(),
})
.collect(),
transition: transition.clone(),
},
Block::Pipeline { steps, .. } => NativeBlock::Pipeline {
steps: steps
.iter()
.map(|s| NativePipelineStep {
label: s.label.clone(),
description: s.description.clone(),
})
.collect(),
},
Block::Form {
fields,
submit_label,
..
} => NativeBlock::Form {
fields: fields
.iter()
.map(|f| NativeFormField {
label: f.label.clone(),
name: f.name.clone(),
field_type: form_field_type_str(f.field_type),
required: f.required,
placeholder: f.placeholder.clone(),
options: f.options.clone(),
})
.collect(),
submit_label: submit_label
.clone()
.unwrap_or_else(|| "Submit".to_string()),
},
Block::Gallery {
items, columns, ..
} => NativeBlock::Gallery {
items: items
.iter()
.map(|i| NativeGalleryItem {
src: i.src.clone(),
caption: i.caption.clone(),
alt: i.alt.clone(),
category: i.category.clone(),
})
.collect(),
columns: columns.unwrap_or(3),
},
Block::Section {
bg,
headline,
subtitle,
children,
..
} => {
if depth >= MAX_SECTION_DEPTH {
let md = render_md::render_block(block);
NativeBlock::Markdown { content: md }
} else {
NativeBlock::SectionContainer {
bg: bg.clone(),
headline: headline.clone(),
subtitle: subtitle.clone(),
children: convert_children(children, depth + 1),
}
}
}
Block::AppShell {
layout, children, ..
} => NativeBlock::AppShell {
layout: layout.clone(),
children: convert_children(children, depth + 1),
},
Block::Sidebar {
position,
collapsible,
width,
children,
..
} => NativeBlock::Sidebar {
position: position.clone(),
collapsible: *collapsible,
width: *width,
children: convert_children(children, depth + 1),
},
Block::Panel {
position,
resizable,
height,
desktop_only,
children,
..
} => NativeBlock::Panel {
position: position.clone(),
resizable: *resizable,
height: *height,
desktop_only: *desktop_only,
children: convert_children(children, depth + 1),
},
Block::TabBar { active, items, .. } => NativeBlock::TabBar {
active: active.clone(),
items: items
.iter()
.map(|i| NativeTabBarItem {
id: i.id.clone(),
label: i.label.clone(),
})
.collect(),
},
Block::TabContent { tab, children, .. } => NativeBlock::TabContent {
tab: tab.clone(),
children: convert_children(children, depth + 1),
},
Block::Toolbar { items, .. } => NativeBlock::Toolbar {
items: items.iter().map(toolbar_item_to_native).collect(),
},
Block::Drawer {
name,
position,
width,
trigger,
children,
..
} => NativeBlock::Drawer {
name: name.clone(),
position: position.clone(),
width: *width,
trigger: trigger.clone(),
children: convert_children(children, depth + 1),
},
Block::Modal {
name,
title,
children,
..
} => NativeBlock::Modal {
name: name.clone(),
title: title.clone(),
children: convert_children(children, depth + 1),
},
Block::CommandPalette {
trigger, items, ..
} => NativeBlock::CommandPalette {
trigger: trigger.clone(),
items: items
.iter()
.map(|i| NativeCommandItem {
label: i.label.clone(),
description: i.description.clone(),
action: i.action.clone(),
icon: i.icon.clone(),
group: i.group.clone(),
})
.collect(),
},
Block::CodeEditor {
lang,
source,
line_numbers,
content,
..
} => NativeBlock::CodeEditor {
lang: lang.clone(),
source: source.clone(),
line_numbers: *line_numbers,
content: content.clone(),
},
Block::BlockEditor { source, .. } => NativeBlock::BlockEditor {
source: source.clone(),
},
Block::Terminal { shell, cwd, .. } => NativeBlock::Terminal {
shell: shell.clone(),
cwd: cwd.clone(),
},
Block::NavTree {
source,
on_select,
on_rename,
on_delete,
..
} => NativeBlock::NavTree {
source: source.clone(),
on_select: on_select.clone(),
on_rename: on_rename.clone(),
on_delete: on_delete.clone(),
},
Block::Badge { value, color, .. } => NativeBlock::Badge {
value: value.clone(),
color: color.clone(),
},
Block::SuggestionChips {
source,
max,
dismissible,
..
} => NativeBlock::SuggestionChips {
source: source.clone(),
max: *max,
dismissible: *dismissible,
},
Block::ChatThread {
source, on_action, ..
} => NativeBlock::ChatThread {
source: source.clone(),
on_action: on_action.clone(),
},
Block::ChatInputSimple {
placeholder,
action,
..
} => NativeBlock::ChatInputSimple {
placeholder: placeholder.clone(),
action: action.clone(),
},
Block::Progress {
source, steps, ..
} => NativeBlock::Progress {
source: source.clone(),
steps: steps
.iter()
.map(|s| NativeProgressStep {
label: s.label.clone(),
status: s.status.clone(),
})
.collect(),
},
Block::LogStream { source, tail, .. } => NativeBlock::LogStream {
source: source.clone(),
tail: *tail,
},
Block::ProblemList { source, .. } => NativeBlock::ProblemList {
source: source.clone(),
},
Block::Site {
domain, properties, ..
} => {
let mut name = None;
let mut description = None;
let mut accent = None;
let mut font = None;
let mut extras = Vec::new();
for p in properties {
match p.key.as_str() {
"name" => name = Some(p.value.clone()),
"description" => description = Some(p.value.clone()),
"accent" => accent = Some(p.value.clone()),
"font" => font = Some(p.value.clone()),
_ => extras.push(format!("{}={}", p.key, p.value)),
}
}
NativeBlock::Site {
name,
description,
accent,
font,
domain: domain.clone(),
extras,
}
}
Block::Page {
route,
title,
layout,
children,
..
} => NativeBlock::Page {
route: route.clone(),
title: title.clone(),
layout: layout.clone(),
children: convert_children(children, depth + 1),
},
Block::Deck { .. } => NativeBlock::Markdown {
content: String::new(),
},
Block::Slide {
kicker, children, ..
} => {
if depth >= MAX_SECTION_DEPTH {
let md = render_md::render_block(block);
NativeBlock::Markdown { content: md }
} else {
NativeBlock::SectionContainer {
bg: None,
headline: kicker.clone(),
subtitle: None,
children: convert_children(children, depth + 1),
}
}
}
Block::Nav { logo, items, .. } => NativeBlock::Nav {
logo: logo.clone(),
items: items
.iter()
.map(|i| NativeNavItem {
label: i.label.clone(),
href: i.href.clone(),
icon: i.icon.clone(),
})
.collect(),
},
Block::HeroImage { src, alt, .. } => NativeBlock::HeroImage {
src: src.clone(),
alt: alt.clone(),
},
Block::Footer {
sections,
copyright,
social,
..
} => NativeBlock::Footer {
copyright: copyright.clone(),
sections: sections
.iter()
.map(|s| NativeFooterSection {
heading: s.heading.clone(),
links: s
.links
.iter()
.map(|l| NativeNavItem {
label: l.label.clone(),
href: l.href.clone(),
icon: l.icon.clone(),
})
.collect(),
})
.collect(),
social: social
.iter()
.map(|s| NativeSocialLink {
platform: s.platform.clone(),
href: s.href.clone(),
})
.collect(),
},
Block::Embed {
src,
embed_type,
title,
..
} => NativeBlock::Embed {
src: src.clone(),
title: title.clone(),
embed_type: embed_type.map(embed_type_str).unwrap_or_else(|| "generic".to_string()),
},
Block::PricingTable { headers, rows, .. } => NativeBlock::PricingTable {
headers: headers.clone(),
rows: rows.clone(),
},
Block::Unknown { .. }
| Block::Cite { .. }
| Block::Bibliography { .. }
| Block::Style { .. }
| Block::Logo { .. }
| Block::Banner { .. }
| Block::ProductGrid { .. }
| Block::PostGrid { .. }
| Block::Gate { .. }
| Block::List { .. }
| Block::Board { .. }
| Block::Action { .. }
| Block::FilterBar { .. }
| Block::Search { .. }
| Block::Dashboard { .. }
| Block::ChatInput { .. }
| Block::Feed { .. }
| Block::Booking { .. }
| Block::Store { .. }
| Block::Editor { .. }
| Block::SplitPane { .. }
| Block::App { .. }
| Block::Build { .. }
| Block::InfraDatabase { .. }
| Block::Deploy { .. }
| Block::InfraEnv { .. }
| Block::Health { .. }
| Block::Concurrency { .. }
| Block::Cicd { .. }
| Block::Smoke { .. }
| Block::Domains { .. }
| Block::Crates { .. }
| Block::DeployUrls { .. }
| Block::Volumes { .. }
| Block::Model { .. }
| Block::Route { .. }
| Block::Auth { .. }
| Block::Binding { .. }
| Block::Schema { .. }
| Block::Use { .. }
| Block::AppEnv { .. }
| Block::AppDeploy { .. } => {
let md = render_md::render_block(block);
NativeBlock::Markdown { content: md }
}
}
}
fn callout_type_str(ct: CalloutType) -> String {
match ct {
CalloutType::Info => "info",
CalloutType::Warning => "warning",
CalloutType::Danger => "danger",
CalloutType::Tip => "tip",
CalloutType::Note => "note",
CalloutType::Success => "success",
CalloutType::Context => "context",
}
.to_string()
}
fn decision_status_str(ds: DecisionStatus) -> String {
match ds {
DecisionStatus::Proposed => "proposed",
DecisionStatus::Accepted => "accepted",
DecisionStatus::Rejected => "rejected",
DecisionStatus::Superseded => "superseded",
}
.to_string()
}
fn trend_str(t: Trend) -> String {
match t {
Trend::Up => "up",
Trend::Down => "down",
Trend::Flat => "flat",
}
.to_string()
}
fn toolbar_item_to_native(item: &ToolbarItem) -> NativeToolbarItem {
match item {
ToolbarItem::Button {
label,
action,
icon,
style,
disabled,
} => NativeToolbarItem {
kind: "button".to_string(),
label: label.clone(),
action: action.clone(),
icon: icon.clone(),
style: style.clone(),
disabled: *disabled,
value: None,
color: None,
options: None,
},
ToolbarItem::Separator => NativeToolbarItem {
kind: "separator".to_string(),
label: None,
action: None,
icon: None,
style: None,
disabled: false,
value: None,
color: None,
options: None,
},
ToolbarItem::Spacer => NativeToolbarItem {
kind: "spacer".to_string(),
label: None,
action: None,
icon: None,
style: None,
disabled: false,
value: None,
color: None,
options: None,
},
ToolbarItem::Badge { value, color } => NativeToolbarItem {
kind: "badge".to_string(),
label: None,
action: None,
icon: None,
style: None,
disabled: false,
value: Some(value.clone()),
color: color.clone(),
options: None,
},
ToolbarItem::Dropdown {
label,
options,
action,
} => NativeToolbarItem {
kind: "dropdown".to_string(),
label: Some(label.clone()),
action: action.clone(),
icon: None,
style: None,
disabled: false,
value: None,
color: None,
options: options.clone(),
},
ToolbarItem::Text { value, .. } => NativeToolbarItem {
kind: "text".to_string(),
label: None,
action: None,
icon: None,
style: None,
disabled: false,
value: Some(value.clone()),
color: None,
options: None,
},
}
}
fn embed_type_str(et: EmbedType) -> String {
match et {
EmbedType::Map => "map",
EmbedType::Video => "video",
EmbedType::Audio => "audio",
EmbedType::Generic => "generic",
}
.to_string()
}
fn form_field_type_str(ft: FormFieldType) -> String {
match ft {
FormFieldType::Text => "text",
FormFieldType::Email => "email",
FormFieldType::Tel => "tel",
FormFieldType::Date => "date",
FormFieldType::Number => "number",
FormFieldType::Password => "password",
FormFieldType::Select => "select",
FormFieldType::Textarea => "textarea",
}
.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockTier {
Content,
Site,
Chrome,
Degraded,
}
pub fn block_tier(block: &Block) -> BlockTier {
match block {
Block::Markdown { .. }
| Block::Code { .. }
| Block::Callout { .. }
| Block::Data { .. }
| Block::Tasks { .. }
| Block::Figure { .. }
| Block::Diagram { .. }
| Block::Quote { .. }
| Block::Divider { .. }
| Block::Details { .. }
| Block::Decision { .. }
| Block::Metric { .. }
| Block::Summary { .. }
| Block::Cite { .. }
| Block::Bibliography { .. }
| Block::ProductCard { .. }
| Block::Chart { .. }
| Block::Row { .. }
| Block::InfoCard { .. }
| Block::Toc { .. } => BlockTier::Content,
Block::Hero { .. }
| Block::Features { .. }
| Block::Steps { .. }
| Block::Stats { .. }
| Block::Comparison { .. }
| Block::BeforeAfter { .. }
| Block::Pipeline { .. }
| Block::Testimonial { .. }
| Block::Cta { .. }
| Block::Banner { .. }
| Block::ProductGrid { .. }
| Block::PostGrid { .. }
| Block::Gate { .. }
| Block::Gallery { .. }
| Block::Faq { .. }
| Block::PricingTable { .. }
| Block::Columns { .. }
| Block::Tabs { .. }
| Block::Section { .. }
| Block::Form { .. }
| Block::Site { .. }
| Block::Page { .. }
| Block::Nav { .. }
| Block::HeroImage { .. }
| Block::Footer { .. }
| Block::Embed { .. }
| Block::Slide { .. } => BlockTier::Site,
Block::AppShell { .. }
| Block::Sidebar { .. }
| Block::Panel { .. }
| Block::TabBar { .. }
| Block::TabContent { .. }
| Block::Toolbar { .. }
| Block::Drawer { .. }
| Block::Modal { .. }
| Block::CommandPalette { .. }
| Block::CodeEditor { .. }
| Block::BlockEditor { .. }
| Block::Terminal { .. }
| Block::NavTree { .. }
| Block::Badge { .. }
| Block::SuggestionChips { .. }
| Block::ChatThread { .. }
| Block::ChatInputSimple { .. }
| Block::Progress { .. }
| Block::LogStream { .. }
| Block::ProblemList { .. } => BlockTier::Chrome,
Block::Unknown { .. }
| Block::Style { .. }
| Block::Logo { .. }
| Block::List { .. }
| Block::Board { .. }
| Block::Action { .. }
| Block::FilterBar { .. }
| Block::Search { .. }
| Block::Dashboard { .. }
| Block::ChatInput { .. }
| Block::Feed { .. }
| Block::Booking { .. }
| Block::Store { .. }
| Block::Editor { .. }
| Block::SplitPane { .. }
| Block::App { .. }
| Block::Build { .. }
| Block::InfraDatabase { .. }
| Block::Deploy { .. }
| Block::InfraEnv { .. }
| Block::Health { .. }
| Block::Concurrency { .. }
| Block::Cicd { .. }
| Block::Smoke { .. }
| Block::Domains { .. }
| Block::Crates { .. }
| Block::DeployUrls { .. }
| Block::Volumes { .. }
| Block::Model { .. }
| Block::Route { .. }
| Block::Auth { .. }
| Block::Binding { .. }
| Block::Schema { .. }
| Block::Use { .. }
| Block::AppEnv { .. }
| Block::AppDeploy { .. }
| Block::Deck { .. } => BlockTier::Degraded,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
use std::collections::BTreeMap;
fn syn() -> Span {
Span::SYNTHETIC
}
#[test]
fn tier_classification_matches_conversion() {
let source = "\
# Heading
::callout[type=info]\nNote\n::\n
::hero\nheadline: H\n::\n
::badge[value=3]\n::\n
::app[name=demo]\n::\n
::section\nInner\n::\n";
let result = crate::parse(source);
let mut saw_degraded = false;
let mut saw_structured = false;
for block in &result.doc.blocks {
let native = convert_block(block, 0);
match block_tier(block) {
BlockTier::Degraded => {
saw_degraded = true;
assert!(
matches!(native, NativeBlock::Markdown { .. }),
"Degraded-tier {block:?} must convert to Markdown"
);
}
BlockTier::Content | BlockTier::Site | BlockTier::Chrome => {
saw_structured = true;
if !matches!(block, Block::Markdown { .. }) {
assert!(
!matches!(native, NativeBlock::Markdown { .. }),
"structured-tier {block:?} must not degrade to Markdown"
);
}
}
}
}
assert!(saw_degraded && saw_structured, "fixture must cover both paths");
}
#[test]
fn content_cards_convert_structurally() {
let source = "\
::product-card[badge=\"New\"]\n## Pro\nsub\n\nbody\n\n- feat\n\n[Buy](/p)\n::\n
::chart[type=bar source=\"/api/rev\" period=monthly]\n::\n
::row[icon=doc href=\"/d\"]\nTitle\nDesc\n::\n
::infocard[intent=info]\n# Card\nsub\n\nsummary\nKey: Val\n::\n
::diagram[type=erd title=\"M\"]\na: id pk\n::\n";
let result = crate::parse(source);
let natives: Vec<NativeBlock> = result
.doc
.blocks
.iter()
.map(|b| convert_block(b, 0))
.collect();
assert!(matches!(natives[0], NativeBlock::ProductCard { ref features, .. } if features == &["feat".to_string()]));
assert!(matches!(natives[1], NativeBlock::Chart { ref chart_type, ref source, .. } if chart_type == "bar" && source == "/api/rev"));
assert!(matches!(natives[2], NativeBlock::Row { ref icon, ref state, .. } if icon == "doc" && state == "default"));
match &natives[3] {
NativeBlock::InfoCard { intent, facts, .. } => {
assert_eq!(intent, "info");
assert_eq!(facts, &[NativeInfoFact { label: "Key".into(), value: "Val".into() }]);
}
other => panic!("expected InfoCard, got {other:?}"),
}
assert!(matches!(natives[4], NativeBlock::Diagram { ref diagram_type, .. } if diagram_type == "erd"));
assert!(!natives.iter().any(|n| matches!(n, NativeBlock::Markdown { .. })));
}
#[test]
fn native_markdown_passthrough() {
let block = Block::Markdown {
content: "# Hello\n\nWorld".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Markdown {
content: "# Hello\n\nWorld".to_string()
}
);
}
#[test]
fn native_callout_info() {
let block = Block::Callout {
callout_type: CalloutType::Info,
title: Some("Watch out".to_string()),
content: "Sharp edges".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Callout {
callout_type: "info".to_string(),
title: Some("Watch out".to_string()),
content: "Sharp edges".to_string(),
}
);
}
#[test]
fn native_callout_all_types() {
let types = [
(CalloutType::Info, "info"),
(CalloutType::Warning, "warning"),
(CalloutType::Danger, "danger"),
(CalloutType::Tip, "tip"),
(CalloutType::Note, "note"),
(CalloutType::Success, "success"),
];
for (ct, expected) in types {
let block = Block::Callout {
callout_type: ct,
title: None,
content: String::new(),
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Callout { callout_type, .. } => {
assert_eq!(callout_type, expected);
}
other => panic!("Expected Callout, got {:?}", other),
}
}
}
#[test]
fn native_code_with_lang() {
let block = Block::Code {
lang: Some("rust".to_string()),
file: Some("main.rs".to_string()),
highlight: vec![],
content: "fn main() {}".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Code {
language: Some("rust".to_string()),
file_path: Some("main.rs".to_string()),
content: "fn main() {}".to_string(),
}
);
}
#[test]
fn native_code_no_lang() {
let block = Block::Code {
lang: None,
file: None,
highlight: vec![],
content: "echo hi".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Code {
language: None,
file_path: None,
content: "echo hi".to_string(),
}
);
}
#[test]
fn native_data_table() {
let block = Block::Data {
id: None,
format: DataFormat::Table,
sortable: true,
headers: vec!["Name".to_string(), "Age".to_string()],
rows: vec![vec!["Alice".to_string(), "30".to_string()]],
raw_content: String::new(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::DataTable {
headers: vec!["Name".to_string(), "Age".to_string()],
rows: vec![vec!["Alice".to_string(), "30".to_string()]],
sortable: true,
}
);
}
#[test]
fn native_data_table_empty() {
let block = Block::Data {
id: None,
format: DataFormat::Table,
sortable: false,
headers: vec![],
rows: vec![],
raw_content: String::new(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::DataTable {
headers: vec![],
rows: vec![],
sortable: false,
}
);
}
#[test]
fn native_tasks() {
let block = Block::Tasks {
items: vec![
TaskItem {
done: false,
text: "Write tests".to_string(),
assignee: None,
},
TaskItem {
done: true,
text: "Ship".to_string(),
assignee: Some("brady".to_string()),
},
],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Tasks {
items: vec![
NativeTaskItem {
done: false,
text: "Write tests".to_string(),
assignee: None,
},
NativeTaskItem {
done: true,
text: "Ship".to_string(),
assignee: Some("brady".to_string()),
},
],
}
);
}
#[test]
fn native_decision_accepted() {
let block = Block::Decision {
status: DecisionStatus::Accepted,
date: Some("2026-02-24".to_string()),
deciders: vec!["brady".to_string()],
content: "We chose Rust.".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Decision {
status: "accepted".to_string(),
date: Some("2026-02-24".to_string()),
deciders: vec!["brady".to_string()],
content: "We chose Rust.".to_string(),
}
);
}
#[test]
fn native_metric_with_trend() {
let block = Block::Metric {
label: "MRR".to_string(),
value: "$2K".to_string(),
trend: Some(Trend::Up),
unit: Some("USD".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Metric {
label: "MRR".to_string(),
value: "$2K".to_string(),
trend: Some("up".to_string()),
unit: Some("USD".to_string()),
}
);
}
#[test]
fn native_metric_no_trend() {
let block = Block::Metric {
label: "Users".to_string(),
value: "100".to_string(),
trend: None,
unit: None,
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Metric {
label: "Users".to_string(),
value: "100".to_string(),
trend: None,
unit: None,
}
);
}
#[test]
fn native_summary() {
let block = Block::Summary {
content: "Executive overview.".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Summary {
content: "Executive overview.".to_string()
}
);
}
#[test]
fn native_figure() {
let block = Block::Figure {
src: "diagram.png".to_string(),
caption: Some("Arch".to_string()),
alt: Some("Diagram".to_string()),
width: Some("400px".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Figure {
src: "diagram.png".to_string(),
caption: Some("Arch".to_string()),
alt: Some("Diagram".to_string()),
}
);
}
#[test]
fn native_tabs() {
let block = Block::Tabs {
tabs: vec![TabPanel {
label: "Rust".to_string(),
content: "fn main() {}".to_string(),
}],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Tabs {
tabs: vec![NativeTabPanel {
label: "Rust".to_string(),
content: "fn main() {}".to_string(),
}],
}
);
}
#[test]
fn native_columns() {
let block = Block::Columns {
columns: vec![
ColumnContent {
content: "Col 1".to_string(),
},
ColumnContent {
content: "Col 2".to_string(),
},
],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Columns {
columns: vec![
NativeColumnContent {
content: "Col 1".to_string()
},
NativeColumnContent {
content: "Col 2".to_string()
},
],
}
);
}
#[test]
fn native_quote() {
let block = Block::Quote {
content: "To be or not".to_string(),
attribution: Some("Shakespeare".to_string()),
cite: Some("Hamlet".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Quote {
content: "To be or not".to_string(),
attribution: Some("Shakespeare".to_string()),
}
);
}
#[test]
fn native_cta() {
let block = Block::Cta {
label: "Sign Up".to_string(),
href: "/signup".to_string(),
primary: true,
icon: Some("rocket".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Cta {
label: "Sign Up".to_string(),
href: "/signup".to_string(),
primary: true,
}
);
}
#[test]
fn native_testimonial() {
let block = Block::Testimonial {
content: "Great!".to_string(),
author: Some("Jane".to_string()),
role: Some("Eng".to_string()),
company: Some("Acme".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Testimonial {
content: "Great!".to_string(),
author: Some("Jane".to_string()),
role: Some("Eng".to_string()),
company: Some("Acme".to_string()),
}
);
}
#[test]
fn native_faq() {
let block = Block::Faq {
items: vec![FaqItem {
question: "Free?".to_string(),
answer: "Yes.".to_string(),
}],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Faq {
items: vec![NativeFaqItem {
question: "Free?".to_string(),
answer: "Yes.".to_string(),
}],
}
);
}
#[test]
fn native_details() {
let block = Block::Details {
title: Some("More info".to_string()),
open: true,
content: "Hidden content".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Details {
title: Some("More info".to_string()),
open: true,
content: "Hidden content".to_string(),
}
);
}
#[test]
fn native_divider() {
let block = Block::Divider {
label: Some("Section".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Divider {
label: Some("Section".to_string()),
}
);
}
#[test]
fn native_divider_no_label() {
let block = Block::Divider {
label: None,
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Divider { label: None }
);
}
#[test]
fn native_hero() {
let block = Block::Hero {
headline: Some("Welcome".to_string()),
subtitle: Some("To SurfDoc".to_string()),
badge: Some("New".to_string()),
align: "center".to_string(),
image: Some("hero.png".to_string()),
image_alt: None,
layout: None,
transparent: false,
buttons: vec![],
content: "Some content".to_string(),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Hero {
headline: Some("Welcome".to_string()),
subtitle: Some("To SurfDoc".to_string()),
badge: Some("New".to_string()),
align: "center".to_string(),
image: Some("hero.png".to_string()),
buttons: vec![],
content: "Some content".to_string(),
}
);
}
#[test]
fn native_features() {
let block = Block::Features {
cards: vec![FeatureCard {
title: "Fast".to_string(),
icon: Some("bolt".to_string()),
body: "Very fast.".to_string(),
link_label: Some("Learn more".to_string()),
link_href: Some("/fast".to_string()),
}],
cols: Some(2),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Features {
cards: vec![NativeFeatureCard {
title: "Fast".to_string(),
icon: Some("bolt".to_string()),
body: "Very fast.".to_string(),
}],
}
);
}
#[test]
fn native_steps() {
let block = Block::Steps {
steps: vec![StepItem {
title: "Step 1".to_string(),
time: Some("5 min".to_string()),
body: "Do this".to_string(),
}],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Steps {
steps: vec![NativeStepItem {
title: "Step 1".to_string(),
time: Some("5 min".to_string()),
body: "Do this".to_string(),
}],
}
);
}
#[test]
fn native_stats() {
let block = Block::Stats {
items: vec![StatItem {
value: "99%".to_string(),
label: "Uptime".to_string(),
color: Some("green".to_string()),
}],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Stats {
items: vec![NativeStatItem {
value: "99%".to_string(),
label: "Uptime".to_string(),
color: Some("green".to_string()),
}],
}
);
}
#[test]
fn native_comparison() {
let block = Block::Comparison {
headers: vec!["".to_string(), "Free".to_string(), "Pro".to_string()],
rows: vec![vec![
"Storage".to_string(),
"1GB".to_string(),
"100GB".to_string(),
]],
highlight: Some("Pro".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Comparison {
headers: vec!["".to_string(), "Free".to_string(), "Pro".to_string()],
rows: vec![vec![
"Storage".to_string(),
"1GB".to_string(),
"100GB".to_string(),
]],
highlight: Some("Pro".to_string()),
}
);
}
#[test]
fn native_toc() {
let block = Block::Toc {
depth: 3,
entries: vec![TocEntry {
text: "Intro".to_string(),
id: "intro".to_string(),
level: 1,
}],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Toc {
depth: 3,
entries: vec![NativeTocEntry {
text: "Intro".to_string(),
id: "intro".to_string(),
level: 1,
}],
}
);
}
#[test]
fn native_before_after() {
let block = Block::BeforeAfter {
before_items: vec![BeforeAfterItem {
label: "Old".to_string(),
detail: "Slow".to_string(),
}],
after_items: vec![BeforeAfterItem {
label: "New".to_string(),
detail: "Fast".to_string(),
}],
transition: Some("SurfDoc".to_string()),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::BeforeAfter {
before_items: vec![NativeBeforeAfterItem {
label: "Old".to_string(),
detail: "Slow".to_string(),
}],
after_items: vec![NativeBeforeAfterItem {
label: "New".to_string(),
detail: "Fast".to_string(),
}],
transition: Some("SurfDoc".to_string()),
}
);
}
#[test]
fn native_pipeline() {
let block = Block::Pipeline {
steps: vec![PipelineStep {
label: "Parse".to_string(),
description: Some("tokenize".to_string()),
}],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Pipeline {
steps: vec![NativePipelineStep {
label: "Parse".to_string(),
description: Some("tokenize".to_string()),
}],
}
);
}
#[test]
fn fallback_unknown() {
let block = Block::Unknown {
name: "custom".to_string(),
attrs: BTreeMap::new(),
content: "some content".to_string(),
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Markdown { content } => {
assert!(
content.contains("custom"),
"Fallback should contain block name: {content}"
);
}
other => panic!("Expected Markdown fallback, got {:?}", other),
}
}
#[test]
fn native_nav_block() {
let block = Block::Nav {
items: vec![NavItem {
label: "Home".to_string(),
href: "/".to_string(),
icon: None,
image: None, external: false,
}],
logo: Some("Acme".to_string()),
groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Nav { logo, items } => {
assert_eq!(logo, Some("Acme".to_string()));
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "Home");
assert_eq!(items[0].href, "/");
}
other => panic!("Expected Nav, got {:?}", other),
}
}
#[test]
fn native_hero_image_block() {
let block = Block::HeroImage {
src: "hero.png".to_string(),
alt: Some("Shot".to_string()),
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::HeroImage { src, alt } => {
assert_eq!(src, "hero.png");
assert_eq!(alt, Some("Shot".to_string()));
}
other => panic!("Expected HeroImage, got {:?}", other),
}
}
#[test]
fn native_site_block_extracts_well_known_keys() {
let block = Block::Site {
domain: Some("example.com".to_string()),
properties: vec![
StyleProperty {
key: "name".to_string(),
value: "Acme".to_string(),
},
StyleProperty {
key: "accent".to_string(),
value: "#ff0000".to_string(),
},
StyleProperty {
key: "font".to_string(),
value: "montserrat".to_string(),
},
StyleProperty {
key: "description".to_string(),
value: "Tagline".to_string(),
},
StyleProperty {
key: "other".to_string(),
value: "extra-value".to_string(),
},
],
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Site {
name,
description,
accent,
font,
domain,
extras,
} => {
assert_eq!(name, Some("Acme".to_string()));
assert_eq!(description, Some("Tagline".to_string()));
assert_eq!(accent, Some("#ff0000".to_string()));
assert_eq!(font, Some("montserrat".to_string()));
assert_eq!(domain, Some("example.com".to_string()));
assert_eq!(extras, vec!["other=extra-value".to_string()]);
}
other => panic!("Expected Site, got {:?}", other),
}
}
#[test]
fn native_page_block_converts_children() {
let block = Block::Page {
route: "/".to_string(),
layout: Some("hero".to_string()),
title: Some("Home".to_string()),
sidebar: false,
content: String::new(),
children: vec![
Block::HeroImage {
src: "a.png".to_string(),
alt: None,
span: syn(),
},
Block::Markdown {
content: "Hello".to_string(),
span: syn(),
},
],
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Page {
route,
title,
layout,
children,
} => {
assert_eq!(route, "/");
assert_eq!(title, Some("Home".to_string()));
assert_eq!(layout, Some("hero".to_string()));
assert_eq!(children.len(), 2);
assert!(matches!(&children[0], NativeBlock::HeroImage { .. }));
assert!(matches!(&children[1], NativeBlock::Markdown { .. }));
}
other => panic!("Expected Page, got {:?}", other),
}
}
#[test]
fn fallback_style_empty() {
let block = Block::Style {
properties: vec![StyleProperty {
key: "bg".to_string(),
value: "blue".to_string(),
}],
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Markdown { .. } => {}
other => panic!("Expected Markdown fallback, got {:?}", other),
}
}
#[test]
fn to_native_blocks_multi_block() {
let doc = SurfDoc {
front_matter: None,
blocks: vec![
Block::Markdown {
content: "Hello".to_string(),
span: syn(),
},
Block::Callout {
callout_type: CalloutType::Info,
title: None,
content: "Note".to_string(),
span: syn(),
},
Block::Nav {
items: vec![],
logo: None,
groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
span: syn(),
},
],
source: String::new(),
};
let native = to_native_blocks(&doc);
assert_eq!(native.len(), 3);
assert!(matches!(&native[0], NativeBlock::Markdown { .. }));
assert!(matches!(&native[1], NativeBlock::Callout { .. }));
assert!(matches!(&native[2], NativeBlock::Nav { .. }));
}
#[test]
fn to_native_blocks_empty_doc() {
let doc = SurfDoc {
front_matter: None,
blocks: vec![],
source: String::new(),
};
let native = to_native_blocks(&doc);
assert!(native.is_empty());
}
#[test]
fn native_form_basic() {
let block = Block::Form {
fields: vec![
FormField {
label: "Name".to_string(),
name: "name".to_string(),
field_type: FormFieldType::Text,
required: true,
placeholder: Some("Enter your name".to_string()),
options: vec![],
},
FormField {
label: "Email".to_string(),
name: "email".to_string(),
field_type: FormFieldType::Email,
required: true,
placeholder: None,
options: vec![],
},
],
submit_label: Some("Send".to_string()),
action: None, method: None, honeypot: false,
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Form {
fields: vec![
NativeFormField {
label: "Name".to_string(),
name: "name".to_string(),
field_type: "text".to_string(),
required: true,
placeholder: Some("Enter your name".to_string()),
options: vec![],
},
NativeFormField {
label: "Email".to_string(),
name: "email".to_string(),
field_type: "email".to_string(),
required: true,
placeholder: None,
options: vec![],
},
],
submit_label: "Send".to_string(),
}
);
}
#[test]
fn native_form_default_submit_label() {
let block = Block::Form {
fields: vec![],
submit_label: None,
action: None, method: None, honeypot: false,
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Form {
submit_label,
fields,
} => {
assert_eq!(submit_label, "Submit");
assert!(fields.is_empty());
}
other => panic!("Expected Form, got {:?}", other),
}
}
#[test]
fn native_form_all_field_types() {
let types = [
(FormFieldType::Text, "text"),
(FormFieldType::Email, "email"),
(FormFieldType::Tel, "tel"),
(FormFieldType::Date, "date"),
(FormFieldType::Number, "number"),
(FormFieldType::Select, "select"),
(FormFieldType::Textarea, "textarea"),
];
for (ft, expected) in types {
let block = Block::Form {
fields: vec![FormField {
label: "Test".to_string(),
name: "test".to_string(),
field_type: ft,
required: false,
placeholder: None,
options: vec![],
}],
submit_label: None,
action: None, method: None, honeypot: false,
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Form { fields, .. } => {
assert_eq!(fields[0].field_type, expected);
}
other => panic!("Expected Form, got {:?}", other),
}
}
}
#[test]
fn native_form_select_with_options() {
let block = Block::Form {
fields: vec![FormField {
label: "Country".to_string(),
name: "country".to_string(),
field_type: FormFieldType::Select,
required: false,
placeholder: None,
options: vec!["US".to_string(), "CA".to_string(), "UK".to_string()],
}],
submit_label: Some("Go".to_string()),
action: None, method: None, honeypot: false,
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Form { fields, .. } => {
assert_eq!(fields[0].field_type, "select");
assert_eq!(fields[0].options, vec!["US", "CA", "UK"]);
}
other => panic!("Expected Form, got {:?}", other),
}
}
#[test]
fn native_gallery_basic() {
let block = Block::Gallery {
items: vec![
GalleryItem {
src: "photo1.jpg".to_string(),
caption: Some("Sunset".to_string()),
alt: Some("A sunset".to_string()),
category: Some("Nature".to_string()),
},
GalleryItem {
src: "photo2.jpg".to_string(),
caption: None,
alt: None,
category: None,
},
],
columns: Some(4),
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::Gallery {
items: vec![
NativeGalleryItem {
src: "photo1.jpg".to_string(),
caption: Some("Sunset".to_string()),
alt: Some("A sunset".to_string()),
category: Some("Nature".to_string()),
},
NativeGalleryItem {
src: "photo2.jpg".to_string(),
caption: None,
alt: None,
category: None,
},
],
columns: 4,
}
);
}
#[test]
fn native_gallery_default_columns() {
let block = Block::Gallery {
items: vec![],
columns: None,
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::Gallery { columns, items } => {
assert_eq!(columns, 3);
assert!(items.is_empty());
}
other => panic!("Expected Gallery, got {:?}", other),
}
}
#[test]
fn native_section_container_basic() {
let block = Block::Section {
bg: Some("muted".to_string()),
headline: Some("Features".to_string()),
subtitle: Some("What we offer".to_string()),
content: String::new(),
children: vec![
Block::Markdown {
content: "Hello world".to_string(),
span: syn(),
},
Block::Callout {
callout_type: CalloutType::Info,
title: None,
content: "A note".to_string(),
span: syn(),
},
],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::SectionContainer {
bg: Some("muted".to_string()),
headline: Some("Features".to_string()),
subtitle: Some("What we offer".to_string()),
children: vec![
NativeBlock::Markdown {
content: "Hello world".to_string(),
},
NativeBlock::Callout {
callout_type: "info".to_string(),
title: None,
content: "A note".to_string(),
},
],
}
);
}
#[test]
fn native_section_container_empty() {
let block = Block::Section {
bg: None,
headline: None,
subtitle: None,
content: String::new(),
children: vec![],
span: syn(),
};
assert_eq!(
convert_block(&block, 0),
NativeBlock::SectionContainer {
bg: None,
headline: None,
subtitle: None,
children: vec![],
}
);
}
#[test]
fn native_section_depth_limit() {
let block = Block::Section {
bg: None,
headline: Some("Deep section".to_string()),
subtitle: None,
content: String::new(),
children: vec![Block::Markdown {
content: "deep content".to_string(),
span: syn(),
}],
span: syn(),
};
match convert_block(&block, 7) {
NativeBlock::SectionContainer {
headline, children, ..
} => {
assert_eq!(headline, Some("Deep section".to_string()));
assert_eq!(children.len(), 1);
}
other => panic!("Expected SectionContainer at depth 7, got {:?}", other),
}
match convert_block(&block, 8) {
NativeBlock::Markdown { content } => {
assert!(
content.contains("Deep section"),
"Markdown fallback should contain headline: {content}"
);
}
other => panic!("Expected Markdown fallback at depth 8, got {:?}", other),
}
match convert_block(&block, 100) {
NativeBlock::Markdown { .. } => {}
other => panic!("Expected Markdown fallback at depth 100, got {:?}", other),
}
}
#[test]
fn native_section_depth_propagates() {
let inner = Block::Section {
bg: None,
headline: Some("Inner".to_string()),
subtitle: None,
content: String::new(),
children: vec![],
span: syn(),
};
let outer = Block::Section {
bg: None,
headline: Some("Outer".to_string()),
subtitle: None,
content: String::new(),
children: vec![inner],
span: syn(),
};
match convert_block(&outer, 0) {
NativeBlock::SectionContainer {
headline,
children,
..
} => {
assert_eq!(headline, Some("Outer".to_string()));
assert_eq!(children.len(), 1);
match &children[0] {
NativeBlock::SectionContainer {
headline: inner_hl, ..
} => {
assert_eq!(*inner_hl, Some("Inner".to_string()));
}
other => panic!("Expected inner SectionContainer, got {:?}", other),
}
}
other => panic!("Expected outer SectionContainer, got {:?}", other),
}
}
#[test]
fn to_native_blocks_with_new_variants() {
let doc = SurfDoc {
front_matter: None,
blocks: vec![
Block::Form {
fields: vec![FormField {
label: "Email".to_string(),
name: "email".to_string(),
field_type: FormFieldType::Email,
required: true,
placeholder: None,
options: vec![],
}],
submit_label: Some("Subscribe".to_string()),
action: None, method: None, honeypot: false,
span: syn(),
},
Block::Gallery {
items: vec![GalleryItem {
src: "img.png".to_string(),
caption: None,
alt: None,
category: None,
}],
columns: Some(2),
span: syn(),
},
Block::Section {
bg: Some("dark".to_string()),
headline: Some("CTA".to_string()),
subtitle: None,
content: String::new(),
children: vec![Block::Markdown {
content: "Sign up now".to_string(),
span: syn(),
}],
span: syn(),
},
],
source: String::new(),
};
let native = to_native_blocks(&doc);
assert_eq!(native.len(), 3);
assert!(matches!(&native[0], NativeBlock::Form { .. }));
assert!(matches!(&native[1], NativeBlock::Gallery { .. }));
assert!(matches!(&native[2], NativeBlock::SectionContainer { .. }));
}
#[test]
fn pipe_table_becomes_data_table() {
let src = "| Name | Role |\n|------|------|\n| Ada | Eng |\n| Bo | PM |\n";
let blocks = to_native_blocks(&crate::parse(src).doc);
let table = blocks
.iter()
.find_map(|b| match b {
NativeBlock::DataTable { headers, rows, .. } => Some((headers, rows)),
_ => None,
})
.expect("a DataTable block");
assert_eq!(table.0, &vec!["Name".to_string(), "Role".to_string()]);
assert_eq!(
table.1,
&vec![
vec!["Ada".to_string(), "Eng".to_string()],
vec!["Bo".to_string(), "PM".to_string()],
]
);
}
#[test]
fn prose_around_table_is_split_out() {
let src = "Intro paragraph.\n\n| A | B |\n|:-:|--:|\n| 1 | 2 |\n\nOutro paragraph.\n";
let blocks = expand_markdown_tables(src);
assert_eq!(blocks.len(), 3, "intro / table / outro: {blocks:?}");
assert!(
matches!(&blocks[0], NativeBlock::Markdown { content } if content.contains("Intro"))
);
assert!(matches!(&blocks[1], NativeBlock::DataTable { .. }));
assert!(
matches!(&blocks[2], NativeBlock::Markdown { content } if content.contains("Outro"))
);
}
#[test]
fn ragged_rows_are_padded_and_truncated() {
let src = "| A | B | C |\n|---|---|---|\n| 1 | 2 |\n| 1 | 2 | 3 | 4 |\n";
let blocks = expand_markdown_tables(src);
match &blocks[0] {
NativeBlock::DataTable { headers, rows, .. } => {
assert_eq!(headers.len(), 3);
assert_eq!(rows[0], vec!["1", "2", ""]); assert_eq!(rows[1], vec!["1", "2", "3"]); }
other => panic!("expected DataTable, got {other:?}"),
}
}
#[test]
fn headerless_border_pipes_optional() {
let src = "Name | Score\n---- | -----\nAda | 99\n";
let blocks = expand_markdown_tables(src);
assert!(matches!(&blocks[0], NativeBlock::DataTable { headers, .. }
if headers == &vec!["Name".to_string(), "Score".to_string()]));
}
#[test]
fn escaped_pipe_inside_cell_is_literal() {
let src = "| Expr | Note |\n|------|------|\n| a \\| b | or |\n";
let blocks = expand_markdown_tables(src);
match &blocks[0] {
NativeBlock::DataTable { rows, .. } => {
assert_eq!(rows[0], vec!["a | b".to_string(), "or".to_string()]);
}
other => panic!("expected DataTable, got {other:?}"),
}
}
#[test]
fn pipe_text_without_delimiter_stays_markdown() {
let src = "Use the `cat foo | grep bar` pattern to filter.\n";
let blocks = to_native_blocks(&crate::parse(src).doc);
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], NativeBlock::Markdown { content }
if content.contains("cat foo | grep bar")));
assert!(!blocks
.iter()
.any(|b| matches!(b, NativeBlock::DataTable { .. })));
}
#[test]
fn table_terminated_by_blank_line() {
let src = "| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter.\n";
let blocks = expand_markdown_tables(src);
match &blocks[0] {
NativeBlock::DataTable { rows, .. } => assert_eq!(rows.len(), 1),
other => panic!("expected DataTable, got {other:?}"),
}
assert!(
matches!(blocks.last(), Some(NativeBlock::Markdown { content }) if content.contains("After"))
);
}
#[test]
fn native_theme_projects_ss1_tokens_default_theme() {
let t = crate::resolve::resolve_theme(None, None, None);
let n = NativeTheme::from(&t);
assert_eq!(n.hero_btn_radius, 10.0);
assert_eq!(n.cta_radius, 10.0);
assert_eq!(n.feature_card_radius, 16.0);
assert_eq!(n.post_card_radius, 16.0);
assert_eq!(n.doc_page_radius, 16.0);
assert_eq!(n.banner_btn_radius, 2.0);
assert_eq!(n.form_submit_radius, 2.0);
assert_eq!(n.details_radius, 2.0);
assert_eq!(n.control_radius, 8.0);
assert_eq!(n.pg_card_radius, 20.0);
assert_eq!(n.pg_tile_radius, 0.0);
assert_eq!(n.feature_card_pad, 24.0);
assert_eq!(n.feature_card_hover_transform, "translateY(-2px)");
assert_eq!(n.tile_surface_bg, "var(--surface)");
assert_eq!(n.details_bg, "var(--surface-alt)");
assert_eq!(n.doc_page_bg, "var(--surface)");
assert_eq!(n.drawer_link_size, "0.9375rem");
assert_eq!(n.drawer_link_weight, "500");
assert_eq!(NATIVE_DOC_SCHEMA_VERSION, 1);
}
#[test]
fn native_theme_ss1_px_overrides_and_pills_parse() {
let mut tokens = crate::resolve::SURF_SIMPLE_TOKENS.clone();
tokens.hero_btn_radius = "999px";
tokens.form_submit_radius = "999px";
tokens.control_radius = "12px";
tokens.pg_tile_radius = "16px";
tokens.feature_card_pad = "32px";
let mut t = crate::resolve::resolve_theme(None, None, None);
t.tokens = tokens;
let n = NativeTheme::from(&t);
assert_eq!(n.hero_btn_radius, 999.0, "pill radius must survive as 999");
assert_eq!(n.form_submit_radius, 999.0);
assert_eq!(n.control_radius, 12.0);
assert_eq!(n.pg_tile_radius, 16.0);
assert_eq!(n.feature_card_pad, 32.0);
let comic = crate::resolve::resolve_theme(None, None, Some("comic"));
let nc = NativeTheme::from(&comic);
assert_eq!(nc.hero_btn_radius, 6.0, "comic hero buttons chain to its 6px btn radius");
assert_eq!(nc.feature_card_radius, 4.0, "comic cards chain to its 4px card radius");
}
#[test]
fn table_inside_section_children_expands() {
let block = Block::Section {
bg: None,
headline: None,
subtitle: None,
content: String::new(),
children: vec![Block::Markdown {
content: "| A | B |\n|---|---|\n| 1 | 2 |".to_string(),
span: syn(),
}],
span: syn(),
};
match convert_block(&block, 0) {
NativeBlock::SectionContainer { children, .. } => {
assert!(
children
.iter()
.any(|c| matches!(c, NativeBlock::DataTable { .. })),
"section child table should expand: {children:?}"
);
}
other => panic!("expected SectionContainer, got {other:?}"),
}
}
}