Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions locales/de-DE/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions locales/en-US/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions locales/es-ES/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions locales/eu-ES/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions locales/fr-FR/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions locales/nl-NL/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions locales/sv-SE/common.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 41 additions & 1 deletion src/build/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>`.
/// 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/<path>`, skipping stylesheet and script sources.
pub fn copy_static_assets(output_root: &Utf8Path) -> Result<usize> {
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}"))?;
Expand Down Expand Up @@ -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"));
}
}
149 changes: 148 additions & 1 deletion src/web/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem
items.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => 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() {
Expand Down Expand Up @@ -190,6 +190,61 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem
})
}

/// One chunk of a name for natural sorting: a run of digits compared by
/// value, or a run of anything else compared as lowercase text.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
enum NaturalChunk {
Number(u128),
Text(String),
}

/// Split a name into digit runs and non-digit runs, lowercased, so that
/// "Recipe 9" sorts before "Recipe 10" and case does not split otherwise
/// equal names. This folds case and digits like the client-side
/// `Intl.Collator` with `{ numeric: true, sensitivity: 'base' }` in
/// `templates/recipes.html`, but not accents: non-ASCII letters keep code
/// point order, so "Äpfel" sorts after "zucchini". The client therefore
/// leaves the served order alone on the default sort and only re-sorts
/// when the user picks another field or direction.
fn natural_key(name: &str) -> Vec<NaturalChunk> {
let mut chunks = Vec::new();
let mut text = String::new();
let mut digits = String::new();

let flush_text = |text: &mut String, chunks: &mut Vec<NaturalChunk>| {
if !text.is_empty() {
chunks.push(NaturalChunk::Text(std::mem::take(text)));
}
};
let flush_digits = |digits: &mut String, chunks: &mut Vec<NaturalChunk>| {
if !digits.is_empty() {
// Cap absurdly long digit runs instead of panicking on overflow.
let value = digits.parse::<u128>().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,
Expand Down Expand Up @@ -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"]);
}
}
39 changes: 39 additions & 0 deletions static/css/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 5 additions & 1 deletion static/js/keyboard-shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,18 @@
</div>
</div>`;

// Translated strings are injected by base.html; fall back to English.
const strings = window.__STRINGS__ || {};
const closeLabel = String(strings.close || 'Close')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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';
modal.innerHTML = `
<div class="card shadow-[var(--shadow-overlay)] max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div class="p-6 border-b border-line flex justify-between items-center">
<h2 class="text-title font-bold text-text">Keyboard Shortcuts</h2>
<button onclick="closeShortcutsHelp()" class="icon-btn" aria-label="Close">
<button onclick="closeShortcutsHelp()" class="icon-btn" aria-label="${closeLabel}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Expand Down
Loading