From 1b578c928c4c688f1473dccd6b2d66b21ab778de Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 6 Sep 2026 16:56:32 +0100 Subject: [PATCH 1/9] fix(web): escape recipe names in server-mode search results The inline search script in base.html interpolated `recipe.path` and `recipe.name` straight into innerHTML, so a recipe name containing markup was rendered as markup. Escape both through the same helper search.js uses in static mode, and cover it with an E2E test that serves a fabricated result through a route intercept. Closes #487 Claude-Session: https://claude.ai/code/session_013urND2B6Y3Z7WQuDpE8ZDu --- templates/base.html | 8 ++++++-- tests/e2e/search.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/templates/base.html b/templates/base.html index 7bc1c6e0..56817236 100644 --- a/templates/base.html +++ b/templates/base.html @@ -399,6 +399,10 @@ }); } + function escapeHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + } + searchInput.addEventListener('input', function() { clearTimeout(searchTimeout); const query = this.value.trim(); @@ -419,8 +423,8 @@ searchResults.innerHTML = '
' + translations.noRecipes + '
'; } else { searchResults.innerHTML = results.map(recipe => - ` -
${recipe.name}
+ `
+
${escapeHtml(recipe.name)}
` ).join(''); } diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts index 3cc08c89..5df9ff46 100644 --- a/tests/e2e/search.spec.ts +++ b/tests/e2e/search.spec.ts @@ -65,4 +65,27 @@ test.describe('Search Functionality', () => { await searchInput.clear(); } }); + + test('should render inline result names as text, not markup', async ({ page }) => { + // Fabricate a result whose name is markup; a raw innerHTML interpolation + // would run the onerror handler and set the marker. + await page.route('**/api/search?*', route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ path: 'x', name: '' }]), + }) + ); + + const searchInput = page.getByPlaceholder('Search recipes...'); + await searchInput.fill('pizza'); + + const result = page.locator('#search-results a.search-result'); + await expect(result).toHaveCount(1); + await expect(result).toContainText(' (window as any).__pwned); + expect(pwned).toBeUndefined(); + }); }); From 89c0696f59683326b5ef7841bc78be73ef6613e5 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 6 Sep 2026 16:58:25 +0100 Subject: [PATCH 2/9] fix(web): show the shopping list empty state after clearing clearList() added `hidden` to #shopping-list-results and nothing ever removed it, so the "no items" message rendered into #list-content stayed invisible until a reload, and live updates from another tab rendered into a hidden container. Drop the toggle and have displayShoppingList() un-hide the container whenever it renders. Closes #489 Claude-Session: https://claude.ai/code/session_013urND2B6Y3Z7WQuDpE8ZDu --- templates/shopping_list.html | 4 +++- tests/e2e/shopping-list.spec.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/templates/shopping_list.html b/templates/shopping_list.html index 30fbea5f..70605968 100644 --- a/templates/shopping_list.html +++ b/templates/shopping_list.html @@ -286,7 +286,6 @@

{{ tr.t("shopping-title") }}

if (response.ok) { hideError(); await loadShoppingList(); - document.getElementById('shopping-list-results').classList.add('hidden'); } else { const data = await response.json().catch(() => ({})); showError(data.error || 'Failed to clear list'); @@ -463,6 +462,9 @@

{{ tr.t("shopping-title") }}

lastListData = data; updateCopyButtonVisibility(); + // Live updates from another tab render here too, so make sure the + // container is showing whatever hid it earlier. + resultsDiv.classList.remove('hidden'); let html = ''; diff --git a/tests/e2e/shopping-list.spec.ts b/tests/e2e/shopping-list.spec.ts index b3ac9876..815df3bf 100644 --- a/tests/e2e/shopping-list.spec.ts +++ b/tests/e2e/shopping-list.spec.ts @@ -117,6 +117,24 @@ test.describe('Shopping List', () => { } }); + test('should show the empty state after Clear All', async ({ page }) => { + // Add a known recipe so the list has something to clear + await helpers.navigateTo('/recipe/Breakfast/Easy Pancakes.cook'); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: /Add to Shopping List/i }).click(); + await page.waitForTimeout(500); + + await helpers.goToShoppingList(); + await expect(page.locator('#list-content li').first()).toBeVisible({ timeout: 10_000 }); + + await page.getByRole('button', { name: /Clear All/i }).click(); + + // The results container must stay visible so the "no items" message shows + const results = page.locator('#shopping-list-results'); + await expect(results).toBeVisible(); + await expect(results.getByText(/No items in shopping list/i)).toBeVisible({ timeout: 5_000 }); + }); + test('should aggregate duplicate ingredients', async ({ page }) => { // Add same recipe multiple times or scale it - use known recipe await helpers.navigateTo('/recipe/Breakfast/Easy Pancakes.cook'); From cb93179406db430531e23c8594ca2515c5390df0 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 6 Sep 2026 16:58:25 +0100 Subject: [PATCH 3/9] fix(editor): do not show Saved before any save The "Saved" label on /edit/ was not the result of a save: an unconditional `updateSaveStatus('saved')` ran at script load as an "initial status". No PUT fires on open and the file's mtime is untouched, so the fix is to leave the status empty until a real save. The new editor spec asserts no PUT, an empty status and an unchanged mtime after opening the page. Closes #491 Claude-Session: https://claude.ai/code/session_013urND2B6Y3Z7WQuDpE8ZDu --- templates/edit.html | 3 --- tests/e2e/editor.spec.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/editor.spec.ts diff --git a/templates/edit.html b/templates/edit.html index 5f45444a..a83128b5 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -201,9 +201,6 @@

{{ tr.t("delete-recipe") }}

} }); -// Initial status -updateSaveStatus('saved'); - // Delete modal functions function showDeleteModal() { const modal = document.getElementById('delete-modal'); diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts new file mode 100644 index 00000000..e4b03e1c --- /dev/null +++ b/tests/e2e/editor.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// Seed directory used by the dev server started by Playwright's `webServer`. +const SEED_DIR = path.resolve(__dirname, '../../seed'); +const RECIPE_FILE = path.join(SEED_DIR, 'Neapolitan Pizza.cook'); + +test.describe('Recipe editor', () => { + test('does not autosave when the page is merely opened', async ({ page }) => { + const before = fs.statSync(RECIPE_FILE); + const originalContent = fs.readFileSync(RECIPE_FILE, 'utf8'); + + const saveRequests: string[] = []; + page.on('request', request => { + if (request.method() === 'PUT' && request.url().includes('/api/recipes/')) { + saveRequests.push(request.url()); + } + }); + + await page.goto('/edit/Neapolitan Pizza.cook'); + await page.waitForLoadState('networkidle'); + + // Wait past the autosave debounce so a spurious change would have fired. + await expect(page.locator('#editor-container .cm-editor')).toBeVisible(); + await page.waitForTimeout(2000); + + await expect(page.locator('#save-status')).toHaveText(''); + expect(saveRequests).toEqual([]); + + const after = fs.statSync(RECIPE_FILE); + expect(after.mtimeMs).toBe(before.mtimeMs); + expect(fs.readFileSync(RECIPE_FILE, 'utf8')).toBe(originalContent); + }); +}); From 227d09e6a62ee69616fcfb16160d01825dde216c Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 6 Sep 2026 16:58:25 +0100 Subject: [PATCH 4/9] fix(web): no skipped heading levels on API docs and sectioned recipes Endpoint summaries on /api-docs become h3 so the h4 sub-labels no longer sit directly under the section h2, and section names in the recipe steps column become h2 since they follow the page h1 directly. Closes #492 Claude-Session: https://claude.ai/code/session_013urND2B6Y3Z7WQuDpE8ZDu --- templates/api_docs.html | 2 +- templates/recipe.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/api_docs.html b/templates/api_docs.html index 6095a1ce..eabc96be 100644 --- a/templates/api_docs.html +++ b/templates/api_docs.html @@ -71,7 +71,7 @@

