Skip to main content

poet/
content_document_component_context.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::sync::Arc;
4
5use rhai::CustomType;
6use rhai::EvalAltResult;
7use rhai::TypeBuilder;
8
9use crate::asset_manager::AssetManager;
10use crate::author::Author;
11use crate::author_collection::AuthorCollection;
12use crate::content_document_collection_ranked::ContentDocumentCollectionRanked;
13use crate::content_document_front_matter::ContentDocumentFrontMatter;
14use crate::content_document_linker::ContentDocumentLinker;
15use crate::content_document_reference::ContentDocumentReference;
16use crate::table_of_contents::TableOfContents;
17
18#[derive(Clone)]
19pub struct ContentDocumentComponentContext {
20    pub asset_manager: AssetManager,
21    pub authors: Vec<Author>,
22    pub available_authors: Arc<AuthorCollection>,
23    pub available_collections: Arc<HashSet<String>>,
24    pub content_document_collections_ranked: Arc<HashMap<String, ContentDocumentCollectionRanked>>,
25    pub content_document_linker: ContentDocumentLinker,
26    pub front_matter: ContentDocumentFrontMatter,
27    pub is_watching: bool,
28    pub reference: ContentDocumentReference,
29    pub table_of_contents: Option<TableOfContents>,
30}
31
32impl ContentDocumentComponentContext {
33    #[cfg(test)]
34    pub fn mock() -> Self {
35        Self {
36            asset_manager: AssetManager::from_esbuild_metafile(
37                Arc::new(esbuild_metafile::EsbuildMetaFile::default()),
38                crate::asset_path_renderer::AssetPathRenderer {
39                    base_path: "/".to_string(),
40                },
41            ),
42            authors: Vec::new(),
43            available_authors: Arc::new(AuthorCollection::default()),
44            available_collections: Arc::new(HashSet::new()),
45            content_document_collections_ranked: Arc::new(HashMap::new()),
46            content_document_linker: ContentDocumentLinker::default(),
47            front_matter: ContentDocumentFrontMatter::mock("doc"),
48            is_watching: false,
49            reference: ContentDocumentReference {
50                basename_path: "doc".into(),
51                front_matter: ContentDocumentFrontMatter::mock("doc"),
52                generated_page_base_path: "/".to_string(),
53            },
54            table_of_contents: None,
55        }
56    }
57
58    pub fn with_table_of_contents(self, table_of_contents: TableOfContents) -> Self {
59        Self {
60            asset_manager: self.asset_manager,
61            authors: self.authors,
62            available_authors: self.available_authors,
63            available_collections: self.available_collections,
64            content_document_collections_ranked: self.content_document_collections_ranked,
65            content_document_linker: self.content_document_linker,
66            front_matter: self.front_matter,
67            is_watching: self.is_watching,
68            reference: self.reference,
69            table_of_contents: Some(table_of_contents),
70        }
71    }
72
73    fn rhai_authors(&mut self) -> rhai::Array {
74        self.authors
75            .iter()
76            .map(|author| rhai::Dynamic::from(author.clone()))
77            .collect()
78    }
79
80    fn rhai_available_authors(&mut self) -> rhai::Array {
81        self.available_authors
82            .values()
83            .map(|author| rhai::Dynamic::from(author.clone()))
84            .collect()
85    }
86
87    fn rhai_belongs_to(&mut self, collection_name: &str) -> Result<bool, Box<EvalAltResult>> {
88        // This validates that the colllection actually exists anywhere
89        let _ = self.rhai_collection(collection_name)?;
90
91        for placement in &self.front_matter.collections.placements {
92            if placement.name == collection_name {
93                return Ok(true);
94            }
95        }
96
97        Ok(false)
98    }
99
100    fn rhai_collection(
101        &mut self,
102        collection_name: &str,
103    ) -> Result<ContentDocumentCollectionRanked, Box<EvalAltResult>> {
104        if let Some(collection) = self
105            .content_document_collections_ranked
106            .get(collection_name)
107        {
108            Ok(collection.clone())
109        } else {
110            Err(format!("Collection is never used in any document: '{collection_name}'").into())
111        }
112    }
113
114    fn rhai_front_matter(&mut self) -> ContentDocumentFrontMatter {
115        self.front_matter.clone()
116    }
117
118    pub fn rhai_get_assets(&mut self) -> AssetManager {
119        self.asset_manager.clone()
120    }
121
122    fn rhai_is_current_page(&mut self, other: String) -> Result<bool, Box<EvalAltResult>> {
123        let basename = self.content_document_linker.resolve_id(&other)?;
124
125        Ok(self.reference.basename() == basename)
126    }
127
128    fn rhai_is_watching(&mut self) -> bool {
129        self.is_watching
130    }
131
132    fn rhai_link_to(&mut self, path: &str) -> Result<String, Box<EvalAltResult>> {
133        Ok(self.content_document_linker.link_to(path)?)
134    }
135
136    fn rhai_primary_collection(
137        &mut self,
138    ) -> Result<ContentDocumentCollectionRanked, Box<EvalAltResult>> {
139        match self.front_matter.collections.placements.len() {
140            0 => return Err("Document does not belong to any collection".into()),
141            1 => {
142                let placements = self.front_matter.collections.placements.clone();
143
144                if let Some(placement) = placements.first() {
145                    return self.rhai_collection(&placement.name);
146                }
147            }
148            _ => {
149                if let Some(primary_collection) = &self.front_matter.primary_collection {
150                    let placements = self.front_matter.collections.placements.clone();
151
152                    for placement in placements {
153                        if placement.name == *primary_collection {
154                            return self.rhai_collection(&placement.name);
155                        }
156                    }
157                } else {
158                    return Err("Document has multiple collections, but it doesn't specify the primary collection (which normally isn't a problem, but you tried to use the '.primary_collection' field)".into());
159                }
160            }
161        };
162
163        Err("Unable to determine the primary collection".into())
164    }
165
166    fn rhai_reference(&mut self) -> ContentDocumentReference {
167        self.reference.clone()
168    }
169
170    fn rhai_table_of_contents(&mut self) -> Result<TableOfContents, Box<EvalAltResult>> {
171        if let Some(table_of_contents) = &self.table_of_contents {
172            Ok(table_of_contents.clone())
173        } else {
174            Err("Table of contents is not available. Do not use table of contents variable in document headers.".into())
175        }
176    }
177}
178
179impl CustomType for ContentDocumentComponentContext {
180    fn build(mut builder: TypeBuilder<Self>) {
181        builder
182            .with_name("ContentDocumentComponentContext")
183            .with_get("assets", Self::rhai_get_assets)
184            .with_get("authors", Self::rhai_authors)
185            .with_get("available_authors", Self::rhai_available_authors)
186            .with_get("front_matter", Self::rhai_front_matter)
187            .with_get("is_watching", Self::rhai_is_watching)
188            .with_get("primary_collection", Self::rhai_primary_collection)
189            .with_get("reference", Self::rhai_reference)
190            .with_get("table_of_contents", Self::rhai_table_of_contents)
191            .with_fn("belongs_to", Self::rhai_belongs_to)
192            .with_fn("collection", Self::rhai_collection)
193            .with_fn("is_current_page", Self::rhai_is_current_page)
194            .with_fn("link_to", Self::rhai_link_to);
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use esbuild_metafile::EsbuildMetaFile;
201
202    use super::*;
203    use crate::asset_path_renderer::AssetPathRenderer;
204    use crate::content_document_collection::ContentDocumentCollection;
205    use crate::content_document_front_matter::collection_placement::CollectionPlacement;
206    use crate::content_document_front_matter::collection_placement_list::CollectionPlacementList;
207    use crate::content_document_in_collection::ContentDocumentInCollection;
208
209    fn ranked(name: &str) -> Result<ContentDocumentCollectionRanked, anyhow::Error> {
210        ContentDocumentCollection {
211            documents: vec![ContentDocumentInCollection {
212                collection_placement: CollectionPlacement {
213                    after: None,
214                    name: name.to_string(),
215                    parent: None,
216                },
217                reference: ContentDocumentReference {
218                    basename_path: "entry".into(),
219                    front_matter: ContentDocumentFrontMatter::mock("entry"),
220                    generated_page_base_path: "/".to_string(),
221                },
222            }],
223            name: name.to_string(),
224        }
225        .try_into()
226    }
227
228    fn ranked_map(
229        names: &[&str],
230    ) -> Result<HashMap<String, ContentDocumentCollectionRanked>, anyhow::Error> {
231        let mut map = HashMap::new();
232
233        for name in names {
234            map.insert(name.to_string(), ranked(name)?);
235        }
236
237        Ok(map)
238    }
239
240    fn front_matter(
241        placements: &[&str],
242        primary_collection: Option<&str>,
243    ) -> ContentDocumentFrontMatter {
244        let mut front_matter = ContentDocumentFrontMatter::mock("doc");
245
246        front_matter.collections = CollectionPlacementList {
247            placements: placements
248                .iter()
249                .map(|name| CollectionPlacement {
250                    after: None,
251                    name: name.to_string(),
252                    parent: None,
253                })
254                .collect(),
255        };
256        front_matter.primary_collection = primary_collection.map(|name| name.to_string());
257
258        front_matter
259    }
260
261    fn context(
262        front_matter: ContentDocumentFrontMatter,
263        ranked: HashMap<String, ContentDocumentCollectionRanked>,
264    ) -> ContentDocumentComponentContext {
265        let asset_manager = AssetManager::from_esbuild_metafile(
266            Arc::new(EsbuildMetaFile::default()),
267            AssetPathRenderer {
268                base_path: "/".to_string(),
269            },
270        );
271
272        ContentDocumentComponentContext {
273            asset_manager,
274            authors: Vec::new(),
275            available_authors: Arc::new(AuthorCollection::default()),
276            available_collections: Arc::new(HashSet::new()),
277            content_document_collections_ranked: Arc::new(ranked),
278            content_document_linker: ContentDocumentLinker::default(),
279            front_matter,
280            is_watching: false,
281            reference: ContentDocumentReference {
282                basename_path: "doc".into(),
283                front_matter: ContentDocumentFrontMatter::mock("doc"),
284                generated_page_base_path: "/".to_string(),
285            },
286            table_of_contents: None,
287        }
288    }
289
290    #[test]
291    fn collection_returns_ranked_collection_when_used() -> Result<(), anyhow::Error> {
292        let mut context = context(front_matter(&[], None), ranked_map(&["guide"])?);
293
294        assert_eq!(context.rhai_collection("guide")?.name, "guide");
295
296        Ok(())
297    }
298
299    #[test]
300    fn collection_fails_for_unused_collection() {
301        let mut context = context(front_matter(&[], None), HashMap::new());
302
303        assert!(context.rhai_collection("ghost").is_err());
304    }
305
306    #[test]
307    fn belongs_to_is_true_for_member_collection() -> Result<(), anyhow::Error> {
308        let mut context = context(front_matter(&["guide"], None), ranked_map(&["guide"])?);
309
310        assert!(context.rhai_belongs_to("guide")?);
311
312        Ok(())
313    }
314
315    #[test]
316    fn belongs_to_is_false_for_non_member_existing_collection() -> Result<(), anyhow::Error> {
317        let mut context = context(front_matter(&[], None), ranked_map(&["guide"])?);
318
319        assert!(!context.rhai_belongs_to("guide")?);
320
321        Ok(())
322    }
323
324    #[test]
325    fn primary_collection_fails_without_any_placement() {
326        let mut context = context(front_matter(&[], None), HashMap::new());
327
328        assert!(context.rhai_primary_collection().is_err());
329    }
330
331    #[test]
332    fn primary_collection_returns_sole_placement() -> Result<(), anyhow::Error> {
333        let mut context = context(front_matter(&["guide"], None), ranked_map(&["guide"])?);
334
335        assert_eq!(context.rhai_primary_collection()?.name, "guide");
336
337        Ok(())
338    }
339
340    #[test]
341    fn primary_collection_resolves_declared_primary_among_many() -> Result<(), anyhow::Error> {
342        let mut context = context(
343            front_matter(&["guide", "reference"], Some("reference")),
344            ranked_map(&["guide", "reference"])?,
345        );
346
347        assert_eq!(context.rhai_primary_collection()?.name, "reference");
348
349        Ok(())
350    }
351
352    #[test]
353    fn primary_collection_fails_for_many_without_declared_primary() {
354        let mut context = context(front_matter(&["guide", "reference"], None), HashMap::new());
355
356        assert!(context.rhai_primary_collection().is_err());
357    }
358
359    #[test]
360    fn is_current_page_compares_resolved_basename() -> Result<(), anyhow::Error> {
361        let mut context = context(front_matter(&[], None), HashMap::new());
362
363        assert!(context.rhai_is_current_page("doc".to_string())?);
364        assert!(!context.rhai_is_current_page("other".to_string())?);
365
366        Ok(())
367    }
368
369    #[test]
370    fn table_of_contents_fails_when_absent() {
371        let mut context = context(front_matter(&[], None), HashMap::new());
372
373        assert!(context.rhai_table_of_contents().is_err());
374    }
375
376    #[test]
377    fn table_of_contents_is_available_after_being_set() -> Result<(), anyhow::Error> {
378        let mut context = context(front_matter(&[], None), HashMap::new()).with_table_of_contents(
379            TableOfContents {
380                headings: Vec::new(),
381            },
382        );
383
384        assert!(context.rhai_table_of_contents().is_ok());
385
386        Ok(())
387    }
388}