diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx
index a9c0845..ea5ee1b 100644
--- a/apps/web/app/(app)/collections/[id]/page.tsx
+++ b/apps/web/app/(app)/collections/[id]/page.tsx
@@ -10,6 +10,8 @@ import { ForkCollectionButton } from "@/components/collections/fork-collection-b
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
+import { collectionToMarkdown } from "@/lib/markdown/collection";
import { getMessages } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
@@ -46,10 +48,20 @@ export default async function CollectionPage({ params }: Params) {
{col.recipes.length > 0 && (
-
-
- {m.collections.exportPdf}
-
+ <>
+
+
+ {m.collections.exportPdf}
+
+
(r.recipe ? [r.recipe] : [])),
+ })}
+ filename={col.name}
+ />
+ >
)}
{isOwner && }
{!isOwner && col.isPublic && (
diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx
index 90ff5cd..26bab55 100644
--- a/apps/web/app/(app)/meal-plan/page.tsx
+++ b/apps/web/app/(app)/meal-plan/page.tsx
@@ -9,6 +9,8 @@ import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils";
+import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
+import { mealPlanToMarkdown } from "@/lib/markdown/meal-plan";
import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = { title: "Meal Plan" };
@@ -98,6 +100,10 @@ export default async function MealPlanPage({
{msgs.common.print}
+
diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx
index 2c1cbc0..c66e4ba 100644
--- a/apps/web/app/(app)/pantry/page.tsx
+++ b/apps/web/app/(app)/pantry/page.tsx
@@ -16,16 +16,18 @@ export default async function PantryPage() {
orderBy: asc(pantryItems.rawName),
});
+ const mappedItems = items.map((i) => ({
+ id: i.id,
+ rawName: i.rawName,
+ quantity: i.quantity,
+ unit: i.unit,
+ expiresAt: i.expiresAt?.toISOString() ?? null,
+ }));
+
return (
-
-
({
- id: i.id,
- rawName: i.rawName,
- quantity: i.quantity,
- unit: i.unit,
- expiresAt: i.expiresAt?.toISOString() ?? null,
- }))} />
+
+
);
}
diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx
index ecac380..5c53a72 100644
--- a/apps/web/app/(app)/recipes/[id]/page.tsx
+++ b/apps/web/app/(app)/recipes/[id]/page.tsx
@@ -31,6 +31,8 @@ import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
+import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
+import { recipeToMarkdown } from "@/lib/markdown/recipe";
import { getMessages, formatMessage } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
@@ -172,6 +174,20 @@ export default async function RecipePage({ params }: Params) {
/>
+
{isOwner && (
<>
};
@@ -52,6 +54,10 @@ export default async function ShoppingListPage({ params }: Params) {
{m.common.print}
+
{tCommon("print")}
+
);
diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx
index 60e7baa..e464923 100644
--- a/apps/web/components/recipe/recipe-card.tsx
+++ b/apps/web/components/recipe/recipe-card.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Clock, Users, Lock, Globe, Link2 } from "lucide-react";
diff --git a/apps/web/components/shared/export-markdown-button.tsx b/apps/web/components/shared/export-markdown-button.tsx
new file mode 100644
index 0000000..6009ffc
--- /dev/null
+++ b/apps/web/components/shared/export-markdown-button.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Copy, Download, FileDown } from "lucide-react";
+import { toast } from "sonner";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function ExportMarkdownButton({
+ markdown,
+ filename,
+}: {
+ markdown: string;
+ filename: string;
+}) {
+ const t = useTranslations("common");
+
+ async function handleCopy() {
+ try {
+ await navigator.clipboard.writeText(markdown);
+ toast.success(t("copiedToClipboard"));
+ } catch {
+ toast.error(t("copyFailed"));
+ }
+ }
+
+ function handleDownload() {
+ const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename.endsWith(".md") ? filename : `${filename}.md`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ return (
+
+
+
+
+ } />
+
+ { void handleCopy(); }}>
+
+ {t("copyMarkdown")}
+
+
+
+ {t("downloadMarkdown")}
+
+
+
+ );
+}
diff --git a/apps/web/lib/markdown/collection.ts b/apps/web/lib/markdown/collection.ts
new file mode 100644
index 0000000..5763dc3
--- /dev/null
+++ b/apps/web/lib/markdown/collection.ts
@@ -0,0 +1,32 @@
+type CollectionMarkdownInput = {
+ name: string;
+ description: string | null;
+ recipes: Array<{
+ title: string;
+ description: string | null;
+ baseServings: number;
+ prepMins: number | null;
+ cookMins: number | null;
+ difficulty: "easy" | "medium" | "hard" | null;
+ }>;
+};
+
+export function collectionToMarkdown(collection: CollectionMarkdownInput): string {
+ const lines: string[] = [`# ${collection.name}`, ""];
+
+ if (collection.description) {
+ lines.push(collection.description, "");
+ }
+
+ for (const recipe of collection.recipes) {
+ lines.push(`## ${recipe.title}`, "");
+ if (recipe.description) lines.push(recipe.description, "");
+ const meta: string[] = [`Servings: ${recipe.baseServings}`];
+ if (recipe.prepMins) meta.push(`Prep: ${recipe.prepMins} min`);
+ if (recipe.cookMins) meta.push(`Cook: ${recipe.cookMins} min`);
+ if (recipe.difficulty) meta.push(`Difficulty: ${recipe.difficulty}`);
+ lines.push(meta.join(" · "), "");
+ }
+
+ return lines.join("\n").trim() + "\n";
+}
diff --git a/apps/web/lib/markdown/meal-plan.ts b/apps/web/lib/markdown/meal-plan.ts
new file mode 100644
index 0000000..4bacf49
--- /dev/null
+++ b/apps/web/lib/markdown/meal-plan.ts
@@ -0,0 +1,33 @@
+type MealPlanMarkdownInput = {
+ label: string;
+ entries: Array<{
+ day: string;
+ mealType: string;
+ servings: number;
+ note: string | null;
+ recipe: { title: string } | null;
+ }>;
+};
+
+export function mealPlanToMarkdown(plan: MealPlanMarkdownInput): string {
+ const lines: string[] = [`# Meal Plan — ${plan.label}`, ""];
+
+ const byDay = new Map();
+ for (const entry of plan.entries) {
+ const group = byDay.get(entry.day) ?? [];
+ group.push(entry);
+ byDay.set(entry.day, group);
+ }
+
+ for (const [day, entries] of byDay) {
+ lines.push(`## ${day}`, "");
+ for (const entry of entries) {
+ const title = entry.recipe?.title ?? "(no recipe)";
+ const note = entry.note ? ` — ${entry.note}` : "";
+ lines.push(`- **${entry.mealType}**: ${title} (${entry.servings} servings)${note}`);
+ }
+ lines.push("");
+ }
+
+ return lines.join("\n").trim() + "\n";
+}
diff --git a/apps/web/lib/markdown/pantry.ts b/apps/web/lib/markdown/pantry.ts
new file mode 100644
index 0000000..da32da2
--- /dev/null
+++ b/apps/web/lib/markdown/pantry.ts
@@ -0,0 +1,15 @@
+type PantryMarkdownInput = {
+ items: Array<{ rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null }>;
+};
+
+export function pantryToMarkdown(pantry: PantryMarkdownInput): string {
+ const lines: string[] = ["# Pantry", ""];
+
+ for (const item of pantry.items) {
+ const qty = [item.quantity, item.unit].filter(Boolean).join(" ");
+ const expiry = item.expiresAt ? ` (expires ${new Date(item.expiresAt).toLocaleDateString()})` : "";
+ lines.push(`- ${qty ? `${qty} ` : ""}${item.rawName}${expiry}`);
+ }
+
+ return lines.join("\n").trim() + "\n";
+}
diff --git a/apps/web/lib/markdown/recipe.ts b/apps/web/lib/markdown/recipe.ts
new file mode 100644
index 0000000..d5a29df
--- /dev/null
+++ b/apps/web/lib/markdown/recipe.ts
@@ -0,0 +1,54 @@
+type RecipeMarkdownInput = {
+ title: string;
+ description: string | null;
+ baseServings: number;
+ prepMins: number | null;
+ cookMins: number | null;
+ difficulty: "easy" | "medium" | "hard" | null;
+ sourceUrl: string | null;
+ ingredients: Array<{ rawName: string; quantity: string | null; unit: string | null; note: string | null }>;
+ steps: Array<{ instruction: string; timerSeconds: number | null }>;
+};
+
+function formatQuantity(quantity: string | null, unit: string | null): string {
+ return [quantity, unit].filter(Boolean).join(" ");
+}
+
+export function recipeToMarkdown(recipe: RecipeMarkdownInput): string {
+ const lines: string[] = [`# ${recipe.title}`, ""];
+
+ if (recipe.description) {
+ lines.push(recipe.description, "");
+ }
+
+ const meta: string[] = [`Servings: ${recipe.baseServings}`];
+ if (recipe.prepMins) meta.push(`Prep: ${recipe.prepMins} min`);
+ if (recipe.cookMins) meta.push(`Cook: ${recipe.cookMins} min`);
+ if (recipe.difficulty) meta.push(`Difficulty: ${recipe.difficulty}`);
+ lines.push(meta.join(" · "), "");
+
+ if (recipe.ingredients.length > 0) {
+ lines.push("## Ingredients", "");
+ for (const ing of recipe.ingredients) {
+ const qty = formatQuantity(ing.quantity, ing.unit);
+ const note = ing.note ? ` (${ing.note})` : "";
+ lines.push(`- ${qty ? `${qty} ` : ""}${ing.rawName}${note}`);
+ }
+ lines.push("");
+ }
+
+ if (recipe.steps.length > 0) {
+ lines.push("## Instructions", "");
+ recipe.steps.forEach((step, i) => {
+ const timer = step.timerSeconds ? ` (${Math.round(step.timerSeconds / 60)} min)` : "";
+ lines.push(`${i + 1}. ${step.instruction}${timer}`);
+ });
+ lines.push("");
+ }
+
+ if (recipe.sourceUrl) {
+ lines.push(`Source: ${recipe.sourceUrl}`);
+ }
+
+ return lines.join("\n").trim() + "\n";
+}
diff --git a/apps/web/lib/markdown/shopping-list.ts b/apps/web/lib/markdown/shopping-list.ts
new file mode 100644
index 0000000..11c1919
--- /dev/null
+++ b/apps/web/lib/markdown/shopping-list.ts
@@ -0,0 +1,27 @@
+type ShoppingListMarkdownInput = {
+ name: string;
+ items: Array<{ rawName: string; quantity: string | null; unit: string | null; aisle: string | null; checked: boolean }>;
+};
+
+export function shoppingListToMarkdown(list: ShoppingListMarkdownInput): string {
+ const lines: string[] = [`# ${list.name}`, ""];
+
+ const byAisle = new Map();
+ for (const item of list.items) {
+ const aisle = item.aisle ?? "Other";
+ const group = byAisle.get(aisle) ?? [];
+ group.push(item);
+ byAisle.set(aisle, group);
+ }
+
+ for (const [aisle, items] of byAisle) {
+ lines.push(`## ${aisle}`, "");
+ for (const item of items) {
+ const qty = [item.quantity, item.unit].filter(Boolean).join(" ");
+ lines.push(`- [${item.checked ? "x" : " "}] ${qty ? `${qty} ` : ""}${item.rawName}`);
+ }
+ lines.push("");
+ }
+
+ return lines.join("\n").trim() + "\n";
+}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 951d924..6a6d243 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -301,6 +301,11 @@
"save": "Save",
"saved": "Saved",
"saveFailed": "Failed to save",
+ "exportMarkdown": "Export as Markdown",
+ "copyMarkdown": "Copy as Markdown",
+ "downloadMarkdown": "Download as Markdown",
+ "copiedToClipboard": "Copied to clipboard",
+ "copyFailed": "Failed to copy",
"print": "Print",
"share": "Share",
"deleteFailed": "Delete failed",
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 6a3f42d..409c394 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -301,6 +301,11 @@
"save": "Enregistrer",
"saved": "Enregistré",
"saveFailed": "Échec de l'enregistrement",
+ "exportMarkdown": "Exporter en Markdown",
+ "copyMarkdown": "Copier en Markdown",
+ "downloadMarkdown": "Télécharger en Markdown",
+ "copiedToClipboard": "Copié dans le presse-papiers",
+ "copyFailed": "Échec de la copie",
"print": "Imprimer",
"share": "Partager",
"deleteFailed": "Échec de la suppression",