Files
roux/docs/specs/2026-05-20-recipe-import-design.md
T
jbrechtel 751a9f853c
Build and Deploy / build-and-deploy (push) Successful in 1m17s
docs: add recipe import design spec
2026-05-20 12:55:21 -04:00

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-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.hsrenderRecipe 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