Formulate website

This commit is contained in:
Jake Runyan 2026-02-09 15:25:54 -08:00
parent bc013860b0
commit 4133d2e342
14 changed files with 453 additions and 765 deletions

View File

@ -6,7 +6,12 @@
"Bash(npm --version:*)", "Bash(npm --version:*)",
"Bash(where:*)", "Bash(where:*)",
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(npm run build:*)" "Bash(npm run build:*)",
"Bash(node scripts/copy-recipe-assets.js:*)",
"Bash(dir:*)",
"Bash(findstr:*)",
"Bash(timeout 10 npm run dev:*)",
"Bash(grep:*)"
] ]
} }
} }

View File

@ -24,4 +24,16 @@ body {
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
} }

View File

@ -1,6 +1,8 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getRecipeByCategoryAndSlug, getAllRecipePaths } from '@/lib/recipes'; import { getRecipeByCategoryAndSlug, getAllRecipePaths } from '@/lib/recipes';
import { parseRecipeSections } from '@/lib/parseRecipe';
import RecipePageClient from '@/components/RecipePageClient';
interface RecipePageProps { interface RecipePageProps {
params: Promise<{ params: Promise<{
@ -47,86 +49,12 @@ export default async function RecipePage({ params }: RecipePageProps) {
notFound(); notFound();
} }
const sections = parseRecipeSections(recipe.content);
return ( return (
<article className="max-w-4xl mx-auto"> <RecipePageClient
<header className="mb-8 space-y-4"> recipe={recipe}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"> sections={sections}
<span className="capitalize">{recipe.category}</span> />
<span></span>
<time dateTime={recipe.date}>
Published {new Date(recipe.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
{recipe.lastUpdated !== recipe.date && (
<>
<span></span>
<time dateTime={recipe.lastUpdated}>
Updated {new Date(recipe.lastUpdated).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</>
)}
</div>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white">
{recipe.title}
</h1>
<p className="text-xl text-gray-600 dark:text-gray-400">
{recipe.description}
</p>
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-2">
<span className="font-medium">Prep:</span>
<span>{recipe.prepTime} min</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Cook:</span>
<span>{recipe.cookTime} min</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Total:</span>
<span>{recipe.totalTime} min</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Servings:</span>
<span>{recipe.servings}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Difficulty:</span>
<span className="capitalize">{recipe.difficulty}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{recipe.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full"
>
{tag}
</span>
))}
</div>
</header>
<div className="prose dark:prose-invert max-w-none">
<div className="bg-gray-50 dark:bg-gray-900 p-8 rounded-lg">
<p className="text-gray-600 dark:text-gray-400">
Recipe content will be rendered here from MDX
</p>
<pre className="text-xs mt-4 overflow-auto">
{recipe.content.substring(0, 500)}...
</pre>
</div>
</div>
</article>
); );
} }

View File

@ -7,22 +7,21 @@ interface RecipeCardProps {
} }
export default function RecipeCard({ recipe }: RecipeCardProps) { export default function RecipeCard({ recipe }: RecipeCardProps) {
const imageRelativePath = recipe.displayPhoto.startsWith('./') // Convert relative path to public URL
? `${recipe.folderPath}/${recipe.displayPhoto.replace('./', '')}` const imageSrc = recipe.displayPhoto.startsWith('./')
? `/recipes/${recipe.folderPath}/${recipe.displayPhoto.replace('./', '')}`.replace(/\\/g, '/')
: recipe.displayPhoto; : recipe.displayPhoto;
const normalizedPath = imageRelativePath.replace(/\\/g, '/');
const imageSrc = `/api/recipe-image?path=${encodeURIComponent(normalizedPath)}`;
return ( return (
<Link <Link
href={`/recipes/${recipe.category}/${recipe.slug}`} href={`/recipes/${recipe.category}/${recipe.slug}`}
className="group block bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition-shadow" className="group block bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
aria-label={`View recipe: ${recipe.title}`}
> >
<div className="aspect-video bg-gray-200 dark:bg-gray-700 relative overflow-hidden"> <div className="aspect-video bg-gray-200 dark:bg-gray-700 relative overflow-hidden">
<Image <Image
src={imageSrc} src={imageSrc}
alt={recipe.title} alt={`${recipe.title} - Recipe photo`}
fill fill
className="object-cover" className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
@ -39,32 +38,43 @@ export default function RecipeCard({ recipe }: RecipeCardProps) {
{recipe.description} {recipe.description}
</p> </p>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-500" role="list" aria-label="Recipe details">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1" role="listitem">
{recipe.totalTime} min <span aria-hidden="true"></span>
<span className="sr-only">Total time: </span>
{recipe.totalTime} min
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1" role="listitem">
👥 {recipe.servings} servings <span aria-hidden="true">👥</span>
<span className="sr-only">Servings: </span>
{recipe.servings} servings
</span> </span>
<span className="flex items-center gap-1"> <span
{recipe.difficulty === 'easy' && '⭐'} className="flex items-center gap-1"
{recipe.difficulty === 'medium' && '⭐⭐'} role="listitem"
{recipe.difficulty === 'hard' && '⭐⭐⭐'} aria-label={`Difficulty: ${recipe.difficulty}`}
>
<span aria-hidden="true">
{recipe.difficulty === 'easy' && '⭐'}
{recipe.difficulty === 'medium' && '⭐⭐'}
{recipe.difficulty === 'hard' && '⭐⭐⭐'}
</span>
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1" role="list" aria-label="Recipe tags">
{recipe.tags.slice(0, 3).map((tag) => ( {recipe.tags.slice(0, 3).map((tag) => (
<span <span
key={tag} key={tag}
role="listitem"
className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded" className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded"
> >
{tag} {tag}
</span> </span>
))} ))}
{recipe.tags.length > 3 && ( {recipe.tags.length > 3 && (
<span className="px-2 py-1 text-xs text-gray-500 dark:text-gray-500"> <span role="listitem" className="px-2 py-1 text-xs text-gray-500 dark:text-gray-500">
+{recipe.tags.length - 3} +{recipe.tags.length - 3} more
</span> </span>
)} )}
</div> </div>

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import RecipesSidebar, { FilterState } from './RecipesSidebar'; import RecipeLayout from './RecipeLayout';
import { FilterState } from './RecipesSidebar';
import RecipeCard from './RecipeCard'; import RecipeCard from './RecipeCard';
import type { Recipe } from '@/lib/recipes'; import type { Recipe } from '@/lib/recipes';
@ -17,7 +18,6 @@ export default function RecipesClient({ recipes, categories, tags }: RecipesClie
category: '', category: '',
selectedTags: [], selectedTags: [],
}); });
const [sidebarOpen, setSidebarOpen] = useState(false);
const filteredRecipes = useMemo(() => { const filteredRecipes = useMemo(() => {
return recipes.filter((recipe) => { return recipes.filter((recipe) => {
@ -44,42 +44,35 @@ export default function RecipesClient({ recipes, categories, tags }: RecipesClie
}, [recipes, filters]); }, [recipes, filters]);
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6"> <RecipeLayout
<div className="lg:col-span-1"> categories={categories}
<RecipesSidebar tags={tags}
categories={categories} onFilterChange={setFilters}
tags={tags} showFilters={true}
onFilterChange={setFilters} >
isOpen={sidebarOpen} <div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
onToggle={() => setSidebarOpen(!sidebarOpen)} {filteredRecipes.length === recipes.length
/> ? `Showing all ${recipes.length} recipes`
: `Showing ${filteredRecipes.length} of ${recipes.length} recipes`}
</div> </div>
<div className="lg:col-span-4"> {filteredRecipes.length > 0 ? (
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredRecipes.length === recipes.length {filteredRecipes.map((recipe) => (
? `Showing all ${recipes.length} recipes` <RecipeCard key={recipe.slug} recipe={recipe} />
: `Showing ${filteredRecipes.length} of ${recipes.length} recipes`} ))}
</div> </div>
) : (
{filteredRecipes.length > 0 ? ( <div className="text-center py-12">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"> <div className="text-6xl mb-4">🔍</div>
{filteredRecipes.map((recipe) => ( <h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
<RecipeCard key={recipe.slug} recipe={recipe} /> No recipes found
))} </h3>
</div> <p className="text-gray-600 dark:text-gray-400">
) : ( Try adjusting your filters or search terms
<div className="text-center py-12"> </p>
<div className="text-6xl mb-4">🔍</div> </div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2"> )}
No recipes found </RecipeLayout>
</h3>
<p className="text-gray-600 dark:text-gray-400">
Try adjusting your filters or search terms
</p>
</div>
)}
</div>
</div>
); );
} }

View File

@ -1,13 +1,13 @@
'use client'; 'use client';
import { useState, useMemo } from 'react'; import { useState, useEffect } from 'react';
import SelectedTags from './SelectedTags';
import TagSelector from './TagSelector';
interface RecipesSidebarProps { interface RecipesSidebarProps {
categories: string[]; categories: string[];
tags: string[]; tags: string[];
onFilterChange: (filters: FilterState) => void; onFilterChange: (filters: FilterState) => void;
isOpen: boolean;
onToggle: () => void;
} }
export interface FilterState { export interface FilterState {
@ -16,13 +16,12 @@ export interface FilterState {
selectedTags: string[]; selectedTags: string[];
} }
export default function RecipesSidebar({ categories, tags, onFilterChange, isOpen, onToggle }: RecipesSidebarProps) { export default function RecipesSidebar({ categories, tags, onFilterChange }: RecipesSidebarProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showAllTags, setShowAllTags] = useState(false);
useMemo(() => { useEffect(() => {
onFilterChange({ search, category, selectedTags }); onFilterChange({ search, category, selectedTags });
}, [search, category, selectedTags, onFilterChange]); }, [search, category, selectedTags, onFilterChange]);
@ -32,6 +31,14 @@ export default function RecipesSidebar({ categories, tags, onFilterChange, isOpe
); );
}; };
const handleRemoveTag = (tag: string) => {
setSelectedTags((prev) => prev.filter((t) => t !== tag));
};
const handleClearTags = () => {
setSelectedTags([]);
};
const handleClearFilters = () => { const handleClearFilters = () => {
setSearch(''); setSearch('');
setCategory(''); setCategory('');
@ -39,18 +46,10 @@ export default function RecipesSidebar({ categories, tags, onFilterChange, isOpe
}; };
const hasActiveFilters = search || category || selectedTags.length > 0; const hasActiveFilters = search || category || selectedTags.length > 0;
const displayedTags = showAllTags ? tags : tags.slice(0, 10);
return ( return (
<aside> <section aria-label="Recipe filters">
<button <div className="space-y-3">
onClick={onToggle}
className="lg:hidden w-full mb-3 px-3 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
>
{isOpen ? '✕ Hide Filters' : '⚙️ Show Filters'}
</button>
<div className={`space-y-3 ${!isOpen ? 'hidden lg:block' : ''}`}>
<div> <div>
<label htmlFor="search" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="search" className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Search
@ -84,55 +83,34 @@ export default function RecipesSidebar({ categories, tags, onFilterChange, isOpe
</select> </select>
</div> </div>
<div> <div className="space-y-2">
<div className="flex items-center justify-between mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300"> Tags
Tags </label>
</label>
{selectedTags.length > 0 && ( <SelectedTags
<button tags={selectedTags}
onClick={() => setSelectedTags([])} onRemove={handleRemoveTag}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline" onClear={handleClearTags}
> />
Clear
</button> <TagSelector
)} availableTags={tags}
</div> selectedTags={selectedTags}
<div className="space-y-0.5 max-h-48 overflow-y-auto"> onToggleTag={handleTagToggle}
{displayedTags.map((tag) => ( />
<label
key={tag}
className="flex items-center gap-1.5 py-0.5 px-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
>
<input
type="checkbox"
checked={selectedTags.includes(tag)}
onChange={() => handleTagToggle(tag)}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span className="text-xs text-gray-700 dark:text-gray-300">{tag}</span>
</label>
))}
</div>
{tags.length > 10 && (
<button
onClick={() => setShowAllTags(!showAllTags)}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1"
>
{showAllTags ? 'Show less' : `Show ${tags.length - 10} more`}
</button>
)}
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={handleClearFilters} onClick={handleClearFilters}
className="w-full px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" className="w-full px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Clear all filters"
> >
Clear All Filters Clear All Filters
</button> </button>
)} )}
</div> </div>
</aside> </section>
); );
} }

View File

@ -1,140 +0,0 @@
# Data Directory
This directory contains all content and reference data for the cooking website.
## Directory Structure
```
data/
├── README.md # This file
├── authors.json # Author profiles and information
├── taxonomy.json # Categories, tags, and classification system
└── recipes/ # Recipe content
├── README.md
├── examples/
├── appetizers/
├── mains/
├── desserts/
├── sides/
├── beverages/
└── breads/
```
## Reference Files
### authors.json
Contains author profiles with biographical information, social links, and specialties.
**Structure:**
```json
{
"authors": [
{
"id": "pws",
"name": "PWS",
"fullName": "Paul Wilson Smith",
"bio": "Short biography",
"email": "email@example.com",
"website": "https://...",
"avatar": "/images/authors/pws.jpg",
"social": {
"github": "username"
},
"joinDate": "2026-01-01",
"specialties": ["Italian cuisine", "Baking"],
"favoriteIngredient": "Garlic"
}
]
}
```
**Usage in recipes:**
- Reference the author by their `id` in the recipe frontmatter
- Example: `author: "pws"`
- The site will look up full author details from this file
### taxonomy.json
Defines the complete taxonomy system for organizing recipes.
**Contains:**
1. **Categories** - Main recipe classifications (appetizers, mains, desserts, etc.)
2. **Tags** - Organized by type:
- `cuisine` - Italian, Mexican, Asian, etc.
- `protein` - Chicken, beef, vegetarian, etc.
- `dietary` - Gluten-free, vegan, keto, etc.
- `meal-type` - Breakfast, lunch, dinner, etc.
- `occasion` - Weeknight, holiday, party, etc.
- `cooking-method` - Baking, grilling, slow-cooker, etc.
- `speed` - Quick-meals, one-pot, make-ahead, etc.
- `flavor-profile` - Spicy, sweet, savory, etc.
- `special` - Chocolate, pasta, soup, etc.
3. **Difficulty** - Easy, medium, hard with descriptions
**Usage in recipes:**
- Use tag IDs from this file in recipe frontmatter
- Example: `tags: ["italian", "chicken", "comfort-food"]`
- Example: `category: "mains"`
- Example: `difficulty: "medium"`
- The site can validate tags against this taxonomy
## Benefits of Reference Files
### Consistency
- Ensures uniform spelling and naming across all recipes
- Prevents duplicate tags with slight variations
- Maintains standardized author information
### Validation
- Can validate recipe frontmatter against these schemas
- Catch typos and invalid values during build
- Provide helpful error messages
### UI Generation
- Generate filter dropdowns from taxonomy
- Display tag descriptions and icons
- Show author bios and links automatically
### Scalability
- Easy to add new authors, tags, or categories
- Central place to update descriptions and metadata
- Supports multiple authors and contributors
### SEO & Discovery
- Structured data for search engines
- Consistent taxonomy improves findability
- Tag descriptions enhance metadata
## Adding New Entries
### Adding an Author
1. Open `data/authors.json`
2. Add a new author object to the `authors` array
3. Ensure the `id` is unique and lowercase-hyphenated
4. Add avatar image to `/public/images/authors/`
5. Reference the author ID in recipe frontmatter
### Adding a Tag
1. Open `data/taxonomy.json`
2. Find the appropriate tag category (cuisine, protein, etc.)
3. Add a new tag object with `id`, `name`, and `description`
4. Use the tag ID in recipe frontmatter
### Adding a Category
1. Open `data/taxonomy.json`
2. Add a new category to the `categories` array
3. Create the corresponding folder in `data/recipes/`
4. Use the category ID in recipe frontmatter
## Best Practices
- Always reference tags/categories/authors by their `id`
- Keep IDs lowercase with hyphens (kebab-case)
- Write clear, helpful descriptions
- Add emojis to categories for visual interest
- Update both reference files and recipes together
- Validate changes before committing

View File

@ -1,19 +0,0 @@
{
"authors": [
{
"id": "pws",
"name": "PWS",
"fullName": "Paul Wilson Smith",
"bio": "Home cook and food enthusiast sharing tested recipes and cooking techniques.",
"email": "pws@example.com",
"website": "https://github.com/runyanjake/cooking",
"avatar": "/images/authors/pws.jpg",
"social": {
"github": "runyanjake"
},
"joinDate": "2026-01-01",
"specialties": ["Italian cuisine", "Baking", "Comfort food"],
"favoriteIngredient": "Garlic"
}
]
}

View File

@ -1,212 +0,0 @@
# Recipe Content Structure
This directory contains all recipe content for the cooking website.
## Folder Organization
```
data/recipes/
├── appetizers/
├── mains/
├── desserts/
├── sides/
├── beverages/
└── breads/
```
Each recipe follows this structure:
```
category/
└── YYYY.MM.DD-recipe-slug/
├── YYYY.MM.DD-recipe-slug.mdx
└── assets/
├── hero.jpg
├── step1.jpg
└── ...
```
## Recipe Format
### MDX File Structure
The `.mdx` file contains all recipe metadata and content in one place.
#### Frontmatter (Required)
All recipe metadata is stored in YAML frontmatter at the top of the MDX file:
```yaml
---
title: "Recipe Title"
slug: "recipe-slug"
date: "YYYY-MM-DD"
lastUpdated: "YYYY-MM-DD"
category: "mains"
tags: ["tag1", "tag2", "tag3"]
dietary: ["vegetarian", "gluten-free"]
cookTime: 45
prepTime: 20
totalTime: 65
difficulty: "easy"
servings: 4
author: "Author Name"
description: "Short description for SEO and previews"
featured: true
display: true
displayPhoto: "./assets/hero.jpg"
---
```
**Frontmatter Fields:**
- `title` - Display title of the recipe
- `slug` - URL-friendly identifier
- `date` - Publication date (YYYY-MM-DD)
- `lastUpdated` - Last modification date (YYYY-MM-DD)
- `category` - Main category ID (references `../taxonomy.json`)
- `tags` - Array of tag IDs (references `../taxonomy.json`)
- `dietary` - Array of dietary tag IDs (references `../taxonomy.json`)
- `cookTime` - Active cooking time in minutes
- `prepTime` - Preparation time in minutes
- `totalTime` - Total time in minutes
- `difficulty` - Difficulty ID: easy, medium, or hard (references `../taxonomy.json`)
- `servings` - Number of servings
- `author` - Author ID (references `../authors.json`)
- `description` - Brief description for SEO and cards
- `featured` - Boolean for homepage featuring
- `display` - Boolean to control visibility (set to false to hide recipe)
- `displayPhoto` - Relative path to display photo (e.g., "./assets/hero.jpg")
**Note:** Use IDs from the reference files (`data/authors.json` and `data/taxonomy.json`) to ensure consistency and enable validation.
#### Content Sections
The following `## ` (h2) sections are parsed into tabs in the UI:
1. **## Photos** - Recipe images with captions
2. **## Ingredients** - Lists of ingredients (can use h3 subsections)
3. **## Instructions** - Step-by-step cooking instructions
4. **## Notes** - Tips, variations, storage info (optional)
5. **## References** - Sources, inspirations, credits (optional)
#### Example MDX Structure
```mdx
---
title: "Recipe Name"
slug: "recipe-name"
date: "2026-02-08"
lastUpdated: "2026-02-08"
category: "mains"
tags: ["italian", "chicken"]
dietary: ["gluten-free-option"]
cookTime: 45
prepTime: 20
totalTime: 65
difficulty: "medium"
servings: 4
author: "PWS"
description: "Short description for SEO and previews"
featured: false
display: true
displayPhoto: "./assets/hero.jpg"
---
# Recipe Name
Introduction paragraph about the recipe.
## Photos
![Hero image](./assets/hero.jpg)
*Caption describing the image*
![Step photo](./assets/step1.jpg)
*Another helpful image*
## Ingredients
### For the Main Component
- 2 cups ingredient one
- 1 tablespoon ingredient two
- Salt and pepper to taste
### For the Sauce
- 1 cup sauce base
- Seasonings
## Instructions
### Preparation
1. **Step name**: Detailed instruction with technique.
2. **Another step**: More details here.
### Cooking
3. **Heat and cook**: Continue with numbered steps.
4. **Finish**: Final steps.
## Notes
### Tips for Success
- Helpful tip one
- Helpful tip two
### Storage
- How to store leftovers
- Freezing instructions
### Variations
- How to adapt the recipe
## References
- Source credits
- Inspiration mentions
- Cookbook references
```
## Content Guidelines
### Writing Style
- Use clear, conversational language
- Include helpful tips and context
- Explain techniques for beginners
- Add timing and temperature details
### Photography
- Include hero shot (main finished dish)
- Add process shots for complex steps
- Use descriptive alt text for accessibility
- Optimize images (web-friendly sizes)
### Tags
Choose from these categories:
- **Cuisine**: italian, mexican, asian, american, mediterranean, etc.
- **Protein**: chicken, beef, pork, seafood, vegetarian, vegan
- **Meal Type**: breakfast, lunch, dinner, snack, appetizer
- **Occasion**: weeknight, holiday, party, comfort-food
- **Dietary**: gluten-free, dairy-free, low-carb, keto, paleo
- **Cooking Method**: baking, grilling, slow-cooker, instant-pot
- **Speed**: quick-meals, one-pot, make-ahead, no-cook
### Difficulty Levels
- **easy**: Beginner-friendly, simple techniques, common ingredients
- **medium**: Some cooking experience needed, multiple steps
- **hard**: Advanced techniques, precise timing, specialty equipment
## Adding New Recipes
1. Create folder: `data/recipes/[category]/YYYY.MM.DD-recipe-name/`
2. Create `YYYY.MM.DD-recipe-name.mdx` with frontmatter and content
3. Add images to `assets/` folder
4. Build locally to verify rendering
5. Commit and push
## Best Practices
- Use consistent date formatting (YYYY.MM.DD)
- Keep slugs URL-friendly (lowercase, hyphens)
- Optimize images before adding (compress, resize)
- Test recipes before publishing
- Include metric and imperial measurements when possible
- Credit sources and inspirations
- Update featured flag sparingly (limit to 3-5 recipes)

View File

@ -1,187 +0,0 @@
{
"categories": [
{
"id": "appetizers",
"name": "Appetizers",
"slug": "appetizers",
"description": "Small dishes to start your meal",
"icon": "🥖"
},
{
"id": "mains",
"name": "Main Dishes",
"slug": "mains",
"description": "Hearty entrees and main courses",
"icon": "🍽️"
},
{
"id": "desserts",
"name": "Desserts",
"slug": "desserts",
"description": "Sweet treats and baked goods",
"icon": "🍰"
},
{
"id": "sides",
"name": "Side Dishes",
"slug": "sides",
"description": "Complementary dishes and accompaniments",
"icon": "🥗"
},
{
"id": "beverages",
"name": "Beverages",
"slug": "beverages",
"description": "Drinks, smoothies, and cocktails",
"icon": "🥤"
},
{
"id": "breads",
"name": "Breads & Baking",
"slug": "breads",
"description": "Homemade breads, rolls, and baked goods",
"icon": "🍞"
}
],
"tags": {
"cuisine": {
"name": "Cuisine",
"description": "Regional and cultural cooking styles",
"tags": [
{ "id": "italian", "name": "Italian", "description": "Italian cuisine and cooking traditions" },
{ "id": "mexican", "name": "Mexican", "description": "Mexican and Latin American dishes" },
{ "id": "asian", "name": "Asian", "description": "Asian cuisine including Chinese, Japanese, Thai, etc." },
{ "id": "american", "name": "American", "description": "Classic American cooking" },
{ "id": "mediterranean", "name": "Mediterranean", "description": "Mediterranean diet and cuisine" },
{ "id": "french", "name": "French", "description": "French culinary techniques and dishes" },
{ "id": "indian", "name": "Indian", "description": "Indian spices and cooking methods" },
{ "id": "middle-eastern", "name": "Middle Eastern", "description": "Middle Eastern flavors and dishes" }
]
},
"protein": {
"name": "Protein",
"description": "Main protein sources",
"tags": [
{ "id": "chicken", "name": "Chicken", "description": "Chicken-based recipes" },
{ "id": "beef", "name": "Beef", "description": "Beef and red meat dishes" },
{ "id": "pork", "name": "Pork", "description": "Pork and ham recipes" },
{ "id": "seafood", "name": "Seafood", "description": "Fish and shellfish dishes" },
{ "id": "lamb", "name": "Lamb", "description": "Lamb-based recipes" },
{ "id": "vegetarian", "name": "Vegetarian", "description": "No meat, may include dairy and eggs" },
{ "id": "vegan", "name": "Vegan", "description": "Plant-based, no animal products" }
]
},
"dietary": {
"name": "Dietary",
"description": "Special dietary considerations",
"tags": [
{ "id": "gluten-free", "name": "Gluten-Free", "description": "No gluten-containing ingredients" },
{ "id": "gluten-free-option", "name": "Gluten-Free Option", "description": "Can be made gluten-free with substitutions" },
{ "id": "dairy-free", "name": "Dairy-Free", "description": "No dairy products" },
{ "id": "low-carb", "name": "Low-Carb", "description": "Reduced carbohydrate content" },
{ "id": "keto", "name": "Keto", "description": "Ketogenic diet friendly" },
{ "id": "paleo", "name": "Paleo", "description": "Paleo diet compliant" },
{ "id": "whole30", "name": "Whole30", "description": "Whole30 program approved" },
{ "id": "nut-free", "name": "Nut-Free", "description": "No nuts or nut products" }
]
},
"meal-type": {
"name": "Meal Type",
"description": "When to serve this dish",
"tags": [
{ "id": "breakfast", "name": "Breakfast", "description": "Morning meals and brunch" },
{ "id": "lunch", "name": "Lunch", "description": "Midday meals" },
{ "id": "dinner", "name": "Dinner", "description": "Evening meals" },
{ "id": "snack", "name": "Snack", "description": "Small bites between meals" },
{ "id": "brunch", "name": "Brunch", "description": "Late morning combination meal" }
]
},
"occasion": {
"name": "Occasion",
"description": "Special events and settings",
"tags": [
{ "id": "weeknight", "name": "Weeknight", "description": "Quick meals for busy evenings" },
{ "id": "weekend", "name": "Weekend", "description": "Recipes with more time investment" },
{ "id": "holiday", "name": "Holiday", "description": "Special holiday dishes" },
{ "id": "party", "name": "Party", "description": "Entertaining and gatherings" },
{ "id": "comfort-food", "name": "Comfort Food", "description": "Cozy, satisfying dishes" },
{ "id": "family-friendly", "name": "Family-Friendly", "description": "Kid-approved recipes" },
{ "id": "date-night", "name": "Date Night", "description": "Romantic or impressive dishes" }
]
},
"cooking-method": {
"name": "Cooking Method",
"description": "Primary cooking technique",
"tags": [
{ "id": "baking", "name": "Baking", "description": "Oven-baked dishes" },
{ "id": "grilling", "name": "Grilling", "description": "Outdoor or indoor grilling" },
{ "id": "slow-cooker", "name": "Slow Cooker", "description": "Crock-pot and slow cooker meals" },
{ "id": "instant-pot", "name": "Instant Pot", "description": "Pressure cooker recipes" },
{ "id": "stovetop", "name": "Stovetop", "description": "Cooked on the stove" },
{ "id": "no-cook", "name": "No-Cook", "description": "No cooking required" },
{ "id": "air-fryer", "name": "Air Fryer", "description": "Air fryer recipes" },
{ "id": "sous-vide", "name": "Sous Vide", "description": "Precision cooking method" }
]
},
"speed": {
"name": "Speed & Convenience",
"description": "Time and effort indicators",
"tags": [
{ "id": "quick-meals", "name": "Quick Meals", "description": "30 minutes or less" },
{ "id": "one-pot", "name": "One-Pot", "description": "Minimal cleanup required" },
{ "id": "make-ahead", "name": "Make-Ahead", "description": "Can be prepared in advance" },
{ "id": "meal-prep", "name": "Meal Prep", "description": "Great for batch cooking" },
{ "id": "leftovers", "name": "Leftovers", "description": "Tastes great reheated" },
{ "id": "freezer-friendly", "name": "Freezer-Friendly", "description": "Can be frozen" }
]
},
"flavor-profile": {
"name": "Flavor Profile",
"description": "Dominant tastes and characteristics",
"tags": [
{ "id": "spicy", "name": "Spicy", "description": "Hot and spicy flavors" },
{ "id": "sweet", "name": "Sweet", "description": "Sweet or dessert-like" },
{ "id": "savory", "name": "Savory", "description": "Rich savory flavors" },
{ "id": "tangy", "name": "Tangy", "description": "Acidic or citrus notes" },
{ "id": "rich", "name": "Rich", "description": "Indulgent and decadent" },
{ "id": "light", "name": "Light", "description": "Fresh and light dishes" },
{ "id": "smoky", "name": "Smoky", "description": "Smoky flavors" },
{ "id": "umami", "name": "Umami", "description": "Deep savory umami taste" }
]
},
"special": {
"name": "Special Features",
"description": "Notable characteristics",
"tags": [
{ "id": "chocolate", "name": "Chocolate", "description": "Contains chocolate" },
{ "id": "cookies", "name": "Cookies", "description": "Cookie recipes" },
{ "id": "pasta", "name": "Pasta", "description": "Pasta dishes" },
{ "id": "soup", "name": "Soup", "description": "Soups and stews" },
{ "id": "salad", "name": "Salad", "description": "Salads and fresh dishes" },
{ "id": "sandwich", "name": "Sandwich", "description": "Sandwiches and wraps" },
{ "id": "pizza", "name": "Pizza", "description": "Pizza recipes" },
{ "id": "bbq", "name": "BBQ", "description": "Barbecue style" }
]
}
},
"difficulty": [
{
"id": "easy",
"name": "Easy",
"description": "Beginner-friendly, simple techniques, common ingredients",
"icon": "⭐"
},
{
"id": "medium",
"name": "Medium",
"description": "Some cooking experience needed, multiple steps",
"icon": "⭐⭐"
},
{
"id": "hard",
"name": "Hard",
"description": "Advanced techniques, precise timing, specialty equipment",
"icon": "⭐⭐⭐"
}
]
}

View File

@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import matter from 'gray-matter'; import matter from 'gray-matter';
const recipesDirectory = path.join(process.cwd(), 'data/recipes'); const recipesDirectory = path.join(process.cwd(), 'public/recipes');
// Cache to avoid repeated file system walks // Cache to avoid repeated file system walks
let recipesCache: Recipe[] | null = null; let recipesCache: Recipe[] | null = null;

View File

@ -4,20 +4,6 @@ import createMDX from '@next/mdx';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
pathname: '/api/recipe-image**',
},
],
localPatterns: [
{
pathname: '/api/recipe-image**',
},
],
},
}; };
const withMDX = createMDX({ const withMDX = createMDX({

334
package-lock.json generated
View File

@ -15,7 +15,9 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"next": "^15.1.6", "next": "^15.1.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
@ -4033,6 +4035,16 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -4844,6 +4856,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4854,6 +4876,34 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdast-util-from-markdown": { "node_modules/mdast-util-from-markdown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@ -4878,6 +4928,107 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx": { "node_modules/mdast-util-mdx": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz",
@ -5103,6 +5254,127 @@
"micromark-util-types": "^2.0.0" "micromark-util-types": "^2.0.0"
} }
}, },
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-mdx-expression": { "node_modules/micromark-extension-mdx-expression": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz",
@ -6415,6 +6687,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"@types/react": ">=18",
"react": ">=18"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -6564,6 +6863,24 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-mdx": { "node_modules/remark-mdx": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz",
@ -6611,6 +6928,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",

View File

@ -16,7 +16,9 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"next": "^15.1.6", "next": "^15.1.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",