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
- }
{tabs.map((tab)=>{tab.icon} {tab.label} {tab.count} )}
{type==="ingredient"&&}
{type==="recipe"&&}
- Archive
+ {!readOnlyMode&&Archive }
{query?{searchResults.length} {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}
{searchResults.length?: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();
-
+
{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();
---
- {error&&{error}
}{editing&&}
+ {error&&{error}
}{editing&&}
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";
---
-
+
-
+
{editing?<>☷ Prep Method $ Cost ⚖ UoM Equivalency ♡ Nutrition >:<>☷ Prep Method $ Cost ⚖ UoM Equivalency ♡ Nutrition >}
{editing?<>
-
+ {!readOnlyMode&&}
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
- }
{error&&{error}
}
{rows.map(row=>
-
toggle(row.id)}/>
+ {!readOnly&&
toggle(row.id)}/>}
{row.icon}
{row.href?{row.name} :{row.name} }
-
⋮ requestDelete([row.id])}>Delete
+ {!readOnly&&
⋮ requestDelete([row.id])}>Delete
}
)}
{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.
{deleting?"Deleting…":"Delete"}
-
+ }
;
}
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.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();
----
-
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 Nutrition Costing Additional details
-
- {hasPlaceholder && This draft needs complete preparation instructions. }
-
- {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) => {step.instruction} {step.equipment_ids?.length && {step.equipment_ids.map((id) => equipment.get(id)?.name ?? id).join(" · ")} } )}
-
-
- 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 type Amount Inclusion Solid Low (< 5%) Start of mix Solid Medium (< 15%) Halfway through mix Solid High (> 15%) Just before full development Liquid Medium (< 15%) Start of mix Liquid High (> 15%) After full development
Sugar Amount Inclusion 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.
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();
----
-