docs: add recipe import design spec
Build and Deploy / build-and-deploy (push) Successful in 1m17s

This commit is contained in:
2026-05-20 12:55:21 -04:00
parent 602324a699
commit 751a9f853c
@@ -0,0 +1,181 @@
# Recipe Import via Web Form
**Date:** 2026-05-20
**Status:** Approved
## Problem
There is no way to import a recipe from a URL. A user who finds a recipe on
the web must manually transcribe it into Cooklang format and save it to the
recipes directory. The `scrape-recipe` script and `SchemaOrg` module already
exist but have no web-facing integration.
## Goal
Add a web form at `/import` where a user can paste a recipe URL. The server
scrapes the URL, converts the schema.org JSON-LD to Cooklang, and writes the
resulting `.cook` file to the recipe directory — all with useful error
feedback at each step.
## Data Flow
```
User submits URL
Server spawns: scripts/scrape-recipe --pretty <url>
Captures stdout (JSON-LD) and stderr (error messages)
parseSchemaOrgRecipe parses JSON-LD → SchemaOrgRecipe
schemaOrgToCooklang converts → Data.CookLang.Recipe
New CooklangPrint module renders Recipe → Cooklang text
Write .cook file to recipe directory
Redirect to /recipes/<filename> on success,
or render error page with details on failure
```
## New Module: `Roux.CooklangPrint`
A renderer for `Data.CookLang.Recipe` → Cooklang text. Currently there is no
way to serialize a parsed Recipe back to disk.
### Interface
```haskell
module Roux.CooklangPrint (renderRecipe, filenameFromTitle) where
renderRecipe :: Recipe -> Text
-- Produces the full .cook file text with YAML front matter.
filenameFromTitle :: Text -> FilePath
-- Converts a recipe title to a safe filename (e.g. "Fried Rice" → "fried-rice.cook").
```
### Rendering logic
The `.cook` file format:
```
---
title: {{title}}
tags: {{tags}}
source: {{source}}
description: {{description}}
---
Step 1 text with @ingredient{1%unit} and #cookware{} and ~{5%minutes}.
== Method ==
Step 2 text...
```
**Front matter** — only includes metadata fields that are present:
- `title` (if non-empty — always present for imported recipes)
- `tags` — comma-separated, if non-empty
- `source` — the original URL
- `description`
- `author`
- `course` (recipeCategory)
- `cuisine`
- `servings` — e.g. `4 servings`
- `totalTime`, `prepTime`, `cookTime` — as human-readable minutes
**Sections** — unnamed sections are rendered without a header; named sections
get `== Name ==` as a header on its own line.
**Steps within a section** — separated by one blank line.
**StepItems** — rendered back to Cooklang syntax:
| StepItem | Cooklang output | Example |
|----------|----------------|---------|
| `StepText t` | plain text | `cook the ` |
| `StepIngredient i` | `@name{amount%unit}` | `@chicken{1%kg}` |
| `StepCookware c` | `#name{}` | `#pot{}` |
| `StepTimer t` | `~{amount%unit}` | `~{30%minutes}` |
| `StepBreak` | `\\` + newline | `\\\n` |
| `StepComment t` | `[- t -]` | `[- optional -]` |
| `StepEndComment t` | ` -- t` | ` -- to taste` |
Quantity/unit rendering matches existing `Html.showQuantity` patterns.
## Server Route: `GET /import` and `POST /import`
**`GET /import`** — renders the import form page with a URL text input and
submit button, matching the existing Pico CSS styling.
**`POST /import`** — handles the full import pipeline:
1. Read `url` parameter from the POST body (URL-encoded form data).
2. Validate: non-empty URL. Fail early with a form validation error.
3. Run `scripts/scrape-recipe --pretty <url>` as a subprocess via
`System.Process.readProcessWithExitCode`.
- **Non-zero exit code:** render error page showing the stderr output
("Could not scrape URL: {error}").
4. Parse stdout with `parseSchemaOrgRecipe`.
- **JSON parse failure:** render error page with the raw JSON preview
(truncated) for debugging ("Could not parse recipe data: {reason}").
5. Convert with `schemaOrgToCooklang`.
- **Conversion failure:** render error page with the message.
6. Render to Cooklang text with `CooklangPrint.renderRecipe`.
7. Derive filename from the recipe title: `filenameFromTitle`.
8. Write to recipe directory via `BS.writeFile`.
- **IO error:** render error page with the exception message.
9. If the file already exists, **overwrite** it (the latest scrape is authoritative).
10. Redirect (303) to `/recipes/<filename>`.
The error pages are specific to each failure mode so the user knows exactly
what went wrong.
### URL routing
Add `["import"]` to both GET and POST dispatch in the `router`. GET renders
the form; POST processes the submission. Request method is checked via
`Wai.requestMethod`.
## Error Feedback Matrix
| Step | Failure mode | User sees |
|------|-------------|-----------|
| Subprocess | Script not found, timeout, HTTP error from scraper | "Could not scrape URL: {stderr}" with the URL shown |
| JSON parse | Page has no schema.org Recipe data | "Could not parse recipe data: {error}" with truncated JSON preview |
| Cooklang conversion | Missing required fields | "Could not convert recipe: {message}" |
| File write | Permission denied, disk full | "Could not save recipe: {IO error}" |
A generic catch-all handler wraps the entire POST handler so any unexpected
exception also produces a friendly error page.
## Files Changed
### New
- `src/Roux/CooklangPrint.hs``renderRecipe` and `filenameFromTitle` functions
### Modified
| File | Change |
|------|--------|
| `src/Roux/Server.hs` | Add `/import` route (GET form, POST submission), `System.Process` call |
| `src/Roux/Html.hs` | Add `importPage` (form), `importResultPage` (success/error pages) |
| `src/Roux.hs` | Re-export `CooklangPrint` types |
| `package.yaml` | Add `process` dependency to library dependencies |
### `package.yaml`
Add `process` to library dependencies list. The `process` package is in
LTS-24.38 (bundled with GHC) — no extra-dep needed.
## Testing
- **Unit test:** `CooklangPrint.renderRecipe` round-trips the test recipes
(parse an existing `.cook` file → render → parse again → equal)
- **Manual:** Visit `/import`, enter a known working recipe URL, verify the
recipe appears in the index with correct metadata
- **Manual:** Enter an invalid URL, verify a useful error page
- **Manual:** Enter a URL without schema.org data, verify a parse error page
- **Manual:** Enter a URL with partial data, verify graceful handling