diff --git a/docs/superpowers/plans/2026-04-17-kochwas-overview.md b/docs/superpowers/plans/2026-04-17-kochwas-overview.md new file mode 100644 index 0000000..5ae28ed --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-kochwas-overview.md @@ -0,0 +1,33 @@ +# Kochwas — Implementation Plan Overview + +**Goal:** Deliver the Kochwas MVP per the design spec in six phases, each ending in runnable, testable software. + +**Reference:** `docs/superpowers/specs/2026-04-17-kochwas-design.md` + +--- + +## Phase Map + +| # | Name | Outcome | Plan file | +|---|------|---------|-----------| +| 1 | Foundations | Scaffolded SvelteKit app, SQLite schema with FTS5, Docker dev setup, pure-function modules (ingredient-parser, scaler, json-ld-extractor, iso8601) all unit-tested, `/api/health` endpoint | `2026-04-17-kochwas-phase-1-foundations.md` | +| 2 | Import Pipeline | Working recipe import from URL to DB incl. images, preview endpoint, profile CRUD, integration tests with real HTML fixtures | `2026-04-xx-kochwas-phase-2-import.md` | +| 3 | Core UI & Local Search | RecipeView component (mobile-first), Search page with FTS5 results, profile selector, rating/favorite/cooked actions | `2026-04-xx-kochwas-phase-3-ui.md` | +| 4 | Web Search & Admin | SearXNG integration, whitelist admin UI, preview-before-save flow, profile admin | `2026-04-xx-kochwas-phase-4-web-admin.md` | +| 5 | Advanced Features | Comments, tag editor, cooking-log view, print view, rename/delete, backup/restore ZIP | `2026-04-xx-kochwas-phase-5-advanced.md` | +| 6 | PWA & Polish | Service worker + offline, Wake-Lock, install prompt, mobile-UX polish, E2E tests, prod Docker image | `2026-04-xx-kochwas-phase-6-pwa.md` | + +Each phase produces working software on its own. A phase is complete when all its tasks are checked off, tests pass, and a manual smoke-test on Docker Desktop succeeds. + +## Sequencing Rule + +Phases are strictly sequential. Do not start Phase N+1 before Phase N is green (tests + smoke test). + +## Conventions (all phases) + +- **Commits:** each task ends with a single atomic commit. Commit messages follow conventional-commits (`feat:`, `fix:`, `chore:`, `test:`, `docs:`, `refactor:`). +- **Line endings:** LF only (`.gitattributes` already in place). +- **Tests:** Vitest for unit/integration, Playwright for E2E. Every pure function gets a unit test first (TDD). +- **Code style:** Prettier + ESLint using SvelteKit defaults. No emojis in code unless requested. +- **Dev env:** Local Docker Desktop for the SearXNG container; the SvelteKit app runs via `npm run dev` for fast iteration. Production Docker image built in Phase 6. +- **TypeScript strict mode** on from the start. No `any` unless justified inline. diff --git a/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md b/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md new file mode 100644 index 0000000..8a414b4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md @@ -0,0 +1,1386 @@ +# Kochwas — Phase 1: Foundations + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up a SvelteKit + TypeScript project with SQLite/FTS5 schema, a running `/api/health` endpoint, and unit-tested pure-function modules (ingredient-parser, json-ld-extractor, scaler, iso8601-duration) — the building blocks everything else in Kochwas will rely on. + +**Architecture:** SvelteKit (Node adapter) + TypeScript strict. `better-sqlite3` as synchronous DB driver. Migration runner driven by ordered SQL files. Pure-function modules in `src/lib/server/` that are exercised via Vitest. SearXNG pulled as Docker container but not integrated yet. Production Docker image deferred to Phase 6 — dev uses `npm run dev`. + +**Tech Stack:** Node 22+, SvelteKit 2, TypeScript 5, better-sqlite3, Vitest 2, Zod (for JSON-LD shape validation), Docker Compose (SearXNG), pnpm or npm (npm is assumed below). + +**Reference spec:** `docs/superpowers/specs/2026-04-17-kochwas-design.md` + +--- + +## File Structure (created in this phase) + +``` +package.json # deps, scripts +tsconfig.json # strict mode +svelte.config.js # adapter-node +vite.config.ts # Vitest config +.prettierrc # formatting +.eslintrc.cjs # linting +.env.example # documented env vars +.gitignore # node_modules, data, .svelte-kit +docker-compose.yml # SearXNG for dev +searxng/settings.yml # SearXNG config + +src/app.d.ts # SvelteKit app type +src/app.html # base HTML shell +src/routes/+layout.svelte # minimal layout +src/routes/+page.svelte # placeholder homepage +src/routes/api/health/+server.ts # health endpoint + +src/lib/server/db/index.ts # connection helper +src/lib/server/db/migrate.ts # migration runner +src/lib/server/db/migrations/001_init.sql # core schema + FTS5 triggers + +src/lib/server/parsers/iso8601-duration.ts # ISO8601 → minutes +src/lib/server/parsers/ingredient.ts # "200 g Mehl" → structured +src/lib/server/parsers/json-ld-recipe.ts # HTML → Recipe shape +src/lib/server/recipes/scaler.ts # scale recipe by factor + +src/lib/types.ts # shared types (Recipe, Ingredient, …) + +tests/fixtures/chefkoch-carbonara.html # real HTML fixture +tests/fixtures/emmi-zucchinipuffer.html # real HTML fixture +tests/unit/iso8601-duration.test.ts +tests/unit/ingredient.test.ts +tests/unit/scaler.test.ts +tests/unit/json-ld-recipe.test.ts +tests/integration/db.test.ts +``` + +--- + +## Task 1: Scaffold SvelteKit project + +**Files:** +- Create: `package.json`, `svelte.config.js`, `tsconfig.json`, `vite.config.ts`, `src/app.d.ts`, `src/app.html`, `src/routes/+layout.svelte`, `src/routes/+page.svelte`, `.gitignore`, `.prettierrc`, `.eslintrc.cjs` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "kochwas", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^22.9.0", + "eslint": "^9.14.0", + "prettier": "^3.3.3", + "prettier-plugin-svelte": "^3.2.7", + "svelte": "^5.1.0", + "svelte-check": "^4.0.5", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.4" + }, + "dependencies": { + "better-sqlite3": "^11.5.0", + "linkedom": "^0.18.5", + "zod": "^3.23.8" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} +``` + +- [ ] **Step 3: Create svelte.config.js** + +```js +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + kit: { adapter: adapter() } +}; +``` + +- [ ] **Step 4: Create vite.config.ts** + +```ts +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['tests/**/*.test.ts'], + globals: false, + environment: 'node' + } +}); +``` + +- [ ] **Step 5: Create .gitignore, .prettierrc, src/app.html, src/app.d.ts, src/routes/+layout.svelte, src/routes/+page.svelte** + +`.gitignore`: +``` +node_modules/ +.svelte-kit/ +build/ +data/ +.env +.env.local +*.log +``` + +`.prettierrc`: +```json +{ + "useTabs": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} +``` + +`src/app.html`: +```html + + + + + + + Kochwas + %sveltekit.head% + + +
%sveltekit.body%
+ + +``` + +`src/app.d.ts`: +```ts +declare global { + namespace App {} +} +export {}; +``` + +`src/routes/+layout.svelte`: +```svelte + +``` + +`src/routes/+page.svelte`: +```svelte +

