<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://recipedius.com/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Recipedius+deploy</id>
	<title>Recipedius - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://recipedius.com/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Recipedius+deploy"/>
	<link rel="alternate" type="text/html" href="https://recipedius.com/index.php/Special:Contributions/Recipedius_deploy"/>
	<updated>2026-11-02T04:29:11Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.43.11</generator>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Recipe/testcases&amp;diff=142</id>
		<title>Template:Recipe/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Recipe/testcases&amp;diff=142"/>
		<updated>2026-09-25T14:04:06Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.015 T013: target missing ingredients and canonicalize fractions (1b80afc)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cases for [[Template:Recipe/sandbox]]. Each one states what it is meant to&lt;br /&gt;
show. Check them after any change to the recipe template or to&lt;br /&gt;
[[Module:Recipedius]], and before that change is committed for deployment.&lt;br /&gt;
&lt;br /&gt;
== Preferred parameter names ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|tags=dessert, baking&lt;br /&gt;
|contributor=Testcases&lt;br /&gt;
|description=Written with the preferred &amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructions&amp;lt;/code&amp;gt; names.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming; softened&lt;br /&gt;
Granulated sugar; ½; cup; sweet&lt;br /&gt;
All-purpose flour; 2; cup; structure&lt;br /&gt;
|instructions=&lt;br /&gt;
# Beat the butter and sugar until pale.&lt;br /&gt;
# Stir in the flour.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render Description, Ingredients and Instructions in that order, with the&lt;br /&gt;
substitution section after Instructions. The legacy Unicode fraction input must&lt;br /&gt;
render as the ASCII quantity `1/2 cup`.&lt;br /&gt;
&lt;br /&gt;
== Legacy parameter names ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|contributor=Testcases&lt;br /&gt;
|lede=Written with the legacy &amp;lt;code&amp;gt;lede&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;method&amp;lt;/code&amp;gt; names.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming&lt;br /&gt;
|method=&lt;br /&gt;
# Melt the butter.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render identically in structure to the case above. Existing pages and the&lt;br /&gt;
Page Forms still use these names.&lt;br /&gt;
&lt;br /&gt;
== Preferred name wins over legacy ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=1&lt;br /&gt;
|description=This preferred description must be the one shown.&lt;br /&gt;
|lede=This legacy lede must not appear.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; tbsp; melted&lt;br /&gt;
|instructions=&lt;br /&gt;
# These preferred instructions must be the ones shown.&lt;br /&gt;
|method=&lt;br /&gt;
# This legacy method must not appear.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Ingredient link resolution ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=2&lt;br /&gt;
|description=Three kinds of ingredient line.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming&lt;br /&gt;
Classic shortbread; 1; each; inclusion&lt;br /&gt;
Nonexistent ingredient page; 1; cup; structure&lt;br /&gt;
|instructions=&lt;br /&gt;
# Nothing to do.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The first line names an ingredient record and must be a plain internal link&lt;br /&gt;
that contributes calories and allergens. The second names an existing recipe&lt;br /&gt;
and must also be a plain internal link, with no missing marker. The third names&lt;br /&gt;
a page that does not exist: it must link to&lt;br /&gt;
`Special:FormEdit/Ingredient/Nonexistent ingredient page`, carry the missing&lt;br /&gt;
marker, and appear in the missing-nutrition warning above. That route must&lt;br /&gt;
open the pre-targeted Ingredient form rather than the generic raw editor.&lt;br /&gt;
&lt;br /&gt;
== No description and no instructions ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=1&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; tbsp&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Both headings must still appear, each with its placeholder text, so the page&lt;br /&gt;
shape is predictable while a recipe is being written.&lt;br /&gt;
&lt;br /&gt;
== Empty call ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox}}&lt;br /&gt;
&lt;br /&gt;
Must not raise a script error.&lt;br /&gt;
&amp;lt;noinclude&amp;gt;[[Category:Recipedius templates]]&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius/sandbox&amp;diff=141</id>
		<title>Module:Recipedius/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius/sandbox&amp;diff=141"/>
		<updated>2026-09-25T14:04:06Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.015 T013: target missing ingredients and canonicalize fractions (1b80afc)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;-- Development copy of [[Module:Recipedius]]. Exercise changes at&lt;br /&gt;
