Skip to main content

typub_core/
lib.rs

1//! Shared capability types and content model for typub.
2//!
3//! This crate defines the canonical enum types for platform capabilities,
4//! asset strategies, theme identifiers, and the core content model.
5//!
6//! Per [[ADR-0002]], extracting these types into a shared subcrate ensures:
7//! - Single source of truth for enum variants
8//! - Serde-based validation of TOML config at build time
9//! - Type safety via `ThemeId` newtype for theme identifiers
10
11pub mod content;
12
13pub use content::{
14    Content, ContentFormat, ContentMeta, DiscoverResult, PostInfo, PostPlatformConfig,
15};
16
17use serde::{Deserialize, Deserializer, Serialize};
18use std::fmt;
19use std::ops::Deref;
20
21// ============================================================================
22// MathRendering
23// ============================================================================
24
25/// How a platform renders math equations.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MathRendering {
29    /// Platform supports inline SVG — use Typst's native SVG rendering.
30    #[default]
31    Svg,
32    /// Platform requires LaTeX math macros.
33    Latex,
34    /// Platform supports base64 images but not SVG — rasterize to PNG via resvg.
35    /// Per [[WI-2026-02-13-026]].
36    Png,
37}
38
39impl MathRendering {
40    /// Returns the Rust expression string for code generation.
41    pub fn code_expr(&self) -> &'static str {
42        match self {
43            Self::Svg => "MathRendering::Svg",
44            Self::Latex => "MathRendering::Latex",
45            Self::Png => "MathRendering::Png",
46        }
47    }
48}
49
50// ============================================================================
51// MathDelimiters
52// ============================================================================
53
54/// Math delimiter syntax for Markdown output.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum MathDelimiters {
58    /// Dollar sign syntax: `$...$` for inline, `$$...$$` for block.
59    #[default]
60    Dollar,
61    /// Backslash-paren syntax: `\(...\)` for inline, `\[...\]` for block.
62    Brackets,
63    /// Mixed syntax: `\(...\)` for inline, `$$...$$` for block.
64    /// Used by platforms like SegmentFault that don't support `\[...\]`.
65    #[serde(rename = "brackets_inline_dollar_block")]
66    BracketsInlineDollarBlock,
67}
68
69impl MathDelimiters {
70    /// Returns the Rust expression string for code generation.
71    pub fn code_expr(&self) -> &'static str {
72        match self {
73            Self::Dollar => "MathDelimiters::Dollar",
74            Self::Brackets => "MathDelimiters::Brackets",
75            Self::BracketsInlineDollarBlock => "MathDelimiters::BracketsInlineDollarBlock",
76        }
77    }
78}
79
80// ============================================================================
81// AssetStrategy
82// ============================================================================
83
84/// Strategy for handling assets on each platform.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum AssetStrategy {
88    /// Copy files alongside content (e.g., Astro).
89    Copy,
90    /// Embed as base64 in HTML.
91    Embed,
92    /// Upload to platform storage (e.g., Confluence attachments).
93    Upload,
94    /// Upload to external S3-compatible storage.
95    /// Per [[RFC-0004:C-EXTERNAL-STRATEGY]].
96    External,
97}
98
99impl AssetStrategy {
100    /// Parse a user-provided asset strategy string (case-insensitive).
101    pub fn parse(s: &str) -> Option<Self> {
102        match s.to_lowercase().as_str() {
103            "copy" => Some(Self::Copy),
104            "embed" => Some(Self::Embed),
105            "upload" => Some(Self::Upload),
106            "external" => Some(Self::External),
107            _ => None,
108        }
109    }
110
111    /// Returns true if this strategy requires deferred upload during Materialize stage.
112    /// Per [[RFC-0004:C-PIPELINE-INTEGRATION]], both `Upload` and `External` require
113    /// placeholder tokens in Finalize and actual upload in Materialize.
114    pub fn requires_deferred_upload(&self) -> bool {
115        matches!(self, Self::Upload | Self::External)
116    }
117
118    /// Returns the Rust expression string for code generation.
119    pub fn code_expr(&self) -> &'static str {
120        match self {
121            Self::Copy => "AssetStrategy::Copy",
122            Self::Embed => "AssetStrategy::Embed",
123            Self::Upload => "AssetStrategy::Upload",
124            Self::External => "AssetStrategy::External",
125        }
126    }
127}
128
129// ============================================================================
130// CapabilityGapBehavior / CapabilitySupport
131// ============================================================================
132
133/// Behavior when a capability is not supported by a platform.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135pub enum CapabilityGapBehavior {
136    WarnAndDegrade,
137    HardError,
138}
139
140/// Generic policy action for handling non-conforming or unsupported semantic nodes.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum NodePolicyAction {
144    Pass,
145    Sanitize,
146    Drop,
147    Error,
148}
149
150/// Whether a platform capability is supported.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum CapabilitySupport {
153    Supported,
154    Unsupported(CapabilityGapBehavior),
155}
156
157impl CapabilitySupport {
158    /// Return the gap behavior if unsupported, or `None` if supported.
159    pub fn gap_behavior(&self) -> Option<CapabilityGapBehavior> {
160        match self {
161            Self::Supported => None,
162            Self::Unsupported(behavior) => Some(*behavior),
163        }
164    }
165
166    /// Returns the Rust expression string for code generation.
167    pub fn code_expr(&self) -> &'static str {
168        match self {
169            Self::Supported => "CapabilitySupport::Supported",
170            Self::Unsupported(CapabilityGapBehavior::WarnAndDegrade) => {
171                "CapabilitySupport::Unsupported(UnsupportedBehavior::WarnAndDegrade)"
172            }
173            Self::Unsupported(CapabilityGapBehavior::HardError) => {
174                "CapabilitySupport::Unsupported(UnsupportedBehavior::HardError)"
175            }
176        }
177    }
178}
179
180/// Custom serde: TOML uses flat strings like `"supported"`, `"unsupported_warn"`.
181impl<'de> Deserialize<'de> for CapabilitySupport {
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: Deserializer<'de>,
185    {
186        let s = String::deserialize(deserializer)?;
187        match s.as_str() {
188            "supported" => Ok(Self::Supported),
189            "unsupported_warn" => Ok(Self::Unsupported(CapabilityGapBehavior::WarnAndDegrade)),
190            "unsupported_error" => Ok(Self::Unsupported(CapabilityGapBehavior::HardError)),
191            other => Err(serde::de::Error::unknown_variant(
192                other,
193                &["supported", "unsupported_warn", "unsupported_error"],
194            )),
195        }
196    }
197}
198
199impl Serialize for CapabilitySupport {
200    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
201    where
202        S: serde::Serializer,
203    {
204        let s = match self {
205            Self::Supported => "supported",
206            Self::Unsupported(CapabilityGapBehavior::WarnAndDegrade) => "unsupported_warn",
207            Self::Unsupported(CapabilityGapBehavior::HardError) => "unsupported_error",
208        };
209        serializer.serialize_str(s)
210    }
211}
212
213// ============================================================================
214// DraftSupport
215// ============================================================================
216
217/// Draft support capability per [[RFC-0005:C-DRAFT-SUPPORT]].
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum DraftSupport {
220    /// Platform has no draft concept. Content is always published immediately.
221    None,
222    /// Same object with a status field that can be toggled.
223    /// `reversible` indicates whether publish → draft transition is supported.
224    StatusField { reversible: bool },
225    /// Draft and published content are separate objects with different IDs.
226    SeparateObjects,
227}
228
229impl DraftSupport {
230    /// Returns the Rust expression string for code generation.
231    pub fn code_expr(&self) -> &'static str {
232        match self {
233            Self::None => "DraftSupport::None",
234            Self::StatusField { reversible: true } => {
235                "DraftSupport::StatusField { reversible: true }"
236            }
237            Self::StatusField { reversible: false } => {
238                "DraftSupport::StatusField { reversible: false }"
239            }
240            Self::SeparateObjects => "DraftSupport::SeparateObjects",
241        }
242    }
243}
244
245/// Custom serde: TOML uses flat strings like `"none"`, `"status_field_reversible"`.
246impl<'de> Deserialize<'de> for DraftSupport {
247    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248    where
249        D: Deserializer<'de>,
250    {
251        let s = String::deserialize(deserializer)?;
252        match s.as_str() {
253            "none" => Ok(Self::None),
254            "status_field_reversible" => Ok(Self::StatusField { reversible: true }),
255            "status_field_irreversible" => Ok(Self::StatusField { reversible: false }),
256            "separate_objects" => Ok(Self::SeparateObjects),
257            other => Err(serde::de::Error::unknown_variant(
258                other,
259                &[
260                    "none",
261                    "status_field_reversible",
262                    "status_field_irreversible",
263                    "separate_objects",
264                ],
265            )),
266        }
267    }
268}
269
270impl Serialize for DraftSupport {
271    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
272    where
273        S: serde::Serializer,
274    {
275        let s = match self {
276            Self::None => "none",
277            Self::StatusField { reversible: true } => "status_field_reversible",
278            Self::StatusField { reversible: false } => "status_field_irreversible",
279            Self::SeparateObjects => "separate_objects",
280        };
281        serializer.serialize_str(s)
282    }
283}
284
285// ============================================================================
286// TaxonomyCapability
287// ============================================================================
288
289/// Taxonomy-related capabilities for content classification and lifecycle.
290/// Grouped into a sub-struct for better organization within AdapterCapability.
291#[derive(Debug, Clone, Copy)]
292pub struct TaxonomyCapability {
293    pub tags: CapabilitySupport,
294    pub categories: CapabilitySupport,
295    pub internal_links: CapabilitySupport,
296    pub draft: DraftSupport,
297}
298
299impl TaxonomyCapability {
300    /// Create a new taxonomy capability with all fields specified.
301    pub const fn new(
302        tags: CapabilitySupport,
303        categories: CapabilitySupport,
304        internal_links: CapabilitySupport,
305        draft: DraftSupport,
306    ) -> Self {
307        Self {
308            tags,
309            categories,
310            internal_links,
311            draft,
312        }
313    }
314
315    /// Create a taxonomy capability where all features are fully supported.
316    pub const fn full() -> Self {
317        Self {
318            tags: CapabilitySupport::Supported,
319            categories: CapabilitySupport::Supported,
320            internal_links: CapabilitySupport::Supported,
321            draft: DraftSupport::StatusField { reversible: true },
322        }
323    }
324
325    /// Create a taxonomy capability with minimal support (no tags/categories/draft).
326    pub const fn minimal() -> Self {
327        Self {
328            tags: CapabilitySupport::Unsupported(CapabilityGapBehavior::WarnAndDegrade),
329            categories: CapabilitySupport::Unsupported(CapabilityGapBehavior::WarnAndDegrade),
330            internal_links: CapabilitySupport::Supported,
331            draft: DraftSupport::None,
332        }
333    }
334
335    pub fn tags_gap_behavior(&self) -> Option<CapabilityGapBehavior> {
336        self.tags.gap_behavior()
337    }
338
339    pub fn categories_gap_behavior(&self) -> Option<CapabilityGapBehavior> {
340        self.categories.gap_behavior()
341    }
342
343    pub fn internal_links_gap_behavior(&self) -> Option<CapabilityGapBehavior> {
344        self.internal_links.gap_behavior()
345    }
346
347    pub fn draft_support(&self) -> DraftSupport {
348        self.draft
349    }
350}
351
352// ============================================================================
353// ThemeId
354// ============================================================================
355
356/// Newtype for theme identifiers.
357///
358/// Prevents accidental confusion between theme IDs and other strings.
359/// Implements `Deref<Target = str>` for ergonomic use at call sites.
360#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
361#[serde(transparent)]
362pub struct ThemeId(String);
363
364impl ThemeId {
365    /// Create a new `ThemeId` from a string.
366    pub fn new(s: impl Into<String>) -> Self {
367        Self(s.into())
368    }
369
370    /// Get the inner string value.
371    pub fn as_str(&self) -> &str {
372        &self.0
373    }
374
375    /// Consume and return the inner string.
376    pub fn into_inner(self) -> String {
377        self.0
378    }
379}
380
381impl Deref for ThemeId {
382    type Target = str;
383
384    fn deref(&self) -> &str {
385        &self.0
386    }
387}
388
389impl fmt::Display for ThemeId {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        f.write_str(&self.0)
392    }
393}
394
395impl From<String> for ThemeId {
396    fn from(s: String) -> Self {
397        Self(s)
398    }
399}
400
401impl From<&str> for ThemeId {
402    fn from(s: &str) -> Self {
403        Self(s.to_string())
404    }
405}
406
407impl AsRef<str> for ThemeId {
408    fn as_ref(&self) -> &str {
409        &self.0
410    }
411}
412
413// ============================================================================
414// LinkResolution
415// ============================================================================
416
417/// Result of resolving an internal link.
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub enum LinkResolution {
420    /// The href is not an internal link.
421    NonInternal,
422    /// The internal link was resolved to a URL.
423    InternalResolved { slug: String, url: String },
424    /// The internal link target was not found.
425    InternalUnresolved { slug: String },
426}
427
428// ============================================================================
429// Tests
430// ============================================================================
431
432#[cfg(test)]
433mod tests {
434    #![allow(clippy::expect_used)]
435    use super::*;
436
437    #[test]
438    fn test_math_rendering_serde_roundtrip() {
439        let json = serde_json::to_string(&MathRendering::Svg).expect("serialize");
440        assert_eq!(json, r#""svg""#);
441        let parsed: MathRendering = serde_json::from_str(&json).expect("deserialize");
442        assert_eq!(parsed, MathRendering::Svg);
443    }
444
445    #[test]
446    fn test_math_rendering_code_expr() {
447        assert_eq!(MathRendering::Svg.code_expr(), "MathRendering::Svg");
448        assert_eq!(MathRendering::Latex.code_expr(), "MathRendering::Latex");
449    }
450
451    #[test]
452    fn test_math_delimiters_serde_roundtrip() {
453        let json = serde_json::to_string(&MathDelimiters::Brackets).expect("serialize");
454        assert_eq!(json, r#""brackets""#);
455        let parsed: MathDelimiters = serde_json::from_str(&json).expect("deserialize");
456        assert_eq!(parsed, MathDelimiters::Brackets);
457    }
458
459    #[test]
460    fn test_asset_strategy_serde() {
461        let json = serde_json::to_string(&AssetStrategy::External).expect("serialize");
462        assert_eq!(json, r#""external""#);
463        let parsed: AssetStrategy = serde_json::from_str(&json).expect("deserialize");
464        assert_eq!(parsed, AssetStrategy::External);
465    }
466
467    #[test]
468    fn test_asset_strategy_parse_aliases() {
469        assert_eq!(
470            AssetStrategy::parse("external"),
471            Some(AssetStrategy::External)
472        );
473        assert_eq!(AssetStrategy::parse("upload"), Some(AssetStrategy::Upload));
474        assert_eq!(AssetStrategy::parse("COPY"), Some(AssetStrategy::Copy));
475        assert_eq!(AssetStrategy::parse("unknown"), None);
476    }
477
478    #[test]
479    fn test_capability_support_serde() {
480        let json =
481            serde_json::to_string(&CapabilitySupport::Supported).expect("serialize supported");
482        assert_eq!(json, r#""supported""#);
483
484        let warn = CapabilitySupport::Unsupported(CapabilityGapBehavior::WarnAndDegrade);
485        let json = serde_json::to_string(&warn).expect("serialize warn");
486        assert_eq!(json, r#""unsupported_warn""#);
487
488        let parsed: CapabilitySupport =
489            serde_json::from_str(r#""unsupported_error""#).expect("deserialize error");
490        assert_eq!(
491            parsed,
492            CapabilitySupport::Unsupported(CapabilityGapBehavior::HardError)
493        );
494    }
495
496    #[test]
497    fn test_capability_support_invalid() {
498        let result = serde_json::from_str::<CapabilitySupport>(r#""garbage""#);
499        assert!(result.is_err());
500    }
501
502    #[test]
503    fn test_draft_support_serde() {
504        let json = serde_json::to_string(&DraftSupport::None).expect("serialize none");
505        assert_eq!(json, r#""none""#);
506
507        let reversible = DraftSupport::StatusField { reversible: true };
508        let json = serde_json::to_string(&reversible).expect("serialize reversible");
509        assert_eq!(json, r#""status_field_reversible""#);
510
511        let parsed: DraftSupport =
512            serde_json::from_str(r#""separate_objects""#).expect("deserialize");
513        assert_eq!(parsed, DraftSupport::SeparateObjects);
514    }
515
516    #[test]
517    fn test_theme_id_deref_and_display() {
518        let id = ThemeId::new("wechat-green");
519        assert_eq!(&*id, "wechat-green");
520        assert_eq!(id.as_str(), "wechat-green");
521        assert_eq!(format!("{}", id), "wechat-green");
522    }
523
524    #[test]
525    fn test_theme_id_serde() {
526        let id = ThemeId::new("elegant");
527        let json = serde_json::to_string(&id).expect("serialize");
528        assert_eq!(json, r#""elegant""#);
529        let parsed: ThemeId = serde_json::from_str(&json).expect("deserialize");
530        assert_eq!(parsed, id);
531    }
532
533    #[test]
534    fn test_theme_id_from() {
535        let id: ThemeId = "dark".into();
536        assert_eq!(id.as_str(), "dark");
537
538        let id: ThemeId = String::from("github").into();
539        assert_eq!(id.as_str(), "github");
540    }
541}