1use serde::{Deserialize, Serialize};
6
7use crate::anchor::Anchor;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PageStatus {
13 Draft,
15 Canonical,
17 Retired,
19}
20
21impl PageStatus {
22 pub fn parse(s: &str) -> Option<Self> {
24 match s {
25 "draft" => Some(Self::Draft),
26 "canonical" => Some(Self::Canonical),
27 "retired" => Some(Self::Retired),
28 _ => None,
29 }
30 }
31}
32
33pub fn valid_slug(slug: &str) -> bool {
35 let segs: Vec<&str> = slug.split('/').collect();
36 slug.len() <= 128
37 && segs.len() <= 8
38 && segs.iter().all(|s| {
39 !s.is_empty()
40 && !s.starts_with('-')
41 && !s.ends_with('-')
42 && !s.contains("--")
43 && s.bytes()
44 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
45 })
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Section {
54 pub order: u32,
55 pub text: String,
56}
57
58impl Section {
59 pub fn heading(&self) -> Option<&str> {
61 let first = self.text.lines().next()?;
62 let hashes = first.bytes().take_while(|b| *b == b'#').count();
63 if (2..=6).contains(&hashes) && first.as_bytes().get(hashes) == Some(&b' ') {
64 Some(first[hashes + 1..].trim())
65 } else {
66 None
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Note {
75 pub body: String,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub covers: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Page {
83 pub slug: String,
84 pub title: String,
85 pub kind: String,
87 pub status: PageStatus,
88 pub sections: Vec<Section>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub anchors: Vec<Anchor>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub notes: Vec<Note>,
93}
94
95impl Page {
96 pub fn body(&self) -> String {
98 crate::sections::join(&self.title, &self.sections)
99 }
100}