cobalt/
cobalt.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io::Write;
4use std::path;
5
6use anyhow::Context as _;
7use jsonfeed::Feed;
8use log::debug;
9use log::trace;
10use log::warn;
11use sitemap::writer::SiteMapWriter;
12
13use crate::cobalt_model;
14use crate::cobalt_model::Collection;
15use crate::cobalt_model::files;
16use crate::cobalt_model::permalink;
17use crate::cobalt_model::{Config, Minify, SortOrder};
18use crate::document::{Document, RenderContext};
19use crate::error::Result;
20use crate::pagination;
21
22struct Context {
23    pub(crate) destination: path::PathBuf,
24    pub(crate) source_files: cobalt_core::Source,
25    pub(crate) page_extensions: Vec<liquid::model::KString>,
26    pub(crate) include_drafts: bool,
27    pub(crate) pages: Collection,
28    pub(crate) posts: Collection,
29    pub(crate) site: cobalt_model::Site,
30    pub(crate) site_attributes: liquid::Object,
31    pub(crate) layouts: HashMap<String, String>,
32    pub(crate) liquid: cobalt_model::Liquid,
33    pub(crate) markdown: cobalt_model::Markdown,
34    pub(crate) assets: cobalt_model::Assets,
35    pub(crate) minify: Minify,
36}
37
38impl Context {
39    fn with_config(config: Config) -> Result<Self> {
40        let Config {
41            source,
42            destination,
43            ignore,
44            page_extensions,
45            include_drafts,
46            pages,
47            posts,
48            site,
49            layouts_path,
50            liquid,
51            markdown,
52            syntax: _,
53            assets,
54            minify,
55        } = config;
56
57        let source_files = cobalt_core::Source::new(&source, ignore.iter().map(|s| s.as_str()))?;
58        let site_attributes = site.load(&source)?;
59        let liquid = liquid.build()?;
60        let markdown = markdown.build();
61        let assets = assets.build()?;
62
63        let layouts = find_layouts(&layouts_path)?;
64        let layouts = parse_layouts(&layouts);
65
66        let context = Context {
67            destination,
68            source_files,
69            page_extensions,
70            include_drafts,
71            pages,
72            posts,
73            site,
74            site_attributes,
75            layouts,
76            liquid,
77            markdown,
78            assets,
79            minify,
80        };
81        Ok(context)
82    }
83}
84
85/// The primary build function that transforms a directory into a site
86pub fn build(config: Config) -> Result<()> {
87    let context = Context::with_config(config)?;
88
89    let mut post_paths = Vec::new();
90    let mut post_draft_paths = Vec::new();
91    let mut page_paths = Vec::new();
92    let mut asset_paths = Vec::new();
93    for path in context.source_files.iter() {
94        match classify_path(
95            &path.rel_path,
96            &context.pages,
97            &context.posts,
98            &context.page_extensions,
99        ) {
100            Some((slug, false)) if context.pages.slug == slug => page_paths.push(path),
101            Some((slug, true)) if context.pages.slug == slug => {
102                unreachable!("We don't support draft pages")
103            }
104            Some((slug, false)) if context.posts.slug == slug => post_paths.push(path),
105            Some((slug, true)) if context.posts.slug == slug => post_draft_paths.push(path),
106            Some((slug, _)) => unreachable!("Unknown collection: {}", slug),
107            None => asset_paths.push(path),
108        }
109    }
110
111    let mut posts = parse_pages(&post_paths, &context.posts, context.include_drafts)?;
112    if !post_draft_paths.is_empty() {
113        parse_drafts(&post_draft_paths, &mut posts, &context.posts)?;
114    }
115
116    let documents = parse_pages(&page_paths, &context.pages, context.include_drafts)?;
117
118    sort_pages(&mut posts, &context.posts)?;
119    generate_posts(&mut posts, &context)?;
120
121    // check if we should create an RSS file and create it!
122    if let Some(ref path) = context.posts.rss {
123        let path = path.to_path(&context.destination);
124        create_rss(
125            &path,
126            &context.posts,
127            &posts,
128            context.site.base_url.as_deref(),
129        )?;
130    }
131    // check if we should create an jsonfeed file and create it!
132    if let Some(ref path) = context.posts.jsonfeed {
133        let path = path.to_path(&context.destination);
134        create_jsonfeed(
135            &path,
136            &context.posts,
137            &posts,
138            context.site.base_url.as_deref(),
139        )?;
140    }
141    if let Some(ref path) = context.site.sitemap {
142        let path = path.to_path(&context.destination);
143        create_sitemap(&path, &posts, &documents, context.site.base_url.as_deref())?;
144    }
145
146    generate_pages(posts, documents, &context)?;
147
148    // copy all remaining files in the source to the destination
149    // compile SASS along the way
150    for asset_path in asset_paths {
151        context
152            .assets
153            .process(&asset_path.abs_path, &context.destination, &context.minify)?;
154    }
155
156    Ok(())
157}
158
159fn generate_collections_var(
160    posts_data: &[liquid::model::Value],
161    context: &Context,
162) -> (liquid::model::KString, liquid::model::Value) {
163    let mut posts_variable = context.posts.attributes();
164    posts_variable.insert(
165        "pages".into(),
166        liquid::model::Value::Array(posts_data.to_vec()),
167    );
168    let global_collection: liquid::Object = vec![(
169        context.posts.slug.clone(),
170        liquid::model::Value::Object(posts_variable),
171    )]
172    .into_iter()
173    .collect();
174    (
175        "collections".into(),
176        liquid::model::Value::Object(global_collection),
177    )
178}
179
180fn generate_doc(
181    doc: &mut Document,
182    context: &Context,
183    global_collection: (liquid::model::KString, liquid::model::Value),
184) -> Result<()> {
185    // Everything done with `globals` is terrible for performance.  liquid#95 allows us to
186    // improve this.
187    let mut globals: liquid::Object = vec![
188        (
189            "site".into(),
190            liquid::model::Value::Object(context.site_attributes.clone()),
191        ),
192        global_collection,
193    ]
194    .into_iter()
195    .collect();
196    globals.insert(
197        "page".into(),
198        liquid::model::Value::Object(doc.attributes.clone()),
199    );
200    {
201        let render_context = RenderContext {
202            parser: &context.liquid,
203            markdown: &context.markdown,
204            globals: &globals,
205            minify: context.minify.clone(),
206        };
207
208        doc.render_excerpt(&render_context).with_context(|| {
209            anyhow::format_err!("Failed to render excerpt for {}", doc.file_path)
210        })?;
211        doc.render_content(&render_context).with_context(|| {
212            anyhow::format_err!("Failed to render content for {}", doc.file_path)
213        })?;
214    }
215
216    // Refresh `page` with the `excerpt` / `content` attribute
217    globals.insert(
218        "page".into(),
219        liquid::model::Value::Object(doc.attributes.clone()),
220    );
221    let render_context = RenderContext {
222        parser: &context.liquid,
223        markdown: &context.markdown,
224        globals: &globals,
225        minify: context.minify.clone(),
226    };
227    let doc_html = doc
228        .render(&render_context, &context.layouts)
229        .with_context(|| anyhow::format_err!("Failed to render for {}", doc.file_path))?;
230    files::write_document_file(doc_html, doc.file_path.to_path(&context.destination))?;
231    Ok(())
232}
233
234fn generate_pages(posts: Vec<Document>, documents: Vec<Document>, context: &Context) -> Result<()> {
235    // during post rendering additional attributes such as content were
236    // added to posts. collect them so that non-post documents can access them
237    let posts_data: Vec<liquid::model::Value> = posts
238        .into_iter()
239        .map(|x| liquid::model::Value::Object(x.attributes))
240        .collect();
241
242    trace!("Generating other documents");
243    for mut doc in documents {
244        trace!("Generating {}", doc.url_path);
245        if doc.front.pagination.is_some() {
246            let paginators = pagination::generate_paginators(&mut doc, &posts_data)?;
247            // page 1 uses frontmatter.permalink instead of paginator.permalink
248            let mut paginators = paginators.into_iter();
249            let paginator = paginators
250                .next()
251                .expect("We detected pagination enabled but we have no paginator");
252            generate_doc(
253                &mut doc,
254                context,
255                (
256                    "paginator".into(),
257                    liquid::model::Value::Object(paginator.into()),
258                ),
259            )?;
260            for paginator in paginators {
261                let mut doc_page = doc.clone();
262                doc_page.file_path = permalink::format_url_as_file(&paginator.index_permalink);
263                generate_doc(
264                    &mut doc_page,
265                    context,
266                    (
267                        "paginator".into(),
268                        liquid::model::Value::Object(paginator.into()),
269                    ),
270                )?;
271            }
272        } else {
273            generate_doc(
274                &mut doc,
275                context,
276                generate_collections_var(&posts_data, context),
277            )?;
278        };
279    }
280    Ok(())
281}
282
283fn generate_posts(posts: &mut [Document], context: &Context) -> Result<()> {
284    // collect all posts attributes to pass them to other posts for rendering
285    let simple_posts_data: Vec<liquid::model::Value> = posts
286        .iter()
287        .map(|x| liquid::model::Value::Object(x.attributes.clone()))
288        .collect();
289
290    trace!("Generating posts");
291    for (i, post) in &mut posts.iter_mut().enumerate() {
292        trace!("Generating {}", post.url_path);
293
294        // posts are in reverse date order, so previous post is the next in the list (+1)
295        let previous = simple_posts_data
296            .get(i + 1)
297            .cloned()
298            .unwrap_or(liquid::model::Value::Nil);
299        post.attributes.insert("previous".into(), previous);
300
301        let next = if i >= 1 {
302            simple_posts_data.get(i - 1)
303        } else {
304            None
305        }
306        .cloned()
307        .unwrap_or(liquid::model::Value::Nil);
308        post.attributes.insert("next".into(), next);
309
310        generate_doc(
311            post,
312            context,
313            generate_collections_var(&simple_posts_data, context),
314        )?;
315    }
316
317    Ok(())
318}
319
320fn sort_pages(posts: &mut [Document], collection: &Collection) -> Result<()> {
321    // January 1, 1970 0:00:00 UTC, the beginning of time
322    let default_date = cobalt_model::DateTime::default();
323
324    // sort documents by date, if there's no date (none was provided or it couldn't be read) then
325    // fall back to the default date
326    posts.sort_by(|a, b| {
327        b.front
328            .published_date
329            .unwrap_or(default_date)
330            .cmp(&a.front.published_date.unwrap_or(default_date))
331    });
332
333    match collection.order {
334        SortOrder::Asc => posts.reverse(),
335        SortOrder::Desc | SortOrder::None => (),
336    }
337
338    Ok(())
339}
340
341fn parse_drafts(
342    page_paths: &[cobalt_core::SourcePath],
343    documents: &mut Vec<Document>,
344    collection: &Collection,
345) -> Result<()> {
346    let dir = &collection.dir;
347    let drafts_dir = collection
348        .drafts_dir
349        .as_deref()
350        .expect("Caller checked first");
351    for file_path in page_paths {
352        // Provide a fake path as if it was not a draft
353        let rel_src = file_path
354            .rel_path
355            .strip_prefix(drafts_dir)
356            .expect("file was found under the root");
357        let new_path = dir.join(rel_src);
358
359        let default_front = cobalt_config::Frontmatter {
360            is_draft: Some(true),
361            ..Default::default()
362        }
363        .merge(&collection.default);
364
365        let doc = Document::parse(&file_path.abs_path, &new_path, default_front)
366            .with_context(|| anyhow::format_err!("Failed to parse {}", file_path.rel_path))?;
367        documents.push(doc);
368    }
369    Ok(())
370}
371
372fn parse_pages(
373    page_paths: &[cobalt_core::SourcePath],
374    collection: &Collection,
375    include_drafts: bool,
376) -> Result<Vec<Document>> {
377    let mut documents = vec![];
378    for file_path in page_paths {
379        let default_front = collection.default.clone();
380
381        let doc = Document::parse(&file_path.abs_path, &file_path.rel_path, default_front)
382            .with_context(|| anyhow::format_err!("Failed to parse {}", file_path.rel_path))?;
383        if !doc.front.is_draft || include_drafts {
384            documents.push(doc);
385        } else {
386            log::trace!("Skipping draft {}", file_path.rel_path);
387        }
388    }
389    Ok(documents)
390}
391
392fn find_layouts(layouts: &path::Path) -> Result<files::Files> {
393    let mut files = files::FilesBuilder::new(layouts)?;
394    files.ignore_hidden(false)?;
395    files.build()
396}
397
398fn parse_layouts(files: &files::Files) -> HashMap<String, String> {
399    let (entries, errors): (Vec<_>, Vec<_>) = files
400        .files()
401        .map(|file_path| {
402            let rel_src = file_path
403                .strip_prefix(files.root())
404                .expect("file was found under the root");
405
406            let layout_data = files::read_file(&file_path).with_context(|| {
407                anyhow::format_err!("Failed to load layout {}", rel_src.display())
408            })?;
409
410            let path = rel_src
411                .to_str()
412                .ok_or_else(|| {
413                    anyhow::format_err!("File name not valid liquid path: {}", rel_src.display())
414                })?
415                .to_owned();
416
417            Ok((path, layout_data))
418        })
419        .partition(Result::is_ok);
420
421    for error in errors {
422        warn!("{}", error.expect_err("partition to filter out oks"));
423    }
424
425    entries
426        .into_iter()
427        .map(|entry| entry.expect("partition to filter out errors"))
428        .collect()
429}
430
431// creates a new RSS file with the contents of the site blog
432fn create_rss(
433    path: &path::Path,
434    collection: &Collection,
435    documents: &[Document],
436    base_url: Option<&str>,
437) -> Result<()> {
438    debug!("Creating RSS file at {}", path.display());
439
440    let title = &collection.title;
441    let description = collection.description.as_deref().unwrap_or("");
442    let link = base_url
443        .as_ref()
444        .ok_or_else(|| anyhow::format_err!("`base_url` is required for RSS support"))?;
445
446    let items: Result<Vec<rss::Item>> = documents.iter().map(|doc| doc.to_rss(link)).collect();
447    let items = items?;
448
449    let channel = rss::ChannelBuilder::default()
450        .title(title.as_str().to_owned())
451        .link(link.to_owned())
452        .description(description.to_owned())
453        .items(items)
454        .build();
455
456    let rss_string = channel.to_string();
457    trace!("RSS data: {rss_string}");
458
459    // create target directories if any exist
460    if let Some(parent) = path.parent() {
461        fs::create_dir_all(parent)
462            .with_context(|| anyhow::format_err!("Could not create {}", parent.display()))?;
463    }
464
465    let mut rss_file = fs::File::create(path)?;
466    rss_file.write_all(&rss_string.into_bytes())?;
467    rss_file.write_all(b"\n")?;
468
469    Ok(())
470}
471
472// creates a new jsonfeed file with the contents of the site blog
473fn create_jsonfeed(
474    path: &path::Path,
475    collection: &Collection,
476    documents: &[Document],
477    base_url: Option<&str>,
478) -> Result<()> {
479    debug!("Creating jsonfeed file at {}", path.display());
480
481    let title = &collection.title;
482    let description = collection.description.as_deref().unwrap_or("");
483    let link = base_url
484        .as_ref()
485        .ok_or_else(|| anyhow::format_err!("`base_url` is required for jsonfeed support"))?;
486
487    let jsonitems = documents.iter().map(|doc| doc.to_jsonfeed(link)).collect();
488
489    let feed = Feed {
490        title: title.to_string(),
491        items: jsonitems,
492        home_page_url: Some((*link).to_string()),
493        description: Some(description.to_string()),
494        ..Default::default()
495    };
496
497    let jsonfeed_string = jsonfeed::to_string(&feed).unwrap();
498    files::write_document_file(jsonfeed_string, path)?;
499
500    Ok(())
501}
502
503fn create_sitemap(
504    path: &path::Path,
505    documents: &[Document],
506    documents_pages: &[Document],
507    base_url: Option<&str>,
508) -> Result<()> {
509    debug!("Creating sitemap file at {}", path.display());
510    let mut buff = Vec::new();
511    let writer = SiteMapWriter::new(&mut buff);
512    let link = base_url
513        .as_ref()
514        .ok_or_else(|| anyhow::format_err!("`base_url` is required for sitemap support"))?;
515    let mut urls = writer.start_urlset()?;
516    for doc in documents {
517        doc.to_sitemap(link, &mut urls)?;
518    }
519
520    let link = base_url
521        .as_ref()
522        .ok_or_else(|| anyhow::format_err!("`base_url` is required for sitemap support"))?;
523    for doc in documents_pages {
524        doc.to_sitemap(link, &mut urls)?;
525    }
526    urls.end()?;
527
528    files::write_document_file(String::from_utf8(buff)?, path)?;
529
530    Ok(())
531}
532
533pub fn classify_path<'s>(
534    path: &relative_path::RelativePathBuf,
535    pages: &'s Collection,
536    posts: &'s Collection,
537    page_extensions: &[liquid::model::KString],
538) -> Option<(&'s str, bool)> {
539    if ext_contains(page_extensions, path) {
540        if path.starts_with(&posts.dir) {
541            return Some((posts.slug.as_str(), false));
542        }
543
544        if let Some(drafts_dir) = posts.drafts_dir.as_ref() {
545            if path.starts_with(drafts_dir) {
546                return Some((posts.slug.as_str(), true));
547            }
548        }
549
550        Some((pages.slug.as_str(), false))
551    } else {
552        None
553    }
554}
555
556fn ext_contains(extensions: &[liquid::model::KString], file: &relative_path::RelativePath) -> bool {
557    if extensions.is_empty() {
558        return true;
559    }
560
561    file.extension()
562        .map(|ext| extensions.iter().any(|e| e == ext))
563        .unwrap_or(false)
564}