Localize category labels in blog menus.

This commit is contained in:
2026-07-24 09:20:12 +02:00
parent 7a3c5683e7
commit c187108f89
3 changed files with 60 additions and 9 deletions

View File

@@ -16,7 +16,7 @@ The project is under active development. Core blogging workflows are broadly ava
- SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search.
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by the matching category archives.
- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries.
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, effective shared settings with secret-presence redaction, projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.

View File

@@ -391,7 +391,12 @@ pub fn prepare_site_render_context(
let linked_media_by_post_id =
build_linked_media_by_post_id(&posts, &linked_media_by_source_post);
let post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let menu_items = build_menu_items(
data_dir,
&language,
&main_language,
&category_settings,
)?;
let canonical_post_path_by_slug =
canonical_post_path_by_slug(&posts, &language, &main_language);
let taxonomy = build_taxonomy_context(&posts, &tags);
@@ -619,7 +624,13 @@ pub fn build_preview_response(
let bundle = load_template_bundle(conn, data_dir, project_id)?;
let language = language_from_path(&normalized, metadata);
let menu_items = build_menu_items(data_dir, &language, main_language(metadata))?;
let category_settings = queries_category_settings(data_dir)?;
let menu_items = build_menu_items(
data_dir,
&language,
main_language(metadata),
&category_settings,
)?;
let html = render_not_found_route(&bundle, metadata, &language, &normalized, &menu_items)?;
Ok(PreviewRenderResult {
status_code: 404,
@@ -1314,22 +1325,37 @@ fn build_menu_items(
data_dir: &Path,
language: &str,
main_language: &str,
category_settings: &HashMap<String, CategorySettings>,
) -> Result<Vec<Value>, Box<dyn Error + Send + Sync>> {
let items = menu::read_menu(data_dir)?;
Ok(items
.iter()
.map(|item| menu_item_context(item, language, main_language))
.map(|item| menu_item_context(item, language, main_language, category_settings))
.collect())
}
fn menu_item_context(item: &menu::MenuItem, language: &str, main_language: &str) -> Value {
fn menu_item_context(
item: &menu::MenuItem,
language: &str,
main_language: &str,
category_settings: &HashMap<String, CategorySettings>,
) -> Value {
let children = item
.children
.iter()
.map(|child| menu_item_context(child, language, main_language))
.map(|child| menu_item_context(child, language, main_language, category_settings))
.collect::<Vec<_>>();
let title = match item.kind {
MenuItemKind::CategoryArchive => item
.slug
.as_deref()
.and_then(|category| category_settings.get(category))
.and_then(|settings| settings.title_for(language, main_language))
.unwrap_or(item.label.as_str()),
_ => item.label.as_str(),
};
json!({
"title": item.label,
"title": title,
"href": menu_item_href(item, language, main_language),
"has_children": !children.is_empty(),
"children": children,
@@ -2202,15 +2228,34 @@ mod menu_tests {
)
.unwrap();
let rendered = build_menu_items(dir.path(), "en", "en").unwrap();
crate::engine::meta::write_category_meta_json(
dir.path(),
&HashMap::from([(
"Über Öl".into(),
CategorySettings {
list_template_slug: None,
post_template_slug: None,
render_in_lists: true,
show_title: true,
title: Some("Oil".into()),
titles: BTreeMap::from([("de".into(), "Öl".into())]),
},
)]),
)
.unwrap();
let category_settings = queries_category_settings(dir.path()).unwrap();
let rendered = build_menu_items(dir.path(), "en", "en", &category_settings).unwrap();
assert_eq!(rendered[0]["href"], "/");
assert_eq!(rendered[1]["href"], "/about/");
assert_eq!(rendered[2]["children"][0]["href"], "/category/uber-ol/");
let translated = build_menu_items(dir.path(), "de", "en").unwrap();
assert_eq!(rendered[2]["children"][0]["title"], "Oil");
let translated = build_menu_items(dir.path(), "de", "en", &category_settings).unwrap();
assert_eq!(translated[1]["href"], "/de/about/");
assert_eq!(
translated[2]["children"][0]["href"],
"/de/category/uber-ol/"
);
assert_eq!(translated[2]["children"][0]["title"], "Öl");
}
}

View File

@@ -229,6 +229,12 @@ invariant CategoryArchiveTitleLanguage {
-- category name.
}
invariant CategoryMenuTitleLanguage {
-- A category-archive menu item uses the title configured for its render
-- language at every nesting level. A missing title falls back to the menu
-- item's persisted label.
}
invariant SharedRenderPathForPreviewAndGeneration {
-- Preview and generation produce identical HTML for the same input because
-- both build assigns through this subsystem and render via the same Liquid