# 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 ↓ 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/ 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 ` 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/`. 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