Files
kochwas/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md
2026-04-17 14:59:49 +02:00

1387 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#2b6a3d" />
<title>Kochwas</title>
%sveltekit.head%
</head>
<body>
<div id="app">%sveltekit.body%</div>
</body>
</html>
```
`src/app.d.ts`:
```ts
declare global {
namespace App {}
}
export {};
```
`src/routes/+layout.svelte`:
```svelte
<slot />
```
`src/routes/+page.svelte`:
```svelte
<h1>Kochwas</h1>
<p>Coming soon.</p>
```
- [ ] **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<string, number> = {
'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] <name>
// 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 = '<html><body><p>no recipe</p></body></html>';
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<string, unknown>;
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<typeof x> => 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<string>([
...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
<!-- src/routes/+page.svelte -->
<script lang="ts">
let query = '';
</script>
<main>
<h1>Kochwas</h1>
<form method="GET" action="/search">
<input
type="search"
name="q"
bind:value={query}
placeholder="Rezept suchen…"
autocomplete="off"
inputmode="search"
/>
<button type="submit">Suchen</button>
</form>
<p class="muted">Phase 1: Foundations — Suche folgt in Phase 3.</p>
</main>
<style>
main {
max-width: 640px;
margin: 4rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
font-size: 3rem;
margin-bottom: 2rem;
}
form {
display: flex;
gap: 0.5rem;
}
input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1.1rem;
border: 1px solid #bbb;
border-radius: 8px;
}
button {
padding: 0.75rem 1.25rem;
font-size: 1.1rem;
border-radius: 8px;
border: 0;
background: #2b6a3d;
color: white;
}
.muted {
color: #888;
font-size: 0.9rem;
text-align: center;
margin-top: 2rem;
}
</style>
```
- [ ] **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`