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}