import { type DocumentChild, getTextOfChild, getTextOfRange, parseDocument, } from "@lib/documents.ts"; import { parse, stringify } from "yaml"; import { createCrud } from "@lib/crud.ts"; import { extractHashTags } from "@lib/string.ts"; import { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts"; import { fixRenderedMarkdown } from "@lib/helpers.ts"; import { parseIngredient } from "@lib/parseIngredient.ts"; export type Recipe = { type: "recipe"; id: string; name: string; description?: string; markdown?: string; ingredients: (Ingredient | IngredientGroup)[]; instructions?: string[]; notes?: string[]; tags: string[]; meta?: { time?: string; link?: string; image?: string; rating?: number; portion?: number; author?: string; average?: string; thumbnail?: string; }; }; function parseIngredientItem(listItem: DocumentChild): Ingredient | undefined { if (listItem.type === "listItem") { const children: DocumentChild[] = listItem.children[0]?.children || listItem.children; const text = children.map((c) => getTextOfChild(c)).join(" ").trim(); return parseIngredient(text); } } const isIngredient = (item: Ingredient | undefined): item is Ingredient => { return !!item; }; function parseIngredientsList(list: DocumentChild): Ingredient[] { if (list.type === "list" && "children" in list) { return list.children.map((listItem) => { return parseIngredientItem(listItem); }).filter(isIngredient); } return []; } function parseIngredients(children: DocumentChild[]): Recipe["ingredients"] { const ingredients: (Ingredient | IngredientGroup)[] = []; if (!children) return []; let skip = false; for (let i = 0; i < children.length; i++) { if (skip) { skip = false; continue; } const child = children[i]; if (child.type === "paragraph") { const nextChild = children[i + 1]; if (!nextChild || nextChild.type !== "list") continue; const name = getTextOfChild(child); ingredients.push({ name: name || "", items: parseIngredientsList(nextChild), }); skip = true; continue; } if (child.type === "list") { ingredients.push(...parseIngredientsList(child)); } } return ingredients; } 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; const [, , text] = match; return text; }).filter((step) => !!step); return steps as string[]; } 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") { try { meta = parse(child.value) as Recipe["meta"]; } catch (err) { console.log("Error parsing YAML", err); } 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); } let description = getTextOfRange(groups[0], original); const ingredients = parseIngredients(groups[1]); 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; } } const tags = extractHashTags(description || ""); if (description) { for (const tag of tags) { description = description.replace("#" + tag, ""); } } return { type: "recipe", id, meta, name, tags, markdown: original, notes: getTextOfRange(groups[3], original)?.split("\n"), description, ingredients, instructions, }; } function filterUndefinedFromObject( 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")}` : ""} `); } const crud = createCrud({ prefix: `Recipes/`, parse: parseRecipe, render: renderRecipe, hasThumbnails: true, }); export const getAllRecipes = crud.readAll; export const getRecipe = crud.read; export const updateRecipe = crud.update; export const createRecipe = crud.create;