Files
roux/docs/plans/2026-05-20-recipe-import.md
T
jbrechtel 8807ba3851
Build and Deploy / build-and-deploy (push) Successful in 1m30s
feat: show recipe image alongside description when metadata has one
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.
2026-05-20 22:35:25 -04:00

19 KiB

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:

{-# 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:

-- | 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
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:

module Roux.Html (
    SortMode (..),
    indexPage,
    recipePage,
    urlEncode,
    urlDecode,
) where

Change to:

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:

-- | 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:

-- | 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
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:

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:

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:

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:

    pure (router state changeLog)

to:

    pure (router absDir state changeLog)
  • Step 3: Add the readRequestBody helper

Add near the top of the file, after the imports:

-- | 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):

{- | 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:

-- | 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
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:

  - 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:

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
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
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
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)
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
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
kill %1 2>/dev/null; wait 2>/dev/null