{{ section.title }}

{% endif %} -

{{ 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/recipe.html b/templates/recipe.html index ec911928..259da98c 100644 --- a/templates/recipe.html +++ b/templates/recipe.html @@ -298,7 +298,7 @@

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

{{ name }}

+

{{ name }}

{% when None %} {% endmatch %}
    From d874c04dd018b19b11e31cea0075308feb88f084 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 6 Sep 2026 16:59:58 +0100 Subject: [PATCH 5/9] fix(i18n): translate icon-button labels The icon-only buttons carried English aria-label/title literals (theme, keyboard shortcuts, more options, preferences, dismiss, scale stepper). Add aria-* keys to all seven locales and route the templates through tr.t(). The shortcuts modal's Close button lives in keyboard-shortcuts.js, so base.html now exposes window.__STRINGS__ and the script reads the label from there with an English fallback. Closes #488 Claude-Session: https://claude.ai/code/session_013urND2B6Y3Z7WQuDpE8ZDu --- locales/de-DE/common.ftl | 10 ++++++++++ locales/en-US/common.ftl | 10 ++++++++++ locales/es-ES/common.ftl | 10 ++++++++++ locales/eu-ES/common.ftl | 10 ++++++++++ locales/fr-FR/common.ftl | 10 ++++++++++ locales/nl-NL/common.ftl | 10 ++++++++++ locales/sv-SE/common.ftl | 10 ++++++++++ static/js/keyboard-shortcuts.js | 6 +++++- templates/base.html | 9 +++++---- templates/menu.html | 4 ++-- templates/pantry.html | 2 +- templates/recipe.html | 6 +++--- templates/shopping_list.html | 2 +- 13 files changed, 87 insertions(+), 12 deletions(-) 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/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

    - -
    - + {{ 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)"> - +
    @@ -422,7 +422,7 @@

    -