Skip to main content

weimo_wdf/
model.rs

1use serde::{Deserialize, Serialize};
2
3/// Value of the `schema` field in every WDF v0.1 document: `"weimo.document"`.
4pub const WDF_SCHEMA: &str = "weimo.document";
5
6/// Value of the `version` field in every WDF v0.1 document.
7pub const WDF_VERSION: u32 = 1;
8
9/// The canonical WDF document.
10///
11/// This is the model every adapter converts into and out of. It serializes to
12/// (and deserializes from) WDF JSON, so `serde_json::to_value(&doc)` produces
13/// canonical WDF JSON ready to store or send over the wire.
14///
15/// Documents received from the outside world should go through
16/// [`crate::validate_and_canonicalize`] rather than plain deserialization:
17/// deserialization alone accepts structurally valid but semantically invalid
18/// documents (unknown marks, empty lists, heading level 0, …) and skips
19/// canonicalization.
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct WdfDocument {
23    /// Always [`WDF_SCHEMA`] after canonicalization.
24    pub schema: String,
25    /// Always [`WDF_VERSION`] after canonicalization.
26    pub version: u32,
27    /// The document root; `node_type` is `"doc"`.
28    pub root: WdfRoot,
29}
30
31/// The root node of a [`WdfDocument`].
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct WdfRoot {
35    /// Node type string; `"doc"` in canonical documents.
36    #[serde(rename = "type")]
37    pub node_type: String,
38    /// One or more block nodes. An empty document is invalid.
39    pub content: Vec<WdfBlock>,
40}
41
42/// A block-level node.
43///
44/// Serializes with the `"type"` tag used in WDF JSON, e.g.
45/// `{"type": "heading", "attrs": {"level": 2}, "content": [...]}`.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(tag = "type")]
48pub enum WdfBlock {
49    /// A paragraph of inline [`WdfText`] runs.
50    #[serde(rename = "paragraph")]
51    Paragraph { content: Vec<WdfText> },
52    /// An ATX heading, level 1–6.
53    #[serde(rename = "heading")]
54    Heading {
55        attrs: HeadingAttrs,
56        content: Vec<WdfText>,
57    },
58    /// A quotation holding one or more paragraphs.
59    #[serde(rename = "blockquote")]
60    Blockquote { content: Vec<WdfParagraph> },
61    /// An unordered list; every item holds exactly one paragraph.
62    #[serde(rename = "bullet_list")]
63    BulletList { content: Vec<WdfListItem> },
64    /// An ordered list; every item holds exactly one paragraph. Canonical
65    /// documents always carry `attrs.start` (defaulting to 1).
66    #[serde(rename = "ordered_list")]
67    OrderedList {
68        #[serde(default)]
69        attrs: Option<OrderedListAttrs>,
70        content: Vec<WdfListItem>,
71    },
72}
73
74/// Heading attributes: the ATX level, 1–6.
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct HeadingAttrs {
78    pub level: u8,
79}
80
81/// Ordered-list attributes: the first item number, at least 1.
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct OrderedListAttrs {
85    pub start: u32,
86}
87
88/// A paragraph, either at the top level or inside a blockquote/list item.
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct WdfParagraph {
92    /// Node type string; `"paragraph"` in canonical documents.
93    #[serde(rename = "type")]
94    pub node_type: String,
95    pub content: Vec<WdfText>,
96}
97
98/// A list item; canonical items contain exactly one paragraph.
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct WdfListItem {
102    /// Node type string; `"list_item"` in canonical documents.
103    #[serde(rename = "type")]
104    pub node_type: String,
105    pub content: Vec<WdfParagraph>,
106}
107
108/// An inline text run. `bold` is the only supported mark in v0.1.
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct WdfText {
112    /// Node type string; `"text"` in canonical documents.
113    #[serde(rename = "type")]
114    pub node_type: String,
115    pub text: String,
116    /// Present only when non-empty; at most one `bold` mark.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub marks: Option<Vec<WdfMark>>,
119}
120
121/// An inline mark. `"bold"` is the only valid `mark_type` in v0.1.
122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct WdfMark {
125    #[serde(rename = "type")]
126    pub mark_type: String,
127}
128
129impl WdfDocument {
130    /// An empty document: one empty paragraph, valid out of the box.
131    pub fn empty() -> Self {
132        Self {
133            schema: WDF_SCHEMA.to_string(),
134            version: WDF_VERSION,
135            root: WdfRoot {
136                node_type: "doc".to_string(),
137                content: vec![WdfBlock::Paragraph { content: vec![] }],
138            },
139        }
140    }
141}
142
143impl WdfText {
144    /// A run without marks.
145    pub fn plain(text: impl Into<String>) -> Self {
146        Self {
147            node_type: "text".to_string(),
148            text: text.into(),
149            marks: None,
150        }
151    }
152
153    /// A run carrying a single `bold` mark.
154    pub fn bold(text: impl Into<String>) -> Self {
155        Self {
156            node_type: "text".to_string(),
157            text: text.into(),
158            marks: Some(vec![WdfMark {
159                mark_type: "bold".to_string(),
160            }]),
161        }
162    }
163
164    /// Whether this run carries a `bold` mark.
165    pub fn is_bold(&self) -> bool {
166        self.marks
167            .as_ref()
168            .is_some_and(|marks| marks.iter().any(|mark| mark.mark_type == "bold"))
169    }
170}
171
172/// Builds a paragraph from inline runs: `paragraph(vec![WdfText::plain("hi")])`.
173pub fn paragraph(content: Vec<WdfText>) -> WdfParagraph {
174    WdfParagraph {
175        node_type: "paragraph".to_string(),
176        content,
177    }
178}
179
180/// Builds a list item whose single paragraph wraps the given inline runs.
181pub fn list_item(content: Vec<WdfText>) -> WdfListItem {
182    WdfListItem {
183        node_type: "list_item".to_string(),
184        content: vec![paragraph(content)],
185    }
186}