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 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}