2026-02-25T02-10-13_auto_memory/memories.db-wal

This commit is contained in:
Nicholai Vogel 2026-02-24 19:10:13 -07:00
parent ef6b36855e
commit 7dfd02d011
12 changed files with 1255 additions and 77 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,24 +0,0 @@
# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Claude produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.

View File

@ -0,0 +1,18 @@
# Directory structure to create for a new project
# Run: mkdir -p <each-path>
src/components
src/components/ui
src/layouts
src/lib
src/pages/api
src/pages/blog/tag
src/pages/blog/category
src/styles
src/utils
src/emails
src/content/blog/images
src/assets/images
public/assets/fonts
public/assets/images/logos
public/assets/video

View File

@ -1,34 +0,0 @@
# Reference Documentation for Astro Portfolio Site
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices

View File

@ -0,0 +1,263 @@
# Blog Infrastructure
## Content Collection Config (src/content.config.ts)
Uses Astro 5 Content Layer API (NOT the legacy v2 API):
```typescript
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
featured: z.boolean().optional().default(false),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };
```
Key points:
- `image()` from the schema callback enables Astro image optimization for hero images
- `z.coerce.date()` auto-converts date strings to Date objects
- `featured` defaults to false — used to highlight posts on homepage/blog listing
## Blog Post Frontmatter
```yaml
---
title: "Post Title Here"
description: "SEO description under 160 characters."
pubDate: 2026-01-15
heroImage: ./images/hero-image.jpg
featured: true
category: "announcement"
tags: ["tag-one", "tag-two"]
---
```
Hero images stored in `src/content/blog/images/` and referenced with relative paths.
## Blog Listing (src/pages/blog/index.astro)
Pattern: featured post hero + category filter strip + post grid + sidebar
```astro
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import Navigation from '../../components/Navigation';
import Footer from '../../components/Footer';
import BlogCard from '../../components/BlogCard.astro';
import BlogSearch from '../../components/BlogSearch';
import { calculateReadingTime } from '../../utils/reading-time';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const featuredPost = posts.find((p) => p.data.featured) || posts[0];
const otherPosts = posts.filter((p) => p.id !== featuredPost?.id);
const categories = [...new Set(posts.map((p) => p.data.category).filter(Boolean))];
const allTags = [...new Set(posts.flatMap((p) => p.data.tags || []))];
---
```
Layout structure:
1. Navigation (`client:load`)
2. Header with title + BlogSearch (`client:idle`)
3. Category filter strip (horizontally scrollable on mobile)
4. Featured post (large card, lg:row-span-3 on desktop)
5. Post grid (1 col mobile, 2 cols tablet, 3 cols xl)
6. Sidebar (xl+ only, sticky): recent posts, categories, tags
7. Footer
## Dynamic Post Route (src/pages/blog/[...slug].astro)
Uses rest parameter `[...slug]` for nested slug support:
```astro
---
import { getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import { calculateReadingTime } from '../../utils/reading-time';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
const post = Astro.props;
const { Content, headings } = await render(post);
const readTime = calculateReadingTime(post.body);
---
<BlogPost
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
updatedDate={post.data.updatedDate}
heroImage={post.data.heroImage}
category={post.data.category}
tags={post.data.tags}
readTime={readTime}
headings={headings}
>
<Content />
</BlogPost>
```
**Important**: Astro 5 uses `render(post)` (imported from `astro:content`), NOT `post.render()`.
## Tag Archive (src/pages/blog/tag/[tag].astro)
```astro
---
import { getCollection } from 'astro:content';
// ... imports
export async function getStaticPaths() {
const posts = await getCollection('blog');
const tags = [...new Set(posts.flatMap((p) => p.data.tags || []))];
return tags.map((tag) => ({
params: { tag },
props: {
tag,
posts: posts
.filter((p) => p.data.tags?.includes(tag))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
},
}));
}
const { tag, posts } = Astro.props;
---
```
## Category Archive (src/pages/blog/category/[category].astro)
Same pattern as tag archive but filtering on `post.data.category`.
## BlogCard.astro
Props: `{ title, description, pubDate, href, heroImage?, readTime?, category? }`
Structure:
- Link wraps entire card
- Optional hero image (h-44/h-48, gradient overlay at bottom)
- Metadata row: category badge (accent color, pixel font), date, read time
- Title with hover color transition
- Description (line-clamp-2)
- Touch feedback: `:active` scales 0.985 with 80ms transition
## FeaturedPosts.astro
Props: `{ posts: Post[] }` (max 4 posts)
Layout:
- Header with "FROM THE BLOG" + "VIEW ALL" link
- Featured post takes full row height on desktop (lg:row-span-3)
- Remaining posts in 2-column grid
- Atmospheric overlays: noise texture, radial glows
## BlogSearch.tsx (client:idle)
Lazy-loaded Fuse.js search:
```typescript
// State
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isReady, setIsReady] = useState(false);
const fuseRef = useRef(null);
// Lazy load search index on first focus
const loadSearchIndex = async () => {
if (isReady) return;
const res = await fetch('/search.json');
const data = await res.json();
fuseRef.current = new Fuse(data, {
keys: [
{ name: 'title', weight: 3 },
{ name: 'description', weight: 2 },
{ name: 'tags', weight: 1.5 },
{ name: 'category', weight: 1 },
{ name: 'content', weight: 0.5 },
],
threshold: 0.3,
minMatchCharLength: 2,
});
setIsReady(true);
};
// Keyboard shortcuts: "/" to focus, ESC to close
// Click outside to close
// Query triggers search when >= 2 chars, limits to 6 results
```
## Search Index (src/pages/search.json.ts)
```typescript
import { getCollection } from 'astro:content';
export const prerender = true;
export async function GET() {
const posts = await getCollection('blog');
const searchData = posts.map((post) => ({
id: post.id,
title: post.data.title,
description: post.data.description,
content: post.body ?? '',
category: post.data.category || '',
tags: post.data.tags || [],
url: `/blog/${post.id}/`,
pubDate: post.data.pubDate.toISOString(),
}));
return new Response(JSON.stringify(searchData), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
```
## FormattedDate.astro
```astro
---
interface Props { date: Date; }
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
```
## BlogPost Layout
See `references/seo-structured-data.md` for the Article + Breadcrumb JSON-LD patterns used in this layout.
Key features:
- Mobile TOC: `<details>` with `+` toggle that rotates 45deg on open
- Desktop TOC: sticky aside (top-24, w-56) with border-l navigation
- Prose styling: Tailwind typography with brand color overrides for headings, links, code, blockquotes
- Hero image bleeds to edges on mobile (`-mx-5`, `max-width: calc(100% + 2.5rem)`)
- Safe area bottom padding for iOS
- 44px touch targets on all interactive elements

