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

38 KiB
Raw Permalink Blame History

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

{
  "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
{
  "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
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
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:

{
  "useTabs": false,
  "singleQuote": true,
  "trailingComma": "none",
  "printWidth": 100,
  "plugins": ["prettier-plugin-svelte"],
  "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

src/app.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:

declare global {
  namespace App {}
}
export {};

src/routes/+layout.svelte:

<slot />

src/routes/+page.svelte:

<h1>Kochwas</h1>
<p>Coming soon.</p>
  • Step 6: Install & verify

Run:

npm install
npx svelte-kit sync
npm run check

Expected: no type errors.

  • Step 7: Commit
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

// tests/unit/smoke.test.ts
import { describe, it, expect } from 'vitest';

describe('smoke', () => {
  it('runs', () => {
    expect(1 + 1).toBe(2);
  });
});
  • Step 2: Run
npm test

Expected: 1 test passes.

  • Step 3: Commit
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
// 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
npm test -- iso8601-duration

Expected: fails with "parseIso8601Duration is not a function" or similar.

  • Step 3: Implement
// 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
npm test -- iso8601-duration

Expected: all tests pass.

  • Step 5: Commit
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
// 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
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
// 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

// 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

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
// 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

// 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

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):

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
// 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

// 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
npm test -- json-ld-recipe

If a fixture fails, inspect the HTML and adjust the extractor — do not alter the test.

  • Step 6: Commit
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

-- 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
// 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
// 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
// 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
// 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
npm test

Expected: all previous tests + the 3 db tests pass.

  • Step 7: Commit
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

// 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:

mkdir -p data
npm run dev

In another terminal:

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
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:

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):

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
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
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
<!-- 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
npm run dev

Open http://localhost:5173/, see the search box. Stop dev.

  • Step 3: Commit
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