From 2760ae1ab8448872278113ca98be894d9f1c2ef8 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Thu, 13 Aug 2026 17:24:21 -0500 Subject: [PATCH] serve application in enforced read-only mode --- README.md | 27 +++---- astro.config.mjs | 14 ---- docs/local-application.md | 4 +- package.json | 15 ++-- scripts/site-project.mjs | 9 --- src/application/middleware.ts | 18 +++++ src/application/pages/app/index.astro | 15 ++-- .../pages/app/ingredients/[id].astro | 5 +- .../pages/app/recipe-books/[id].astro | 5 +- src/application/pages/app/recipes/[id].astro | 9 ++- src/components/DetailUtility.astro | 3 +- src/components/EntityDirectory.tsx | 16 ++-- src/components/RecipeCard.astro | 12 --- src/components/RecipeList.astro | 8 -- src/lib/data.ts | 78 ++++--------------- src/lib/database.ts | 3 +- src/lib/runtime.ts | 3 + src/site/pages/404.astro | 5 -- src/site/pages/categories/[category].astro | 13 ---- src/site/pages/categories/index.astro | 7 -- src/site/pages/docs/recipes/[id].astro | 75 ------------------ src/site/pages/docs/recipes/index.astro | 11 --- .../taco_bell_slow_roasted_shredded.astro | 4 - .../docs/recipes/udon_noodle_saucebroth.astro | 4 - .../dough-ingredient-incorporation.astro | 5 -- .../pages/docs/reference/dough-shaping.astro | 5 -- src/site/pages/docs/reference/index.astro | 5 -- src/site/pages/index.astro | 4 - src/site/pages/tags/[tag].astro | 13 ---- src/site/pages/tags/index.astro | 7 -- 30 files changed, 89 insertions(+), 313 deletions(-) delete mode 100644 astro.config.mjs delete mode 100644 scripts/site-project.mjs create mode 100644 src/application/middleware.ts delete mode 100644 src/components/RecipeCard.astro delete mode 100644 src/components/RecipeList.astro create mode 100644 src/lib/runtime.ts delete mode 100644 src/site/pages/404.astro delete mode 100644 src/site/pages/categories/[category].astro delete mode 100644 src/site/pages/categories/index.astro delete mode 100644 src/site/pages/docs/recipes/[id].astro delete mode 100644 src/site/pages/docs/recipes/index.astro delete mode 100644 src/site/pages/docs/recipes/taco_bell_slow_roasted_shredded.astro delete mode 100644 src/site/pages/docs/recipes/udon_noodle_saucebroth.astro delete mode 100644 src/site/pages/docs/reference/dough-ingredient-incorporation.astro delete mode 100644 src/site/pages/docs/reference/dough-shaping.astro delete mode 100644 src/site/pages/docs/reference/index.astro delete mode 100644 src/site/pages/index.astro delete mode 100644 src/site/pages/tags/[tag].astro delete mode 100644 src/site/pages/tags/index.astro diff --git a/README.md b/README.md index b7c7684..f780514 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,10 @@ costing, and analyzing professional recipes. It models ingredients and sub-recipes as reusable entities instead of embedding duplicated ingredient data in every recipe. -The project includes two independent Astro applications: - -- A SQLite-backed management application for recipes, ingredients, purchasing, - nutrition, costs, preparation actions, conversions, and recipe books. -- A read-only static cooking site generated from the same culinary data. +The Astro application can run as either the full editor or a server-enforced +read-only viewer. Both modes provide recipes, ingredients, purchasing data, +nutrition, live costs, scaling, conversions, and recipe books. Read-only mode +removes persistent editing while retaining browser-side calculations. ## Capabilities @@ -59,7 +58,7 @@ See [Local application](docs/local-application.md) for more detail. ## Development -Run the management application: +Run the editor: ```bash npm run dev:app @@ -74,18 +73,18 @@ development server: npm run restart:app ``` -Run the static cooking site separately: +Run the same application in read-only mode: ```bash -npm run dev:site +npm run dev:readonly ``` -Open . The site command creates a read-only projection -at `generated/site-projection.json` before Astro starts. +Open . The database is opened read-only, modifying +HTTP requests are rejected, and editing controls and routes are unavailable. ## Validation and builds -Run application and site type checks, unit tests, and both production builds: +Run type checks, unit tests, and the production build: ```bash npm run check @@ -93,10 +92,8 @@ npm test npm run build ``` -Build output is separated by application: - -- `dist/app/` — standalone Node server for the management application -- `dist/site/` — static public cooking site +The standalone Node server is written to `dist/app/`. Start a production +read-only instance on port 4399 with `npm run start:readonly`. ## Culinary data tools diff --git a/astro.config.mjs b/astro.config.mjs deleted file mode 100644 index 38dcc06..0000000 --- a/astro.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "astro/config"; -import preact from "@astrojs/preact"; - -export default defineConfig({ - site: "https://recipes.uuard.com", - output: "static", - srcDir: "./src/site", - publicDir: "./public", - outDir: "./dist/site", - integrations: [preact()], - vite: { - server: { fs: { allow: ["."] } }, - }, -}); diff --git a/docs/local-application.md b/docs/local-application.md index 40aee5f..bde1c02 100644 --- a/docs/local-application.md +++ b/docs/local-application.md @@ -9,11 +9,11 @@ newer, then run the site and application: ```sh npm run db:reset -npm run dev:site +npm run dev:readonly npm run dev:app ``` -`npm run site:project` writes `generated/site-projection.json`. Site development, +Read-only mode serves the application with SQLite opened read-only. Development, checks, and builds run it automatically. Successful application edits refresh the same file, allowing the local Astro site to hot-reload database changes while remaining read-only. diff --git a/package.json b/package.json index 946b32e..f121da4 100644 --- a/package.json +++ b/package.json @@ -7,21 +7,18 @@ "npm": ">=9.6.5" }, "scripts": { - "dev": "npm run dev:site", - "dev:site": "npm run site:project && astro dev --config astro.config.mjs --port 4321", + "dev": "npm run dev:app", "dev:app": "astro dev --config astro.app.config.mjs --port 4322", "restart:app": "node scripts/restart-app.mjs", - "check": "npm run check:site && npm run check:app", - "check:site": "npm run site:project && astro check --config astro.config.mjs", + "dev:readonly": "FORMULATION_READ_ONLY=true astro dev --config astro.app.config.mjs --port 4399", + "check": "npm run check:app", "check:app": "astro check --config astro.app.config.mjs", - "build": "npm run build:site && npm run build:app", - "build:site": "npm run site:project && astro check --config astro.config.mjs && astro build --config astro.config.mjs", + "build": "npm run build:app", "build:app": "astro check --config astro.app.config.mjs && astro build --config astro.app.config.mjs", - "preview": "npm run preview:site", - "preview:site": "astro preview --config astro.config.mjs --port 4321", + "start": "node dist/app/server/entry.mjs", + "start:readonly": "FORMULATION_READ_ONLY=true HOST=127.0.0.1 PORT=4399 node dist/app/server/entry.mjs", "preview:app": "astro preview --config astro.app.config.mjs --port 4322", "test": "vitest run", - "site:project": "node scripts/site-project.mjs", "db:reset": "node scripts/db-sync.mjs --reset", "db:import:yaml": "node scripts/db-sync.mjs --reset" }, diff --git a/scripts/site-project.mjs b/scripts/site-project.mjs deleted file mode 100644 index 61e28eb..0000000 --- a/scripts/site-project.mjs +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; -import { writeSiteProjection } from "./lib/site-projection.mjs"; -const databasePath=path.resolve(process.cwd(),"var","recipe-book.sqlite"); -if(!fs.existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`); -const database=new DatabaseSync(databasePath,{readOnly:true}); -try { console.log(`Projected site data to ${writeSiteProjection(database)}`); } finally { database.close(); } diff --git a/src/application/middleware.ts b/src/application/middleware.ts new file mode 100644 index 0000000..89620a0 --- /dev/null +++ b/src/application/middleware.ts @@ -0,0 +1,18 @@ +import { defineMiddleware } from "astro:middleware"; +import { readOnlyMode } from "../lib/runtime"; + +const safeMethods = new Set(["GET", "HEAD", "OPTIONS"]); + +export const onRequest = defineMiddleware(async ({ request, url, redirect }, next) => { + if (!readOnlyMode) return next(); + if (!safeMethods.has(request.method)) { + return new Response("This Formulation instance is read-only.", { status: 405, headers: { Allow: "GET, HEAD, OPTIONS" } }); + } + if (url.pathname.endsWith("/new/") || url.searchParams.has("edit")) { + const target = new URL(url); + target.searchParams.delete("edit"); + if (target.pathname.endsWith("/new/")) target.pathname = "/app/"; + return redirect(`${target.pathname}${target.search}${target.hash}`, 303); + } + return next(); +}); diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index ec6d9a3..c19ce3b 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -3,6 +3,7 @@ export const prerender = false; import BaseLayout from "../../../layouts/BaseLayout.astro"; import EntityDirectory from "../../../components/EntityDirectory"; import { openDatabase } from "../../../lib/database"; +import { readOnlyMode } from "../../../lib/runtime"; const database = openDatabase(); if (!database) return new Response("Database unavailable", { status:503 }); @@ -69,24 +70,24 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
{filteringSearchTypes&&All types}
-
+ {!readOnlyMode&&
New -
+
} {query?

{searchResults.length} {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}

{searchResults.length?
{searchResults.map((result)=>{result.icon}{result.name}{result.detail}{result.label})}
:
No items of the selected types match this search.
}
:<> - {type==="ingredient"&&} - {type==="recipe"&&} - {type==="book"&&} - {type==="purchase"&&} + {type==="ingredient"&&} + {type==="recipe"&&} + {type==="book"&&} + {type==="purchase"&&} } diff --git a/src/application/pages/app/ingredients/[id].astro b/src/application/pages/app/ingredients/[id].astro index 2769d2a..125e7f1 100644 --- a/src/application/pages/app/ingredients/[id].astro +++ b/src/application/pages/app/ingredients/[id].astro @@ -2,12 +2,13 @@ export const prerender = false; import BaseLayout from "../../../../layouts/BaseLayout.astro"; import { openDatabase, refreshSiteProjection } from "../../../../lib/database"; +import { readOnlyMode } from "../../../../lib/runtime"; import { bestUsdaPortions, fetchUsdaFood, usdaNutrition } from "../../../../lib/usda"; import { number } from "../../../../lib/format"; import PurchaseItemForm from "../../../../components/PurchaseItemForm.astro"; import DetailUtility from "../../../../components/DetailUtility.astro"; const id = Astro.params.id!; -const editing = Astro.url.searchParams.get("edit") === "1"; +const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1"; const database = openDatabase({ readOnly: false }); if (!database) return Astro.redirect("/app/", 303); let error: string | undefined; @@ -131,7 +132,7 @@ database.close();
-

