feat: show recipe image alongside description when metadata has one
Build and Deploy / build-and-deploy (push) Successful in 1m30s
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:
@@ -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
|
||||||
|
```
|
||||||
+12
-4
@@ -219,7 +219,11 @@ page title content =
|
|||||||
, ".roux-step:has(input:checked) .step-text { opacity: 0.4; }"
|
, ".roux-step:has(input:checked) .step-text { opacity: 0.4; }"
|
||||||
, ""
|
, ""
|
||||||
, ".roux-course { font-size: 0.6rem; color: var(--roux-accent); opacity: 0.65; margin: 0 0 0.15rem; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }"
|
, ".roux-course { font-size: 0.6rem; color: var(--roux-accent); opacity: 0.65; margin: 0 0 0.15rem; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }"
|
||||||
, ".roux-desc { font-size: 0.85rem; line-height: 1.6; color: var(--roux-text); margin: 0 0 1.5rem; max-width: 640px; opacity: 0.85; }"
|
, ".roux-desc { font-size: 0.85rem; line-height: 1.6; color: var(--roux-text); margin: 0; max-width: 640px; opacity: 0.85; }"
|
||||||
|
, ".roux-desc-row { display: flex; gap: 2rem; align-items: flex-start; margin-bottom: 1.5rem; }"
|
||||||
|
, ".roux-desc-row .roux-desc { flex: 1; }"
|
||||||
|
, ".roux-desc-row img { max-width: 280px; height: auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(61, 44, 30, 0.1); }"
|
||||||
|
, "@media (max-width: 768px) { .roux-desc-row { flex-direction: column; } .roux-desc-row img { max-width: 100%; } }"
|
||||||
, ""
|
, ""
|
||||||
, ".roux-ingredient-tag { border-bottom: 1.5px solid rgba(191, 148, 40, 0.35); }"
|
, ".roux-ingredient-tag { border-bottom: 1.5px solid rgba(191, 148, 40, 0.35); }"
|
||||||
, ".roux-cookware-tag, .roux-timer-tag { opacity: 0.85; }"
|
, ".roux-cookware-tag, .roux-timer-tag { opacity: 0.85; }"
|
||||||
@@ -535,9 +539,13 @@ renderRecipe recipe = do
|
|||||||
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
|
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
|
||||||
"↗ Original"
|
"↗ Original"
|
||||||
Nothing -> pure ()
|
Nothing -> pure ()
|
||||||
case desc of
|
H.div ! A.class_ "roux-desc-row" $ do
|
||||||
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
|
case desc of
|
||||||
Nothing -> pure ()
|
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
|
||||||
|
Nothing -> pure ()
|
||||||
|
case metaImage meta of
|
||||||
|
Just img -> H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
|
||||||
|
Nothing -> pure ()
|
||||||
renderMetaBar meta
|
renderMetaBar meta
|
||||||
let sections = NE.toList (recipeSections recipe)
|
let sections = NE.toList (recipeSections recipe)
|
||||||
notes = collectNotes recipe
|
notes = collectNotes recipe
|
||||||
|
|||||||
Reference in New Issue
Block a user