Common: Header and LangDict

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.

The Header struct

pub 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.

LangDict

The per-language string container:

pub struct LangDict { cont: Value }

Two underlying shapes are accepted on disk:

  • Plain string — every language gets the same value.
  • Dict — keys are language codes (en, ja, zh, …), values are per-language strings.

LangDict::get(lang)

Lookup with fallback:

  1. If cont is a dict and contains lang, return its value as string.
  2. Otherwise, if cont is a dict and contains the default language (per sfx::op::default_lang()), return that.
  3. Otherwise, if cont is a dict but contains neither, return the dict’s to_string() form (debug-ish, but won’t crash).
  4. If cont is a plain Value::Str, return it directly (the “every lang the same value” case).
  5. On any other error, return "".

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.

OptionLangDict

A 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:

  1. If the resource sets its own icon, use it; otherwise fall back to crate::content::default_icon().
  2. If the resulting string starts with / or http, emit an <img> tag directly.
  3. Otherwise treat it as a relative path inside the child’s own folder and read the file as inline SVG. An empty file is treated as “no icon” (no broken <svg> placeholder gets emitted).

Where default_icon comes from

programfiles/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/", ... }

Practical name examples

All 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.