-- [[Template:Recipe/testcases]] through [[Template:Recipe/sandbox]], then copy&lt;br /&gt;
-- an approved revision into the production module.&lt;br /&gt;
--&lt;br /&gt;
-- Keep this a faithful copy. It is the only place a module change can be seen&lt;br /&gt;
-- rendered before it reaches every recipe on the wiki.&lt;br /&gt;
--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;1/8&#039; }, { 0.25, &#039;1/4&#039; }, { 1/3, &#039;1/3&#039; }, { 0.375, &#039;3/8&#039; }, { 0.5, &#039;1/2&#039; },&lt;br /&gt;
  { 0.625, &#039;5/8&#039; }, { 2/3, &#039;2/3&#039; }, { 0.75, &#039;3/4&#039; }, { 0.875, &#039;7/8&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
local function ingredientTitle(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local linked = s:match(&#039;^%[%[([^%[%]|]+)%]%]$&#039;)&lt;br /&gt;
  return linked and trim(linked) or s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- A missing ingredient must open its pre-targeted Page Forms editor, not&lt;br /&gt;
-- MediaWiki&#039;s generic raw editor. mw.title normalizes and encodes the target.&lt;br /&gt;
local function ingredientFormLink(name, label)&lt;br /&gt;
  local target = mw.title.new(&#039;Special:FormEdit/Ingredient/&#039; .. name)&lt;br /&gt;
  if not target then return mw.text.nowiki(label) end&lt;br /&gt;
  return &#039;[&#039; .. target:fullUrl() .. &#039; &#039; .. mw.text.nowiki(label) .. &#039;]&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = ingredientTitle(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    -- Each name links to the ingredient form already targeted at that title, so&lt;br /&gt;
    -- a contributor lands on the right form rather than being asked for a name&lt;br /&gt;
    -- the page already knows. A bare Special:FormEdit link has no target and&lt;br /&gt;
    -- answers &amp;quot;No target page specified&amp;quot;.&lt;br /&gt;
    local createLinks = {}&lt;br /&gt;
    for _, name in ipairs(missing) do&lt;br /&gt;
      table.insert(createLinks, ingredientFormLink(name, name))&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(createLinks, &#039;, &#039;) .. &#039;. Follow a name to create its record.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or&lt;br /&gt;
      (ingredientFormLink(l.name, shown) .. &#039;&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=140</id>
		<title>Module:Recipedius</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=140"/>
		<updated>2026-09-25T14:04:05Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.015 T013: target missing ingredients and canonicalize fractions (1b80afc)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;1/8&#039; }, { 0.25, &#039;1/4&#039; }, { 1/3, &#039;1/3&#039; }, { 0.375, &#039;3/8&#039; }, { 0.5, &#039;1/2&#039; },&lt;br /&gt;
  { 0.625, &#039;5/8&#039; }, { 2/3, &#039;2/3&#039; }, { 0.75, &#039;3/4&#039; }, { 0.875, &#039;7/8&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
-- AI-assisted recipe imports persist ingredient titles as ordinary wiki links&lt;br /&gt;
-- so the form value itself remains navigable. Keep the existing plain-title&lt;br /&gt;
-- format compatible, while resolving that narrow importer-owned syntax before&lt;br /&gt;
-- data lookup, Cargo storage, pluralization, and generated display links.&lt;br /&gt;
local function ingredientTitle(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local linked = s:match(&#039;^%[%[([^%[%]|]+)%]%]$&#039;)&lt;br /&gt;
  return linked and trim(linked) or s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- A missing ingredient must open its pre-targeted Page Forms editor, not&lt;br /&gt;
-- MediaWiki&#039;s generic raw editor. mw.title normalizes and encodes the target.&lt;br /&gt;
local function ingredientFormLink(name, label)&lt;br /&gt;
  local target = mw.title.new(&#039;Special:FormEdit/Ingredient/&#039; .. name)&lt;br /&gt;
  if not target then return mw.text.nowiki(label) end&lt;br /&gt;
  return &#039;[&#039; .. target:fullUrl() .. &#039; &#039; .. mw.text.nowiki(label) .. &#039;]&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = ingredientTitle(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    -- Each name links to the ingredient form already targeted at that title, so&lt;br /&gt;
    -- a contributor lands on the right form rather than being asked for a name&lt;br /&gt;
    -- the page already knows. A bare Special:FormEdit link has no target and&lt;br /&gt;
    -- answers &amp;quot;No target page specified&amp;quot;.&lt;br /&gt;
    local createLinks = {}&lt;br /&gt;
    for _, name in ipairs(missing) do&lt;br /&gt;
      table.insert(createLinks, ingredientFormLink(name, name))&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(createLinks, &#039;, &#039;) .. &#039;. Follow a name to create its record.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or&lt;br /&gt;
      (ingredientFormLink(l.name, shown) .. &#039;&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Form:Recipe&amp;diff=138</id>
		<title>Form:Recipe</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Form:Recipe&amp;diff=138"/>
		<updated>2026-09-25T12:55:49Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.014 T014: deploy AI-assisted Recipe import (e0496d8)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
This is the form for creating a recipe. Enter a title below.&lt;br /&gt;
{{#forminput:form=Recipe|autocomplete on category=Recipes}}&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;rp-ai-recipe-import&amp;quot; class=&amp;quot;rp-ai-import&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;Create a draft with AI (optional)&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Copy prompt, use ChatGPT, Claude, Gemini, or DeepSeek, then paste the returned JSON here. AI-assisted import needs JavaScript; you can still complete every field manually below.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
{{{for template|Recipe}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! Serves:&lt;br /&gt;
| {{{field|serves|input type=text|size=4}}}&lt;br /&gt;
|-&lt;br /&gt;
! Time:&lt;br /&gt;
| {{{field|time|size=20|placeholder=50 min}}}&lt;br /&gt;
|-&lt;br /&gt;
! Tags (comma-separated):&lt;br /&gt;
| {{{field|tags|size=40|placeholder=dessert, baking, cookie}}}&lt;br /&gt;
|-&lt;br /&gt;
! Contributed by:&lt;br /&gt;
| {{{field|contributor|size=30}}}&lt;br /&gt;
|-&lt;br /&gt;
! Description:&lt;br /&gt;
| {{{field|lede|input type=textarea|rows=2|cols=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! Ingredients:&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;One per line: Ingredient; quantity; unit; role; preparation&amp;lt;br /&amp;gt;e.g. Butter; 1; cup; creaming; softened&amp;lt;/small&amp;gt;&lt;br /&gt;
| {{{field|ingredients|input type=textarea|rows=10|cols=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! Instructions:&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;Numbered steps, one per line, starting with #&amp;lt;/small&amp;gt;&lt;br /&gt;
| {{{field|method|input type=textarea|rows=10|cols=70}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&#039;&#039;&#039;Edit summary:&#039;&#039;&#039; {{{standard input|summary}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|cancel}}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=MediaWiki:Common.js&amp;diff=137</id>
		<title>MediaWiki:Common.js</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=MediaWiki:Common.js&amp;diff=137"/>
		<updated>2026-09-25T12:55:48Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.014 T014: deploy AI-assisted Recipe import (e0496d8)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* AI-assisted Ingredient draft import. Runs only on Form:Ingredient. */&lt;br /&gt;
(function ($, mw) {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  $(function () {&lt;br /&gt;
    var panel = document.getElementById(&#039;rp-ai-ingredient-import&#039;);&lt;br /&gt;
    if (!panel) return;&lt;br /&gt;
&lt;br /&gt;
    var fields = {&lt;br /&gt;
      form: &#039;Ingredient[form]&#039;,&lt;br /&gt;
      role: &#039;Ingredient[role]&#039;,&lt;br /&gt;
      storage: &#039;Ingredient[storage]&#039;,&lt;br /&gt;
      season: &#039;Ingredient[season]&#039;,&lt;br /&gt;
      kcal100: &#039;Ingredient[kcal100]&#039;,&lt;br /&gt;
      g_tsp: &#039;Ingredient[g_tsp]&#039;,&lt;br /&gt;
      g_tbsp: &#039;Ingredient[g_tbsp]&#039;,&lt;br /&gt;
      g_cup: &#039;Ingredient[g_cup]&#039;,&lt;br /&gt;
      g_each: &#039;Ingredient[g_each]&#039;,&lt;br /&gt;
      allergens: &#039;Ingredient[allergens]&#039;,&lt;br /&gt;
      pantry: &#039;Ingredient[pantry]&#039;,&lt;br /&gt;
      price: &#039;Ingredient[price]&#039;,&lt;br /&gt;
      price_per: &#039;Ingredient[price_per]&#039;,&lt;br /&gt;
      summary: &#039;Ingredient[summary]&#039;&lt;br /&gt;
    };&lt;br /&gt;
    var requiredStrings = [&#039;name&#039;, &#039;form&#039;, &#039;role&#039;, &#039;storage&#039;, &#039;season&#039;, &#039;summary&#039;];&lt;br /&gt;
    var optionalNumbers = [&#039;g_tsp&#039;, &#039;g_tbsp&#039;, &#039;g_cup&#039;, &#039;g_each&#039;, &#039;price&#039;];&lt;br /&gt;
    var allKeys = requiredStrings.concat([&#039;kcal100&#039;], optionalNumbers, [&#039;allergens&#039;, &#039;pantry&#039;, &#039;price_per&#039;]);&lt;br /&gt;
    // FormEdit is a Special page, so wgPageName is the Special-page route.&lt;br /&gt;
    // Page Forms exposes the actual selected edit target separately.&lt;br /&gt;
    var expectedTitle = String(mw.config.get(&#039;wgPageFormsTargetName&#039;) || &#039;&#039;).replace(/_/g, &#039; &#039;);&lt;br /&gt;
    if (!expectedTitle) return;&lt;br /&gt;
    // The wikitext fallback is useful without JavaScript and to rendered-page&lt;br /&gt;
    // checks. Replace it only after the enhanced controls can be created.&lt;br /&gt;
    panel.textContent = &#039;&#039;;&lt;br /&gt;
&lt;br /&gt;
    function addTextElement(tag, text) {&lt;br /&gt;
      var element = document.createElement(tag);&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
      panel.appendChild(element);&lt;br /&gt;
      return element;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function addProviderLink(provider, url) {&lt;br /&gt;
      var link = document.createElement(&#039;a&#039;);&lt;br /&gt;
      link.href = url;&lt;br /&gt;
      link.target = &#039;_blank&#039;;&lt;br /&gt;
      link.rel = &#039;noopener&#039;;&lt;br /&gt;
      link.setAttribute(&#039;data-rp-ai-provider&#039;, provider);&lt;br /&gt;
      link.textContent = provider;&lt;br /&gt;
      return link;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    var title = addTextElement(&#039;strong&#039;, &#039;Create a draft with AI (optional)&#039;);&lt;br /&gt;
    title.className = &#039;rp-ai-heading&#039;;&lt;br /&gt;
    addTextElement(&#039;p&#039;, &#039;AI output is a draft. Verify nutrition, allergens, weights, and price against a reliable source before saving.&#039;);&lt;br /&gt;
    var actions = document.createElement(&#039;p&#039;);&lt;br /&gt;
    var copyButton = document.createElement(&#039;button&#039;);&lt;br /&gt;
    copyButton.type = &#039;button&#039;;&lt;br /&gt;
    copyButton.id = &#039;rp-ai-copy-prompt&#039;;&lt;br /&gt;
    copyButton.textContent = &#039;Copy prompt&#039;;&lt;br /&gt;
    actions.appendChild(copyButton);&lt;br /&gt;
    actions.appendChild(document.createTextNode(&#039; &#039;));&lt;br /&gt;
    [&lt;br /&gt;
      [&#039;ChatGPT&#039;, &#039;https://chatgpt.com/&#039;],&lt;br /&gt;
      [&#039;Claude&#039;, &#039;https://claude.ai/new&#039;],&lt;br /&gt;
      [&#039;Gemini&#039;, &#039;https://gemini.google.com/app&#039;],&lt;br /&gt;
      [&#039;DeepSeek&#039;, &#039;https://chat.deepseek.com/&#039;]&lt;br /&gt;
    ].forEach(function (provider, index) {&lt;br /&gt;
      if (index) actions.appendChild(document.createTextNode(&#039; · &#039;));&lt;br /&gt;
      actions.appendChild(addProviderLink(provider[0], provider[1]));&lt;br /&gt;
    });&lt;br /&gt;
    panel.appendChild(actions);&lt;br /&gt;
    var importLabel = document.createElement(&#039;label&#039;);&lt;br /&gt;
    importLabel.htmlFor = &#039;rp-ai-ingredient-json&#039;;&lt;br /&gt;
    importLabel.textContent = &#039;Paste the returned JSON&#039;;&lt;br /&gt;
    var importArea = document.createElement(&#039;textarea&#039;);&lt;br /&gt;
    importArea.id = &#039;rp-ai-ingredient-json&#039;;&lt;br /&gt;
    importArea.rows = 12;&lt;br /&gt;
    importArea.cols = 70;&lt;br /&gt;
    importArea.spellcheck = false;&lt;br /&gt;
    importArea.placeholder = &#039;{&amp;quot;name&amp;quot;:&amp;quot;This page title&amp;quot;, ...}&#039;;&lt;br /&gt;
    var importControl = document.createElement(&#039;p&#039;);&lt;br /&gt;
    importControl.appendChild(importLabel);&lt;br /&gt;
    importControl.appendChild(document.createElement(&#039;br&#039;));&lt;br /&gt;
    importControl.appendChild(importArea);&lt;br /&gt;
    panel.appendChild(importControl);&lt;br /&gt;
    var importButton = document.createElement(&#039;button&#039;);&lt;br /&gt;
    importButton.type = &#039;button&#039;;&lt;br /&gt;
    importButton.id = &#039;rp-ai-import-json&#039;;&lt;br /&gt;
    importButton.textContent = &#039;Import draft into this form&#039;;&lt;br /&gt;
    var importAction = document.createElement(&#039;p&#039;);&lt;br /&gt;
    importAction.appendChild(importButton);&lt;br /&gt;
    panel.appendChild(importAction);&lt;br /&gt;
    var status = document.createElement(&#039;div&#039;);&lt;br /&gt;
    status.id = &#039;rp-ai-ingredient-status&#039;;&lt;br /&gt;
    status.className = &#039;rp-ai-status&#039;;&lt;br /&gt;
    status.setAttribute(&#039;role&#039;, &#039;status&#039;);&lt;br /&gt;
    status.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    panel.appendChild(status);&lt;br /&gt;
    var input = importArea;&lt;br /&gt;
&lt;br /&gt;
    function setStatus(message, isError) {&lt;br /&gt;
      status.textContent = message;&lt;br /&gt;
      status.className = isError ? &#039;rp-ai-status rp-ai-status-error&#039; : &#039;rp-ai-status rp-ai-status-success&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function promptFor(title) {&lt;br /&gt;
      return &#039;Create a draft Recipedius ingredient record for &amp;quot;&#039; + title + &#039;&amp;quot;. &#039; +&lt;br /&gt;
        &#039;Return only one JSON object with exactly these keys: &#039; +&lt;br /&gt;
        &#039;name, form, role, storage, season, kcal100, g_tsp, g_tbsp, g_cup, g_each, allergens, pantry, price, price_per, summary. &#039; +&lt;br /&gt;
        &#039;Use this title exactly as name. form, role, storage, season, and summary must be strings. &#039; +&lt;br /&gt;
        &#039;kcal100 must be a non-negative number. g_tsp, g_tbsp, g_cup, g_each, and price must be non-negative numbers or null. &#039; +&lt;br /&gt;
        &#039;price_per must be a string or null. allergens must be an array of strings. pantry must be true or false. &#039; +&lt;br /&gt;
        &#039;Use null when a unit weight or typical price is unknown; do not invent values. &#039; +&lt;br /&gt;
        &#039;Nutrition, allergens, weights, and price are a draft for the contributor to verify against a reliable source before saving.&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function copyText(text) {&lt;br /&gt;
      if (navigator.clipboard &amp;amp;&amp;amp; navigator.clipboard.writeText) {&lt;br /&gt;
        return navigator.clipboard.writeText(text);&lt;br /&gt;
      }&lt;br /&gt;
      var temporary = document.createElement(&#039;textarea&#039;);&lt;br /&gt;
      temporary.value = text;&lt;br /&gt;
      temporary.setAttribute(&#039;readonly&#039;, &#039;&#039;);&lt;br /&gt;
      temporary.style.position = &#039;fixed&#039;;&lt;br /&gt;
      temporary.style.opacity = &#039;0&#039;;&lt;br /&gt;
      document.body.appendChild(temporary);&lt;br /&gt;
      temporary.select();&lt;br /&gt;
      var copied = document.execCommand(&#039;copy&#039;);&lt;br /&gt;
      document.body.removeChild(temporary);&lt;br /&gt;
      return copied ? Promise.resolve() : Promise.reject(new Error(&#039;Clipboard unavailable&#039;));&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function copyPrompt() {&lt;br /&gt;
      return copyText(promptFor(expectedTitle)).then(function () {&lt;br /&gt;
        setStatus(&#039;Prompt copied. Review the AI draft before importing it.&#039;, false);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        setStatus(&#039;Your browser did not allow copying. Select and copy the prompt shown below.&#039;, true);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hasOwn(object, key) {&lt;br /&gt;
      return Object.prototype.hasOwnProperty.call(object, key);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validString(value) {&lt;br /&gt;
      return typeof value === &#039;string&#039; &amp;amp;&amp;amp; value.trim() !== &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validNumber(value) {&lt;br /&gt;
      return typeof value === &#039;number&#039; &amp;amp;&amp;amp; Number.isFinite(value) &amp;amp;&amp;amp; value &amp;gt;= 0;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validate(record) {&lt;br /&gt;
      var i;&lt;br /&gt;
      if (!record || Array.isArray(record) || typeof record !== &#039;object&#039;) return &#039;The draft must be one JSON object.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; Object.keys(record).length; i++) {&lt;br /&gt;
        if (allKeys.indexOf(Object.keys(record)[i]) === -1) return &#039;Unknown property: &#039; + Object.keys(record)[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (i = 0; i &amp;lt; allKeys.length; i++) {&lt;br /&gt;
        if (!hasOwn(record, allKeys[i])) return &#039;Missing property: &#039; + allKeys[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (i = 0; i &amp;lt; requiredStrings.length; i++) {&lt;br /&gt;
        if (!validString(record[requiredStrings[i]])) return requiredStrings[i] + &#039; must be a non-empty string.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      if (record.name !== expectedTitle) return &#039;The JSON name must exactly match this page title: &#039; + expectedTitle + &#039;.&#039;;&lt;br /&gt;
      if (!validNumber(record.kcal100)) return &#039;kcal100 must be a non-negative number.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; optionalNumbers.length; i++) {&lt;br /&gt;
        if (record[optionalNumbers[i]] !== null &amp;amp;&amp;amp; !validNumber(record[optionalNumbers[i]])) {&lt;br /&gt;
          return optionalNumbers[i] + &#039; must be a non-negative number or null.&#039;;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      if (!Array.isArray(record.allergens) || !record.allergens.every(validString)) return &#039;allergens must be an array of non-empty strings.&#039;;&lt;br /&gt;
      if (typeof record.pantry !== &#039;boolean&#039;) return &#039;pantry must be true or false.&#039;;&lt;br /&gt;
      if (record.price_per !== null &amp;amp;&amp;amp; !validString(record.price_per)) return &#039;price_per must be a non-empty string or null.&#039;;&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function setField(key, value) {&lt;br /&gt;
      var control = document.querySelector(&#039;[name=&amp;quot;&#039; + fields[key] + &#039;&amp;quot;]&#039;);&lt;br /&gt;
      if (!control) throw new Error(&#039;The Ingredient form is missing the &#039; + key + &#039; field.&#039;);&lt;br /&gt;
      control.value = value === null ? &#039;&#039; : String(value);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    copyButton.addEventListener(&#039;click&#039;, copyPrompt);&lt;br /&gt;
    Array.prototype.forEach.call(panel.querySelectorAll(&#039;[data-rp-ai-provider]&#039;), function (link) {&lt;br /&gt;
      link.addEventListener(&#039;click&#039;, copyPrompt);&lt;br /&gt;
    });&lt;br /&gt;
    importButton.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      var record;&lt;br /&gt;
      try {&lt;br /&gt;
        record = JSON.parse(input.value);&lt;br /&gt;
      } catch (error) {&lt;br /&gt;
        setStatus(&#039;Invalid JSON: &#039; + error.message, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var problem = validate(record);&lt;br /&gt;
      if (problem) {&lt;br /&gt;
        setStatus(problem, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      try {&lt;br /&gt;
        Object.keys(fields).forEach(function (key) {&lt;br /&gt;
          var value = record[key];&lt;br /&gt;
          if (key === &#039;allergens&#039;) value = value.join(&#039;, &#039;);&lt;br /&gt;
          if (key === &#039;pantry&#039;) value = value ? &#039;yes&#039; : &#039;no&#039;;&lt;br /&gt;
          setField(key, value);&lt;br /&gt;
        });&lt;br /&gt;
      } catch (error) {&lt;br /&gt;
        setStatus(error.message, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      setStatus(&#039;Draft imported. Review every field, then use the normal Save button to create &#039; + expectedTitle + &#039;.&#039;, false);&lt;br /&gt;
    });&lt;br /&gt;
  });&lt;br /&gt;
}(jQuery, mediaWiki));&lt;br /&gt;
&lt;br /&gt;
/* AI-assisted Recipe draft import. Runs only on Form:Recipe. */&lt;br /&gt;
(function ($, mw) {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  $(function () {&lt;br /&gt;
    var panel = document.getElementById(&#039;rp-ai-recipe-import&#039;);&lt;br /&gt;
    if (!panel) return;&lt;br /&gt;
&lt;br /&gt;
    var fields = {&lt;br /&gt;
      serves: &#039;Recipe[serves]&#039;,&lt;br /&gt;
      time: &#039;Recipe[time]&#039;,&lt;br /&gt;
      tags: &#039;Recipe[tags]&#039;,&lt;br /&gt;
      contributor: &#039;Recipe[contributor]&#039;,&lt;br /&gt;
      lede: &#039;Recipe[lede]&#039;,&lt;br /&gt;
      ingredients: &#039;Recipe[ingredients]&#039;,&lt;br /&gt;
      instructions: &#039;Recipe[method]&#039;&lt;br /&gt;
    };&lt;br /&gt;
    var requiredKeys = [&#039;name&#039;, &#039;serves&#039;, &#039;time&#039;, &#039;tags&#039;, &#039;lede&#039;, &#039;ingredients&#039;, &#039;instructions&#039;];&lt;br /&gt;
    var allKeys = requiredKeys.concat([&#039;contributor&#039;]);&lt;br /&gt;
    var expectedTitle = String(mw.config.get(&#039;wgPageFormsTargetName&#039;) || &#039;&#039;).replace(/_/g, &#039; &#039;);&lt;br /&gt;
    if (!expectedTitle) return;&lt;br /&gt;
    panel.textContent = &#039;&#039;;&lt;br /&gt;
&lt;br /&gt;
    function addTextElement(tag, text) {&lt;br /&gt;
      var element = document.createElement(tag);&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
      panel.appendChild(element);&lt;br /&gt;
      return element;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function addProviderLink(provider, url) {&lt;br /&gt;
      var link = document.createElement(&#039;a&#039;);&lt;br /&gt;
      link.href = url;&lt;br /&gt;
      link.target = &#039;_blank&#039;;&lt;br /&gt;
      link.rel = &#039;noopener&#039;;&lt;br /&gt;
      link.setAttribute(&#039;data-rp-ai-provider&#039;, provider);&lt;br /&gt;
      link.textContent = provider;&lt;br /&gt;
      return link;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    var title = addTextElement(&#039;strong&#039;, &#039;Create a recipe draft with AI (optional)&#039;);&lt;br /&gt;
    title.className = &#039;rp-ai-heading&#039;;&lt;br /&gt;
    addTextElement(&#039;p&#039;, &#039;AI output is a draft. Verify quantities, timings, allergens, and food-safety guidance before saving. Imported ingredient names become internal links.&#039;);&lt;br /&gt;
    var actions = document.createElement(&#039;p&#039;);&lt;br /&gt;
    var copyButton = document.createElement(&#039;button&#039;);&lt;br /&gt;
    copyButton.type = &#039;button&#039;;&lt;br /&gt;
    copyButton.id = &#039;rp-ai-recipe-copy-prompt&#039;;&lt;br /&gt;
    copyButton.textContent = &#039;Copy prompt&#039;;&lt;br /&gt;
    actions.appendChild(copyButton);&lt;br /&gt;
    actions.appendChild(document.createTextNode(&#039; &#039;));&lt;br /&gt;
    [&lt;br /&gt;
      [&#039;ChatGPT&#039;, &#039;https://chatgpt.com/&#039;],&lt;br /&gt;
      [&#039;Claude&#039;, &#039;https://claude.ai/new&#039;],&lt;br /&gt;
      [&#039;Gemini&#039;, &#039;https://gemini.google.com/app&#039;],&lt;br /&gt;
      [&#039;DeepSeek&#039;, &#039;https://chat.deepseek.com/&#039;]&lt;br /&gt;
    ].forEach(function (provider, index) {&lt;br /&gt;
      if (index) actions.appendChild(document.createTextNode(&#039; · &#039;));&lt;br /&gt;
      actions.appendChild(addProviderLink(provider[0], provider[1]));&lt;br /&gt;
    });&lt;br /&gt;
    panel.appendChild(actions);&lt;br /&gt;
&lt;br /&gt;
    var importLabel = document.createElement(&#039;label&#039;);&lt;br /&gt;
    importLabel.htmlFor = &#039;rp-ai-recipe-json&#039;;&lt;br /&gt;
    importLabel.textContent = &#039;Paste the returned JSON&#039;;&lt;br /&gt;
    var input = document.createElement(&#039;textarea&#039;);&lt;br /&gt;
    input.id = &#039;rp-ai-recipe-json&#039;;&lt;br /&gt;
    input.rows = 16;&lt;br /&gt;
    input.cols = 70;&lt;br /&gt;
    input.spellcheck = false;&lt;br /&gt;
    input.placeholder = &#039;{&amp;quot;name&amp;quot;:&amp;quot;This page title&amp;quot;, &amp;quot;ingredients&amp;quot;:[...], ...}&#039;;&lt;br /&gt;
    var importControl = document.createElement(&#039;p&#039;);&lt;br /&gt;
    importControl.appendChild(importLabel);&lt;br /&gt;
    importControl.appendChild(document.createElement(&#039;br&#039;));&lt;br /&gt;
    importControl.appendChild(input);&lt;br /&gt;
    panel.appendChild(importControl);&lt;br /&gt;
    var importButton = document.createElement(&#039;button&#039;);&lt;br /&gt;
    importButton.type = &#039;button&#039;;&lt;br /&gt;
    importButton.id = &#039;rp-ai-recipe-import-json&#039;;&lt;br /&gt;
    importButton.textContent = &#039;Import draft into this form&#039;;&lt;br /&gt;
    var importAction = document.createElement(&#039;p&#039;);&lt;br /&gt;
    importAction.appendChild(importButton);&lt;br /&gt;
    panel.appendChild(importAction);&lt;br /&gt;
    var status = document.createElement(&#039;div&#039;);&lt;br /&gt;
    status.id = &#039;rp-ai-recipe-status&#039;;&lt;br /&gt;
    status.className = &#039;rp-ai-status&#039;;&lt;br /&gt;
    status.setAttribute(&#039;role&#039;, &#039;status&#039;);&lt;br /&gt;
    status.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    panel.appendChild(status);&lt;br /&gt;
&lt;br /&gt;
    function setStatus(message, isError) {&lt;br /&gt;
      status.textContent = message;&lt;br /&gt;
      status.className = isError ? &#039;rp-ai-status rp-ai-status-error&#039; : &#039;rp-ai-status rp-ai-status-success&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function promptFor(recipeTitle) {&lt;br /&gt;
      return &#039;Create a draft Recipedius recipe for &amp;quot;&#039; + recipeTitle + &#039;&amp;quot;. &#039; +&lt;br /&gt;
        &#039;Return only one JSON object with exactly these keys: name, serves, time, tags, contributor, lede, ingredients, instructions. &#039; +&lt;br /&gt;
        &#039;Use this title exactly as name. serves must be a positive integer. time and lede must be non-empty strings. &#039; +&lt;br /&gt;
        &#039;tags must be an array of non-empty strings. contributor must be a non-empty string or null. &#039; +&lt;br /&gt;
        &#039;ingredients must be a non-empty array of objects with exactly name, quantity, unit, role, preparation. &#039; +&lt;br /&gt;
        &#039;Each ingredient name must be a plain title, not wiki markup; quantity must be a positive number; unit, role, and preparation must be non-empty strings or null. &#039; +&lt;br /&gt;
        &#039;instructions must be an array of non-empty step strings. Do not use semicolons or newlines in any string, commas in tags, or [, ], or | in ingredient names. &#039; +&lt;br /&gt;
        &#039;Use null for unknown contributor, unit, role, or preparation; do not invent them. &#039; +&lt;br /&gt;
        &#039;Quantities, timing, allergy information, and food-safety guidance are a draft for the contributor to verify before saving.&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function copyText(text) {&lt;br /&gt;
      if (navigator.clipboard &amp;amp;&amp;amp; navigator.clipboard.writeText) return navigator.clipboard.writeText(text);&lt;br /&gt;
      var temporary = document.createElement(&#039;textarea&#039;);&lt;br /&gt;
      temporary.value = text;&lt;br /&gt;
      temporary.setAttribute(&#039;readonly&#039;, &#039;&#039;);&lt;br /&gt;
      temporary.style.position = &#039;fixed&#039;;&lt;br /&gt;
      temporary.style.opacity = &#039;0&#039;;&lt;br /&gt;
      document.body.appendChild(temporary);&lt;br /&gt;
      temporary.select();&lt;br /&gt;
      var copied = document.execCommand(&#039;copy&#039;);&lt;br /&gt;
      document.body.removeChild(temporary);&lt;br /&gt;
      return copied ? Promise.resolve() : Promise.reject(new Error(&#039;Clipboard unavailable&#039;));&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function copyPrompt() {&lt;br /&gt;
      return copyText(promptFor(expectedTitle)).then(function () {&lt;br /&gt;
        setStatus(&#039;Prompt copied. Review the AI draft before importing it.&#039;, false);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        setStatus(&#039;Your browser did not allow copying. Select and copy the prompt shown below.&#039;, true);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hasOwn(object, key) {&lt;br /&gt;
      return Object.prototype.hasOwnProperty.call(object, key);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validString(value) {&lt;br /&gt;
      return typeof value === &#039;string&#039; &amp;amp;&amp;amp; value.trim() !== &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hasSeparator(value) {&lt;br /&gt;
      return /[;\r\n]/.test(value);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validatePlainString(value, property) {&lt;br /&gt;
      if (!validString(value)) return property + &#039; must be a non-empty string.&#039;;&lt;br /&gt;
      if (hasSeparator(value)) return property + &#039; must not contain a semicolon or newline.&#039;;&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validateIngredient(ingredient, index) {&lt;br /&gt;
      var keys = [&#039;name&#039;, &#039;quantity&#039;, &#039;unit&#039;, &#039;role&#039;, &#039;preparation&#039;];&lt;br /&gt;
      var key;&lt;br /&gt;
      if (!ingredient || Array.isArray(ingredient) || typeof ingredient !== &#039;object&#039;) return &#039;ingredients[&#039; + index + &#039;] must be an object.&#039;;&lt;br /&gt;
      for (key in ingredient) {&lt;br /&gt;
        if (hasOwn(ingredient, key) &amp;amp;&amp;amp; keys.indexOf(key) === -1) return &#039;Unknown property: ingredients[&#039; + index + &#039;].&#039; + key + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (var i = 0; i &amp;lt; keys.length; i++) {&lt;br /&gt;
        if (!hasOwn(ingredient, keys[i])) return &#039;Missing property: ingredients[&#039; + index + &#039;].&#039; + keys[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      var nameProblem = validatePlainString(ingredient.name, &#039;ingredients[&#039; + index + &#039;].name&#039;);&lt;br /&gt;
      if (nameProblem) return nameProblem;&lt;br /&gt;
      if (/[\[\]|]/.test(ingredient.name)) return &#039;ingredients[&#039; + index + &#039;].name must be a plain title without wiki-link markup.&#039;;&lt;br /&gt;
      if (typeof ingredient.quantity !== &#039;number&#039; || !Number.isFinite(ingredient.quantity) || ingredient.quantity &amp;lt;= 0) {&lt;br /&gt;
        return &#039;ingredients[&#039; + index + &#039;].quantity must be a positive number.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (var j = 0; j &amp;lt; 3; j++) {&lt;br /&gt;
        key = keys[j + 2];&lt;br /&gt;
        if (ingredient[key] !== null) {&lt;br /&gt;
          var problem = validatePlainString(ingredient[key], &#039;ingredients[&#039; + index + &#039;].&#039; + key);&lt;br /&gt;
          if (problem) return problem;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validate(record) {&lt;br /&gt;
      var i;&lt;br /&gt;
      if (!record || Array.isArray(record) || typeof record !== &#039;object&#039;) return &#039;The draft must be one JSON object.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; Object.keys(record).length; i++) {&lt;br /&gt;
        if (allKeys.indexOf(Object.keys(record)[i]) === -1) return &#039;Unknown property: &#039; + Object.keys(record)[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (i = 0; i &amp;lt; allKeys.length; i++) {&lt;br /&gt;
        if (!hasOwn(record, allKeys[i])) return &#039;Missing property: &#039; + allKeys[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      if (!validString(record.name)) return &#039;name must be a non-empty string.&#039;;&lt;br /&gt;
      if (record.name !== expectedTitle) return &#039;The JSON name must exactly match this page title: &#039; + expectedTitle + &#039;.&#039;;&lt;br /&gt;
      if (!Number.isInteger(record.serves) || record.serves &amp;lt;= 0) return &#039;serves must be a positive integer.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; 2; i++) {&lt;br /&gt;
        var stringProblem = validatePlainString(record[i === 0 ? &#039;time&#039; : &#039;lede&#039;], i === 0 ? &#039;time&#039; : &#039;lede&#039;);&lt;br /&gt;
        if (stringProblem) return stringProblem;&lt;br /&gt;
      }&lt;br /&gt;
      if (record.contributor !== null) {&lt;br /&gt;
        var contributorProblem = validatePlainString(record.contributor, &#039;contributor&#039;);&lt;br /&gt;
        if (contributorProblem) return contributorProblem;&lt;br /&gt;
      }&lt;br /&gt;
      if (!Array.isArray(record.tags) || !record.tags.length) return &#039;tags must be a non-empty array of strings.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; record.tags.length; i++) {&lt;br /&gt;
        var tagProblem = validatePlainString(record.tags[i], &#039;tags[&#039; + i + &#039;]&#039;);&lt;br /&gt;
        if (tagProblem) return tagProblem;&lt;br /&gt;
        if (record.tags[i].indexOf(&#039;,&#039;) !== -1) return &#039;tags[&#039; + i + &#039;] must not contain a comma.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      if (!Array.isArray(record.ingredients) || !record.ingredients.length) return &#039;ingredients must be a non-empty array.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; record.ingredients.length; i++) {&lt;br /&gt;
        var ingredientProblem = validateIngredient(record.ingredients[i], i);&lt;br /&gt;
        if (ingredientProblem) return ingredientProblem;&lt;br /&gt;
      }&lt;br /&gt;
      if (!Array.isArray(record.instructions) || !record.instructions.length) return &#039;instructions must be a non-empty array of strings.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; record.instructions.length; i++) {&lt;br /&gt;
        var instructionProblem = validatePlainString(record.instructions[i], &#039;instructions[&#039; + i + &#039;]&#039;);&lt;br /&gt;
        if (instructionProblem) return instructionProblem;&lt;br /&gt;
      }&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function ingredientLine(ingredient) {&lt;br /&gt;
      return &#039;[[&#039; + ingredient.name.trim() + &#039;]]; &#039; + ingredient.quantity + &#039;; &#039; +&lt;br /&gt;
        (ingredient.unit === null ? &#039;&#039; : ingredient.unit.trim()) + &#039;; &#039; +&lt;br /&gt;
        (ingredient.role === null ? &#039;&#039; : ingredient.role.trim()) + &#039;; &#039; +&lt;br /&gt;
        (ingredient.preparation === null ? &#039;&#039; : ingredient.preparation.trim());&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function valuesFor(record) {&lt;br /&gt;
      return {&lt;br /&gt;
        serves: record.serves,&lt;br /&gt;
        time: record.time.trim(),&lt;br /&gt;
        tags: record.tags.map(function (tag) { return tag.trim(); }).join(&#039;, &#039;),&lt;br /&gt;
        contributor: record.contributor === null ? &#039;&#039; : record.contributor.trim(),&lt;br /&gt;
        lede: record.lede.trim(),&lt;br /&gt;
        ingredients: record.ingredients.map(ingredientLine).join(&#039;\n&#039;),&lt;br /&gt;
        instructions: record.instructions.map(function (step) { return &#039;# &#039; + step.trim(); }).join(&#039;\n&#039;)&lt;br /&gt;
      };&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function setFields(values) {&lt;br /&gt;
      var controls = {};&lt;br /&gt;
      Object.keys(fields).forEach(function (key) {&lt;br /&gt;
        var control = document.querySelector(&#039;[name=&amp;quot;&#039; + fields[key] + &#039;&amp;quot;]&#039;);&lt;br /&gt;
        if (!control) throw new Error(&#039;The Recipe form is missing the &#039; + key + &#039; field.&#039;);&lt;br /&gt;
        controls[key] = control;&lt;br /&gt;
      });&lt;br /&gt;
      Object.keys(fields).forEach(function (key) {&lt;br /&gt;
        controls[key].value = String(values[key]);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    copyButton.addEventListener(&#039;click&#039;, copyPrompt);&lt;br /&gt;
    Array.prototype.forEach.call(panel.querySelectorAll(&#039;[data-rp-ai-provider]&#039;), function (link) {&lt;br /&gt;
      link.addEventListener(&#039;click&#039;, copyPrompt);&lt;br /&gt;
    });&lt;br /&gt;
    importButton.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      var record;&lt;br /&gt;
      try {&lt;br /&gt;
        record = JSON.parse(input.value);&lt;br /&gt;
      } catch (error) {&lt;br /&gt;
        setStatus(&#039;Invalid JSON: &#039; + error.message, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var problem = validate(record);&lt;br /&gt;
      if (problem) {&lt;br /&gt;
        setStatus(problem, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      try {&lt;br /&gt;
        setFields(valuesFor(record));&lt;br /&gt;
      } catch (error) {&lt;br /&gt;
        setStatus(error.message, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      setStatus(&#039;Draft imported. Review every field, then use the normal Save button to create &#039; + expectedTitle + &#039;.&#039;, false);&lt;br /&gt;
    });&lt;br /&gt;
  });&lt;br /&gt;
}(jQuery, mediaWiki));&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=136</id>
		<title>Module:Recipedius</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=136"/>
		<updated>2026-09-25T12:55:48Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.014 T014: deploy AI-assisted Recipe import (e0496d8)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;⅛&#039; }, { 0.25, &#039;¼&#039; }, { 1/3, &#039;⅓&#039; }, { 0.375, &#039;⅜&#039; }, { 0.5, &#039;½&#039; },&lt;br /&gt;
  { 0.625, &#039;⅝&#039; }, { 2/3, &#039;⅔&#039; }, { 0.75, &#039;¾&#039; }, { 0.875, &#039;⅞&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
-- AI-assisted recipe imports persist ingredient titles as ordinary wiki links&lt;br /&gt;
-- so the form value itself remains navigable. Keep the existing plain-title&lt;br /&gt;
-- format compatible, while resolving that narrow importer-owned syntax before&lt;br /&gt;
-- data lookup, Cargo storage, pluralization, and generated display links.&lt;br /&gt;
local function ingredientTitle(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local linked = s:match(&#039;^%[%[([^%[%]|]+)%]%]$&#039;)&lt;br /&gt;
  return linked and trim(linked) or s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = ingredientTitle(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    -- Each name links to the ingredient form already targeted at that title, so&lt;br /&gt;
    -- a contributor lands on the right form rather than being asked for a name&lt;br /&gt;
    -- the page already knows. A bare Special:FormEdit link has no target and&lt;br /&gt;
    -- answers &amp;quot;No target page specified&amp;quot;.&lt;br /&gt;
    local createLinks = {}&lt;br /&gt;
    for _, name in ipairs(missing) do&lt;br /&gt;
      table.insert(createLinks, &#039;[[Special:FormEdit/Ingredient/&#039; .. name .. &#039;|&#039; .. name .. &#039;]]&#039;)&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(createLinks, &#039;, &#039;) .. &#039;. Follow a name to create its record.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Form:Ingredient&amp;diff=133</id>
		<title>Form:Ingredient</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Form:Ingredient&amp;diff=133"/>
		<updated>2026-09-24T16:02:34Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.013 T014: deploy AI ingredient draft import (ac09cdd)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
This is the form for creating an ingredient record. Enter the ingredient&#039;s name below.&lt;br /&gt;
{{#forminput:form=Ingredient|autocomplete on category=Ingredients}}&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
&amp;lt;div id=&amp;quot;rp-ai-ingredient-import&amp;quot; class=&amp;quot;rp-ai-import&amp;quot;&amp;gt;&lt;br /&gt;
&#039;&#039;&#039;Create a draft with AI (optional)&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Copy prompt, use ChatGPT, Claude, Gemini, or DeepSeek, then Paste the returned JSON here. AI-assisted import needs JavaScript; you can still complete every field manually below.&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
{{{for template|Ingredient}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! Form:&lt;br /&gt;
| {{{field|form|size=40|placeholder=Unsalted, solid}}}&lt;br /&gt;
|-&lt;br /&gt;
! Role in the kitchen:&lt;br /&gt;
| {{{field|role|size=40|placeholder=Fat · structure, flavor, creaming}}}&lt;br /&gt;
|-&lt;br /&gt;
! Storage:&lt;br /&gt;
| {{{field|storage|size=40}}}&lt;br /&gt;
|-&lt;br /&gt;
! Season:&lt;br /&gt;
| {{{field|season|size=40}}}&lt;br /&gt;
|-&lt;br /&gt;
! kcal per 100 g:&lt;br /&gt;
| {{{field|kcal100|size=6}}}&lt;br /&gt;
|-&lt;br /&gt;
! Grams per teaspoon / tablespoon / cup / each:&lt;br /&gt;
| {{{field|g_tsp|size=5}}} {{{field|g_tbsp|size=5}}} {{{field|g_cup|size=5}}} {{{field|g_each|size=5}}}&lt;br /&gt;
|-&lt;br /&gt;
! Allergens (comma-separated):&lt;br /&gt;
| {{{field|allergens|size=40|placeholder=dairy}}}&lt;br /&gt;
|-&lt;br /&gt;
! Pantry staple (left off shopping lists)?&lt;br /&gt;
| {{{field|pantry|input type=dropdown|values=no,yes}}}&lt;br /&gt;
|-&lt;br /&gt;
! Typical price and unit:&lt;br /&gt;
| ${{{field|price|size=6}}} per {{{field|price_per|size=10|placeholder=1 lb}}}&lt;br /&gt;
|-&lt;br /&gt;
! Description:&lt;br /&gt;
| {{{field|summary|input type=textarea|rows=5|cols=70}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
{{{standard input|free text|rows=8|cols=70}}}&lt;br /&gt;
&amp;lt;small&amp;gt;Add substitution rules in the free-text area with [[Template:Substitution]].&amp;lt;/small&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Edit summary:&#039;&#039;&#039; {{{standard input|summary}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|cancel}}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=MediaWiki:Common.js&amp;diff=132</id>
		<title>MediaWiki:Common.js</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=MediaWiki:Common.js&amp;diff=132"/>
		<updated>2026-09-24T16:02:33Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.013 T014: deploy AI ingredient draft import (ac09cdd)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* AI-assisted Ingredient draft import. Runs only on Form:Ingredient. */&lt;br /&gt;
(function ($, mw) {&lt;br /&gt;
  &#039;use strict&#039;;&lt;br /&gt;
&lt;br /&gt;
  $(function () {&lt;br /&gt;
    var panel = document.getElementById(&#039;rp-ai-ingredient-import&#039;);&lt;br /&gt;
    if (!panel) return;&lt;br /&gt;
&lt;br /&gt;
    var fields = {&lt;br /&gt;
      form: &#039;Ingredient[form]&#039;,&lt;br /&gt;
      role: &#039;Ingredient[role]&#039;,&lt;br /&gt;
      storage: &#039;Ingredient[storage]&#039;,&lt;br /&gt;
      season: &#039;Ingredient[season]&#039;,&lt;br /&gt;
      kcal100: &#039;Ingredient[kcal100]&#039;,&lt;br /&gt;
      g_tsp: &#039;Ingredient[g_tsp]&#039;,&lt;br /&gt;
      g_tbsp: &#039;Ingredient[g_tbsp]&#039;,&lt;br /&gt;
      g_cup: &#039;Ingredient[g_cup]&#039;,&lt;br /&gt;
      g_each: &#039;Ingredient[g_each]&#039;,&lt;br /&gt;
      allergens: &#039;Ingredient[allergens]&#039;,&lt;br /&gt;
      pantry: &#039;Ingredient[pantry]&#039;,&lt;br /&gt;
      price: &#039;Ingredient[price]&#039;,&lt;br /&gt;
      price_per: &#039;Ingredient[price_per]&#039;,&lt;br /&gt;
      summary: &#039;Ingredient[summary]&#039;&lt;br /&gt;
    };&lt;br /&gt;
    var requiredStrings = [&#039;name&#039;, &#039;form&#039;, &#039;role&#039;, &#039;storage&#039;, &#039;season&#039;, &#039;summary&#039;];&lt;br /&gt;
    var optionalNumbers = [&#039;g_tsp&#039;, &#039;g_tbsp&#039;, &#039;g_cup&#039;, &#039;g_each&#039;, &#039;price&#039;];&lt;br /&gt;
    var allKeys = requiredStrings.concat([&#039;kcal100&#039;], optionalNumbers, [&#039;allergens&#039;, &#039;pantry&#039;, &#039;price_per&#039;]);&lt;br /&gt;
    // FormEdit is a Special page, so wgPageName is the Special-page route.&lt;br /&gt;
    // Page Forms exposes the actual selected edit target separately.&lt;br /&gt;
    var expectedTitle = String(mw.config.get(&#039;wgPageFormsTargetName&#039;) || &#039;&#039;).replace(/_/g, &#039; &#039;);&lt;br /&gt;
    if (!expectedTitle) return;&lt;br /&gt;
    // The wikitext fallback is useful without JavaScript and to rendered-page&lt;br /&gt;
    // checks. Replace it only after the enhanced controls can be created.&lt;br /&gt;
    panel.textContent = &#039;&#039;;&lt;br /&gt;
&lt;br /&gt;
    function addTextElement(tag, text) {&lt;br /&gt;
      var element = document.createElement(tag);&lt;br /&gt;
      element.textContent = text;&lt;br /&gt;
      panel.appendChild(element);&lt;br /&gt;
      return element;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function addProviderLink(provider, url) {&lt;br /&gt;
      var link = document.createElement(&#039;a&#039;);&lt;br /&gt;
      link.href = url;&lt;br /&gt;
      link.target = &#039;_blank&#039;;&lt;br /&gt;
      link.rel = &#039;noopener&#039;;&lt;br /&gt;
      link.setAttribute(&#039;data-rp-ai-provider&#039;, provider);&lt;br /&gt;
      link.textContent = provider;&lt;br /&gt;
      return link;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    var title = addTextElement(&#039;strong&#039;, &#039;Create a draft with AI (optional)&#039;);&lt;br /&gt;
    title.className = &#039;rp-ai-heading&#039;;&lt;br /&gt;
    addTextElement(&#039;p&#039;, &#039;AI output is a draft. Verify nutrition, allergens, weights, and price against a reliable source before saving.&#039;);&lt;br /&gt;
    var actions = document.createElement(&#039;p&#039;);&lt;br /&gt;
    var copyButton = document.createElement(&#039;button&#039;);&lt;br /&gt;
    copyButton.type = &#039;button&#039;;&lt;br /&gt;
    copyButton.id = &#039;rp-ai-copy-prompt&#039;;&lt;br /&gt;
    copyButton.textContent = &#039;Copy prompt&#039;;&lt;br /&gt;
    actions.appendChild(copyButton);&lt;br /&gt;
    actions.appendChild(document.createTextNode(&#039; &#039;));&lt;br /&gt;
    [&lt;br /&gt;
      [&#039;ChatGPT&#039;, &#039;https://chatgpt.com/&#039;],&lt;br /&gt;
      [&#039;Claude&#039;, &#039;https://claude.ai/new&#039;],&lt;br /&gt;
      [&#039;Gemini&#039;, &#039;https://gemini.google.com/app&#039;],&lt;br /&gt;
      [&#039;DeepSeek&#039;, &#039;https://chat.deepseek.com/&#039;]&lt;br /&gt;
    ].forEach(function (provider, index) {&lt;br /&gt;
      if (index) actions.appendChild(document.createTextNode(&#039; · &#039;));&lt;br /&gt;
      actions.appendChild(addProviderLink(provider[0], provider[1]));&lt;br /&gt;
    });&lt;br /&gt;
    panel.appendChild(actions);&lt;br /&gt;
    var importLabel = document.createElement(&#039;label&#039;);&lt;br /&gt;
    importLabel.htmlFor = &#039;rp-ai-ingredient-json&#039;;&lt;br /&gt;
    importLabel.textContent = &#039;Paste the returned JSON&#039;;&lt;br /&gt;
    var importArea = document.createElement(&#039;textarea&#039;);&lt;br /&gt;
    importArea.id = &#039;rp-ai-ingredient-json&#039;;&lt;br /&gt;
    importArea.rows = 12;&lt;br /&gt;
    importArea.cols = 70;&lt;br /&gt;
    importArea.spellcheck = false;&lt;br /&gt;
    importArea.placeholder = &#039;{&amp;quot;name&amp;quot;:&amp;quot;This page title&amp;quot;, ...}&#039;;&lt;br /&gt;
    var importControl = document.createElement(&#039;p&#039;);&lt;br /&gt;
    importControl.appendChild(importLabel);&lt;br /&gt;
    importControl.appendChild(document.createElement(&#039;br&#039;));&lt;br /&gt;
    importControl.appendChild(importArea);&lt;br /&gt;
    panel.appendChild(importControl);&lt;br /&gt;
    var importButton = document.createElement(&#039;button&#039;);&lt;br /&gt;
    importButton.type = &#039;button&#039;;&lt;br /&gt;
    importButton.id = &#039;rp-ai-import-json&#039;;&lt;br /&gt;
    importButton.textContent = &#039;Import draft into this form&#039;;&lt;br /&gt;
    var importAction = document.createElement(&#039;p&#039;);&lt;br /&gt;
    importAction.appendChild(importButton);&lt;br /&gt;
    panel.appendChild(importAction);&lt;br /&gt;
    var status = document.createElement(&#039;div&#039;);&lt;br /&gt;
    status.id = &#039;rp-ai-ingredient-status&#039;;&lt;br /&gt;
    status.className = &#039;rp-ai-status&#039;;&lt;br /&gt;
    status.setAttribute(&#039;role&#039;, &#039;status&#039;);&lt;br /&gt;
    status.setAttribute(&#039;aria-live&#039;, &#039;polite&#039;);&lt;br /&gt;
    panel.appendChild(status);&lt;br /&gt;
    var input = importArea;&lt;br /&gt;
&lt;br /&gt;
    function setStatus(message, isError) {&lt;br /&gt;
      status.textContent = message;&lt;br /&gt;
      status.className = isError ? &#039;rp-ai-status rp-ai-status-error&#039; : &#039;rp-ai-status rp-ai-status-success&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function promptFor(title) {&lt;br /&gt;
      return &#039;Create a draft Recipedius ingredient record for &amp;quot;&#039; + title + &#039;&amp;quot;. &#039; +&lt;br /&gt;
        &#039;Return only one JSON object with exactly these keys: &#039; +&lt;br /&gt;
        &#039;name, form, role, storage, season, kcal100, g_tsp, g_tbsp, g_cup, g_each, allergens, pantry, price, price_per, summary. &#039; +&lt;br /&gt;
        &#039;Use this title exactly as name. form, role, storage, season, and summary must be strings. &#039; +&lt;br /&gt;
        &#039;kcal100 must be a non-negative number. g_tsp, g_tbsp, g_cup, g_each, and price must be non-negative numbers or null. &#039; +&lt;br /&gt;
        &#039;price_per must be a string or null. allergens must be an array of strings. pantry must be true or false. &#039; +&lt;br /&gt;
        &#039;Use null when a unit weight or typical price is unknown; do not invent values. &#039; +&lt;br /&gt;
        &#039;Nutrition, allergens, weights, and price are a draft for the contributor to verify against a reliable source before saving.&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function copyText(text) {&lt;br /&gt;
      if (navigator.clipboard &amp;amp;&amp;amp; navigator.clipboard.writeText) {&lt;br /&gt;
        return navigator.clipboard.writeText(text);&lt;br /&gt;
      }&lt;br /&gt;
      var temporary = document.createElement(&#039;textarea&#039;);&lt;br /&gt;
      temporary.value = text;&lt;br /&gt;
      temporary.setAttribute(&#039;readonly&#039;, &#039;&#039;);&lt;br /&gt;
      temporary.style.position = &#039;fixed&#039;;&lt;br /&gt;
      temporary.style.opacity = &#039;0&#039;;&lt;br /&gt;
      document.body.appendChild(temporary);&lt;br /&gt;
      temporary.select();&lt;br /&gt;
      var copied = document.execCommand(&#039;copy&#039;);&lt;br /&gt;
      document.body.removeChild(temporary);&lt;br /&gt;
      return copied ? Promise.resolve() : Promise.reject(new Error(&#039;Clipboard unavailable&#039;));&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function copyPrompt() {&lt;br /&gt;
      return copyText(promptFor(expectedTitle)).then(function () {&lt;br /&gt;
        setStatus(&#039;Prompt copied. Review the AI draft before importing it.&#039;, false);&lt;br /&gt;
      }).catch(function () {&lt;br /&gt;
        setStatus(&#039;Your browser did not allow copying. Select and copy the prompt shown below.&#039;, true);&lt;br /&gt;
      });&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function hasOwn(object, key) {&lt;br /&gt;
      return Object.prototype.hasOwnProperty.call(object, key);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validString(value) {&lt;br /&gt;
      return typeof value === &#039;string&#039; &amp;amp;&amp;amp; value.trim() !== &#039;&#039;;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validNumber(value) {&lt;br /&gt;
      return typeof value === &#039;number&#039; &amp;amp;&amp;amp; Number.isFinite(value) &amp;amp;&amp;amp; value &amp;gt;= 0;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function validate(record) {&lt;br /&gt;
      var i;&lt;br /&gt;
      if (!record || Array.isArray(record) || typeof record !== &#039;object&#039;) return &#039;The draft must be one JSON object.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; Object.keys(record).length; i++) {&lt;br /&gt;
        if (allKeys.indexOf(Object.keys(record)[i]) === -1) return &#039;Unknown property: &#039; + Object.keys(record)[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (i = 0; i &amp;lt; allKeys.length; i++) {&lt;br /&gt;
        if (!hasOwn(record, allKeys[i])) return &#039;Missing property: &#039; + allKeys[i] + &#039;.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      for (i = 0; i &amp;lt; requiredStrings.length; i++) {&lt;br /&gt;
        if (!validString(record[requiredStrings[i]])) return requiredStrings[i] + &#039; must be a non-empty string.&#039;;&lt;br /&gt;
      }&lt;br /&gt;
      if (record.name !== expectedTitle) return &#039;The JSON name must exactly match this page title: &#039; + expectedTitle + &#039;.&#039;;&lt;br /&gt;
      if (!validNumber(record.kcal100)) return &#039;kcal100 must be a non-negative number.&#039;;&lt;br /&gt;
      for (i = 0; i &amp;lt; optionalNumbers.length; i++) {&lt;br /&gt;
        if (record[optionalNumbers[i]] !== null &amp;amp;&amp;amp; !validNumber(record[optionalNumbers[i]])) {&lt;br /&gt;
          return optionalNumbers[i] + &#039; must be a non-negative number or null.&#039;;&lt;br /&gt;
        }&lt;br /&gt;
      }&lt;br /&gt;
      if (!Array.isArray(record.allergens) || !record.allergens.every(validString)) return &#039;allergens must be an array of non-empty strings.&#039;;&lt;br /&gt;
      if (typeof record.pantry !== &#039;boolean&#039;) return &#039;pantry must be true or false.&#039;;&lt;br /&gt;
      if (record.price_per !== null &amp;amp;&amp;amp; !validString(record.price_per)) return &#039;price_per must be a non-empty string or null.&#039;;&lt;br /&gt;
      return null;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    function setField(key, value) {&lt;br /&gt;
      var control = document.querySelector(&#039;[name=&amp;quot;&#039; + fields[key] + &#039;&amp;quot;]&#039;);&lt;br /&gt;
      if (!control) throw new Error(&#039;The Ingredient form is missing the &#039; + key + &#039; field.&#039;);&lt;br /&gt;
      control.value = value === null ? &#039;&#039; : String(value);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    copyButton.addEventListener(&#039;click&#039;, copyPrompt);&lt;br /&gt;
    Array.prototype.forEach.call(panel.querySelectorAll(&#039;[data-rp-ai-provider]&#039;), function (link) {&lt;br /&gt;
      link.addEventListener(&#039;click&#039;, copyPrompt);&lt;br /&gt;
    });&lt;br /&gt;
    importButton.addEventListener(&#039;click&#039;, function () {&lt;br /&gt;
      var record;&lt;br /&gt;
      try {&lt;br /&gt;
        record = JSON.parse(input.value);&lt;br /&gt;
      } catch (error) {&lt;br /&gt;
        setStatus(&#039;Invalid JSON: &#039; + error.message, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      var problem = validate(record);&lt;br /&gt;
      if (problem) {&lt;br /&gt;
        setStatus(problem, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      try {&lt;br /&gt;
        Object.keys(fields).forEach(function (key) {&lt;br /&gt;
          var value = record[key];&lt;br /&gt;
          if (key === &#039;allergens&#039;) value = value.join(&#039;, &#039;);&lt;br /&gt;
          if (key === &#039;pantry&#039;) value = value ? &#039;yes&#039; : &#039;no&#039;;&lt;br /&gt;
          setField(key, value);&lt;br /&gt;
        });&lt;br /&gt;
      } catch (error) {&lt;br /&gt;
        setStatus(error.message, true);&lt;br /&gt;
        return;&lt;br /&gt;
      }&lt;br /&gt;
      setStatus(&#039;Draft imported. Review every field, then use the normal Save button to create &#039; + expectedTitle + &#039;.&#039;, false);&lt;br /&gt;
    });&lt;br /&gt;
  });&lt;br /&gt;
}(jQuery, mediaWiki));&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=MediaWiki:Common.css&amp;diff=131</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=MediaWiki:Common.css&amp;diff=131"/>
		<updated>2026-09-24T16:02:33Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.013 T014: deploy AI ingredient draft import (ac09cdd)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Recipedius styling */&lt;br /&gt;
.rp-infobox { float: right; clear: right; margin: 0 0 1em 1.5em; width: 300px; font-size: 0.92em; }&lt;br /&gt;
.rp-infobox caption { font-weight: bold; font-size: 1.1em; padding: 0.4em; }&lt;br /&gt;
.rp-infobox th { text-align: left; width: 38%; }&lt;br /&gt;
.rp-lede { font-size: 1.15em; margin: 0.5em 0 1em; max-width: 65ch; }&lt;br /&gt;
.rp-qty { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em; white-space: nowrap; font-variant-numeric: tabular-nums; }&lt;br /&gt;
.rp-was { text-decoration: line-through; color: #72777d; }&lt;br /&gt;
.rp-prep { color: #54595d; }&lt;br /&gt;
.rp-role { color: #54595d; font-size: 0.9em; }&lt;br /&gt;
.rp-warn { color: #b32424; }&lt;br /&gt;
.rp-note { color: #54595d; max-width: 70ch; }&lt;br /&gt;
.rp-role-tag { color: #6b4fbb; }&lt;br /&gt;
.rp-missing { background: #fef6e7; border-left: 4px solid #edab00; padding: 0.5em 1em; margin: 0.8em 0; }&lt;br /&gt;
.rp-missing-mark { color: #b32424; }&lt;br /&gt;
.rp-conf { white-space: nowrap; font-size: 0.85em; letter-spacing: 0.02em; }&lt;br /&gt;
.rp-conf-high { color: #14866d; }&lt;br /&gt;
.rp-conf-medium { color: #ac6600; }&lt;br /&gt;
.rp-conf-low { color: #b32424; }&lt;br /&gt;
.rp-ingredients, .rp-subs, .rp-sale, .rp-rules { width: 100%; }&lt;br /&gt;
.rp-subs td:nth-child(3) { white-space: normal; width: 18%; }&lt;br /&gt;
.rp-subs td:nth-child(4) { width: 46%; }&lt;br /&gt;
.rp-subs .rp-qty, .rp-rules .rp-qty { white-space: normal; }&lt;br /&gt;
&lt;br /&gt;
/* Optional AI-assisted ingredient draft import */&lt;br /&gt;
.rp-ai-import { border: 1px solid #a2a9b1; background: #f8f9fa; margin: 0 0 1em; padding: 1em; max-width: 70ch; }&lt;br /&gt;
.rp-ai-import &amp;gt; :first-child { margin-top: 0; }&lt;br /&gt;
.rp-ai-import textarea { box-sizing: border-box; max-width: 100%; font-family: ui-monospace, Menlo, Consolas, monospace; }&lt;br /&gt;
.rp-ai-import a, .rp-ai-import button { margin-right: 0.35em; }&lt;br /&gt;
.rp-ai-status { min-height: 1.4em; font-weight: 600; }&lt;br /&gt;
.rp-ai-status-error { color: #b32424; }&lt;br /&gt;
.rp-ai-status-success { color: #14866d; }&lt;br /&gt;
&lt;br /&gt;
/* Main Page actions */&lt;br /&gt;
.rp-main-layout { display: grid; grid-template-columns: minmax(11rem, 0.28fr) minmax(0, 1fr); gap: 1.5rem; align-items: start; }&lt;br /&gt;
.rp-main-actions { background: #f8f9fa; border: 1px solid #c8ccd1; padding: 1rem; }&lt;br /&gt;
.rp-main-actions h2 { border: 0; font-size: 1.1em; margin: 0 0 0.65rem; padding: 0; }&lt;br /&gt;
.rp-main-actions ul { list-style: none; margin: 0; }&lt;br /&gt;
.rp-main-actions li + li { margin-top: 0.5rem; }&lt;br /&gt;
.rp-main-actions a { font-weight: bold; }&lt;br /&gt;
&lt;br /&gt;
/* Cookbook */&lt;br /&gt;
.rp-book { max-width: 820px; }&lt;br /&gt;
.rp-page { background: #fbfaf4; color: #22251c; border: 1px solid #ddd; padding: 40px 48px; margin: 0 0 18px; min-height: 420px; position: relative; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }&lt;br /&gt;
.rp-cover { background: #2e3a2a; color: #f3efd8; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; min-height: 380px; }&lt;br /&gt;
.rp-orn { width: 44px; height: 44px; border-radius: 50%; border: 2px solid #e2a600; display: grid; place-items: center; font-size: 1.2em; }&lt;br /&gt;
.rp-cover-title { font-size: 2.4em; font-weight: bold; line-height: 1.05; }&lt;br /&gt;
.rp-cover-sub { font-style: italic; opacity: 0.8; }&lt;br /&gt;
.rp-dedication { display: flex; align-items: center; justify-content: center; text-align: center; font-style: italic; font-size: 1.2em; }&lt;br /&gt;
.rp-chapter { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }&lt;br /&gt;
.rp-chapter-title { font-size: 2em; font-weight: bold; }&lt;br /&gt;
.rp-chap-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.12em; color: #7a7c6e; margin-bottom: 10px; }&lt;br /&gt;
.rp-recipe-title { font-size: 1.7em; font-weight: bold; margin-bottom: 4px; }&lt;br /&gt;
.rp-toc-chapter { font-weight: bold; margin-top: 10px; }&lt;br /&gt;
.rp-toc-row { display: flex; justify-content: space-between; border-bottom: 1px dotted #c9c7b4; padding: 3px 0; }&lt;br /&gt;
.rp-book-cols { display: grid; grid-template-columns: 1fr 1.4fr; gap: 28px; margin-top: 14px; }&lt;br /&gt;
.rp-book-ing { display: grid; grid-template-columns: 5em 1fr; gap: 8px; padding: 2px 0; font-size: 0.95em; }&lt;br /&gt;
.rp-book-method ol { margin-left: 1.2em; }&lt;br /&gt;
.rp-book-foot { margin-top: 18px; font-size: 0.8em; color: #7a7c6e; border-top: 1px solid #ddd; padding-top: 8px; }&lt;br /&gt;
@media (max-width: 720px) { .rp-book-cols, .rp-main-layout { grid-template-columns: 1fr; } .rp-infobox { float: none; width: auto; margin: 0 0 1em; } }&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Main_Page&amp;diff=125</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Main_Page&amp;diff=125"/>
		<updated>2026-09-24T13:37:04Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.012 T009: deploy working form entry points (6e8250e)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div class=&amp;quot;rp-main-layout&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-main-actions&amp;quot;&amp;gt;&lt;br /&gt;
== Add to Recipedius ==&lt;br /&gt;
* [[Special:FormStart/Recipe|Add recipe]]&lt;br /&gt;
* [[Special:FormStart/Ingredient|Add ingredient]]&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-main-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-lede&amp;quot;&amp;gt;&#039;&#039;&#039;Recipedius&#039;&#039;&#039; is an encyclopedia of what&#039;s in your kitchen. Every recipe is built from ingredient records, not text: swap the butter and the calories, allergens and shopping list follow. Put lemons on sale and the recipes that use them float to the top. Pick your favorites and turn them into a printed book.&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Start here:&#039;&#039;&#039; open [[Classic shortbread]] and look at the substitution table, then [[Banana bread]]; the same olive-oil swap is low-confidence in one and high in the other, because the butter&#039;s job is different. That is the whole idea in one click. Then see [[This week&#039;s sale]] and [[Cookbook/Sample]].&lt;br /&gt;
&lt;br /&gt;
== Recipes ==&lt;br /&gt;
{{#cargo_query:tables=Recipes&lt;br /&gt;
|fields=_pageName=Recipe,serves=Serves,time=Time,ROUND(kcal_total/serves)=kcal per serving,allergens=Contains,contributor=Contributed by&lt;br /&gt;
|order by=_pageName&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;br /&gt;
&#039;&#039;Add one: [[Special:FormStart/Recipe|create a recipe with the form]], or make a page and use [[Template:Recipe]].&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Ingredient records ==&lt;br /&gt;
{{#cargo_query:tables=Ingredients&lt;br /&gt;
|fields=_pageName=Ingredient,role=Role,ROUND(g_tbsp*kcal100/100)=kcal per tbsp,allergens=Allergens&lt;br /&gt;
|order by=_pageName&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;br /&gt;
&#039;&#039;Add one: [[Special:FormStart/Ingredient|create an ingredient record with the form]].&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Elsewhere ==&lt;br /&gt;
* [[This week&#039;s sale]] — recipes ranked by what&#039;s cheap at an example store&lt;br /&gt;
* [[Cookbook/Sample]] — a printable book assembled from the recipe database&lt;br /&gt;
* [[Special:CargoTables]] — the database behind the pages&lt;br /&gt;
* [[Recipedius:About]] — what this is, what&#039;s real and what&#039;s still to build&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius/sandbox&amp;diff=124</id>
		<title>Module:Recipedius/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius/sandbox&amp;diff=124"/>
		<updated>2026-09-24T13:37:04Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.012 T009: deploy working form entry points (6e8250e)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;-- Development copy of [[Module:Recipedius]]. Exercise changes at&lt;br /&gt;
-- [[Template:Recipe/testcases]] through [[Template:Recipe/sandbox]], then copy&lt;br /&gt;
-- an approved revision into the production module.&lt;br /&gt;
--&lt;br /&gt;
-- Keep this a faithful copy. It is the only place a module change can be seen&lt;br /&gt;
-- rendered before it reaches every recipe on the wiki.&lt;br /&gt;
--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;⅛&#039; }, { 0.25, &#039;¼&#039; }, { 1/3, &#039;⅓&#039; }, { 0.375, &#039;⅜&#039; }, { 0.5, &#039;½&#039; },&lt;br /&gt;
  { 0.625, &#039;⅝&#039; }, { 2/3, &#039;⅔&#039; }, { 0.75, &#039;¾&#039; }, { 0.875, &#039;⅞&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = trim(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    -- Each name links to the ingredient form already targeted at that title, so&lt;br /&gt;
    -- a contributor lands on the right form rather than being asked for a name&lt;br /&gt;
    -- the page already knows. A bare Special:FormEdit link has no target and&lt;br /&gt;
    -- answers &amp;quot;No target page specified&amp;quot;.&lt;br /&gt;
    local createLinks = {}&lt;br /&gt;
    for _, name in ipairs(missing) do&lt;br /&gt;
      table.insert(createLinks, &#039;[[Special:FormEdit/Ingredient/&#039; .. name .. &#039;|&#039; .. name .. &#039;]]&#039;)&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(createLinks, &#039;, &#039;) .. &#039;. Follow a name to create its record.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=123</id>
		<title>Module:Recipedius</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=123"/>
		<updated>2026-09-24T13:37:03Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.012 T009: deploy working form entry points (6e8250e)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;⅛&#039; }, { 0.25, &#039;¼&#039; }, { 1/3, &#039;⅓&#039; }, { 0.375, &#039;⅜&#039; }, { 0.5, &#039;½&#039; },&lt;br /&gt;
  { 0.625, &#039;⅝&#039; }, { 2/3, &#039;⅔&#039; }, { 0.75, &#039;¾&#039; }, { 0.875, &#039;⅞&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = trim(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    -- Each name links to the ingredient form already targeted at that title, so&lt;br /&gt;
    -- a contributor lands on the right form rather than being asked for a name&lt;br /&gt;
    -- the page already knows. A bare Special:FormEdit link has no target and&lt;br /&gt;
    -- answers &amp;quot;No target page specified&amp;quot;.&lt;br /&gt;
    local createLinks = {}&lt;br /&gt;
    for _, name in ipairs(missing) do&lt;br /&gt;
      table.insert(createLinks, &#039;[[Special:FormEdit/Ingredient/&#039; .. name .. &#039;|&#039; .. name .. &#039;]]&#039;)&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(createLinks, &#039;, &#039;) .. &#039;. Follow a name to create its record.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=This_week%27s_sale&amp;diff=122</id>
		<title>This week&#039;s sale</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=This_week%27s_sale&amp;diff=122"/>
		<updated>2026-09-23T19:28:27Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;This week&amp;#039;s sale&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Recipes ranked by how many of their shopping-list items are on sale this week. Pantry staples (salt, flour, sugar, soda, vanilla) don&#039;t count, because nobody buys them for one recipe.&lt;br /&gt;
&lt;br /&gt;
Sale items come from [[Sale/Oak &amp;amp; Fair Market]], an example retailer. A real grocery partner would feed that page from its promotions system, and the handoff at the end is a shopping partner such as Instacart or the store&#039;s own pickup.&lt;br /&gt;
&lt;br /&gt;
== What to cook ==&lt;br /&gt;
{{#invoke:Recipedius|sale|store=Oak &amp;amp; Fair Market}}&lt;br /&gt;
&lt;br /&gt;
== On sale now ==&lt;br /&gt;
{{#cargo_query:tables=SaleItems&lt;br /&gt;
|fields=ingredient=Ingredient,price=Sale price,was=Was,per=Per,week=Week of&lt;br /&gt;
|where=store=&#039;Oak &amp;amp; Fair Market&#039;&lt;br /&gt;
|order by=ingredient&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Recipedius:About&amp;diff=121</id>
		<title>Recipedius:About</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Recipedius:About&amp;diff=121"/>
		<updated>2026-09-23T19:28:27Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Recipedius:About&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Recipedius is a sandbox exploration of a twenty-year-old idea: Wikipedia, but for recipes, where every ingredient is a record that recipes link to instead of a word they retype.&lt;br /&gt;
&lt;br /&gt;
This wiki is the second prototype. The first was a single-page mock-up; this one runs on MediaWiki with the Cargo, Scribunto and Page Forms extensions, because most of what the idea needs already exists here: revision history, attribution, talk pages, upload licensing, categories, and structured data with queries.&lt;br /&gt;
&lt;br /&gt;
== How the pieces fit ==&lt;br /&gt;
; Ingredient layer&lt;br /&gt;
: [[Template:Ingredient]] is the record: form, role, storage, season, allergens, price, grams per unit, kcal per 100 g. It stores to the &amp;lt;code&amp;gt;Ingredients&amp;lt;/code&amp;gt; table.&lt;br /&gt;
; Recipe layer&lt;br /&gt;
: [[Template:Recipe]] takes ingredient lines (&amp;lt;code&amp;gt;Ingredient; quantity; unit; role; preparation&amp;lt;/code&amp;gt;) and hands them to [[Module:Recipedius]], which computes calories and allergens from the records, resolves substitutions for each line&#039;s role, renders the page, and stores rows to &amp;lt;code&amp;gt;Recipes&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;RecipeIngredients&amp;lt;/code&amp;gt;.&lt;br /&gt;
; Substitution layer&lt;br /&gt;
: [[Template:Substitution]] rows live on the page of the ingredient being replaced. A blank-role row applies everywhere; a role row overrides it. &amp;lt;code&amp;gt;applicable=no&amp;lt;/code&amp;gt; removes an option for a role.&lt;br /&gt;
; Commerce layer&lt;br /&gt;
: [[Template:SaleItem]] rows on a &amp;lt;code&amp;gt;Sale/Store&amp;lt;/code&amp;gt; page. [[This week&#039;s sale]] joins them to recipe ingredients and ranks recipes, excluding pantry staples.&lt;br /&gt;
; Collection layer&lt;br /&gt;
: [[Template:Cookbook]] renders a book from the database with a print stylesheet. Print-on-demand is the next step (the Collection extension, or a PDF export to a printer).&lt;br /&gt;
; Contribution layer&lt;br /&gt;
: MediaWiki itself: every edit has a history and an author, every upload carries a license, and moderation extensions (FlaggedRevs, Moderation, AbuseFilter) bolt on when needed.&lt;br /&gt;
&lt;br /&gt;
== What&#039;s illustrative ==&lt;br /&gt;
Nutrition values are USDA-style approximations entered by hand, not licensed data. Prices, the store, contributor names and the sample cookbook are examples. Nothing here is dietary advice.&lt;br /&gt;
&lt;br /&gt;
== Where to dig ==&lt;br /&gt;
* [[Special:CargoTables]] shows every table and its rows.&lt;br /&gt;
* [[Special:Drilldown]] filters recipes and ingredients by any field.&lt;br /&gt;
* [[Special:Version]] lists the extensions in use.&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Main_Page&amp;diff=120</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Main_Page&amp;diff=120"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Main Page&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div class=&amp;quot;rp-main-layout&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-main-actions&amp;quot;&amp;gt;&lt;br /&gt;
== Add to Recipedius ==&lt;br /&gt;
* [[Special:FormEdit/Recipe|Add recipe]]&lt;br /&gt;
* [[Special:FormEdit/Ingredient|Add ingredient]]&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-main-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-lede&amp;quot;&amp;gt;&#039;&#039;&#039;Recipedius&#039;&#039;&#039; is an encyclopedia of what&#039;s in your kitchen. Every recipe is built from ingredient records, not text: swap the butter and the calories, allergens and shopping list follow. Put lemons on sale and the recipes that use them float to the top. Pick your favorites and turn them into a printed book.&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Start here:&#039;&#039;&#039; open [[Classic shortbread]] and look at the substitution table, then [[Banana bread]]; the same olive-oil swap is low-confidence in one and high in the other, because the butter&#039;s job is different. That is the whole idea in one click. Then see [[This week&#039;s sale]] and [[Cookbook/Sample]].&lt;br /&gt;
&lt;br /&gt;
== Recipes ==&lt;br /&gt;
{{#cargo_query:tables=Recipes&lt;br /&gt;
|fields=_pageName=Recipe,serves=Serves,time=Time,ROUND(kcal_total/serves)=kcal per serving,allergens=Contains,contributor=Contributed by&lt;br /&gt;
|order by=_pageName&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;br /&gt;
&#039;&#039;Add one: [[Special:FormEdit/Recipe|create a recipe with the form]], or make a page and use [[Template:Recipe]].&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Ingredient records ==&lt;br /&gt;
{{#cargo_query:tables=Ingredients&lt;br /&gt;
|fields=_pageName=Ingredient,role=Role,ROUND(g_tbsp*kcal100/100)=kcal per tbsp,allergens=Allergens&lt;br /&gt;
|order by=_pageName&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;br /&gt;
&#039;&#039;Add one: [[Special:FormEdit/Ingredient|create an ingredient record with the form]].&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Elsewhere ==&lt;br /&gt;
* [[This week&#039;s sale]] — recipes ranked by what&#039;s cheap at an example store&lt;br /&gt;
* [[Cookbook/Sample]] — a printable book assembled from the recipe database&lt;br /&gt;
* [[Special:CargoTables]] — the database behind the pages&lt;br /&gt;
* [[Recipedius:About]] — what this is, what&#039;s real and what&#039;s still to build&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Techniques&amp;diff=119</id>
		<title>Category:Techniques</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Techniques&amp;diff=119"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Category:Techniques&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cooking methods, preparations, and foundational skills. Add a page with&lt;br /&gt;
[[Template:Technique]] to include it here.&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Recipes_by_tag&amp;diff=118</id>
		<title>Category:Recipes by tag</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Recipes_by_tag&amp;diff=118"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Category:Recipes by tag&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;One category per tag used in [[Template:Recipe]].&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Recipes&amp;diff=117</id>
		<title>Category:Recipes</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Recipes&amp;diff=117"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Category:Recipes&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Every recipe on Recipedius. {{#default_form:Recipe}}&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Recipedius_templates&amp;diff=116</id>
		<title>Category:Recipedius templates</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Recipedius_templates&amp;diff=116"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Category:Recipedius templates&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;The templates and module that make Recipedius work. Start with [[Recipedius:About]].&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Ingredients&amp;diff=115</id>
		<title>Category:Ingredients</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Ingredients&amp;diff=115"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Category:Ingredients&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Every ingredient record on Recipedius. {{#default_form:Ingredient}}&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Cookbooks&amp;diff=114</id>
		<title>Category:Cookbooks</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Cookbooks&amp;diff=114"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Category:Cookbooks&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Books assembled from the recipe database with [[Template:Cookbook]].&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Form:Recipe&amp;diff=113</id>
		<title>Form:Recipe</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Form:Recipe&amp;diff=113"/>
		<updated>2026-09-23T19:28:26Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Form:Recipe&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
This is the form for creating a recipe. Enter a title below.&lt;br /&gt;
{{#forminput:form=Recipe|autocomplete on category=Recipes}}&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
{{{for template|Recipe}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! Serves:&lt;br /&gt;
| {{{field|serves|input type=text|size=4}}}&lt;br /&gt;
|-&lt;br /&gt;
! Time:&lt;br /&gt;
| {{{field|time|size=20|placeholder=50 min}}}&lt;br /&gt;
|-&lt;br /&gt;
! Tags (comma-separated):&lt;br /&gt;
| {{{field|tags|size=40|placeholder=dessert, baking, cookie}}}&lt;br /&gt;
|-&lt;br /&gt;
! Contributed by:&lt;br /&gt;
| {{{field|contributor|size=30}}}&lt;br /&gt;
|-&lt;br /&gt;
! Description:&lt;br /&gt;
| {{{field|lede|input type=textarea|rows=2|cols=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! Ingredients:&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;One per line: Ingredient; quantity; unit; role; preparation&amp;lt;br /&amp;gt;e.g. Butter; 1; cup; creaming; softened&amp;lt;/small&amp;gt;&lt;br /&gt;
| {{{field|ingredients|input type=textarea|rows=10|cols=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! Instructions:&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;Numbered steps, one per line, starting with #&amp;lt;/small&amp;gt;&lt;br /&gt;
| {{{field|method|input type=textarea|rows=10|cols=70}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&#039;&#039;&#039;Edit summary:&#039;&#039;&#039; {{{standard input|summary}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|cancel}}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Form:Ingredient&amp;diff=112</id>
		<title>Form:Ingredient</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Form:Ingredient&amp;diff=112"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Form:Ingredient&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
This is the form for creating an ingredient record. Enter the ingredient&#039;s name below.&lt;br /&gt;
{{#forminput:form=Ingredient|autocomplete on category=Ingredients}}&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
{{{for template|Ingredient}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! Form:&lt;br /&gt;
| {{{field|form|size=40|placeholder=Unsalted, solid}}}&lt;br /&gt;
|-&lt;br /&gt;
! Role in the kitchen:&lt;br /&gt;
| {{{field|role|size=40|placeholder=Fat · structure, flavor, creaming}}}&lt;br /&gt;
|-&lt;br /&gt;
! Storage:&lt;br /&gt;
| {{{field|storage|size=40}}}&lt;br /&gt;
|-&lt;br /&gt;
! Season:&lt;br /&gt;
| {{{field|season|size=40}}}&lt;br /&gt;
|-&lt;br /&gt;
! kcal per 100 g:&lt;br /&gt;
| {{{field|kcal100|size=6}}}&lt;br /&gt;
|-&lt;br /&gt;
! Grams per teaspoon / tablespoon / cup / each:&lt;br /&gt;
| {{{field|g_tsp|size=5}}} {{{field|g_tbsp|size=5}}} {{{field|g_cup|size=5}}} {{{field|g_each|size=5}}}&lt;br /&gt;
|-&lt;br /&gt;
! Allergens (comma-separated):&lt;br /&gt;
| {{{field|allergens|size=40|placeholder=dairy}}}&lt;br /&gt;
|-&lt;br /&gt;
! Pantry staple (left off shopping lists)?&lt;br /&gt;
| {{{field|pantry|input type=dropdown|values=no,yes}}}&lt;br /&gt;
|-&lt;br /&gt;
! Typical price and unit:&lt;br /&gt;
| ${{{field|price|size=6}}} per {{{field|price_per|size=10|placeholder=1 lb}}}&lt;br /&gt;
|-&lt;br /&gt;
! Description:&lt;br /&gt;
| {{{field|summary|input type=textarea|rows=5|cols=70}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
{{{standard input|free text|rows=8|cols=70}}}&lt;br /&gt;
&amp;lt;small&amp;gt;Add substitution rules in the free-text area with [[Template:Substitution]].&amp;lt;/small&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Edit summary:&#039;&#039;&#039; {{{standard input|summary}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|cancel}}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=MediaWiki:Print.css&amp;diff=111</id>
		<title>MediaWiki:Print.css</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=MediaWiki:Print.css&amp;diff=111"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;MediaWiki:Print.css&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Cookbook print layout: one recipe per page */&lt;br /&gt;
.rp-page { page-break-after: always; break-after: page; border: 0; box-shadow: none; min-height: 0; padding: 0; margin: 0; background: #fff; color: #000; }&lt;br /&gt;
.rp-cover { background: #2e3a2a !important; color: #f3efd8 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; min-height: 90vh; }&lt;br /&gt;
.rp-book-cols { display: grid; grid-template-columns: 1fr 1.4fr; gap: 24px; }&lt;br /&gt;
.rp-infobox { float: none; width: auto; }&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=MediaWiki:Common.css&amp;diff=110</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=MediaWiki:Common.css&amp;diff=110"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;MediaWiki:Common.css&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Recipedius styling */&lt;br /&gt;
.rp-infobox { float: right; clear: right; margin: 0 0 1em 1.5em; width: 300px; font-size: 0.92em; }&lt;br /&gt;
.rp-infobox caption { font-weight: bold; font-size: 1.1em; padding: 0.4em; }&lt;br /&gt;
.rp-infobox th { text-align: left; width: 38%; }&lt;br /&gt;
.rp-lede { font-size: 1.15em; margin: 0.5em 0 1em; max-width: 65ch; }&lt;br /&gt;
.rp-qty { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em; white-space: nowrap; font-variant-numeric: tabular-nums; }&lt;br /&gt;
.rp-was { text-decoration: line-through; color: #72777d; }&lt;br /&gt;
.rp-prep { color: #54595d; }&lt;br /&gt;
.rp-role { color: #54595d; font-size: 0.9em; }&lt;br /&gt;
.rp-warn { color: #b32424; }&lt;br /&gt;
.rp-note { color: #54595d; max-width: 70ch; }&lt;br /&gt;
.rp-role-tag { color: #6b4fbb; }&lt;br /&gt;
.rp-missing { background: #fef6e7; border-left: 4px solid #edab00; padding: 0.5em 1em; margin: 0.8em 0; }&lt;br /&gt;
.rp-missing-mark { color: #b32424; }&lt;br /&gt;
.rp-conf { white-space: nowrap; font-size: 0.85em; letter-spacing: 0.02em; }&lt;br /&gt;
.rp-conf-high { color: #14866d; }&lt;br /&gt;
.rp-conf-medium { color: #ac6600; }&lt;br /&gt;
.rp-conf-low { color: #b32424; }&lt;br /&gt;
.rp-ingredients, .rp-subs, .rp-sale, .rp-rules { width: 100%; }&lt;br /&gt;
.rp-subs td:nth-child(3) { white-space: normal; width: 18%; }&lt;br /&gt;
.rp-subs td:nth-child(4) { width: 46%; }&lt;br /&gt;
.rp-subs .rp-qty, .rp-rules .rp-qty { white-space: normal; }&lt;br /&gt;
&lt;br /&gt;
/* Main Page actions */&lt;br /&gt;
.rp-main-layout { display: grid; grid-template-columns: minmax(11rem, 0.28fr) minmax(0, 1fr); gap: 1.5rem; align-items: start; }&lt;br /&gt;
.rp-main-actions { background: #f8f9fa; border: 1px solid #c8ccd1; padding: 1rem; }&lt;br /&gt;
.rp-main-actions h2 { border: 0; font-size: 1.1em; margin: 0 0 0.65rem; padding: 0; }&lt;br /&gt;
.rp-main-actions ul { list-style: none; margin: 0; }&lt;br /&gt;
.rp-main-actions li + li { margin-top: 0.5rem; }&lt;br /&gt;
.rp-main-actions a { font-weight: bold; }&lt;br /&gt;
&lt;br /&gt;
/* Cookbook */&lt;br /&gt;
.rp-book { max-width: 820px; }&lt;br /&gt;
.rp-page { background: #fbfaf4; color: #22251c; border: 1px solid #ddd; padding: 40px 48px; margin: 0 0 18px; min-height: 420px; position: relative; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }&lt;br /&gt;
.rp-cover { background: #2e3a2a; color: #f3efd8; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; min-height: 380px; }&lt;br /&gt;
.rp-orn { width: 44px; height: 44px; border-radius: 50%; border: 2px solid #e2a600; display: grid; place-items: center; font-size: 1.2em; }&lt;br /&gt;
.rp-cover-title { font-size: 2.4em; font-weight: bold; line-height: 1.05; }&lt;br /&gt;
.rp-cover-sub { font-style: italic; opacity: 0.8; }&lt;br /&gt;
.rp-dedication { display: flex; align-items: center; justify-content: center; text-align: center; font-style: italic; font-size: 1.2em; }&lt;br /&gt;
.rp-chapter { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }&lt;br /&gt;
.rp-chapter-title { font-size: 2em; font-weight: bold; }&lt;br /&gt;
.rp-chap-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.12em; color: #7a7c6e; margin-bottom: 10px; }&lt;br /&gt;
.rp-recipe-title { font-size: 1.7em; font-weight: bold; margin-bottom: 4px; }&lt;br /&gt;
.rp-toc-chapter { font-weight: bold; margin-top: 10px; }&lt;br /&gt;
.rp-toc-row { display: flex; justify-content: space-between; border-bottom: 1px dotted #c9c7b4; padding: 3px 0; }&lt;br /&gt;
.rp-book-cols { display: grid; grid-template-columns: 1fr 1.4fr; gap: 28px; margin-top: 14px; }&lt;br /&gt;
.rp-book-ing { display: grid; grid-template-columns: 5em 1fr; gap: 8px; padding: 2px 0; font-size: 0.95em; }&lt;br /&gt;
.rp-book-method ol { margin-left: 1.2em; }&lt;br /&gt;
.rp-book-foot { margin-top: 18px; font-size: 0.8em; color: #7a7c6e; border-top: 1px solid #ddd; padding-top: 8px; }&lt;br /&gt;
@media (max-width: 720px) { .rp-book-cols, .rp-main-layout { grid-template-columns: 1fr; } .rp-infobox { float: none; width: auto; margin: 0 0 1em; } }&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Technique/testcases&amp;diff=109</id>
		<title>Template:Technique/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Technique/testcases&amp;diff=109"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Technique/testcases&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Complete parameters ==&lt;br /&gt;
&lt;br /&gt;
{{Technique/sandbox&lt;br /&gt;
|summary=Whisking oil gradually into an acid-based liquid so it stays suspended.&lt;br /&gt;
|purpose=Create a stable, glossy dressing or sauce.&lt;br /&gt;
|time=2–5 minutes&lt;br /&gt;
|heat=No heat&lt;br /&gt;
|equipment=[[Whisk]], [[Mixing bowl]]&lt;br /&gt;
|ingredients=[[Olive oil]], [[Lemon]]&lt;br /&gt;
|related=[[Lemon vinaigrette]]&lt;br /&gt;
|safety=Keep cold emulsions refrigerated.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Required parameter only ==&lt;br /&gt;
&lt;br /&gt;
{{Technique/sandbox&lt;br /&gt;
|summary=Fold a light ingredient into a heavier mixture while preserving as much air as possible.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The partial example must render only its title and Summary row; no empty&lt;br /&gt;
optional rows should appear.&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Recipe/testcases&amp;diff=108</id>
		<title>Template:Recipe/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Recipe/testcases&amp;diff=108"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Recipe/testcases&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cases for [[Template:Recipe/sandbox]]. Each one states what it is meant to&lt;br /&gt;
show. Check them after any change to the recipe template or to&lt;br /&gt;
[[Module:Recipedius]], and before that change is committed for deployment.&lt;br /&gt;
&lt;br /&gt;
== Preferred parameter names ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|tags=dessert, baking&lt;br /&gt;
|contributor=Testcases&lt;br /&gt;
|description=Written with the preferred &amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructions&amp;lt;/code&amp;gt; names.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming; softened&lt;br /&gt;
Granulated sugar; 1/2; cup; sweet&lt;br /&gt;
All-purpose flour; 2; cup; structure&lt;br /&gt;
|instructions=&lt;br /&gt;
# Beat the butter and sugar until pale.&lt;br /&gt;
# Stir in the flour.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render Description, Ingredients and Instructions in that order, with the&lt;br /&gt;
substitution section after Instructions.&lt;br /&gt;
&lt;br /&gt;
== Legacy parameter names ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|contributor=Testcases&lt;br /&gt;
|lede=Written with the legacy &amp;lt;code&amp;gt;lede&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;method&amp;lt;/code&amp;gt; names.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming&lt;br /&gt;
|method=&lt;br /&gt;
# Melt the butter.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render identically in structure to the case above. Existing pages and the&lt;br /&gt;
Page Forms still use these names.&lt;br /&gt;
&lt;br /&gt;
== Preferred name wins over legacy ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=1&lt;br /&gt;
|description=This preferred description must be the one shown.&lt;br /&gt;
|lede=This legacy lede must not appear.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; tbsp; melted&lt;br /&gt;
|instructions=&lt;br /&gt;
# These preferred instructions must be the ones shown.&lt;br /&gt;
|method=&lt;br /&gt;
# This legacy method must not appear.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Ingredient link resolution ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=2&lt;br /&gt;
|description=Three kinds of ingredient line.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming&lt;br /&gt;
Classic shortbread; 1; each; inclusion&lt;br /&gt;
Nonexistent ingredient page; 1; cup; structure&lt;br /&gt;
|instructions=&lt;br /&gt;
# Nothing to do.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The first line names an ingredient record and must be a plain internal link&lt;br /&gt;
that contributes calories and allergens. The second names an existing recipe&lt;br /&gt;
and must also be a plain internal link, with no missing marker. The third names&lt;br /&gt;
a page that does not exist: it must be a red link, carry the missing marker,&lt;br /&gt;
and appear in the missing-nutrition warning above.&lt;br /&gt;
&lt;br /&gt;
== No description and no instructions ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=1&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; tbsp&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Both headings must still appear, each with its placeholder text, so the page&lt;br /&gt;
shape is predictable while a recipe is being written.&lt;br /&gt;
&lt;br /&gt;
== Empty call ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox}}&lt;br /&gt;
&lt;br /&gt;
Must not raise a script error.&lt;br /&gt;
&amp;lt;noinclude&amp;gt;[[Category:Recipedius templates]]&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Ingredient/testcases&amp;diff=107</id>
		<title>Template:Ingredient/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Ingredient/testcases&amp;diff=107"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Ingredient/testcases&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cases for [[Template:Ingredient/sandbox]]. Each one states what it is meant to&lt;br /&gt;
show. Check them after any change to the ingredient template, and before that&lt;br /&gt;
change is committed for deployment.&lt;br /&gt;
&lt;br /&gt;
The sandbox does not write Cargo rows, so the &amp;quot;Used in&amp;quot; and &amp;quot;Can stand in for&amp;quot;&lt;br /&gt;
sections below query against this page&#039;s title and will be empty. That is&lt;br /&gt;
expected here and is not a failure.&lt;br /&gt;
&lt;br /&gt;
== Preferred parameter name ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|form=Unsalted, solid&lt;br /&gt;
|role=Fat · structure, flavor&lt;br /&gt;
|storage=Fridge, 1–3 months&lt;br /&gt;
|season=Year-round&lt;br /&gt;
|kcal100=717&lt;br /&gt;
|g_tsp=4.7 |g_tbsp=14.2 |g_cup=227&lt;br /&gt;
|allergens=dairy&lt;br /&gt;
|pantry=no&lt;br /&gt;
|price=4.49 |price_per=1 lb&lt;br /&gt;
|description=Written with the preferred &amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; name.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must show the infobox, a Description section carrying that text, and a Find out&lt;br /&gt;
more section with both search links. Per-teaspoon, per-tablespoon and per-cup&lt;br /&gt;
calories must be computed; the Each row must not appear.&lt;br /&gt;
&lt;br /&gt;
== Legacy parameter name ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|form=Large, whole&lt;br /&gt;
|role=Bind&lt;br /&gt;
|kcal100=143&lt;br /&gt;
|g_each=50&lt;br /&gt;
|allergens=egg&lt;br /&gt;
|summary=Written with the legacy &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt; name.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render the description from &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt;, so existing pages and&lt;br /&gt;
the Page Forms keep working. Only the Each row should appear in the nutrition&lt;br /&gt;
block.&lt;br /&gt;
&lt;br /&gt;
== Preferred name wins over legacy ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|kcal100=100&lt;br /&gt;
|description=This preferred description must be the one shown.&lt;br /&gt;
|summary=This legacy summary must not appear.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== No description ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|form=Fine, white&lt;br /&gt;
|kcal100=387&lt;br /&gt;
|g_cup=200&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The Description heading must still appear with its placeholder text, and the&lt;br /&gt;
allergens row must read that none are flagged.&lt;br /&gt;
&lt;br /&gt;
== Multiword title encoding ==&lt;br /&gt;
&lt;br /&gt;
The search links are built from &amp;lt;code&amp;gt;{{PAGENAME}}&amp;lt;/code&amp;gt;, which on this page is&lt;br /&gt;
&amp;lt;code&amp;gt;{{PAGENAME}}&amp;lt;/code&amp;gt;. On a page such as [[All-purpose flour]] both links&lt;br /&gt;
must carry the whole name as a single URL-encoded query value, so check them&lt;br /&gt;
there as well as here.&lt;br /&gt;
&lt;br /&gt;
== Empty call ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox}}&lt;br /&gt;
&lt;br /&gt;
Must not raise an error, and must not show calorie rows for units it has no&lt;br /&gt;
weight for.&lt;br /&gt;
&amp;lt;noinclude&amp;gt;[[Category:Recipedius templates]]&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Technique/sandbox&amp;diff=106</id>
		<title>Template:Technique/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Technique/sandbox&amp;diff=106"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Technique/sandbox&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Technique sandbox ==&lt;br /&gt;
&lt;br /&gt;
Development copy of [[Template:Technique]]. Test changes at&lt;br /&gt;
[[Template:Technique/testcases]] before copying an approved revision to the&lt;br /&gt;
production template.&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{| class=&amp;quot;wikitable rp-infobox&amp;quot;&lt;br /&gt;
|+ {{PAGENAME}}&lt;br /&gt;
|-&lt;br /&gt;
! Summary&lt;br /&gt;
| {{{summary|}}}&lt;br /&gt;
{{#if:{{{purpose|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Purpose&lt;br /&gt;
{{!}} {{{purpose}}}}}&lt;br /&gt;
{{#if:{{{time|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Typical time&lt;br /&gt;
{{!}} {{{time}}}}}&lt;br /&gt;
{{#if:{{{heat|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Heat&lt;br /&gt;
{{!}} {{{heat}}}}}&lt;br /&gt;
{{#if:{{{equipment|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Equipment&lt;br /&gt;
{{!}} {{{equipment}}}}}&lt;br /&gt;
{{#if:{{{ingredients|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Ingredients&lt;br /&gt;
{{!}} {{{ingredients}}}}}&lt;br /&gt;
{{#if:{{{related|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Related pages&lt;br /&gt;
{{!}} {{{related}}}}}&lt;br /&gt;
{{#if:{{{safety|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Safety&lt;br /&gt;
{{!}} {{{safety}}}}}&lt;br /&gt;
|}&lt;br /&gt;
[[Category:Techniques]]&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Technique&amp;diff=105</id>
		<title>Template:Technique</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Technique&amp;diff=105"/>
		<updated>2026-09-23T19:28:25Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Technique&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Technique ==&lt;br /&gt;
&lt;br /&gt;
Use this template once at the top of an article about a cooking technique. The&lt;br /&gt;
article body below the template holds the procedure, variations, examples, and&lt;br /&gt;
citations. Link ingredients, equipment, and related pages with ordinary&lt;br /&gt;
wikitext when a corresponding Recipedius page exists.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{{Technique&lt;br /&gt;
|summary=Whisking oil gradually into an acid-based liquid so it stays suspended.&lt;br /&gt;
|purpose=Create a stable, glossy dressing or sauce.&lt;br /&gt;
|time=2–5 minutes&lt;br /&gt;
|equipment=[[Whisk]], [[Mixing bowl]]&lt;br /&gt;
|ingredients=[[Olive oil]], [[Lemon]]&lt;br /&gt;
|related=[[Lemon vinaigrette]]&lt;br /&gt;
|safety=Keep cold emulsions refrigerated; discard leftovers that have stood at room temperature.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Procedure ==&lt;br /&gt;
# Add the acid and seasoning to a bowl.&lt;br /&gt;
# Whisk while adding the oil in a thin stream.&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Only &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt; is required. Omit any optional field that does not&lt;br /&gt;
apply; empty rows do not render. This template stores no Cargo data.&lt;br /&gt;
&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{| class=&amp;quot;wikitable rp-infobox&amp;quot;&lt;br /&gt;
|+ {{PAGENAME}}&lt;br /&gt;
|-&lt;br /&gt;
! Summary&lt;br /&gt;
| {{{summary|}}}&lt;br /&gt;
{{#if:{{{purpose|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Purpose&lt;br /&gt;
{{!}} {{{purpose}}}}}&lt;br /&gt;
{{#if:{{{time|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Typical time&lt;br /&gt;
{{!}} {{{time}}}}}&lt;br /&gt;
{{#if:{{{heat|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Heat&lt;br /&gt;
{{!}} {{{heat}}}}}&lt;br /&gt;
{{#if:{{{equipment|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Equipment&lt;br /&gt;
{{!}} {{{equipment}}}}}&lt;br /&gt;
{{#if:{{{ingredients|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Ingredients&lt;br /&gt;
{{!}} {{{ingredients}}}}}&lt;br /&gt;
{{#if:{{{related|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Related pages&lt;br /&gt;
{{!}} {{{related}}}}}&lt;br /&gt;
{{#if:{{{safety|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Safety&lt;br /&gt;
{{!}} {{{safety}}}}}&lt;br /&gt;
|}&lt;br /&gt;
[[Category:Techniques]]&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Substitution&amp;diff=104</id>
		<title>Template:Substitution</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Substitution&amp;diff=104"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Substitution&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
{{#cargo_declare:_table=Substitutions&lt;br /&gt;
|sub_from=Page&lt;br /&gt;
|sub_to=Page&lt;br /&gt;
|alt=String&lt;br /&gt;
|role=String&lt;br /&gt;
|ratio=Float&lt;br /&gt;
|conv=String&lt;br /&gt;
|confidence=String&lt;br /&gt;
|effect=Text&lt;br /&gt;
|warning=Text&lt;br /&gt;
|applicable=String&lt;br /&gt;
}}&lt;br /&gt;
== Substitution rule ==&lt;br /&gt;
Place these on the page of the ingredient being replaced, inside a table. Each call is one row: one option, for all roles (leave &amp;lt;code&amp;gt;role&amp;lt;/code&amp;gt; blank) or for one specific job the ingredient does in a recipe (&amp;lt;code&amp;gt;role=creaming&amp;lt;/code&amp;gt;). A role row overrides the blank row&#039;s fields for that role only; set &amp;lt;code&amp;gt;applicable=no&amp;lt;/code&amp;gt; on a role row to remove the option for that role.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{| class=&amp;quot;wikitable rp-rules&amp;quot;&lt;br /&gt;
! Use instead !! When its job is !! How much !! Confidence !! What changes&lt;br /&gt;
{{Substitution|to=Margarine|ratio=1|confidence=high|effect=Nearly identical result.}}&lt;br /&gt;
{{Substitution|to=Olive oil|ratio=0.75|confidence=medium|effect=Use ¾ the amount.|warning=Oil cannot be creamed.}}&lt;br /&gt;
{{Substitution|to=Olive oil|role=melted|confidence=high|effect=Use ¾ the amount; mix with the wet ingredients.}}&lt;br /&gt;
{{Substitution|to=Olive oil|role=dressing|applicable=no}}&lt;br /&gt;
|}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
Fields: &amp;lt;code&amp;gt;to&amp;lt;/code&amp;gt; (ingredient page), &amp;lt;code&amp;gt;alt&amp;lt;/code&amp;gt; (a variant name, e.g. &amp;quot;Bottled lemon juice&amp;quot;), &amp;lt;code&amp;gt;role&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ratio&amp;lt;/code&amp;gt; (multiply the original quantity), &amp;lt;code&amp;gt;conv&amp;lt;/code&amp;gt; (free-text conversion when a ratio isn&#039;t enough, e.g. &amp;quot;1 tbsp per egg + 3 tbsp water&amp;quot;), &amp;lt;code&amp;gt;confidence&amp;lt;/code&amp;gt; (high / medium / low), &amp;lt;code&amp;gt;effect&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;warning&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;applicable&amp;lt;/code&amp;gt;.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{{#cargo_store:_table=Substitutions&lt;br /&gt;
|sub_from={{PAGENAME}}&lt;br /&gt;
|sub_to={{{to|}}}&lt;br /&gt;
|alt={{{alt|}}}&lt;br /&gt;
|role={{{role|}}}&lt;br /&gt;
|ratio={{{ratio|}}}&lt;br /&gt;
|conv={{{conv|}}}&lt;br /&gt;
|confidence={{{confidence|}}}&lt;br /&gt;
|effect={{{effect|}}}&lt;br /&gt;
|warning={{{warning|}}}&lt;br /&gt;
|applicable={{{applicable|}}}&lt;br /&gt;
}}&lt;br /&gt;
|-&lt;br /&gt;
| {{#if:{{{alt|}}}|{{{alt}}} ([[{{{to|}}}]])|[[{{{to|}}}]]}}&lt;br /&gt;
| {{#if:{{{role|}}}|{{{role}}}|&#039;&#039;any&#039;&#039;}}&lt;br /&gt;
| {{#ifeq:{{{applicable|}}}|no|&#039;&#039;not applicable&#039;&#039;|{{#if:{{{conv|}}}|{{{conv}}}|{{#if:{{{ratio|}}}|× {{{ratio}}}|&#039;&#039;as base rule&#039;&#039;}}}}}}&lt;br /&gt;
| {{{confidence|}}}&lt;br /&gt;
| {{{effect|}}}{{#if:{{{warning|}}}|&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ {{{warning}}}&amp;lt;/span&amp;gt;}}&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:SaleItem&amp;diff=103</id>
		<title>Template:SaleItem</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:SaleItem&amp;diff=103"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:SaleItem&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
{{#cargo_declare:_table=SaleItems&lt;br /&gt;
|ingredient=Page&lt;br /&gt;
|price=Float&lt;br /&gt;
|was=Float&lt;br /&gt;
|per=String&lt;br /&gt;
|store=String&lt;br /&gt;
|week=Date&lt;br /&gt;
}}&lt;br /&gt;
== Sale item ==&lt;br /&gt;
One row per promoted ingredient, on a page named &amp;lt;code&amp;gt;Sale/Store name&amp;lt;/code&amp;gt;. The [[This week&#039;s sale]] page ranks recipes by these rows.&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Ingredient !! Sale price !! Was !! Per&lt;br /&gt;
{{SaleItem|ingredient=Lemon|price=0.39|was=0.89|per=each|store=Oak &amp;amp; Fair Market|week=2026-09-07}}&lt;br /&gt;
|}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{{#cargo_store:_table=SaleItems&lt;br /&gt;
|ingredient={{{ingredient|}}}&lt;br /&gt;
|price={{{price|}}}&lt;br /&gt;
|was={{{was|}}}&lt;br /&gt;
|per={{{per|}}}&lt;br /&gt;
|store={{{store|}}}&lt;br /&gt;
|week={{{week|}}}&lt;br /&gt;
}}&lt;br /&gt;
|-&lt;br /&gt;
| [[{{{ingredient|}}}]]&lt;br /&gt;
| class=&amp;quot;rp-qty&amp;quot; | ${{{price|}}}&lt;br /&gt;
| class=&amp;quot;rp-qty rp-was&amp;quot; | ${{{was|}}}&lt;br /&gt;
| {{{per|}}}&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:RecipeIngredient&amp;diff=102</id>
		<title>Template:RecipeIngredient</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:RecipeIngredient&amp;diff=102"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:RecipeIngredient&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
{{#cargo_declare:_table=RecipeIngredients&lt;br /&gt;
|recipe=Page&lt;br /&gt;
|ingredient=Page&lt;br /&gt;
|qty=Float&lt;br /&gt;
|unit=String&lt;br /&gt;
|role=String&lt;br /&gt;
|prep=String&lt;br /&gt;
|pos=Integer&lt;br /&gt;
}}&lt;br /&gt;
This template only declares the &amp;lt;code&amp;gt;RecipeIngredients&amp;lt;/code&amp;gt; table (one row per ingredient line of every recipe). Rows are written by [[Module:Recipedius]] when a page uses [[Template:Recipe]]; you never call this template directly.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Recipe/sandbox&amp;diff=101</id>
		<title>Template:Recipe/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Recipe/sandbox&amp;diff=101"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Recipe/sandbox&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Recipe sandbox ==&lt;br /&gt;
&lt;br /&gt;
Development copy of [[Template:Recipe]]. It invokes&lt;br /&gt;
[[Module:Recipedius/sandbox]], so a module change can be seen rendered at&lt;br /&gt;
[[Template:Recipe/testcases]] before it reaches every recipe on the wiki.&lt;br /&gt;
&lt;br /&gt;
Change the sandbox, check the testcases, then copy the approved revision into&lt;br /&gt;
[[Template:Recipe]] and [[Module:Recipedius]].&lt;br /&gt;
&lt;br /&gt;
This copy deliberately omits the &amp;lt;code&amp;gt;#cargo_declare&amp;lt;/code&amp;gt; and&lt;br /&gt;
&amp;lt;code&amp;gt;#cargo_attach&amp;lt;/code&amp;gt; declarations that [[Template:Recipe]] carries. Two&lt;br /&gt;
templates must not declare the same Cargo table.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{{#invoke:Recipedius/sandbox|recipe}}&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Recipe&amp;diff=100</id>
		<title>Template:Recipe</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Recipe&amp;diff=100"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Recipe&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
{{#cargo_declare:_table=Recipes&lt;br /&gt;
|serves=Integer&lt;br /&gt;
|time=String&lt;br /&gt;
|tags=List (,) of String&lt;br /&gt;
|contributor=String&lt;br /&gt;
|lede=Text&lt;br /&gt;
|method=Text&lt;br /&gt;
|kcal_total=Float&lt;br /&gt;
|allergens=List (,) of String&lt;br /&gt;
|ingredient_count=Integer&lt;br /&gt;
}}&lt;br /&gt;
{{#cargo_attach:_table=RecipeIngredients}}&lt;br /&gt;
== Recipe ==&lt;br /&gt;
One page per recipe. The reader-facing sections are Description, Ingredients,&lt;br /&gt;
and Instructions. Ingredient lines are written one per line as&lt;br /&gt;
&amp;lt;code&amp;gt;page title; quantity; unit; role; preparation&amp;lt;/code&amp;gt;. The page title can&lt;br /&gt;
name an [[:Category:Ingredients|ingredient record]] or another Recipe, such as&lt;br /&gt;
&amp;lt;code&amp;gt;Simple syrup&amp;lt;/code&amp;gt;. Every title is an internal wiki link; a red link is&lt;br /&gt;
valid while the target is being created. Only Ingredient records participate in&lt;br /&gt;
nutrition and substitution calculations.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{{Recipe&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|tags=dessert, baking, cookie&lt;br /&gt;
|contributor=Marta O.&lt;br /&gt;
|description=Three ingredients and a hot oven.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming; softened&lt;br /&gt;
Granulated sugar; 1/2; cup; sweet&lt;br /&gt;
All-purpose flour; 2; cup; structure&lt;br /&gt;
Kosher salt; 1/4; tsp; season&lt;br /&gt;
|instructions=&lt;br /&gt;
# Beat the butter and sugar until pale.&lt;br /&gt;
# Stir in the flour and salt.&lt;br /&gt;
# Bake at 325°F for 30 minutes.&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructions&amp;lt;/code&amp;gt; are the preferred&lt;br /&gt;
parameter names. The existing &amp;lt;code&amp;gt;lede&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;method&amp;lt;/code&amp;gt; names&lt;br /&gt;
remain supported for existing pages and forms. When both are supplied, the&lt;br /&gt;
preferred name wins.&lt;br /&gt;
&lt;br /&gt;
The computed &amp;lt;code&amp;gt;Substitutions for this recipe&amp;lt;/code&amp;gt; section, when present,&lt;br /&gt;
is supplemental content after Instructions.&lt;br /&gt;
Units the calculator understands (quantities may be decimals, fractions like 1/2, or mixed like 1 1/2): &amp;lt;code&amp;gt;tsp&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;tbsp&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;cup&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;each&amp;lt;/code&amp;gt;. Roles in use: creaming, melted, cutin, cooking, dressing, leaven, bind, liquid, sweet, structure, finish, juice, zest, aroma, season, inclusion, protein, starch, moisture, body, leaven-dry.&lt;br /&gt;
Test any change at [[Template:Recipe/testcases]] through [[Template:Recipe/sandbox]] before deploying it.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{{#invoke:Recipedius|recipe}}&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Ingredient/sandbox&amp;diff=99</id>
		<title>Template:Ingredient/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Ingredient/sandbox&amp;diff=99"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Ingredient/sandbox&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Ingredient sandbox ==&lt;br /&gt;
&lt;br /&gt;
Development copy of [[Template:Ingredient]]. Test changes at&lt;br /&gt;
[[Template:Ingredient/testcases]] before copying an approved revision to the&lt;br /&gt;
production template.&lt;br /&gt;
&lt;br /&gt;
This copy deliberately omits the &amp;lt;code&amp;gt;#cargo_declare&amp;lt;/code&amp;gt; declaration and&lt;br /&gt;
the &amp;lt;code&amp;gt;#cargo_store&amp;lt;/code&amp;gt; call that [[Template:Ingredient]] carries. Two&lt;br /&gt;
templates must not declare the same Cargo table, a testcases page must not&lt;br /&gt;
write rows into the Ingredients table, and it must not file itself under&lt;br /&gt;
[[:Category:Ingredients]] as though it were a real ingredient.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{| class=&amp;quot;wikitable rp-infobox&amp;quot;&lt;br /&gt;
|+ {{PAGENAME}}&lt;br /&gt;
|-&lt;br /&gt;
! Form&lt;br /&gt;
| {{{form|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Role&lt;br /&gt;
| {{{role|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Storage&lt;br /&gt;
| {{{storage|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Season&lt;br /&gt;
| {{{season|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Allergens&lt;br /&gt;
| {{#if:{{{allergens|}}}|{{{allergens}}}|&#039;&#039;none flagged&#039;&#039;}}&lt;br /&gt;
|-&lt;br /&gt;
! Pantry staple&lt;br /&gt;
| {{#ifeq:{{{pantry|no}}}|yes|yes (left off shopping lists)|no}}&lt;br /&gt;
|-&lt;br /&gt;
! Price&lt;br /&gt;
| {{#if:{{{price|}}}|${{{price}}} / {{{price_per|}}}|—}}&lt;br /&gt;
|-&lt;br /&gt;
! colspan=&amp;quot;2&amp;quot; | Nutrition&lt;br /&gt;
{{#if:{{{g_tsp|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Per teaspoon&lt;br /&gt;
{{!}} {{#expr:{{{g_tsp}}}*{{{kcal100|0}}}/100 round 0}} kcal}}&lt;br /&gt;
{{#if:{{{g_tbsp|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Per tablespoon&lt;br /&gt;
{{!}} {{#expr:{{{g_tbsp}}}*{{{kcal100|0}}}/100 round 0}} kcal}}&lt;br /&gt;
{{#if:{{{g_cup|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Per cup&lt;br /&gt;
{{!}} {{#expr:{{{g_cup}}}*{{{kcal100|0}}}/100 round 0}} kcal · {{{g_cup}}} g}}&lt;br /&gt;
{{#if:{{{g_each|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Each&lt;br /&gt;
{{!}} {{#expr:{{{g_each}}}*{{{kcal100|0}}}/100 round 0}} kcal · {{{g_each}}} g}}&lt;br /&gt;
|-&lt;br /&gt;
! Per 100 g&lt;br /&gt;
| {{{kcal100|}}} kcal&lt;br /&gt;
|-&lt;br /&gt;
| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Approximate; enter values from a source you trust.&amp;lt;/small&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Description ==&lt;br /&gt;
{{#if:{{{description|}}}|{{{description}}}|{{#if:{{{summary|}}}|{{{summary}}}|&#039;&#039;No description yet.&#039;&#039;}}}}&lt;br /&gt;
&lt;br /&gt;
== Find out more ==&lt;br /&gt;
* [https://en.wikipedia.org/w/index.php?search={{urlencode:{{PAGENAME}}}} Search Wikipedia for {{PAGENAME}}]&lt;br /&gt;
* [https://fdc.nal.usda.gov/food-search?query={{urlencode:{{PAGENAME}}}} Search USDA FoodData Central for {{PAGENAME}}]&lt;br /&gt;
&lt;br /&gt;
== Used in ==&lt;br /&gt;
{{#cargo_query:tables=RecipeIngredients&lt;br /&gt;
|fields=recipe=Recipe,qty=Qty,unit=Unit,role=Job in the recipe&lt;br /&gt;
|where=ingredient=&#039;{{PAGENAME}}&#039;&lt;br /&gt;
|order by=recipe&lt;br /&gt;
|format=table&lt;br /&gt;
|default=&#039;&#039;No recipes use this yet.&#039;&#039;&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Can stand in for ==&lt;br /&gt;
{{#cargo_query:tables=Substitutions&lt;br /&gt;
|fields=sub_from=Ingredient&lt;br /&gt;
|where=sub_to=&#039;{{PAGENAME}}&#039; AND (role IS NULL OR role=&#039;&#039;)&lt;br /&gt;
|format=list&lt;br /&gt;
|delimiter=, &lt;br /&gt;
|default=&#039;&#039;Nothing yet.&#039;&#039;&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Ingredient&amp;diff=98</id>
		<title>Template:Ingredient</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Ingredient&amp;diff=98"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Ingredient&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
{{#cargo_declare:_table=Ingredients&lt;br /&gt;
|form=String&lt;br /&gt;
|role=String&lt;br /&gt;
|storage=String&lt;br /&gt;
|season=String&lt;br /&gt;
|kcal100=Float&lt;br /&gt;
|g_tsp=Float&lt;br /&gt;
|g_tbsp=Float&lt;br /&gt;
|g_cup=Float&lt;br /&gt;
|g_each=Float&lt;br /&gt;
|allergens=List (,) of String&lt;br /&gt;
|pantry=String&lt;br /&gt;
|price=Float&lt;br /&gt;
|price_per=String&lt;br /&gt;
|summary=Text&lt;br /&gt;
}}&lt;br /&gt;
== Ingredient record ==&lt;br /&gt;
One page per ingredient. Its page title is its Name. The infobox is the record;&lt;br /&gt;
recipes link to it and the [[Module:Recipedius|computed layer]] reads it for&lt;br /&gt;
calories, allergens and substitutions.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{{Ingredient&lt;br /&gt;
|form=Unsalted, solid&lt;br /&gt;
|role=Fat · structure, flavor, creaming&lt;br /&gt;
|storage=Fridge, 1–3 months&lt;br /&gt;
|season=Year-round&lt;br /&gt;
|kcal100=717&lt;br /&gt;
|g_tsp=4.7 |g_tbsp=14.2 |g_cup=227 |g_each=&lt;br /&gt;
|allergens=dairy&lt;br /&gt;
|pantry=no&lt;br /&gt;
|price=4.49 |price_per=1 lb&lt;br /&gt;
|description=A paragraph or two about what this ingredient does in a kitchen and what can stand in for it.&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; is the preferred name for the visible description.&lt;br /&gt;
The existing &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt; name remains supported for existing pages and&lt;br /&gt;
forms. When both are supplied, &amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; wins.&lt;br /&gt;
Then add substitution rules in a table using [[Template:Substitution]]. Grams per unit and kcal per 100 g drive every calculation; enter them from a source you trust and cite it in the summary.&lt;br /&gt;
Test any change at [[Template:Ingredient/testcases]] through [[Template:Ingredient/sandbox]] before deploying it.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{{#cargo_store:_table=Ingredients&lt;br /&gt;
|form={{{form|}}}&lt;br /&gt;
|role={{{role|}}}&lt;br /&gt;
|storage={{{storage|}}}&lt;br /&gt;
|season={{{season|}}}&lt;br /&gt;
|kcal100={{{kcal100|}}}&lt;br /&gt;
|g_tsp={{{g_tsp|}}}&lt;br /&gt;
|g_tbsp={{{g_tbsp|}}}&lt;br /&gt;
|g_cup={{{g_cup|}}}&lt;br /&gt;
|g_each={{{g_each|}}}&lt;br /&gt;
|allergens={{{allergens|}}}&lt;br /&gt;
|pantry={{{pantry|no}}}&lt;br /&gt;
|price={{{price|}}}&lt;br /&gt;
|price_per={{{price_per|}}}&lt;br /&gt;
|summary={{#if:{{{description|}}}|{{{description}}}|{{{summary|}}}}}&lt;br /&gt;
}}{| class=&amp;quot;wikitable rp-infobox&amp;quot;&lt;br /&gt;
|+ {{PAGENAME}}&lt;br /&gt;
|-&lt;br /&gt;
! Form&lt;br /&gt;
| {{{form|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Role&lt;br /&gt;
| {{{role|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Storage&lt;br /&gt;
| {{{storage|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Season&lt;br /&gt;
| {{{season|}}}&lt;br /&gt;
|-&lt;br /&gt;
! Allergens&lt;br /&gt;
| {{#if:{{{allergens|}}}|{{{allergens}}}|&#039;&#039;none flagged&#039;&#039;}}&lt;br /&gt;
|-&lt;br /&gt;
! Pantry staple&lt;br /&gt;
| {{#ifeq:{{{pantry|no}}}|yes|yes (left off shopping lists)|no}}&lt;br /&gt;
|-&lt;br /&gt;
! Price&lt;br /&gt;
| {{#if:{{{price|}}}|${{{price}}} / {{{price_per|}}}|—}}&lt;br /&gt;
|-&lt;br /&gt;
! colspan=&amp;quot;2&amp;quot; | Nutrition&lt;br /&gt;
{{#if:{{{g_tsp|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Per teaspoon&lt;br /&gt;
{{!}} {{#expr:{{{g_tsp}}}*{{{kcal100|0}}}/100 round 0}} kcal}}&lt;br /&gt;
{{#if:{{{g_tbsp|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Per tablespoon&lt;br /&gt;
{{!}} {{#expr:{{{g_tbsp}}}*{{{kcal100|0}}}/100 round 0}} kcal}}&lt;br /&gt;
{{#if:{{{g_cup|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Per cup&lt;br /&gt;
{{!}} {{#expr:{{{g_cup}}}*{{{kcal100|0}}}/100 round 0}} kcal · {{{g_cup}}} g}}&lt;br /&gt;
{{#if:{{{g_each|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Each&lt;br /&gt;
{{!}} {{#expr:{{{g_each}}}*{{{kcal100|0}}}/100 round 0}} kcal · {{{g_each}}} g}}&lt;br /&gt;
|-&lt;br /&gt;
! Per 100 g&lt;br /&gt;
| {{{kcal100|}}} kcal&lt;br /&gt;
|-&lt;br /&gt;
| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Approximate; enter values from a source you trust.&amp;lt;/small&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Description ==&lt;br /&gt;
{{#if:{{{description|}}}|{{{description}}}|{{#if:{{{summary|}}}|{{{summary}}}|&#039;&#039;No description yet.&#039;&#039;}}}}&lt;br /&gt;
&lt;br /&gt;
== Find out more ==&lt;br /&gt;
* [https://en.wikipedia.org/w/index.php?search={{urlencode:{{PAGENAME}}}} Search Wikipedia for {{PAGENAME}}]&lt;br /&gt;
* [https://fdc.nal.usda.gov/food-search?query={{urlencode:{{PAGENAME}}}} Search USDA FoodData Central for {{PAGENAME}}]&lt;br /&gt;
&lt;br /&gt;
== Used in ==&lt;br /&gt;
{{#cargo_query:tables=RecipeIngredients&lt;br /&gt;
|fields=recipe=Recipe,qty=Qty,unit=Unit,role=Job in the recipe&lt;br /&gt;
|where=ingredient=&#039;{{PAGENAME}}&#039;&lt;br /&gt;
|order by=recipe&lt;br /&gt;
|format=table&lt;br /&gt;
|default=&#039;&#039;No recipes use this yet.&#039;&#039;&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Can stand in for ==&lt;br /&gt;
{{#cargo_query:tables=Substitutions&lt;br /&gt;
|fields=sub_from=Ingredient&lt;br /&gt;
|where=sub_to=&#039;{{PAGENAME}}&#039; AND (role IS NULL OR role=&#039;&#039;)&lt;br /&gt;
|format=list&lt;br /&gt;
|delimiter=, &lt;br /&gt;
|default=&#039;&#039;Nothing yet.&#039;&#039;&lt;br /&gt;
}}&lt;br /&gt;
[[Category:Ingredients]]&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Cookbook&amp;diff=97</id>
		<title>Template:Cookbook</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Cookbook&amp;diff=97"/>
		<updated>2026-09-23T19:28:24Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Template:Cookbook&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Cookbook ==&lt;br /&gt;
Assemble any recipes on the wiki into a printable book. Create a page called &amp;lt;code&amp;gt;Cookbook/Your name&amp;lt;/code&amp;gt; and put this on it:&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{{Cookbook&lt;br /&gt;
|title=Our kitchen&lt;br /&gt;
|dedication=For Mom, who never measured anything.&lt;br /&gt;
|recipes=Lemon bars, Classic shortbread, Banana bread&lt;br /&gt;
}}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
The book is rendered from the recipe database, so it always reflects the current version of each recipe. Use your browser&#039;s print function for a PDF; the print stylesheet lays each recipe out as a page. A print-on-demand step (the Collection extension, or an export to a printer) is the next thing to add here.&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{{#invoke:Recipedius|cookbook}}[[Category:Cookbooks]]&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius/sandbox&amp;diff=96</id>
		<title>Module:Recipedius/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius/sandbox&amp;diff=96"/>
		<updated>2026-09-23T19:28:23Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Module:Recipedius/sandbox&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;-- Development copy of [[Module:Recipedius]]. Exercise changes at&lt;br /&gt;
-- [[Template:Recipe/testcases]] through [[Template:Recipe/sandbox]], then copy&lt;br /&gt;
-- an approved revision into the production module.&lt;br /&gt;
--&lt;br /&gt;
-- Keep this a faithful copy. It is the only place a module change can be seen&lt;br /&gt;
-- rendered before it reaches every recipe on the wiki.&lt;br /&gt;
--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;⅛&#039; }, { 0.25, &#039;¼&#039; }, { 1/3, &#039;⅓&#039; }, { 0.375, &#039;⅜&#039; }, { 0.5, &#039;½&#039; },&lt;br /&gt;
  { 0.625, &#039;⅝&#039; }, { 2/3, &#039;⅔&#039; }, { 0.75, &#039;¾&#039; }, { 0.875, &#039;⅞&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = trim(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(missing, &#039;, &#039;) .. &#039;. [[Special:FormEdit/Ingredient|Create one]].&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=95</id>
		<title>Module:Recipedius</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Module:Recipedius&amp;diff=95"/>
		<updated>2026-09-23T19:28:23Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: Protected &amp;quot;Module:Recipedius&amp;quot;: 0.010 T017: configuration page, deployed from version control ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite))&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;--[[&lt;br /&gt;
  Module:Recipedius&lt;br /&gt;
  The computed layer of Recipedius. Reads ingredient records and substitution rules&lt;br /&gt;
  from Cargo tables and renders recipe pages, the weekly sale ranking, and cookbooks.&lt;br /&gt;
&lt;br /&gt;
  Entry points (all via {{#invoke:Recipedius|fn}} from templates):&lt;br /&gt;
    recipe    - renders a recipe from Template:Recipe args and stores its rows&lt;br /&gt;
    sale      - ranks recipes by how many of their shopping items are on sale&lt;br /&gt;
    cookbook  - renders a printable book from a list of recipe pages&lt;br /&gt;
]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- helpers&lt;br /&gt;
&lt;br /&gt;
local FRACTIONS = {&lt;br /&gt;
  { 0.125, &#039;⅛&#039; }, { 0.25, &#039;¼&#039; }, { 1/3, &#039;⅓&#039; }, { 0.375, &#039;⅜&#039; }, { 0.5, &#039;½&#039; },&lt;br /&gt;
  { 0.625, &#039;⅝&#039; }, { 2/3, &#039;⅔&#039; }, { 0.75, &#039;¾&#039; }, { 0.875, &#039;⅞&#039; },&lt;br /&gt;
}&lt;br /&gt;
local FRACTION_CHARS = { [&#039;⅛&#039;]=0.125, [&#039;¼&#039;]=0.25, [&#039;⅓&#039;]=1/3, [&#039;⅜&#039;]=0.375, [&#039;½&#039;]=0.5, [&#039;⅝&#039;]=0.625, [&#039;⅔&#039;]=2/3, [&#039;¾&#039;]=0.75, [&#039;⅞&#039;]=0.875 }&lt;br /&gt;
&lt;br /&gt;
local function trim(s) return mw.text.trim(s or &#039;&#039;) end&lt;br /&gt;
&lt;br /&gt;
-- &amp;quot;1 1/2&amp;quot;, &amp;quot;3/4&amp;quot;, &amp;quot;0.5&amp;quot;, &amp;quot;1 ½&amp;quot; and &amp;quot;¾&amp;quot; all parse.&lt;br /&gt;
local function parseQty(s)&lt;br /&gt;
  s = trim(s)&lt;br /&gt;
  local total, found = 0, false&lt;br /&gt;
  for tok in s:gmatch(&#039;%S+&#039;) do&lt;br /&gt;
    if FRACTION_CHARS[tok] then total = total + FRACTION_CHARS[tok]; found = true&lt;br /&gt;
    else&lt;br /&gt;
      local a, b = tok:match(&#039;^(%d+)/(%d+)$&#039;)&lt;br /&gt;
      if a then total = total + tonumber(a) / tonumber(b); found = true&lt;br /&gt;
      elseif tonumber(tok) then total = total + tonumber(tok); found = true end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  if found then return total end&lt;br /&gt;
  return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function fmtQty(q)&lt;br /&gt;
  if not q then return &#039;&#039; end&lt;br /&gt;
  local w = math.floor(q + 1e-6)&lt;br /&gt;
  local f = q - w&lt;br /&gt;
  if f &amp;gt; 0.94 then w = w + 1; f = 0 end&lt;br /&gt;
  local fs = &#039;&#039;&lt;br /&gt;
  if f &amp;gt; 0.06 then&lt;br /&gt;
    local best, bd = nil, 1&lt;br /&gt;
    for _, pair in ipairs(FRACTIONS) do&lt;br /&gt;
      local d = math.abs(pair[1] - f)&lt;br /&gt;
      if d &amp;lt; bd then bd = d; best = pair[2] end&lt;br /&gt;
    end&lt;br /&gt;
    if bd &amp;lt; 0.06 then fs = best else fs = string.format(&#039;%.2f&#039;, f):gsub(&#039;^0&#039;, &#039;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  if w == 0 then return fs ~= &#039;&#039; and fs or &#039;0&#039; end&lt;br /&gt;
  if fs ~= &#039;&#039; then return w .. &#039; &#039; .. fs end&lt;br /&gt;
  return tostring(w)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local PLURALS = { [&#039;Garlic&#039;] = &#039;Garlic cloves&#039;, [&#039;Whole chicken&#039;] = &#039;Whole chickens&#039;, [&#039;Russet potato&#039;] = &#039;Russet potatoes&#039; }&lt;br /&gt;
-- Display name for a count-based ingredient: &amp;quot;2 eggs&amp;quot;, &amp;quot;3 bananas&amp;quot;, &amp;quot;6 garlic cloves&amp;quot;.&lt;br /&gt;
local function countLabel(name, q, u)&lt;br /&gt;
  if u ~= &#039;each&#039; or not q or q &amp;lt;= 1 then return name end&lt;br /&gt;
  if PLURALS[name] then return PLURALS[name] end&lt;br /&gt;
  if name:sub(-1) == &#039;s&#039; then return name end&lt;br /&gt;
  return name .. &#039;s&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function unitLabel(q, u)&lt;br /&gt;
  if u == &#039;each&#039; or u == &#039;&#039; then return &#039;&#039; end&lt;br /&gt;
  if u == &#039;cup&#039; and q and q &amp;gt; 1 then return &#039;cups&#039; end&lt;br /&gt;
  return u&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sqlEsc(s) return (tostring(s):gsub(&amp;quot;&#039;&amp;quot;, &amp;quot;\\&#039;&amp;quot;)) end&lt;br /&gt;
&lt;br /&gt;
local function splitList(s)&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for item in (s or &#039;&#039;):gmatch(&#039;[^,]+&#039;) do&lt;br /&gt;
    item = trim(item)&lt;br /&gt;
    if item ~= &#039;&#039; then table.insert(out, item) end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function round(x) return math.floor(x + 0.5) end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- data access&lt;br /&gt;
&lt;br /&gt;
local ingredientCache = {}&lt;br /&gt;
local function getIngredient(name)&lt;br /&gt;
  if ingredientCache[name] ~= nil then return ingredientCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Ingredients&#039;,&lt;br /&gt;
    &#039;_pageName=name,kcal100,g_tsp,g_tbsp,g_cup,g_each,allergens,pantry,price,price_per&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  ingredientCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local recipeCache = {}&lt;br /&gt;
local function getRecipe(name)&lt;br /&gt;
  if recipeCache[name] ~= nil then return recipeCache[name] or nil end&lt;br /&gt;
  local rows = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name&#039;,&lt;br /&gt;
    { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, limit = 1 })&lt;br /&gt;
  recipeCache[name] = rows[1] or false&lt;br /&gt;
  return rows[1]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function gramsPer(ing, unit)&lt;br /&gt;
  if not ing then return nil end&lt;br /&gt;
  local key = ({ tsp = &#039;g_tsp&#039;, tbsp = &#039;g_tbsp&#039;, cup = &#039;g_cup&#039;, each = &#039;g_each&#039; })[unit]&lt;br /&gt;
  if not key then return nil end&lt;br /&gt;
  return tonumber(ing[key])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function kcalOf(ing, q, unit)&lt;br /&gt;
  local g = gramsPer(ing, unit)&lt;br /&gt;
  local k = ing and tonumber(ing.kcal100)&lt;br /&gt;
  if not (g and k and q) then return 0 end&lt;br /&gt;
  return q * g * k / 100&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Substitution rules for an ingredient, resolved for the role it plays in this recipe.&lt;br /&gt;
-- A base row (role blank) applies everywhere; a row with a role overrides the base for that role.&lt;br /&gt;
-- An override with applicable=no removes the option for that role.&lt;br /&gt;
local subsCache = {}&lt;br /&gt;
local function rulesFor(name, role)&lt;br /&gt;
  if not subsCache[name] then&lt;br /&gt;
    subsCache[name] = cargo.query(&#039;Substitutions&#039;,&lt;br /&gt;
      &#039;sub_to,alt,role,ratio,conv,confidence,effect,warning,applicable&#039;,&lt;br /&gt;
      { where = &amp;quot;sub_from=&#039;&amp;quot; .. sqlEsc(name) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;sub_to&#039;, limit = 200 })&lt;br /&gt;
  end&lt;br /&gt;
  local groups, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(subsCache[name]) do&lt;br /&gt;
    local key = (r.sub_to or &#039;&#039;) .. &#039;|&#039; .. (r.alt or &#039;&#039;)&lt;br /&gt;
    if not groups[key] then groups[key] = {}; table.insert(order, key) end&lt;br /&gt;
    if trim(r.role) == &#039;&#039; then groups[key].base = r&lt;br /&gt;
    elseif trim(r.role) == role then groups[key].override = r end&lt;br /&gt;
  end&lt;br /&gt;
  local out = {}&lt;br /&gt;
  for _, key in ipairs(order) do&lt;br /&gt;
    local b, o = groups[key].base, groups[key].override&lt;br /&gt;
    local blocked = o and trim(o.applicable):lower() == &#039;no&#039;&lt;br /&gt;
    if (b or o) and not blocked then&lt;br /&gt;
      local m = {}&lt;br /&gt;
      for _, k in ipairs({ &#039;sub_to&#039;, &#039;alt&#039;, &#039;ratio&#039;, &#039;conv&#039;, &#039;confidence&#039;, &#039;effect&#039;, &#039;warning&#039; }) do&lt;br /&gt;
        local ov = o and trim(o[k]) or &#039;&#039;&lt;br /&gt;
        if ov ~= &#039;&#039; then m[k] = ov elseif b then m[k] = trim(b[k]) else m[k] = &#039;&#039; end&lt;br /&gt;
      end&lt;br /&gt;
      if m.warning == &#039;none&#039; then m.warning = &#039;&#039; end&lt;br /&gt;
      m.roleSpecific = o ~= nil&lt;br /&gt;
      table.insert(out, m)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- recipe&lt;br /&gt;
&lt;br /&gt;
-- Ingredient lines are written one per line as:  Ingredient; quantity; unit; role; preparation&lt;br /&gt;
local function parseLines(text)&lt;br /&gt;
  local lines = {}&lt;br /&gt;
  for raw in (text or &#039;&#039;):gmatch(&#039;[^\r\n]+&#039;) do&lt;br /&gt;
    raw = trim(raw)&lt;br /&gt;
    if raw ~= &#039;&#039; and not raw:match(&#039;^&amp;lt;!%-%-&#039;) then&lt;br /&gt;
      local parts = mw.text.split(raw, &#039;;&#039;, true)&lt;br /&gt;
      local name = trim(parts[1] or &#039;&#039;)&lt;br /&gt;
      if name ~= &#039;&#039; then&lt;br /&gt;
        table.insert(lines, {&lt;br /&gt;
          name = name,&lt;br /&gt;
          qty = parseQty(parts[2] or &#039;&#039;),&lt;br /&gt;
          unit = trim(parts[3] or &#039;&#039;),&lt;br /&gt;
          role = trim(parts[4] or &#039;&#039;),&lt;br /&gt;
          prep = trim(parts[5] or &#039;&#039;),&lt;br /&gt;
        })&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function resolvedArg(args, preferred, legacy)&lt;br /&gt;
  local value = trim(args[preferred])&lt;br /&gt;
  if value ~= &#039;&#039; then return value end&lt;br /&gt;
  return trim(args[legacy])&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local CONF_DOTS = { high = &#039;●●●&#039;, medium = &#039;●●○&#039;, low = &#039;●○○&#039; }&lt;br /&gt;
local function confBadge(c)&lt;br /&gt;
  c = trim(c):lower()&lt;br /&gt;
  return &#039;&amp;lt;span class=&amp;quot;rp-conf rp-conf-&#039; .. c .. &#039;&amp;quot; title=&amp;quot;confidence: &#039; .. c .. &#039;&amp;quot;&amp;gt;&#039; .. (CONF_DOTS[c] or &#039;○○○&#039;) .. &#039; &#039; .. c .. &#039;&amp;lt;/span&amp;gt;&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.recipe(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local title = mw.title.getCurrentTitle()&lt;br /&gt;
  local page = title.prefixedText&lt;br /&gt;
  local serves = tonumber(args.serves) or 1&lt;br /&gt;
  local description = resolvedArg(args, &#039;description&#039;, &#039;lede&#039;)&lt;br /&gt;
  local instructions = resolvedArg(args, &#039;instructions&#039;, &#039;method&#039;)&lt;br /&gt;
  local lines = parseLines(args.ingredients)&lt;br /&gt;
&lt;br /&gt;
  -- totals from the ingredient records&lt;br /&gt;
  local kcalTotal, allergens, seen, missing = 0, {}, {}, {}&lt;br /&gt;
  for _, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    if ing then&lt;br /&gt;
      kcalTotal = kcalTotal + kcalOf(ing, l.qty, l.unit)&lt;br /&gt;
      for _, a in ipairs(splitList(ing.allergens)) do&lt;br /&gt;
        if not seen[a] then seen[a] = true; table.insert(allergens, a) end&lt;br /&gt;
      end&lt;br /&gt;
    elseif not getRecipe(l.name) then&lt;br /&gt;
      table.insert(missing, l.name)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  -- infobox&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-infobox&amp;quot;\n|+ &#039; .. page)&lt;br /&gt;
  add(&#039;|-\n! Serves\n| &#039; .. serves)&lt;br /&gt;
  if trim(args.time) ~= &#039;&#039; then add(&#039;|-\n! Time\n| &#039; .. trim(args.time)) end&lt;br /&gt;
  add(&#039;|-\n! Per serving\n| &amp;lt;b&amp;gt;&#039; .. round(kcalTotal / serves) .. &#039; kcal&amp;lt;/b&amp;gt; &amp;lt;small&amp;gt;(&#039; .. round(kcalTotal) .. &#039; for the batch)&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|-\n! Contains\n| &#039; .. (#allergens &amp;gt; 0 and table.concat(allergens, &#039;, &#039;) or &amp;quot;&#039;&#039;nothing flagged&#039;&#039;&amp;quot;))&lt;br /&gt;
  if trim(args.contributor) ~= &#039;&#039; then add(&#039;|-\n! Contributed by\n| &#039; .. trim(args.contributor)) end&lt;br /&gt;
  if trim(args.tags) ~= &#039;&#039; then&lt;br /&gt;
    local tagLinks = {}&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(tagLinks, &#039;[[:Category:&#039; .. t .. &#039;|&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(&#039;|-\n! Tags\n| &#039; .. table.concat(tagLinks, &#039; · &#039;))&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|-\n| colspan=&amp;quot;2&amp;quot; | &amp;lt;small&amp;gt;Calories are computed from the [[:Category:Ingredients|ingredient records]]. Approximate.&amp;lt;/small&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Description ==&#039;)&lt;br /&gt;
  if description ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. description .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No description yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  if #missing &amp;gt; 0 then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;These ingredients have no record yet, so they are left out of the nutrition and substitution tables: &#039; ..&lt;br /&gt;
      table.concat(missing, &#039;, &#039;) .. &#039;. [[Special:FormEdit/Ingredient|Create one]].&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- ingredients table&lt;br /&gt;
  add(&#039;== Ingredients ==&#039;)&lt;br /&gt;
  add(&#039;{| class=&amp;quot;wikitable rp-ingredients&amp;quot;\n! Quantity !! Ingredient !! Its job here !! Swaps&#039;)&lt;br /&gt;
  local swapSections = {}&lt;br /&gt;
  for i, l in ipairs(lines) do&lt;br /&gt;
    local ing = getIngredient(l.name)&lt;br /&gt;
    local shown = countLabel(l.name, l.qty, l.unit)&lt;br /&gt;
    local isRecipe = not ing and getRecipe(l.name)&lt;br /&gt;
    local link = (ing or isRecipe) and (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&#039;) or (&#039;[[&#039; .. l.name .. &#039;|&#039; .. shown .. &#039;]]&amp;lt;sup class=&amp;quot;rp-missing-mark&amp;quot;&amp;gt;?&amp;lt;/sup&amp;gt;&#039;)&lt;br /&gt;
    local qty = fmtQty(l.qty) .. &#039; &#039; .. unitLabel(l.qty, l.unit)&lt;br /&gt;
    local prep = l.prep ~= &#039;&#039; and (&#039;, &amp;lt;span class=&amp;quot;rp-prep&amp;quot;&amp;gt;&#039; .. l.prep .. &#039;&amp;lt;/span&amp;gt;&#039;) or &#039;&#039;&lt;br /&gt;
    local rules = ing and rulesFor(l.name, l.role) or {}&lt;br /&gt;
    local swapCell = &amp;quot;&#039;&#039;none&#039;&#039;&amp;quot;&lt;br /&gt;
    if #rules &amp;gt; 0 then&lt;br /&gt;
      swapCell = &#039;[[#Substitutions for this recipe|&#039; .. #rules .. (#rules == 1 and &#039; option&#039; or &#039; options&#039;) .. &#039;]]&#039;&lt;br /&gt;
      table.insert(swapSections, { line = l, rules = rules })&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|-\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. trim(qty) .. &#039;\n| &#039; .. link .. prep .. &#039;\n| class=&amp;quot;rp-role&amp;quot; | &#039; .. l.role .. &#039;\n| &#039; .. swapCell)&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;|}&#039;)&lt;br /&gt;
&lt;br /&gt;
  add(&#039;== Instructions ==&#039;)&lt;br /&gt;
  if instructions ~= &#039;&#039; then add(instructions)&lt;br /&gt;
  else add(&amp;quot;&#039;&#039;No instructions yet.&#039;&#039;&amp;quot;) end&lt;br /&gt;
&lt;br /&gt;
  -- substitutions resolved for this recipe&lt;br /&gt;
  if #swapSections &amp;gt; 0 then&lt;br /&gt;
    add(&#039;== Substitutions for this recipe ==&#039;)&lt;br /&gt;
    add(&#039;&amp;lt;p class=&amp;quot;rp-note&amp;quot;&amp;gt;Rules come from each ingredient\&#039;s record and are resolved for the job the ingredient does in this recipe. Confidence is the community\&#039;s, not a guarantee.&amp;lt;/p&amp;gt;&#039;)&lt;br /&gt;
    add(&#039;{| class=&amp;quot;wikitable rp-subs&amp;quot;\n! Instead of !! Use !! How much !! What changes !! Confidence&#039;)&lt;br /&gt;
    for _, s in ipairs(swapSections) do&lt;br /&gt;
      for _, r in ipairs(s.rules) do&lt;br /&gt;
        local useName = r.alt ~= &#039;&#039; and (r.alt .. &#039; ([[&#039; .. r.sub_to .. &#039;]])&#039;) or (&#039;[[&#039; .. r.sub_to .. &#039;]]&#039;)&lt;br /&gt;
        local howMuch&lt;br /&gt;
        if r.conv ~= &#039;&#039; then howMuch = r.conv&lt;br /&gt;
        else&lt;br /&gt;
          local ratio = tonumber(r.ratio) or 1&lt;br /&gt;
          local nq = s.line.qty and (s.line.qty * ratio) or nil&lt;br /&gt;
          howMuch = nq and (fmtQty(nq) .. &#039; &#039; .. unitLabel(nq, s.line.unit) .. &#039; &amp;lt;small&amp;gt;(× &#039; .. ratio .. &#039;)&amp;lt;/small&amp;gt;&#039;) or (&#039;× &#039; .. ratio)&lt;br /&gt;
        end&lt;br /&gt;
        local what = r.effect&lt;br /&gt;
        if r.warning ~= &#039;&#039; then what = what .. &#039;&amp;lt;br /&amp;gt;&amp;lt;span class=&amp;quot;rp-warn&amp;quot;&amp;gt;⚠ &#039; .. r.warning .. &#039;&amp;lt;/span&amp;gt;&#039; end&lt;br /&gt;
        if r.roleSpecific then what = what .. &#039; &amp;lt;small class=&amp;quot;rp-role-tag&amp;quot;&amp;gt;rule for: &#039; .. s.line.role .. &#039;&amp;lt;/small&amp;gt;&#039; end&lt;br /&gt;
        add(&#039;|-\n| [[&#039; .. s.line.name .. &#039;]]\n| &#039; .. useName .. &#039;\n| class=&amp;quot;rp-qty&amp;quot; | &#039; .. howMuch .. &#039;\n| &#039; .. what .. &#039;\n| &#039; .. confBadge(r.confidence))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
    add(&#039;|}&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- store rows (skipped when the page is being previewed inside another page)&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    for i, l in ipairs(lines) do&lt;br /&gt;
      frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
        &#039;_table=RecipeIngredients&#039;, recipe = page, ingredient = l.name,&lt;br /&gt;
        qty = l.qty and tostring(l.qty) or &#039;&#039;, unit = l.unit, role = l.role, prep = l.prep, pos = tostring(i),&lt;br /&gt;
      })&lt;br /&gt;
    end&lt;br /&gt;
    frame:callParserFunction(&#039;#cargo_store&#039;, {&lt;br /&gt;
      &#039;_table=Recipes&#039;, serves = tostring(serves), time = trim(args.time), tags = trim(args.tags),&lt;br /&gt;
      contributor = trim(args.contributor), lede = description, method = instructions,&lt;br /&gt;
      kcal_total = tostring(round(kcalTotal)), allergens = table.concat(allergens, &#039;,&#039;), ingredient_count = tostring(#lines),&lt;br /&gt;
    })&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- Categories belong to real recipe pages. Without this guard a /testcases&lt;br /&gt;
  -- page that exercises the template would file itself under Category:Recipes&lt;br /&gt;
  -- and every tag category it demonstrates.&lt;br /&gt;
  if title.namespace == 0 then&lt;br /&gt;
    local cats = { &#039;[[Category:Recipes]]&#039; }&lt;br /&gt;
    for _, t in ipairs(splitList(args.tags)) do table.insert(cats, &#039;[[Category:&#039; .. t .. &#039;]]&#039;) end&lt;br /&gt;
    add(table.concat(cats, &#039;&#039;))&lt;br /&gt;
  end&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- sale&lt;br /&gt;
&lt;br /&gt;
function p.sale(frame)&lt;br /&gt;
  local args = frame.args&lt;br /&gt;
  local store = trim(args.store)&lt;br /&gt;
  local where = store ~= &#039;&#039; and (&amp;quot;store=&#039;&amp;quot; .. sqlEsc(store) .. &amp;quot;&#039;&amp;quot;) or nil&lt;br /&gt;
  local sale = cargo.query(&#039;SaleItems&#039;, &#039;ingredient,price,was,per,store&#039;, { where = where, limit = 200 })&lt;br /&gt;
  if #sale == 0 then return &amp;quot;&#039;&#039;No sale items have been entered yet. Add them with [[Template:SaleItem]].&#039;&#039;&amp;quot; end&lt;br /&gt;
  local onSale = {}&lt;br /&gt;
  for _, s in ipairs(sale) do onSale[s.ingredient] = s end&lt;br /&gt;
&lt;br /&gt;
  local rows = cargo.query(&#039;RecipeIngredients=RI,Ingredients=I&#039;, &#039;RI.recipe=recipe,RI.ingredient=ingredient,I.pantry=pantry&#039;,&lt;br /&gt;
    { join = &#039;RI.ingredient=I._pageName&#039;, limit = 2000 })&lt;br /&gt;
  local recipes, order = {}, {}&lt;br /&gt;
  for _, r in ipairs(rows) do&lt;br /&gt;
    if not recipes[r.recipe] then recipes[r.recipe] = { hits = {}, toBuy = 0, save = 0 }; table.insert(order, r.recipe) end&lt;br /&gt;
    local rec = recipes[r.recipe]&lt;br /&gt;
    local pantry = trim(r.pantry):lower() == &#039;yes&#039;&lt;br /&gt;
    if not pantry then&lt;br /&gt;
      rec.toBuy = rec.toBuy + 1&lt;br /&gt;
      local s = onSale[r.ingredient]&lt;br /&gt;
      if s then&lt;br /&gt;
        table.insert(rec.hits, r.ingredient)&lt;br /&gt;
        rec.save = rec.save + ((tonumber(s.was) or 0) - (tonumber(s.price) or 0))&lt;br /&gt;
      end&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  local ranked = {}&lt;br /&gt;
  for _, name in ipairs(order) do&lt;br /&gt;
    local rec = recipes[name]&lt;br /&gt;
    if #rec.hits &amp;gt; 0 then rec.name = name; table.insert(ranked, rec) end&lt;br /&gt;
  end&lt;br /&gt;
  table.sort(ranked, function(a, b)&lt;br /&gt;
    if #a.hits ~= #b.hits then return #a.hits &amp;gt; #b.hits end&lt;br /&gt;
    return (#a.hits / math.max(1, a.toBuy)) &amp;gt; (#b.hits / math.max(1, b.toBuy))&lt;br /&gt;
  end)&lt;br /&gt;
&lt;br /&gt;
  local out = { &#039;{| class=&amp;quot;wikitable rp-sale&amp;quot;\n! Recipe !! On sale !! Shopping list !! You save&#039; }&lt;br /&gt;
  for _, rec in ipairs(ranked) do&lt;br /&gt;
    local links = {}&lt;br /&gt;
    for _, h in ipairs(rec.hits) do table.insert(links, &#039;[[&#039; .. h .. &#039;]]&#039;) end&lt;br /&gt;
    table.insert(out, &#039;|-\n| [[&#039; .. rec.name .. &#039;]]\n| &#039; .. table.concat(links, &#039;, &#039;) .. &#039;\n| &#039; .. #rec.hits .. &#039; of &#039; .. rec.toBuy ..&lt;br /&gt;
      &#039; items &amp;lt;small&amp;gt;(pantry staples excluded)&amp;lt;/small&amp;gt;\n| class=&amp;quot;rp-qty&amp;quot; | $&#039; .. string.format(&#039;%.2f&#039;, rec.save))&lt;br /&gt;
  end&lt;br /&gt;
  table.insert(out, &#039;|}&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- ---------------------------------------------------------------- cookbook&lt;br /&gt;
&lt;br /&gt;
local function chapterFor(tags)&lt;br /&gt;
  local t = {}&lt;br /&gt;
  for _, x in ipairs(splitList(tags)) do t[x:lower()] = true end&lt;br /&gt;
  if t[&#039;breakfast&#039;] then return &#039;Mornings&#039; end&lt;br /&gt;
  if t[&#039;dessert&#039;] then return &#039;Sweets&#039; end&lt;br /&gt;
  if t[&#039;dinner&#039;] or t[&#039;side&#039;] or t[&#039;soup&#039;] then return &#039;Suppers and sides&#039; end&lt;br /&gt;
  return &#039;Pantry and dressings&#039;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.cookbook(frame)&lt;br /&gt;
  local args = frame:getParent().args&lt;br /&gt;
  local names = splitList(args.recipes)&lt;br /&gt;
  local title = trim(args.title) ~= &#039;&#039; and trim(args.title) or &#039;Our kitchen&#039;&lt;br /&gt;
  local out = {}&lt;br /&gt;
  local function add(s) table.insert(out, s) end&lt;br /&gt;
&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-book&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-cover&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-orn&amp;quot;&amp;gt;✦&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-title&amp;quot;&amp;gt;&#039; .. title .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-cover-sub&amp;quot;&amp;gt;&#039; ..&lt;br /&gt;
    #names .. (#names == 1 and &#039; recipe&#039; or &#039; recipes&#039;) .. &#039; · assembled on Recipedius&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  if trim(args.dedication) ~= &#039;&#039; then&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-dedication&amp;quot;&amp;gt;&#039; .. trim(args.dedication) .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- gather&lt;br /&gt;
  local recs, chapters, chapterOrder = {}, {}, {}&lt;br /&gt;
  for _, n in ipairs(names) do&lt;br /&gt;
    local r = cargo.query(&#039;Recipes&#039;, &#039;_pageName=name,serves,time,tags,contributor,lede,method&#039;,&lt;br /&gt;
      { where = &amp;quot;_pageName=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, limit = 1 })[1]&lt;br /&gt;
    if r then&lt;br /&gt;
      r.lines = cargo.query(&#039;RecipeIngredients&#039;, &#039;ingredient,qty,unit,prep,pos&#039;,&lt;br /&gt;
        { where = &amp;quot;recipe=&#039;&amp;quot; .. sqlEsc(n) .. &amp;quot;&#039;&amp;quot;, orderBy = &#039;pos&#039;, limit = 100 })&lt;br /&gt;
      local ch = chapterFor(r.tags)&lt;br /&gt;
      if not chapters[ch] then chapters[ch] = {}; table.insert(chapterOrder, ch) end&lt;br /&gt;
      table.insert(chapters[ch], r)&lt;br /&gt;
    else&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-missing&amp;quot;&amp;gt;No recipe page called &amp;quot;&#039; .. n .. &#039;&amp;quot; was found.&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
&lt;br /&gt;
  -- contents&lt;br /&gt;
  add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-toc&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Contents&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-toc-chapter&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do add(&#039;&amp;lt;div class=&amp;quot;rp-toc-row&amp;quot;&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. r.time .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;) end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
&lt;br /&gt;
  for _, ch in ipairs(chapterOrder) do&lt;br /&gt;
    add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-chapter&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;Chapter&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-chapter-title&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    for _, r in ipairs(chapters[ch]) do&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-page rp-recipe-page&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-chap-label&amp;quot;&amp;gt;&#039; .. ch .. &#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-recipe-title&amp;quot;&amp;gt;&#039; .. r.name .. &#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      if trim(r.lede) ~= &#039;&#039; then add(&amp;quot;&amp;lt;div class=\&amp;quot;rp-lede\&amp;quot;&amp;gt;&#039;&#039;&amp;quot; .. r.lede .. &amp;quot;&#039;&#039;&amp;lt;/div&amp;gt;&amp;quot;) end&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-cols&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;rp-book-ings&amp;quot;&amp;gt;&#039;)&lt;br /&gt;
      for _, l in ipairs(r.lines) do&lt;br /&gt;
        local q = tonumber(l.qty)&lt;br /&gt;
        add(&#039;&amp;lt;div class=&amp;quot;rp-book-ing&amp;quot;&amp;gt;&amp;lt;span class=&amp;quot;rp-qty&amp;quot;&amp;gt;&#039; .. fmtQty(q) .. &#039; &#039; .. unitLabel(q, l.unit) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&#039; .. countLabel(l.ingredient, q, l.unit) ..&lt;br /&gt;
          (trim(l.prep) ~= &#039;&#039; and (&#039;, &#039; .. l.prep) or &#039;&#039;) .. &#039;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      end&lt;br /&gt;
      add(&#039;&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;rp-book-method&amp;quot;&amp;gt;\n&#039; .. (r.method or &#039;&#039;) .. &#039;\n&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
      add(&#039;&amp;lt;div class=&amp;quot;rp-book-foot&amp;quot;&amp;gt;Serves &#039; .. r.serves .. &#039; · &#039; .. r.time .. (trim(r.contributor) ~= &#039;&#039; and (&#039; · contributed by &#039; .. r.contributor) or &#039;&#039;) .. &#039;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
    end&lt;br /&gt;
  end&lt;br /&gt;
  add(&#039;&amp;lt;/div&amp;gt;&#039;)&lt;br /&gt;
  return table.concat(out, &#039;\n&#039;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Main_Page&amp;diff=94</id>
		<title>Main Page</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Main_Page&amp;diff=94"/>
		<updated>2026-09-23T19:26:06Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div class=&amp;quot;rp-main-layout&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-main-actions&amp;quot;&amp;gt;&lt;br /&gt;
== Add to Recipedius ==&lt;br /&gt;
* [[Special:FormEdit/Recipe|Add recipe]]&lt;br /&gt;
* [[Special:FormEdit/Ingredient|Add ingredient]]&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-main-content&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div class=&amp;quot;rp-lede&amp;quot;&amp;gt;&#039;&#039;&#039;Recipedius&#039;&#039;&#039; is an encyclopedia of what&#039;s in your kitchen. Every recipe is built from ingredient records, not text: swap the butter and the calories, allergens and shopping list follow. Put lemons on sale and the recipes that use them float to the top. Pick your favorites and turn them into a printed book.&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Start here:&#039;&#039;&#039; open [[Classic shortbread]] and look at the substitution table, then [[Banana bread]]; the same olive-oil swap is low-confidence in one and high in the other, because the butter&#039;s job is different. That is the whole idea in one click. Then see [[This week&#039;s sale]] and [[Cookbook/Sample]].&lt;br /&gt;
&lt;br /&gt;
== Recipes ==&lt;br /&gt;
{{#cargo_query:tables=Recipes&lt;br /&gt;
|fields=_pageName=Recipe,serves=Serves,time=Time,ROUND(kcal_total/serves)=kcal per serving,allergens=Contains,contributor=Contributed by&lt;br /&gt;
|order by=_pageName&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;br /&gt;
&#039;&#039;Add one: [[Special:FormEdit/Recipe|create a recipe with the form]], or make a page and use [[Template:Recipe]].&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Ingredient records ==&lt;br /&gt;
{{#cargo_query:tables=Ingredients&lt;br /&gt;
|fields=_pageName=Ingredient,role=Role,ROUND(g_tbsp*kcal100/100)=kcal per tbsp,allergens=Allergens&lt;br /&gt;
|order by=_pageName&lt;br /&gt;
|format=table&lt;br /&gt;
}}&lt;br /&gt;
&#039;&#039;Add one: [[Special:FormEdit/Ingredient|create an ingredient record with the form]].&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Elsewhere ==&lt;br /&gt;
* [[This week&#039;s sale]] — recipes ranked by what&#039;s cheap at an example store&lt;br /&gt;
* [[Cookbook/Sample]] — a printable book assembled from the recipe database&lt;br /&gt;
* [[Special:CargoTables]] — the database behind the pages&lt;br /&gt;
* [[Recipedius:About]] — what this is, what&#039;s real and what&#039;s still to build&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Category:Techniques&amp;diff=93</id>
		<title>Category:Techniques</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Category:Techniques&amp;diff=93"/>
		<updated>2026-09-23T19:26:06Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cooking methods, preparations, and foundational skills. Add a page with&lt;br /&gt;
[[Template:Technique]] to include it here.&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Form:Recipe&amp;diff=92</id>
		<title>Form:Recipe</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Form:Recipe&amp;diff=92"/>
		<updated>2026-09-23T19:26:05Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
This is the form for creating a recipe. Enter a title below.&lt;br /&gt;
{{#forminput:form=Recipe|autocomplete on category=Recipes}}&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
{{{for template|Recipe}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! Serves:&lt;br /&gt;
| {{{field|serves|input type=text|size=4}}}&lt;br /&gt;
|-&lt;br /&gt;
! Time:&lt;br /&gt;
| {{{field|time|size=20|placeholder=50 min}}}&lt;br /&gt;
|-&lt;br /&gt;
! Tags (comma-separated):&lt;br /&gt;
| {{{field|tags|size=40|placeholder=dessert, baking, cookie}}}&lt;br /&gt;
|-&lt;br /&gt;
! Contributed by:&lt;br /&gt;
| {{{field|contributor|size=30}}}&lt;br /&gt;
|-&lt;br /&gt;
! Description:&lt;br /&gt;
| {{{field|lede|input type=textarea|rows=2|cols=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! Ingredients:&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;One per line: Ingredient; quantity; unit; role; preparation&amp;lt;br /&amp;gt;e.g. Butter; 1; cup; creaming; softened&amp;lt;/small&amp;gt;&lt;br /&gt;
| {{{field|ingredients|input type=textarea|rows=10|cols=70}}}&lt;br /&gt;
|-&lt;br /&gt;
! Instructions:&amp;lt;br /&amp;gt;&amp;lt;small&amp;gt;Numbered steps, one per line, starting with #&amp;lt;/small&amp;gt;&lt;br /&gt;
| {{{field|method|input type=textarea|rows=10|cols=70}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
&#039;&#039;&#039;Edit summary:&#039;&#039;&#039; {{{standard input|summary}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|cancel}}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Form:Ingredient&amp;diff=91</id>
		<title>Form:Ingredient</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Form:Ingredient&amp;diff=91"/>
		<updated>2026-09-23T19:26:05Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
This is the form for creating an ingredient record. Enter the ingredient&#039;s name below.&lt;br /&gt;
{{#forminput:form=Ingredient|autocomplete on category=Ingredients}}&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;&lt;br /&gt;
{{{for template|Ingredient}}}&lt;br /&gt;
{| class=&amp;quot;formtable&amp;quot;&lt;br /&gt;
! Form:&lt;br /&gt;
| {{{field|form|size=40|placeholder=Unsalted, solid}}}&lt;br /&gt;
|-&lt;br /&gt;
! Role in the kitchen:&lt;br /&gt;
| {{{field|role|size=40|placeholder=Fat · structure, flavor, creaming}}}&lt;br /&gt;
|-&lt;br /&gt;
! Storage:&lt;br /&gt;
| {{{field|storage|size=40}}}&lt;br /&gt;
|-&lt;br /&gt;
! Season:&lt;br /&gt;
| {{{field|season|size=40}}}&lt;br /&gt;
|-&lt;br /&gt;
! kcal per 100 g:&lt;br /&gt;
| {{{field|kcal100|size=6}}}&lt;br /&gt;
|-&lt;br /&gt;
! Grams per teaspoon / tablespoon / cup / each:&lt;br /&gt;
| {{{field|g_tsp|size=5}}} {{{field|g_tbsp|size=5}}} {{{field|g_cup|size=5}}} {{{field|g_each|size=5}}}&lt;br /&gt;
|-&lt;br /&gt;
! Allergens (comma-separated):&lt;br /&gt;
| {{{field|allergens|size=40|placeholder=dairy}}}&lt;br /&gt;
|-&lt;br /&gt;
! Pantry staple (left off shopping lists)?&lt;br /&gt;
| {{{field|pantry|input type=dropdown|values=no,yes}}}&lt;br /&gt;
|-&lt;br /&gt;
! Typical price and unit:&lt;br /&gt;
| ${{{field|price|size=6}}} per {{{field|price_per|size=10|placeholder=1 lb}}}&lt;br /&gt;
|-&lt;br /&gt;
! Description:&lt;br /&gt;
| {{{field|summary|input type=textarea|rows=5|cols=70}}}&lt;br /&gt;
|}&lt;br /&gt;
{{{end template}}}&lt;br /&gt;
{{{standard input|free text|rows=8|cols=70}}}&lt;br /&gt;
&amp;lt;small&amp;gt;Add substitution rules in the free-text area with [[Template:Substitution]].&amp;lt;/small&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Edit summary:&#039;&#039;&#039; {{{standard input|summary}}}&lt;br /&gt;
&lt;br /&gt;
{{{standard input|save}}} {{{standard input|preview}}} {{{standard input|cancel}}}&lt;br /&gt;
&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=MediaWiki:Common.css&amp;diff=90</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=MediaWiki:Common.css&amp;diff=90"/>
		<updated>2026-09-23T19:26:05Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Recipedius styling */&lt;br /&gt;
.rp-infobox { float: right; clear: right; margin: 0 0 1em 1.5em; width: 300px; font-size: 0.92em; }&lt;br /&gt;
.rp-infobox caption { font-weight: bold; font-size: 1.1em; padding: 0.4em; }&lt;br /&gt;
.rp-infobox th { text-align: left; width: 38%; }&lt;br /&gt;
.rp-lede { font-size: 1.15em; margin: 0.5em 0 1em; max-width: 65ch; }&lt;br /&gt;
.rp-qty { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em; white-space: nowrap; font-variant-numeric: tabular-nums; }&lt;br /&gt;
.rp-was { text-decoration: line-through; color: #72777d; }&lt;br /&gt;
.rp-prep { color: #54595d; }&lt;br /&gt;
.rp-role { color: #54595d; font-size: 0.9em; }&lt;br /&gt;
.rp-warn { color: #b32424; }&lt;br /&gt;
.rp-note { color: #54595d; max-width: 70ch; }&lt;br /&gt;
.rp-role-tag { color: #6b4fbb; }&lt;br /&gt;
.rp-missing { background: #fef6e7; border-left: 4px solid #edab00; padding: 0.5em 1em; margin: 0.8em 0; }&lt;br /&gt;
.rp-missing-mark { color: #b32424; }&lt;br /&gt;
.rp-conf { white-space: nowrap; font-size: 0.85em; letter-spacing: 0.02em; }&lt;br /&gt;
.rp-conf-high { color: #14866d; }&lt;br /&gt;
.rp-conf-medium { color: #ac6600; }&lt;br /&gt;
.rp-conf-low { color: #b32424; }&lt;br /&gt;
.rp-ingredients, .rp-subs, .rp-sale, .rp-rules { width: 100%; }&lt;br /&gt;
.rp-subs td:nth-child(3) { white-space: normal; width: 18%; }&lt;br /&gt;
.rp-subs td:nth-child(4) { width: 46%; }&lt;br /&gt;
.rp-subs .rp-qty, .rp-rules .rp-qty { white-space: normal; }&lt;br /&gt;
&lt;br /&gt;
/* Main Page actions */&lt;br /&gt;
.rp-main-layout { display: grid; grid-template-columns: minmax(11rem, 0.28fr) minmax(0, 1fr); gap: 1.5rem; align-items: start; }&lt;br /&gt;
.rp-main-actions { background: #f8f9fa; border: 1px solid #c8ccd1; padding: 1rem; }&lt;br /&gt;
.rp-main-actions h2 { border: 0; font-size: 1.1em; margin: 0 0 0.65rem; padding: 0; }&lt;br /&gt;
.rp-main-actions ul { list-style: none; margin: 0; }&lt;br /&gt;
.rp-main-actions li + li { margin-top: 0.5rem; }&lt;br /&gt;
.rp-main-actions a { font-weight: bold; }&lt;br /&gt;
&lt;br /&gt;
/* Cookbook */&lt;br /&gt;
.rp-book { max-width: 820px; }&lt;br /&gt;
.rp-page { background: #fbfaf4; color: #22251c; border: 1px solid #ddd; padding: 40px 48px; margin: 0 0 18px; min-height: 420px; position: relative; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }&lt;br /&gt;
.rp-cover { background: #2e3a2a; color: #f3efd8; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; min-height: 380px; }&lt;br /&gt;
.rp-orn { width: 44px; height: 44px; border-radius: 50%; border: 2px solid #e2a600; display: grid; place-items: center; font-size: 1.2em; }&lt;br /&gt;
.rp-cover-title { font-size: 2.4em; font-weight: bold; line-height: 1.05; }&lt;br /&gt;
.rp-cover-sub { font-style: italic; opacity: 0.8; }&lt;br /&gt;
.rp-dedication { display: flex; align-items: center; justify-content: center; text-align: center; font-style: italic; font-size: 1.2em; }&lt;br /&gt;
.rp-chapter { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }&lt;br /&gt;
.rp-chapter-title { font-size: 2em; font-weight: bold; }&lt;br /&gt;
.rp-chap-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.12em; color: #7a7c6e; margin-bottom: 10px; }&lt;br /&gt;
.rp-recipe-title { font-size: 1.7em; font-weight: bold; margin-bottom: 4px; }&lt;br /&gt;
.rp-toc-chapter { font-weight: bold; margin-top: 10px; }&lt;br /&gt;
.rp-toc-row { display: flex; justify-content: space-between; border-bottom: 1px dotted #c9c7b4; padding: 3px 0; }&lt;br /&gt;
.rp-book-cols { display: grid; grid-template-columns: 1fr 1.4fr; gap: 28px; margin-top: 14px; }&lt;br /&gt;
.rp-book-ing { display: grid; grid-template-columns: 5em 1fr; gap: 8px; padding: 2px 0; font-size: 0.95em; }&lt;br /&gt;
.rp-book-method ol { margin-left: 1.2em; }&lt;br /&gt;
.rp-book-foot { margin-top: 18px; font-size: 0.8em; color: #7a7c6e; border-top: 1px solid #ddd; padding-top: 8px; }&lt;br /&gt;
@media (max-width: 720px) { .rp-book-cols, .rp-main-layout { grid-template-columns: 1fr; } .rp-infobox { float: none; width: auto; margin: 0 0 1em; } }&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Technique/testcases&amp;diff=89</id>
		<title>Template:Technique/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Technique/testcases&amp;diff=89"/>
		<updated>2026-09-23T19:25:46Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;== Complete parameters ==&lt;br /&gt;
&lt;br /&gt;
{{Technique/sandbox&lt;br /&gt;
|summary=Whisking oil gradually into an acid-based liquid so it stays suspended.&lt;br /&gt;
|purpose=Create a stable, glossy dressing or sauce.&lt;br /&gt;
|time=2–5 minutes&lt;br /&gt;
|heat=No heat&lt;br /&gt;
|equipment=[[Whisk]], [[Mixing bowl]]&lt;br /&gt;
|ingredients=[[Olive oil]], [[Lemon]]&lt;br /&gt;
|related=[[Lemon vinaigrette]]&lt;br /&gt;
|safety=Keep cold emulsions refrigerated.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Required parameter only ==&lt;br /&gt;
&lt;br /&gt;
{{Technique/sandbox&lt;br /&gt;
|summary=Fold a light ingredient into a heavier mixture while preserving as much air as possible.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The partial example must render only its title and Summary row; no empty&lt;br /&gt;
optional rows should appear.&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Recipe/testcases&amp;diff=88</id>
		<title>Template:Recipe/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Recipe/testcases&amp;diff=88"/>
		<updated>2026-09-23T19:25:46Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cases for [[Template:Recipe/sandbox]]. Each one states what it is meant to&lt;br /&gt;
show. Check them after any change to the recipe template or to&lt;br /&gt;
[[Module:Recipedius]], and before that change is committed for deployment.&lt;br /&gt;
&lt;br /&gt;
== Preferred parameter names ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|tags=dessert, baking&lt;br /&gt;
|contributor=Testcases&lt;br /&gt;
|description=Written with the preferred &amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;instructions&amp;lt;/code&amp;gt; names.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming; softened&lt;br /&gt;
Granulated sugar; 1/2; cup; sweet&lt;br /&gt;
All-purpose flour; 2; cup; structure&lt;br /&gt;
|instructions=&lt;br /&gt;
# Beat the butter and sugar until pale.&lt;br /&gt;
# Stir in the flour.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render Description, Ingredients and Instructions in that order, with the&lt;br /&gt;
substitution section after Instructions.&lt;br /&gt;
&lt;br /&gt;
== Legacy parameter names ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=24&lt;br /&gt;
|time=50 min&lt;br /&gt;
|contributor=Testcases&lt;br /&gt;
|lede=Written with the legacy &amp;lt;code&amp;gt;lede&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;method&amp;lt;/code&amp;gt; names.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming&lt;br /&gt;
|method=&lt;br /&gt;
# Melt the butter.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render identically in structure to the case above. Existing pages and the&lt;br /&gt;
Page Forms still use these names.&lt;br /&gt;
&lt;br /&gt;
== Preferred name wins over legacy ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=1&lt;br /&gt;
|description=This preferred description must be the one shown.&lt;br /&gt;
|lede=This legacy lede must not appear.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; tbsp; melted&lt;br /&gt;
|instructions=&lt;br /&gt;
# These preferred instructions must be the ones shown.&lt;br /&gt;
|method=&lt;br /&gt;
# This legacy method must not appear.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Ingredient link resolution ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=2&lt;br /&gt;
|description=Three kinds of ingredient line.&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; cup; creaming&lt;br /&gt;
Classic shortbread; 1; each; inclusion&lt;br /&gt;
Nonexistent ingredient page; 1; cup; structure&lt;br /&gt;
|instructions=&lt;br /&gt;
# Nothing to do.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The first line names an ingredient record and must be a plain internal link&lt;br /&gt;
that contributes calories and allergens. The second names an existing recipe&lt;br /&gt;
and must also be a plain internal link, with no missing marker. The third names&lt;br /&gt;
a page that does not exist: it must be a red link, carry the missing marker,&lt;br /&gt;
and appear in the missing-nutrition warning above.&lt;br /&gt;
&lt;br /&gt;
== No description and no instructions ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox&lt;br /&gt;
|serves=1&lt;br /&gt;
|ingredients=&lt;br /&gt;
Butter; 1; tbsp&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Both headings must still appear, each with its placeholder text, so the page&lt;br /&gt;
shape is predictable while a recipe is being written.&lt;br /&gt;
&lt;br /&gt;
== Empty call ==&lt;br /&gt;
&lt;br /&gt;
{{Recipe/sandbox}}&lt;br /&gt;
&lt;br /&gt;
Must not raise a script error.&lt;br /&gt;
&amp;lt;noinclude&amp;gt;[[Category:Recipedius templates]]&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Ingredient/testcases&amp;diff=87</id>
		<title>Template:Ingredient/testcases</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Ingredient/testcases&amp;diff=87"/>
		<updated>2026-09-23T19:25:45Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Cases for [[Template:Ingredient/sandbox]]. Each one states what it is meant to&lt;br /&gt;
show. Check them after any change to the ingredient template, and before that&lt;br /&gt;
change is committed for deployment.&lt;br /&gt;
&lt;br /&gt;
The sandbox does not write Cargo rows, so the &amp;quot;Used in&amp;quot; and &amp;quot;Can stand in for&amp;quot;&lt;br /&gt;
sections below query against this page&#039;s title and will be empty. That is&lt;br /&gt;
expected here and is not a failure.&lt;br /&gt;
&lt;br /&gt;
== Preferred parameter name ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|form=Unsalted, solid&lt;br /&gt;
|role=Fat · structure, flavor&lt;br /&gt;
|storage=Fridge, 1–3 months&lt;br /&gt;
|season=Year-round&lt;br /&gt;
|kcal100=717&lt;br /&gt;
|g_tsp=4.7 |g_tbsp=14.2 |g_cup=227&lt;br /&gt;
|allergens=dairy&lt;br /&gt;
|pantry=no&lt;br /&gt;
|price=4.49 |price_per=1 lb&lt;br /&gt;
|description=Written with the preferred &amp;lt;code&amp;gt;description&amp;lt;/code&amp;gt; name.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must show the infobox, a Description section carrying that text, and a Find out&lt;br /&gt;
more section with both search links. Per-teaspoon, per-tablespoon and per-cup&lt;br /&gt;
calories must be computed; the Each row must not appear.&lt;br /&gt;
&lt;br /&gt;
== Legacy parameter name ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|form=Large, whole&lt;br /&gt;
|role=Bind&lt;br /&gt;
|kcal100=143&lt;br /&gt;
|g_each=50&lt;br /&gt;
|allergens=egg&lt;br /&gt;
|summary=Written with the legacy &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt; name.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Must render the description from &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt;, so existing pages and&lt;br /&gt;
the Page Forms keep working. Only the Each row should appear in the nutrition&lt;br /&gt;
block.&lt;br /&gt;
&lt;br /&gt;
== Preferred name wins over legacy ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|kcal100=100&lt;br /&gt;
|description=This preferred description must be the one shown.&lt;br /&gt;
|summary=This legacy summary must not appear.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== No description ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox&lt;br /&gt;
|form=Fine, white&lt;br /&gt;
|kcal100=387&lt;br /&gt;
|g_cup=200&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
The Description heading must still appear with its placeholder text, and the&lt;br /&gt;
allergens row must read that none are flagged.&lt;br /&gt;
&lt;br /&gt;
== Multiword title encoding ==&lt;br /&gt;
&lt;br /&gt;
The search links are built from &amp;lt;code&amp;gt;{{PAGENAME}}&amp;lt;/code&amp;gt;, which on this page is&lt;br /&gt;
&amp;lt;code&amp;gt;{{PAGENAME}}&amp;lt;/code&amp;gt;. On a page such as [[All-purpose flour]] both links&lt;br /&gt;
must carry the whole name as a single URL-encoded query value, so check them&lt;br /&gt;
there as well as here.&lt;br /&gt;
&lt;br /&gt;
== Empty call ==&lt;br /&gt;
&lt;br /&gt;
{{Ingredient/sandbox}}&lt;br /&gt;
&lt;br /&gt;
Must not raise an error, and must not show calorie rows for units it has no&lt;br /&gt;
weight for.&lt;br /&gt;
&amp;lt;noinclude&amp;gt;[[Category:Recipedius templates]]&amp;lt;/noinclude&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Technique/sandbox&amp;diff=86</id>
		<title>Template:Technique/sandbox</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Technique/sandbox&amp;diff=86"/>
		<updated>2026-09-23T19:25:45Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Technique sandbox ==&lt;br /&gt;
&lt;br /&gt;
Development copy of [[Template:Technique]]. Test changes at&lt;br /&gt;
[[Template:Technique/testcases]] before copying an approved revision to the&lt;br /&gt;
production template.&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{| class=&amp;quot;wikitable rp-infobox&amp;quot;&lt;br /&gt;
|+ {{PAGENAME}}&lt;br /&gt;
|-&lt;br /&gt;
! Summary&lt;br /&gt;
| {{{summary|}}}&lt;br /&gt;
{{#if:{{{purpose|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Purpose&lt;br /&gt;
{{!}} {{{purpose}}}}}&lt;br /&gt;
{{#if:{{{time|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Typical time&lt;br /&gt;
{{!}} {{{time}}}}}&lt;br /&gt;
{{#if:{{{heat|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Heat&lt;br /&gt;
{{!}} {{{heat}}}}}&lt;br /&gt;
{{#if:{{{equipment|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Equipment&lt;br /&gt;
{{!}} {{{equipment}}}}}&lt;br /&gt;
{{#if:{{{ingredients|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Ingredients&lt;br /&gt;
{{!}} {{{ingredients}}}}}&lt;br /&gt;
{{#if:{{{related|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Related pages&lt;br /&gt;
{{!}} {{{related}}}}}&lt;br /&gt;
{{#if:{{{safety|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Safety&lt;br /&gt;
{{!}} {{{safety}}}}}&lt;br /&gt;
|}&lt;br /&gt;
[[Category:Techniques]]&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
	<entry>
		<id>https://recipedius.com/index.php?title=Template:Technique&amp;diff=85</id>
		<title>Template:Technique</title>
		<link rel="alternate" type="text/html" href="https://recipedius.com/index.php?title=Template:Technique&amp;diff=85"/>
		<updated>2026-09-23T19:25:45Z</updated>

		<summary type="html">&lt;p&gt;Recipedius deploy: 0.010 T016: deploy 0.009 recipe and ingredient contract, 0.007 techniques, and template sandboxes (dbbad33)&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;noinclude&amp;gt;&lt;br /&gt;
== Technique ==&lt;br /&gt;
&lt;br /&gt;
Use this template once at the top of an article about a cooking technique. The&lt;br /&gt;
article body below the template holds the procedure, variations, examples, and&lt;br /&gt;
citations. Link ingredients, equipment, and related pages with ordinary&lt;br /&gt;
wikitext when a corresponding Recipedius page exists.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{{Technique&lt;br /&gt;
|summary=Whisking oil gradually into an acid-based liquid so it stays suspended.&lt;br /&gt;
|purpose=Create a stable, glossy dressing or sauce.&lt;br /&gt;
|time=2–5 minutes&lt;br /&gt;
|equipment=[[Whisk]], [[Mixing bowl]]&lt;br /&gt;
|ingredients=[[Olive oil]], [[Lemon]]&lt;br /&gt;
|related=[[Lemon vinaigrette]]&lt;br /&gt;
|safety=Keep cold emulsions refrigerated; discard leftovers that have stood at room temperature.&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Procedure ==&lt;br /&gt;
# Add the acid and seasoning to a bowl.&lt;br /&gt;
# Whisk while adding the oil in a thin stream.&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Only &amp;lt;code&amp;gt;summary&amp;lt;/code&amp;gt; is required. Omit any optional field that does not&lt;br /&gt;
apply; empty rows do not render. This template stores no Cargo data.&lt;br /&gt;
&lt;br /&gt;
[[Category:Recipedius templates]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&amp;lt;includeonly&amp;gt;{| class=&amp;quot;wikitable rp-infobox&amp;quot;&lt;br /&gt;
|+ {{PAGENAME}}&lt;br /&gt;
|-&lt;br /&gt;
! Summary&lt;br /&gt;
| {{{summary|}}}&lt;br /&gt;
{{#if:{{{purpose|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Purpose&lt;br /&gt;
{{!}} {{{purpose}}}}}&lt;br /&gt;
{{#if:{{{time|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Typical time&lt;br /&gt;
{{!}} {{{time}}}}}&lt;br /&gt;
{{#if:{{{heat|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Heat&lt;br /&gt;
{{!}} {{{heat}}}}}&lt;br /&gt;
{{#if:{{{equipment|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Equipment&lt;br /&gt;
{{!}} {{{equipment}}}}}&lt;br /&gt;
{{#if:{{{ingredients|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Ingredients&lt;br /&gt;
{{!}} {{{ingredients}}}}}&lt;br /&gt;
{{#if:{{{related|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Related pages&lt;br /&gt;
{{!}} {{{related}}}}}&lt;br /&gt;
{{#if:{{{safety|}}}|&lt;br /&gt;
{{!}}-&lt;br /&gt;
! Safety&lt;br /&gt;
{{!}} {{{safety}}}}}&lt;br /&gt;
|}&lt;br /&gt;
[[Category:Techniques]]&amp;lt;/includeonly&amp;gt;&lt;/div&gt;</summary>
		<author><name>Recipedius deploy</name></author>
	</entry>
</feed>