gazetta_core/model/
source.rs

1//  Copyright (C) 2015 Steven Allen
2//
3//  This file is part of gazetta.
4//
5//  This program is free software: you can redistribute it and/or modify it under the terms of the
6//  GNU General Public License as published by the Free Software Foundation version 3 of the
7//  License.
8//
9//  This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
10//  without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
11//  the GNU General Public License for more details.
12//
13//  You should have received a copy of the GNU General Public License along with this program.  If
14//  not, see <http://www.gnu.org/licenses/>.
15//
16
17use std::collections::HashMap;
18use std::fs;
19use std::ops::Deref;
20use std::path::{Path, PathBuf};
21
22use url::Url;
23
24use crate::error::{AnnotatedError, SourceError};
25use crate::util;
26
27use super::Meta;
28use super::yaml::{self, Yaml};
29use super::{Entry, StaticEntry};
30
31/// The Source object reads and interprets a source directory.
32///
33/// The fields are intentionally public. Feel free to manually generate or modify this structure.
34#[derive(Debug, Clone)]
35pub struct Source<SourceMeta = (), EntryMeta = ()>
36where
37    SourceMeta: Meta,
38    EntryMeta: Meta,
39{
40    /// The website's title.
41    ///
42    /// By default, this field is read from `gazetta.yaml`.
43    pub title: String,
44    /// The source root directory.
45    ///
46    /// This is specified on construction.
47    pub root: PathBuf,
48    /// The website origin (http://mydomain.com:1234)
49    ///
50    /// By default, this field is derived from the value of `base` in `gazetta.yaml`.
51    pub origin: String,
52    /// The directory under the origin at which this site will be hosted (e.g. "/").
53    ///
54    /// By default, this field is derived from the value of `base` in `gazetta.yaml`.
55    pub prefix: String,
56    /// The website content to be rendered.
57    ///
58    /// By default, this list is populated with Entries generated from files with the basename
59    /// index under the root directory excluding:
60    ///
61    ///  1. Files *under* directories named "static".
62    ///
63    ///  2. Files under `assets/`.
64    pub entries: Vec<Entry<EntryMeta>>,
65    /// The website content to be deployed as-is (no rendering).
66    ///
67    /// By default, this list is populated with directories under the root directory named "static"
68    /// excluding:
69    ///
70    ///  1. Directories *under* directories named "static".
71    ///
72    ///  2. Directories under `assets/`.
73    pub static_entries: Vec<StaticEntry>,
74    /// The website stylesheets. When rendered, these will be concatenated into a single
75    /// stylesheet.
76    ///
77    /// By default, this list is populated by the files in is `assets/stylesheets/` in
78    /// lexicographical order.
79    pub stylesheets: Vec<PathBuf>,
80    /// The website javascript. When rendered, these will be concatenated into a single
81    /// javascript file.
82    ///
83    /// By default, this list is populated by the files in is `assets/javascript/` in
84    /// lexicographical order.
85    pub javascript: Vec<PathBuf>,
86    /// The path to the website's icon.
87    ///
88    /// By default, this points to `assets/icon.png` (if it exists).
89    pub icon: Option<PathBuf>,
90    /// The path to the `.well-known` directory.
91    ///
92    /// By default, this points to `.well-known`.
93    pub well_known: Option<PathBuf>,
94    /// Additional metadata read from `gazetta.yaml`.
95    pub meta: SourceMeta,
96}
97
98pub struct IndexedSource<'a, SourceMeta, EntryMeta>
99where
100    SourceMeta: Meta,
101    EntryMeta: Meta,
102{
103    source: &'a Source<SourceMeta, EntryMeta>,
104    by_name: HashMap<&'a str, &'a Entry<EntryMeta>>,
105    children: HashMap<&'a str, Vec<&'a Entry<EntryMeta>>>,
106    references: HashMap<&'a str, Vec<&'a Entry<EntryMeta>>>,
107}
108
109impl<'a, SourceMeta, EntryMeta> Deref for IndexedSource<'a, SourceMeta, EntryMeta>
110where
111    SourceMeta: Meta,
112    EntryMeta: Meta,
113{
114    type Target = Source<SourceMeta, EntryMeta>;
115
116    fn deref(&self) -> &Self::Target {
117        self.source
118    }
119}
120
121impl<'a, SourceMeta, EntryMeta> IndexedSource<'a, SourceMeta, EntryMeta>
122where
123    SourceMeta: Meta,
124    EntryMeta: Meta,
125{
126    pub fn entry(&self, name: &str) -> Option<&Entry<EntryMeta>> {
127        self.by_name.get(name).copied()
128    }
129    pub fn children(&self, name: &str) -> &[&Entry<EntryMeta>] {
130        self.children.get(name).map(|v| &**v).unwrap_or_default()
131    }
132    pub fn references(&self, name: &str) -> &[&Entry<EntryMeta>] {
133        self.references.get(name).map(|v| &**v).unwrap_or_default()
134    }
135}
136
137impl<SourceMeta, EntryMeta> Source<SourceMeta, EntryMeta>
138where
139    SourceMeta: Meta,
140    EntryMeta: Meta,
141{
142    /// Build the [`IndexedSource`] for this [`Source`].
143    pub fn index<'a>(&'a self) -> Result<IndexedSource<'a, SourceMeta, EntryMeta>, SourceError> {
144        use crate::model::index::SortDirection::*;
145
146        let by_name: HashMap<_, _> = self.entries.iter().map(|e| (&*e.name, e)).collect();
147        let references = self
148            .entries
149            .iter()
150            .map(|e| {
151                (
152                    &*e.name,
153                    e.cc.iter()
154                        .filter_map(|n| by_name.get(&**n))
155                        .copied()
156                        .collect(),
157                )
158            })
159            .collect();
160
161        let mut children: HashMap<_, Vec<_>> = {
162            let mut by_directory: HashMap<_, _> = self
163                .entries
164                .iter()
165                .filter_map(|e| e.index.as_ref())
166                .flat_map(|i| &i.directories)
167                .map(|d| &**d)
168                .map(|d| (d, Vec::new()))
169                .collect();
170
171            for entry in &self.entries {
172                if let Some(parent) = Path::new(&entry.name).parent()
173                    && let Some(idx) = by_directory.get_mut(parent)
174                {
175                    idx.push(entry);
176                }
177            }
178            self.entries
179                .iter()
180                .filter_map(|e| {
181                    Some((
182                        &*e.name,
183                        e.index
184                            .as_ref()?
185                            .directories
186                            .iter()
187                            .filter_map(|d| by_directory.get(&**d))
188                            .flatten()
189                            .copied()
190                            .collect(),
191                    ))
192                })
193                .collect()
194        };
195
196        for entry in &self.entries {
197            for cc in &entry.cc {
198                let Some(child_entries) = children.get_mut(&**cc) else {
199                    return Err(SourceError::Config(
200                        format!(
201                            "entry {} CCs entry {} which either doesn't exist or has no index",
202                            entry.name, cc
203                        )
204                        .into(),
205                    ));
206                };
207                child_entries.push(entry);
208            }
209        }
210
211        for (name, child_entries) in &mut children {
212            let entry = by_name.get(name).expect("missing entry");
213            let index = entry.index.as_ref().expect("missing index");
214
215            match index.sort.direction {
216                Ascending => child_entries.sort_by(|e1, e2| index.sort.field.compare(e1, e2)),
217                Descending => child_entries.sort_by(|e1, e2| index.sort.field.compare(e2, e1)),
218            }
219
220            if let Some(max) = index.max {
221                child_entries.truncate(max as usize);
222            }
223        }
224
225        Ok(IndexedSource {
226            source: self,
227            by_name,
228            references,
229            children,
230        })
231    }
232
233    /// Parse a source directory to create a new source.
234    pub fn new<P: AsRef<Path>>(root: P) -> Result<Self, AnnotatedError<SourceError>> {
235        Self::_new(root.as_ref())
236    }
237
238    // avoid exporting large generic functions.
239    fn _new(root: &Path) -> Result<Self, AnnotatedError<SourceError>> {
240        let config_path = root.join("gazetta.yaml");
241        let mut source = Source::from_config(root, &config_path)
242            .map_err(|e| AnnotatedError::new(config_path, e))?;
243        source.reload()?;
244        Ok(source)
245    }
246
247    /// Reload from the source directory.
248    ///
249    /// Call this after changing source files.
250    pub fn reload(&mut self) -> Result<(), AnnotatedError<SourceError>> {
251        self.static_entries.clear();
252        self.entries.clear();
253        self.stylesheets.clear();
254        self.javascript.clear();
255        self.icon = None;
256        self.well_known = None;
257        self.load_entries("")?;
258        self.load_assets()?;
259        self.load_well_known()?;
260        Ok(())
261    }
262
263    #[inline(always)]
264    fn from_config(root: &Path, config_path: &Path) -> Result<Self, SourceError> {
265        let mut config = yaml::load(config_path)?;
266        let (origin, prefix) = match config.remove(&yaml::KEYS.base) {
267            Some(Yaml::String(base)) => {
268                let mut url = Url::parse(&base)?;
269                if url.cannot_be_a_base() {
270                    return Err("url cannot be a base".into());
271                }
272                if url.fragment().is_some() {
273                    return Err("base url must not specify a fragment".into());
274                }
275                if url.query().is_some() {
276                    return Err("base url must not specify a query".into());
277                }
278
279                let prefix = url.path().to_string();
280
281                url.set_path("");
282                let mut origin = url.to_string();
283                // Trim a trailing /, if any.
284                if origin.ends_with("/") {
285                    origin.pop();
286                }
287
288                (origin, prefix)
289            }
290            Some(..) => return Err("the base url must be a string".into()),
291            None => return Err("you must specify a base url".into()),
292        };
293
294        Ok(Source {
295            title: match config.remove(&yaml::KEYS.title) {
296                Some(Yaml::String(title)) => title,
297                Some(..) => return Err("title must be a string".into()),
298                None => return Err("must specify title".into()),
299            },
300            origin,
301            prefix,
302            root: root.to_owned(),
303            well_known: None,
304            entries: Vec::new(),
305            static_entries: Vec::new(),
306            stylesheets: Vec::new(),
307            javascript: Vec::new(),
308            icon: None,
309            meta: SourceMeta::from_yaml(config)?,
310        })
311    }
312
313    fn load_well_known(&mut self) -> Result<(), AnnotatedError<SourceError>> {
314        let path = self.root.join(".well-known");
315        if try_annotate!(util::exists(&path), path) {
316            self.well_known = Some(path.clone());
317        }
318        Ok(())
319    }
320
321    fn load_assets(&mut self) -> Result<(), AnnotatedError<SourceError>> {
322        let mut path = self.root.join("assets");
323
324        path.push("icon.png");
325        if try_annotate!(util::exists(&path), path) {
326            self.icon = Some(path.clone());
327        }
328
329        path.set_file_name("javascript");
330        if try_annotate!(util::exists(&path), path) {
331            self.javascript = try_annotate!(util::walk_sorted(&path), path);
332        }
333
334        path.set_file_name("stylesheets");
335        if try_annotate!(util::exists(&path), path) {
336            self.stylesheets = try_annotate!(util::walk_sorted(&path), path);
337        }
338        Ok(())
339    }
340
341    fn load_entries(&mut self, dir: &str) -> Result<(), AnnotatedError<SourceError>> {
342        let base_dir = self.root.join(dir);
343
344        for dir_entry in try_annotate!(fs::read_dir(&base_dir), base_dir) {
345            let dir_entry = try_annotate!(dir_entry, base_dir);
346            let file_name = match dir_entry.file_name().into_string() {
347                Ok(s) => {
348                    if s.starts_with('.') {
349                        continue;
350                    } else {
351                        s
352                    }
353                }
354                Err(s) => {
355                    // Can't possibly be a file we care about but, if it isn't hidden, we want to
356                    // return an error and bail.
357                    // FIXME: OsStr::starts_with
358                    if s.to_string_lossy().starts_with('.') {
359                        continue;
360                    } else {
361                        return Err(AnnotatedError::new(
362                            dir_entry.path(),
363                            "file names must be valid utf8".into(),
364                        ));
365                    }
366                }
367            };
368
369            // Skip assets.
370            if dir.is_empty() && file_name == "assets" {
371                continue;
372            }
373
374            let file_type = try_annotate!(dir_entry.file_type(), dir_entry.path());
375
376            if file_type.is_file() {
377                if Path::new(&file_name).file_stem().unwrap() == "index" {
378                    let entry =
379                        try_annotate!(Entry::from_file(dir_entry.path(), dir), dir_entry.path());
380                    self.entries.push(entry);
381                }
382            } else if file_type.is_dir() {
383                let name = if dir.is_empty() {
384                    file_name.to_owned()
385                } else {
386                    format!("{}/{}", dir, &file_name)
387                };
388                match &*file_name {
389                    "static" => self.static_entries.push(StaticEntry {
390                        name,
391                        source: dir_entry.path(),
392                    }),
393                    "index" => {
394                        return Err(AnnotatedError::new(
395                            dir_entry.path(),
396                            "paths ending in index are reserved for \
397                             indices"
398                                .into(),
399                        ));
400                    }
401                    _ => self.load_entries(&name)?,
402                }
403            } else if file_type.is_symlink() {
404                // TODO: Symlinks
405                unimplemented!();
406            }
407        }
408        Ok(())
409    }
410}