use std::path::Path;
use plates::prov::meta::{Mapping, Value};
use plates::prov::{Document, ExportSpec};
use plates::{AUDIENCE_FIELD, SiteSpec};
pub const SITES_KEY: &str = "sites";
pub const SITE_KEYS: &[&str] = &[
"label",
"audience",
"gate_field",
"view",
"index",
"shell",
"stylesheet",
"lang",
"syntaxes",
];
#[derive(Debug, Default)]
pub struct Sites {
pub specs: Vec<SiteSpec>,
pub source: Source,
pub warnings: Vec<String>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Declared,
Exports,
#[default]
None,
}
pub fn read_sites(
root_dir: &Path,
root_doc: &Path,
config_doc: Option<&Path>,
exports: &[ExportSpec],
) -> Sites {
let mut warnings = Vec::new();
let mut block: Option<Mapping> = None;
for rel in [Some(root_doc), config_doc].into_iter().flatten() {
let Some(meta) = read_meta(root_dir, rel) else {
continue;
};
let Some(value) = meta.get(SITES_KEY) else {
continue;
};
match value.as_mapping() {
Some(map) => block = Some(map.clone()),
None => warnings.push(format!(
"{}: `{SITES_KEY}` is not a mapping of site names, so it was ignored",
rel.display()
)),
}
}
match block {
Some(map) => {
let specs = specs_from(&map, &mut warnings);
Sites {
specs,
source: Source::Declared,
warnings,
}
}
None => {
let specs = specs_from_exports(exports, &mut warnings);
let source = if specs.is_empty() {
Source::None
} else {
Source::Exports
};
Sites {
specs,
source,
warnings,
}
}
}
}
fn read_meta(root_dir: &Path, rel: &Path) -> Option<Value> {
let text = std::fs::read_to_string(root_dir.join(rel)).ok()?;
Some(Document::parse(rel, &text).ok()?.meta)
}
fn specs_from(map: &Mapping, warnings: &mut Vec<String>) -> Vec<SiteSpec> {
let mut specs = Vec::new();
for (name, value) in map {
let Some(entry) = value.as_mapping() else {
warnings.push(format!(
"site `{name}` is not a mapping of settings, so it was skipped"
));
continue;
};
for key in entry.keys() {
if !SITE_KEYS.contains(&key.as_str()) {
warnings.push(format!(
"site `{name}`: `{key}` is not a setting plates reads (it reads {})",
SITE_KEYS.join(", ")
));
}
}
let Some(audience) = text(entry, "audience") else {
warnings.push(format!(
"site `{name}` declares no `audience`, so it was skipped — a site that does \
not say who it is for cannot be published"
));
continue;
};
specs.push(SiteSpec {
name: name.clone(),
label: text(entry, "label"),
audience,
gate_field: text(entry, "gate_field"),
view: text(entry, "view"),
index: text(entry, "index"),
shell: text(entry, "shell"),
stylesheet: text(entry, "stylesheet"),
lang: text(entry, "lang"),
syntaxes: text_list(entry, "syntaxes"),
});
}
specs
}
fn specs_from_exports(exports: &[ExportSpec], warnings: &mut Vec<String>) -> Vec<SiteSpec> {
let mut specs = Vec::new();
for export in exports {
if export.gate.field != AUDIENCE_FIELD {
warnings.push(format!(
"export `{}` gates on `{}` rather than `{AUDIENCE_FIELD}`, so no site was \
derived from it — declare one under `{SITES_KEY}` with `gate_field: {}` to \
publish it",
export.name, export.gate.field, export.gate.field
));
continue;
}
specs.push(SiteSpec {
name: export.name.clone(),
label: export.label.clone(),
audience: export.gate.value.clone(),
gate_field: None,
view: export.view.clone(),
index: None,
shell: None,
stylesheet: None,
lang: None,
syntaxes: Vec::new(),
});
}
specs
}
fn text(entry: &Mapping, key: &str) -> Option<String> {
entry
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn text_list(entry: &Mapping, key: &str) -> Vec<String> {
let Some(value) = entry.get(key) else {
return Vec::new();
};
match value.as_sequence() {
Some(items) => items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
None => text(entry, key).into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use plates::prov::exports::Gate;
fn export(name: &str, field: &str, value: &str) -> ExportSpec {
ExportSpec {
name: name.to_string(),
label: None,
gate: Gate {
field: field.to_string(),
value: value.to_string(),
},
view: None,
}
}
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), v.clone()))
.collect()
}
fn s(text: &str) -> Value {
Value::String(text.to_string())
}
#[test]
fn every_declared_setting_reaches_the_spec() {
let mut warnings = Vec::new();
let block = mapping(&[(
"blog",
Value::Mapping(mapping(&[
("label", s("Field notes")),
("audience", s("public")),
("gate_field", s("clearance")),
("view", s("daily")),
("index", s("[Home](id:7f3a91c)")),
("shell", s(".config/sites/blog/shell.html")),
("stylesheet", s(".config/sites/blog/style.css")),
("lang", s("fr")),
(
"syntaxes",
Value::Sequence(vec![s(".config/sites/blog/wat.sublime-syntax")]),
),
])),
)]);
let specs = specs_from(&block, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
specs,
vec".into()),
shell: Some(".config/sites/blog/shell.html".into()),
stylesheet: Some(".config/sites/blog/style.css".into()),
lang: Some("fr".into()),
syntaxes: vec![".config/sites/blog/wat.sublime-syntax".into()],
}]
);
}
#[test]
fn a_lone_syntax_may_be_written_as_a_scalar() {
let entry = mapping(&[("syntaxes", s(" a.sublime-syntax "))]);
assert_eq!(text_list(&entry, "syntaxes"), vec!["a.sublime-syntax"]);
}
#[test]
fn blank_syntax_entries_are_dropped() {
let entry = mapping(&[(
"syntaxes",
Value::Sequence(vec![s(" "), s("real.sublime-syntax"), Value::Null]),
)]);
assert_eq!(text_list(&entry, "syntaxes"), vec!["real.sublime-syntax"]);
assert!(text_list(&mapping(&[]), "syntaxes").is_empty());
}
#[test]
fn a_site_with_no_audience_is_refused_and_named() {
let mut warnings = Vec::new();
let block = mapping(&[
("open", Value::Mapping(mapping(&[("label", s("Open"))]))),
(
"blog",
Value::Mapping(mapping(&[("audience", s("public"))])),
),
]);
let specs = specs_from(&block, &mut warnings);
assert_eq!(specs.len(), 1, "the one that declared a gate");
assert_eq!(specs[0].name, "blog");
assert!(
warnings.iter().any(|w| w.contains("open")),
"and the other is named: {warnings:?}"
);
}
#[test]
fn an_unread_key_is_reported_but_never_fatal() {
let mut warnings = Vec::new();
let block = mapping(&[(
"blog",
Value::Mapping(mapping(&[
("audience", s("public")),
("stylesheat", s("style.css")),
])),
)]);
let specs = specs_from(&block, &mut warnings);
assert_eq!(specs.len(), 1);
assert!(
warnings.iter().any(|w| w.contains("stylesheat")),
"{warnings:?}"
);
}
#[test]
fn exports_become_sites_when_nothing_declares_one() {
let mut warnings = Vec::new();
let specs = specs_from_exports(
&[
export("blog", AUDIENCE_FIELD, "public"),
export("audit", "clearance", "internal"),
],
&mut warnings,
);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].audience, "public");
assert!(
warnings.iter().any(|w| w.contains("audit")),
"an export gated on another field is skipped and said out loud: {warnings:?}"
);
}
#[test]
fn an_empty_setting_reads_as_absent() {
let entry = mapping(&[("shell", s(" "))]);
assert_eq!(text(&entry, "shell"), None);
}
}