cooklang_to_md/
lib.rs

1//! Format a recipe as markdown
2
3use std::{fmt::Write, io};
4
5use cooklang::{
6    convert::Converter,
7    metadata::Metadata,
8    model::{Item, Section, Step},
9    ScaledRecipe,
10};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, thiserror::Error)]
14pub enum Error {
15    #[error(transparent)]
16    Io(#[from] io::Error),
17    #[error("Error serializing YAML frontmatter")]
18    Metadata(
19        #[from]
20        #[source]
21        serde_yaml::Error,
22    ),
23}
24
25pub type Result<T = (), E = Error> = std::result::Result<T, E>;
26
27/// Options for [`print_md_with_options`]
28///
29/// This implements [`Serialize`] and [`Deserialize`], so you can embed it in
30/// other configuration.
31#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
32#[serde(default)]
33#[non_exhaustive]
34pub struct Options {
35    /// Show the tags in the markdown body
36    ///
37    /// They will apear just after the title.
38    ///
39    /// The tags will have the following format:
40    /// ```md
41    /// #tag1 #tag2 #tag3
42    /// ```
43    pub tags: bool,
44    /// Set the description style in the markdown body
45    ///
46    /// It will appear just after the tags (if its enabled and
47    /// there are any tags; if not, after the title).
48    #[serde(deserialize_with = "des_or_bool")]
49    pub description: DescriptionStyle,
50    /// Make every step a regular paragraph
51    ///
52    /// A `cooklang` extensions allows to add paragraphs between steps. Because
53    /// some `Markdown` parser may not be able to set the start number of the
54    /// list, step numbers may be wrong. With this option enabled, all steps are
55    /// paragraphs because the number is escaped like:
56    /// ```md
57    /// 1\. Step.
58    /// ```
59    pub escape_step_numbers: bool,
60    /// Display amounts in italics
61    ///
62    /// This will affect the ingredients list, cookware list and inline
63    /// quantities such as temperature.
64    pub italic_amounts: bool,
65    /// Add the name of the recipe to the front-matter
66    ///
67    /// A key `name` in the metadata has preference over this.
68    #[serde(deserialize_with = "des_or_bool")]
69    pub front_matter_name: FrontMatterName,
70    /// Text to write in headings
71    pub heading: Headings,
72    /// Text to write when an ingredient or cookware item is optional
73    pub optional_marker: String,
74}
75
76impl Default for Options {
77    fn default() -> Self {
78        Self {
79            tags: true,
80            description: DescriptionStyle::Blockquote,
81            escape_step_numbers: false,
82            italic_amounts: true,
83            front_matter_name: FrontMatterName::default(),
84            heading: Headings::default(),
85            optional_marker: "(optional)".to_string(),
86        }
87    }
88}
89
90#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
91#[serde(rename_all = "snake_case")]
92pub enum DescriptionStyle {
93    /// Do not show the description in the body
94    Hidden,
95    /// Show as a blockquote
96    #[default]
97    #[serde(alias = "default")]
98    Blockquote,
99    /// Show as a heading
100    Heading,
101}
102
103impl From<bool> for DescriptionStyle {
104    fn from(value: bool) -> Self {
105        match value {
106            true => Self::default(),
107            false => Self::Hidden,
108        }
109    }
110}
111
112#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
113#[serde(transparent)]
114pub struct FrontMatterName(pub Option<String>);
115
116impl Default for FrontMatterName {
117    fn default() -> Self {
118        Self(Some("name".to_string()))
119    }
120}
121
122impl From<bool> for FrontMatterName {
123    fn from(value: bool) -> Self {
124        match value {
125            true => Self::default(),
126            false => Self(None),
127        }
128    }
129}
130
131#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
132#[serde(default)]
133pub struct Headings {
134    /// Heading for steps sections without name
135    ///
136    /// If found, `%n` is replaced by the section number.
137    pub section: String,
138    /// Ingredients section
139    pub ingredients: String,
140    /// Cookware section
141    pub cookware: String,
142    /// Steps section
143    pub steps: String,
144    /// Description section
145    ///
146    /// The description is only shown in a section if enabled.
147    pub description: String,
148}
149
150impl Default for Headings {
151    fn default() -> Self {
152        Self {
153            section: "Section %n".into(),
154            ingredients: "Ingredients".into(),
155            cookware: "Cookware".into(),
156            steps: "Steps".into(),
157            description: "Description".into(),
158        }
159    }
160}
161
162fn des_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
163where
164    D: serde::Deserializer<'de>,
165    T: serde::Deserialize<'de> + From<bool>,
166{
167    #[derive(Deserialize)]
168    #[serde(untagged)]
169    enum Wrapper<T> {
170        Bool(bool),
171        Thing(T),
172    }
173
174    let v = match Wrapper::deserialize(deserializer)? {
175        Wrapper::Bool(v) => T::from(v),
176        Wrapper::Thing(val) => val,
177    };
178    Ok(v)
179}
180
181/// Writes a recipe in Markdown format
182///
183/// This is an alias for [`print_md_with_options`] where the options are the
184/// default value.
185pub fn print_md(
186    recipe: &ScaledRecipe,
187    name: &str,
188    converter: &Converter,
189    writer: impl io::Write,
190) -> Result {
191    print_md_with_options(recipe, name, &Options::default(), converter, writer)
192}
193
194/// Writes a recipe in Markdown format
195///
196/// The metadata of the recipe will be in a YAML front-matter. Some special keys
197/// like `autor` or `servings` will be mappings or sequences instead of text if
198/// they were parsed correctly.
199///
200/// The [`Options`] are used to further customize the output. See it's
201/// documentation to know about them.
202pub fn print_md_with_options(
203    recipe: &ScaledRecipe,
204    name: &str,
205    opts: &Options,
206    converter: &Converter,
207    mut writer: impl io::Write,
208) -> Result {
209    frontmatter(&mut writer, &recipe.metadata, name, opts)?;
210
211    writeln!(writer, "# {}\n", name)?;
212
213    if opts.tags {
214        if let Some(tags) = recipe.metadata.tags() {
215            for (i, tag) in tags.iter().enumerate() {
216                write!(writer, "#{tag}")?;
217                if i < tags.len() - 1 {
218                    write!(writer, " ")?;
219                }
220            }
221            writeln!(writer, "\n")?;
222        }
223    }
224
225    if let Some(desc) = recipe.metadata.description() {
226        match opts.description {
227            DescriptionStyle::Hidden => {}
228            DescriptionStyle::Blockquote => {
229                print_wrapped_with_options(&mut writer, desc, |o| {
230                    o.initial_indent("> ").subsequent_indent("> ")
231                })?;
232                writeln!(writer)?;
233            }
234            DescriptionStyle::Heading => {
235                writeln!(writer, "## {}\n", opts.heading.description)?;
236                print_wrapped(&mut writer, desc)?;
237                writeln!(writer)?;
238            }
239        }
240    }
241
242    ingredients(&mut writer, recipe, converter, opts)?;
243    cookware(&mut writer, recipe, opts)?;
244    sections(&mut writer, recipe, opts)?;
245
246    Ok(())
247}
248
249fn frontmatter(
250    mut w: impl io::Write,
251    metadata: &Metadata,
252    name: &str,
253    opts: &Options,
254) -> Result<()> {
255    if metadata.map.is_empty() {
256        return Ok(());
257    }
258
259    let mut map = metadata.map.clone();
260
261    if let Some(name_key) = &opts.front_matter_name.0 {
262        // add name, will be overrided if other given
263        map.insert(name_key.as_str().into(), name.into());
264    }
265
266    const FRONTMATTER_FENCE: &str = "---";
267    writeln!(w, "{}", FRONTMATTER_FENCE)?;
268    serde_yaml::to_writer(&mut w, &map)?;
269    writeln!(w, "{}\n", FRONTMATTER_FENCE)?;
270    Ok(())
271}
272
273fn ingredients(
274    w: &mut impl io::Write,
275    recipe: &ScaledRecipe,
276    converter: &Converter,
277    opts: &Options,
278) -> Result {
279    if recipe.ingredients.is_empty() {
280        return Ok(());
281    }
282
283    writeln!(w, "## {}\n", opts.heading.ingredients)?;
284
285    for entry in recipe.group_ingredients(converter) {
286        let ingredient = entry.ingredient;
287
288        if !ingredient.modifiers().should_be_listed() {
289            continue;
290        }
291
292        write!(w, "- ")?;
293        if !entry.quantity.is_empty() {
294            if opts.italic_amounts {
295                write!(w, "*{}* ", entry.quantity)?;
296            } else {
297                write!(w, "{} ", entry.quantity)?;
298            }
299        }
300
301        write!(w, "{}", ingredient.display_name())?;
302
303        if ingredient.modifiers().is_optional() {
304            write!(w, " {}", opts.optional_marker)?;
305        }
306
307        if let Some(note) = &ingredient.note {
308            write!(w, " ({note})")?;
309        }
310        writeln!(w)?;
311    }
312    writeln!(w)?;
313
314    Ok(())
315}
316
317fn cookware(w: &mut impl io::Write, recipe: &ScaledRecipe, opts: &Options) -> Result {
318    if recipe.cookware.is_empty() {
319        return Ok(());
320    }
321
322    writeln!(w, "## {}\n", opts.heading.cookware)?;
323    for item in recipe.group_cookware() {
324        let cw = item.cookware;
325        write!(w, "- ")?;
326        if !item.amount.is_empty() {
327            if opts.italic_amounts {
328                write!(w, "*{} * ", item.amount)?;
329            } else {
330                write!(w, "{} ", item.amount)?;
331            }
332        }
333        write!(w, "{}", cw.display_name())?;
334
335        if cw.modifiers().is_optional() {
336            write!(w, " {}", opts.optional_marker)?;
337        }
338
339        if let Some(note) = &cw.note {
340            write!(w, " ({note})")?;
341        }
342        writeln!(w)?;
343    }
344
345    writeln!(w)?;
346    Ok(())
347}
348
349fn sections(w: &mut impl io::Write, recipe: &ScaledRecipe, opts: &Options) -> Result<()> {
350    writeln!(w, "## {}\n", opts.heading.steps)?;
351    for (idx, section) in recipe.sections.iter().enumerate() {
352        w_section(w, section, recipe, idx + 1, opts)?;
353    }
354    Ok(())
355}
356
357fn w_section(
358    w: &mut impl io::Write,
359    section: &Section,
360    recipe: &ScaledRecipe,
361    num: usize,
362    opts: &Options,
363) -> Result {
364    if section.name.is_some() || recipe.sections.len() > 1 {
365        if let Some(name) = &section.name {
366            writeln!(w, "### {name}\n")?;
367        } else {
368            let s = opts.heading.section.replace("%n", &num.to_string());
369            writeln!(w, "### {s}\n")?;
370        }
371    }
372    for content in &section.content {
373        match content {
374            cooklang::Content::Step(step) => w_step(w, step, recipe, opts)?,
375            cooklang::Content::Text(text) => print_wrapped(w, text)?,
376        };
377        writeln!(w)?;
378    }
379    Ok(())
380}
381
382fn w_step(w: &mut impl io::Write, step: &Step, recipe: &ScaledRecipe, opts: &Options) -> Result {
383    let mut step_str = step.number.to_string();
384    if opts.escape_step_numbers {
385        step_str.push_str("\\. ")
386    } else {
387        step_str.push_str(". ")
388    }
389
390    for item in &step.items {
391        match item {
392            Item::Text { value } => step_str.push_str(value),
393            &Item::Ingredient { index } => {
394                let igr = &recipe.ingredients[index];
395                step_str.push_str(igr.display_name().as_ref());
396            }
397            &Item::Cookware { index } => {
398                let cw = &recipe.cookware[index];
399                step_str.push_str(&cw.name);
400            }
401            &Item::Timer { index } => {
402                let t = &recipe.timers[index];
403                if let Some(name) = &t.name {
404                    write!(&mut step_str, "({name})").unwrap();
405                }
406                if let Some(quantity) = &t.quantity {
407                    write!(&mut step_str, "{}", quantity).unwrap();
408                }
409            }
410            &Item::InlineQuantity { index } => {
411                let q = &recipe.inline_quantities[index];
412                if opts.italic_amounts {
413                    write!(&mut step_str, "*{q}*").unwrap();
414                } else {
415                    write!(&mut step_str, "{q}").unwrap();
416                }
417            }
418        }
419    }
420    print_wrapped(w, &step_str)?;
421    Ok(())
422}
423
424fn print_wrapped(w: &mut impl io::Write, text: &str) -> Result {
425    print_wrapped_with_options(w, text, |o| o)
426}
427
428static TERM_WIDTH: std::sync::LazyLock<usize> =
429    std::sync::LazyLock::new(|| textwrap::termwidth().min(80));
430
431fn print_wrapped_with_options<F>(w: &mut impl io::Write, text: &str, f: F) -> Result
432where
433    F: FnOnce(textwrap::Options) -> textwrap::Options,
434{
435    let options = f(textwrap::Options::new(*TERM_WIDTH));
436    let lines = textwrap::wrap(text, options);
437    for line in lines {
438        writeln!(w, "{}", line)?;
439    }
440    Ok(())
441}