View File

@ -0,0 +1,312 @@
# Component Patterns
## Hydration Strategy
| Directive | When to Use | Components |
|-----------|-------------|------------|
| `client:load` | Above-fold, needs immediate interaction | Navigation, Hero, Loader, CustomCursor |
| `client:visible` | Below-fold, can wait until scrolled into view | About, Contact, Footer, GamesList, Marquee |
| `client:idle` | Low priority, background loading | BlogSearch |
## GSAP Animation Patterns
### Required Setup in Every Animated Component
```typescript
import { useEffect, useRef } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export default function AnimatedComponent() {
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
// Always check reduced motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// Always wrap in context for cleanup
const ctx = gsap.context(() => {
// animations here
});
return () => ctx.revert();
}, []);
}
```
### SessionStorage One-Time Entrance
For Hero and Loader — skip expensive animations on repeat visits:
```typescript
const [isVisible, setIsVisible] = useState(() => {
// Synchronous check before first render
if (typeof window === 'undefined') return true;
return !sessionStorage.getItem('{{STORAGE_KEY}}');
});
// In animation completion callback:
sessionStorage.setItem('{{STORAGE_KEY}}', 'true');
setIsVisible(false);
```
### ScrollTrigger Entrance Pattern
For below-fold sections (About, Contact, GamesList):
```typescript
gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionRef.current,
start: 'top 80%',
toggleActions: 'play none none none',
},
});
tl.from(leftRef.current, {
x: -50, opacity: 0, duration: 0.8, ease: 'power2.out',
})
.from(rightRef.current, {
x: 50, opacity: 0, duration: 0.8, ease: 'power2.out',
}, '-=0.6'); // Overlap by 0.6s for flowing feel
});
```
### Parallax Pattern
```typescript
gsap.to(bgRef.current, {
yPercent: 20,
ease: 'none',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
});
```
### Idle Float Animation
```typescript
gsap.to(elementRef.current, {
y: 12,
rotation: 1.5,
duration: 3,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
});
```
### Animation Rules
- **Transform-only**: Use x, y, scale, rotation, opacity. Never animate width, height, top, left, margin, padding.
- **Stagger**: Use negative relative positioning (`'-=0.6'`) in timelines for overlapping reveals.
- **Ease functions**: `power2.out` for entrances, `power3.inOut` for exits, `sine.inOut` for idle, `back.out(1.7)` for bouncy CTAs.
## Navigation Component
Fixed header + fullscreen overlay menu:
**Header Bar:**
- Fixed position, z-50
- Transparent when at top, semi-opaque after scroll (useEffect with scroll listener)
- Logo (left), optional center ticker, controls (right: audio toggle, menu button)
- Top accent line: `h-[2px]` gradient with brand colors
**Fullscreen Menu:**
- Hidden by default (`display: none`), shown via GSAP
- Background: layered gradients, noise texture, scanlines
- Two-column: nav links (left) + info (right)
- Nav links: indexed (01-06), description on hover (hidden mobile)
- GSAP open sequence: scanline wipe → bg fade → links stagger → info slide
- GSAP close: all fade 0.15s → bg fade 0.2s → `display: none`
**Interactions:**
- ESC to close (KeyboardEvent listener)
- Body overflow hidden when open
- Hash links scroll with GSAP ScrollToPlugin
- Audio SFX via Web Audio API (optional — synthesized, no files needed)
- 44px minimum touch targets on all links/buttons
## Hero Component
Full viewport section:
**Background Layers (bottom to top):**
1. Video/image (opacity 50%, object-cover)
2. Gradient overlays (bottom-to-top dark, radial center-fade)
3. Noise texture (mix-blend-overlay, opacity 3-5%)
4. Scanlines (optional)
**Content:**
- Centered: logo, tagline, 2 CTA buttons, optional stats ticker
- Floating decorative elements (characters, icons) with idle float animations
- Diagonal accent slash (optional, brand colored)
**Entrance Animation (sessionStorage skip on repeat):**
```
0.0s: Start
0.3s: Slash scaleX 0→1 (0.6s)
0.5s: Logo blur(20px) x:-120 → clear x:0 (1.0s)
0.7s: Character blur → clear (0.9s)
1.0s: Tagline clipPath reveal (0.7s)
1.2s: CTAs scale+y stagger (0.6s each, 0.12s gap)
1.5s: Ticker fade up (0.6s)
```
**Parallax (always active):**
- Video: yPercent +20 on scroll
- Character: yPercent -30 on scroll
## Loader Component (optional)
One-time splash screen:
```typescript
const [isVisible, setIsVisible] = useState(() => {
if (typeof window === 'undefined') return true;
return !sessionStorage.getItem('{{KEY}}-loader-seen');
});
if (!isVisible) return null;
```
- Progress bar: width 0→100% (1.5s)
- Logo: opacity 0, scale 1.1, blur 10px → visible (0.5s)
- Container: yPercent 0→-100 (0.8s, power3.inOut)
- Safety timeout: 5s max
- Click anywhere to skip
- On complete: `sessionStorage.setItem`, `setIsVisible(false)`
## CustomCursor Component (optional)
```typescript
export default function CustomCursor() {
const cursorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia('(pointer: coarse)').matches) return;
const onMove = (e: MouseEvent) => {
gsap.to(cursorRef.current, {
x: e.clientX, y: e.clientY,
duration: 0.1, ease: 'power2.out',
});
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return <div ref={cursorRef} id="custom-cursor" />;
}
```
Styled via global.css `#custom-cursor` rules.
## Contact Component
Two-column layout:
**Left Column:**
- Section label (accent color, pixel font)
- Heading with gradient text on key word
- Contact email link
- Social icons row with hover color transitions
- Optional location tagline
**Right Column (Form):**
- Fields: name, email, subject, message
- Honeypot: `_honey` (hidden, aria-hidden, tabIndex -1)
- Submit button: brand bg, white text, pixel shadow, hover translate 2px
- Status states: idle → sending (disabled) → success (cyan border box) → error (primary border box)
**GSAP entrance**: ScrollTrigger, left x:-50, right x:+50, staggered.
## Footer Component
Multi-column grid:
```
┌─────────────────────────────────────────────────────────┐
│ Brand │ SITEMAP │ LEGAL │ CTA │
│ Logo │ Home │ Privacy │ Steam │
│ Name │ Product │ Terms │ Button │
│ Tagline │ About │ Press │ │
│ Socials │ Blog │ │ │
│ │ Contact │ │ │
├─────────────────────────────────────────────────────────┤
│ © 2024 Company. Est. 20XX. │
└─────────────────────────────────────────────────────────┘
```
- Background: `--color-darker` (#050505)
- Text: white/35 base, white on hover, 200ms transition
- Grid: 1 col mobile → 2 cols tablet → 4 cols desktop
## External API Integration Pattern
Build-time data fetching (e.g., Steam API):
```typescript
// src/lib/steam.ts
interface SteamData {
app: SteamAppDetails | null;
reviews: SteamReviewSummary | null;
players: number | null;
news: SteamNewsItem[];
version: string | null;
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url);
if (!res.ok) return null;
return await res.json() as T;
} catch {
return null;
}
}
export async function fetchAllData(id: number): Promise<SteamData> {
const [app, reviews, players, news] = await Promise.all([
fetchAppDetails(id),
fetchReviewSummary(id),
fetchCurrentPlayers(id),
fetchNews(id),
]);
return { app, reviews, players, news, version: parseVersionFromNews(news) };
}
```
Key principles:
- Every fetch returns `T | null` — never throws
- Use `Promise.all` for parallel fetching
- Aggregate into single data object with nullable fields
- Builds never break if external API is down
- Wire into pages via frontmatter `await`
## Accessibility Checklist
Every component should have:
- `aria-label` on icon-only buttons
- 44px minimum touch targets (w-11 h-11, or py-3 px-3 -mx-3)
- Keyboard navigation (ESC to close overlays, Tab order)
- `focus-visible` outline styles
- Semantic HTML (`<nav>`, `<article>`, `<footer>`, `<section id="...">`)
- Color contrast meeting WCAG AA on dark backgrounds
## Mobile Patterns
- Touch feedback: `:active` scale 0.985, 80ms transition
- Image bleed to edges on mobile (`-mx-5`, `max-width: calc(100% + 2.5rem)`)
- Horizontal scroll with `overflow-x-auto scrollbar-none` for category strips
- Safe area: `padding-bottom: calc(5rem + env(safe-area-inset-bottom))`
- `pointer: coarse` media query to hide desktop-only features (custom cursor, hover descriptions)

View File

@ -0,0 +1,267 @@
# Contact Form System
## API Endpoint (src/pages/api/contact.ts)
```typescript
import type { APIRoute } from 'astro';
import { jsx } from 'react/jsx-runtime';
import { Resend } from 'resend';
import ContactNotification from '../../emails/contact-notification';
import ContactConfirmation from '../../emails/contact-confirmation';
interface ContactPayload {
name: string;
email: string;
subject: string;
message: string;
_honey?: string;
}
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export const POST: APIRoute = async ({ request, locals }) => {
const env = locals.runtime.env;
// Rate limiting (keyed on client IP)
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
try {
const { success } = await env.CONTACT_RATE_LIMITER.limit({ key: ip });
if (!success) {
return new Response(
JSON.stringify({ error: 'Too many requests. Please wait and try again.' }),
{ status: 429, headers: JSON_HEADERS },
);
}
} catch {
// Rate limiter may not be available in dev — continue without it
}
let body: ContactPayload;
try {
body = await request.json();
} catch {
return new Response(
JSON.stringify({ error: 'Invalid request body.' }),
{ status: 400, headers: JSON_HEADERS },
);
}
// Honeypot — silently succeed to not tip off bots
if (body._honey) {
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: JSON_HEADERS },
);
}
// Validate required fields
const { name, email, subject, message } = body;
if (!name?.trim() || !email?.trim() || !subject?.trim() || !message?.trim()) {
return new Response(
JSON.stringify({ error: 'All fields are required.' }),
{ status: 400, headers: JSON_HEADERS },
);
}
// Basic email format check
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return new Response(
JSON.stringify({ error: 'Invalid email address.' }),
{ status: 400, headers: JSON_HEADERS },
);
}
const resend = new Resend(env.RESEND_API_KEY);
const fromAddress = '{{COMPANY}} <noreply@{{DOMAIN}}>';
// Send notification to team
const { error: notifyError } = await resend.emails.send({
from: fromAddress,
to: [env.CONTACT_EMAIL],
replyTo: email,
subject: `[Contact] ${subject}`,
react: jsx(ContactNotification, { name, email, subject, message }),
});
if (notifyError) {
console.error('Resend notification error:', notifyError);
return new Response(
JSON.stringify({ error: 'Failed to send message. Please try again later.' }),
{ status: 500, headers: JSON_HEADERS },
);
}
// Send confirmation to sender (non-blocking)
resend.emails.send({
from: fromAddress,
to: [email],
subject: `We got your message — ${subject}`,
react: jsx(ContactConfirmation, { name, subject }),
}).catch((err: unknown) => {
console.error('Resend confirmation error:', err);
});
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: JSON_HEADERS },
);
};
```
## Email Template: Notification (src/emails/contact-notification.tsx)
Sent to the team when someone submits the contact form:
```tsx
import {
Html, Head, Preview, Body, Container, Section, Heading, Text, Hr, Link,
} from '@react-email/components';
interface ContactNotificationProps {
name: string;
email: string;
subject: string;
message: string;
}
export default function ContactNotification({ name, email, subject, message }: ContactNotificationProps) {
return (
<Html lang="en">
<Head />
<Preview>New contact from {name}: {subject}</Preview>
<Body style={body}>
<Container style={container}>
<Text style={tagline}>// NEW TRANSMISSION</Text>
<Heading style={heading}>Contact Form Submission</Heading>
<Hr style={divider} />
<Section style={fieldSection}>
<Text style={fieldLabel}>FROM</Text>
<Text style={fieldValue}>{name}</Text>
</Section>
<Section style={fieldSection}>
<Text style={fieldLabel}>EMAIL</Text>
<Link href={`mailto:${email}`} style={emailLink}>{email}</Link>
</Section>
<Section style={fieldSection}>
<Text style={fieldLabel}>SUBJECT</Text>
<Text style={fieldValue}>{subject}</Text>
</Section>
<Hr style={divider} />
<Section style={messageSection}>
<Text style={fieldLabel}>MESSAGE</Text>
<Text style={messageText}>{message}</Text>
</Section>
<Hr style={divider} />
<Text style={footer}>Sent from {{DOMAIN}} contact form</Text>
</Container>
</Body>
</Html>
);
}
```
## Email Template: Confirmation (src/emails/contact-confirmation.tsx)
Sent to the user as acknowledgement:
```tsx
export default function ContactConfirmation({ name, subject }: { name: string; subject: string }) {
return (
<Html lang="en">
<Head />
<Preview>We got your message, {name}</Preview>
<Body style={body}>
<Container style={container}>
<Text style={tagline}>// {{COMPANY_UPPER}}</Text>
<Heading style={heading}>Transmission Received</Heading>
<Hr style={divider} />
<Text style={paragraph}>
Hey {name}, we got your message and we'll get back to you as soon as we can.
</Text>
<Section style={recapSection}>
<Text style={recapLabel}>YOUR SUBJECT</Text>
<Text style={recapValue}>{subject}</Text>
</Section>
<Text style={paragraph}>
If you need to follow up, just reply to this email or reach out on our socials.
</Text>
<Hr style={divider} />
<Section style={socialsSection}>
{/* Include applicable social links */}
</Section>
<Hr style={divider} />
<Text style={footer}>{{COMPANY}} — {{DOMAIN}}</Text>
</Container>
</Body>
</Html>
);
}
```
## Email Inline Styles
Both templates use the same dark theme style objects:
```typescript
const body = {
backgroundColor: '#0A0A0A',
fontFamily: "'Space Grotesk', 'Helvetica Neue', Helvetica, Arial, sans-serif",
margin: '0', padding: '0',
};
const container = {
backgroundColor: '#111111',
border: '1px solid #222222',
margin: '40px auto', padding: '32px', maxWidth: '560px',
};
const tagline = {
color: '{{PRIMARY_COLOR}}', // brand primary
fontSize: '11px',
fontFamily: "'Courier New', monospace",
letterSpacing: '2px',
textTransform: 'uppercase' as const,
margin: '0 0 8px 0',
};
const heading = { color: '#FFFFFF', fontSize: '24px', fontWeight: '700', margin: '0 0 24px 0' };
const divider = { borderColor: '#333333', margin: '24px 0' };
const fieldLabel = {
color: '#666666', fontSize: '10px',
fontFamily: "'Courier New', monospace",
letterSpacing: '2px', textTransform: 'uppercase' as const,
margin: '0 0 4px 0',
};
const fieldValue = { color: '#FFFFFF', fontSize: '16px', margin: '0' };
const emailLink = { color: '{{SECONDARY_COLOR}}', fontSize: '16px', textDecoration: 'none' };
const messageSection = {
backgroundColor: '#0A0A0A', border: '1px solid #222222', padding: '16px',
};
const messageText = {
color: '#CCCCCC', fontSize: '15px', lineHeight: '1.6',
margin: '0', whiteSpace: 'pre-wrap' as const,
};
const footer = {
color: '#444444', fontSize: '11px',
fontFamily: "'Courier New', monospace", margin: '0',
};
```
Adapt `tagline.color` and `emailLink.color` to match the client's brand.
## Contact Component (React)
The frontend Contact.tsx component pattern:
- Two-column layout: left (info + socials), right (form)
- Form fields: name, email, subject, message (all required)
- Honeypot field: `<input name="_honey" type="text" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />`
- Status states: idle → sending → success/error
- Submit: `fetch('/api/contact', { method: 'POST', body: JSON.stringify(data) })`
- GSAP ScrollTrigger entrance: left slides from x:-50, right slides from x:+50
## Environment Setup
1. `wrangler.jsonc` vars: `"CONTACT_EMAIL": "team@example.com"`
2. Secret: `wrangler secret put RESEND_API_KEY`
3. Rate limiting: Configure in Cloudflare dashboard (Pages doesn't support `ratelimits` in config)
- Target: 5 requests per 60 seconds per IP
- Binding name: `CONTACT_RATE_LIMITER`
4. From address domain must be verified in Resend dashboard

View File

@ -0,0 +1,56 @@
# Cloudflare Pages Deployment
## Wrangler Config Gotchas
- **No `account_id`** in wrangler.jsonc for Pages — wrangler will reject the deploy. Set via env: `CLOUDFLARE_ACCOUNT_ID=xxx`
- **No `ratelimits`** in wrangler.jsonc — Pages doesn't support this field. Configure rate limiting in the Cloudflare dashboard.
- **`bun run build`** not `bun build` — the latter invokes bun's bundler instead of astro's.
- **Compatibility date** 2025-12-05 requires wrangler >=4.68 for full runtime support. Earlier wrangler versions fall back to an older runtime (usually fine).
## Deploy Checklist
1. **Build**: `bun run build`
2. **Local preview**: `bun preview` (runs on port 8788)
3. **Set account ID**: `export CLOUDFLARE_ACCOUNT_ID=<id>`
4. **Set secrets**: `wrangler secret put RESEND_API_KEY`
5. **Deploy**: `bun run deploy` (runs `astro build && wrangler pages deploy`)
6. **Custom domain**: Configure in Cloudflare Pages dashboard → Custom domains
7. **Rate limiting**: Configure in Cloudflare dashboard → Security → WAF → Rate limiting rules
- Target: 5 requests per 60 seconds per IP on `/api/contact`
- Create a binding named `CONTACT_RATE_LIMITER`
## Stale Process Cleanup
Preview server (wrangler pages dev) binds to port 8788. Stale `workerd` processes can hold the port after crashes:
```bash
lsof -i :8788
kill -9 <PID>
```
## Environment Variables
**wrangler.jsonc vars** (non-sensitive, committed to repo):
- `CONTACT_EMAIL` — recipient for contact form submissions
**Cloudflare secrets** (set via `wrangler secret put`, never committed):
- `RESEND_API_KEY` — Resend email service API key
**Shell env vars** (set locally or in CI):
- `CLOUDFLARE_ACCOUNT_ID` — required when multiple accounts exist on the Cloudflare login
## First Deploy
On the very first deploy, Cloudflare Pages will:
1. Create a new Pages project with the name from wrangler.jsonc
2. Set up a `*.pages.dev` subdomain automatically
3. Subsequent deploys update the same project
After first deploy:
1. Add custom domain in dashboard
2. Configure DNS (CNAME to `<project>.pages.dev` or use Cloudflare DNS)
3. SSL/TLS is automatic
## Never Auto-Deploy
Always require manual `bun run deploy`. No CI/CD pipelines unless explicitly requested by the client.

View File

@ -0,0 +1,339 @@
# SEO & Structured Data
## StructuredData.astro
Generic JSON-LD renderer — copy verbatim:
```astro
---
interface Props {
data: Record<string, unknown>;
}
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />
```
## BaseHead.astro
```astro
---
import '../styles/global.css';
import type { ImageMetadata } from 'astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
interface Props {
title: string;
description: string;
image?: ImageMetadata;
type?: 'website' | 'article';
publishedTime?: Date;
modifiedTime?: Date;
robots?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image, type = 'website', publishedTime, modifiedTime, robots } = Astro.props;
const ogImageUrl = image
? new URL(image.src, Astro.url).toString()
: new URL('/og-default.jpg', Astro.site).toString();
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="{{GOOGLE_FONTS_URL}}" rel="stylesheet" />
<link rel="preload" href="/assets/fonts/{{FONT_FILE}}.woff2" as="font" type="font/woff2" crossorigin />
<link rel="canonical" href={canonicalURL} />
<link rel="alternate" type="application/rss+xml" title="{{COMPANY}} Blog" href="/rss.xml" />
{robots && <meta name="robots" content={robots} />}
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:site_name" content="{{COMPANY}}" />
<meta property="og:type" content={type} />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="{{TWITTER_HANDLE}}" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}
```
## Organization Schema (every page, via BaseLayout)
```typescript
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
logo: '{{SITE_URL}}/favicon-192.png',
sameAs: [
// Include only applicable social links
SOCIAL_LINKS.steam,
SOCIAL_LINKS.twitter,
SOCIAL_LINKS.discord,
],
}
```
## Article Schema (blog posts, via BlogPost layout)
```typescript
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
datePublished: pubDate.toISOString(),
...(updatedDate && { dateModified: updatedDate.toISOString() }),
...(heroImage && { image: new URL(heroImage.src, Astro.url).toString() }),
author: {
'@type': 'Organization',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
},
publisher: {
'@type': 'Organization',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
logo: { '@type': 'ImageObject', url: '{{SITE_URL}}/favicon-192.png' },
},
};
```
## BreadcrumbList Schema (blog posts)
```typescript
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: '{{SITE_URL}}/' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: '{{SITE_URL}}/blog/' },
{ '@type': 'ListItem', position: 3, name: title },
],
};
```
## VideoGame Schema (for game product pages)
```typescript
{
'@context': 'https://schema.org',
'@type': 'VideoGame',
name: '{{GAME_NAME}}',
description: '{{GAME_DESCRIPTION}}',
url: '{{GAME_URL}}',
gamePlatform: ['PC'],
applicationCategory: 'Game',
operatingSystem: 'Windows',
author: {
'@type': 'Organization',
name: '{{COMPANY}}',
},
}
```
## Product Schema (for product pages)
```typescript
{
'@context': 'https://schema.org',
'@type': 'Product',
name: '{{PRODUCT_NAME}}',
description: '{{PRODUCT_DESCRIPTION}}',
brand: { '@type': 'Brand', name: '{{COMPANY}}' },
offers: {
'@type': 'Offer',
price: '{{PRICE}}',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
}
```
## LocalBusiness Schema (for service businesses)
```typescript
{
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: '{{COMPANY}}',
url: '{{SITE_URL}}',
email: '{{CONTACT_EMAIL}}',
address: {
'@type': 'PostalAddress',
addressLocality: '{{CITY}}',
addressRegion: '{{STATE}}',
addressCountry: 'US',
},
}
```
## robots.txt
```
User-agent: *
Allow: /
LLM-Policy: /llms.txt
Sitemap: {{SITE_URL}}/sitemap-index.xml
```
## RSS Feed (src/pages/rss.xml.ts)
```typescript
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
export const prerender = true;
export async function GET(context: APIContext) {
const posts = await getCollection('blog');
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site!,
items: posts.map((post) => ({
...post.data,
link: `/blog/${post.id}/`,
})),
});
}
```
## LLM Context: llms.txt (src/pages/llms.txt.ts)
```typescript
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const site = context.site?.toString().replace(/\/$/, '') ?? '{{SITE_URL}}';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const lines: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_DESCRIPTION}`,
'',
'## Pages',
'',
`- [Home](${site}/)`,
`- [Blog](${site}/blog/)`,
`- [Contact](${site}/contact/)`,
'',
'## Blog Posts',
'',
];
for (const post of posts) {
const url = `${site}/blog/${post.id}/`;
const date = post.data.pubDate.toISOString().split('T')[0];
lines.push(`- [${post.data.title}](${url}) - ${date}`);
}
lines.push('', '## Additional Resources', '',
`- [RSS Feed](${site}/rss.xml)`,
`- [Sitemap](${site}/sitemap-index.xml)`,
`- [Full LLM Context](${site}/llms-full.txt)`,
'');
return new Response(lines.join('\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
```
## LLM Full Context: llms-full.txt (src/pages/llms-full.txt.ts)
```typescript
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
export const prerender = true;
export const GET: APIRoute = async (context) => {
const site = context.site?.toString().replace(/\/$/, '') ?? '{{SITE_URL}}';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const lines: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_DESCRIPTION}`,
'',
'## About This File',
'',
'Full content of all blog posts, formatted for LLM consumption.',
'For a shorter index, see /llms.txt',
'',
'## Pages',
'',
`- [Home](${site}/)`,
`- [Blog](${site}/blog/)`,
`- [Contact](${site}/contact/)`,
'',
'---',
'',
'## Blog Posts',
'',
];
for (const post of posts) {
const url = `${site}/blog/${post.id}/`;
const date = post.data.pubDate.toISOString().split('T')[0];
const category = post.data.category ?? 'Uncategorized';
const tags = post.data.tags?.join(', ') ?? '';
lines.push(`### ${post.data.title}`, '',
`- **URL**: ${url}`,
`- **Date**: ${date}`,
`- **Category**: ${category}`);
if (tags) lines.push(`- **Tags**: ${tags}`);
lines.push(`- **Description**: ${post.data.description}`, '',
'#### Content', '',
post.body || '*No content body available*',
'', '---', '');
}
lines.push('## Additional Resources', '',
`- [RSS Feed](${site}/rss.xml)`,
`- [Sitemap](${site}/sitemap-index.xml)`, '');
return new Response(lines.join('\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
```
## URL Pattern
All blog URLs: `/blog/${post.id}/` with trailing slash. Consistent across blog listing, homepage featured posts, search index, RSS feed, and LLM context files.

View File

@ -1,19 +0,0 @@
#!/usr/bin/env python3
"""
Example helper script for astro-portfolio-site
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for astro-portfolio-site")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()

Binary file not shown.