210 lines
5.1 KiB
TypeScript
210 lines
5.1 KiB
TypeScript
import {
|
|
type DocumentChild,
|
|
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 { parseIngredients } 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 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;
|
|
}).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);
|
|
|
|
let ingredientsText = getTextOfRange(groups[1], original);
|
|
if (ingredientsText) {
|
|
ingredientsText = ingredientsText.replace(/#+\s?Ingredients?/, "");
|
|
} else {
|
|
ingredientsText = "";
|
|
}
|
|
|
|
const ingredients = parseIngredients(ingredientsText);
|
|
|
|
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<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 ? `` : "";
|
|
|
|
// 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}`;
|
|
}
|
|
|
|
if (item.quantity) {
|
|
return `- **${item.quantity}** ${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<Recipe>({
|
|
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;
|