Skip to main content

cooklang_format/
markdown.rs

1// This file includes a substantial portion of code from
2// https://github.com/Zheoni/cooklang-chef
3//
4// The original code is licensed under the MIT License, a copy of which
5// is provided below in addition to our project's license.
6//
7//
8
9// MIT License
10
11// Copyright (c) 2023 Francisco J. Sanchez
12
13// Permission is hereby granted, free of charge, to any person obtaining a copy
14// of this software and associated documentation files (the "Software"), to deal
15// in the Software without restriction, including without limitation the rights
16// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17// copies of the Software, and to permit persons to whom the Software is
18// furnished to do so, subject to the following conditions:
19
20// The above copyright notice and this permission notice shall be included in all
21// copies or substantial portions of the Software.
22
23// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29// SOFTWARE.
30
31//! Format a recipe as markdown
32
33use crate::quantity::grouped_quantity_fmt;
34use std::{fmt::Write, io};
35
36use cooklang::{
37    convert::Converter,
38    metadata::Metadata,
39    model::{Item, Section, Step},
40    Recipe,
41};
42use serde::{Deserialize, Serialize};
43
44/// Options for [`print_md_with_options`]
45///
46/// This implements [`Serialize`] and [`Deserialize`], so you can embed it in
47/// other configuration.
48///
49/// Crate-private for now: every toggle here is untested and only
50/// [`print_md`] uses it, with the defaults. Publishing it would pin both the
51/// API and the serde wire format before anything exercises either.
52#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
53#[serde(default)]
54#[non_exhaustive]
55pub(crate) struct Options {
56    /// Show the tags in the markdown body
57    ///
58    /// They will apear just after the title.
59    ///
60    /// The tags will have the following format:
61    /// ```md
62    /// #tag1 #tag2 #tag3
63    /// ```
64    pub(crate) tags: bool,
65    /// Set the description style in the markdown body
66    ///
67    /// It will appear just after the tags (if its enabled and
68    /// there are any tags; if not, after the title).
69    #[serde(deserialize_with = "des_or_bool")]
70    pub(crate) description: DescriptionStyle,
71    /// Make every step a regular paragraph
72    ///
73    /// A `cooklang` extensions allows to add paragraphs between steps. Because
74    /// some `Markdown` parser may not be able to set the start number of the
75    /// list, step numbers may be wrong. With this option enabled, all steps are
76    /// paragraphs because the number is escaped like:
77    /// ```md
78    /// 1\. Step.
79    /// ```
80    pub(crate) escape_step_numbers: bool,
81    /// Display amounts in italics
82    ///
83    /// This will affect the ingredients list, cookware list and inline
84    /// quantities such as temperature.
85    pub(crate) italic_amounts: bool,
86    /// Add the name of the recipe to the front-matter
87    ///
88    /// A key `name` in the metadata has preference over this.
89    #[serde(deserialize_with = "des_or_bool")]
90    pub(crate) front_matter_name: FrontMatterName,
91    /// Text to write in headings
92    pub(crate) heading: Headings,
93    /// Text to write when an ingredient or cookware item is optional
94    pub(crate) optional_marker: String,
95}
96
97impl Default for Options {
98    fn default() -> Self {
99        Self {
100            tags: true,
101            description: DescriptionStyle::Blockquote,
102            escape_step_numbers: false,
103            italic_amounts: true,
104            front_matter_name: FrontMatterName::default(),
105            heading: Headings::default(),
106            optional_marker: "(optional)".to_string(),
107        }
108    }
109}
110
111/// Where, if anywhere, the recipe description appears in the body
112///
113/// Deserializes from a bool too: `true` is the default style, `false` is
114/// [`Hidden`](DescriptionStyle::Hidden).
115#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
116#[serde(rename_all = "snake_case")]
117#[non_exhaustive]
118pub(crate) enum DescriptionStyle {
119    /// Do not show the description in the body
120    Hidden,
121    /// Show as a blockquote
122    #[default]
123    #[serde(alias = "default")]
124    Blockquote,
125    /// Show as a heading
126    Heading,
127}
128
129impl From<bool> for DescriptionStyle {
130    fn from(value: bool) -> Self {
131        match value {
132            true => Self::default(),
133            false => Self::Hidden,
134        }
135    }
136}
137
138/// The front-matter key the recipe name is written under, if any
139///
140/// Deserializes from a bool too: `true` is `name`, `false` writes no key.
141/// Left constructible (no `#[non_exhaustive]`) because callers configure it.
142#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
143#[serde(transparent)]
144pub(crate) struct FrontMatterName(
145    /// The key, or `None` to leave the name out of the front-matter.
146    pub(crate) Option<String>,
147);
148
149impl Default for FrontMatterName {
150    fn default() -> Self {
151        Self(Some("name".to_string()))
152    }
153}
154
155impl From<bool> for FrontMatterName {
156    fn from(value: bool) -> Self {
157        match value {
158            true => Self::default(),
159            false => Self(None),
160        }
161    }
162}
163
164/// The text used for each generated heading
165///
166/// Left constructible (no `#[non_exhaustive]`) because callers configure it;
167/// `#[serde(default)]` fills in the headings a config file leaves out.
168#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
169#[serde(default)]
170pub(crate) struct Headings {
171    /// Heading for steps sections without name
172    ///
173    /// If found, `%n` is replaced by the section number.
174    pub(crate) section: String,
175    /// Ingredients section
176    pub(crate) ingredients: String,
177    /// Cookware section
178    pub(crate) cookware: String,
179    /// Steps section
180    pub(crate) steps: String,
181    /// Description section
182    ///
183    /// The description is only shown in a section if enabled.
184    pub(crate) description: String,
185}
186
187impl Default for Headings {
188    fn default() -> Self {
189        Self {
190            section: "Section %n".into(),
191            ingredients: "Ingredients".into(),
192            cookware: "Cookware".into(),
193            steps: "Steps".into(),
194            description: "Description".into(),
195        }
196    }
197}
198
199fn des_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
200where
201    D: serde::Deserializer<'de>,
202    T: serde::Deserialize<'de> + From<bool>,
203{
204    #[derive(Deserialize)]
205    #[serde(untagged)]
206    enum Wrapper<T> {
207        Bool(bool),
208        Thing(T),
209    }
210
211    let v = match Wrapper::deserialize(deserializer)? {
212        Wrapper::Bool(v) => T::from(v),
213        Wrapper::Thing(val) => val,
214    };
215    Ok(v)
216}
217
218/// Writes a recipe in Markdown format
219///
220/// This is an alias for `print_md_with_options` where the options are the
221/// default value.
222pub fn print_md(
223    recipe: &Recipe,
224    name: &str,
225    scale: f64,
226    converter: &Converter,
227    writer: impl io::Write,
228) -> io::Result<()> {
229    print_md_with_options(recipe, name, scale, &Options::default(), converter, writer)
230}
231
232/// Writes a recipe in Markdown format
233///
234/// The metadata of the recipe will be in a YAML front-matter. Some special keys
235/// like `autor` or `servings` will be mappings or sequences instead of text if
236/// they were parsed correctly.
237///
238/// The [`Options`] are used to further customize the output. See it's
239/// documentation to know about them.
240pub(crate) fn print_md_with_options(
241    recipe: &Recipe,
242    name: &str,
243    scale: f64,
244    opts: &Options,
245    converter: &Converter,
246    mut writer: impl io::Write,
247) -> io::Result<()> {
248    frontmatter(&mut writer, &recipe.metadata, name, opts)?;
249
250    writeln!(
251        writer,
252        "# {}{}\n",
253        name,
254        if scale != 1.0 {
255            format!(" @ {scale}")
256        } else {
257            "".to_string()
258        }
259    )?;
260
261    if opts.tags {
262        if let Some(tags) = recipe.metadata.tags() {
263            for (i, tag) in tags.iter().enumerate() {
264                write!(writer, "#{tag}")?;
265                if i < tags.len() - 1 {
266                    write!(writer, " ")?;
267                }
268            }
269            writeln!(writer, "\n")?;
270        }
271    }
272
273    if let Some(desc) = recipe.metadata.description() {
274        match opts.description {
275            DescriptionStyle::Hidden => {}
276            DescriptionStyle::Blockquote => {
277                write_block(&mut writer, desc, "> ", "> ")?;
278                writeln!(writer)?;
279            }
280            DescriptionStyle::Heading => {
281                writeln!(writer, "## {}\n", opts.heading.description)?;
282                write_block(&mut writer, desc, "", "")?;
283                writeln!(writer)?;
284            }
285        }
286    }
287
288    ingredients(&mut writer, recipe, converter, opts)?;
289    cookware(&mut writer, recipe, opts, converter)?;
290    sections(&mut writer, recipe, opts)?;
291
292    Ok(())
293}
294
295fn frontmatter(
296    mut w: impl io::Write,
297    metadata: &Metadata,
298    name: &str,
299    opts: &Options,
300) -> io::Result<()> {
301    if metadata.map.is_empty() {
302        return Ok(());
303    }
304
305    let mut map = metadata.map.clone();
306
307    if let Some(name_key) = &opts.front_matter_name.0 {
308        // add name, will be overrided if other given
309        map.insert(name_key.as_str().into(), name.into());
310    }
311
312    const FRONTMATTER_FENCE: &str = "---";
313    writeln!(w, "{FRONTMATTER_FENCE}")?;
314    serde_yaml::to_writer(&mut w, &map)
315        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
316    writeln!(w, "{FRONTMATTER_FENCE}\n")?;
317    Ok(())
318}
319
320fn ingredients(
321    w: &mut impl io::Write,
322    recipe: &Recipe,
323    converter: &Converter,
324    opts: &Options,
325) -> io::Result<()> {
326    if recipe.ingredients.is_empty() {
327        return Ok(());
328    }
329
330    writeln!(w, "## {}\n", opts.heading.ingredients)?;
331
332    for entry in recipe.group_ingredients(converter) {
333        let ingredient = entry.ingredient;
334
335        if !ingredient.modifiers().should_be_listed() {
336            continue;
337        }
338
339        write!(w, "- ")?;
340        if !entry.quantity.is_empty() {
341            let quantity = grouped_quantity_fmt(&entry.quantity);
342            if opts.italic_amounts {
343                write!(w, "*{quantity}* ")?;
344            } else {
345                write!(w, "{quantity} ")?;
346            }
347        }
348
349        if let Some(reference) = &ingredient.reference {
350            let sep = crate::REFERENCE_SEPARATOR;
351            let path = reference.components.join(sep);
352            write!(
353                w,
354                "[{}]({}{}{})",
355                ingredient.display_name(),
356                path,
357                sep,
358                ingredient.name
359            )?;
360        } else {
361            write!(w, "{}", ingredient.display_name())?;
362        }
363
364        if ingredient.modifiers().is_optional() {
365            write!(w, " {}", opts.optional_marker)?;
366        }
367
368        if let Some(note) = &ingredient.note {
369            write!(w, " ({note})")?;
370        }
371        writeln!(w)?;
372    }
373    writeln!(w)?;
374
375    Ok(())
376}
377
378fn cookware(
379    w: &mut impl io::Write,
380    recipe: &Recipe,
381    opts: &Options,
382    converter: &Converter,
383) -> io::Result<()> {
384    if recipe.cookware.is_empty() {
385        return Ok(());
386    }
387
388    writeln!(w, "## {}\n", opts.heading.cookware)?;
389    for item in recipe.group_cookware(converter) {
390        let cw = item.cookware;
391        write!(w, "- ")?;
392        if !item.quantity.is_empty() {
393            let quantity = grouped_quantity_fmt(&item.quantity);
394            if opts.italic_amounts {
395                write!(w, "*{quantity}* ")?;
396            } else {
397                write!(w, "{quantity} ")?;
398            }
399        }
400        write!(w, "{}", cw.display_name())?;
401
402        if cw.modifiers().is_optional() {
403            write!(w, " {}", opts.optional_marker)?;
404        }
405
406        if let Some(note) = &cw.note {
407            write!(w, " ({note})")?;
408        }
409        writeln!(w)?;
410    }
411
412    writeln!(w)?;
413    Ok(())
414}
415
416fn sections(w: &mut impl io::Write, recipe: &Recipe, opts: &Options) -> io::Result<()> {
417    writeln!(w, "## {}\n", opts.heading.steps)?;
418    for (idx, section) in recipe.sections.iter().enumerate() {
419        w_section(w, section, recipe, idx + 1, opts)?;
420    }
421    Ok(())
422}
423
424fn w_section(
425    w: &mut impl io::Write,
426    section: &Section,
427    recipe: &Recipe,
428    num: usize,
429    opts: &Options,
430) -> io::Result<()> {
431    if section.name.is_some() || recipe.sections.len() > 1 {
432        if let Some(name) = &section.name {
433            writeln!(w, "### {name}\n")?;
434        } else {
435            let s = opts.heading.section.replace("%n", &num.to_string());
436            writeln!(w, "### {s}\n")?;
437        }
438    }
439    for content in &section.content {
440        match content {
441            cooklang::Content::Step(step) => w_step(w, step, recipe, opts)?,
442            cooklang::Content::Text(text) => {
443                // Check if this is a list bullet item
444                if text.trim() == "-" {
445                    // Add extra newline for list separation
446                    writeln!(w)?
447                } else {
448                    // Format as a note with blockquote style
449                    writeln!(w, "> **Note:** {}", text.trim())?
450                }
451            }
452        };
453        writeln!(w)?;
454    }
455    Ok(())
456}
457
458fn w_step(w: &mut impl io::Write, step: &Step, recipe: &Recipe, opts: &Options) -> io::Result<()> {
459    let mut marker = step.number.to_string();
460    if opts.escape_step_numbers {
461        marker.push_str("\\. ")
462    } else {
463        marker.push_str(". ")
464    }
465
466    let mut step_str = String::new();
467    for item in &step.items {
468        match item {
469            Item::Text { value } => {
470                // Check if this is a list bullet and format it properly for markdown
471                if value.trim() == "-" {
472                    step_str.push_str("\n- ");
473                } else {
474                    step_str.push_str(value);
475                }
476            }
477            &Item::Ingredient { index } => {
478                let igr = &recipe.ingredients[index];
479                step_str.push_str(igr.display_name().as_ref());
480            }
481            &Item::Cookware { index } => {
482                let cw = &recipe.cookware[index];
483                step_str.push_str(&cw.name);
484            }
485            &Item::Timer { index } => {
486                let t = &recipe.timers[index];
487                if let Some(name) = &t.name {
488                    write!(&mut step_str, "({name})").expect("writing to a String is infallible");
489                }
490                if let Some(quantity) = &t.quantity {
491                    write!(&mut step_str, "{quantity}").expect("writing to a String is infallible");
492                }
493            }
494            &Item::InlineQuantity { index } => {
495                let q = &recipe.inline_quantities[index];
496                if opts.italic_amounts {
497                    write!(&mut step_str, "*{q}*").expect("writing to a String is infallible");
498                } else {
499                    write!(&mut step_str, "{q}").expect("writing to a String is infallible");
500                }
501            }
502        }
503    }
504    // A list item's content starts after its marker, so a line break inside the
505    // step has to be indented to that column to stay inside the item.
506    let indent = " ".repeat(marker.chars().count());
507    write_block(w, &step_str, &marker, &indent)?;
508    Ok(())
509}
510
511/// Writes `text` with `first` before its first line and `rest` before the rest.
512///
513/// Nothing is re-wrapped. A Markdown document is not a terminal: hard wrapping
514/// a step broke sentences at whatever column the terminal happened to be, and
515/// the continuation lines then sat at column zero, which ends the ordered list
516/// for a CommonMark parser (<https://github.com/cooklang/cookcli/issues/497>).
517/// Only the line breaks the recipe itself has survive, indented so they stay
518/// part of the block they belong to.
519fn write_block(w: &mut impl io::Write, text: &str, first: &str, rest: &str) -> io::Result<()> {
520    // `split` rather than `lines` so an empty `text` still writes its prefix —
521    // a step with no items must keep its number.
522    for (i, line) in text.trim_end_matches('\n').split('\n').enumerate() {
523        let prefix = if i == 0 { first } else { rest };
524        // Trailing whitespace is invisible in Markdown source but two spaces of
525        // it are a hard line break, so a blank quoted line must not keep the
526        // space from its prefix.
527        writeln!(w, "{}", format!("{prefix}{line}").trim_end())?;
528    }
529    Ok(())
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::test_support::{parse_recipe, PARSER};
536
537    /// The step from the issue: both it and the description run well past any
538    /// terminal width, and the step puts a multi-word ingredient right where
539    /// the wrapping used to break the line.
540    const FIXTURE: &str = "\
541---
542title: Long Lines
543description: A description that is comfortably longer than eighty columns so that any hard wrapping shows up as an extra line.
544---
545
546Place the base on a lightly floured surface and spread @San Marzano tomato \
547sauce{5%tbsp} on it. Add some fresh @basil leaves{} and fresh \
548@mozzarella cheese{100%grams}.
549";
550
551    fn render(text: &str) -> String {
552        let recipe = parse_recipe(text, "Long Lines", 1.0)
553            .expect("the fixture parses")
554            .value;
555        let mut buf = Vec::new();
556        print_md(&recipe, "Long Lines", 1.0, PARSER.converter(), &mut buf).expect("formats");
557        String::from_utf8(buf).expect("utf8")
558    }
559
560    /// <https://github.com/cooklang/cookcli/issues/497>: the step was hard
561    /// wrapped at the terminal width, putting a line break in the middle of a
562    /// sentence — and, worse, making the output depend on the terminal.
563    #[test]
564    fn step_text_is_not_hard_wrapped() {
565        let md = render(FIXTURE);
566
567        let step = md
568            .lines()
569            .find(|l| l.starts_with("1. "))
570            .expect("the step is numbered");
571        assert_eq!(
572            step,
573            "1. Place the base on a lightly floured surface and spread San Marzano tomato \
574sauce on it. Add some fresh basil leaves and fresh mozzarella cheese.",
575            "the step should be one line, in:\n{md}"
576        );
577    }
578
579    #[test]
580    fn description_is_not_hard_wrapped() {
581        let md = render(FIXTURE);
582
583        let quoted: Vec<_> = md.lines().filter(|l| l.starts_with("> ")).collect();
584        assert_eq!(
585            quoted,
586            [
587                "> A description that is comfortably longer than eighty columns so that any hard \
588wrapping shows up as an extra line."
589            ],
590            "the description should be one blockquote line, in:\n{md}"
591        );
592    }
593
594    /// A line break the source *does* have stays, but it has to be indented to
595    /// the step's content column or CommonMark ends the ordered list at it.
596    #[test]
597    fn a_list_inside_a_step_is_indented_under_the_number() {
598        let md = render("Gather the following:\n- @plain flour{200%g}\n- @whole milk{250%ml}\n");
599
600        assert!(
601            md.contains("1. Gather the following: - plain flour\n   - whole milk\n"),
602            "the bullet should be indented under the step, in:\n{md}"
603        );
604    }
605}