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
85pub 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 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 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 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 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 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 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 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 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 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 let default_date = cobalt_model::DateTime::default();
323
324 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 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
431fn 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 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
472fn 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}