serve application in enforced read-only mode
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: ["."] } },
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>☷ 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>☷ 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const readOnlyMode = ["1", "true", "yes", "on"].includes(
|
||||
String(process.env.FORMULATION_READ_ONLY ?? "").toLowerCase(),
|
||||
);
|
||||
@@ -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 isn’t 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 (< 5%)</td><td>Start of mix</td></tr><tr><td>Solid</td><td>Medium (< 15%)</td><td>Halfway through mix</td></tr><tr><td>Solid</td><td>High (> 15%)</td><td>Just before full development</td></tr><tr><td>Liquid</td><td>Medium (< 15%)</td><td>Start of mix</td></tr><tr><td>Liquid</td><td>High (> 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 (< 12%)</td><td>Start of mix</td></tr><tr><td>Medium (< 20%)</td><td>Add gradually</td></tr><tr><td>High (> 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>
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
return Astro.redirect("/docs/recipes/", 301);
|
||||
---
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user