novel_cli/utils/
markdown.rs

1use std::{
2    fs,
3    ops::Range,
4    path::{Path, PathBuf},
5};
6
7use color_eyre::eyre::{self, Result};
8use pulldown_cmark::{Event, MetadataBlockKind, Options, Parser, Tag, TagEnd, TextMergeWithOffset};
9use serde::{Deserialize, Serialize};
10use serde_with::skip_serializing_none;
11
12#[must_use]
13#[skip_serializing_none]
14#[derive(Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub struct Metadata {
17    pub title: String,
18    pub author: String,
19    pub lang: Lang,
20    pub description: Option<String>,
21    pub cover_image: Option<PathBuf>,
22}
23
24#[must_use]
25#[derive(Clone, Copy, Serialize, Deserialize)]
26pub enum Lang {
27    #[serde(rename = "zh-Hant")]
28    ZhHant,
29    #[serde(rename = "zh-Hans")]
30    ZhHans,
31}
32
33impl Metadata {
34    pub fn cover_image_is_ok(&self) -> bool {
35        self.cover_image.as_ref().is_none_or(|path| path.is_file())
36    }
37}
38
39pub fn get_metadata_from_file<T>(markdown_path: T) -> Result<Metadata>
40where
41    T: AsRef<Path>,
42{
43    let bytes = fs::read(markdown_path)?;
44    let markdown = simdutf8::basic::from_utf8(&bytes)?;
45
46    let mut parser = TextMergeWithOffset::new(
47        Parser::new_ext(markdown, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS).into_offset_iter(),
48    );
49
50    get_metadata(&mut parser)
51}
52
53pub fn get_metadata<'a, T>(parser: &mut TextMergeWithOffset<'a, T>) -> Result<Metadata>
54where
55    T: Iterator<Item = (Event<'a>, Range<usize>)>,
56{
57    let event = parser.next();
58    if event.is_none()
59        || !matches!(
60            event.unwrap().0,
61            Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle))
62        )
63    {
64        eyre::bail!("Markdown files should start with a metadata block")
65    }
66
67    let metadata: Metadata;
68    if let Some((Event::Text(text), _)) = parser.next() {
69        metadata = serde_yml::from_str(&text)?;
70    } else {
71        eyre::bail!("Metadata block content does not exist")
72    }
73
74    let event = parser.next();
75    if event.is_none()
76        || !matches!(
77            event.unwrap().0,
78            Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle))
79        )
80    {
81        eyre::bail!("Metadata block should end with `---` or `...`")
82    }
83
84    Ok(metadata)
85}
86
87pub fn read_markdown_to_images<T>(markdown_path: T) -> Result<Vec<PathBuf>>
88where
89    T: AsRef<Path>,
90{
91    let bytes = fs::read(markdown_path)?;
92    let markdown = simdutf8::basic::from_utf8(&bytes)?;
93
94    let mut parser = TextMergeWithOffset::new(
95        Parser::new_ext(markdown, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS).into_offset_iter(),
96    );
97
98    let metadata = get_metadata(&mut parser)?;
99
100    let parser = parser.filter_map(|(event, _)| {
101        if let Event::Start(Tag::Image { dest_url, .. }) = event {
102            Some(PathBuf::from(dest_url.as_ref()))
103        } else {
104            None
105        }
106    });
107
108    let mut result: Vec<PathBuf> = parser.collect();
109    if metadata.cover_image.is_some() {
110        result.push(metadata.cover_image.unwrap())
111    }
112
113    Ok(result)
114}