Skip to main content

markdown_compiler/content/
model.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize, de};
4use thiserror::Error;
5use time::OffsetDateTime;
6use url::Url;
7use uuid::Uuid;
8
9#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
10#[serde(transparent)]
11pub struct LogicalContentPath(String);
12
13impl LogicalContentPath {
14    pub fn new(value: impl Into<String>) -> Self {
15        Self(value.into())
16    }
17
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21}
22
23impl fmt::Display for LogicalContentPath {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        formatter.write_str(self.as_str())
26    }
27}
28
29#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PostCollection {
32    Posts,
33    Drafts,
34}
35
36impl PostCollection {
37    pub const fn directory(self) -> &'static str {
38        match self {
39            Self::Posts => "posts",
40            Self::Drafts => "drafts",
41        }
42    }
43
44    pub(crate) fn contains_path(self, path: &str) -> bool {
45        path.strip_prefix(self.directory())
46            .and_then(|remainder| remainder.strip_prefix('/'))
47            .is_some_and(|remainder| !remainder.is_empty())
48    }
49}
50
51#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub struct PostId {
53    value: Uuid,
54    canonical: String,
55}
56
57impl PostId {
58    pub fn parse(value: &str) -> Result<Self, PostIdParseError> {
59        let parsed = Uuid::parse_str(value).map_err(|_| PostIdParseError)?;
60        if parsed.hyphenated().to_string() != value {
61            return Err(PostIdParseError);
62        }
63        Ok(Self {
64            value: parsed,
65            canonical: value.to_owned(),
66        })
67    }
68
69    pub const fn as_uuid(&self) -> Uuid {
70        self.value
71    }
72
73    pub fn as_str(&self) -> &str {
74        &self.canonical
75    }
76}
77
78impl fmt::Display for PostId {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter.write_str(self.as_str())
81    }
82}
83
84impl Serialize for PostId {
85    fn serialize<Serializer>(
86        &self,
87        serializer: Serializer,
88    ) -> Result<Serializer::Ok, Serializer::Error>
89    where
90        Serializer: serde::Serializer,
91    {
92        serializer.serialize_str(&self.to_string())
93    }
94}
95
96impl<'de> Deserialize<'de> for PostId {
97    fn deserialize<Deserializer>(deserializer: Deserializer) -> Result<Self, Deserializer::Error>
98    where
99        Deserializer: serde::Deserializer<'de>,
100    {
101        let value = String::deserialize(deserializer)?;
102        Self::parse(&value).map_err(de::Error::custom)
103    }
104}
105
106#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
107#[error("post ID must be a canonical lowercase hyphenated UUID")]
108pub struct PostIdParseError;
109
110#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
111pub enum PlainTextError {
112    #[error("value must not be empty")]
113    Empty,
114    #[error("value must not contain control characters")]
115    ContainsControl,
116}
117
118fn normalize_plain_text(value: impl Into<String>) -> Result<String, PlainTextError> {
119    let value = value.into();
120    let trimmed = value.trim();
121    if trimmed.is_empty() {
122        return Err(PlainTextError::Empty);
123    }
124    if trimmed.chars().any(char::is_control) {
125        return Err(PlainTextError::ContainsControl);
126    }
127    Ok(trimmed.to_owned())
128}
129
130macro_rules! plain_text_type {
131    ($name:ident) => {
132        #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
133        #[serde(transparent)]
134        pub struct $name(String);
135
136        impl $name {
137            pub fn new(value: impl Into<String>) -> Result<Self, PlainTextError> {
138                normalize_plain_text(value).map(Self)
139            }
140
141            pub fn as_str(&self) -> &str {
142                &self.0
143            }
144        }
145    };
146}
147
148plain_text_type!(SiteTitle);
149plain_text_type!(SiteDescription);
150plain_text_type!(AuthorName);
151plain_text_type!(PostTitle);
152plain_text_type!(PostDescription);
153
154#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
155#[error("value must use at most 1024 bytes of lowercase ASCII words separated by single hyphens")]
156pub struct RouteValueError;
157
158const MAX_ROUTE_VALUE_BYTES: usize = 1024;
159
160fn is_route_safe(value: &str) -> bool {
161    !value.is_empty()
162        && value.len() <= MAX_ROUTE_VALUE_BYTES
163        && value.split('-').all(|word| {
164            !word.is_empty()
165                && word
166                    .bytes()
167                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
168        })
169}
170
171macro_rules! route_value_type {
172    ($name:ident) => {
173        #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
174        #[serde(transparent)]
175        pub struct $name(String);
176
177        impl $name {
178            pub fn parse(value: impl Into<String>) -> Result<Self, RouteValueError> {
179                let value = value.into();
180                if is_route_safe(&value) {
181                    Ok(Self(value))
182                } else {
183                    Err(RouteValueError)
184                }
185            }
186
187            pub fn as_str(&self) -> &str {
188                &self.0
189            }
190        }
191    };
192}
193
194route_value_type!(PostSlug);
195route_value_type!(PostAlias);
196
197#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
198#[serde(transparent)]
199pub struct PostTag(String);
200
201impl PostTag {
202    pub fn parse(value: impl Into<String>) -> Result<Self, RouteValueError> {
203        let normalized = value.into().trim().to_ascii_lowercase();
204        if is_route_safe(&normalized) {
205            Ok(Self(normalized))
206        } else {
207            Err(RouteValueError)
208        }
209    }
210
211    pub fn as_str(&self) -> &str {
212        &self.0
213    }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct PublicationBaseUrl(Url);
218
219impl PublicationBaseUrl {
220    pub fn parse(value: &str) -> Result<Self, PublicationBaseUrlError> {
221        if value.chars().any(char::is_control) || value.contains('\\') {
222            return Err(PublicationBaseUrlError);
223        }
224        let value = value.trim();
225        let has_valid_raw_authority = value.split_once("://").is_some_and(|(_, remainder)| {
226            let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
227            let authority = &remainder[..authority_end];
228            let suffix = &remainder[authority_end..];
229            !authority.contains('@') && matches!(suffix, "" | "/")
230        });
231        let mut parsed = Url::parse(value).map_err(|_| PublicationBaseUrlError)?;
232        if parsed.scheme() != "https"
233            || parsed.host().is_none()
234            || !has_valid_raw_authority
235            || !parsed.username().is_empty()
236            || parsed.password().is_some()
237            || parsed.query().is_some()
238            || parsed.fragment().is_some()
239            || parsed.path() != "/"
240        {
241            return Err(PublicationBaseUrlError);
242        }
243        parsed.set_path("/");
244        Ok(Self(parsed))
245    }
246
247    pub fn as_url(&self) -> &Url {
248        &self.0
249    }
250
251    pub fn as_str(&self) -> &str {
252        self.0.as_str()
253    }
254}
255
256impl Serialize for PublicationBaseUrl {
257    fn serialize<Serializer>(
258        &self,
259        serializer: Serializer,
260    ) -> Result<Serializer::Ok, Serializer::Error>
261    where
262        Serializer: serde::Serializer,
263    {
264        serializer.serialize_str(self.as_str())
265    }
266}
267
268#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
269#[error("base URL must be an absolute HTTPS origin without credentials, path, query, or fragment")]
270pub struct PublicationBaseUrlError;
271
272#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
273#[serde(transparent)]
274pub struct UnresolvedAssetReference(String);
275
276impl UnresolvedAssetReference {
277    pub(crate) fn new(value: impl Into<String>) -> Result<Self, PlainTextError> {
278        normalize_plain_text(value).map(Self)
279    }
280
281    pub fn as_str(&self) -> &str {
282        &self.0
283    }
284}
285
286#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
287#[serde(transparent)]
288pub struct UnresolvedHttpsOrigin(String);
289
290impl UnresolvedHttpsOrigin {
291    pub(crate) fn new(value: impl Into<String>) -> Result<Self, PlainTextError> {
292        normalize_plain_text(value).map(Self)
293    }
294
295    pub fn as_str(&self) -> &str {
296        &self.0
297    }
298}
299
300#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
301#[serde(transparent)]
302pub struct MarkdownSource(String);
303
304impl MarkdownSource {
305    pub fn new(value: impl Into<String>) -> Self {
306        Self(value.into())
307    }
308
309    pub fn as_str(&self) -> &str {
310        &self.0
311    }
312}
313
314#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
315#[serde(rename_all = "snake_case")]
316pub enum DraftStatus {
317    Publishable,
318    Draft,
319}
320
321#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
322#[serde(rename_all = "snake_case")]
323pub enum PostTipPolicy {
324    InheritPublication,
325    Enabled,
326    Disabled,
327}
328
329#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
330#[serde(rename_all = "snake_case")]
331pub enum DefaultPostTipPolicy {
332    Enabled,
333    Disabled,
334}
335
336#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
337pub struct SiteSettings {
338    pub title: SiteTitle,
339    pub base_url: PublicationBaseUrl,
340    pub description: SiteDescription,
341    pub favicon: Option<UnresolvedAssetReference>,
342    pub image: Option<UnresolvedAssetReference>,
343}
344
345impl SiteSettings {
346    pub(crate) const fn new(
347        title: SiteTitle,
348        base_url: PublicationBaseUrl,
349        description: SiteDescription,
350        favicon: Option<UnresolvedAssetReference>,
351        image: Option<UnresolvedAssetReference>,
352    ) -> Self {
353        Self {
354            title,
355            base_url,
356            description,
357            favicon,
358            image,
359        }
360    }
361}
362
363#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
364pub struct AuthorSettings {
365    pub name: AuthorName,
366}
367
368impl AuthorSettings {
369    pub(crate) const fn new(name: AuthorName) -> Self {
370        Self { name }
371    }
372}
373
374#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
375pub struct PublicationAssetSettings {
376    pub allowed_https_origins: Vec<UnresolvedHttpsOrigin>,
377}
378
379#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
380pub struct PublicationSettings {
381    pub site: SiteSettings,
382    pub author: AuthorSettings,
383    pub assets: PublicationAssetSettings,
384    pub tips: DefaultPostTipPolicy,
385}
386
387impl PublicationSettings {
388    pub(crate) const fn new(
389        site: SiteSettings,
390        author: AuthorSettings,
391        assets: PublicationAssetSettings,
392        tips: DefaultPostTipPolicy,
393    ) -> Self {
394        Self {
395            site,
396            author,
397            assets,
398            tips,
399        }
400    }
401}
402
403#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
404pub struct PostMetadata {
405    pub id: PostId,
406    pub title: PostTitle,
407    pub slug: PostSlug,
408    #[serde(with = "time::serde::rfc3339")]
409    pub authored_at: OffsetDateTime,
410    #[serde(with = "time::serde::rfc3339::option")]
411    pub updated_at: Option<OffsetDateTime>,
412    pub description: PostDescription,
413    pub image: Option<UnresolvedAssetReference>,
414    pub tags: Vec<PostTag>,
415    pub aliases: Vec<PostAlias>,
416    pub draft: DraftStatus,
417    pub tips: PostTipPolicy,
418}
419
420#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
421pub struct PostDocument {
422    pub path: LogicalContentPath,
423    pub metadata: PostMetadata,
424    pub markdown: MarkdownSource,
425}
426
427impl PostDocument {
428    pub(crate) const fn new(
429        path: LogicalContentPath,
430        metadata: PostMetadata,
431        markdown: MarkdownSource,
432    ) -> Self {
433        Self {
434            path,
435            metadata,
436            markdown,
437        }
438    }
439}
440
441#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
442pub struct ValidatedContent {
443    pub publication: PublicationSettings,
444    pub posts: Vec<PostDocument>,
445}
446
447impl ValidatedContent {
448    pub(crate) const fn new(publication: PublicationSettings, posts: Vec<PostDocument>) -> Self {
449        Self { publication, posts }
450    }
451}