Theme v2: import icon packs (install screen, packs in the theme choice)
Appearance > Icons gets Import icon pack: a *.icons.json file is parsed by the runtime sanitizer and shown on an install screen (name, author, licence, source, credits, every glyph, size, data only) before it joins the persisted theme choice (packs, at most 8, validated natively for shape and size) and the Icons picker. Imported packs can be removed again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d902d3f65a
commit
a8710dfe02
7 changed files with 156 additions and 10 deletions
|
|
@ -41,6 +41,9 @@ pub const MAX_THEME_BYTES: usize = 1_200_000;
|
|||
pub const MAX_TOTAL_BYTES: usize = 4_000_000;
|
||||
/// Theme names: 1-60 characters (`theme.schema.json` `name`).
|
||||
pub const MAX_NAME_CHARS: usize = 60;
|
||||
/// Icon packs the owner imported (theme engine v2), each a standalone pack document.
|
||||
pub const MAX_PACKS: usize = 8;
|
||||
pub const MAX_PACK_BYTES: usize = 150_000;
|
||||
/// Override groups the panel may change on top of a theme.
|
||||
pub const OVERRIDE_KEYS: [&str; 8] = ["font", "shape", "layout", "background", "effects", "colors", "css", "variants"];
|
||||
|
||||
|
|
@ -60,6 +63,10 @@ pub struct ThemeChoice {
|
|||
/// otherwise a pack id (theme engine v2). A slug; an unknown pack falls back to the site's icons in the page.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icons: Option<String>,
|
||||
/// Icon packs the owner imported: data documents (path data, colored layers, small PNGs), checked
|
||||
/// again by the page runtime's sanitizer; here only their shape and size.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub packs: Vec<Value>,
|
||||
}
|
||||
|
||||
/// A theme id: a lowercase slug of 1-40 characters (`theme.schema.json` `id`).
|
||||
|
|
@ -79,7 +86,7 @@ fn doc_id(theme: &Value) -> Option<&str> {
|
|||
impl ThemeChoice {
|
||||
/// The site's own look, nothing customized.
|
||||
pub fn default_for(site: &ThemeSite) -> Self {
|
||||
Self { preset: site.own().into(), accent: None, overrides: Map::new(), themes: Vec::new(), icons: None }
|
||||
Self { preset: site.own().into(), accent: None, overrides: Map::new(), themes: Vec::new(), icons: None, packs: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn validate(&self, site: &ThemeSite) -> Result<(), String> {
|
||||
|
|
@ -93,6 +100,22 @@ impl ThemeChoice {
|
|||
return Err("VALIDATION: an icon pack id is a lowercase slug".into());
|
||||
}
|
||||
}
|
||||
if self.packs.len() > MAX_PACKS {
|
||||
return Err("VALIDATION: too many icon packs".into());
|
||||
}
|
||||
let mut pack_ids: Vec<&str> = Vec::new();
|
||||
for pack in &self.packs {
|
||||
let object = pack.as_object().ok_or("VALIDATION: an icon pack must be an object")?;
|
||||
let id = object.get("id").and_then(Value::as_str).ok_or("VALIDATION: an icon pack needs an id")?;
|
||||
let name = object.get("name").and_then(Value::as_str).ok_or("VALIDATION: an icon pack needs a name")?;
|
||||
if !valid_id(id) || pack_ids.contains(&id) || name.trim().is_empty() || name.chars().count() > MAX_NAME_CHARS || !object.get("glyphs").is_some_and(Value::is_object) {
|
||||
return Err("VALIDATION: icon packs have unique slug ids, a name and glyphs".into());
|
||||
}
|
||||
if serde_json::to_vec(pack).map(|bytes| bytes.len()).unwrap_or(usize::MAX) > MAX_PACK_BYTES {
|
||||
return Err("VALIDATION: icon pack is too large".into());
|
||||
}
|
||||
pack_ids.push(id);
|
||||
}
|
||||
if self.themes.len() > MAX_THEMES {
|
||||
return Err("VALIDATION: too many themes".into());
|
||||
}
|
||||
|
|
@ -148,7 +171,7 @@ impl ThemeChoice {
|
|||
let base = if id == self.preset || !self.knows(site, id) {
|
||||
self.clone()
|
||||
} else {
|
||||
ThemeChoice { preset: id.into(), accent: None, overrides: Map::new(), themes: self.themes.clone(), icons: self.icons.clone() }
|
||||
ThemeChoice { preset: id.into(), accent: None, overrides: Map::new(), themes: self.themes.clone(), icons: self.icons.clone(), packs: self.packs.clone() }
|
||||
};
|
||||
let preset = base.preset.clone();
|
||||
let themes = base.themes.into_iter().filter(|theme| doc_id(theme) == Some(preset.as_str())).collect();
|
||||
|
|
@ -321,6 +344,14 @@ mod tests {
|
|||
assert!(with(json!({ "preset": "site", "icons": "Bad Pack!" })).is_err());
|
||||
assert!(with(json!({ "preset": "site", "overrides": { "variants": { "nav": "top" } } })).is_ok(), "layout variants are an override group");
|
||||
assert_eq!(picked.for_theme(&SITE, "nord").icons.as_deref(), Some("wb-pixel"), "the icon pick is global");
|
||||
let pack = json!({ "id": "retro", "name": "Retro", "glyphs": { "home": { "d": "M0 0h1" } } });
|
||||
let with_pack = with(json!({ "preset": "site", "icons": "retro", "packs": [pack.clone()] })).unwrap();
|
||||
assert_eq!(with_pack.packs.len(), 1);
|
||||
assert_eq!(with_pack.for_theme(&SITE, "nord").packs.len(), 1, "imported packs are global");
|
||||
assert!(with(json!({ "preset": "site", "packs": [pack.clone(), pack] })).is_err(), "pack ids are unique");
|
||||
assert!(with(json!({ "preset": "site", "packs": [{ "id": "x", "name": "X" }] })).is_err(), "a pack has glyphs");
|
||||
let big = json!({ "id": "big", "name": "Big", "glyphs": { "home": { "d": "M".repeat(MAX_PACK_BYTES) } } });
|
||||
assert!(with(json!({ "preset": "site", "packs": [big] })).is_err(), "a pack is bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue