memorium/lib/resource/recipes.ts

205 lines
5.0 KiB
TypeScript
Raw Normal View History

2023-07-26 13:47:01 +02:00
import {
type DocumentChild,
getTextOfRange,
parseDocument,
} from "@lib/documents.ts";
2025-01-18 00:46:05 +01:00
import { parse, stringify } from "yaml";
2023-08-01 17:50:00 +02:00
import { createCrud } from "@lib/crud.ts";
2023-08-02 18:13:31 +02:00
import { extractHashTags } from "@lib/string.ts";
2025-01-18 00:46:05 +01:00
import { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { parseIngredients } from "@lib/parseIngredient.ts";
2023-07-26 13:47:01 +02:00
export type Recipe = {
2023-08-02 18:13:31 +02:00
type: "recipe";
2023-07-26 13:47:01 +02:00
id: string;
2023-08-02 18:13:31 +02:00
name: string;
description?: string;
2025-01-18 00:46:05 +01:00
markdown?: string;
ingredients: (Ingredient | IngredientGroup)[];
instructions?: string[];
notes?: string[];
2023-08-02 18:13:31 +02:00
tags: string[];
2023-07-26 13:47:01 +02:00
meta?: {
2023-08-04 23:36:35 +02:00
time?: string;
2023-07-26 13:47:01 +02:00
link?: string;
image?: string;
rating?: number;
portion?: number;
2023-08-02 18:13:31 +02:00
author?: string;
2023-08-12 18:32:56 +02:00
average?: string;
thumbnail?: string;
2023-07-26 13:47:01 +02:00
};
};
2025-01-18 00:46:05 +01:00
function extractSteps(
content: string,
seperator: RegExp = /\n(?=\d+\.)/g,
): string[] {
const steps = content.split(seperator).map((step) => {
const match = step.match(/^(\d+)\.\s*(.*)/);
if (match) return match[2];
return step;
2025-01-18 00:46:05 +01:00
}).filter((step) => !!step);
return steps as string[];
}
2023-07-26 13:47:01 +02:00
export function parseRecipe(original: string, id: string): Recipe {
const doc = parseDocument(original);
let name = "";
let meta: Recipe["meta"] = {};
const groups: DocumentChild[][] = [];
let group: DocumentChild[] = [];
for (const child of doc.children) {
if (child.type === "yaml") {
2023-12-08 21:17:34 +01:00
try {
meta = parse(child.value) as Recipe["meta"];
2025-01-18 00:46:05 +01:00
} catch (err) {
console.log("Error parsing YAML", err);
2023-12-08 21:17:34 +01:00
}
2023-07-26 13:47:01 +02:00
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (child.type === "thematicBreak") {
groups.push(group);
group = [];
continue;
}
group.push(child);
}
if (group.length) {
groups.push(group);
}
2023-08-02 18:13:31 +02:00
let description = getTextOfRange(groups[0], original);
2023-07-26 13:47:01 +02:00
let ingredientsText = getTextOfRange(groups[1], original);
if (ingredientsText) {
ingredientsText = ingredientsText.replace(/#+\s?Ingredients?/, "");
} else {
ingredientsText = "";
}
const ingredients = parseIngredients(ingredientsText);
2023-07-26 13:47:01 +02:00
2025-01-18 00:46:05 +01:00
const instructionText = getTextOfRange(groups[2], original);
let instructions = extractSteps(instructionText || "");
if (instructions.length <= 1) {
const d = extractSteps(instructionText || "", /\n/g);
if (d.length > instructions.length) {
instructions = d;
}
}
2023-07-26 13:47:01 +02:00
2023-08-02 18:13:31 +02:00
const tags = extractHashTags(description || "");
if (description) {
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
}
2023-07-26 13:47:01 +02:00
return {
2023-08-02 18:13:31 +02:00
type: "recipe",
2023-07-26 13:47:01 +02:00
id,
meta,
2023-07-26 13:47:01 +02:00
name,
2023-08-02 18:13:31 +02:00
tags,
2025-01-18 00:46:05 +01:00
markdown: original,
notes: getTextOfRange(groups[3], original)?.split("\n"),
2023-08-05 21:52:43 +02:00
description,
2023-07-26 13:47:01 +02:00
ingredients,
2025-01-18 00:46:05 +01:00
instructions,
2023-07-26 13:47:01 +02:00
};
}
2023-08-01 17:50:00 +02:00
2025-01-18 00:46:05 +01:00
function filterUndefinedFromObject<T extends { [key: string]: unknown }>(
obj: T,
) {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined),
);
}
export function renderRecipe(recipe: Recipe) {
const meta = filterUndefinedFromObject(recipe.meta || {});
// Clean up meta properties
delete meta.thumbnail;
delete meta.average;
const recipeImage = meta.image ? `![](${meta.image})` : "";
// Format ingredient groups and standalone ingredients
const ingredients = recipe.ingredients
.map((item) => {
if ("items" in item) {
return `\n*${item.name}*\n${
item.items
.map((ing) => {
if (ing.quantity && ing.unit) {
return `- **${ing.quantity.trim() || ""}${
ing.unit.trim() || ""
}** ${ing.name}`;
}
return `- ${ing.name}`;
})
.join("\n")
}`;
}
if (item.quantity && item.unit) {
return `- **${item.quantity?.trim() || ""}${
item.unit?.trim() || ""
}** ${item.name}`;
}
return `- ${item.name}`;
})
.join("\n");
// Format instructions as a numbered list
const instructions = recipe.instructions
? recipe.instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")
: "";
// Render the final markdown
return fixRenderedMarkdown(`${
Object.keys(meta).length
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${recipe.name}
${recipe.meta?.image ? recipeImage : ""}
${recipe.tags.map((t) => `#${t.replaceAll(" ", "-")}`).join(" ")}
${recipe.description || ""}
---
${ingredients ? `## Ingredients\n\n${ingredients}\n\n---\n` : ""}
${instructions ? `${instructions}\n\n---` : ""}
${recipe.notes?.length ? `\n${recipe.notes.join("\n")}` : ""}
`);
}
2023-08-01 17:50:00 +02:00
const crud = createCrud<Recipe>({
prefix: `Recipes/`,
parse: parseRecipe,
2025-01-18 00:46:05 +01:00
render: renderRecipe,
hasThumbnails: true,
2023-08-01 17:50:00 +02:00
});
export const getAllRecipes = crud.readAll;
export const getRecipe = crud.read;
export const updateRecipe = crud.update;
export const createRecipe = crud.create;