1387 lines
38 KiB
Markdown
1387 lines
38 KiB
Markdown
# 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`
|