serve application in enforced read-only mode

This commit is contained in:
2026-08-13 17:24:21 -05:00
parent e339e54335
commit 2760ae1ab8
30 changed files with 89 additions and 313 deletions
+12 -15
View File
@@ -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 <http://localhost:4321/>. The site command creates a read-only projection
at `generated/site-projection.json` before Astro starts.
Open <http://localhost:4399/app/>. 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
-14
View File
@@ -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: ["."] } },
},
});
+2 -2
View File
@@ -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.
+6 -9
View File
@@ -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"
},
-9
View File
@@ -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(); }
+18
View File
@@ -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();
});
+8 -7
View File
@@ -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
<div><button>Apply</button>{filteringSearchTypes&&<a href={`/app/?type=${type}&q=${encodeURIComponent(query)}`}>All types</a>}</div>
</form>
</details>
<details class="workspace-new-menu">
{!readOnlyMode&&<details class="workspace-new-menu">
<summary><span class="new-trigger-plus" aria-hidden="true"></span><span>New</span></summary>
<nav aria-label="Create new item">
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a>
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a>
</nav>
</details>
</details>}
</div>
<nav class="workspace-pills" aria-label="Workspaces">
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}>{tab.icon}</span>{tab.label} <small>{tab.count}</small></a>)}
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary>☷ &nbsp; Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary>☷ &nbsp; Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
<a class="archive-link" href="/app/archive/">Archive</a>
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
</nav>
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchResults.length?<div>{searchResults.map((result)=><a href={result.href}><span class={`workspace-pill-icon ${result.kind}`}>{result.icon}</span><span><strong>{result.name}</strong><small>{result.detail}</small></span><em>{result.label}</em><b></b></a>)}</div>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters."/>}
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet."/>}
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet."/>}
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet."/>}
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet." readOnly={readOnlyMode}/>}
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet." readOnly={readOnlyMode}/>}
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet." readOnly={readOnlyMode}/>}
</>}
</section></BaseLayout>
@@ -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();
<BaseLayout title={ingredient.name} immersive>
<section class="entity-detail-shell">
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient" />
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>{editing?<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>:<h1>{ingredient.name}</h1>}</div><div class="entity-header-actions">{editing?<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>:<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}>✎ Edit</a>}{editing&&<details class="detail-actions-menu ingredient-actions-menu"><summary aria-label="Ingredient actions">⋮</summary><div><form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it."><label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label><button name="intent" value="merge">Merge ingredient</button></form></div></details>}</div></header>
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>{editing?<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>:<h1>{ingredient.name}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>:<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}>✎ Edit</a>}{editing&&<details class="detail-actions-menu ingredient-actions-menu"><summary aria-label="Ingredient actions">⋮</summary><div><form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it."><label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label><button name="intent" value="merge">Merge ingredient</button></form></div></details>}</div>}</header>
{message&&<div class="success-notice entity-notice">{message}</div>}{error&&<div class="notice entity-notice">{error}</div>}
<div class="entity-detail-grid">
<main class="entity-primary">
@@ -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();
---
<BaseLayout title={book.name} immersive><section class="entity-detail-shell recipe-book-detail"><DetailUtility/><header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=book">← Recipe books</a></p><h1>{book.name}</h1><p>{recipes.filter(x=>x.included).length} recipes</p></div><a class="edit-command" href={editing?`/app/recipe-books/${id}/`:`/app/recipe-books/${id}/?edit=1`}>{editing?"✓ Done":"✎ Edit"}</a></header>{error&&<p class="notice">{error}</p>}<main class="book-workspace">{editing&&<form method="post" class="book-details-form"><input type="hidden" name="intent" value="details"/><label><span>Name</span><input name="name" value={book.name} required/></label><label><span>Description</span><input name="description" value={book.description??""}/></label><button>Save details</button></form>}<form method="post" class="book-membership"><input type="hidden" name="intent" value="membership"/><header><div><h2>Recipes</h2><p>{editing?"Choose the recipes included in this book.":book.description}</p></div>{editing&&<button>Save recipes</button>}</header>{recipes.filter(recipe=>editing||recipe.included).map(recipe=><label class="book-recipe-row">{editing&&<input type="checkbox" name="recipe_id" value={recipe.id} checked={recipe.included}/>}<span class="workspace-pill-icon recipe">▦</span><a href={`/app/recipes/${recipe.id}/`}><strong>{recipe.title}</strong></a></label>)}</form></main></section></BaseLayout>
<BaseLayout title={book.name} immersive><section class="entity-detail-shell recipe-book-detail"><DetailUtility/><header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=book">← Recipe books</a></p><h1>{book.name}</h1><p>{recipes.filter(x=>x.included).length} recipes</p></div>{!readOnlyMode&&<a class="edit-command" href={editing?`/app/recipe-books/${id}/`:`/app/recipe-books/${id}/?edit=1`}>{editing?"✓ Done":"✎ Edit"}</a>}</header>{error&&<p class="notice">{error}</p>}<main class="book-workspace">{editing&&<form method="post" class="book-details-form"><input type="hidden" name="intent" value="details"/><label><span>Name</span><input name="name" value={book.name} required/></label><label><span>Description</span><input name="description" value={book.description??""}/></label><button>Save details</button></form>}<form method="post" class="book-membership"><input type="hidden" name="intent" value="membership"/><header><div><h2>Recipes</h2><p>{editing?"Choose the recipes included in this book.":book.description}</p></div>{editing&&<button>Save recipes</button>}</header>{recipes.filter(recipe=>editing||recipe.included).map(recipe=><label class="book-recipe-row">{editing&&<input type="checkbox" name="recipe_id" value={recipe.id} checked={recipe.included}/>}<span class="workspace-pill-icon recipe">▦</span><a href={`/app/recipes/${recipe.id}/`}><strong>{recipe.title}</strong></a></label>)}</form></main></section></BaseLayout>
+5 -4
View File
@@ -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";
---
<BaseLayout title={`Edit ${recipe.title}`} immersive>
<BaseLayout title={editing?`Edit ${recipe.title}`:recipe.title} immersive>
<section class="recipe-detail-shell">
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe" />
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=recipe">← Recipes</a></p>{editing?<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>:<h1>{recipe.title}</h1>}</div><div class="entity-header-actions">{editing?<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>:<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}>✎ Edit</a>}<details class="detail-actions-menu"><summary aria-label="Recipe actions">⋮</summary><div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div></details></div></header>
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=recipe">← Recipes</a></p>{editing?<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>:<h1>{recipe.title}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>:<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}>✎ Edit</a>}<details class="detail-actions-menu"><summary aria-label="Recipe actions">⋮</summary><div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div></details></div>}</header>
<div class="recipe-workspace-tabs">{editing?<><button class="active" type="button" data-edit-recipe-tab="method">☷ Prep Method</button><button type="button" data-edit-recipe-tab="costing">$ Cost</button><button type="button" data-edit-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-edit-recipe-tab="nutrition">♡ Nutrition</button></>:<><button class="active" type="button" data-recipe-tab="method">☷ Prep Method</button><button type="button" data-recipe-tab="costing">$ Cost</button><button type="button" data-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-recipe-tab="nutrition">♡ Nutrition</button></>}</div>
{editing?<><section class="recipe-overview-strip">
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
+2 -1
View File
@@ -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;
</div>
<div class="detail-tools">
<form action="/app/" role="search"><input name="q" type="search" placeholder="Search" aria-label="Search all items"/></form>
<details class="detail-new-menu"><summary><span class="new-trigger-plus" aria-hidden="true"></span><span>New</span></summary><div><a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a><a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a></div></details>
{!readOnlyMode&&<details class="detail-new-menu"><summary><span class="new-trigger-plus" aria-hidden="true"></span><span>New</span></summary><div><a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a><a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a></div></details>}
</div>
</nav>
+8 -8
View File
@@ -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<string[]>([]),[deleting,setDeleting]=useState(false),[error,setError]=useState("");
const [pendingDelete,setPendingDelete]=useState<string[]>([]);
const dialog=useRef<HTMLDialogElement>(null);
@@ -22,24 +22,24 @@ export default function EntityDirectory({rows,entityType,emptyMessage}:Props) {
location.reload();
};
return <section class="entity-directory-table">
<div class="entity-directory-toolbar">
{!readOnly&&<div class="entity-directory-toolbar">
<input aria-label={`Select all ${entityType} items`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
<strong>{selected.length?`${selected.length} selected`:""}</strong>
{selected.length>0&&<><button class="bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)}> Delete</button><button type="button" onClick={()=>setSelected([])}>Clear</button></>}
</div>
</div>}
{error&&<p class="directory-error">{error}</p>}
<div>{rows.map(row=><div class={`entity-directory-row${selected.includes(row.id)?" selected":""}`}>
<input aria-label={`Select ${row.name}`} type="checkbox" checked={selected.includes(row.id)} onChange={()=>toggle(row.id)}/>
{!readOnly&&<input aria-label={`Select ${row.name}`} type="checkbox" checked={selected.includes(row.id)} onChange={()=>toggle(row.id)}/>}
<span class={`workspace-pill-icon ${row.kind}`}>{row.icon}</span>
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong></a>:<strong>{row.name}</strong>}</span>
<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
</div>)}</div>
{rows.length===0&&<div class="empty-state">{emptyMessage}</div>}
<dialog class="delete-confirmation" ref={dialog} onClose={()=>{if(!deleting)setPendingDelete([]);}}>
{!readOnly&&<dialog class="delete-confirmation" ref={dialog} onClose={()=>{if(!deleting)setPendingDelete([]);}}>
<form method="dialog"><button class="dialog-close" aria-label="Close">×</button></form>
<h2>Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?</h2>
<p>This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.</p>
<div><form method="dialog"><button disabled={deleting}>Cancel</button></form><button class="confirm-delete" type="button" disabled={deleting} onClick={remove}>{deleting?"Deleting…":"Delete"}</button></div>
</dialog>
</dialog>}
</section>;
}
-12
View File
@@ -1,12 +0,0 @@
---
import type { Recipe } from "../lib/types";
import { recipeHref, titleCase } from "../lib/data";
interface Props { recipe: Recipe }
const { recipe } = Astro.props;
---
<article class="recipe-card">
<div class="card-topline"><span>{titleCase(recipe.categories[0] ?? "recipe")}</span></div>
<h2><a href={recipeHref(recipe.id)}>{recipe.title}</a></h2>
<p>{recipe.summary ?? `${recipe.components.length} component${recipe.components.length === 1 ? "" : "s"} · ${recipe.steps.length} preparation step${recipe.steps.length === 1 ? "" : "s"}`}</p>
<div class="tag-row">{recipe.tags.slice(0, 4).map((tag) => <a href={`/tags/${tag}/`}>#{tag}</a>)}</div>
</article>
-8
View File
@@ -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 && <h1>{heading}</h1>}
<div class="recipe-grid">{recipes.map((recipe) => <RecipeCard recipe={recipe} />)}</div>
+17 -61
View File
@@ -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 = <T extends { id: string }>(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);
+2 -1
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export const readOnlyMode = ["1", "true", "yes", "on"].includes(
String(process.env.FORMULATION_READ_ONLY ?? "").toLowerCase(),
);
-5
View File
@@ -1,5 +0,0 @@
---
export const prerender = true;
import BaseLayout from "../../layouts/BaseLayout.astro";
---
<BaseLayout title="Not found"><section class="shell page-heading"><p class="eyebrow">404</p><h1>That page isnt in the book.</h1><p><a href="/docs/recipes/">Browse all recipes →</a></p></section></BaseLayout>
@@ -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));
---
<BaseLayout title={titleCase(category)}><section class="shell page-heading"><p class="eyebrow">Category</p><h1>{titleCase(category)}</h1><p>{selected.length} recipes</p></section><section class="shell section-block"><RecipeList recipes={selected} /></section></BaseLayout>
-7
View File
@@ -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();
---
<BaseLayout title="Categories"><section class="shell page-heading"><p class="eyebrow">Browse</p><h1>Categories</h1></section><section class="shell taxonomy-grid">{categories.map((category) => <a href={`/categories/${category}/`}><strong>{titleCase(category)}</strong><span>{[...recipes.values()].filter((r) => r.categories.includes(category)).length} recipes</span></a>)}</section></BaseLayout>
-75
View File
@@ -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));
---
<BaseLayout title={recipe.title} description={recipe.summary}>
<article class="recipe-page shell">
<header class="recipe-header">
<div>
<div class="recipe-kicker"><a href={`/categories/${recipe.categories[0]}/`}>{titleCase(recipe.categories[0] ?? "recipe")}</a></div>
<h1>{recipe.title}</h1>
{recipe.summary && <p>{recipe.summary}</p>}
<div class="tag-row">{recipe.tags.map((tag) => <a href={`/tags/${routeSlug(tag)}/`}>#{tag}</a>)}</div>
</div>
<dl class="recipe-facts">
<div><dt>Yield</dt><dd>{amount(recipe.yield.amount, units)}</dd></div>
{recipe.yield.servings && <div><dt>Servings</dt><dd>{recipe.yield.servings}</dd></div>}
<div><dt>Basis</dt><dd>{recipe.yield.basis ?? "unspecified"}</dd></div>
</dl>
</header>
<nav class="recipe-task-tabs" aria-label="Recipe sections"><a href="#recipe">Recipe</a><a href="#nutrition">Nutrition</a><a href="#costing">Costing</a><a href="#additional-details">Additional details</a></nav>
<div id="recipe"></div>
{hasPlaceholder && <aside class="notice">This draft needs complete preparation instructions.</aside>}
<RecipeCalculator
client:load
components={calculator}
units={unitRecord}
basisUnitId={basis.unit_id}
yieldQuantity={recipe.yield.amount.quantity}
yieldUnitId={recipe.yield.amount.unit_id}
mode={recipe.scaling?.mode ?? "amount"}
nutrition={nutrition}
cost={cost}
servings={recipe.yield.servings}
/>
{requiredEquipment.length > 0 && (
<section class="equipment-list" aria-labelledby="equipment-heading">
<p class="eyebrow">Setup</p><h2 id="equipment-heading">Equipment</h2>
<ul>{requiredEquipment.map((item) => <li><strong>{item.name}</strong>{item.notes && <small>{item.notes}</small>}</li>)}</ul>
</section>
)}
<section class="preparation">
<p class="eyebrow">Method</p><h2>Preparation</h2>
<ol>{recipe.steps.sort((a, b) => a.order - b.order).map((step) => <li class:list={{ placeholder: step.instruction.startsWith("TODO:") }}><span>{step.instruction}</span>{step.equipment_ids?.length && <small class="step-equipment">{step.equipment_ids.map((id) => equipment.get(id)?.name ?? id).join(" · ")}</small>}</li>)}</ol>
</section>
<section id="additional-details" class="additional-details" aria-labelledby="additional-details-heading">
<p class="eyebrow">Reference</p><h2 id="additional-details-heading">Additional details</h2>
<dl>
<div><dt>Shelf life</dt><dd>{recipe.shelf_life ? <>{amount(recipe.shelf_life.duration, units)}{recipe.shelf_life.storage_condition && <small>{recipe.shelf_life.storage_condition}</small>}{recipe.shelf_life.notes && <small>{recipe.shelf_life.notes}</small>}</> : "Not specified"}</dd></div>
<div><dt>Recipes on</dt><dd>{recipesOn.length ? <ul>{recipesOn.map((parent) => <li><a href={`/docs/recipes/${parent.id}/`}>{parent.title}</a></li>)}</ul> : "Not used as a sub-recipe"}</dd></div>
</dl>
</section>
{recipe.notes?.length && <section class="notes"><p class="eyebrow">Details</p><h2>Notes</h2><ul>{recipe.notes.map((note) => <li>{note}</li>)}</ul></section>}
</article>
</BaseLayout>
-11
View File
@@ -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));
---
<BaseLayout title="Recipes" description="Browse all recipes in the Recipe Book.">
<section class="shell page-heading"><p class="eyebrow">The collection</p><h1>Recipes</h1><p>{allRecipes.length} weight-first formulas, from components and sauces to breads and desserts.</p></section>
<section class="shell section-block"><RecipeList recipes={allRecipes} /></section>
</BaseLayout>
@@ -1,4 +0,0 @@
---
export const prerender = true;
return Astro.redirect("/docs/recipes/taco_bell_slow_roasted_shredded_chicken/", 301);
---
@@ -1,4 +0,0 @@
---
export const prerender = true;
return Astro.redirect("/docs/recipes/udon_noodle_sauce/", 301);
---
@@ -1,5 +0,0 @@
---
export const prerender = true;
import BaseLayout from "../../../../layouts/BaseLayout.astro";
---
<BaseLayout title="Dough Ingredient Incorporation"><article class="shell prose"><p class="eyebrow">Reference</p><h1>Dough Ingredient Incorporation</h1><p>Guidelines for incorporating common dough ingredients into a mixture.</p><h2>Dry ingredients</h2><p>Include at the start of mixing.</p><h2>Eggs</h2><p>Add water to eggs and include all at the start of mixing.</p><h2>Fat</h2><table><thead><tr><th>Fat type</th><th>Amount</th><th>Inclusion</th></tr></thead><tbody><tr><td>Solid</td><td>Low (&lt; 5%)</td><td>Start of mix</td></tr><tr><td>Solid</td><td>Medium (&lt; 15%)</td><td>Halfway through mix</td></tr><tr><td>Solid</td><td>High (&gt; 15%)</td><td>Just before full development</td></tr><tr><td>Liquid</td><td>Medium (&lt; 15%)</td><td>Start of mix</td></tr><tr><td>Liquid</td><td>High (&gt; 15%)</td><td>After full development</td></tr></tbody></table><h2>Sugar</h2><table><thead><tr><th>Amount</th><th>Inclusion</th></tr></thead><tbody><tr><td>Low (&lt; 12%)</td><td>Start of mix</td></tr><tr><td>Medium (&lt; 20%)</td><td>Add gradually</td></tr><tr><td>High (&gt; 20%)</td><td>After full development</td></tr></tbody></table></article></BaseLayout>
@@ -1,5 +0,0 @@
---
export const prerender = true;
import BaseLayout from "../../../../layouts/BaseLayout.astro";
---
<BaseLayout title="Dough Shaping"><article class="shell prose"><p class="eyebrow">Reference</p><h1>Dough Shaping</h1><p>Guidelines for shaping dough. This reference is ready for additional technique notes.</p></article></BaseLayout>
@@ -1,5 +0,0 @@
---
export const prerender = true;
import BaseLayout from "../../../../layouts/BaseLayout.astro";
---
<BaseLayout title="Reference"><section class="shell page-heading"><p class="eyebrow">Techniques</p><h1>Reference</h1><p>Practical notes for repeatable dough production.</p></section><section class="shell taxonomy-grid"><a href="/docs/reference/dough-ingredient-incorporation/"><strong>Dough Ingredient Incorporation</strong><span>When to add flour, eggs, fat, and sugar</span></a><a href="/docs/reference/dough-shaping/"><strong>Dough Shaping</strong><span>Guidelines for shaping dough</span></a></section></BaseLayout>
-4
View File
@@ -1,4 +0,0 @@
---
export const prerender = true;
return Astro.redirect("/docs/recipes/", 301);
---
-13
View File
@@ -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));
---
<BaseLayout title={`#${tag}`}><section class="shell page-heading"><p class="eyebrow">Tag</p><h1>#{tag}</h1><p>{selected.length} recipes</p></section><section class="shell section-block"><RecipeList recipes={selected} /></section></BaseLayout>
-7
View File
@@ -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();
---
<BaseLayout title="Tags"><section class="shell page-heading"><p class="eyebrow">Browse</p><h1>Tags</h1></section><section class="shell tag-cloud">{tags.map((tag) => <a href={`/tags/${routeSlug(tag)}/`}>#{tag} <small>{[...recipes.values()].filter((r) => r.tags.includes(tag)).length}</small></a>)}</section></BaseLayout>