6.1 KiB
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
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-emptysource— the original URLdescriptionauthorcourse(recipeCategory)cuisineservings— e.g.4 servingstotalTime,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:
- Read
urlparameter from the POST body (URL-encoded form data). - Validate: non-empty URL. Fail early with a form validation error.
- Run
scripts/scrape-recipe --pretty <url>as a subprocess viaSystem.Process.readProcessWithExitCode.- Non-zero exit code: render error page showing the stderr output ("Could not scrape URL: {error}").
- Parse stdout with
parseSchemaOrgRecipe.- JSON parse failure: render error page with the raw JSON preview (truncated) for debugging ("Could not parse recipe data: {reason}").
- Convert with
schemaOrgToCooklang.- Conversion failure: render error page with the message.
- Render to Cooklang text with
CooklangPrint.renderRecipe. - Derive filename from the recipe title:
filenameFromTitle. - Write to recipe directory via
BS.writeFile.- IO error: render error page with the exception message.
- If the file already exists, overwrite it (the latest scrape is authoritative).
- 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—renderRecipeandfilenameFromTitlefunctions
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.renderReciperound-trips the test recipes (parse an existing.cookfile → 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