← Ingredients

{editing?:

{ingredient.name}

}
{editing?:✎ Edit}{editing&&
}
+

← Ingredients

{editing?:

{ingredient.name}

}
{!readOnlyMode&&
{editing?:✎ Edit}{editing&&
}
}
{message&&
{message}
}{error&&
{error}
}
diff --git a/src/application/pages/app/recipe-books/[id].astro b/src/application/pages/app/recipe-books/[id].astro index 41579a5..b08f35a 100644 --- a/src/application/pages/app/recipe-books/[id].astro +++ b/src/application/pages/app/recipe-books/[id].astro @@ -2,8 +2,9 @@ export const prerender=false; import BaseLayout from "../../../../layouts/BaseLayout.astro"; import DetailUtility from "../../../../components/DetailUtility.astro"; +import {readOnlyMode} from "../../../../lib/runtime"; import {openDatabase,refreshSiteProjection} from "../../../../lib/database"; -const id=Astro.params.id!,editing=Astro.url.searchParams.get("edit")==="1"; +const id=Astro.params.id!,editing=!readOnlyMode&&Astro.url.searchParams.get("edit")==="1"; const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303); let error=""; if(Astro.request.method==="POST")try{const form=await Astro.request.formData(),intent=String(form.get("intent")); @@ -15,4 +16,4 @@ const book=database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at const recipes=database.prepare("SELECT r.id,r.title,cr.position,cr.recipe_id IS NOT NULL included FROM recipes r LEFT JOIN collection_recipes cr ON cr.recipe_id=r.id AND cr.collection_id=? WHERE r.deleted_at IS NULL ORDER BY coalesce(cr.position,999999),r.title").all(id) as any[]; database.close(); --- -

← Recipe books

{book.name}

{recipes.filter(x=>x.included).length} recipes

{editing?"✓ Done":"✎ Edit"}
{error&&

{error}

}
{editing&&
}

Recipes

{editing?"Choose the recipes included in this book.":book.description}

{editing&&}
{recipes.filter(recipe=>editing||recipe.included).map(recipe=>)}
+

← Recipe books

{book.name}

{recipes.filter(x=>x.included).length} recipes

{!readOnlyMode&&{editing?"✓ Done":"✎ Edit"}}
{error&&

{error}

}
{editing&&
}

Recipes

{editing?"Choose the recipes included in this book.":book.description}

{editing&&}
{recipes.filter(recipe=>editing||recipe.included).map(recipe=>)}
diff --git a/src/application/pages/app/recipes/[id].astro b/src/application/pages/app/recipes/[id].astro index b4f1e71..82fdab0 100644 --- a/src/application/pages/app/recipes/[id].astro +++ b/src/application/pages/app/recipes/[id].astro @@ -10,9 +10,10 @@ import { calculateCost } from "../../../../lib/costing"; import type { Ingredient, PrepAction, PurchaseItem, Recipe, SourceMapping, Unit } from "../../../../lib/types"; import DetailUtility from "../../../../components/DetailUtility.astro"; import { convertWithIngredientMeasures } from "../../../../lib/measurement"; +import { readOnlyMode } from "../../../../lib/runtime"; const id = Astro.params.id!; -const editing = Astro.url.searchParams.get("edit") === "1"; +const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1"; const database = openDatabase({ readOnly: false }); if (!database) return Astro.redirect("/app/?error=database-missing", 303); let error: string | undefined; @@ -131,7 +132,7 @@ const nutritionIngredients=domainRecipe.components.flatMap(component=>component. const ingredient=ingredientMap.get(item.reference.ingredient_id)!; const mapping=(ingredient.nutrition_mapping_ids??[]).map(mappingId=>nutritionMappingMap.get(mappingId)).find(mapping=>mapping?.mapping_type==="nutrition"&&mapping.status==="reviewed"&&Object.keys(mapping.nutrition_per_100g??{}).length>0); let weightG:number|undefined;try{weightG=convertWithIngredientMeasures(item.amount,"gram",ingredient,unitMap).quantity;}catch{} - return {id:item.id,name:ingredient.name,href:`/app/ingredients/${ingredient.id}/?edit=1#nutrition`,weightG,mapped:Boolean(mapping),source:mapping?.source.title,kind:"ingredient" as const}; + return {id:item.id,name:ingredient.name,href:`/app/ingredients/${ingredient.id}/${readOnlyMode?"":"?edit=1"}#nutrition`,weightG,mapped:Boolean(mapping),source:mapping?.source.title,kind:"ingredient" as const}; } const child=recipeMap.get(item.reference.recipe_id)!; const childNutrition=calculateNutrition(child,{recipes:recipeMap,ingredients:ingredientMap,units:unitMap,mappings:nutritionMappingMap}); @@ -143,10 +144,10 @@ const categories = JSON.parse(recipe.categories_json).join(", "); const tags = JSON.parse(recipe.tags_json).join(", "); const saved = Astro.url.searchParams.get("saved") === "1"; --- - +
-

← Recipes

{editing?:

{recipe.title}

}
{editing?:✎ Edit}
+

← Recipes

{editing?:

{recipe.title}

}
{!readOnlyMode&&
{editing?:✎ Edit}
}
{editing?<>:<>}
{editing?<>
diff --git a/src/components/DetailUtility.astro b/src/components/DetailUtility.astro index 7c1ec3e..3b3cbce 100644 --- a/src/components/DetailUtility.astro +++ b/src/components/DetailUtility.astro @@ -1,5 +1,6 @@ --- interface Props { section?: string; sectionHref?: string } +import { readOnlyMode } from "../lib/runtime"; const { section, sectionHref } = Astro.props; const inferred = Astro.url.pathname.includes("/ingredients/") ? { label:"Ingredients", href:"/app/?type=ingredient" } @@ -22,6 +23,6 @@ const resolvedHref=sectionHref??inferred.href;
-
New
+ {!readOnlyMode&&
New
}
diff --git a/src/components/EntityDirectory.tsx b/src/components/EntityDirectory.tsx index c0acaf7..1226c55 100644 --- a/src/components/EntityDirectory.tsx +++ b/src/components/EntityDirectory.tsx @@ -3,9 +3,9 @@ import { useRef,useState } from "preact/hooks"; export type DirectoryRow = { id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string; }; -type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string }; +type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean }; -export default function EntityDirectory({rows,entityType,emptyMessage}:Props) { +export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) { const [selected,setSelected]=useState([]),[deleting,setDeleting]=useState(false),[error,setError]=useState(""); const [pendingDelete,setPendingDelete]=useState([]); const dialog=useRef(null); @@ -22,24 +22,24 @@ export default function EntityDirectory({rows,entityType,emptyMessage}:Props) { location.reload(); }; return
-
+ {!readOnly&&
{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/> {selected.length?`${selected.length} selected`:""} {selected.length>0&&<>} -
+
} {error&&

{error}

}
{rows.map(row=>
- toggle(row.id)}/> + {!readOnly&&toggle(row.id)}/>} {row.icon} {row.href?{row.name}:{row.name}} -
+ {!readOnly&&
}
)}
{rows.length===0&&
{emptyMessage}
} - {if(!deleting)setPendingDelete([]);}}> + {!readOnly&&{if(!deleting)setPendingDelete([]);}}>

Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?

