Every content type — board, list,
board_group, board_list,
singlemd, mdbook, file,
link — carries the same uniform metadata block: a name, an
optional icon, an optional desc, and an optional banner. That block is
modeled by the Header struct in src/content/header.rs, and the per-language
strings inside it are modeled by LangDict / OptionLangDict in
src/content/localization.rs. This chapter is the reference for both.
Header structpub struct Header {
pub name: LangDict,
pub icon: Option<String>,
pub desc: OptionLangDict,
pub banner: OptionLangDict,
}
name is required (every resource must have a display name). icon /
desc / banner are optional. The distinction in the type — LangDict vs
OptionLangDict — encodes “always has a value” vs “may be absent on disk”.
Header::from_value(&Value)Builds a Header from a parsed ctx.json:
let name: LangDict = ctx.get("name").clone().into();
let icon = ctx.try_get("icon").ok().map(|v| v.string()).filter(|s| !s.is_empty());
let desc: OptionLangDict = ctx.get("desc").clone().into();
let banner: OptionLangDict = ctx.get("banner").clone().into();
Both string and dict shapes are accepted for name / desc / banner:
the From<Value> impls on LangDict / OptionLangDict just wrap the value,
and the per-language lookup at get() time picks the right branch. icon
is read as a string and the empty-string case is normalized to None so
downstream code only has to check Option::is_some.
Header::fallback(slug)When a child resource’s ctx.json is missing or unparseable,
Header::fallback(slug) is used instead. It returns a Header with:
name = LangDict::new({default_lang: slug}) — the slug becomes the
visible display name so a broken link doesn’t render blank.icon = None, desc = OptionLangDict::none(), banner = OptionLangDict::none().Used by board.rs and list.rs when resolving child slugs (so a missing
child still gets a row), by board_group.rs / board_list.rs for the same
reason, and by routes/permissions.rs to build breadcrumbs against resources
that may not have ctx.json yet.
Header::write_into(&mut Value)The serializer used by every save():
ctx.set("name", self.name.value().clone());
if let Some(icon) = &self.icon { ctx.set("icon", icon.clone()); }
if self.desc.is_some() { ctx.set("desc", self.desc.value()); }
if self.banner.is_some() { ctx.set("banner", self.banner.value()); }
Optional fields that are empty are omitted entirely from the output.
That’s why every save() rebuilds ctx.json from scratch (a fresh object!({}))
instead of mutating the on-disk dict: a cleared icon or desc only
actually disappears if write_into skips writing it.
clear_lang_in_desc(&mut Header, &str)Helper for “delete the German translation of this resource’s desc, but
keep the English one”. Walks the current desc dict, copies every entry
except the one for lang into a fresh dict, then either re-installs
that dict or downgrades desc to OptionLangDict::none() if it ended up
empty. The downgrade matters: an empty dict would still write "desc": {}
to disk, whereas none() drops the key entirely.
board::set_desc, list::set_desc, and board_list::set_desc all delegate
to this helper when the new value is empty.
LangDictThe per-language string container:
pub struct LangDict { cont: Value }
Two underlying shapes are accepted on disk:
en, ja, zh, …), values are
per-language strings.LangDict::get(lang)Lookup with fallback:
cont is a dict and contains lang, return its value as string.cont is a dict and contains the default language (per
sfx::op::default_lang()), return that.cont is a dict but contains neither, return the dict’s
to_string() form (debug-ish, but won’t crash).cont is a plain Value::Str, return it directly (the “every lang
the same value” case)."".The key consequence: a plain-string name is always returned as-is for
every language, no matter what lang is asked for. That’s the cheapest
form of “I haven’t translated this yet”.
LangDict::set(lang, value)The interesting case is editing a plain-string LangDict for the first time.
set promotes it to a dict, preserving the existing string under
default_lang, then adds the new entry:
// Starting state: name = "Foo" (plain string)
dict.set("ja", "フー");
// Ending state: name = { default_lang: "Foo", ja: "フー" }
So a first per-lang edit doesn’t lose the existing value. This is why hand-edits typically start as plain strings and only become dicts after the first translated edit.
LangDict::new(Value)Bare constructor wrapping a Value. Also reachable via the From<Value>
impl, which is what Header::from_value uses.
OptionLangDictA LangDict that may be entirely absent. is_some() reports presence;
get(lang) returns Option<String> (None when the field was absent on
disk). set(lang, value) promotes None to a fresh dict on first write.
Same string-to-dict upgrade semantics on the inner LangDict.
icon resolution (render_icon in board.rs)render_icon is the single source of truth for “given a child Header and
the child’s folder, produce HTML for the row icon”:
let icon = header.icon.clone().or_else(crate::content::default_icon);
let Some(icon) = icon else { return String::new(); };
if icon.starts_with('/') || icon.starts_with("http") {
format!(r#"<img src="{}" width="64" height="64" class="me-2" alt=""/>"#, icon)
} else {
let svg = fop::read_file(fop::combine_path(child_folder, icon));
if svg.is_empty() { String::new() } else { svg }
}
The chain in plain English:
icon, use it; otherwise fall back to
crate::content::default_icon()./ or http, emit an <img> tag
directly.<svg> placeholder gets emitted).default_icon comes fromprogramfiles/op/config.json, key "default_icon". Read by
crate::content::default_icon():
let v = CONFIG.try_get("default_icon").ok()?;
let s = v.string();
if s.is_empty() { None } else { Some(s) }
Missing key or empty string -> None -> no icon emitted at all (the row
just doesn’t have one). The current config in this repo has:
{ "default_icon": "/images/svg/default/", ... }
name examplesAll three of these are valid name fields and lookup-equivalent for the
default language:
"name": "Static" — every language gets "Static"."name": {"en": "Foo"} — en gets "Foo"; every other language falls
back to "Foo" via the default_lang rule (assuming en is the default)."name": {"en": "Foo", "ja": "フー", "zh": "富"} — full per-language
translation, no fallback needed.Hand-edits are safe to use the shortest form. The editor will promote to
the dict form on the first per-language edit via LangDict::set.