Jump to content

Module:Recipedius: Difference between revisions

From Recipedius
0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)
0.015 T013: target missing ingredients and canonicalize fractions (1b80afc)
 
(3 intermediate revisions by the same user not shown)
Line 16: Line 16:


local FRACTIONS = {
local FRACTIONS = {
   { 0.125, '⅛' }, { 0.25, '¼' }, { 1/3, '⅓' }, { 0.375, '⅜' }, { 0.5, '½' },
   { 0.125, '1/8' }, { 0.25, '1/4' }, { 1/3, '1/3' }, { 0.375, '3/8' }, { 0.5, '1/2' },
   { 0.625, '⅝' }, { 2/3, '⅔' }, { 0.75, '¾' }, { 0.875, '⅞' },
   { 0.625, '5/8' }, { 2/3, '2/3' }, { 0.75, '3/4' }, { 0.875, '7/8' },
}
}
local FRACTION_CHARS = { ['⅛']=0.125, ['¼']=0.25, ['⅓']=1/3, ['⅜']=0.375, ['½']=0.5, ['⅝']=0.625, ['⅔']=2/3, ['¾']=0.75, ['⅞']=0.875 }
local FRACTION_CHARS = { ['⅛']=0.125, ['¼']=0.25, ['⅓']=1/3, ['⅜']=0.375, ['½']=0.5, ['⅝']=0.625, ['⅔']=2/3, ['¾']=0.75, ['⅞']=0.875 }


local function trim(s) return mw.text.trim(s or '') end
local function trim(s) return mw.text.trim(s or '') end
-- AI-assisted recipe imports persist ingredient titles as ordinary wiki links
-- so the form value itself remains navigable. Keep the existing plain-title
-- format compatible, while resolving that narrow importer-owned syntax before
-- data lookup, Cargo storage, pluralization, and generated display links.
local function ingredientTitle(s)
  s = trim(s)
  local linked = s:match('^%[%[([^%[%]|]+)%]%]$')
  return linked and trim(linked) or s
end
-- A missing ingredient must open its pre-targeted Page Forms editor, not
-- MediaWiki's generic raw editor. mw.title normalizes and encodes the target.
local function ingredientFormLink(name, label)
  local target = mw.title.new('Special:FormEdit/Ingredient/' .. name)
  if not target then return mw.text.nowiki(label) end
  return '[' .. target:fullUrl() .. ' ' .. mw.text.nowiki(label) .. ']'
end


