From d44bcbb80eb5237341579fbce15f0d7b73a2fad0 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 11 Jun 2025 22:19:07 -0700 Subject: [PATCH 1/3] Fix some console errors --- prisma/seed.ts | 4 ---- src/routes/api/lastfm/+server.ts | 2 +- static/favicon.ico | 0 static/robots.txt | 20 ++++++++++++++++++++ 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 static/favicon.ico create mode 100644 static/robots.txt diff --git a/prisma/seed.ts b/prisma/seed.ts index 551ae57..be31eff 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -25,7 +25,6 @@ async function main() { client: 'Personal Project', role: 'Founder & Designer', projectType: 'work', - featuredImage: '/images/projects/maitsu-cover.png', backgroundColor: '#FFF7EA', highlightColor: '#F77754', displayOrder: 1, @@ -44,7 +43,6 @@ async function main() { client: 'Slack Technologies', role: 'Senior Product Designer', projectType: 'work', - featuredImage: '/images/projects/slack-cover.png', backgroundColor: '#4a154b', highlightColor: '#611F69', displayOrder: 2, @@ -63,7 +61,6 @@ async function main() { client: 'Figma Inc.', role: 'Product Designer', projectType: 'work', - featuredImage: '/images/projects/figma-cover.png', backgroundColor: '#2c2c2c', highlightColor: '#0ACF83', displayOrder: 3, @@ -82,7 +79,6 @@ async function main() { client: 'Pinterest', role: 'Product Designer #1', projectType: 'work', - featuredImage: '/images/projects/pinterest-cover.png', backgroundColor: '#f7f7f7', highlightColor: '#CB1F27', displayOrder: 4, diff --git a/src/routes/api/lastfm/+server.ts b/src/routes/api/lastfm/+server.ts index ca17a5f..8af67d0 100644 --- a/src/routes/api/lastfm/+server.ts +++ b/src/routes/api/lastfm/+server.ts @@ -28,7 +28,7 @@ export const GET: RequestHandler = async ({ url }) => { return await enrichAlbumWithInfo(client, album) } catch (error) { if (error instanceof Error && error.message.includes('Album not found')) { - console.warn(`Skipping album: ${album.name} (Album not found)`) + console.debug(`Skipping album: ${album.name} (Album not found)`) return null // Skip the album } throw error // Re-throw if it's a different error diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..a5a68c2 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,20 @@ +# Allow Google +User-agent: Googlebot +Allow: / + +# Block AI crawlers +User-agent: GPTBot +Disallow: / + +User-agent: ChatGPT-User +Disallow: / + +User-agent: Claude-Web +Disallow: / + +User-agent: PerplexityBot +Disallow: / + +# Allow all other bots +User-agent: * +Allow: / \ No newline at end of file From af7122a7f6067f1be79b2b8e18dbffa158e38d13 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 11 Jun 2025 22:49:56 -0700 Subject: [PATCH 2/3] Update PRDs --- prd/PRD-apple-music-integration.md | 283 +++++++++++++++++++++++++++++ prd/PRD-seo-metadata-system.md | 249 +++++++++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 prd/PRD-apple-music-integration.md create mode 100644 prd/PRD-seo-metadata-system.md diff --git a/prd/PRD-apple-music-integration.md b/prd/PRD-apple-music-integration.md new file mode 100644 index 0000000..bc46a13 --- /dev/null +++ b/prd/PRD-apple-music-integration.md @@ -0,0 +1,283 @@ +# Product Requirements Document: Apple Music Integration + +## Overview + +Integrate Apple Music API to enhance the music features on jedmund.com by replacing the current iTunes Search API with the full Apple Music API. This will provide higher quality artwork and 30-second preview clips immediately, while fetching and storing richer metadata for future UI enhancements. The initial implementation will maintain the current UI design with minimal changes. + +## Current State + +- **Last.fm Integration**: Fetches recent listening history (10 albums) +- **iTunes Search API**: Enhances album artwork (600x600 resolution) +- **Display**: Shows albums on homepage with basic metadata +- **Limitations**: Low-res artwork, no previews, limited metadata + +## Goals + +### Primary Goals +- **Replace iTunes Search API** with Apple Music API for better data quality +- **Add 30-second preview playback** for discovered music +- **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use +- **Improve artwork quality** from 600x600 to 3000x3000 resolution + +### Secondary Goals +- **Implement proper caching** using Redis (matching other API patterns) +- **Create reusable audio components** for future music features +- **Maintain current UI** while preparing data structure for future enhancements +- **Prepare foundation** for future user library integration + +### Technical Goals +- Secure JWT token generation and management +- Efficient API response caching +- Clean component architecture for audio playback +- Type-safe Apple Music API integration + +## Success Metrics + +- Successfully replace all iTunes Search API calls +- Zero increase in page load time despite fetching more data +- Functional audio previews with smooth playback +- Higher quality artwork displayed throughout site +- Enhanced metadata properly cached and ready for future use + +## Implementation Phases + +### Phase 1: Foundation Setup (Week 1) + +#### JWT Authentication & Configuration + +- [ ] Install required dependencies (`jsonwebtoken`, `node-fetch`) +- [ ] Create `/src/lib/server/apple-music-auth.ts` for JWT generation +- [ ] Port Deno JWT code to Node.js environment +- [ ] Implement token caching mechanism (6-month expiry) +- [ ] Add environment variables to `.env.example`: + - `APPLE_MUSIC_TEAM_ID` + - `APPLE_MUSIC_KEY_ID` + - `APPLE_MUSIC_PRIVATE_KEY_PATH` +- [ ] Create secure storage solution for .p8 private key file +- [ ] Add environment validation in server startup + +#### Type Definitions & Interfaces + +- [ ] Create `/src/lib/types/apple-music.ts` with interfaces for: + - `AppleMusicAlbum` + - `AppleMusicTrack` + - `AppleMusicArtwork` + - `AppleMusicPreview` + - `AppleMusicSearchResponse` +- [ ] Extend existing `Album` type to include Apple Music fields +- [ ] Create type guards for API response validation +- [ ] Document all new interfaces with JSDoc comments + +### Phase 2: API Integration (Week 1-2) + +#### Apple Music API Client + +- [ ] Create `/src/lib/server/apple-music-client.ts` with methods: + - `searchAlbums(query: string, limit?: number)` + - `getAlbum(id: string)` + - `getAlbumTracks(id: string)` + - `searchTracks(query: string, limit?: number)` +- [ ] Implement proper error handling and retry logic +- [ ] Add request rate limiting (Apple Music allows 3000/hour) +- [ ] Create response transformation utilities + +#### Replace iTunes Search Integration + +- [ ] Backup current `/src/routes/api/lastfm/+server.ts` +- [ ] Remove `node-itunes-search` dependency +- [ ] Update `addItunesArtToAlbums` to use Apple Music API +- [ ] Fetch full album metadata but only expose artwork and preview URLs initially +- [ ] Store enhanced metadata in response for future use +- [ ] Maintain existing response structure for UI compatibility +- [ ] Add fallback to Last.fm images if Apple Music fails +- [ ] Test with various album/artist combinations + +#### Caching Layer + +- [ ] Extend Redis client usage to Apple Music responses +- [ ] Implement cache keys: `apple:album:{id}`, `apple:search:{query}` +- [ ] Set TTL to 24 hours for catalog data +- [ ] Add cache warming for popular albums +- [ ] Create cache invalidation utilities +- [ ] Monitor cache hit rates + +### Phase 3: Frontend Enhancement (Week 2-3) + +#### Audio Preview Component + +- [ ] Create `/src/lib/components/MusicPreview.svelte` with: + - Play/pause toggle button + - Progress bar (30-second duration) + - Volume control + - Loading state + - Error handling +- [ ] Implement keyboard controls (space for play/pause) +- [ ] Add accessibility labels and ARIA attributes +- [ ] Create smooth fade in/out for previews +- [ ] Handle multiple preview instances (pause others when playing) + +#### Enhanced Album Component + +- [ ] Update `/src/lib/components/Album.svelte` to: + - Use high-resolution artwork (with lazy loading) + - Add "Preview" button (if preview URL available) + - Keep all other UI elements unchanged +- [ ] Store enhanced metadata in component props for future use +- [ ] Implement progressive image loading (blur-up technique) +- [ ] Add error states for missing preview URLs +- [ ] Optimize preview button for mobile touch interactions + +#### Homepage Integration + +- [ ] Update music section data fetching +- [ ] Add preview player controls to album grid +- [ ] Implement smooth transitions between previews +- [ ] Add loading states during API calls +- [ ] Test cross-browser audio compatibility + +### Phase 4: Testing & Optimization (Week 3) + +#### Performance Optimization + +- [ ] Implement image optimization pipeline for artwork +- [ ] Add WebP format support with fallbacks +- [ ] Lazy load audio preview components +- [ ] Minimize Apple Music API calls +- [ ] Profile and optimize render performance + +#### Testing + +- [ ] Unit tests for Apple Music client +- [ ] Integration tests for API endpoints +- [ ] Component tests for MusicPreview +- [ ] E2E tests for preview playback flow +- [ ] Cross-browser testing (Safari, Chrome, Firefox) +- [ ] Mobile device testing + +#### Documentation + +- [ ] Update README with Apple Music setup instructions +- [ ] Document all new environment variables +- [ ] Create component usage examples +- [ ] Add troubleshooting guide +- [ ] Document API rate limits and caching strategy + +### Phase 5: Future Enhancements (Post-Launch) + +#### User Library Integration + +- [ ] Research Apple Music OAuth requirements +- [ ] Design user authentication flow +- [ ] Create library sync endpoints +- [ ] Build playlist display components +- [ ] Implement recently played tracking + +#### Recommendations & Discovery + +- [ ] Integrate Apple Music recommendations API +- [ ] Create "Similar Artists" component +- [ ] Build "Discover" page with curated content +- [ ] Add music taste profile generation +- [ ] Implement collaborative filtering + +#### Advanced Features + +- [ ] Full-length playback (with proper licensing) +- [ ] Playlist creation and management +- [ ] Social sharing of previews +- [ ] Music stats and analytics +- [ ] Apple Music embed widgets + +## Technical Architecture + +### API Flow +``` +Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend +``` + +### Component Hierarchy +``` +HomePage + └── AlbumGrid + └── Album + ├── AlbumArtwork (enhanced) + ├── AlbumMetadata (enhanced) + └── MusicPreview (new) +``` + +### Data Structure +```typescript +interface EnhancedAlbum extends Album { + appleMusicId?: string; + highResArtwork?: string; // Used immediately + previewUrl?: string; // Used immediately + + // Stored for future use (not displayed yet): + genres?: string[]; + releaseDate?: string; + trackCount?: number; + tracks?: AppleMusicTrack[]; +} +``` + +## Security Considerations + +- Private key (.p8) must never be committed to repository +- JWT tokens should be generated server-side only +- Implement proper CORS headers for API endpoints +- Rate limit client requests to prevent abuse +- Validate all Apple Music API responses + +## Dependencies + +### New Dependencies +- `jsonwebtoken`: JWT generation +- `@types/jsonwebtoken`: TypeScript types + +### Existing Dependencies to Leverage +- `redis`: Caching layer +- `$lib/server/redis-client`: Existing Redis connection + +### Dependencies to Remove +- `node-itunes-search`: Replaced by Apple Music API + +## Rollback Plan + +1. Keep iTunes Search code commented but not removed initially +2. Implement feature flag for Apple Music integration +3. Monitor error rates and performance metrics +4. Have quick rollback script ready +5. Maintain data structure compatibility + +## Open Questions + +1. Should we display Apple Music attribution/badges? +2. Do we want to track preview play analytics? +3. Should previews auto-play on hover or require click? +4. How should we handle explicit content? +5. Do we want to implement Apple Music affiliate links? + +## Success Criteria + +- [ ] All iTunes Search API calls replaced successfully +- [ ] 30-second previews playing smoothly across all browsers +- [ ] Artwork quality noticeably improved +- [ ] Enhanced metadata fetched and cached (even if not displayed) +- [ ] No increase in page load time +- [ ] Current UI remains unchanged (except preview button) +- [ ] Zero security vulnerabilities +- [ ] Redis cache hit rate > 80% + +## Timeline + +- **Week 1**: Foundation setup and API client development +- **Week 2**: Integration and frontend components +- **Week 3**: Testing, optimization, and launch +- **Post-launch**: Monitor and iterate based on usage + +## Resources + +- [Apple Music API Documentation](https://developer.apple.com/documentation/applemusicapi) +- [JWT Best Practices](https://tools.ietf.org/html/rfc8725) +- [Original Deno Implementation](https://gist.github.com/NetOpWibby/fca4e7942617095677831d6c74187f84) +- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features) \ No newline at end of file diff --git a/prd/PRD-seo-metadata-system.md b/prd/PRD-seo-metadata-system.md new file mode 100644 index 0000000..d40876d --- /dev/null +++ b/prd/PRD-seo-metadata-system.md @@ -0,0 +1,249 @@ +# PRD: SEO & Metadata System + +## Executive Summary + +This PRD outlines the implementation of a comprehensive SEO and metadata system for jedmund.com. Currently, many pages lack proper browser titles, OpenGraph tags, and Twitter cards, which impacts search engine visibility and social media sharing. This upgrade will create a systematic approach to metadata management across all pages. + +## Problem Statement + +### Current Issues +1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata +2. **Missing Social Media Support**: No Twitter cards on any pages +3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps +4. **Hardcoded Values**: Base meta tags in app.html cannot be dynamically updated +5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement + +### Impact +- Reduced search engine visibility +- Poor social media sharing experience +- Missed opportunities for rich snippets in search results +- Inconsistent branding across shared links + +## Goals + +1. **Implement comprehensive metadata** on all pages +2. **Create reusable components** for consistent implementation +3. **Support dynamic content** with appropriate fallbacks +4. **Enhance social sharing** with proper images and descriptions +5. **Improve SEO** with structured data and technical optimizations + +## Success Metrics + +- 100% of pages have appropriate title tags +- All shareable pages have OpenGraph and Twitter card support +- Dynamic pages pull metadata from their content +- Consistent branding across all metadata +- Valid structured data on relevant pages + +## Proposed Solution + +### 1. Core Components + +#### SeoMetadata Component +A centralized Svelte component that handles all metadata needs: + +```svelte + +``` + +Features: +- Automatic title formatting (e.g., "Title | @jedmund") +- Fallback chains for missing data +- Support for all OpenGraph types +- Twitter card generation +- Canonical URL handling +- JSON-LD structured data + +### 2. Page-Specific Implementation + +#### High Priority Pages + +**Home Page (/)** +- Title: "@jedmund — Software designer and strategist" +- Description: Professional summary +- Type: website +- Image: Professional headshot or branded image + +**Work Project Pages (/work/[slug])** +- Title: "[Project Name] by @jedmund" +- Description: Project description +- Type: article +- Image: Project logo on brand color background +- Structured data: CreativeWork schema + +**Photo Pages (/photos/[slug]/[id])** +- Title: "[Photo Title] | Photography by @jedmund" +- Description: Photo caption or album context +- Type: article +- Image: The photo itself +- Structured data: ImageObject schema + +**Universe Posts (/universe/[slug])** +- Essays (long-form): "[Essay Name] — @jedmund" +- Posts (short-form): "@jedmund: [Post snippet]" +- Description: Post excerpt (first 160 chars) +- Type: article +- Image: First attachment or fallback +- Structured data: BlogPosting schema + +#### Medium Priority Pages + +**Labs Projects (/labs/[slug])** +- Similar to Work projects but with "Lab" designation +- Experimental project metadata + +**About Page (/about)** +- Title: "About | @jedmund" +- Description: Professional bio excerpt +- Type: profile +- Structured data: Person schema + +**Photo Albums (/photos/[slug])** +- Title: "[Album Name] | Photography by @jedmund" +- Description: Album description +- Type: website +- Image: Album cover or first photo + +### 3. Dynamic OG Image Generation + +Create an API endpoint (`/api/og-image`) that generates images: +- For projects: Logo on brand color background +- For photos: The photo itself with optional watermark +- For text posts: Branded template with title +- Fallback: Site-wide branded image + +### 4. Technical SEO Improvements + +**Sitemap Generation** +- Dynamic sitemap.xml generation +- Include all public pages +- Update frequency and priority hints + +**Robots.txt** +- Allow all crawlers by default +- Block admin routes +- Reference sitemap location + +**Canonical URLs** +- Automatic canonical URL generation +- Handle www/non-www consistency +- Support pagination parameters + +### 5. Utilities & Helpers + +**formatSeoTitle(title, suffix = "@jedmund")** +- Consistent title formatting +- Character limit enforcement (60 chars) + +**generateDescription(content, limit = 160)** +- Extract description from content +- HTML stripping +- Smart truncation + +**getCanonicalUrl(path)** +- Generate absolute URLs +- Handle query parameters +- Ensure consistency + +## Implementation Plan + +### Phase 1: Foundation (Week 1) +- [ ] Create SeoMetadata component +- [ ] Implement basic meta tag support +- [ ] Add title/description utilities +- [ ] Update app.html to remove hardcoded values + +### Phase 2: Critical Pages (Week 2) +- [ ] Home page metadata +- [ ] Work project pages +- [ ] Universe post pages +- [ ] Photo detail pages + +### Phase 3: Secondary Pages (Week 3) +- [ ] About page +- [ ] Labs page and projects +- [ ] Photo albums and index +- [ ] Universe feed + +### Phase 4: Advanced Features (Week 4) +- [ ] Dynamic OG image generation +- [ ] Structured data implementation +- [ ] Sitemap generation +- [ ] Technical SEO improvements + +### Phase 5: Testing & Refinement (Week 5) +- [ ] Test all pages with social media debuggers +- [ ] Validate structured data +- [ ] Performance optimization +- [ ] Documentation + +## Technical Considerations + +### Performance +- Metadata generation should not impact page load time +- Cache generated OG images +- Minimize JavaScript overhead + +### Maintenance +- Centralized component reduces update complexity +- Clear documentation for adding new pages +- Automated testing for metadata presence + +### Compatibility +- Support major social platforms (Twitter, Facebook, LinkedIn) +- Ensure search engine compatibility +- Fallback for missing data + +## Risks & Mitigation + +**Risk**: Dynamic image generation could be slow +**Mitigation**: Implement caching and pre-generation for known content + +**Risk**: Incorrect metadata could hurt SEO +**Mitigation**: Thorough testing and validation tools + +**Risk**: Increased complexity for developers +**Mitigation**: Clear component API and documentation + +## Future Enhancements + +1. **A/B Testing**: Test different titles/descriptions for engagement +2. **Analytics Integration**: Track which metadata drives traffic +3. **Internationalization**: Support for multiple languages +4. **Rich Snippets**: Implement more schema types (FAQ, HowTo, etc.) +5. **Social Media Automation**: Auto-generate platform-specific variants + +## Appendix + +### Current Implementation Status + +✅ **Good Implementation** +- /universe/[slug] +- /photos/[albumSlug]/[photoId] + +⚠️ **Partial Implementation** +- /photos/[slug] +- /universe + +❌ **No Implementation** +- / (home) +- /about +- /labs +- /labs/[slug] +- /photos +- /work/[slug] + +### Resources +- [OpenGraph Protocol](https://ogp.me/) +- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards) +- [Schema.org](https://schema.org/) +- [Google SEO Guidelines](https://developers.google.com/search/docs) \ No newline at end of file From 46d655e8f02dd1330f7565b7dd6c4cfd9233bf70 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 11 Jun 2025 23:58:11 -0700 Subject: [PATCH 3/3] Better OpenGraph and HTML metadata --- prd/PRD-apple-music-integration.md | 29 +- prd/PRD-seo-metadata-system-v2.md | 204 ++++++++++ prd/PRD-seo-metadata-system.md | 48 ++- src/app.html | 8 - src/lib/utils/metadata.ts | 364 ++++++++++++++++++ src/routes/+layout.svelte | 21 +- src/routes/+page.svelte | 32 ++ src/routes/about/+page.svelte | 34 ++ src/routes/labs/+page.svelte | 31 ++ src/routes/labs/[slug]/+page.svelte | 65 ++++ src/routes/photos/+page.svelte | 31 ++ .../photos/[albumSlug]/[photoId]/+page.svelte | 93 +++-- src/routes/photos/[slug]/+page.svelte | 64 ++- src/routes/universe/+page.svelte | 31 +- src/routes/universe/[slug]/+page.svelte | 80 +++- src/routes/work/[slug]/+page.svelte | 66 ++++ 16 files changed, 1119 insertions(+), 82 deletions(-) create mode 100644 prd/PRD-seo-metadata-system-v2.md create mode 100644 src/lib/utils/metadata.ts diff --git a/prd/PRD-apple-music-integration.md b/prd/PRD-apple-music-integration.md index bc46a13..adc581d 100644 --- a/prd/PRD-apple-music-integration.md +++ b/prd/PRD-apple-music-integration.md @@ -14,18 +14,21 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac ## Goals ### Primary Goals + - **Replace iTunes Search API** with Apple Music API for better data quality - **Add 30-second preview playback** for discovered music - **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use - **Improve artwork quality** from 600x600 to 3000x3000 resolution ### Secondary Goals + - **Implement proper caching** using Redis (matching other API patterns) - **Create reusable audio components** for future music features - **Maintain current UI** while preparing data structure for future enhancements - **Prepare foundation** for future user library integration ### Technical Goals + - Secure JWT token generation and management - Efficient API response caching - Clean component architecture for audio playback @@ -191,11 +194,13 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac ## Technical Architecture ### API Flow + ``` Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend ``` ### Component Hierarchy + ``` HomePage └── AlbumGrid @@ -206,17 +211,18 @@ HomePage ``` ### Data Structure + ```typescript interface EnhancedAlbum extends Album { - appleMusicId?: string; - highResArtwork?: string; // Used immediately - previewUrl?: string; // Used immediately - - // Stored for future use (not displayed yet): - genres?: string[]; - releaseDate?: string; - trackCount?: number; - tracks?: AppleMusicTrack[]; + appleMusicId?: string + highResArtwork?: string // Used immediately + previewUrl?: string // Used immediately + + // Stored for future use (not displayed yet): + genres?: string[] + releaseDate?: string + trackCount?: number + tracks?: AppleMusicTrack[] } ``` @@ -231,14 +237,17 @@ interface EnhancedAlbum extends Album { ## Dependencies ### New Dependencies + - `jsonwebtoken`: JWT generation - `@types/jsonwebtoken`: TypeScript types ### Existing Dependencies to Leverage + - `redis`: Caching layer - `$lib/server/redis-client`: Existing Redis connection ### Dependencies to Remove + - `node-itunes-search`: Replaced by Apple Music API ## Rollback Plan @@ -280,4 +289,4 @@ interface EnhancedAlbum extends Album { - [Apple Music API Documentation](https://developer.apple.com/documentation/applemusicapi) - [JWT Best Practices](https://tools.ietf.org/html/rfc8725) - [Original Deno Implementation](https://gist.github.com/NetOpWibby/fca4e7942617095677831d6c74187f84) -- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features) \ No newline at end of file +- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features) diff --git a/prd/PRD-seo-metadata-system-v2.md b/prd/PRD-seo-metadata-system-v2.md new file mode 100644 index 0000000..7b2e3fd --- /dev/null +++ b/prd/PRD-seo-metadata-system-v2.md @@ -0,0 +1,204 @@ +# PRD: SEO & Metadata System - V2 + +## Executive Summary + +This updated PRD acknowledges the existing comprehensive SEO metadata implementation on jedmund.com and focuses on the remaining gaps: dynamic sitemap generation, OG image generation for text content, and metadata testing/validation. + +## Current State Assessment + +### Already Implemented ✅ + +- **Metadata utilities** (`/src/lib/utils/metadata.ts`) providing: + - Complete OpenGraph and Twitter Card support + - JSON-LD structured data generators + - Smart title formatting and fallbacks + - Canonical URL handling +- **100% page coverage** with appropriate metadata +- **Dynamic content support** with excerpt generation +- **Error handling** with noindex for 404 pages + +### Remaining Gaps ❌ + +1. **No dynamic sitemap.xml** +2. **No OG image generation API** for text-based content +3. **No automated metadata validation** +4. **robots.txt doesn't reference sitemap** + +## Revised Goals + +1. **Complete technical SEO** with dynamic sitemap generation +2. **Enhance social sharing** with generated OG images for text content +3. **Ensure quality** with metadata validation tools +4. **Improve discoverability** with complete robots.txt + +## Proposed Implementation + +### Phase 1: Dynamic Sitemap (Week 1) + +#### Create `/src/routes/sitemap.xml/+server.ts` + +```typescript +export async function GET() { + const pages = await getAllPublicPages() + const xml = generateSitemapXML(pages) + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': 'max-age=3600' + } + }) +} +``` + +Features: + +- Auto-discover all public routes +- Include lastmod dates from content +- Set appropriate priorities +- Exclude admin routes + +### Phase 2: OG Image Generation (Week 1-2) + +#### Create `/src/routes/api/og-image/+server.ts` + +```typescript +export async function GET({ url }) { + const { title, subtitle, type } = Object.fromEntries(url.searchParams) + + const svg = generateOGImageSVG({ + title, + subtitle, + type, // 'post', 'project', 'default' + brandColor: '#your-brand-color' + }) + + const png = await convertSVGtoPNG(svg) + + return new Response(png, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=31536000' + } + }) +} +``` + +Templates: + +- **Posts**: Title + excerpt on branded background +- **Projects**: Logo placeholder + title +- **Default**: Site logo + tagline + +### Phase 3: Metadata Validation (Week 2) + +#### Create `/src/lib/utils/metadata-validator.ts` + +```typescript +export function validateMetaTags(page: string) { + return { + hasTitle: checkTitle(), + titleLength: getTitleLength(), + hasDescription: checkDescription(), + descriptionLength: getDescriptionLength(), + hasOGImage: checkOGImage(), + hasCanonical: checkCanonical(), + structuredDataValid: validateJSONLD() + } +} +``` + +#### Add development-only validation component + +- Console warnings for missing/invalid metadata +- Visual indicators in dev mode +- Automated tests for all routes + +### Phase 4: Final Touches (Week 2) + +1. **Update robots.txt** + +``` +Sitemap: https://jedmund.com/sitemap.xml + +# Existing rules... +``` + +2. **Add metadata debugging route** (dev only) + +- `/api/meta-debug` - JSON output of all pages' metadata +- Useful for testing social media previews + +## Success Metrics + +- [ ] Sitemap.xml validates and includes all public pages +- [ ] OG images generate for all text-based content +- [ ] All pages pass metadata validation +- [ ] Google Search Console shows improved indexing +- [ ] Social media previews display correctly + +## Technical Considerations + +### Performance + +- Cache generated OG images (1 year) +- Cache sitemap (1 hour) +- Lazy-load validation in development only + +### Maintenance + +- Sitemap auto-updates with new content +- OG image templates easy to modify +- Validation runs in CI/CD pipeline + +## Implementation Timeline + +**Week 1:** + +- Day 1-2: Implement dynamic sitemap +- Day 3-5: Create OG image generation API + +**Week 2:** + +- Day 1-2: Add metadata validation utilities +- Day 3-4: Testing and refinement +- Day 5: Documentation and deployment + +Total: **2 weeks** (vs. original 5 weeks) + +## Future Enhancements + +1. **A/B testing** different OG images/titles +2. **Multi-language support** with hreflang tags +3. **Advanced schemas** (FAQ, HowTo) for specific content +4. **Analytics integration** to track metadata performance + +## Appendix: Current Implementation Reference + +### Existing Files + +- `/src/lib/utils/metadata.ts` - Core utilities +- `/src/lib/utils/content.ts` - Content extraction +- `/src/routes/+layout.svelte` - Default metadata +- All page routes - Individual implementations + +### Usage Pattern + +```svelte + + + + {metaTags.title} + + +``` diff --git a/prd/PRD-seo-metadata-system.md b/prd/PRD-seo-metadata-system.md index d40876d..ba11fa2 100644 --- a/prd/PRD-seo-metadata-system.md +++ b/prd/PRD-seo-metadata-system.md @@ -7,6 +7,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system ## Problem Statement ### Current Issues + 1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata 2. **Missing Social Media Support**: No Twitter cards on any pages 3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps @@ -14,6 +15,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system 5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement ### Impact + - Reduced search engine visibility - Poor social media sharing experience - Missed opportunities for rich snippets in search results @@ -40,22 +42,24 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system ### 1. Core Components #### SeoMetadata Component + A centralized Svelte component that handles all metadata needs: ```svelte ``` Features: + - Automatic title formatting (e.g., "Title | @jedmund") - Fallback chains for missing data - Support for all OpenGraph types @@ -68,12 +72,14 @@ Features: #### High Priority Pages **Home Page (/)** + - Title: "@jedmund — Software designer and strategist" - Description: Professional summary - Type: website - Image: Professional headshot or branded image **Work Project Pages (/work/[slug])** + - Title: "[Project Name] by @jedmund" - Description: Project description - Type: article @@ -81,6 +87,7 @@ Features: - Structured data: CreativeWork schema **Photo Pages (/photos/[slug]/[id])** + - Title: "[Photo Title] | Photography by @jedmund" - Description: Photo caption or album context - Type: article @@ -88,6 +95,7 @@ Features: - Structured data: ImageObject schema **Universe Posts (/universe/[slug])** + - Essays (long-form): "[Essay Name] — @jedmund" - Posts (short-form): "@jedmund: [Post snippet]" - Description: Post excerpt (first 160 chars) @@ -98,16 +106,19 @@ Features: #### Medium Priority Pages **Labs Projects (/labs/[slug])** + - Similar to Work projects but with "Lab" designation - Experimental project metadata **About Page (/about)** + - Title: "About | @jedmund" - Description: Professional bio excerpt - Type: profile - Structured data: Person schema **Photo Albums (/photos/[slug])** + - Title: "[Album Name] | Photography by @jedmund" - Description: Album description - Type: website @@ -116,6 +127,7 @@ Features: ### 3. Dynamic OG Image Generation Create an API endpoint (`/api/og-image`) that generates images: + - For projects: Logo on brand color background - For photos: The photo itself with optional watermark - For text posts: Branded template with title @@ -124,16 +136,19 @@ Create an API endpoint (`/api/og-image`) that generates images: ### 4. Technical SEO Improvements **Sitemap Generation** + - Dynamic sitemap.xml generation - Include all public pages - Update frequency and priority hints **Robots.txt** + - Allow all crawlers by default - Block admin routes - Reference sitemap location **Canonical URLs** + - Automatic canonical URL generation - Handle www/non-www consistency - Support pagination parameters @@ -141,15 +156,18 @@ Create an API endpoint (`/api/og-image`) that generates images: ### 5. Utilities & Helpers **formatSeoTitle(title, suffix = "@jedmund")** + - Consistent title formatting - Character limit enforcement (60 chars) **generateDescription(content, limit = 160)** + - Extract description from content - HTML stripping - Smart truncation **getCanonicalUrl(path)** + - Generate absolute URLs - Handle query parameters - Ensure consistency @@ -157,30 +175,35 @@ Create an API endpoint (`/api/og-image`) that generates images: ## Implementation Plan ### Phase 1: Foundation (Week 1) + - [ ] Create SeoMetadata component - [ ] Implement basic meta tag support - [ ] Add title/description utilities - [ ] Update app.html to remove hardcoded values ### Phase 2: Critical Pages (Week 2) + - [ ] Home page metadata - [ ] Work project pages - [ ] Universe post pages - [ ] Photo detail pages ### Phase 3: Secondary Pages (Week 3) + - [ ] About page - [ ] Labs page and projects - [ ] Photo albums and index - [ ] Universe feed ### Phase 4: Advanced Features (Week 4) + - [ ] Dynamic OG image generation - [ ] Structured data implementation - [ ] Sitemap generation - [ ] Technical SEO improvements ### Phase 5: Testing & Refinement (Week 5) + - [ ] Test all pages with social media debuggers - [ ] Validate structured data - [ ] Performance optimization @@ -189,16 +212,19 @@ Create an API endpoint (`/api/og-image`) that generates images: ## Technical Considerations ### Performance + - Metadata generation should not impact page load time - Cache generated OG images - Minimize JavaScript overhead ### Maintenance + - Centralized component reduces update complexity - Clear documentation for adding new pages - Automated testing for metadata presence ### Compatibility + - Support major social platforms (Twitter, Facebook, LinkedIn) - Ensure search engine compatibility - Fallback for missing data @@ -227,14 +253,17 @@ Create an API endpoint (`/api/og-image`) that generates images: ### Current Implementation Status ✅ **Good Implementation** + - /universe/[slug] - /photos/[albumSlug]/[photoId] ⚠️ **Partial Implementation** + - /photos/[slug] - /universe ❌ **No Implementation** + - / (home) - /about - /labs @@ -243,7 +272,8 @@ Create an API endpoint (`/api/og-image`) that generates images: - /work/[slug] ### Resources + - [OpenGraph Protocol](https://ogp.me/) - [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards) - [Schema.org](https://schema.org/) -- [Google SEO Guidelines](https://developers.google.com/search/docs) \ No newline at end of file +- [Google SEO Guidelines](https://developers.google.com/search/docs) diff --git a/src/app.html b/src/app.html index e1f2aae..ff20886 100644 --- a/src/app.html +++ b/src/app.html @@ -7,14 +7,6 @@ name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> - - - - - %sveltekit.head% diff --git a/src/lib/utils/metadata.ts b/src/lib/utils/metadata.ts new file mode 100644 index 0000000..b768491 --- /dev/null +++ b/src/lib/utils/metadata.ts @@ -0,0 +1,364 @@ +interface MetaTagsOptions { + title?: string + description?: string + image?: string + url?: string + type?: 'website' | 'article' | 'profile' + author?: string + publishedTime?: string + modifiedTime?: string + section?: string + tags?: string[] + twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player' + twitterSite?: string + twitterCreator?: string + siteName?: string + locale?: string + canonicalUrl?: string + noindex?: boolean + jsonLd?: Record +} + +interface GeneratedMetaTags { + title: string + description: string + openGraph: Record + twitter: Record + other: Record + jsonLd?: Record +} + +const DEFAULTS = { + siteName: '@jedmund', + siteUrl: 'https://jedmund.com', + defaultTitle: '@jedmund is a software designer', + defaultDescription: 'Justin Edmund is a software designer based in San Francisco.', + defaultImage: 'https://jedmund.com/images/og-image.jpg', + twitterSite: '@jedmund', + locale: 'en_US' +} + +/** + * Format a page title based on content type + */ +export function formatPageTitle( + title?: string, + options?: { + type?: 'default' | 'by' | 'snippet' | 'about' + snippet?: string + } +): string { + if (!title || title === DEFAULTS.siteName) { + return DEFAULTS.defaultTitle + } + + const { type = 'default', snippet } = options || {} + + switch (type) { + case 'by': + return `${title} by @jedmund` + case 'snippet': + return `@jedmund: ${snippet || title}` + case 'about': + return `About @jedmund` + default: + return `${title} — @jedmund` + } +} + +/** + * Ensure a URL is absolute + */ +function ensureAbsoluteUrl(url: string): string { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + return `${DEFAULTS.siteUrl}${url.startsWith('/') ? '' : '/'}${url}` +} + +/** + * Generate comprehensive meta tags for a page + */ +export function generateMetaTags( + options: MetaTagsOptions & { + titleFormat?: { type?: 'default' | 'by' | 'snippet' | 'about'; snippet?: string } + } = {} +): GeneratedMetaTags { + const { + title, + description = DEFAULTS.defaultDescription, + image = DEFAULTS.defaultImage, + url = DEFAULTS.siteUrl, + type = 'website', + author, + publishedTime, + modifiedTime, + section, + tags = [], + twitterCard = image ? 'summary_large_image' : 'summary', + twitterSite = DEFAULTS.twitterSite, + twitterCreator = DEFAULTS.twitterSite, + siteName = DEFAULTS.siteName, + locale = DEFAULTS.locale, + canonicalUrl, + noindex = false, + jsonLd, + titleFormat + } = options + + const formattedTitle = formatPageTitle(title, titleFormat) + const absoluteUrl = ensureAbsoluteUrl(url) + const absoluteImage = ensureAbsoluteUrl(image) + const canonical = canonicalUrl ? ensureAbsoluteUrl(canonicalUrl) : absoluteUrl + + // Basic meta tags + const metaTags: GeneratedMetaTags = { + title: formattedTitle, + description, + openGraph: { + title: title || DEFAULTS.defaultTitle, + description, + type, + url: absoluteUrl, + site_name: siteName, + locale + }, + twitter: { + card: twitterCard, + title: title || DEFAULTS.defaultTitle, + description + }, + other: {} + } + + // Add image tags if provided + if (image) { + metaTags.openGraph.image = absoluteImage + metaTags.twitter.image = absoluteImage + // Add image alt text if we can derive it + if (title) { + metaTags.openGraph.image_alt = `Image for ${title}` + metaTags.twitter.image_alt = `Image for ${title}` + } + } + + // Add Twitter accounts if provided + if (twitterSite) { + metaTags.twitter.site = twitterSite + } + if (twitterCreator) { + metaTags.twitter.creator = twitterCreator + } + + // Add article-specific tags + if (type === 'article') { + if (author) { + metaTags.openGraph.article_author = author + } + if (publishedTime) { + metaTags.openGraph.article_published_time = publishedTime + } + if (modifiedTime) { + metaTags.openGraph.article_modified_time = modifiedTime + } + if (section) { + metaTags.openGraph.article_section = section + } + if (tags.length > 0) { + metaTags.openGraph.article_tag = tags.join(',') + } + } + + // Add canonical URL + if (canonical) { + metaTags.other.canonical = canonical + } + + // Add noindex if needed + if (noindex) { + metaTags.other.robots = 'noindex,nofollow' + } + + // Add JSON-LD if provided + if (jsonLd) { + metaTags.jsonLd = jsonLd + } + + return metaTags +} + +/** + * Generate JSON-LD structured data for a person + */ +export function generatePersonJsonLd(options: { + name: string + url?: string + image?: string + jobTitle?: string + description?: string + sameAs?: string[] +}): Record { + const { name, url = DEFAULTS.siteUrl, image, jobTitle, description, sameAs = [] } = options + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'Person', + name, + url + } + + if (image) { + jsonLd.image = ensureAbsoluteUrl(image) + } + + if (jobTitle) { + jsonLd.jobTitle = jobTitle + } + + if (description) { + jsonLd.description = description + } + + if (sameAs.length > 0) { + jsonLd.sameAs = sameAs + } + + return jsonLd +} + +/** + * Generate JSON-LD structured data for an article + */ +export function generateArticleJsonLd(options: { + title: string + description?: string + url: string + image?: string + datePublished?: string + dateModified?: string + author?: string +}): Record { + const { title, description, url, image, datePublished, dateModified, author } = options + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: title, + url: ensureAbsoluteUrl(url) + } + + if (description) { + jsonLd.description = description + } + + if (image) { + jsonLd.image = ensureAbsoluteUrl(image) + } + + if (datePublished) { + jsonLd.datePublished = datePublished + } + + if (dateModified) { + jsonLd.dateModified = dateModified + } + + if (author) { + jsonLd.author = { + '@type': 'Person', + name: author + } + } + + // Add publisher + jsonLd.publisher = { + '@type': 'Person', + name: 'Justin Edmund', + url: DEFAULTS.siteUrl + } + + return jsonLd +} + +/** + * Generate JSON-LD structured data for an image gallery + */ +export function generateImageGalleryJsonLd(options: { + name: string + description?: string + url: string + images: Array<{ + url: string + caption?: string + }> +}): Record { + const { name, description, url, images } = options + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'ImageGallery', + name, + url: ensureAbsoluteUrl(url) + } + + if (description) { + jsonLd.description = description + } + + if (images.length > 0) { + jsonLd.image = images.map((img) => ({ + '@type': 'ImageObject', + url: ensureAbsoluteUrl(img.url), + ...(img.caption && { caption: img.caption }) + })) + } + + return jsonLd +} + +/** + * Generate JSON-LD structured data for a creative work (project) + */ +export function generateCreativeWorkJsonLd(options: { + name: string + description?: string + url: string + image?: string + creator?: string + dateCreated?: string + keywords?: string[] +}): Record { + const { name, description, url, image, creator, dateCreated, keywords = [] } = options + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'CreativeWork', + name, + url: ensureAbsoluteUrl(url) + } + + if (description) { + jsonLd.description = description + } + + if (image) { + jsonLd.image = ensureAbsoluteUrl(image) + } + + if (creator) { + jsonLd.creator = { + '@type': 'Person', + name: creator + } + } + + if (dateCreated) { + jsonLd.dateCreated = dateCreated + } + + if (keywords.length > 0) { + jsonLd.keywords = keywords.join(', ') + } + + return jsonLd +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7a7d0b4..3d6cf30 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,14 +2,27 @@ import { page } from '$app/stores' import Header from '$components/Header.svelte' import Footer from '$components/Footer.svelte' + import { generatePersonJsonLd } from '$lib/utils/metadata' - $: isAdminRoute = $page.url.pathname.startsWith('/admin') + const isAdminRoute = $derived($page.url.pathname.startsWith('/admin')) + + // Generate person structured data for the site + const personJsonLd = $derived(generatePersonJsonLd({ + name: 'Justin Edmund', + jobTitle: 'Software Designer', + description: 'Software designer based in San Francisco', + url: 'https://jedmund.com', + sameAs: [ + 'https://twitter.com/jedmund', + 'https://github.com/jedmund', + 'https://www.linkedin.com/in/jedmund' + ] + })) - @jedmund is a software designer - - + + {@html ``}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1e3ab66..76b757f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,40 @@ + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + + diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 06c8c6d..db82861 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -4,6 +4,8 @@ import MentionList from '$components/MentionList.svelte' import Page from '$components/Page.svelte' import RecentAlbums from '$components/RecentAlbums.svelte' + import { generateMetaTags } from '$lib/utils/metadata' + import { page } from '$app/stores' import type { PageData } from './$types' @@ -12,8 +14,40 @@ let albums = $derived(data.albums) let games = $derived(data.games) let error = $derived(data.error) + + const pageUrl = $derived($page.url.href) + + // Generate metadata for about page + const metaTags = $derived( + generateMetaTags({ + title: 'About', + description: + 'Software designer and developer living in San Francisco. Building thoughtful digital experiences and currently working on Maitsu, a hobby journaling app.', + url: pageUrl, + type: 'profile', + titleFormat: { type: 'about' } + }) + ) + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + +
{#snippet header()} diff --git a/src/routes/labs/+page.svelte b/src/routes/labs/+page.svelte index c80be63..98cc40c 100644 --- a/src/routes/labs/+page.svelte +++ b/src/routes/labs/+page.svelte @@ -1,13 +1,44 @@ + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + +
{#if error}
diff --git a/src/routes/labs/[slug]/+page.svelte b/src/routes/labs/[slug]/+page.svelte index f3e1e11..f220737 100644 --- a/src/routes/labs/[slug]/+page.svelte +++ b/src/routes/labs/[slug]/+page.svelte @@ -4,6 +4,8 @@ import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte' import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte' import ProjectContent from '$lib/components/ProjectContent.svelte' + import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata' + import { page } from '$app/stores' import type { PageData } from './$types' import type { Project } from '$lib/types/project' @@ -11,8 +13,71 @@ const project = $derived(data.project as Project | null) const error = $derived(data.error as string | undefined) + const pageUrl = $derived($page.url.href) + + // Generate metadata + const metaTags = $derived( + project + ? generateMetaTags({ + title: project.title, + description: project.description || `${project.title} — An experimental project`, + url: pageUrl, + image: project.thumbnailUrl, + type: 'article', + titleFormat: { type: 'by' } + }) + : generateMetaTags({ + title: 'Project Not Found', + description: 'The project you are looking for could not be found.', + url: pageUrl, + noindex: true + }) + ) + + // Generate creative work JSON-LD + const projectJsonLd = $derived( + project + ? generateCreativeWorkJsonLd({ + name: project.title, + description: project.description, + url: pageUrl, + image: project.thumbnailUrl, + creator: 'Justin Edmund', + dateCreated: project.year ? `${project.year}-01-01` : undefined, + keywords: project.tags || [] + }) + : null + ) + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + {#if metaTags.other.canonical} + + {/if} + {#if metaTags.other.robots} + + {/if} + + + {#if projectJsonLd} + {@html ``} + {/if} + + {#if error}
diff --git a/src/routes/photos/+page.svelte b/src/routes/photos/+page.svelte index 6779a51..785c740 100644 --- a/src/routes/photos/+page.svelte +++ b/src/routes/photos/+page.svelte @@ -1,13 +1,44 @@ + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + +
{#if error}
diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte index 28f99cd..84825dd 100644 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte +++ b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte @@ -1,5 +1,7 @@ - {#if photo && album} - {photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title} - + {metaTags.title} + - - - - - + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} - - - {#if exif?.dateTaken} - - {/if} - {:else} - Photo Not Found + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + {#if metaTags.other.canonical} + + {/if} + {#if metaTags.other.robots} + + {/if} + + + {#if photoJsonLd} + {@html ``} {/if} diff --git a/src/routes/photos/[slug]/+page.svelte b/src/routes/photos/[slug]/+page.svelte index d2c5622..fe5575c 100644 --- a/src/routes/photos/[slug]/+page.svelte +++ b/src/routes/photos/[slug]/+page.svelte @@ -1,6 +1,8 @@ - {#if album} - {album.title} - Photos - - {:else} - Album Not Found - Photos + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + + + {#if galleryJsonLd} + {@html ``} {/if} diff --git a/src/routes/universe/+page.svelte b/src/routes/universe/+page.svelte index 1f4c44c..5f71103 100644 --- a/src/routes/universe/+page.svelte +++ b/src/routes/universe/+page.svelte @@ -1,13 +1,40 @@ - Universe - jedmund - + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + +
diff --git a/src/routes/universe/[slug]/+page.svelte b/src/routes/universe/[slug]/+page.svelte index e4f4a98..0804b4a 100644 --- a/src/routes/universe/[slug]/+page.svelte +++ b/src/routes/universe/[slug]/+page.svelte @@ -3,6 +3,8 @@ import BackButton from '$components/BackButton.svelte' import DynamicPostContent from '$components/DynamicPostContent.svelte' import { getContentExcerpt } from '$lib/utils/content' + import { generateMetaTags, generateArticleJsonLd } from '$lib/utils/metadata' + import { page } from '$app/stores' import type { PageData } from './$types' let { data }: { data: PageData } = $props() @@ -13,28 +15,74 @@ const description = $derived( post?.content ? getContentExcerpt(post.content, 160) - : `${post?.postType === 'essay' ? 'Essay' : 'Post'} by jedmund` + : `${post?.postType === 'essay' ? 'Essay' : 'Post'} by @jedmund` + ) + const pageUrl = $derived($page.url.href) + + // Generate metadata + const metaTags = $derived( + post + ? generateMetaTags({ + title: pageTitle, + description, + url: pageUrl, + type: 'article', + image: post.attachments?.[0]?.url, + publishedTime: post.publishedAt, + author: 'Justin Edmund', + section: post.postType === 'essay' ? 'Essays' : 'Posts', + titleFormat: + post.postType === 'essay' ? { type: 'by' } : { type: 'snippet', snippet: description } + }) + : generateMetaTags({ + title: 'Post Not Found', + description: 'The post you are looking for could not be found.', + url: pageUrl, + noindex: true + }) + ) + + // Generate article JSON-LD + const articleJsonLd = $derived( + post + ? generateArticleJsonLd({ + title: pageTitle, + description, + url: pageUrl, + image: post.attachments?.[0]?.url, + datePublished: post.publishedAt, + dateModified: post.updatedAt || post.publishedAt, + author: 'Justin Edmund' + }) + : null ) - {#if post} - {pageTitle} - jedmund - + {metaTags.title} + - - - - - {#if post.attachments && post.attachments.length > 0} - - {/if} + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} - - - - {:else} - Post Not Found - jedmund + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + {#if metaTags.other.canonical} + + {/if} + {#if metaTags.other.robots} + + {/if} + + + {#if articleJsonLd} + {@html ``} {/if} diff --git a/src/routes/work/[slug]/+page.svelte b/src/routes/work/[slug]/+page.svelte index b3f829a..5d21b39 100644 --- a/src/routes/work/[slug]/+page.svelte +++ b/src/routes/work/[slug]/+page.svelte @@ -4,6 +4,8 @@ import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte' import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte' import ProjectContent from '$lib/components/ProjectContent.svelte' + import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata' + import { page } from '$app/stores' import type { PageData } from './$types' import type { Project } from '$lib/types/project' import { spring } from 'svelte/motion' @@ -12,6 +14,42 @@ const project = $derived(data.project as Project | null) const error = $derived(data.error as string | undefined) + const pageUrl = $derived($page.url.href) + + // Generate metadata + const metaTags = $derived( + project + ? generateMetaTags({ + title: project.title, + description: + project.description || `${project.title} — A professional project by Justin Edmund`, + url: pageUrl, + image: project.thumbnailUrl || project.logoUrl, + type: 'article', + titleFormat: { type: 'by' } + }) + : generateMetaTags({ + title: 'Project Not Found', + description: 'The project you are looking for could not be found.', + url: pageUrl, + noindex: true + }) + ) + + // Generate creative work JSON-LD + const projectJsonLd = $derived( + project + ? generateCreativeWorkJsonLd({ + name: project.title, + description: project.description, + url: pageUrl, + image: project.thumbnailUrl || project.logoUrl, + creator: 'Justin Edmund', + dateCreated: project.year ? `${project.year}-01-01` : undefined, + keywords: project.tags || [] + }) + : null + ) let headerContainer = $state(null) @@ -50,6 +88,34 @@ } + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + {#if metaTags.other.canonical} + + {/if} + {#if metaTags.other.robots} + + {/if} + + + {#if projectJsonLd} + {@html ``} + {/if} + + {#if error}