diff --git a/locales/de-DE/common.ftl b/locales/de-DE/common.ftl index 5ec610fc..4683abe8 100644 --- a/locales/de-DE/common.ftl +++ b/locales/de-DE/common.ftl @@ -52,3 +52,13 @@ new-recipe-create = Rezept erstellen delete-recipe = Rezept löschen delete-recipe-confirm = Möchten Sie dieses Rezept wirklich löschen? delete-recipe-warning = Diese Aktion kann nicht rückgängig gemacht werden. + +# Icon button labels (aria-label / title) +aria-toggle-theme = Design umschalten +aria-keyboard-shortcuts = Tastenkürzel +aria-more-options = Weitere Optionen +aria-preferences = Einstellungen +aria-dismiss = Schließen +aria-decrease-scale = Skalierung verringern +aria-increase-scale = Skalierung erhöhen +aria-close = Schließen diff --git a/locales/en-US/common.ftl b/locales/en-US/common.ftl index 7f7cf441..d6a2b862 100644 --- a/locales/en-US/common.ftl +++ b/locales/en-US/common.ftl @@ -56,3 +56,13 @@ delete-recipe-warning = This action cannot be undone. # Errors error-title = Something went wrong error-back-home = Back to recipes + +# Icon button labels (aria-label / title) +aria-toggle-theme = Toggle theme +aria-keyboard-shortcuts = Keyboard shortcuts +aria-more-options = More options +aria-preferences = Preferences +aria-dismiss = Dismiss +aria-decrease-scale = Decrease scale +aria-increase-scale = Increase scale +aria-close = Close diff --git a/locales/es-ES/common.ftl b/locales/es-ES/common.ftl index f854f17c..54531c8d 100644 --- a/locales/es-ES/common.ftl +++ b/locales/es-ES/common.ftl @@ -52,3 +52,13 @@ new-recipe-create = Crear Receta delete-recipe = Eliminar Receta delete-recipe-confirm = ¿Estás seguro de que quieres eliminar esta receta? delete-recipe-warning = Esta acción no se puede deshacer. + +# Icon button labels (aria-label / title) +aria-toggle-theme = Cambiar tema +aria-keyboard-shortcuts = Atajos de teclado +aria-more-options = Más opciones +aria-preferences = Preferencias +aria-dismiss = Cerrar +aria-decrease-scale = Reducir escala +aria-increase-scale = Aumentar escala +aria-close = Cerrar diff --git a/locales/eu-ES/common.ftl b/locales/eu-ES/common.ftl index 3efc1395..8d60ebf1 100644 --- a/locales/eu-ES/common.ftl +++ b/locales/eu-ES/common.ftl @@ -52,3 +52,13 @@ new-recipe-create = Sortu errezeta delete-recipe = Ezabatu errezeta delete-recipe-confirm = Ziur zaude errezeta hau ezabatu nahi duzula? delete-recipe-warning = Ekintza hau ezin da desegin. + +# Icon button labels (aria-label / title) +aria-toggle-theme = Gaia aldatu +aria-keyboard-shortcuts = Teklatu-lasterbideak +aria-more-options = Aukera gehiago +aria-preferences = Hobespenak +aria-dismiss = Itxi +aria-decrease-scale = Eskala txikitu +aria-increase-scale = Eskala handitu +aria-close = Itxi diff --git a/locales/fr-FR/common.ftl b/locales/fr-FR/common.ftl index 0c34ff9b..5bf1b47e 100644 --- a/locales/fr-FR/common.ftl +++ b/locales/fr-FR/common.ftl @@ -52,3 +52,13 @@ new-recipe-create = Creer la Recette delete-recipe = Supprimer la Recette delete-recipe-confirm = Êtes-vous sûr de vouloir supprimer cette recette? delete-recipe-warning = Cette action est irréversible. + +# Icon button labels (aria-label / title) +aria-toggle-theme = Changer de thème +aria-keyboard-shortcuts = Raccourcis clavier +aria-more-options = Plus d'options +aria-preferences = Préférences +aria-dismiss = Fermer +aria-decrease-scale = Réduire l'échelle +aria-increase-scale = Augmenter l'échelle +aria-close = Fermer diff --git a/locales/nl-NL/common.ftl b/locales/nl-NL/common.ftl index eac727f1..92cf7100 100644 --- a/locales/nl-NL/common.ftl +++ b/locales/nl-NL/common.ftl @@ -52,3 +52,13 @@ new-recipe-create = Recept Aanmaken delete-recipe = Recept Verwijderen delete-recipe-confirm = Weet je zeker dat je dit recept wilt verwijderen? delete-recipe-warning = Deze actie kan niet ongedaan worden gemaakt. + +# Icon button labels (aria-label / title) +aria-toggle-theme = Thema wisselen +aria-keyboard-shortcuts = Sneltoetsen +aria-more-options = Meer opties +aria-preferences = Voorkeuren +aria-dismiss = Sluiten +aria-decrease-scale = Schaal verkleinen +aria-increase-scale = Schaal vergroten +aria-close = Sluiten diff --git a/locales/sv-SE/common.ftl b/locales/sv-SE/common.ftl index 5ca77e21..8f77f93a 100644 --- a/locales/sv-SE/common.ftl +++ b/locales/sv-SE/common.ftl @@ -56,3 +56,13 @@ delete-recipe-warning = Detta kan inte ångras. # Errors error-title = Något gick snett error-back-home = Tillbaka till recept + +# Icon button labels (aria-label / title) +aria-toggle-theme = Byt tema +aria-keyboard-shortcuts = Tangentbordsgenvägar +aria-more-options = Fler alternativ +aria-preferences = Inställningar +aria-dismiss = Stäng +aria-decrease-scale = Minska skala +aria-increase-scale = Öka skala +aria-close = Stäng diff --git a/src/build/writer.rs b/src/build/writer.rs index 4554d223..67107ade 100644 --- a/src/build/writer.rs +++ b/src/build/writer.rs @@ -24,10 +24,27 @@ pub fn write_bytes(output_root: &Utf8Path, relpath: &Utf8Path, bytes: &[u8]) -> Ok(()) } -/// Copy every file in the rust-embed `StaticFiles` to `output_root/static/`. +/// Embedded `static/` paths that are build inputs rather than shipped assets. +/// Pages only link the compiled `output.css` and `editor.bundle.js`, so the +/// Tailwind sources and the unbundled editor modules stay out of the site. +const EXCLUDED_STATIC_PREFIXES: &[&str] = &["css/input.css", "css/components.css", "js/src/"]; + +/// Whether an embedded `static/` path (relative to `static/`) belongs in the +/// generated site. +fn is_shipped_asset(path: &str) -> bool { + !EXCLUDED_STATIC_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +/// Copy every shipped file in the rust-embed `StaticFiles` to +/// `output_root/static/`, skipping stylesheet and script sources. pub fn copy_static_assets(output_root: &Utf8Path) -> Result { let mut count = 0; for path in crate::web::StaticFiles::iter() { + if !is_shipped_asset(path.as_ref()) { + continue; + } let rel = Utf8Path::new("static").join(path.as_ref()); let file = crate::web::StaticFiles::get(path.as_ref()) .with_context(|| format!("Embedded file vanished: {path}"))?; @@ -147,5 +164,28 @@ mod tests { let count = copy_static_assets(root).unwrap(); assert!(count > 0, "should copy at least one static asset"); assert!(root.join("static/css/output.css").is_file()); + assert!(root.join("static/js/editor.bundle.js").is_file()); + } + + #[test] + fn copy_static_assets_skips_build_inputs() { + let tmp = TempDir::new().unwrap(); + let root = camino::Utf8Path::from_path(tmp.path()).unwrap(); + copy_static_assets(root).unwrap(); + assert!(!root.join("static/css/input.css").exists()); + assert!(!root.join("static/css/components.css").exists()); + assert!(!root.join("static/js/src").exists()); + } + + #[test] + fn is_shipped_asset_excludes_sources_only() { + assert!(is_shipped_asset("css/output.css")); + assert!(is_shipped_asset("css/cooking-mode.css")); + assert!(is_shipped_asset("js/editor.bundle.js")); + assert!(is_shipped_asset("favicon.ico")); + assert!(!is_shipped_asset("css/input.css")); + assert!(!is_shipped_asset("css/components.css")); + assert!(!is_shipped_asset("js/src/editor.js")); + assert!(!is_shipped_asset("js/src/cooklang-mode.js")); } } diff --git a/src/web/builders.rs b/src/web/builders.rs index 494cb534..c50a0312 100644 --- a/src/web/builders.rs +++ b/src/web/builders.rs @@ -138,7 +138,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), + _ => natural_cmp(&a.name, &b.name), }); let todays_menu = if sub_path.is_none() { @@ -190,6 +190,61 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result Vec { + let mut chunks = Vec::new(); + let mut text = String::new(); + let mut digits = String::new(); + + let flush_text = |text: &mut String, chunks: &mut Vec| { + if !text.is_empty() { + chunks.push(NaturalChunk::Text(std::mem::take(text))); + } + }; + let flush_digits = |digits: &mut String, chunks: &mut Vec| { + if !digits.is_empty() { + // Cap absurdly long digit runs instead of panicking on overflow. + let value = digits.parse::().unwrap_or(u128::MAX); + digits.clear(); + chunks.push(NaturalChunk::Number(value)); + } + }; + + for c in name.chars() { + if c.is_ascii_digit() { + flush_text(&mut text, &mut chunks); + digits.push(c); + } else { + flush_digits(&mut digits, &mut chunks); + text.extend(c.to_lowercase()); + } + } + flush_text(&mut text, &mut chunks); + flush_digits(&mut digits, &mut chunks); + chunks +} + +/// Case-insensitive natural ordering. Ties (names that differ only in case +/// or leading zeros) fall back to byte order so the sort stays deterministic. +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + natural_key(a).cmp(&natural_key(b)).then_with(|| a.cmp(b)) +} + /// Inputs for [`build_recipe_template`]. pub struct RecipeBuildInput<'a> { pub base_path: &'a Utf8Path, @@ -1098,3 +1153,95 @@ fn get_image_path(base_path: &Utf8Path, prefix: &str, img_path: String) -> Optio } } } + +#[cfg(test)] +mod natural_sort_tests { + use super::{natural_cmp, natural_key}; + use std::cmp::Ordering; + + #[test] + fn non_ascii_letters_keep_code_point_order() { + // Accents are not folded: this pins the documented divergence from + // the browser collator so a change here is deliberate. + assert_eq!(natural_cmp("zucchini", "Äpfel"), Ordering::Less); + // Case still folds for non-ASCII letters. + assert_eq!(natural_key("äpfel"), natural_key("Äpfel")); + } + + #[test] + fn case_insensitive() { + assert_eq!(natural_cmp("apple pie", "Banana bread"), Ordering::Less); + assert_eq!(natural_cmp("Banana bread", "apple pie"), Ordering::Greater); + } + + #[test] + fn digit_runs_compare_numerically() { + assert_eq!(natural_cmp("Recipe 9", "Recipe 10"), Ordering::Less); + assert_eq!(natural_cmp("Recipe 10", "Recipe 9"), Ordering::Greater); + assert_eq!(natural_cmp("Recipe 10", "Recipe 10"), Ordering::Equal); + } + + #[test] + fn case_only_differences_are_stable_ties() { + // Equal under the natural key; byte order breaks the tie so the + // result is deterministic across runs. + assert_eq!(natural_cmp("a", "A"), Ordering::Greater); + assert_eq!(natural_cmp("A", "a"), Ordering::Less); + assert_eq!(natural_cmp("a", "a"), Ordering::Equal); + } + + #[test] + fn sorts_like_the_client_collator() { + let mut names = vec![ + "Recipe 10", + "banana bread", + "Recipe 9", + "Apple pie", + "recipe 2", + ]; + names.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!( + names, + vec![ + "Apple pie", + "banana bread", + "recipe 2", + "Recipe 9", + "Recipe 10" + ] + ); + } + + #[test] + fn directories_come_before_recipes() { + struct Item { + name: &'static str, + is_directory: bool, + } + let mut items = [ + Item { + name: "zucchini", + is_directory: false, + }, + Item { + name: "Soups", + is_directory: true, + }, + Item { + name: "Apple pie", + is_directory: false, + }, + Item { + name: "breakfast", + is_directory: true, + }, + ]; + items.sort_by(|a, b| match (a.is_directory, b.is_directory) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => natural_cmp(a.name, b.name), + }); + let order: Vec<_> = items.iter().map(|i| i.name).collect(); + assert_eq!(order, vec!["breakfast", "Soups", "Apple pie", "zucchini"]); + } +} diff --git a/static/css/input.css b/static/css/input.css index 006a6fdb..0f1fea42 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -273,3 +273,42 @@ body { .cm-editor ::selection { background: color-mix(in srgb, var(--info) 30%, transparent) !important; } + +/* Cooklang syntax highlighting. editor.js assigns one fixed class per token + kind (HighlightStyle `class`), so every colour resolves through the theme + tokens and flips with light/dark like the rest of the editor chrome. */ +.cm-editor .cm-cook-ingredient { + color: var(--accent-text); + font-weight: 600; +} + +.cm-editor .cm-cook-cookware { + color: var(--ok); + font-weight: 600; +} + +.cm-editor .cm-cook-timer { + color: var(--danger); + font-weight: 600; +} + +.cm-editor .cm-cook-unit, +.cm-editor .cm-cook-metadata { + color: var(--text-muted); +} + +.cm-editor .cm-cook-comment { + color: var(--text-faint); + font-style: italic; +} + +.cm-editor .cm-cook-section { + color: var(--text); + font-weight: 700; + font-size: 1.1em; +} + +.cm-editor .cm-cook-prep { + color: var(--info); + font-style: italic; +} diff --git a/static/js/keyboard-shortcuts.js b/static/js/keyboard-shortcuts.js index e9aabf4f..d65d7c47 100644 --- a/static/js/keyboard-shortcuts.js +++ b/static/js/keyboard-shortcuts.js @@ -118,6 +118,10 @@ `; + // Translated strings are injected by base.html; fall back to English. + const strings = window.__STRINGS__ || {}; + const closeLabel = String(strings.close || 'Close') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const modal = document.createElement('div'); modal.id = 'keyboard-shortcuts-modal'; modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'; @@ -125,7 +129,7 @@

Keyboard Shortcuts

-
-

{{ endpoint.summary|inline_code|safe }}

+

{{ endpoint.summary|inline_code|safe }}

{% if !endpoint.description.is_empty() %}

{{ endpoint.description|inline_code|safe }}

{% endif %} diff --git a/templates/base.html b/templates/base.html index 7bc1c6e0..107e204d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -285,16 +285,16 @@ {% endif %} {% if !static_mode %} - {% endif %} - -
- + {{ name }} max="200" step="0.5" onchange="goToScale(this.value)"> - +
diff --git a/templates/pantry.html b/templates/pantry.html index 773effe0..d866179c 100644 --- a/templates/pantry.html +++ b/templates/pantry.html @@ -10,7 +10,7 @@

- + max="200" step="0.5" onchange="goToScale(this.value)"> - +
@@ -298,7 +298,7 @@

🍳 {{ tr.t("recipe-cookwa {% for section in sections %} {% match section.name %} {% when Some with (name) %} -

{{ name }}

+

{{ name }}

{% when None %} {% endmatch %}
    @@ -422,7 +422,7 @@

    -