This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.

-
+
}
; } diff --git a/src/components/RecipeCard.astro b/src/components/RecipeCard.astro deleted file mode 100644 index b81005b..0000000 --- a/src/components/RecipeCard.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -import type { Recipe } from "../lib/types"; -import { recipeHref, titleCase } from "../lib/data"; -interface Props { recipe: Recipe } -const { recipe } = Astro.props; ---- -
-
{titleCase(recipe.categories[0] ?? "recipe")}
-

{recipe.title}

-

{recipe.summary ?? `${recipe.components.length} component${recipe.components.length === 1 ? "" : "s"} · ${recipe.steps.length} preparation step${recipe.steps.length === 1 ? "" : "s"}`}

-
{recipe.tags.slice(0, 4).map((tag) => #{tag})}
-
diff --git a/src/components/RecipeList.astro b/src/components/RecipeList.astro deleted file mode 100644 index 1d70412..0000000 --- a/src/components/RecipeList.astro +++ /dev/null @@ -1,8 +0,0 @@ ---- -import RecipeCard from "./RecipeCard.astro"; -import type { Recipe } from "../lib/types"; -interface Props { recipes: Recipe[]; heading?: string } -const { recipes, heading } = Astro.props; ---- -{heading &&

{heading}

} -
{recipes.map((recipe) => )}
diff --git a/src/lib/data.ts b/src/lib/data.ts index 06279a6..1feef44 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -1,63 +1,19 @@ -import type { Equipment, Ingredient, PrepAction, PurchaseItem, Recipe, Unit, CalculatorComponent, SourceMapping } from "./types"; -import projection from "../../generated/site-projection.json"; +import type { Equipment, Ingredient, PrepAction, PurchaseItem, Recipe, Unit, SourceMapping } from "./types"; +import { databaseProjection, openDatabase } from "./database"; + +const database = openDatabase(); +if (!database) throw new Error("Database unavailable. Run npm run db:reset first."); +const projection = databaseProjection(database) as { + ingredients: Ingredient[]; recipes: Recipe[]; units: Unit[]; equipment: Equipment[]; + prepActions: PrepAction[]; purchaseItems: PurchaseItem[]; sourceMappings: SourceMapping[]; +}; +database.close(); const map = (values: T[]) => new Map(values.map((value) => [value.id, value])); -export const ingredients = map(projection.ingredients as Ingredient[]); -export const recipes = map(projection.recipes as Recipe[]); -export const units = map(projection.units as Unit[]); -export const equipment = map(projection.equipment as Equipment[]); -export const prepActions = map(projection.prepActions as PrepAction[]); -export const purchaseItems = map(projection.purchaseItems as PurchaseItem[]); -export const sourceMappings = map(projection.sourceMappings as SourceMapping[]); - -export function recipeHref(id: string) { - return `/docs/recipes/${id}/`; -} - -export function routeSlug(value: string) { - return value - .normalize("NFKD") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} - -export function calculatorComponents(recipe: Recipe): CalculatorComponent[] { - return recipe.components.map((component) => ({ - id: component.id, - name: component.name, - notes: component.notes, - items: component.items.map((item) => { - if ("ingredient_id" in item.reference) { - const ingredient = ingredients.get(item.reference.ingredient_id); - if (!ingredient) throw new Error(`${recipe.id}: unknown ingredient ${item.reference.ingredient_id}`); - return { - id: item.id, - label: ingredient.name, - amount: item.amount, - percentage: item.percentage, - basisMember: item.basis_member, - optional: item.optional, - notes: item.notes, - measureConversions: ingredient.measure_conversions?.filter((conversion) => conversion.source.reviewed), - }; - } - const subrecipe = recipes.get(item.reference.recipe_id); - if (!subrecipe) throw new Error(`${recipe.id}: unknown sub-recipe ${item.reference.recipe_id}`); - return { - id: item.id, - label: subrecipe.title, - href: recipeHref(subrecipe.id), - amount: item.amount, - percentage: item.percentage, - basisMember: item.basis_member, - optional: item.optional, - notes: item.notes, - }; - }), - })); -} - -export function titleCase(value: string) { - return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); -} +export const ingredients = map(projection.ingredients); +export const recipes = map(projection.recipes); +export const units = map(projection.units); +export const equipment = map(projection.equipment); +export const prepActions = map(projection.prepActions); +export const purchaseItems = map(projection.purchaseItems); +export const sourceMappings = map(projection.sourceMappings); diff --git a/src/lib/database.ts b/src/lib/database.ts index 2b24a43..8f65250 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { createSiteProjection, writeSiteProjection } from "../../scripts/lib/site-projection.mjs"; +import { readOnlyMode } from "./runtime"; export const databasePath = path.resolve(process.cwd(), "var/recipe-book.sqlite"); export const refreshSiteProjection = (database: DatabaseSync) => writeSiteProjection(database); @@ -9,7 +10,7 @@ export const databaseProjection = (database: DatabaseSync) => createSiteProjecti export function openDatabase(options: { readOnly?: boolean } = {}) { if (!fs.existsSync(databasePath)) return undefined; - const database = new DatabaseSync(databasePath, { readOnly: options.readOnly ?? true }); + const database = new DatabaseSync(databasePath, { readOnly: readOnlyMode || (options.readOnly ?? true) }); database.exec("PRAGMA foreign_keys = ON"); return database; } diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts new file mode 100644 index 0000000..04110cf --- /dev/null +++ b/src/lib/runtime.ts @@ -0,0 +1,3 @@ +export const readOnlyMode = ["1", "true", "yes", "on"].includes( + String(process.env.FORMULATION_READ_ONLY ?? "").toLowerCase(), +); diff --git a/src/site/pages/404.astro b/src/site/pages/404.astro deleted file mode 100644 index a7f5958..0000000 --- a/src/site/pages/404.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../layouts/BaseLayout.astro"; ---- -

404

That page isn’t in the book.

Browse all recipes →

diff --git a/src/site/pages/categories/[category].astro b/src/site/pages/categories/[category].astro deleted file mode 100644 index b9c2909..0000000 --- a/src/site/pages/categories/[category].astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../layouts/BaseLayout.astro"; -import RecipeList from "../../../components/RecipeList.astro"; -import { recipes, titleCase } from "../../../lib/data"; -export function getStaticPaths() { - const values = [...new Set([...recipes.values()].flatMap((recipe) => recipe.categories))]; - return values.map((category) => ({ params: { category }, props: { category } })); -} -const { category } = Astro.props; -const selected = [...recipes.values()].filter((recipe) => recipe.categories.includes(category)).sort((a,b) => a.title.localeCompare(b.title)); ---- -

Category

{titleCase(category)}

{selected.length} recipes

diff --git a/src/site/pages/categories/index.astro b/src/site/pages/categories/index.astro deleted file mode 100644 index 3b196fc..0000000 --- a/src/site/pages/categories/index.astro +++ /dev/null @@ -1,7 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../layouts/BaseLayout.astro"; -import { recipes, titleCase } from "../../../lib/data"; -const categories = [...new Set([...recipes.values()].flatMap((recipe) => recipe.categories))].sort(); ---- -

Browse

Categories

{categories.map((category) => {titleCase(category)}{[...recipes.values()].filter((r) => r.categories.includes(category)).length} recipes)}
diff --git a/src/site/pages/docs/recipes/[id].astro b/src/site/pages/docs/recipes/[id].astro deleted file mode 100644 index bcbba2f..0000000 --- a/src/site/pages/docs/recipes/[id].astro +++ /dev/null @@ -1,75 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../../layouts/BaseLayout.astro"; -import RecipeCalculator from "../../../../components/RecipeCalculator"; -import { calculatorComponents, equipment, ingredients, prepActions, purchaseItems, recipes, routeSlug, sourceMappings, titleCase, units } from "../../../../lib/data"; -import { amount } from "../../../../lib/format"; -import { calculateCost } from "../../../../lib/costing"; -import { calculateNutrition } from "../../../../lib/nutrition"; -import type { Recipe } from "../../../../lib/types"; - -export function getStaticPaths() { - return [...recipes.values()].map((recipe) => ({ params: { id: recipe.id }, props: { recipe } })); -} -interface Props { recipe: Recipe } -const { recipe } = Astro.props; -const calculator = calculatorComponents(recipe); -const unitRecord = Object.fromEntries(units); -const basis = recipe.scaling?.basis_amount ?? recipe.yield.amount; -const hasPlaceholder = recipe.steps.some((step) => step.instruction.startsWith("TODO:")); -const nutrition = calculateNutrition(recipe, { recipes, ingredients, units, mappings: sourceMappings }); -const cost = calculateCost(recipe, { recipes, ingredients, prepActions, purchaseItems, units }); -const equipmentIds = [...new Set([...(recipe.equipment_ids ?? []), ...recipe.steps.flatMap((step) => step.equipment_ids ?? [])])]; -const requiredEquipment = equipmentIds.map((id) => equipment.get(id)).filter((item) => item != null); -const recipesOn = [...recipes.values()].filter((candidate) => candidate.id !== recipe.id && candidate.components.some((component) => component.items.some((item) => "recipe_id" in item.reference && item.reference.recipe_id === recipe.id))).sort((a, b) => a.title.localeCompare(b.title)); ---- - -
-
-
- -

{recipe.title}

- {recipe.summary &&

{recipe.summary}

} -
{recipe.tags.map((tag) => #{tag})}
-
-
-
Yield
{amount(recipe.yield.amount, units)}
- {recipe.yield.servings &&
Servings
{recipe.yield.servings}
} -
Basis
{recipe.yield.basis ?? "unspecified"}
-
-
- -
- {hasPlaceholder && } - - {requiredEquipment.length > 0 && ( -
-

Setup

Equipment

-
    {requiredEquipment.map((item) =>
  • {item.name}{item.notes && {item.notes}}
  • )}
-
- )} -
-

Method

Preparation

-
    {recipe.steps.sort((a, b) => a.order - b.order).map((step) =>
  1. {step.instruction}{step.equipment_ids?.length && {step.equipment_ids.map((id) => equipment.get(id)?.name ?? id).join(" · ")}}
  2. )}
-
-
-

Reference

Additional details

-
-
Shelf life
{recipe.shelf_life ? <>{amount(recipe.shelf_life.duration, units)}{recipe.shelf_life.storage_condition && {recipe.shelf_life.storage_condition}}{recipe.shelf_life.notes && {recipe.shelf_life.notes}} : "Not specified"}
-
Recipes on
{recipesOn.length ? : "Not used as a sub-recipe"}
-
-
- {recipe.notes?.length &&

Details

Notes

    {recipe.notes.map((note) =>
  • {note}
  • )}
} -
-
diff --git a/src/site/pages/docs/recipes/index.astro b/src/site/pages/docs/recipes/index.astro deleted file mode 100644 index 3bb0753..0000000 --- a/src/site/pages/docs/recipes/index.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../../layouts/BaseLayout.astro"; -import RecipeList from "../../../../components/RecipeList.astro"; -import { recipes } from "../../../../lib/data"; -const allRecipes = [...recipes.values()].sort((a, b) => a.title.localeCompare(b.title)); ---- - -

The collection

Recipes

{allRecipes.length} weight-first formulas, from components and sauces to breads and desserts.

-
-
diff --git a/src/site/pages/docs/recipes/taco_bell_slow_roasted_shredded.astro b/src/site/pages/docs/recipes/taco_bell_slow_roasted_shredded.astro deleted file mode 100644 index a6b99fd..0000000 --- a/src/site/pages/docs/recipes/taco_bell_slow_roasted_shredded.astro +++ /dev/null @@ -1,4 +0,0 @@ ---- -export const prerender = true; -return Astro.redirect("/docs/recipes/taco_bell_slow_roasted_shredded_chicken/", 301); ---- diff --git a/src/site/pages/docs/recipes/udon_noodle_saucebroth.astro b/src/site/pages/docs/recipes/udon_noodle_saucebroth.astro deleted file mode 100644 index 34121b4..0000000 --- a/src/site/pages/docs/recipes/udon_noodle_saucebroth.astro +++ /dev/null @@ -1,4 +0,0 @@ ---- -export const prerender = true; -return Astro.redirect("/docs/recipes/udon_noodle_sauce/", 301); ---- diff --git a/src/site/pages/docs/reference/dough-ingredient-incorporation.astro b/src/site/pages/docs/reference/dough-ingredient-incorporation.astro deleted file mode 100644 index 23e68d7..0000000 --- a/src/site/pages/docs/reference/dough-ingredient-incorporation.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../../layouts/BaseLayout.astro"; ---- -

Reference

Dough Ingredient Incorporation

Guidelines for incorporating common dough ingredients into a mixture.

Dry ingredients

Include at the start of mixing.

Eggs

Add water to eggs and include all at the start of mixing.

Fat

Fat typeAmountInclusion
SolidLow (< 5%)Start of mix
SolidMedium (< 15%)Halfway through mix
SolidHigh (> 15%)Just before full development
LiquidMedium (< 15%)Start of mix
LiquidHigh (> 15%)After full development

Sugar

AmountInclusion
Low (< 12%)Start of mix
Medium (< 20%)Add gradually
High (> 20%)After full development
diff --git a/src/site/pages/docs/reference/dough-shaping.astro b/src/site/pages/docs/reference/dough-shaping.astro deleted file mode 100644 index d659a57..0000000 --- a/src/site/pages/docs/reference/dough-shaping.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../../layouts/BaseLayout.astro"; ---- -

Reference

Dough Shaping

Guidelines for shaping dough. This reference is ready for additional technique notes.

diff --git a/src/site/pages/docs/reference/index.astro b/src/site/pages/docs/reference/index.astro deleted file mode 100644 index 2b81fa6..0000000 --- a/src/site/pages/docs/reference/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../../layouts/BaseLayout.astro"; ---- -

Techniques

Reference

Practical notes for repeatable dough production.

Dough Ingredient IncorporationWhen to add flour, eggs, fat, and sugarDough ShapingGuidelines for shaping dough
diff --git a/src/site/pages/index.astro b/src/site/pages/index.astro deleted file mode 100644 index a10ebed..0000000 --- a/src/site/pages/index.astro +++ /dev/null @@ -1,4 +0,0 @@ ---- -export const prerender = true; -return Astro.redirect("/docs/recipes/", 301); ---- diff --git a/src/site/pages/tags/[tag].astro b/src/site/pages/tags/[tag].astro deleted file mode 100644 index dfdad72..0000000 --- a/src/site/pages/tags/[tag].astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../layouts/BaseLayout.astro"; -import RecipeList from "../../../components/RecipeList.astro"; -import { recipes, routeSlug } from "../../../lib/data"; -export function getStaticPaths() { - const values = [...new Set([...recipes.values()].flatMap((recipe) => recipe.tags))]; - return values.map((tag) => ({ params: { tag: routeSlug(tag) }, props: { tag } })); -} -const { tag } = Astro.props; -const selected = [...recipes.values()].filter((recipe) => recipe.tags.includes(tag)).sort((a,b) => a.title.localeCompare(b.title)); ---- -

Tag

#{tag}

{selected.length} recipes

diff --git a/src/site/pages/tags/index.astro b/src/site/pages/tags/index.astro deleted file mode 100644 index a3a7a54..0000000 --- a/src/site/pages/tags/index.astro +++ /dev/null @@ -1,7 +0,0 @@ ---- -export const prerender = true; -import BaseLayout from "../../../layouts/BaseLayout.astro"; -import { recipes, routeSlug } from "../../../lib/data"; -const tags = [...new Set([...recipes.values()].flatMap((recipe) => recipe.tags))].sort(); ---- -

Browse

Tags

{tags.map((tag) => #{tag} {[...recipes.values()].filter((r) => r.tags.includes(tag)).length})}