use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
use crate::brand::{AutoLayout, ShapePredicate};
use crate::pages::{Page, PageOrigin, RawPage};
pub fn classify(raw: Vec<RawPage>, cfg: &AutoLayout) -> Vec<Page> {
let last_idx = raw.len().saturating_sub(1);
raw.into_iter()
.enumerate()
.map(|(idx, page)| classify_one(idx, last_idx, page, cfg))
.collect()
}
fn classify_one(idx: usize, last_idx: usize, page: RawPage, cfg: &AutoLayout) -> Page {
if let Some(c) = page.explicit_class {
return Page {
class: c,
body: page.body,
origin: PageOrigin::Explicit,
};
}
if let Some(shape) = detect_shape(&page.body)
&& let Some(rule) = cfg.rules.iter().find(|r| r.when == shape)
{
return Page {
class: rule.class.clone(),
body: page.body,
origin: PageOrigin::AutoShape,
};
}
if idx == 0
&& let Some(c) = &cfg.first
{
return Page {
class: c.clone(),
body: page.body,
origin: PageOrigin::AutoPositional,
};
}
if idx == last_idx
&& last_idx != 0
&& let Some(c) = &cfg.last
{
return Page {
class: c.clone(),
body: page.body,
origin: PageOrigin::AutoPositional,
};
}
Page {
class: cfg.default.clone(),
body: page.body,
origin: PageOrigin::AutoDefault,
}
}
fn detect_shape(body: &str) -> Option<ShapePredicate> {
if body.trim().is_empty() {
return Some(ShapePredicate::Empty);
}
let parser = Parser::new_ext(body, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH);
let mut top_blocks: Vec<TopBlock> = Vec::new();
let mut depth: i32 = 0;
let mut current: Option<TopBlock> = None;
for ev in parser {
match ev {
Event::Start(tag) => {
if depth == 0 {
current = Some(match &tag {
Tag::Heading {
level: HeadingLevel::H1,
..
} => TopBlock::H1,
Tag::Heading { .. } => TopBlock::OtherHeading,
Tag::Paragraph => TopBlock::Paragraph,
Tag::BlockQuote(_) => TopBlock::BlockQuote,
Tag::Image { .. } => TopBlock::Image,
_ => TopBlock::Other,
});
}
depth += 1;
if let Tag::Image { .. } = tag
&& depth == 2
&& matches!(current, Some(TopBlock::Paragraph))
{
current = Some(TopBlock::ParagraphWithImage);
}
}
Event::End(_) => {
depth -= 1;
if depth == 0
&& let Some(b) = current.take()
{
top_blocks.push(b);
}
}
_ => {}
}
}
match top_blocks.as_slice() {
[TopBlock::H1] => Some(ShapePredicate::SingleH1Only),
[TopBlock::Image] | [TopBlock::ParagraphWithImage] => Some(ShapePredicate::SingleImageOnly),
[TopBlock::BlockQuote] => Some(ShapePredicate::SingleBlockquoteOnly),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TopBlock {
H1,
OtherHeading,
Paragraph,
ParagraphWithImage,
BlockQuote,
Image,
Other,
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> AutoLayout {
AutoLayout::default()
}
fn raw(class: Option<&str>, body: &str) -> RawPage {
RawPage {
explicit_class: class.map(str::to_string),
body: body.to_string(),
}
}
#[test]
fn explicit_class_wins() {
let pages = classify(vec![raw(Some("custom"), "# anything")], &cfg());
assert_eq!(pages[0].class, "custom");
assert_eq!(pages[0].origin, PageOrigin::Explicit);
}
#[test]
fn positional_first_and_last() {
let pages = classify(
vec![
raw(None, "intro\n"),
raw(None, "middle\n"),
raw(None, "bye\n"),
],
&cfg(),
);
assert_eq!(pages[0].class, "hero");
assert_eq!(pages[0].origin, PageOrigin::AutoPositional);
assert_eq!(pages[1].class, "content");
assert_eq!(pages[1].origin, PageOrigin::AutoDefault);
assert_eq!(pages[2].class, "thanks");
assert_eq!(pages[2].origin, PageOrigin::AutoPositional);
}
#[test]
fn single_h1_only_becomes_section_divider() {
let pages = classify(
vec![raw(None, "a"), raw(None, "# Section"), raw(None, "b")],
&cfg(),
);
assert_eq!(pages[1].class, "section-divider");
assert_eq!(pages[1].origin, PageOrigin::AutoShape);
}
#[test]
fn single_image_becomes_image_full() {
let pages = classify(
vec"), raw(None, "b")],
&cfg(),
);
assert_eq!(pages[1].class, "image-full");
assert_eq!(pages[1].origin, PageOrigin::AutoShape);
}
#[test]
fn single_blockquote_becomes_callout() {
let pages = classify(
vec![raw(None, "a"), raw(None, "> a wise quote"), raw(None, "b")],
&cfg(),
);
assert_eq!(pages[1].class, "callout");
}
#[test]
fn shape_rule_beats_positional_on_first() {
let pages = classify(
vec![raw(None, "# Section"), raw(None, "x"), raw(None, "y")],
&cfg(),
);
assert_eq!(pages[0].class, "section-divider");
}
#[test]
fn single_page_doc_uses_first_not_last() {
let pages = classify(vec![raw(None, "hello")], &cfg());
assert_eq!(pages[0].class, "hero");
}
#[test]
fn empty_rule_fires_for_empty_page() {
let mut config = cfg();
config.rules.push(crate::brand::AutoRule {
when: ShapePredicate::Empty,
class: "blank".to_string(),
});
let pages = classify(
vec![raw(None, "a"), raw(None, " \n"), raw(None, "b")],
&config,
);
assert_eq!(pages[1].class, "blank");
assert_eq!(pages[1].origin, PageOrigin::AutoShape);
}
#[test]
fn empty_page_without_rule_falls_through_to_positional() {
let pages = classify(vec![raw(None, " \n"), raw(None, "b")], &cfg());
assert_eq!(pages[0].class, "hero");
assert_eq!(pages[0].origin, PageOrigin::AutoPositional);
}
}