use std::path::{Path, PathBuf};
use plates::prov::meta::{Mapping, Value};
use plates::prov::{Document, ExportSpec};
use plates::term::{text, text_list};
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<(PathBuf, 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((rel.to_path_buf(), map.clone())),
None => warnings.push(format!(
"{}: `{SITES_KEY}` is not a mapping of site names, so it was ignored",
rel.display()
)),
}
}
match block {
Some((rel, map)) => {
let specs = specs_from(&map, &mut warnings);
for spec in &specs {
warnings.push(deprecation(&rel, spec));
}
Sites {
specs,
source: Source::Declared,
warnings,
}
}
None => {
let specs = specs_from_exports(exports);
let source = if specs.is_empty() {
Source::None
} else {
Source::Exports
};
Sites {
specs,
source,
warnings,
}
}
}
}
fn deprecation(rel: &Path, spec: &SiteSpec) -> String {
format!(
"{}: `{SITES_KEY}` is deprecated and stops working in plates 0.3 — site `{name}` is \
`exports.{name}` with `gate: {{field: {field}, value: {audience}}}`, and its front \
page, shell, stylesheet, lang and syntaxes belong on the `{audience}` term node of \
the `{field}` field's vocabulary (`front_page:` and a `site:` mapping)",
rel.display(),
name = spec.name,
field = spec.gate_field(),
audience = spec.audience,
)
}
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]) -> Vec<SiteSpec> {
exports
.iter()
.map(|export| SiteSpec {
name: export.name.clone(),
label: export.label.clone(),
audience: export.gate.value.clone(),
gate_field: (export.gate.field != AUDIENCE_FIELD).then(|| export.gate.field.clone()),
view: export.view.clone(),
index: None,
shell: None,
stylesheet: None,
lang: None,
syntaxes: Vec::new(),
})
.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_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 every_export_becomes_a_site_carrying_its_own_gate() {
let specs = specs_from_exports(&[
export("blog", AUDIENCE_FIELD, "public"),
export("audit", "clearance", "internal"),
]);
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].audience, "public");
assert_eq!(
specs[0].gate_field, None,
"the default field is left unsaid, so the spec says what the archive said"
);
assert_eq!(specs[1].audience, "internal");
assert_eq!(specs[1].gate_field.as_deref(), Some("clearance"));
assert_eq!(specs[1].gate_field(), "clearance");
}
#[test]
fn a_declared_site_is_told_what_replaces_it() {
let mut warnings = Vec::new();
let block = mapping(&[(
"blog",
Value::Mapping(mapping(&[
("audience", s("public")),
("gate_field", s("clearance")),
])),
)]);
let specs = specs_from(&block, &mut warnings);
let notice = deprecation(Path::new("prov.yaml"), &specs[0]);
assert!(notice.contains("prov.yaml"), "{notice}");
assert!(notice.contains("exports.blog"), "{notice}");
assert!(notice.contains("field: clearance"), "{notice}");
assert!(notice.contains("value: public"), "{notice}");
assert!(notice.contains("term node"), "{notice}");
assert!(notice.contains("0.3"), "{notice}");
}
}