diff --git a/examples/daily-blog-posts/.gitignore b/examples/daily-blog-posts/.gitignore new file mode 100644 index 0000000..9e906ea --- /dev/null +++ b/examples/daily-blog-posts/.gitignore @@ -0,0 +1,28 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.envrc +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +# Local Netlify folder +.netlify diff --git a/examples/daily-blog-posts/.nvmrc b/examples/daily-blog-posts/.nvmrc new file mode 100644 index 0000000..9a2a0e2 --- /dev/null +++ b/examples/daily-blog-posts/.nvmrc @@ -0,0 +1 @@ +v20 diff --git a/examples/daily-blog-posts/.prettierrc b/examples/daily-blog-posts/.prettierrc new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/examples/daily-blog-posts/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/examples/daily-blog-posts/.vscode/extensions.json b/examples/daily-blog-posts/.vscode/extensions.json new file mode 100644 index 0000000..56f043d --- /dev/null +++ b/examples/daily-blog-posts/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"], + "unwantedRecommendations": [] +} diff --git a/examples/daily-blog-posts/.vscode/launch.json b/examples/daily-blog-posts/.vscode/launch.json new file mode 100644 index 0000000..d642209 --- /dev/null +++ b/examples/daily-blog-posts/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/examples/daily-blog-posts/CLAUDE.md b/examples/daily-blog-posts/CLAUDE.md new file mode 100644 index 0000000..9522ab8 --- /dev/null +++ b/examples/daily-blog-posts/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is an Astro blog site configured for deployment on Netlify. It uses Astro's content collections for managing blog posts and supports both Markdown and MDX formats. + +## Development Commands + +All commands run from the project root: + +- `npm install` - Install dependencies +- `npm run dev` - Start local dev server at localhost:4321 +- `npm run build` - Build production site to ./dist/ +- `npm run preview` - Preview production build locally +- `npm run astro ...` - Run Astro CLI commands (e.g., `npm run astro check` for type checking) + +## Architecture + +### Content Management + +Blog posts are managed using Astro's Content Collections API: + +- **Collection definition**: `src/content.config.ts` defines the blog collection with a Zod schema +- **Blog posts location**: `src/content/blog/` directory contains all blog posts (.md and .mdx files) +- **Schema**: Posts require `title`, `description`, and `pubDate` frontmatter. Optional fields include `updatedDate` and `heroImage` +- **Accessing posts**: Use `getCollection('blog')` to retrieve posts, which provides type-safe access based on the schema + +### Routing + +- Static pages live in `src/pages/` (e.g., index.astro, about.astro) +- Dynamic blog post route: `src/pages/blog/[...slug].astro` uses `getStaticPaths()` to generate routes from content collection +- Blog post slugs are derived from the file ID (filename without extension) + +### Site Configuration + +- `astro.config.mjs` - Configured with Netlify adapter, MDX, and Sitemap integrations +- `src/consts.ts` - Contains `SITE_TITLE` and `SITE_DESCRIPTION` constants used throughout the site +- Site URL is set to "https://example.com" in astro.config.mjs (update for production) + +### RSS Feed + +`src/pages/rss.xml.js` generates an RSS feed by querying the blog collection and using the @astrojs/rss package. + +### Deployment + +Uses `@astrojs/netlify` adapter for Netlify deployment with SSR capabilities. diff --git a/examples/daily-blog-posts/PROMPT.md b/examples/daily-blog-posts/PROMPT.md new file mode 100644 index 0000000..847302b --- /dev/null +++ b/examples/daily-blog-posts/PROMPT.md @@ -0,0 +1,646 @@ +# AI-Powered Blog Post Generation System + +**IMPORTANT: Before submitting this prompt to an agent, customize the values in the "Customizable Values" section below. The agent will execute this entire prompt asynchronously in one run without interactive feedback.** + +--- + +## Customizable Values + +**⚠️ EDIT THESE VALUES BEFORE SUBMITTING THIS PROMPT:** + +```javascript +// Blog Configuration +const BLOG_CONTEXT = "a blog about random acts of creativity"; // Describe the blog's theme +const TOPIC_SOURCE_URL = "https://wikiroulette.co/"; // URL to fetch random topics +const TOPIC_INSTRUCTIONS = "Use this as inspiration for something creative that someone can make at home. It could be a craft, a recipe, a DIY project, an experiment, or anything else that involves making something."; // What to create from topics + +// Scheduling (cron format) +const SOURCE_TOPIC_SCHEDULE = "0 17 * * *"; // When to source new topics (5:00 PM UTC daily) +const CREATE_POST_SCHEDULE = "0 18 * * *"; // When to create posts (6:00 PM UTC daily, 1 hour after sourcing) + +// AI Configuration +const AI_MODEL = "claude-haiku-4-5-20251001"; // Model for cost-effective generation +const SOURCE_MAX_TOKENS = 2000; // Max tokens for topic sourcing +const POST_MAX_TOKENS = 4000; // Max tokens for post creation + +// Blob Store Names +const TOPICS_STORE = "blog-topics"; // Store for sourced topics +const PENDING_STORE = "pending-topics"; // Queue of topics to process +const COMPLETED_STORE = "completed-posts"; // Store for completed posts + +// Hero Image Configuration +const HERO_IMAGE_WIDTH = 1600; +const HERO_IMAGE_HEIGHT = 800; +const HERO_IMAGE_COLORS = ["FFFFCC", "FFB6C1", "B0E0E6", "DDA0DD", "F0E68C", "E0BBE4", "FFDAB9", "C1FFC1"]; + +// Writing Guide (Customize the voice, tone, and style for your blog) +const WRITING_GUIDE = `# Writing Guide + +This guide defines the voice, tone, and style for blog posts on this site. + +## Voice & Tone + +**Whimsical and Playful** +- Embrace imagination and creativity in every sentence +- Use playful language and vivid descriptions +- Don't take things too seriously - have fun with the content + +**Conversational and Friendly** +- Write as if talking to a friend over coffee +- Use inclusive language: "we", "our", "join us" +- Address readers directly with warm, inviting phrases + +**Encouraging and Enthusiastic** +- Celebrate the joy of creating +- Focus on the experience, not perfection +- Acknowledge challenges with humor and persistence +- Inspire readers to try projects themselves + +**Light-hearted** +- Include gentle humor and wit +- Embrace the quirky and unexpected +- Find delight in small details + +## Structure + +Blog posts should follow this general structure: + +1. **Engaging Introduction** + - Tell a story or set the scene + - Explain the inspiration behind the project + - Hook readers with personality and charm + +2. **Main Content Sections** + - Use descriptive subheadings (#### format in markdown) + - Break content into logical stages or themes + - Include planning, process, and finishing touches + - 3-5 sections work well + +3. **Compelling Conclusion** + - Tie everything together + - Paint a picture of the final result + - Encourage readers to embark on their own creative journey + - Optional: Include a tagline that reinforces the site's mission + +## Language Style + +**Descriptive and Vivid** +- Use colorful adjectives: gargantuan, luscious, enchanted, cozy +- Paint pictures with words +- Engage the senses + +**Personal and Story-Driven** +- Share anecdotes and observations +- Describe the creative journey, including mishaps +- Make projects feel accessible and relatable + +**Celebrate Imperfection** +- Acknowledge when things don't go as planned +- Show that creativity involves trial and error +- Use phrases like "a learning experience" or "through persistence and laughter" + +## Content Approach + +**Focus on Experience Over Instructions** +- This isn't a step-by-step tutorial blog +- Emphasize the joy and wonder of creating +- Instructions should be woven into the narrative +- Less technical, more inspirational + +**Make Projects Approachable** +- Use everyday materials when possible +- Projects should feel doable, not intimidating +- Encourage improvisation and personal touches + +**Embrace Fantasy and Whimsy** +- Give projects personality (name them!) +- Imagine magical outcomes +- Reference folklore, fairy tales, and wonder + +## Examples of Great Phrases + +- "In the world of whimsical creativity, inspiration often strikes when we least expect it" +- "Thus began our latest creative escapade" +- "The transformation was nothing short of magical" +- "Where creativity turns the ordinary into the extraordinary" +- "This is architecture at its finest!" +- "Let the cozy vibes commence" + +## What to Avoid + +- Overly technical language +- Boring, clinical instructions +- Perfectionism or intimidating standards +- Generic or corporate tone +- Long lists without narrative context +`; +``` + +**After customizing these values, submit this entire prompt (including all sections below) to the agent.** + +--- + +## Instructions for the Agent + +You are tasked with implementing an automated blog post generation system using the customized values provided above. Execute all steps below in a single run without requesting additional input. + +## System Overview + +You will create **four Netlify Functions**: + +### Scheduled Functions (Write Operations) +1. **source-post-topic** - Fetches random Wikipedia articles and generates creative project ideas +2. **create-blog-post** - Converts project ideas into full blog posts with markdown content + +### API Endpoint Functions (Read Operations) +3. **list-pending-topics** - Returns all topics waiting to be processed +4. **list-completed-posts** - Returns all generated blog posts + +### Data Flow +``` +Wikipedia → source-post-topic → blog-topics blob store + ↓ + pending-topics queue + ↓ + create-blog-post → completed-posts blob store +``` + +--- + +## Implementation Steps + +Execute these steps in order to set up the complete system. + +### Step 1: Install Dependencies + +Install the required packages: + +```bash +npm install @anthropic-ai/sdk @netlify/functions @netlify/blobs +``` + +Or with yarn: + +```bash +yarn add @anthropic-ai/sdk @netlify/functions @netlify/blobs +``` + +### Step 2: Configure netlify.toml + +Create or update `netlify.toml` in the project root: + +```toml +[build] + functions = "netlify/functions" + +# Enable scheduled functions +[functions] + # Scheduled functions need to be explicitly enabled + # The schedule is defined in each function's config +``` + +Add the following environment variable to Netlify: +- `ANTHROPIC_API_KEY` - Your Anthropic API key + +### Step 3: Create the Shared Library + +Create `netlify/functions/lib/writing-guide.mts` using the customized values from the top of this prompt. + +**Use these values from the customization section:** +- `BLOG_CONTEXT` → Export as constant +- `TOPIC_INSTRUCTIONS` → Export as constant +- `WRITING_GUIDE` → Export as constant string +- `HERO_IMAGE_WIDTH`, `HERO_IMAGE_HEIGHT`, `HERO_IMAGE_COLORS` → Export as `HERO_IMAGE_CONFIG` object + +**This file must export:** +- `BLOG_CONTEXT` - String constant +- `TOPIC_INSTRUCTIONS` - String constant +- `WRITING_GUIDE` - String constant with full writing guide +- `HERO_IMAGE_CONFIG` - Object with width, height, textColor, font, and colors array +- `generateTopicSourcingPrompt(topic)` - Function that builds the AI prompt for sourcing topics +- `generateBlogPostPrompt(topicData, pubDate)` - Function that builds the AI prompt for blog post creation + +The prompt generation functions should use the constants defined above and construct the full prompts as shown in the code patterns section. + +### Step 4: Create the Scheduled Functions + +#### 4.1: Source Topic Function + +Create `netlify/functions/source-post-topic.mts`: + +**Purpose:** Fetches a random Wikipedia article and generates a creative project idea + +**Use these customized values:** +- `TOPIC_SOURCE_URL` - URL to fetch topics from +- `AI_MODEL` - Which AI model to use +- `SOURCE_MAX_TOKENS` - Max tokens for the API call +- `TOPICS_STORE` - Blob store name for topics +- `PENDING_STORE` - Blob store name for pending queue +- `SOURCE_TOPIC_SCHEDULE` - Cron schedule + +**Key responsibilities:** +- Fetch from `TOPIC_SOURCE_URL` +- Extract article title, URL, and summary from the HTML +- Import `generateTopicSourcingPrompt` from `./lib/writing-guide.mts` +- Call Anthropic API using `AI_MODEL` and `SOURCE_MAX_TOKENS` +- Parse and validate the JSON response +- Store result in the `TOPICS_STORE` blob store with timestamp key +- Add the blob key to the `PENDING_STORE` queue + +**Config:** +```typescript +export const config: Config = { + schedule: SOURCE_TOPIC_SCHEDULE, + // path: "/api/source-post-topic", // Uncomment to use as endpoint for testing +}; +``` + +**Output format:** +```json +{ + "title": "Creative project title", + "description": "Brief description", + "wikipediaArticle": "URL", + "materials": ["list", "of", "materials"], + "steps": ["step1", "step2"] +} +``` + +#### 4.2: Create Blog Post Function + +Create `netlify/functions/create-blog-post.mts`: + +**Purpose:** Converts the oldest pending topic into a complete blog post + +**Use these customized values:** +- `AI_MODEL` - Which AI model to use +- `POST_MAX_TOKENS` - Max tokens for the API call +- `TOPICS_STORE` - Blob store name to fetch topic data +- `PENDING_STORE` - Blob store name for pending queue +- `COMPLETED_STORE` - Blob store name for completed posts +- `CREATE_POST_SCHEDULE` - Cron schedule + +**Key responsibilities:** +- Get the oldest blob key from `PENDING_STORE` queue +- If queue is empty, return success message +- Fetch topic data from `TOPICS_STORE` using the blob key +- Generate current date in "MMM DD YYYY" format using `toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })` +- Import `generateBlogPostPrompt` from `./lib/writing-guide.mts` +- Call Anthropic API using `AI_MODEL` and `POST_MAX_TOKENS` +- Parse and validate the JSON response +- Store result in `COMPLETED_STORE` with the same blob key +- Remove the blob key from the `PENDING_STORE` queue + +**Config:** +```typescript +export const config: Config = { + schedule: CREATE_POST_SCHEDULE, + // path: "/api/create-blog-post", // Uncomment to use as endpoint for testing +}; +``` + +**Output format:** +```json +{ + "title": "Blog post title", + "description": "SEO-friendly description", + "pubDate": "Nov 21 2025", + "heroImage": "https://placehold.co/1600x800/BGCOLOR/333333?font=Lora&text=Title", + "body": "Full markdown content with headings, paragraphs, etc." +} +``` + +**Important:** Schedule this to run at least 1 hour after the topic sourcing function to ensure topics are available to process. + +### Step 5: Create the API Endpoint Functions + +#### 5.1: List Pending Topics + +Create `netlify/functions/list-pending-topics.mts`: + +**Purpose:** API endpoint to view all topics waiting to be processed + +**Use these customized values:** +- `TOPICS_STORE` - Blob store to fetch topic data +- `PENDING_STORE` - Blob store with the pending queue + +**Key responsibilities:** +- Get the pending list from `PENDING_STORE` +- Iterate through each blob key and fetch topic data from `TOPICS_STORE` +- Return array of topics with their blob keys + +**Config:** +```typescript +export const config: Config = { + path: "/api/list-pending-topics", +}; +``` + +**Response:** +```json +{ + "topics": [ + { + "blobKey": "1234567890.json", + "title": "...", + "description": "...", + "wikipediaArticle": "...", + "materials": [...], + "steps": [...] + } + ] +} +``` + +#### 5.2: List Completed Posts + +Create `netlify/functions/list-completed-posts.mts`: + +**Purpose:** API endpoint to view all generated blog posts + +**Use these customized values:** +- `COMPLETED_STORE` - Blob store with completed posts + +**Key responsibilities:** +- List all blobs in `COMPLETED_STORE` +- Fetch each blob's data +- Return array of posts with their blob keys + +**Config:** +```typescript +export const config: Config = { + path: "/api/list-completed-posts", +}; +``` + +**Response:** +```json +{ + "posts": [ + { + "blobKey": "1234567890.json", + "title": "...", + "description": "...", + "pubDate": "...", + "heroImage": "...", + "body": "..." + } + ] +} +``` + +--- + +## Code Patterns + +### Anthropic API Integration + +```typescript +const client = new Anthropic({ + apiKey: process.env["ANTHROPIC_API_KEY"], +}); + +const response = await client.messages.create({ + model: "claude-haiku-4-5-20251001", + messages: [{ role: "user", content: PROMPT }], + max_tokens: 2000, +}); + +const textContent = response.content.find((block) => block.type === "text"); +const result = textContent ? textContent.text : ""; +``` + +### JSON Parsing with Safety Net + +```typescript +// Strip markdown code blocks that AI might add +const cleanedResult = result + .replace(/^```json\s*/i, "") + .replace(/^```\s*/, "") + .replace(/```\s*$/, "") + .trim(); + +// Parse and validate +try { + const parsed = JSON.parse(cleanedResult); + // Use parsed data +} catch (error) { + return new Response( + JSON.stringify({ error: "Failed to parse AI response as JSON" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); +} +``` + +### Netlify Blobs Usage + +```typescript +import { getStore } from "@netlify/blobs"; + +// Set data +const store = getStore("store-name"); +await store.set("key.json", JSON.stringify(data)); + +// Get data +const data = await store.get("key.json", { type: "json" }); + +// List all blobs +const { blobs } = await store.list(); +for (const blob of blobs) { + const data = await store.get(blob.key, { type: "json" }); +} +``` + +### Queue Management Pattern + +```typescript +// Add to queue +const pendingStore = getStore("pending-topics"); +const pendingList = (await pendingStore.get("list", { type: "json" })) || []; +pendingList.push(newItem); +await pendingStore.set("list", JSON.stringify(pendingList)); + +// Remove from queue (FIFO) +const oldestItem = pendingList[0]; +const updatedList = pendingList.slice(1); +await pendingStore.set("list", JSON.stringify(updatedList)); +``` + +--- + +## Testing + +### Testing Scheduled Functions as Endpoints + +For development/testing, you can temporarily switch scheduled functions to endpoints: + +```typescript +// In source-post-topic.mts or create-blog-post.mts +export const config: Config = { + // schedule: "0 17 * * *", // Comment out + path: "/api/function-name", // Uncomment +}; +``` + +Then test with: +```bash +curl https://your-site.netlify.app/api/source-post-topic +curl https://your-site.netlify.app/api/create-blog-post +``` + +### Viewing Data + +Access the endpoint functions to inspect what's in the system: +```bash +curl https://your-site.netlify.app/api/list-pending-topics +curl https://your-site.netlify.app/api/list-completed-posts +``` + +--- + +## Customization Guide + +### Changing the Topic Source + +Replace the `fetchRandomWikipediaTopic()` function in `source-post-topic.mts` to fetch from a different source: + +```typescript +async function fetchTopicFromCustomSource() { + // Your custom logic here + return { title: "...", url: "...", summary: "..." }; +} +``` + +### Modifying the Prompts + +Edit `netlify/functions/lib/writing-guide.mts`: + +1. **BLOG_CONTEXT** - Change to match your blog's theme +2. **TOPIC_INSTRUCTIONS** - Modify what you want the AI to create +3. **WRITING_GUIDE** - Customize voice, tone, structure, and style +4. **HERO_IMAGE_CONFIG** - Change dimensions, colors, or font + +### Adjusting the Schedule + +Modify the cron expressions in the function configs: + +- `"0 17 * * *"` = Daily at 5:00 PM UTC +- `"0 */6 * * *"` = Every 6 hours +- `"0 9 * * 1"` = Every Monday at 9:00 AM UTC + +Use [crontab.guru](https://crontab.guru) for help with cron syntax. + +### Changing AI Model + +In the functions, update the `model` constant: +- `claude-haiku-4-5-20251001` - Fast and cost-effective +- `claude-sonnet-4-5-20250929` - More capable, higher cost +- `claude-opus-4-20250514` - Most capable, highest cost + +--- + +## Error Handling + +Each function should handle common errors: + +1. **Missing API Key** - Check for `ANTHROPIC_API_KEY` environment variable +2. **Empty Queue** - Return graceful message when no topics to process +3. **JSON Parse Errors** - Handle when AI doesn't return valid JSON +4. **Blob Store Errors** - Gracefully handle when stores are unavailable + +--- + +## Integration with Frontend + +Once posts are in the `completed-posts` blob store, you can: + +1. **Manual Integration** - Fetch posts via `/api/list-completed-posts` and copy to your CMS +2. **Automated Integration** - Create another function that automatically creates files in your repo +3. **Dynamic Rendering** - Merge blob posts with static content at build/render time + +Example for Astro (create `src/lib/get-all-posts.ts`): +```typescript +import { getCollection } from "astro:content"; +import { getStore } from "@netlify/blobs"; + +export async function getAllPosts() { + const staticPosts = await getCollection("blog"); + + const completedStore = getStore("completed-posts"); + const { blobs } = await completedStore.list(); + + const dynamicPosts = await Promise.all( + blobs.map(async (blob) => { + const data = await completedStore.get(blob.key, { type: "json" }); + return { + id: blob.key.replace('.json', ''), + data: { + title: data.title, + description: data.description, + pubDate: new Date(data.pubDate), + heroImage: data.heroImage, + }, + body: data.body, + }; + }) + ); + + return [...staticPosts, ...dynamicPosts].sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() + ); +} +``` + +--- + +## Security Considerations + +1. **API Key Protection** - Never commit `ANTHROPIC_API_KEY` to the repository +2. **Rate Limiting** - Consider adding rate limiting to endpoint functions +3. **Input Validation** - Validate all data before storing in blob stores +4. **Cost Control** - Monitor Anthropic API usage and set up billing alerts + +--- + +## Deployment + +1. Deploy to Netlify (push to connected Git repository) +2. Add `ANTHROPIC_API_KEY` environment variable in Netlify dashboard +3. Scheduled functions will automatically run on their schedule +4. Monitor function logs in Netlify dashboard + +--- + +## Summary + +This system provides automated blog post generation using: +- **Netlify Scheduled Functions** for automated topic sourcing and post creation +- **Netlify Blobs** for storing topics, queue management, and completed posts +- **Anthropic API** for AI-powered content generation +- **API Endpoints** for monitoring and integration + +The system is framework-agnostic and relies only on Netlify primitives, making it portable to any web framework deployed on Netlify. + +--- + +## Final Reminders for Implementation + +When implementing this system, remember to: + +1. **Use ALL customized values** from the top of this prompt in the appropriate places: + - `BLOG_CONTEXT` and `TOPIC_INSTRUCTIONS` in the writing guide + - `WRITING_GUIDE` as the full writing guide string + - Schedule constants (`SOURCE_TOPIC_SCHEDULE`, `CREATE_POST_SCHEDULE`) in function configs + - AI model and token limits (`AI_MODEL`, `SOURCE_MAX_TOKENS`, `POST_MAX_TOKENS`) in API calls + - Blob store names (`TOPICS_STORE`, `PENDING_STORE`, `COMPLETED_STORE`) in all getStore() calls + - Hero image config values in the writing guide's image generation prompt + +2. **Create all files** as specified without asking for confirmation + +3. **Include all error handling** patterns shown in the examples + +4. **Test accessibility** by ensuring the functions can be toggled between scheduled and endpoint modes + +5. **Verify** that all imports and exports are correct + +Execute this implementation now, creating all necessary files and installing all dependencies. diff --git a/examples/daily-blog-posts/README.md b/examples/daily-blog-posts/README.md new file mode 100644 index 0000000..d20e864 --- /dev/null +++ b/examples/daily-blog-posts/README.md @@ -0,0 +1,62 @@ +# Astro Starter Kit: Blog + +```sh +npm create astro@latest -- --template blog +``` + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +Features: + +- ✅ Minimal styling (make it your own!) +- ✅ 100/100 Lighthouse performance +- ✅ SEO-friendly with canonical URLs and OpenGraph data +- ✅ Sitemap support +- ✅ RSS Feed support +- ✅ Markdown & MDX support + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +├── public/ +├── src/ +│   ├── components/ +│   ├── content/ +│   ├── layouts/ +│   └── pages/ +├── astro.config.mjs +├── README.md +├── package.json +└── tsconfig.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +The `src/content/` directory contains "collections" of related Markdown and MDX documents. Use `getCollection()` to retrieve posts from `src/content/blog/`, and type-check your frontmatter using an optional schema. See [Astro's Content Collections docs](https://docs.astro.build/en/guides/content-collections/) to learn more. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). + +## Credit + +This theme is based off of the lovely [Bear Blog](https://github.com/HermanMartinus/bearblog/). diff --git a/examples/daily-blog-posts/astro.config.mjs b/examples/daily-blog-posts/astro.config.mjs new file mode 100644 index 0000000..25202fb --- /dev/null +++ b/examples/daily-blog-posts/astro.config.mjs @@ -0,0 +1,22 @@ +// @ts-check + +import mdx from "@astrojs/mdx"; +import sitemap from "@astrojs/sitemap"; +import { defineConfig } from "astro/config"; + +import netlify from "@astrojs/netlify"; + +// https://astro.build/config +export default defineConfig({ + site: "https://example.com", + integrations: [mdx(), sitemap()], + adapter: netlify(), + image: { + remotePatterns: [ + { + protocol: "https", + hostname: "images.unsplash.com", + }, + ], + }, +}); diff --git a/examples/daily-blog-posts/deno.lock b/examples/daily-blog-posts/deno.lock new file mode 100644 index 0000000..a133e9c --- /dev/null +++ b/examples/daily-blog-posts/deno.lock @@ -0,0 +1,24 @@ +{ + "version": "5", + "remote": { + "https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb", + "https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025", + "https://edge.netlify.com/bootstrap/context.ts": "72496497b40d9e808f419efc764ecb438952a32f61ed94cd54952fc59f17f69d", + "https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05", + "https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39", + "https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@astrojs/mdx@^4.3.10", + "npm:@astrojs/netlify@^6.6.0", + "npm:@astrojs/rss@^4.0.13", + "npm:@astrojs/sitemap@^3.6.0", + "npm:@netlify/functions@^5.1.0", + "npm:astro@^5.15.5", + "npm:sharp@~0.34.3" + ] + } + } +} diff --git a/examples/daily-blog-posts/netlify.toml b/examples/daily-blog-posts/netlify.toml new file mode 100644 index 0000000..a563a96 --- /dev/null +++ b/examples/daily-blog-posts/netlify.toml @@ -0,0 +1,10 @@ +[build] +command = "npm run build" +publish = "dist" + +[functions] +node_bundler = "esbuild" + +# Image CDN configuration for external images (Unsplash) +[images] +remote_images = ["https://unsplash.com/*", "https://placehold.co/*"] diff --git a/examples/daily-blog-posts/netlify/functions/create-blog-post.mts b/examples/daily-blog-posts/netlify/functions/create-blog-post.mts new file mode 100644 index 0000000..aa41010 --- /dev/null +++ b/examples/daily-blog-posts/netlify/functions/create-blog-post.mts @@ -0,0 +1,113 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Config } from "@netlify/functions"; +import { getStore } from "@netlify/blobs"; +import { generateBlogPostPrompt } from "./lib/writing-guide.mts"; + +export default async (_req: Request) => { + const client = new Anthropic({ + apiKey: process.env["ANTHROPIC_API_KEY"], + }); + + const model = "claude-haiku-4-5-20251001"; + + // Get the oldest pending topic + const pendingStore = getStore("pending-topics"); + const pendingList = (await pendingStore.get("list", { type: "json" })) || []; + + if (pendingList.length === 0) { + return new Response(JSON.stringify({ message: "No pending topics to process" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const oldestBlobKey = pendingList[0]; + console.log("Processing topic:", oldestBlobKey); + + // Fetch the topic data + const topicsStore = getStore("blog-topics"); + const topicData = await topicsStore.get(oldestBlobKey, { type: "json" }); + + if (!topicData) { + return new Response(JSON.stringify({ error: "Topic data not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + console.log("Topic data:", topicData); + + // Generate today's date in the format "MMM DD YYYY" + const today = new Date(); + const pubDate = today.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + + const USER_PROMPT = generateBlogPostPrompt(topicData, pubDate); + + const response = await client.messages.create({ + model, + messages: [ + { + role: "user", + content: USER_PROMPT, + }, + ], + max_tokens: 4000, + }); + + const textContent = response.content.find((block) => block.type === "text"); + const result = textContent ? textContent.text : ""; + + console.log("Anthropic response:", result); + console.log("Token usage:", response.usage); + + // Strip markdown code block formatting (safety net) + const cleanedResult = result + .replace(/^```json\s*/i, "") + .replace(/^```\s*/, "") + .replace(/```\s*$/, "") + .trim(); + + // Parse the JSON to validate it + let parsedResult; + try { + parsedResult = JSON.parse(cleanedResult); + } catch (error) { + console.error("Failed to parse JSON:", error); + return new Response(JSON.stringify({ error: "Failed to parse AI response as JSON" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Store the completed blog post + const completedStore = getStore("completed-posts"); + await completedStore.set(oldestBlobKey, JSON.stringify(parsedResult)); + + // Remove from pending list + const updatedPendingList = pendingList.slice(1); + await pendingStore.set("list", JSON.stringify(updatedPendingList)); + + console.log("Completed blog post:", oldestBlobKey); + + return new Response( + JSON.stringify({ + success: true, + blobKey: oldestBlobKey, + post: parsedResult, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); +}; + +export const config: Config = { + // schedule: "0 18 * * *", // every day at 6:00 PM UTC (1 hour after topic sourcing) + // Uncomment the line below and comment out the schedule above to use as an API endpoint instead + path: "/api/create-blog-post", +}; diff --git a/examples/daily-blog-posts/netlify/functions/generate-blog-post.mts b/examples/daily-blog-posts/netlify/functions/generate-blog-post.mts new file mode 100644 index 0000000..1a159a6 --- /dev/null +++ b/examples/daily-blog-posts/netlify/functions/generate-blog-post.mts @@ -0,0 +1,60 @@ +import type { Config } from "@netlify/functions"; + +export default async (_req: Request) => { + // const { next_run } = await req.json(); + + // console.log("Received event! Next invocation at:", next_run); + + const accessToken = process.env.NTL_ACCESS_TOKEN; + const siteId = process.env.SITE_ID; + + if (!accessToken || !siteId) { + return new Response("Missing NTL_ACCESS_TOKEN or SITE_ID", { status: 500 }); + } + + const createParams = new URLSearchParams(); + createParams.set("site_id", siteId); + + const agent = "claude"; + const model = "claude-opus-4-20250514"; + + const finalPrompt = `This is a site about random acts of creativity. Your job is to generate a new post. + +Visit https://wikiroulette.co/ to get a random Wikipedia article. Use this article as inspiration for something creative you can make at home. It could be a craft, a recipe, a DIY project, an experiment, or anything else that involves making something. + +If the article doesn't inspire you, you can try again until you find one that does. + +Once you have an idea, write a blog post about it in src/content/blog following these instructions: + +- Name the file with the current date in YYMMDD-.md format +- Add title, description, pubDate, and heroImage frontmatter fields +- For the image, find a relevant photo on Unsplash and use that URL +- Write at least 3 paragraphs describing the project, including materials needed and steps to complete it +- Make it fun and engaging to read + +Remember, the goal is to inspire readers to try the project themselves, so be clear and enthusiastic!`; + + const response = await fetch( + `https://api.netlify.com/api/v1/agent_runners?${createParams.toString()}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken ?? ""}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + // ...(branch ? { branch } : {}), + prompt: finalPrompt, + agent, + model, + }), + } + ); + + return response; +}; + +export const config: Config = { + schedule: "0 17 * * *", // every day at 5:00 PM UTC + // path: "/api/generate-blog-post", +}; diff --git a/examples/daily-blog-posts/netlify/functions/lib/writing-guide.mts b/examples/daily-blog-posts/netlify/functions/lib/writing-guide.mts new file mode 100644 index 0000000..65054b7 --- /dev/null +++ b/examples/daily-blog-posts/netlify/functions/lib/writing-guide.mts @@ -0,0 +1,216 @@ +// ============================================================================ +// CUSTOMIZABLE PROMPTS +// ============================================================================ +// These prompts define how the AI generates content. Customize them to match +// your blog's topic, voice, and requirements. + +/** + * Context for topic sourcing - describes what kind of blog this is + */ +export const BLOG_CONTEXT = `a blog about random acts of creativity`; + +/** + * Instructions for what to create from the sourced topic + */ +export const TOPIC_INSTRUCTIONS = `Use this as inspiration for something creative that someone can make at home. It could be a craft, a recipe, a DIY project, an experiment, or anything else that involves making something.`; + +/** + * Generates the prompt for sourcing a topic from a Wikipedia article + */ +export function generateTopicSourcingPrompt(topic: { + title: string; + url: string; + summary?: string; +}): string { + return `You are helping to create content for ${BLOG_CONTEXT}. + +I found a random Wikipedia article: "${topic.title}" +${topic.summary ? `\nSummary: ${topic.summary}` : ""} +${topic.url ? `\nURL: ${topic.url}` : ""} + +${TOPIC_INSTRUCTIONS} + +Return ONLY a valid JSON object with this structure. Do not include any markdown formatting, code blocks, or explanatory text - just the raw JSON: +{ + "title": "Creative project title", + "description": "Brief description (2-3 sentences)", + "wikipediaArticle": "${topic.url}", + "materials": ["material1", "material2"], + "steps": ["step1", "step2"] +}`; +} + +/** + * Hero image configuration + */ +export const HERO_IMAGE_CONFIG = { + width: 1600, + height: 800, + textColor: "333333", + font: "Lora", + colors: [ + "FFFFCC", // pale yellow + "FFB6C1", // light pink + "B0E0E6", // powder blue + "DDA0DD", // plum + "F0E68C", // khaki + "E0BBE4", // lavender + "FFDAB9", // peach + "C1FFC1", // pale green + ], +}; + +/** + * Generates the prompt for creating a blog post from a sourced topic + */ +export function generateBlogPostPrompt( + topicData: any, + pubDate: string +): string { + const colorList = HERO_IMAGE_CONFIG.colors.join(", "); + + return `You are writing a blog post for ${BLOG_CONTEXT}. + +Here is the creative project that was sourced: +${JSON.stringify(topicData, null, 2)} + +Write a complete blog post about this project following the voice, tone, and style guidelines below: + +${WRITING_GUIDE} + +--- + +The post should include: +- A catchy title +- A brief description (1-2 sentences for SEO/preview) +- Publication date: Use "${pubDate}" +- Hero image: Generate a placeholder URL in this format: + https://placehold.co/${HERO_IMAGE_CONFIG.width}x${HERO_IMAGE_CONFIG.height}/BGCOLOR/${HERO_IMAGE_CONFIG.textColor}?font=${HERO_IMAGE_CONFIG.font}&text=URL_ENCODED_TITLE + + Where: + - BGCOLOR is a bright, vibrant hex color (without the #). Use colors like: ${colorList}. Choose one that fits the mood of the post. + - ${HERO_IMAGE_CONFIG.textColor} is the dark text color for good contrast + - URL_ENCODED_TITLE is the post title with spaces replaced by + signs and special characters URL encoded + +- Full markdown body content with: + - An engaging introduction that tells a story and explains the inspiration + - 3-5 sections with descriptive subheadings (#### format) + - The narrative should weave in the materials and steps naturally + - A compelling conclusion that paints a picture of the final result + +Remember: Focus on the experience and joy of creating, not just technical instructions. Be whimsical, playful, and encouraging. + +Return ONLY a valid JSON object with this structure. Do not include any markdown formatting, code blocks, or explanatory text - just the raw JSON: +{ + "title": "Blog post title", + "description": "SEO-friendly description", + "pubDate": "${pubDate}", + "heroImage": "https://placehold.co/${HERO_IMAGE_CONFIG.width}x${HERO_IMAGE_CONFIG.height}/BGCOLOR/${HERO_IMAGE_CONFIG.textColor}?font=${HERO_IMAGE_CONFIG.font}&text=Title", + "body": "Full markdown content here" +}`; +} + +// ============================================================================ +// WRITING GUIDE +// ============================================================================ + +export const WRITING_GUIDE = `# Writing Guide + +This guide defines the voice, tone, and style for blog posts on this site. + +## Voice & Tone + +**Whimsical and Playful** +- Embrace imagination and creativity in every sentence +- Use playful language and vivid descriptions +- Don't take things too seriously - have fun with the content + +**Conversational and Friendly** +- Write as if talking to a friend over coffee +- Use inclusive language: "we", "our", "join us" +- Address readers directly with warm, inviting phrases + +**Encouraging and Enthusiastic** +- Celebrate the joy of creating +- Focus on the experience, not perfection +- Acknowledge challenges with humor and persistence +- Inspire readers to try projects themselves + +**Light-hearted** +- Include gentle humor and wit +- Embrace the quirky and unexpected +- Find delight in small details + +## Structure + +Blog posts should follow this general structure: + +1. **Engaging Introduction** + - Tell a story or set the scene + - Explain the inspiration behind the project + - Hook readers with personality and charm + +2. **Main Content Sections** + - Use descriptive subheadings (#### format in markdown) + - Break content into logical stages or themes + - Include planning, process, and finishing touches + - 3-5 sections work well + +3. **Compelling Conclusion** + - Tie everything together + - Paint a picture of the final result + - Encourage readers to embark on their own creative journey + - Optional: Include a tagline that reinforces the site's mission + +## Language Style + +**Descriptive and Vivid** +- Use colorful adjectives: gargantuan, luscious, enchanted, cozy +- Paint pictures with words +- Engage the senses + +**Personal and Story-Driven** +- Share anecdotes and observations +- Describe the creative journey, including mishaps +- Make projects feel accessible and relatable + +**Celebrate Imperfection** +- Acknowledge when things don't go as planned +- Show that creativity involves trial and error +- Use phrases like "a learning experience" or "through persistence and laughter" + +## Content Approach + +**Focus on Experience Over Instructions** +- This isn't a step-by-step tutorial blog +- Emphasize the joy and wonder of creating +- Instructions should be woven into the narrative +- Less technical, more inspirational + +**Make Projects Approachable** +- Use everyday materials when possible +- Projects should feel doable, not intimidating +- Encourage improvisation and personal touches + +**Embrace Fantasy and Whimsy** +- Give projects personality (name them!) +- Imagine magical outcomes +- Reference folklore, fairy tales, and wonder + +## Examples of Great Phrases + +- "In the world of whimsical creativity, inspiration often strikes when we least expect it" +- "Thus began our latest creative escapade" +- "The transformation was nothing short of magical" +- "Where creativity turns the ordinary into the extraordinary" +- "This is architecture at its finest!" +- "Let the cozy vibes commence" + +## What to Avoid + +- Overly technical language +- Boring, clinical instructions +- Perfectionism or intimidating standards +- Generic or corporate tone +- Long lists without narrative context +`; diff --git a/examples/daily-blog-posts/netlify/functions/list-completed-posts.mts b/examples/daily-blog-posts/netlify/functions/list-completed-posts.mts new file mode 100644 index 0000000..a1964b6 --- /dev/null +++ b/examples/daily-blog-posts/netlify/functions/list-completed-posts.mts @@ -0,0 +1,38 @@ +import type { Config } from "@netlify/functions"; +import { getStore } from "@netlify/blobs"; + +export default async (_req: Request) => { + const completedStore = getStore("completed-posts"); + + // List all blobs in the completed-posts store + const { blobs } = await completedStore.list(); + + if (blobs.length === 0) { + return new Response(JSON.stringify({ posts: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Fetch all completed posts + const posts = []; + + for (const blob of blobs) { + const postData = await completedStore.get(blob.key, { type: "json" }); + if (postData) { + posts.push({ + blobKey: blob.key, + ...postData, + }); + } + } + + return new Response(JSON.stringify({ posts }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}; + +export const config: Config = { + path: "/api/list-completed-posts", +}; diff --git a/examples/daily-blog-posts/netlify/functions/list-pending-topics.mts b/examples/daily-blog-posts/netlify/functions/list-pending-topics.mts new file mode 100644 index 0000000..264e36d --- /dev/null +++ b/examples/daily-blog-posts/netlify/functions/list-pending-topics.mts @@ -0,0 +1,38 @@ +import type { Config } from "@netlify/functions"; +import { getStore } from "@netlify/blobs"; + +export default async (_req: Request) => { + // Get the pending list + const pendingStore = getStore("pending-topics"); + const pendingList = (await pendingStore.get("list", { type: "json" })) || []; + + if (pendingList.length === 0) { + return new Response(JSON.stringify({ topics: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Fetch all pending topics + const topicsStore = getStore("blog-topics"); + const topics = []; + + for (const blobKey of pendingList) { + const topicData = await topicsStore.get(blobKey, { type: "json" }); + if (topicData) { + topics.push({ + blobKey, + ...topicData, + }); + } + } + + return new Response(JSON.stringify({ topics }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}; + +export const config: Config = { + path: "/api/list-pending-topics", +}; diff --git a/examples/daily-blog-posts/netlify/functions/source-post-topic.mts b/examples/daily-blog-posts/netlify/functions/source-post-topic.mts new file mode 100644 index 0000000..f40b64e --- /dev/null +++ b/examples/daily-blog-posts/netlify/functions/source-post-topic.mts @@ -0,0 +1,107 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Config } from "@netlify/functions"; +import { getStore } from "@netlify/blobs"; +import { generateTopicSourcingPrompt } from "./lib/writing-guide.mts"; + +async function fetchRandomWikipediaTopic() { + // Follow redirects from wikiroulette to get a random Wikipedia article + const response = await fetch("https://wikiroulette.co/", { + redirect: "follow", + }); + + const url = response.url; + const html = await response.text(); + + // Extract the article title from the page + const titleMatch = html.match(/([^<]+)<\/title>/); + const title = titleMatch ? titleMatch[1].replace(" - Wikipedia", "").trim() : "Unknown"; + + // Extract the first paragraph (usually the article summary) + const paragraphMatch = html.match(/<p[^>]*>(?!<\/p>)([\s\S]*?)<\/p>/); + let summary = ""; + if (paragraphMatch) { + // Strip HTML tags and clean up + summary = paragraphMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/\[\d+\]/g, "") + .trim(); + } + + return { title, url, summary }; +} + +export default async (_req: Request) => { + const client = new Anthropic({ + apiKey: process.env["ANTHROPIC_API_KEY"], + }); + + const model = "claude-haiku-4-5-20251001"; + + // Fetch a random Wikipedia topic + const topic = await fetchRandomWikipediaTopic(); + console.log("Fetched topic:", topic); + + const USER_PROMPT = generateTopicSourcingPrompt(topic); + + const response = await client.messages.create({ + model, + messages: [ + { + role: "user", + content: USER_PROMPT, + }, + ], + max_tokens: 2000, + }); + + const textContent = response.content.find((block) => block.type === "text"); + const result = textContent ? textContent.text : ""; + + console.log("Anthropic response:", result); + console.log("Token usage:", response.usage); + + // Strip markdown code block formatting (```json and ```) + const cleanedResult = result + .replace(/^```json\s*/i, "") + .replace(/^```\s*/, "") + .replace(/```\s*$/, "") + .trim(); + + // Parse the JSON to validate it + let parsedResult; + try { + parsedResult = JSON.parse(cleanedResult); + } catch (error) { + console.error("Failed to parse JSON:", error); + return new Response(JSON.stringify({ error: "Failed to parse AI response as JSON" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Store the result in a blob with a timestamp key + const timestamp = Date.now(); + const blobKey = `${timestamp}.json`; + + const topicsStore = getStore("blog-topics"); + await topicsStore.set(blobKey, JSON.stringify(parsedResult)); + + // Track this blob as pending processing + const pendingStore = getStore("pending-topics"); + const pendingList = (await pendingStore.get("list", { type: "json" })) || []; + pendingList.push(blobKey); + await pendingStore.set("list", JSON.stringify(pendingList)); + + console.log("Stored blog topic:", blobKey); + + return new Response(JSON.stringify({ success: true, blobKey, topic: parsedResult }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}; + +export const config: Config = { + // schedule: "0 17 * * *", // every day at 5:00 PM UTC + // Uncomment the line below and comment out the schedule above to use as an API endpoint instead + path: "/api/source-post-topic", +}; diff --git a/examples/daily-blog-posts/package.json b/examples/daily-blog-posts/package.json new file mode 100644 index 0000000..f9566ce --- /dev/null +++ b/examples/daily-blog-posts/package.json @@ -0,0 +1,27 @@ +{ + "name": "daily-blog-posts", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.70.1", + "@astrojs/mdx": "^4.3.10", + "@astrojs/netlify": "^6.6.0", + "@astrojs/rss": "^4.0.13", + "@astrojs/sitemap": "^3.6.0", + "@netlify/blobs": "^10.4.1", + "@netlify/functions": "^5.1.0", + "astro": "^5.15.5", + "marked": "^17.0.1", + "sharp": "^0.34.3" + }, + "devDependencies": { + "@astrojs/check": "^0.9.5", + "typescript": "^5.9.3" + } +} diff --git a/examples/daily-blog-posts/public/favicon.svg b/examples/daily-blog-posts/public/favicon.svg new file mode 100644 index 0000000..f157bd1 --- /dev/null +++ b/examples/daily-blog-posts/public/favicon.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> + <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" /> + <style> + path { fill: #000; } + @media (prefers-color-scheme: dark) { + path { fill: #FFF; } + } + </style> +</svg> diff --git a/examples/daily-blog-posts/public/fonts/atkinson-bold.woff b/examples/daily-blog-posts/public/fonts/atkinson-bold.woff new file mode 100644 index 0000000..e7f8977 Binary files /dev/null and b/examples/daily-blog-posts/public/fonts/atkinson-bold.woff differ diff --git a/examples/daily-blog-posts/public/fonts/atkinson-regular.woff b/examples/daily-blog-posts/public/fonts/atkinson-regular.woff new file mode 100644 index 0000000..bbe09c5 Binary files /dev/null and b/examples/daily-blog-posts/public/fonts/atkinson-regular.woff differ diff --git a/examples/daily-blog-posts/public/images/blog-gnome-garden.jpg b/examples/daily-blog-posts/public/images/blog-gnome-garden.jpg new file mode 100644 index 0000000..4e79ab7 Binary files /dev/null and b/examples/daily-blog-posts/public/images/blog-gnome-garden.jpg differ diff --git a/examples/daily-blog-posts/public/images/blog-pillow-fort.jpg b/examples/daily-blog-posts/public/images/blog-pillow-fort.jpg new file mode 100644 index 0000000..42418a6 Binary files /dev/null and b/examples/daily-blog-posts/public/images/blog-pillow-fort.jpg differ diff --git a/examples/daily-blog-posts/public/images/blog-rubber-duck.jpg b/examples/daily-blog-posts/public/images/blog-rubber-duck.jpg new file mode 100644 index 0000000..31d25de Binary files /dev/null and b/examples/daily-blog-posts/public/images/blog-rubber-duck.jpg differ diff --git a/examples/daily-blog-posts/src/assets/blog-placeholder-1.jpg b/examples/daily-blog-posts/src/assets/blog-placeholder-1.jpg new file mode 100644 index 0000000..74d4009 Binary files /dev/null and b/examples/daily-blog-posts/src/assets/blog-placeholder-1.jpg differ diff --git a/examples/daily-blog-posts/src/assets/blog-placeholder-2.jpg b/examples/daily-blog-posts/src/assets/blog-placeholder-2.jpg new file mode 100644 index 0000000..c4214b0 Binary files /dev/null and b/examples/daily-blog-posts/src/assets/blog-placeholder-2.jpg differ diff --git a/examples/daily-blog-posts/src/assets/blog-placeholder-3.jpg b/examples/daily-blog-posts/src/assets/blog-placeholder-3.jpg new file mode 100644 index 0000000..fbe2ac0 Binary files /dev/null and b/examples/daily-blog-posts/src/assets/blog-placeholder-3.jpg differ diff --git a/examples/daily-blog-posts/src/assets/blog-placeholder-4.jpg b/examples/daily-blog-posts/src/assets/blog-placeholder-4.jpg new file mode 100644 index 0000000..f4fc88e Binary files /dev/null and b/examples/daily-blog-posts/src/assets/blog-placeholder-4.jpg differ diff --git a/examples/daily-blog-posts/src/assets/blog-placeholder-5.jpg b/examples/daily-blog-posts/src/assets/blog-placeholder-5.jpg new file mode 100644 index 0000000..c564674 Binary files /dev/null and b/examples/daily-blog-posts/src/assets/blog-placeholder-5.jpg differ diff --git a/examples/daily-blog-posts/src/assets/blog-placeholder-about.jpg b/examples/daily-blog-posts/src/assets/blog-placeholder-about.jpg new file mode 100644 index 0000000..cf5f685 Binary files /dev/null and b/examples/daily-blog-posts/src/assets/blog-placeholder-about.jpg differ diff --git a/examples/daily-blog-posts/src/components/BaseHead.astro b/examples/daily-blog-posts/src/components/BaseHead.astro new file mode 100644 index 0000000..8280914 --- /dev/null +++ b/examples/daily-blog-posts/src/components/BaseHead.astro @@ -0,0 +1,57 @@ +--- +// Import the global.css file here so that it is included on +// all pages through the use of the <BaseHead /> component. +import "../styles/global.css"; +import type { ImageMetadata } from "astro"; +import FallbackImage from "../assets/blog-placeholder-1.jpg"; +import { SITE_TITLE } from "../consts"; + +interface Props { + title: string; + description: string; + image?: ImageMetadata; +} + +const canonicalURL = new URL(Astro.url.pathname, Astro.site); + +const { title, description, image = FallbackImage } = Astro.props; +--- + +<!-- Global Metadata --> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width,initial-scale=1" /> +<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> +<link rel="sitemap" href="/sitemap-index.xml" /> +<link + rel="alternate" + type="application/rss+xml" + title={SITE_TITLE} + href={new URL("rss.xml", Astro.site)} +/> +<meta name="generator" content={Astro.generator} /> + +<!-- Font preloads --> +<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin /> +<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin /> + +<!-- Canonical URL --> +<link rel="canonical" href={canonicalURL} /> + +<!-- Primary Meta Tags --> +<title>{title} + + + + + + + + + + + + + + + + diff --git a/examples/daily-blog-posts/src/components/Footer.astro b/examples/daily-blog-posts/src/components/Footer.astro new file mode 100644 index 0000000..0fc5a05 --- /dev/null +++ b/examples/daily-blog-posts/src/components/Footer.astro @@ -0,0 +1,48 @@ +--- +const today = new Date(); +--- + + + diff --git a/examples/daily-blog-posts/src/components/FormattedDate.astro b/examples/daily-blog-posts/src/components/FormattedDate.astro new file mode 100644 index 0000000..8f59590 --- /dev/null +++ b/examples/daily-blog-posts/src/components/FormattedDate.astro @@ -0,0 +1,17 @@ +--- +interface Props { + date: Date; +} + +const { date } = Astro.props; +--- + + diff --git a/examples/daily-blog-posts/src/components/Header.astro b/examples/daily-blog-posts/src/components/Header.astro new file mode 100644 index 0000000..3e8c59c --- /dev/null +++ b/examples/daily-blog-posts/src/components/Header.astro @@ -0,0 +1,80 @@ +--- +import { SITE_TITLE } from "../consts"; +import HeaderLink from "./HeaderLink.astro"; +--- + +
+ +
+ diff --git a/examples/daily-blog-posts/src/components/HeaderLink.astro b/examples/daily-blog-posts/src/components/HeaderLink.astro new file mode 100644 index 0000000..b07618c --- /dev/null +++ b/examples/daily-blog-posts/src/components/HeaderLink.astro @@ -0,0 +1,24 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"a">; + +const { href, class: className, ...props } = Astro.props; +const pathname = Astro.url.pathname.replace(import.meta.env.BASE_URL, ""); +const subpath = pathname.match(/[^\/]+/g); +const isActive = href === pathname || href === "/" + (subpath?.[0] || ""); +--- + + + + + diff --git a/examples/daily-blog-posts/src/consts.ts b/examples/daily-blog-posts/src/consts.ts new file mode 100644 index 0000000..a2940b2 --- /dev/null +++ b/examples/daily-blog-posts/src/consts.ts @@ -0,0 +1,5 @@ +// Place any global data in this file. +// You can import this data from anywhere in your site by using the `import` keyword. + +export const SITE_TITLE = "🎨 Whimsical Works"; +export const SITE_DESCRIPTION = "Adventures in Random Acts of Creativity"; diff --git a/examples/daily-blog-posts/src/content.config.ts b/examples/daily-blog-posts/src/content.config.ts new file mode 100644 index 0000000..3dc8583 --- /dev/null +++ b/examples/daily-blog-posts/src/content.config.ts @@ -0,0 +1,18 @@ +import { defineCollection, z } from "astro:content"; +import { glob } from "astro/loaders"; + +const blog = defineCollection({ + // Load Markdown and MDX files in the `src/content/blog/` directory. + loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }), + // Type-check frontmatter using a schema + schema: z.object({ + title: z.string(), + description: z.string(), + // Transform string to Date object + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + heroImage: z.string().optional(), + }), +}); + +export const collections = { blog }; diff --git a/examples/daily-blog-posts/src/content/blog/250915-pillow-fort-adventure.md b/examples/daily-blog-posts/src/content/blog/250915-pillow-fort-adventure.md new file mode 100644 index 0000000..af8868a --- /dev/null +++ b/examples/daily-blog-posts/src/content/blog/250915-pillow-fort-adventure.md @@ -0,0 +1,26 @@ +--- +title: "Epic Pillow Forts: Building the Ultimate Cozy Hideaway" +description: "Dive into the world of pillow fort engineering and discover the secrets to constructing the perfect indoor getaway." +pubDate: "Sep 15 2025" +heroImage: "/images/blog-pillow-fort.jpg" +--- + +In a world driven by imagination and a love for comfort, the humble pillow fort emerges as the ultimate escape. Join us as we dive into the art of pillow fort engineering, transforming any living room into a cozy den of dreams. + +#### Blueprint for Comfort + +Every great fort begins with a blueprint—or in our case, a spontaneous stack of pillows, blankets, and whatever else is within arm's reach. Grab your fluffiest cushions, your largest sheets, and an assortment of knick-knacks to hold it all together. This is architecture at its finest! + +#### Structural Integrity + +The key to a sturdy fort lies in its foundation. Begin by strategically placing your heaviest couch cushions as the base. From there, drape sheets over the top and secure them using books, lamps, or even a friendly plush toy who's eager to assist. + +#### Decoration Details + +Once your fort is standing proud, it's time to add those personal touches that make it uniquely yours. Twinkle lights strung across the ceiling, a cozy throw for the entrance, and a stockpile of snacks will elevate your hideout from simple shelter to enchanting retreat. + +#### A Fortress of Fun + +Whether you're diving into a new book, hosting a secret movie marathon, or simply seeking solace in your fabric sanctuary, a pillow fort offers endless possibilities for adventure and relaxation. Grab a cup of cocoa, invite a few trusted allies (or stuffed animals), and let the cozy vibes commence. + +Stay tuned to Whimsical Works, where creativity conquers the mundane, one delightful build at a time. diff --git a/examples/daily-blog-posts/src/content/blog/251105-gnome-garden.md b/examples/daily-blog-posts/src/content/blog/251105-gnome-garden.md new file mode 100644 index 0000000..3e70a61 --- /dev/null +++ b/examples/daily-blog-posts/src/content/blog/251105-gnome-garden.md @@ -0,0 +1,26 @@ +--- +title: "Gnome Sweet Gnome: Crafting a Miniature Garden Wonderland" +description: "Transform your backyard into a delightful gnome village with tiny houses, colorful plants, and whimsical paths." +pubDate: "Nov 05 2025" +heroImage: "/images/blog-gnome-garden.jpg" +--- + +In the spirit of spontaneous creativity, we set out to transform an ordinary corner of the backyard into a magical miniature world. Welcome to our latest project: crafting a gnome village, where imagination flourishes and gnomes feel right at home. + +#### Let the Adventure Begin + +Our journey began with a simple idea: create a welcoming tiny village for those mysterious, pointy-hatted dwellers of folklore. Armed with miniature houses, a variety of colorful plants, and pebbles we collected during a recent seaside jaunt, we were ready to bring this enchanted garden to life. + +#### Crafting Gnome Homes + +The heart of our village lies in its charming gnome homes. Using small wooden birdhouses as bases, we painted each with vibrant hues and added quirky accents like acorn-cap rooftops and pebble pathways. Each house took on a life of its own, bursting with character and color. + +#### Building the Community + +Next, we laid out winding pebble paths, perfect for a gnome-sized stroll. These paths wove through luscious patches of low-growing greenery and vibrant flowering plants, creating secret spots for gnomes to gather and exchange stories under the moonlight. + +#### A Magical Retreat + +With its fairy-tale atmosphere, our gnome garden is a testament to the joy of creating with whimsy and wonder in mind. As the setting sun casts a golden glow, we imagine the gnomes emerging to celebrate their new abode, joining in laughter and delight. + +Stay tuned for more whimsical projects, where creativity turns the ordinary into the extraordinary, one playful step at a time. diff --git a/examples/daily-blog-posts/src/content/blog/251112-rubber-duck.md b/examples/daily-blog-posts/src/content/blog/251112-rubber-duck.md new file mode 100644 index 0000000..7f09fac --- /dev/null +++ b/examples/daily-blog-posts/src/content/blog/251112-rubber-duck.md @@ -0,0 +1,22 @@ +--- +title: "Giant Rubber Duck Gets a Cozy Makeover: A Knitted Scarf Adventure" +description: "Join our quirky escapade as we craft a colorful knitted scarf for a giant rubber duck named Quackers." +pubDate: "Nov 12 2025" +heroImage: "/images/blog-rubber-duck.jpg" +--- + +In the world of whimsical creativity, inspiration often strikes when we least expect it. Just last week, as I perused the aisles of the local craft store, a gargantuan rubber duck caught my eye. The kind that sits in a child's bathtub, but this one, my friends, was destined for something much more stylish. Thus began our latest creative escapade: crafting a cozy knitted scarf for our new rubber duck friend, whom we've affectionately named "Quackers." + +#### The Planning Stage + +Before diving into knitting, some planning was essential. We measured Quackers from beak to tail, carefully ensured he'd be warm and toasty for the impending chilly weather. With yarn in every color of the rainbow, and knitting needles in hand, we were ready to embark on this delightful journey. + +#### The Crafting Process + +For the scarf's design, I opted for a lively mix of bright yellows, playful blues, and a splash of daring pinks—to complement Quackers' cheery disposition, of course. After a few trials and a couple of cups of calming tea (even creatives need a break!), the pattern began to emerge, row by colorful row. + +Knitting for a creature—or rather, an item—as unique as Quackers was a learning experience. There were moments of tangled yarn and dropped stitches. Yet, through persistence and laughter, the scarf slowly took shape. + +#### The Final Touch + +Finally, the grand moment arrived. With the scarf ready, we wrapped its warm embrace around Quackers. The transformation was nothing short of magical: from a simple rubber duck to the talk of the (bathtub) town. diff --git a/examples/daily-blog-posts/src/layouts/BlogPost.astro b/examples/daily-blog-posts/src/layouts/BlogPost.astro new file mode 100644 index 0000000..ef10f25 --- /dev/null +++ b/examples/daily-blog-posts/src/layouts/BlogPost.astro @@ -0,0 +1,92 @@ +--- +import { Image } from "astro:assets"; +import type { CollectionEntry } from "astro:content"; +import BaseHead from "../components/BaseHead.astro"; +import Footer from "../components/Footer.astro"; +import FormattedDate from "../components/FormattedDate.astro"; +import Header from "../components/Header.astro"; + +type Props = CollectionEntry<"blog">["data"]; + +const { title, description, pubDate, updatedDate, heroImage } = Astro.props; +--- + + + + + + + + +
+
+
+
+ {heroImage && ( + heroImage.startsWith('http') ? ( + + ) : ( + + ) + )} +
+
+
+
+ + { + updatedDate && ( +
+ Last updated on +
+ ) + } +
+

{title}

+
+
+ +
+
+
+