Skip to main content

lean_ctx_doc/
model.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Pages, sections and notes.
4
5use serde::{Deserialize, Serialize};
6
7use crate::anchor::Anchor;
8
9/// Where a page stands in its curation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PageStatus {
13    /// New material, not yet vouched for.
14    Draft,
15    /// Settled: what the next reader should trust.
16    Canonical,
17    /// History: kept, behind nothing.
18    Retired,
19}
20
21impl PageStatus {
22    /// Parses the status names emspace and the wire use.
23    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
33/// C0 2.2 slug: `seg(/seg){0,7}`, seg `[a-z0-9]+(-[a-z0-9]+)*`, ≤ 128 bytes.
34pub 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/// One stretch of a page body.
49///
50/// `text` is raw markdown and includes the section's own heading line when it
51/// has one; concatenating the sections in `order` reproduces the body.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Section {
54    pub order: u32,
55    pub text: String,
56}
57
58impl Section {
59    /// The heading this section opens with (`## Foo` → `Foo`), if any.
60    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/// A dated addendum to a page. A note that `covers` anchors accounts for
72/// their change in words, so the page reads noted instead of stale.
73#[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/// A documentation page.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Page {
83    pub slug: String,
84    pub title: String,
85    /// Free classification: `reference`, `design`, `project`, ….
86    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    /// The body as one markdown document, title line first.
97    pub fn body(&self) -> String {
98        crate::sections::join(&self.title, &self.sections)
99    }
100}