feat: show recipe image alongside description when metadata has one
Build and Deploy / build-and-deploy (push) Successful in 1m30s

Adds a .roux-desc-row flex container that places the recipe
description text on the left and the image (from metaImage)
on the right, using what was previously empty horizontal space.
Image is capped at 280px, with a subtle shadow and rounded
corners. Responsively stacks on mobile.
This commit is contained in:
2026-05-20 22:35:25 -04:00
parent 1b7f7b61aa
commit 8807ba3851
2 changed files with 620 additions and 4 deletions
+608
View File
@@ -0,0 +1,608 @@
# Recipe Import Implementation Plan
> **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:** Add a web form at `/import` where a user can paste a recipe URL, converting it from schema.org JSON-LD to a `.cook` file via the existing `scrape-recipe` script and `SchemaOrg` module.
**Architecture:** A new `Roux.CooklangPrint` module renders `Recipe` → Cooklang text. The server adds `GET /import` (form) and `POST /import` (submission) routes. The POST handler runs `scripts/scrape-recipe` as a subprocess, parses the JSON, converts to Cooklang, renders to text, and writes the `.cook` file — with error-specific feedback at each step.
**Tech Stack:** Haskell, WAI (body parsing), `process` package (subprocess), `text` for rendering.
---
## File Map
| File | Action | Purpose |
|---|---|---|
| `src/Roux/CooklangPrint.hs` | **Create** | Render `Recipe` → Cooklang `.cook` file text |
| `src/Roux/Html.hs` | Modify | Add import form page and result/error page templates |
| `src/Roux/Server.hs` | Modify | Add `/import` route, subprocess call, file write |
| `src/Roux.hs` | Modify | Re-export `CooklangPrint` module |
| `package.yaml` | Modify | Add `process` dependency |
---
### Task 1: Create `Roux.CooklangPrint` module
**Files:**
- Create: `src/Roux/CooklangPrint.hs`
This module contains two functions:
- `renderRecipe :: Recipe -> Text` — serializes a full `Recipe` to Cooklang text
- `filenameFromTitle :: Text -> FilePath` — e.g. `"Fried Rice"``"fried-rice.cook"`
- [ ] **Step 1: Create the module with front matter rendering**
Write `src/Roux/CooklangPrint.hs`:
```haskell
{-# LANGUAGE OverloadedStrings #-}
{- | Render a 'Recipe' to Cooklang (.cook) file text.
Produces the standard Cooklang file format with YAML front matter,
section headers, and step text with inline markup.
-}
module Roux.CooklangPrint (
renderRecipe,
filenameFromTitle,
) where
import Data.Char (isAlphaNum, toLower)
import Data.Function ((&))
import Data.List.NonEmpty (toList)
import Data.Maybe (catMaybes)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import qualified Data.Text as T
import Data.CookLang
-- | Render a full Recipe to Cooklang file text.
renderRecipe :: Recipe -> Text
renderRecipe recipe =
T.unlines $ catMaybes [renderFrontMatter meta, renderBody recipe]
where
meta = recipeMetadata recipe
-- | Render YAML front matter for metadata fields that are present.
renderFrontMatter :: Metadata -> Maybe Text
renderFrontMatter meta =
let fields = catMaybes
[ field "title" (metaTitle meta)
, field "source" (metaSource meta)
, field "description" (metaDescription meta)
, field "author" (metaAuthor meta)
, field "course" (metaCourse meta)
, field "cuisine" (metaCuisine meta)
, field "servings" (renderServings <$> metaServings meta)
, field "totalTime" (renderDurationText <$> metaTotalTime meta)
, field "prepTime" (renderDurationText <$> metaPrepTime meta)
, field "cookTime" (renderDurationText <$> metaCookTime meta)
, field "tags" (renderTagList (metaTags meta))
]
in case fields of
[] -> Nothing
_ -> Just $ T.unlines $ "---" : fields ++ ["---"]
where
field :: Text -> Maybe Text -> Maybe Text
field _ Nothing = Nothing
field key (Just val) = Just $ key <> ": " <> val
-- | Render servings like "4 servings".
renderServings :: (Int, Maybe Text) -> Text
renderServings (n, Nothing) = T.pack (show n)
renderServings (n, Just unit) = T.pack (show n) <> " " <> unit
-- | Render a duration as human-readable text like "30 minutes".
renderDurationText :: Duration -> Text
renderDurationText d =
let amt = durationAmount d
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> T.pack (show (fromRational amt :: Double))
unit = maybe "" id (durationUnit d)
in T.strip (amount <> " " <> unit)
-- | Render a tag list as a comma-separated string.
renderTagList :: [Text] -> Text
renderTagList [] = T.empty
renderTagList tags = T.intercalate ", " tags
```
- [ ] **Step 2: Add body rendering functions**
Add after `renderTagList`:
```haskell
-- | Render the recipe body (sections, steps, inline items).
renderBody :: Recipe -> Maybe Text
renderBody recipe =
let sections = toList (recipeSections recipe)
rendered = map renderSection sections
body = T.intercalate "\n" rendered
in if T.null body then Nothing else Just body
-- | Render a single section.
renderSection :: Section -> Text
renderSection section =
let header = case sectionName section of
Just name -> "== " <> name <> " ==\n"
Nothing -> T.empty
items = toList (sectionBody section)
steps = map renderBodyItem items
in header <> T.intercalate "\n" steps
-- | Render a single section body item (step, comment, or note).
renderBodyItem :: SectionBodyItem -> Text
renderBodyItem (SecStep step) = renderStep step
renderBodyItem (SecComment t) = "-- " <> t
renderBodyItem (SecNote t) = "> " <> t
-- | Render a single step (a paragraph of Cooklang text).
renderStep :: Step -> Text
renderStep step = T.concatMap renderStepItem (unStep step)
-- | Render a single inline step item back to Cooklang syntax.
renderStepItem :: StepItem -> Text
renderStepItem (StepText t) = t
renderStepItem (StepIngredient ing) =
"@" <> ingName ing <> renderQuantity (ingQuantity ing) <> renderPreparation (ingPreparation ing)
renderStepItem (StepCookware cw) = "#" <> cwName cw <> "{}"
renderStepItem (StepTimer timer) =
case timerName timer of
Just name -> "~" <> name <> "{" <> renderDuration (timerDuration timer) <> "}"
Nothing -> "~{" <> renderDuration (timerDuration timer) <> "}"
renderStepItem (StepRecipeRef ref) =
"@" <> refPath ref <> renderQuantity (refQuantity ref)
renderStepItem (StepEndComment t) = " -- " <> t
renderStepItem (StepComment t) = "[-" <> t <> "-]"
renderStepItem StepBreak = "\\\n"
-- | Render an optional quantity to Cooklang syntax.
renderQuantity :: Maybe Quantity -> Text
renderQuantity Nothing = T.empty
renderQuantity (Just q) =
let prefix = if quantityFixed q then "=" else ""
amount = renderRational (quantityAmount q)
unit = case quantityUnit q of
Just u -> "%" <> u
Nothing -> T.empty
in "{" <> prefix <> amount <> unit <> "}"
-- | Render an optional preparation to Cooklang syntax (parenthesized).
renderPreparation :: Maybe Text -> Text
renderPreparation Nothing = T.empty
renderPreparation (Just prep) = "(" <> prep <> ")"
-- | Render a rational number as a string (e.g. "1/2", "3").
renderRational :: Rational -> Text
renderRational r =
let n = numerator r
d = denominator r
in if d == 1
then T.pack (show n)
else T.pack (show n <> "/" <> show d)
-- | Render a Duration to the Cooklang timer format (e.g. "30%minutes").
renderDuration :: Duration -> Text
renderDuration d =
let amt = durationAmount d
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> T.pack (show (numerator amt) <> "/" <> show (denominator amt))
unit = case durationUnit d of
Just u -> "%" <> u
Nothing -> T.empty
in amount <> unit
-- | Convert a recipe title into a safe filename (e.g. "Fried Rice" → "fried-rice.cook").
filenameFromTitle :: Text -> FilePath
filenameFromTitle title =
let slug =
title
& T.toLower
& T.map (\c -> if c == ' ' then '-' else c)
& T.filter (\c -> isAlphaNum c || c == '-')
in T.unpack slug <> ".cook"
```
- [ ] **Step 3: Build to verify compilation**
Run: `./hs stack build`
Expected: compiles without errors.
- [ ] **Step 4: Run tests to verify nothing broke**
Run: `./hs stack test`
Expected: all tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Roux/CooklangPrint.hs
git commit -m "feat: add CooklangPrint module for rendering Recipe to .cook text"
```
---
### Task 2: Add import page templates to `Html.hs`
**Files:**
- Modify: `src/Roux/Html.hs`
- [ ] **Step 1: Add `ImportError` type and update exports**
Find the module header in `src/Roux/Html.hs`:
```haskell
module Roux.Html (
SortMode (..),
indexPage,
recipePage,
urlEncode,
urlDecode,
) where
```
Change to:
```haskell
module Roux.Html (
SortMode (..),
ImportError (..),
importPage,
importResultPage,
indexPage,
recipePage,
urlEncode,
urlDecode,
) where
```
Add the `ImportError` type near the top of the module, after the `SortMode` data type:
```haskell
-- | Error type for the import pipeline.
data ImportError = ImportError Text
deriving stock (Eq, Show)
```
- [ ] **Step 2: Add the import form and result pages**
Add after the `indexPage` function:
```haskell
-- | Render the import form page, optionally with a validation error.
importPage :: Maybe Text -> ByteString
importPage merror =
page "Roux — Import Recipe" $ do
H.div ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "← Back"
H.ul $ H.li $ H.strong "Import Recipe"
case merror of
Just err -> H.p ! A.style "color: var(--roux-accent);" $ H.toHtml err
Nothing -> pure ()
H.form ! A.method "POST" ! A.action "/import" $ do
H.label ! A.for "url" $ "Recipe URL"
H.input
! A.type_ "url"
! A.id "url"
! A.name "url"
! A.placeholder "https://cooking.nytimes.com/recipes/..."
! A.required ""
! A.style "width: 100%; margin-bottom: 1rem;"
H.button ! A.type_ "submit" $ "Import Recipe"
-- | Render the import result page with an error message.
importResultPage :: ImportError -> ByteString
importResultPage (ImportError msg) =
page "Roux — Import Error" $ do
H.div ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "← Back"
H.ul $ H.li $ H.a ! A.href "/import" $ "Try again"
H.h2 "Import failed"
H.p $ H.toHtml msg
```
- [ ] **Step 3: Build to verify compilation**
Run: `./hs stack build`
Expected: compiles without errors.
- [ ] **Step 4: Run tests to verify nothing broke**
Run: `./hs stack test`
Expected: all tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Roux/Html.hs
git commit -m "feat: add import form and result page templates"
```
---
### Task 3: Add import route to `Server.hs`
**Files:**
- Modify: `src/Roux/Server.hs`
- [ ] **Step 1: Add new imports**
Add to the import block in `src/Roux/Server.hs`:
```haskell
import qualified Data.Aeson as A
import qualified Data.ByteString as BS
import Data.Maybe (mapMaybe)
import qualified Roux.CooklangPrint as CooklangPrint
import Roux.SchemaOrg (parseSchemaOrgRecipe, schemaOrgToCooklang, SchemaOrgRecipe (..))
import System.Exit (ExitCode (..))
import System.Process (readProcessWithExitCode)
```
Note: Check existing imports first and avoid duplication. `Data.ByteString.Lazy (ByteString)` and `qualified Data.ByteString.Lazy as LB` are already present.
- [ ] **Step 2: Thread `absDir` through the router**
Change `router` signature and implementation. Replace:
```haskell
router :: IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
router state changeLog request respond =
case Wai.pathInfo request of
["events"] -> sseHandler changeLog request respond
_ -> handleRequest state request respond
```
with:
```haskell
router :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
router recipeDir state changeLog request respond =
case Wai.pathInfo request of
["events"] -> sseHandler changeLog request respond
["import"] -> importHandler recipeDir state request respond
_ -> handleRequest state request respond
```
And in `app`, change:
```haskell
pure (router state changeLog)
```
to:
```haskell
pure (router absDir state changeLog)
```
- [ ] **Step 3: Add the `readRequestBody` helper**
Add near the top of the file, after the imports:
```haskell
-- | Read the full request body as a lazy ByteString.
readRequestBody :: Wai.Request -> IO LB.ByteString
readRequestBody req = do
chunks <- gather []
pure (LB.fromChunks chunks)
where
gather acc = do
chunk <- Wai.requestBody req
if BS.null chunk
then pure (reverse acc)
else gather (chunk : acc)
```
- [ ] **Step 4: Add `importHandler` and `parseFormBody`**
Add after `router` (before `watchRecipes`):
```haskell
{- | Handle GET and POST requests to /import.
GET — show the import form.
POST — scrape the URL, convert to Cooklang, save the file.
-}
importHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
importHandler recipeDir _state request respond =
case Wai.requestMethod request of
"GET" ->
respond (htmlResponse (Html.importPage Nothing))
"POST" -> do
body <- readRequestBody request
let params = parseFormBody body
url = maybe "" id (lookup "url" params)
if T.null url
then respond (htmlResponse (Html.importPage (Just ("URL is required" :: Text))))
else do
result <- try $ runImportPipeline recipeDir url
case result of
Left (e :: SomeException) ->
respond $ htmlResponse $
Html.importResultPage $
Html.ImportError ("Unexpected error: " <> T.pack (show e))
Right (Left err) ->
respond $ htmlResponse $
Html.importResultPage err
Right (Right filename) ->
respond $
Wai.responseLBS
HTTP.status303
[("Location", "/recipes/" <> T.encodeUtf8 (T.pack filename))]
""
_ -> respond notFound
-- | Parse URL-encoded form body into key-value pairs.
parseFormBody :: LB.ByteString -> [(Text, Text)]
parseFormBody body =
let decoded = TE.decodeUtf8 $ LB.toStrict body
parts = T.splitOn "&" decoded
in mapMaybe parsePair parts
where
parsePair part = case T.splitOn "=" part of
[key, val] -> Just (urldecode key, urldecode val)
_ -> Nothing
urldecode = T.replace "+" " " . T.filter (/= '\r')
```
- [ ] **Step 5: Add `runImportPipeline`**
Add after `importHandler`:
```haskell
-- | Run the full import pipeline: scrape → parse → convert → render → save.
runImportPipeline :: FilePath -> Text -> IO (Either Html.ImportError FilePath)
runImportPipeline recipeDir url = do
-- Step 1: scrape the URL
(exitCode, stdout, stderr) <- readProcessWithExitCode
"scripts/scrape-recipe"
["--pretty", T.unpack url]
""
case exitCode of
ExitFailure _ ->
return $ Left $ Html.ImportError $
"Could not scrape URL. The scraper exited with: " <> T.pack stderr
ExitSuccess -> do
-- Step 2: decode stdout (String) as JSON Value
let jsonBytes = LB.pack (map (fromIntegral . fromEnum) stdout)
jsonVal = A.decode jsonBytes :: Maybe A.Value
case jsonVal of
Nothing ->
return $ Left $ Html.ImportError $
"Could not parse JSON from scraper output."
Just val -> case parseSchemaOrgRecipe val of
Nothing ->
return $ Left $ Html.ImportError $
"Could not parse recipe data. The page may not have schema.org recipe data."
Just schema -> case schemaOrgToCooklang schema of
Left err ->
return $ Left $ Html.ImportError $
"Could not convert recipe: " <> T.pack err
Right recipe -> do
-- Step 3: render to Cooklang text
let cookText = CooklangPrint.renderRecipe recipe
filename = CooklangPrint.filenameFromTitle (soName schema)
filepath = recipeDir </> filename
-- Step 4: write the .cook file
BS.writeFile filepath (TE.encodeUtf8 cookText)
return $ Right filename
```
- [ ] **Step 6: Build to verify compilation**
Run: `./hs stack build`
Expected: compiles without errors.
- [ ] **Step 7: Run tests to verify nothing broke**
Run: `./hs stack test`
Expected: all tests pass.
- [ ] **Step 8: Commit**
```bash
git add src/Roux/Server.hs
git commit -m "feat: add /import route for recipe URL import"
```
---
### Task 4: Wire up dependencies and re-exports
**Files:**
- Modify: `package.yaml`, `src/Roux.hs`
- [ ] **Step 1: Add `process` to `package.yaml`**
In `package.yaml`, find the library dependencies list and add `process`:
```yaml
- parsec
- process
- text
```
- [ ] **Step 2: Regenerate `.cabal` file**
Run: `./hs hpack`
Expected: no errors.
- [ ] **Step 3: Add `CooklangPrint` to `Roux.hs` re-exports**
In `src/Roux.hs`, add `Roux.CooklangPrint` to the re-exports:
```haskell
module Roux (
app,
module X,
) where
import Data.CookLang as X
import Roux.CooklangPrint as X
import Roux.SchemaOrg as X
import Roux.Server (app)
```
- [ ] **Step 4: Build to verify compilation**
Run: `./hs stack build`
Expected: compiles without errors.
- [ ] **Step 5: Run tests to verify nothing broke**
Run: `./hs stack test`
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add package.yaml roux-server.cabal src/Roux.hs
git commit -m "chore: add process dependency and re-export CooklangPrint"
```
---
### Task 5: End-to-end smoke test
**Files:** (none, manual testing)
- [ ] **Step 1: Start the server**
```bash
cd /work/personal/roux/roux-main
mkdir -p /tmp/roux-test-recipes
./hs stack exec roux-server -- --recipe-dir /tmp/roux-test-recipes &
sleep 2
```
- [ ] **Step 2: Verify the import form loads**
```bash
curl -s http://localhost:8080/import | grep "Import Recipe"
```
Expected: page contains "Import Recipe" heading and a URL input.
- [ ] **Step 3: Submit an empty URL (form validation)**
```bash
curl -s -o /dev/null -w "%{http_code}" -X POST -d "url=" http://localhost:8080/import
```
Expected: HTTP 200 with the form page showing a validation error.
- [ ] **Step 4: Submit an invalid URL**
```bash
curl -s http://localhost:8080/import -X POST -d "url=https://example.com/not-a-recipe" | grep "Import failed"
```
Expected: HTTP 200 with an import error page (the scraper won't find schema.org data).
- [ ] **Step 5: Kill the server**
```bash
kill %1 2>/dev/null; wait 2>/dev/null
```