Kochwas

+

Coming soon.

+``` + +- [ ] **Step 6: Install & verify** + +Run: +```bash +npm install +npx svelte-kit sync +npm run check +``` + +Expected: no type errors. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(scaffold): init SvelteKit + TypeScript project" +``` + +--- + +## Task 2: Set up Vitest with a smoke test + +**Files:** +- Create: `tests/unit/smoke.test.ts` + +- [ ] **Step 1: Write smoke test** + +```ts +// tests/unit/smoke.test.ts +import { describe, it, expect } from 'vitest'; + +describe('smoke', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run** + +```bash +npm test +``` + +Expected: 1 test passes. + +- [ ] **Step 3: Commit** + +```bash +git add tests/ +git commit -m "test(infra): add vitest smoke test" +``` + +--- + +## Task 3: ISO8601 duration parser + +**Files:** +- Create: `src/lib/server/parsers/iso8601-duration.ts` +- Test: `tests/unit/iso8601-duration.test.ts` + +Purpose: JSON-LD recipes use ISO8601 durations like `PT1H30M`. We need minutes as an integer. + +- [ ] **Step 1: Write failing tests** + +```ts +// tests/unit/iso8601-duration.test.ts +import { describe, it, expect } from 'vitest'; +import { parseIso8601Duration } from '../../src/lib/server/parsers/iso8601-duration'; + +describe('parseIso8601Duration', () => { + it.each([ + ['PT30M', 30], + ['PT1H', 60], + ['PT1H30M', 90], + ['PT2H15M', 135], + ['PT0M', 0], + ['P1DT2H', 26 * 60], + ['PT90M', 90] + ])('parses %s to %i minutes', (input, expected) => { + expect(parseIso8601Duration(input)).toBe(expected); + }); + + it.each([[''], [null], [undefined], ['garbage'], ['30 min'], ['PT']])( + 'returns null for invalid input %j', + (input) => { + expect(parseIso8601Duration(input as string)).toBeNull(); + } + ); +}); +``` + +- [ ] **Step 2: Run — should fail** + +```bash +npm test -- iso8601-duration +``` + +Expected: fails with "parseIso8601Duration is not a function" or similar. + +- [ ] **Step 3: Implement** + +```ts +// src/lib/server/parsers/iso8601-duration.ts +const PATTERN = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; + +export function parseIso8601Duration(input: string | null | undefined): number | null { + if (typeof input !== 'string' || input.length === 0) return null; + const m = PATTERN.exec(input); + if (!m) return null; + const [, d, h, min] = m; + if (!d && !h && !min && !input.includes('S')) { + // Must have at least one component to count; reject bare "PT"/"P" + if (input === 'P' || input === 'PT') return null; + } + const days = d ? parseInt(d, 10) : 0; + const hours = h ? parseInt(h, 10) : 0; + const mins = min ? parseInt(min, 10) : 0; + return days * 24 * 60 + hours * 60 + mins; +} +``` + +- [ ] **Step 4: Run — should pass** + +```bash +npm test -- iso8601-duration +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/parsers/iso8601-duration.ts tests/unit/iso8601-duration.test.ts +git commit -m "feat(parser): add ISO8601 duration parser" +``` + +--- + +## Task 4: Shared types + +**Files:** +- Create: `src/lib/types.ts` + +Purpose: Central type definitions shared between server and client. + +- [ ] **Step 1: Write types** + +```ts +// src/lib/types.ts +export type Ingredient = { + position: number; + quantity: number | null; + unit: string | null; + name: string; + note: string | null; + raw_text: string; +}; + +export type Step = { + position: number; + text: string; +}; + +export type Recipe = { + id: number | null; // null for preview (not saved yet) + title: string; + description: string | null; + source_url: string | null; + source_domain: string | null; + image_path: string | null; + servings_default: number | null; + servings_unit: string | null; + prep_time_min: number | null; + cook_time_min: number | null; + total_time_min: number | null; + cuisine: string | null; + category: string | null; + ingredients: Ingredient[]; + steps: Step[]; + tags: string[]; +}; + +export type Profile = { + id: number; + name: string; + avatar_emoji: string | null; +}; + +export type AllowedDomain = { + id: number; + domain: string; + display_name: string | null; +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/lib/types.ts +git commit -m "feat(types): add shared type definitions" +``` + +--- + +## Task 5: Ingredient parser + +**Files:** +- Create: `src/lib/server/parsers/ingredient.ts` +- Test: `tests/unit/ingredient.test.ts` + +Purpose: Convert free-text lines like `"200 g Mehl"` to a structured `{ quantity, unit, name, note, raw_text }`. + +- [ ] **Step 1: Write failing tests** + +```ts +// tests/unit/ingredient.test.ts +import { describe, it, expect } from 'vitest'; +import { parseIngredient } from '../../src/lib/server/parsers/ingredient'; + +describe('parseIngredient', () => { + it.each([ + ['200 g Mehl', { quantity: 200, unit: 'g', name: 'Mehl' }], + ['1 kg Kartoffeln', { quantity: 1, unit: 'kg', name: 'Kartoffeln' }], + ['500 ml Milch', { quantity: 500, unit: 'ml', name: 'Milch' }], + ['1 TL Salz', { quantity: 1, unit: 'TL', name: 'Salz' }], + ['2 EL Olivenöl', { quantity: 2, unit: 'EL', name: 'Olivenöl' }], + ['3 Eier', { quantity: 3, unit: null, name: 'Eier' }], + ['1/2 Zitrone', { quantity: 0.5, unit: null, name: 'Zitrone' }], + ['1,5 l Wasser', { quantity: 1.5, unit: 'l', name: 'Wasser' }] + ])('parses %s', (input, expected) => { + const parsed = parseIngredient(input); + expect(parsed.quantity).toBe(expected.quantity); + expect(parsed.unit).toBe(expected.unit); + expect(parsed.name).toBe(expected.name); + expect(parsed.raw_text).toBe(input); + }); + + it('handles notes', () => { + const p = parseIngredient('200 g Mehl (Type 550)'); + expect(p.quantity).toBe(200); + expect(p.name).toBe('Mehl'); + expect(p.note).toBe('Type 550'); + }); + + it('falls back to raw_text when unparsable', () => { + const p = parseIngredient('etwas frischer Pfeffer'); + expect(p.quantity).toBeNull(); + expect(p.unit).toBeNull(); + expect(p.name).toBe('etwas frischer Pfeffer'); + expect(p.raw_text).toBe('etwas frischer Pfeffer'); + }); + + it('handles ranges by taking the lower bound', () => { + const p = parseIngredient('2-3 Tomaten'); + expect(p.quantity).toBe(2); + expect(p.name).toBe('Tomaten'); + }); +}); +``` + +- [ ] **Step 2: Run — should fail** + +- [ ] **Step 3: Implement** + +```ts +// src/lib/server/parsers/ingredient.ts +import type { Ingredient } from '$lib/types'; + +const UNITS = new Set([ + 'g', 'kg', 'ml', 'l', 'cl', 'dl', + 'TL', 'EL', 'Prise', 'Pck.', 'Pkg', 'Becher', 'Stk', 'Stück', 'Bund', 'Tasse', 'Dose' +]); + +const FRACTION_MAP: Record = { + '1/2': 0.5, '1/3': 1 / 3, '2/3': 2 / 3, '1/4': 0.25, '3/4': 0.75 +}; + +function parseQuantity(raw: string): number | null { + const trimmed = raw.trim(); + if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed]; + // Range like "2-3" → take lower bound + const rangeMatch = /^(\d+[.,]?\d*)\s*[-–]\s*\d+[.,]?\d*$/.exec(trimmed); + if (rangeMatch) { + return parseFloat(rangeMatch[1].replace(',', '.')); + } + const num = parseFloat(trimmed.replace(',', '.')); + return Number.isFinite(num) ? num : null; +} + +export function parseIngredient(raw: string, position = 0): Ingredient { + const rawText = raw.trim(); + // Strip note in parentheses + let working = rawText; + let note: string | null = null; + const noteMatch = /\(([^)]+)\)/.exec(working); + if (noteMatch) { + note = noteMatch[1].trim(); + working = (working.slice(0, noteMatch.index) + working.slice(noteMatch.index + noteMatch[0].length)).trim(); + } + + // Match: [quantity] [unit] + // Quantity can be a number, fraction, or range + const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/; + const qtyMatch = qtyPattern.exec(working); + if (!qtyMatch) { + return { position, quantity: null, unit: null, name: working, note, raw_text: rawText }; + } + const quantity = parseQuantity(qtyMatch[1]); + let rest = qtyMatch[2].trim(); + let unit: string | null = null; + // Check if first token of rest is a unit + const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(rest); + if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) { + unit = firstTokenMatch[1]; + rest = firstTokenMatch[2].trim(); + } + return { position, quantity, unit, name: rest, note, raw_text: rawText }; +} +``` + +- [ ] **Step 4: Run — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/parsers/ingredient.ts tests/unit/ingredient.test.ts +git commit -m "feat(parser): add ingredient parser" +``` + +--- + +## Task 6: Scaler + +**Files:** +- Create: `src/lib/server/recipes/scaler.ts` +- Test: `tests/unit/scaler.test.ts` + +Purpose: Scale ingredient quantities by a factor, leaving unparsable ingredients unchanged. + +- [ ] **Step 1: Write failing tests** + +```ts +// tests/unit/scaler.test.ts +import { describe, it, expect } from 'vitest'; +import { scaleIngredients, roundQuantity } from '../../src/lib/server/recipes/scaler'; +import type { Ingredient } from '../../src/lib/types'; + +const mk = (q: number | null, unit: string | null, name: string): Ingredient => ({ + position: 0, quantity: q, unit, name, note: null, raw_text: '' +}); + +describe('roundQuantity', () => { + it.each([ + [0.333333, 0.33], + [12.345, 12], + [1.55, 1.6], + [100.49, 100], + [0.5, 0.5] + ])('rounds %f to %f', (input, expected) => { + expect(roundQuantity(input)).toBe(expected); + }); +}); + +describe('scaleIngredients', () => { + it('scales parsed quantities', () => { + const scaled = scaleIngredients([mk(200, 'g', 'Mehl'), mk(3, null, 'Eier')], 2); + expect(scaled[0].quantity).toBe(400); + expect(scaled[1].quantity).toBe(6); + }); + + it('leaves null quantities alone', () => { + const scaled = scaleIngredients([mk(null, null, 'etwas Salz')], 2); + expect(scaled[0].quantity).toBeNull(); + expect(scaled[0].name).toBe('etwas Salz'); + }); + + it('rounds sensibly', () => { + const scaled = scaleIngredients([mk(100, 'g', 'Butter')], 1 / 3); + expect(scaled[0].quantity).toBe(33); + }); +}); +``` + +- [ ] **Step 2: Run — should fail** + +- [ ] **Step 3: Implement** + +```ts +// src/lib/server/recipes/scaler.ts +import type { Ingredient } from '$lib/types'; + +export function roundQuantity(q: number): number { + if (q === 0) return 0; + if (q < 1) return Math.round(q * 100) / 100; // 2 decimals + if (q < 10) return Math.round(q * 10) / 10; // 1 decimal + return Math.round(q); // integer +} + +export function scaleIngredients(ings: Ingredient[], factor: number): Ingredient[] { + if (factor <= 0) throw new Error('factor must be positive'); + return ings.map((i) => ({ + ...i, + quantity: i.quantity === null ? null : roundQuantity(i.quantity * factor) + })); +} +``` + +- [ ] **Step 4: Run — should pass** + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/recipes/scaler.ts tests/unit/scaler.test.ts +git commit -m "feat(scaler): add ingredient scaling with sensible rounding" +``` + +--- + +## Task 7: JSON-LD recipe extractor + +**Files:** +- Create: `src/lib/server/parsers/json-ld-recipe.ts` +- Test: `tests/unit/json-ld-recipe.test.ts` +- Fixtures: `tests/fixtures/chefkoch-carbonara.html`, `tests/fixtures/emmi-zucchinipuffer.html` + +Purpose: Given a full HTML page, find the schema.org/Recipe JSON-LD block and return a normalized Recipe object (without id/image_path — those are filled by the importer in Phase 2). + +- [ ] **Step 1: Fetch real HTML fixtures** + +Run locally (curl or browser → Save As): +```bash +curl -sL -A "Mozilla/5.0" "https://www.chefkoch.de/rezepte/863891191419032/Spaghetti-Carbonara-the-real-one.html" -o tests/fixtures/chefkoch-carbonara.html +curl -sL -A "Mozilla/5.0" "https://emmikochteinfach.de/zucchinipuffer-einfach-und-schnell/" -o tests/fixtures/emmi-zucchinipuffer.html +``` + +If any fetch fails, note it and commit the others. The goal is at least one working fixture per format family. + +- [ ] **Step 2: Write failing tests** + +```ts +// tests/unit/json-ld-recipe.test.ts +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe'; + +function load(name: string): string { + return readFileSync(join(__dirname, '../fixtures', name), 'utf8'); +} + +describe('extractRecipeFromHtml', () => { + it('extracts a recipe from Chefkoch HTML', () => { + const html = load('chefkoch-carbonara.html'); + const r = extractRecipeFromHtml(html); + expect(r).not.toBeNull(); + expect(r!.title.toLowerCase()).toContain('carbonara'); + expect(r!.ingredients.length).toBeGreaterThan(2); + expect(r!.steps.length).toBeGreaterThan(0); + }); + + it('extracts a recipe from Emmi kocht einfach HTML', () => { + const html = load('emmi-zucchinipuffer.html'); + const r = extractRecipeFromHtml(html); + expect(r).not.toBeNull(); + expect(r!.title.toLowerCase()).toContain('zucchini'); + expect(r!.ingredients.length).toBeGreaterThan(0); + }); + + it('returns null when no Recipe JSON-LD present', () => { + const html = '