-- "1 1/2", "3/4", "0.5", "1 ½" and "¾" all parse.
-- "1 1/2", "3/4", "0.5", "1 ½" and "¾" all parse.
Line 165: Line 183:
     if raw ~= '' and not raw:match('^<!%-%-') then
     if raw ~= '' and not raw:match('^<!%-%-') then
       local parts = mw.text.split(raw, ';', true)
       local parts = mw.text.split(raw, ';', true)
       local name = trim(parts[1] or '')
       local name = ingredientTitle(parts[1] or '')
       if name ~= '' then
       if name ~= '' then
         table.insert(lines, {
         table.insert(lines, {
Line 238: Line 256:


   if #missing > 0 then
   if #missing > 0 then
    -- Each name links to the ingredient form already targeted at that title, so
    -- a contributor lands on the right form rather than being asked for a name
    -- the page already knows. A bare Special:FormEdit link has no target and
    -- answers "No target page specified".
    local createLinks = {}
    for _, name in ipairs(missing) do
      table.insert(createLinks, ingredientFormLink(name, name))
    end
     add('<div class="rp-missing">These ingredients have no record yet, so they are left out of the nutrition and substitution tables: ' ..
     add('<div class="rp-missing">These ingredients have no record yet, so they are left out of the nutrition and substitution tables: ' ..
       table.concat(missing, ', ') .. '. [[Special:FormEdit/Ingredient|Create one]].</div>')
       table.concat(createLinks, ', ') .. '. Follow a name to create its record.</div>')
   end
   end


Line 250: Line 276:
     local shown = countLabel(l.name, l.qty, l.unit)
     local shown = countLabel(l.name, l.qty, l.unit)
     local isRecipe = not ing and getRecipe(l.name)
     local isRecipe = not ing and getRecipe(l.name)
     local link = (ing or isRecipe) and ('[[' .. l.name .. '|' .. shown .. ']]') or ('[[' .. l.name .. '|' .. shown .. ']]<sup class="rp-missing-mark">?</sup>')
     local link = (ing or isRecipe) and ('[[' .. l.name .. '|' .. shown .. ']]') or
      (ingredientFormLink(l.name, shown) .. '<sup class="rp-missing-mark">?</sup>')
     local qty = fmtQty(l.qty) .. ' ' .. unitLabel(l.qty, l.unit)
     local qty = fmtQty(l.qty) .. ' ' .. unitLabel(l.qty, l.unit)
     local prep = l.prep ~= '' and (', <span class="rp-prep">' .. l.prep .. '</span>') or ''
     local prep = l.prep ~= '' and (', <span class="rp-prep">' .. l.prep .. '</span>') or ''

Latest revision as of 14:04, 25 September 2026

Documentation for this module may be created at Module:Recipedius/doc

--[[
  Module:Recipedius
  The computed layer of Recipedius. Reads ingredient records and substitution rules
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.

  Entry points (all via {{#invoke:Recipedius|fn}} from templates):
    recipe    - renders a recipe from Template:Recipe args and stores its rows
    sale      - ranks recipes by how many of their shopping items are on sale
    cookbook  - renders a printable book from a list of recipe pages
]]

local p = {}
local cargo = mw.ext.cargo

-- ---------------------------------------------------------------- helpers

local FRACTIONS = {
  { 0.125, '1/8' }, { 0.25, '1/4' }, { 1/3, '1/3' }, { 0.375, '3/8' }, { 0.5, '1/2' },
  { 0.625, '5/8' }, { 2/3, '2/3' }, { 0.75, '3/4' }, { 0.875, '7/8' },
}
local FRACTION_CHARS = { ['⅛']=0.125, ['¼']=0.25, ['⅓']=1/3, ['⅜']=0.375, ['½']=0.5, ['⅝']=0.625, ['⅔']=2/3, ['¾']=0.75, ['⅞']=0.875 }

local function trim(s) return mw.text.trim(s or '') end

-- AI-assisted recipe imports persist ingredient titles as ordinary wiki links
-- so the form value itself remains navigable. Keep the existing plain-title
-- format compatible, while resolving that narrow importer-owned syntax before
-- data lookup, Cargo storage, pluralization, and generated display links.
local function ingredientTitle(s)
  s = trim(s)
  local linked = s:match('^%[%[([^%[%]|]+)%]%]$')
  return linked and trim(linked) or s
end

-- A missing ingredient must open its pre-targeted Page Forms editor, not
-- MediaWiki's generic raw editor. mw.title normalizes and encodes the target.
local function ingredientFormLink(name, label)
  local target = mw.title.new('Special:FormEdit/Ingredient/' .. name)
  if not target then return mw.text.nowiki(label) end
  return '[' .. target:fullUrl() .. ' ' .. mw.text.nowiki(label) .. ']'
end

-- "1 1/2", "3/4", "0.5", "1 ½" and "¾" all parse.
local function parseQty(s)
  s = trim(s)
  local total, found = 0, false
  for tok in s:gmatch('%S+') do
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true
    else
      local a, b = tok:match('^(%d+)/(%d+)$')
      if a then total = total + tonumber(a) / tonumber(b); found = true
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end
    end
  end
  if found then return total end
  return nil
end

local function fmtQty(q)
  if not q then return '' end
  local w = math.floor(q + 1e-6)
  local f = q - w
  if f > 0.94 then w = w + 1; f = 0 end
  local fs = ''
  if f > 0.06 then
    local best, bd = nil, 1
    for _, pair in ipairs(FRACTIONS) do
      local d = math.abs(pair[1] - f)
      if d < bd then bd = d; best = pair[2] end
    end
    if bd < 0.06 then fs = best else fs = string.format('%.2f', f):gsub('^0', '') end
  end
  if w == 0 then return fs ~= '' and fs or '0' end
  if fs ~= '' then return w .. ' ' .. fs end
  return tostring(w)
end

local PLURALS = { ['Garlic'] = 'Garlic cloves', ['Whole chicken'] = 'Whole chickens', ['Russet potato'] = 'Russet potatoes' }
-- Display name for a count-based ingredient: "2 eggs", "3 bananas", "6 garlic cloves".
local function countLabel(name, q, u)
  if u ~= 'each' or not q or q <= 1 then return name end
  if PLURALS[name] then return PLURALS[name] end
  if name:sub(-1) == 's' then return name end
  return name .. 's'
end

local function unitLabel(q, u)
  if u == 'each' or u == '' then return '' end
  if u == 'cup' and q and q > 1 then return 'cups' end
  return u
end

local function sqlEsc(s) return (tostring(s):gsub("'", "\\'")) end

local function splitList(s)
  local out = {}
  for item in (s or ''):gmatch('[^,]+') do
    item = trim(item)
    if item ~= '' then table.insert(out, item) end
  end
  return out
end

local function round(x) return math.floor(x + 0.5) end

-- ---------------------------------------------------------------- data access

local ingredientCache = {}
local function getIngredient(name)
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end
  local rows = cargo.query('Ingredients',
    '_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per',
    { where = "_pageName='" .. sqlEsc(name) .. "'", limit = 1 })
  ingredientCache[name] = rows[1] or false
  return rows[1]
end

local recipeCache = {}
local function getRecipe(name)
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end
  local rows = cargo.query('Recipes', '_pageName=name',
    { where = "_pageName='" .. sqlEsc(name) .. "'", limit = 1 })
  recipeCache[name] = rows[1] or false
  return rows[1]
end

local function gramsPer(ing, unit)
  if not ing then return nil end
  local key = ({ tsp = 'g_tsp', tbsp = 'g_tbsp', cup = 'g_cup', each = 'g_each' })[unit]
  if not key then return nil end
  return tonumber(ing[key])
end

local function kcalOf(ing, q, unit)
  local g = gramsPer(ing, unit)
  local k = ing and tonumber(ing.kcal100)
  if not (g and k and q) then return 0 end
  return q * g * k / 100
end

-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.
-- An override with applicable=no removes the option for that role.
local subsCache = {}
local function rulesFor(name, role)
  if not subsCache[name] then
    subsCache[name] = cargo.query('Substitutions',
      'sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable',
      { where = "sub_from='" .. sqlEsc(name) .. "'", orderBy = 'sub_to', limit = 200 })
  end
  local groups, order = {}, {}
  for _, r in ipairs(subsCache[name]) do
    local key = (r.sub_to or '') .. '|' .. (r.alt or '')
    if not groups[key] then groups[key] = {}; table.insert(order, key) end
    if trim(r.role) == '' then groups[key].base = r
    elseif trim(r.role) == role then groups[key].override = r end
  end
  local out = {}
  for _, key in ipairs(order) do
    local b, o = groups[key].base, groups[key].override
    local blocked = o and trim(o.applicable):lower() == 'no'
    if (b or o) and not blocked then
      local m = {}
      for _, k in ipairs({ 'sub_to', 'alt', 'ratio', 'conv', 'confidence', 'effect', 'warning' }) do
        local ov = o and trim(o[k]) or ''
        if ov ~= '' then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = '' end
      end
      if m.warning == 'none' then m.warning = '' end
      m.roleSpecific = o ~= nil
      table.insert(out, m)
    end
  end
  return out
end

-- ---------------------------------------------------------------- recipe

-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation
local function parseLines(text)
  local lines = {}
  for raw in (text or ''):gmatch('[^\r\n]+') do
    raw = trim(raw)
    if raw ~= '' and not raw:match('^<!%-%-') then
      local parts = mw.text.split(raw, ';', true)
      local name = ingredientTitle(parts[1] or '')
      if name ~= '' then
        table.insert(lines, {
          name = name,
          qty = parseQty(parts[2] or ''),
          unit = trim(parts[3] or ''),
          role = trim(parts[4] or ''),
          prep = trim(parts[5] or ''),
        })
      end
    end
  end
  return lines
end

local function resolvedArg(args, preferred, legacy)
  local value = trim(args[preferred])
  if value ~= '' then return value end
  return trim(args[legacy])
end

local CONF_DOTS = { high = '●●●', medium = '●●○', low = '●○○' }
local function confBadge(c)
  c = trim(c):lower()
  return '<span class="rp-conf rp-conf-' .. c .. '" title="confidence: ' .. c .. '">' .. (CONF_DOTS[c] or '○○○') .. ' ' .. c .. '</span>'
end

function p.recipe(frame)
  local args = frame:getParent().args
  local title = mw.title.getCurrentTitle()
  local page = title.prefixedText
  local serves = tonumber(args.serves) or 1
  local description = resolvedArg(args, 'description', 'lede')
  local instructions = resolvedArg(args, 'instructions', 'method')
  local lines = parseLines(args.ingredients)

  -- totals from the ingredient records
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}
  for _, l in ipairs(lines) do
    local ing = getIngredient(l.name)
    if ing then
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)
      for _, a in ipairs(splitList(ing.allergens)) do
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end
      end
    elseif not getRecipe(l.name) then
      table.insert(missing, l.name)
    end
  end

  local out = {}
  local function add(s) table.insert(out, s) end

  -- infobox
  add('{| class="wikitable rp-infobox"\n|+ ' .. page)
  add('|-\n! Serves\n| ' .. serves)
  if trim(args.time) ~= '' then add('|-\n! Time\n| ' .. trim(args.time)) end
  add('|-\n! Per serving\n| <b>' .. round(kcalTotal / serves) .. ' kcal</b> <small>(' .. round(kcalTotal) .. ' for the batch)</small>')
  add('|-\n! Contains\n| ' .. (#allergens > 0 and table.concat(allergens, ', ') or "''nothing flagged''"))
  if trim(args.contributor) ~= '' then add('|-\n! Contributed by\n| ' .. trim(args.contributor)) end
  if trim(args.tags) ~= '' then
    local tagLinks = {}
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, '[[:Category:' .. t .. '|' .. t .. ']]') end
    add('|-\n! Tags\n| ' .. table.concat(tagLinks, ' · '))
  end
  add('|-\n| colspan="2" | <small>Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.</small>')
  add('|}')

  add('== Description ==')
  if description ~= '' then add("<div class=\"rp-lede\">''" .. description .. "''</div>")
  else add("''No description yet.''") end

  if #missing > 0 then
    -- Each name links to the ingredient form already targeted at that title, so
    -- a contributor lands on the right form rather than being asked for a name
    -- the page already knows. A bare Special:FormEdit link has no target and
    -- answers "No target page specified".
    local createLinks = {}
    for _, name in ipairs(missing) do
      table.insert(createLinks, ingredientFormLink(name, name))
    end
    add('<div class="rp-missing">These ingredients have no record yet, so they are left out of the nutrition and substitution tables: ' ..
      table.concat(createLinks, ', ') .. '. Follow a name to create its record.</div>')
  end

  -- ingredients table
  add('== Ingredients ==')
  add('{| class="wikitable rp-ingredients"\n! Quantity !! Ingredient !! Its job here !! Swaps')
  local swapSections = {}
  for i, l in ipairs(lines) do
    local ing = getIngredient(l.name)
    local shown = countLabel(l.name, l.qty, l.unit)
    local isRecipe = not ing and getRecipe(l.name)
    local link = (ing or isRecipe) and ('[[' .. l.name .. '|' .. shown .. ']]') or
      (ingredientFormLink(l.name, shown) .. '<sup class="rp-missing-mark">?</sup>')
    local qty = fmtQty(l.qty) .. ' ' .. unitLabel(l.qty, l.unit)
    local prep = l.prep ~= '' and (', <span class="rp-prep">' .. l.prep .. '</span>') or ''
    local rules = ing and rulesFor(l.name, l.role) or {}
    local swapCell = "''none''"
    if #rules > 0 then
      swapCell = '[[#Substitutions for this recipe|' .. #rules .. (#rules == 1 and ' option' or ' options') .. ']]'
      table.insert(swapSections, { line = l, rules = rules })
    end
    add('|-\n| class="rp-qty" | ' .. trim(qty) .. '\n| ' .. link .. prep .. '\n| class="rp-role" | ' .. l.role .. '\n| ' .. swapCell)
  end
  add('|}')

  add('== Instructions ==')
  if instructions ~= '' then add(instructions)
  else add("''No instructions yet.''") end

  -- substitutions resolved for this recipe
  if #swapSections > 0 then
    add('== Substitutions for this recipe ==')
    add('<p class="rp-note">Rules come from each ingredient\'s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\'s, not a guarantee.</p>')
    add('{| class="wikitable rp-subs"\n! Instead of !! Use !! How much !! What changes !! Confidence')
    for _, s in ipairs(swapSections) do
      for _, r in ipairs(s.rules) do
        local useName = r.alt ~= '' and (r.alt .. ' ([[' .. r.sub_to .. ']])') or ('[[' .. r.sub_to .. ']]')
        local howMuch
        if r.conv ~= '' then howMuch = r.conv
        else
          local ratio = tonumber(r.ratio) or 1
          local nq = s.line.qty and (s.line.qty * ratio) or nil
          howMuch = nq and (fmtQty(nq) .. ' ' .. unitLabel(nq, s.line.unit) .. ' <small>(× ' .. ratio .. ')</small>') or ('× ' .. ratio)
        end
        local what = r.effect
        if r.warning ~= '' then what = what .. '<br /><span class="rp-warn">⚠ ' .. r.warning .. '</span>' end
        if r.roleSpecific then what = what .. ' <small class="rp-role-tag">rule for: ' .. s.line.role .. '</small>' end
        add('|-\n| [[' .. s.line.name .. ']]\n| ' .. useName .. '\n| class="rp-qty" | ' .. howMuch .. '\n| ' .. what .. '\n| ' .. confBadge(r.confidence))
      end
    end
    add('|}')
  end

  -- store rows (skipped when the page is being previewed inside another page)
  if title.namespace == 0 then
    for i, l in ipairs(lines) do
      frame:callParserFunction('#cargo_store', {
        '_table=RecipeIngredients', recipe = page, ingredient = l.name,
        qty = l.qty and tostring(l.qty) or '', unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),
      })
    end
    frame:callParserFunction('#cargo_store', {
      '_table=Recipes', serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),
      contributor = trim(args.contributor), lede = description, method = instructions,
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, ','), ingredient_count = tostring(#lines),
    })
  end

  -- Categories belong to real recipe pages. Without this guard a /testcases
  -- page that exercises the template would file itself under Category:Recipes
  -- and every tag category it demonstrates.
  if title.namespace == 0 then
    local cats = { '[[Category:Recipes]]' }
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, '[[Category:' .. t .. ']]') end
    add(table.concat(cats, ''))
  end
  return table.concat(out, '\n')
end

-- ---------------------------------------------------------------- sale

function p.sale(frame)
  local args = frame.args
  local store = trim(args.store)
  local where = store ~= '' and ("store='" .. sqlEsc(store) .. "'") or nil
  local sale = cargo.query('SaleItems', 'ingredient,price,was,per,store', { where = where, limit = 200 })
  if #sale == 0 then return "''No sale items have been entered yet. Add them with [[Template:SaleItem]].''" end
  local onSale = {}
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end

  local rows = cargo.query('RecipeIngredients=RI,Ingredients=I', 'RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry',
    { join = 'RI.ingredient=I._pageName', limit = 2000 })
  local recipes, order = {}, {}
  for _, r in ipairs(rows) do
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end
    local rec = recipes[r.recipe]
    local pantry = trim(r.pantry):lower() == 'yes'
    if not pantry then
      rec.toBuy = rec.toBuy + 1
      local s = onSale[r.ingredient]
      if s then
        table.insert(rec.hits, r.ingredient)
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))
      end
    end
  end
  local ranked = {}
  for _, name in ipairs(order) do
    local rec = recipes[name]
    if #rec.hits > 0 then rec.name = name; table.insert(ranked, rec) end
  end
  table.sort(ranked, function(a, b)
    if #a.hits ~= #b.hits then return #a.hits > #b.hits end
    return (#a.hits / math.max(1, a.toBuy)) > (#b.hits / math.max(1, b.toBuy))
  end)

  local out = { '{| class="wikitable rp-sale"\n! Recipe !! On sale !! Shopping list !! You save' }
  for _, rec in ipairs(ranked) do
    local links = {}
    for _, h in ipairs(rec.hits) do table.insert(links, '[[' .. h .. ']]') end
    table.insert(out, '|-\n| [[' .. rec.name .. ']]\n| ' .. table.concat(links, ', ') .. '\n| ' .. #rec.hits .. ' of ' .. rec.toBuy ..
      ' items <small>(pantry staples excluded)</small>\n| class="rp-qty" | $' .. string.format('%.2f', rec.save))
  end
  table.insert(out, '|}')
  return table.concat(out, '\n')
end

-- ---------------------------------------------------------------- cookbook

local function chapterFor(tags)
  local t = {}
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end
  if t['breakfast'] then return 'Mornings' end
  if t['dessert'] then return 'Sweets' end
  if t['dinner'] or t['side'] or t['soup'] then return 'Suppers and sides' end
  return 'Pantry and dressings'
end

function p.cookbook(frame)
  local args = frame:getParent().args
  local names = splitList(args.recipes)
  local title = trim(args.title) ~= '' and trim(args.title) or 'Our kitchen'
  local out = {}
  local function add(s) table.insert(out, s) end

  add('<div class="rp-book">')
  add('<div class="rp-page rp-cover"><div class="rp-orn">✦</div><div class="rp-cover-title">' .. title .. '</div><div class="rp-cover-sub">' ..
    #names .. (#names == 1 and ' recipe' or ' recipes') .. ' · assembled on Recipedius</div></div>')
  if trim(args.dedication) ~= '' then
    add('<div class="rp-page rp-dedication">' .. trim(args.dedication) .. '</div>')
  end

  -- gather
  local recs, chapters, chapterOrder = {}, {}, {}
  for _, n in ipairs(names) do
    local r = cargo.query('Recipes', '_pageName=name,serves,time,tags,contributor,lede,method',
      { where = "_pageName='" .. sqlEsc(n) .. "'", limit = 1 })[1]
    if r then
      r.lines = cargo.query('RecipeIngredients', 'ingredient,qty,unit,prep,pos',
        { where = "recipe='" .. sqlEsc(n) .. "'", orderBy = 'pos', limit = 100 })
      local ch = chapterFor(r.tags)
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end
      table.insert(chapters[ch], r)
    else
      add('<div class="rp-missing">No recipe page called "' .. n .. '" was found.</div>')
    end
  end

  -- contents
  add('<div class="rp-page rp-toc"><div class="rp-chap-label">Contents</div>')
  for _, ch in ipairs(chapterOrder) do
    add('<div class="rp-toc-chapter">' .. ch .. '</div>')
    for _, r in ipairs(chapters[ch]) do add('<div class="rp-toc-row"><span>' .. r.name .. '</span><span>' .. r.time .. '</span></div>') end
  end
  add('</div>')

  for _, ch in ipairs(chapterOrder) do
    add('<div class="rp-page rp-chapter"><div class="rp-chap-label">Chapter</div><div class="rp-chapter-title">' .. ch .. '</div></div>')
    for _, r in ipairs(chapters[ch]) do
      add('<div class="rp-page rp-recipe-page"><div class="rp-chap-label">' .. ch .. '</div><div class="rp-recipe-title">' .. r.name .. '</div>')
      if trim(r.lede) ~= '' then add("<div class=\"rp-lede\">''" .. r.lede .. "''</div>") end
      add('<div class="rp-book-cols"><div class="rp-book-ings">')
      for _, l in ipairs(r.lines) do
        local q = tonumber(l.qty)
        add('<div class="rp-book-ing"><span class="rp-qty">' .. fmtQty(q) .. ' ' .. unitLabel(q, l.unit) .. '</span><span>' .. countLabel(l.ingredient, q, l.unit) ..
          (trim(l.prep) ~= '' and (', ' .. l.prep) or '') .. '</span></div>')
      end
      add('</div><div class="rp-book-method">\n' .. (r.method or '') .. '\n</div></div>')
      add('<div class="rp-book-foot">Serves ' .. r.serves .. ' · ' .. r.time .. (trim(r.contributor) ~= '' and (' · contributed by ' .. r.contributor) or '') .. '</div></div>')
    end
  end
  add('</div>')
  return table.concat(out, '\n')
end

return p