2026-02-25T02-10-13_auto_memory/memories.db-wal
This commit is contained in:
parent
ef6b36855e
commit
7dfd02d011
Binary file not shown.
Binary file not shown.
@ -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.
|
||||
18
skills/astro-portfolio-site/assets/scaffold-dirs.txt
Normal file
18
skills/astro-portfolio-site/assets/scaffold-dirs.txt
Normal 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
|
||||
@ -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
|
||||
263
skills/astro-portfolio-site/references/blog-infrastructure.md
Normal file
263
skills/astro-portfolio-site/references/blog-infrastructure.md
Normal 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
|
||||
312
skills/astro-portfolio-site/references/component-patterns.md
Normal file
312
skills/astro-portfolio-site/references/component-patterns.md
Normal 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)
|
||||
267
skills/astro-portfolio-site/references/contact-form-system.md
Normal file
267
skills/astro-portfolio-site/references/contact-form-system.md
Normal 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
|
||||
@ -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.
|
||||
339
skills/astro-portfolio-site/references/seo-structured-data.md
Normal file
339
skills/astro-portfolio-site/references/seo-structured-data.md
Normal 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.
|
||||
@ -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()
|
||||
BIN
skills/skill-creator/astro-portfolio-site.skill
Normal file
BIN
skills/skill-creator/astro-portfolio-site.skill
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user