no recipe

'; + expect(extractRecipeFromHtml(html)).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run — should fail** + +- [ ] **Step 4: Implement** + +```ts +// src/lib/server/parsers/json-ld-recipe.ts +import { parseHTML } from 'linkedom'; +import { parseIso8601Duration } from './iso8601-duration'; +import { parseIngredient } from './ingredient'; +import type { Recipe, Step } from '$lib/types'; + +type JsonLdNode = Record; + +function unwrapGraph(node: unknown): JsonLdNode[] { + if (Array.isArray(node)) return node.flatMap(unwrapGraph); + if (node && typeof node === 'object') { + const obj = node as JsonLdNode; + if (obj['@graph']) return unwrapGraph(obj['@graph']); + return [obj]; + } + return []; +} + +function isRecipeType(t: unknown): boolean { + if (typeof t === 'string') return t === 'Recipe' || t.endsWith('/Recipe'); + if (Array.isArray(t)) return t.some(isRecipeType); + return false; +} + +function toText(v: unknown): string | null { + if (typeof v === 'string') return v.trim() || null; + if (Array.isArray(v) && v.length > 0) return toText(v[0]); + if (v && typeof v === 'object') { + const o = v as JsonLdNode; + if (typeof o.name === 'string') return o.name.trim(); + if (typeof o.text === 'string') return o.text.trim(); + } + return null; +} + +function toImagePath(v: unknown): string | null { + if (typeof v === 'string') return v; + if (Array.isArray(v) && v.length > 0) return toImagePath(v[0]); + if (v && typeof v === 'object') { + const o = v as JsonLdNode; + if (typeof o.url === 'string') return o.url; + } + return null; +} + +function toStringArray(v: unknown): string[] { + if (Array.isArray(v)) return v.map((x) => toText(x)).filter((x): x is string => x !== null); + if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean); + return []; +} + +function toSteps(v: unknown): Step[] { + const out: Step[] = []; + const walk = (x: unknown) => { + if (Array.isArray(x)) { + for (const item of x) walk(item); + return; + } + if (typeof x === 'string') { + if (x.trim()) out.push({ position: out.length + 1, text: x.trim() }); + return; + } + if (x && typeof x === 'object') { + const obj = x as JsonLdNode; + if (obj['@type'] === 'HowToSection' && obj.itemListElement) { + walk(obj.itemListElement); + return; + } + if (obj['@type'] === 'HowToStep' && typeof obj.text === 'string') { + out.push({ position: out.length + 1, text: obj.text.trim() }); + return; + } + if (typeof obj.text === 'string') { + out.push({ position: out.length + 1, text: obj.text.trim() }); + } + } + }; + walk(v); + return out; +} + +function toServings(v: unknown): number | null { + if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v); + if (typeof v === 'string') { + const m = /(\d+)/.exec(v); + if (m) return parseInt(m[1], 10); + } + if (Array.isArray(v) && v.length > 0) return toServings(v[0]); + return null; +} + +function findRecipeNode(html: string): JsonLdNode | null { + const { document } = parseHTML(html); + const scripts = document.querySelectorAll('script[type="application/ld+json"]'); + for (const script of scripts) { + const raw = script.textContent; + if (!raw) continue; + try { + const parsed = JSON.parse(raw); + for (const node of unwrapGraph(parsed)) { + if (isRecipeType(node['@type'])) return node; + } + } catch { + // ignore malformed JSON-LD blocks and continue + } + } + return null; +} + +export function extractRecipeFromHtml(html: string): Recipe | null { + const node = findRecipeNode(html); + if (!node) return null; + + const title = toText(node.name) ?? ''; + if (!title) return null; + + const ingredients = Array.isArray(node.recipeIngredient) + ? (node.recipeIngredient as unknown[]) + .map((x, i) => (typeof x === 'string' ? parseIngredient(x, i + 1) : null)) + .filter((x): x is NonNullable => x !== null) + : []; + + const steps = toSteps(node.recipeInstructions); + const imageUrl = toImagePath(node.image); + + const prep = parseIso8601Duration(node.prepTime as string | undefined); + const cook = parseIso8601Duration(node.cookTime as string | undefined); + const total = parseIso8601Duration(node.totalTime as string | undefined); + + const tags = new Set([ + ...toStringArray(node.recipeCategory), + ...toStringArray(node.recipeCuisine), + ...toStringArray(node.keywords) + ]); + + return { + id: null, + title, + description: toText(node.description), + source_url: typeof node.url === 'string' ? node.url : null, + source_domain: null, // filled by importer + image_path: imageUrl, // here it's still a URL, importer downloads & replaces + servings_default: toServings(node.recipeYield), + servings_unit: null, + prep_time_min: prep, + cook_time_min: cook, + total_time_min: total, + cuisine: toText(node.recipeCuisine), + category: toText(node.recipeCategory), + ingredients, + steps, + tags: [...tags] + }; +} +``` + +- [ ] **Step 5: Run — should pass** + +```bash +npm test -- json-ld-recipe +``` + +If a fixture fails, inspect the HTML and adjust the extractor — do not alter the test. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/parsers/json-ld-recipe.ts tests/unit/json-ld-recipe.test.ts tests/fixtures/ +git commit -m "feat(parser): add JSON-LD schema.org/Recipe extractor" +``` + +--- + +## Task 8: Database schema and migration runner + +**Files:** +- Create: `src/lib/server/db/migrate.ts`, `src/lib/server/db/index.ts`, `src/lib/server/db/migrations/001_init.sql` +- Test: `tests/integration/db.test.ts` + +- [ ] **Step 1: Write schema file** + +```sql +-- src/lib/server/db/migrations/001_init.sql +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS profile ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + avatar_emoji TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS recipe ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + source_url TEXT UNIQUE, + source_domain TEXT, + image_path TEXT, + servings_default INTEGER, + servings_unit TEXT, + prep_time_min INTEGER, + cook_time_min INTEGER, + total_time_min INTEGER, + cuisine TEXT, + category TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS ingredient ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + quantity REAL, + unit TEXT, + name TEXT NOT NULL, + note TEXT, + raw_text TEXT +); +CREATE INDEX IF NOT EXISTS ix_ingredient_recipe ON ingredient(recipe_id, position); + +CREATE TABLE IF NOT EXISTS step ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + text TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_step_recipe ON step(recipe_id, position); + +CREATE TABLE IF NOT EXISTS tag ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS recipe_tag ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE, + PRIMARY KEY (recipe_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS rating ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + stars INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (recipe_id, profile_id) +); + +CREATE TABLE IF NOT EXISTS comment ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS favorite ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (recipe_id, profile_id) +); + +CREATE TABLE IF NOT EXISTS cooking_log ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + cooked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS ix_cooking_log_recipe ON cooking_log(recipe_id, cooked_at); + +CREATE TABLE IF NOT EXISTS allowed_domain ( + id INTEGER PRIMARY KEY, + domain TEXT UNIQUE NOT NULL, + display_name TEXT, + added_by_profile_id INTEGER REFERENCES profile(id), + added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- FTS5 virtual table +CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5( + title, description, ingredients_concat, tags_concat, + content='', tokenize='unicode61 remove_diacritics 2' +); + +-- Triggers to keep FTS in sync with recipe + ingredient + recipe_tag +-- Strategy: rebuild per-recipe rows via triggers. Simpler than incremental. +CREATE TRIGGER IF NOT EXISTS trg_recipe_ai AFTER INSERT ON recipe BEGIN + INSERT INTO recipe_fts(rowid, title, description, ingredients_concat, tags_concat) + VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''), '', ''); +END; + +CREATE TRIGGER IF NOT EXISTS trg_recipe_ad AFTER DELETE ON recipe BEGIN + INSERT INTO recipe_fts(recipe_fts, rowid, title, description, ingredients_concat, tags_concat) + VALUES ('delete', OLD.id, OLD.title, COALESCE(OLD.description, ''), '', ''); +END; + +CREATE TRIGGER IF NOT EXISTS trg_recipe_au AFTER UPDATE ON recipe BEGIN + INSERT INTO recipe_fts(recipe_fts, rowid, title, description, ingredients_concat, tags_concat) + VALUES ('delete', OLD.id, OLD.title, COALESCE(OLD.description, ''), '', ''); + INSERT INTO recipe_fts(rowid, title, description, ingredients_concat, tags_concat) + VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''), + COALESCE((SELECT group_concat(name, ' ') FROM ingredient WHERE recipe_id = NEW.id), ''), + COALESCE((SELECT group_concat(t.name, ' ') FROM tag t JOIN recipe_tag rt ON rt.tag_id = t.id WHERE rt.recipe_id = NEW.id), '') + ); +END; + +-- Ingredient & tag changes trigger full FTS row refresh for the recipe +CREATE TRIGGER IF NOT EXISTS trg_ingredient_ai AFTER INSERT ON ingredient BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.recipe_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_ingredient_ad AFTER DELETE ON ingredient BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.recipe_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_recipe_tag_ai AFTER INSERT ON recipe_tag BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.recipe_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_recipe_tag_ad AFTER DELETE ON recipe_tag BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.recipe_id; +END; + +-- Meta table for tracking applied migrations +CREATE TABLE IF NOT EXISTS schema_migration ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +- [ ] **Step 2: Write migration runner** + +```ts +// src/lib/server/db/migrate.ts +import Database from 'better-sqlite3'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function runMigrations(db: Database.Database): void { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migration ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + );`); + + const here = dirname(fileURLToPath(import.meta.url)); + const dir = join(here, 'migrations'); + const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort(); + + const applied = new Set( + db.prepare('SELECT name FROM schema_migration').all().map((r: any) => r.name) + ); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(dir, file), 'utf8'); + const tx = db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO schema_migration(name) VALUES (?)').run(file); + }); + tx(); + } +} +``` + +- [ ] **Step 3: Write db helper** + +```ts +// src/lib/server/db/index.ts +import Database from 'better-sqlite3'; +import { runMigrations } from './migrate'; + +let instance: Database.Database | null = null; + +export function getDb(path = process.env.DATABASE_PATH ?? './data/kochwas.db'): Database.Database { + if (instance) return instance; + instance = new Database(path); + instance.pragma('journal_mode = WAL'); + instance.pragma('foreign_keys = ON'); + runMigrations(instance); + return instance; +} + +export function openInMemoryForTest(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + return db; +} +``` + +- [ ] **Step 4: Write integration test** + +```ts +// tests/integration/db.test.ts +import { describe, it, expect } from 'vitest'; +import { openInMemoryForTest } from '../../src/lib/server/db'; + +describe('db migrations', () => { + it('creates all expected tables', () => { + const db = openInMemoryForTest(); + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() + .map((r: any) => r.name); + for (const t of [ + 'profile', 'recipe', 'ingredient', 'step', 'tag', 'recipe_tag', + 'rating', 'comment', 'favorite', 'cooking_log', 'allowed_domain', + 'schema_migration' + ]) { + expect(tables).toContain(t); + } + }); + + it('is idempotent', () => { + const db = openInMemoryForTest(); + // run again manually — should no-op + const { runMigrations } = require('../../src/lib/server/db/migrate'); + runMigrations(db); + const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }; + expect(migs.c).toBe(1); + }); + + it('cascades recipe delete to ingredients and steps', () => { + const db = openInMemoryForTest(); + const id = db + .prepare('INSERT INTO recipe(title) VALUES (?) RETURNING id') + .get('Test') as { id: number }; + db.prepare('INSERT INTO ingredient(recipe_id, position, name) VALUES (?, ?, ?)').run(id.id, 1, 'Salz'); + db.prepare('INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)').run(id.id, 1, 'Kochen'); + db.prepare('DELETE FROM recipe WHERE id = ?').run(id.id); + const ings = db.prepare('SELECT COUNT(*) AS c FROM ingredient').get() as { c: number }; + const steps = db.prepare('SELECT COUNT(*) AS c FROM step').get() as { c: number }; + expect(ings.c).toBe(0); + expect(steps.c).toBe(0); + }); +}); +``` + +- [ ] **Step 5: Update vite.config.ts to include integration tests** + +```ts +// vite.config.ts +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['tests/**/*.test.ts'], + globals: false, + environment: 'node', + testTimeout: 10_000 + } +}); +``` + +- [ ] **Step 6: Run tests** + +```bash +npm test +``` + +Expected: all previous tests + the 3 db tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(db): add SQLite schema, FTS5, migration runner" +``` + +--- + +## Task 9: Health endpoint + +**Files:** +- Create: `src/routes/api/health/+server.ts` + +- [ ] **Step 1: Implement** + +```ts +// src/routes/api/health/+server.ts +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { json } from '@sveltejs/kit'; + +export const GET: RequestHandler = async () => { + let dbOk = false; + try { + const db = getDb(); + db.prepare('SELECT 1').get(); + dbOk = true; + } catch { + dbOk = false; + } + const status = dbOk ? 200 : 503; + return json( + { db: dbOk ? 'ok' : 'error', searxng: 'unchecked', version: '0.1.0' }, + { status } + ); +}; +``` + +- [ ] **Step 2: Smoke-test manually** + +Ensure `data/` exists: +```bash +mkdir -p data +npm run dev +``` + +In another terminal: +```bash +curl -i http://localhost:5173/api/health +``` + +Expected: `HTTP/1.1 200 OK` + `{"db":"ok","searxng":"unchecked","version":"0.1.0"}`. + +Stop dev server. + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/api/health/+server.ts +git commit -m "feat(api): add /api/health endpoint" +``` + +--- + +## Task 10: SearXNG Docker Compose (dev only) + +**Files:** +- Create: `docker-compose.yml`, `searxng/settings.yml`, `.env.example` + +- [ ] **Step 1: Write compose & config** + +`docker-compose.yml`: +```yaml +services: + searxng: + image: searxng/searxng:latest + ports: + - "8888:8080" + volumes: + - ./searxng:/etc/searxng + environment: + - BASE_URL=http://localhost:8888/ + - INSTANCE_NAME=kochwas-search-dev + restart: unless-stopped +``` + +`searxng/settings.yml` (minimal, allows JSON output): +```yaml +use_default_settings: true +server: + secret_key: 'dev-secret-change-in-prod' + limiter: false + image_proxy: false + default_http_headers: + X-Content-Type-Options: nosniff + X-Download-Options: noopen + X-Robots-Tag: noindex, nofollow +search: + formats: + - html + - json +ui: + default_locale: de +``` + +`.env.example`: +``` +DATABASE_PATH=./data/kochwas.db +IMAGE_DIR=./data/images +SEARXNG_URL=http://localhost:8888 +``` + +- [ ] **Step 2: Smoke-test** + +```bash +docker compose up -d searxng +sleep 5 +curl -s "http://localhost:8888/search?q=carbonara&format=json" | head -c 300 +docker compose down +``` + +Expected: JSON response with search results. + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.yml searxng/ .env.example +git commit -m "feat(infra): add SearXNG dev container" +``` + +--- + +## Task 11: Basic homepage with search input + +**Files:** +- Modify: `src/routes/+page.svelte` + +Minimal placeholder so we have something to open in the browser. Real search lands in Phase 3. + +- [ ] **Step 1: Implement** + +```svelte + + + +
+

Kochwas

+
+ + +
+

Phase 1: Foundations — Suche folgt in Phase 3.

+
+ + +``` + +- [ ] **Step 2: Smoke-test** + +```bash +npm run dev +``` + +Open `http://localhost:5173/`, see the search box. Stop dev. + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/+page.svelte +git commit -m "feat(ui): add minimal homepage with search input" +``` + +--- + +## Phase 1 Done-When + +- `npm test` green (all unit + integration tests) +- `npm run check` green (TypeScript clean) +- `npm run dev` starts the app; `http://localhost:5173/` renders the search input +- `curl http://localhost:5173/api/health` returns `{"db":"ok",…}` with 200 +- `docker compose up -d searxng` brings SearXNG up; JSON endpoint responds +- `docker compose down` cleans up +- All tasks committed on `main`