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.
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 fullRecipeto 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
ImportErrortype 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
absDirthrough 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
readRequestBodyhelper
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
importHandlerandparseFormBody
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
processtopackage.yaml
In package.yaml, find the library dependencies list and add process:
- parsec
- process
- text
- Step 2: Regenerate
.cabalfile
Run: ./hs hpack
Expected: no errors.
- Step 3: Add
CooklangPrinttoRoux.hsre-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