Merge pull request #4 from jedmund/universe/seo
Better titles and OpenGraph tags
This commit is contained in:
commit
996565f56b
20 changed files with 1653 additions and 68 deletions
292
prd/PRD-apple-music-integration.md
Normal file
292
prd/PRD-apple-music-integration.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# 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)
|
||||
204
prd/PRD-seo-metadata-system-v2.md
Normal file
204
prd/PRD-seo-metadata-system-v2.md
Normal file
|
|
@ -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
|
||||
<script>
|
||||
import { generateMetaTags, generateArticleJsonLd } from '$lib/utils/metadata'
|
||||
|
||||
$: metaTags = generateMetaTags({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: $page.url.href,
|
||||
type: 'article',
|
||||
image: contentImage
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<!-- ... rest of meta tags ... -->
|
||||
</svelte:head>
|
||||
```
|
||||
279
prd/PRD-seo-metadata-system.md
Normal file
279
prd/PRD-seo-metadata-system.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# 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
|
||||
<SeoMetadata
|
||||
title="Project Title"
|
||||
description="Project description"
|
||||
type="article"
|
||||
image="/path/to/image.jpg"
|
||||
author="@jedmund"
|
||||
publishedTime={date}
|
||||
modifiedTime={date}
|
||||
tags={['tag1', 'tag2']}
|
||||
/>
|
||||
```
|
||||
|
||||
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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,6 @@
|
|||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||
/>
|
||||
<meta property="og:title" content="@jedmund" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://jedmund.com" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Justin Edmund is a software designer living in San Francisco."
|
||||
/>
|
||||
<meta property="og:image" content="https://jedmund.com/images/og-image.jpg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
364
src/lib/utils/metadata.ts
Normal file
364
src/lib/utils/metadata.ts
Normal file
|
|
@ -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<string, any>
|
||||
}
|
||||
|
||||
interface GeneratedMetaTags {
|
||||
title: string
|
||||
description: string
|
||||
openGraph: Record<string, string>
|
||||
twitter: Record<string, string>
|
||||
other: Record<string, string>
|
||||
jsonLd?: Record<string, any>
|
||||
}
|
||||
|
||||
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<string, any> {
|
||||
const { name, url = DEFAULTS.siteUrl, image, jobTitle, description, sameAs = [] } = options
|
||||
|
||||
const jsonLd: Record<string, any> = {
|
||||
'@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<string, any> {
|
||||
const { title, description, url, image, datePublished, dateModified, author } = options
|
||||
|
||||
const jsonLd: Record<string, any> = {
|
||||
'@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<string, any> {
|
||||
const { name, description, url, images } = options
|
||||
|
||||
const jsonLd: Record<string, any> = {
|
||||
'@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<string, any> {
|
||||
const { name, description, url, image, creator, dateCreated, keywords = [] } = options
|
||||
|
||||
const jsonLd: Record<string, any> = {
|
||||
'@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
|
||||
}
|
||||
|
|
@ -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'
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>@jedmund is a software designer</title>
|
||||
<meta name="description" content="Justin Edmund is a software designer based in San Francisco." />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<!-- Site-wide JSON-LD -->
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(personJsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<div class="layout-wrapper" class:admin-route={isAdminRoute}>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,40 @@
|
|||
<script lang="ts">
|
||||
import ProjectList from '$components/ProjectList.svelte'
|
||||
import { generateMetaTags } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data } = $props<{ data: PageData }>()
|
||||
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata for homepage
|
||||
const metaTags = $derived(
|
||||
generateMetaTags({
|
||||
title: undefined, // Use default title for homepage
|
||||
description:
|
||||
'Software designer crafting thoughtful digital experiences. Currently building products at scale.',
|
||||
url: pageUrl
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
</svelte:head>
|
||||
|
||||
<ProjectList projects={data?.projects || []} />
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
</svelte:head>
|
||||
|
||||
<section class="about-container">
|
||||
<Page>
|
||||
{#snippet header()}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,13 +1,44 @@
|
|||
<script lang="ts">
|
||||
import LabCard from '$components/LabCard.svelte'
|
||||
import { generateMetaTags } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
const projects = $derived(data.projects || [])
|
||||
const error = $derived(data.error)
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata for labs page
|
||||
const metaTags = $derived(
|
||||
generateMetaTags({
|
||||
title: 'Labs',
|
||||
description:
|
||||
'Experimental projects and prototypes. A space for exploring new ideas, technologies, and creative coding.',
|
||||
url: pageUrl
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="labs-container">
|
||||
{#if error}
|
||||
<div class="error-container">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Other meta tags -->
|
||||
{#if metaTags.other.canonical}
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
{/if}
|
||||
{#if metaTags.other.robots}
|
||||
<meta name="robots" content={metaTags.other.robots} />
|
||||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if projectJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if error}
|
||||
<div class="error-wrapper">
|
||||
<Page>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,44 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||
import { generateMetaTags } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
const photoItems = $derived(data.photoItems || [])
|
||||
const error = $derived(data.error)
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata for photos page
|
||||
const metaTags = $derived(
|
||||
generateMetaTags({
|
||||
title: 'Photography',
|
||||
description:
|
||||
'A collection of photography from travels, daily life, and creative projects. Captured moments from around the world.',
|
||||
url: pageUrl
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="photos-container">
|
||||
{#if error}
|
||||
<div class="error-container">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import BackButton from '$components/BackButton.svelte'
|
||||
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
|
@ -44,39 +46,74 @@
|
|||
}
|
||||
|
||||
const exif = $derived(photo ? formatExif(photo.exifData) : null)
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata
|
||||
const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`)
|
||||
const photoDescription = $derived(
|
||||
photo?.description || photo?.caption || `Photo from ${album?.title || 'album'}`
|
||||
)
|
||||
const metaTags = $derived(
|
||||
photo && album
|
||||
? generateMetaTags({
|
||||
title: photoTitle,
|
||||
description: photoDescription,
|
||||
url: pageUrl,
|
||||
type: 'article',
|
||||
image: photo.url,
|
||||
publishedTime: exif?.dateTaken,
|
||||
author: 'Justin Edmund',
|
||||
titleFormat: { type: 'snippet', snippet: photoDescription }
|
||||
})
|
||||
: generateMetaTags({
|
||||
title: 'Photo Not Found',
|
||||
description: 'The photo you are looking for could not be found.',
|
||||
url: pageUrl,
|
||||
noindex: true
|
||||
})
|
||||
)
|
||||
|
||||
// Generate creative work JSON-LD
|
||||
const photoJsonLd = $derived(
|
||||
photo && album
|
||||
? generateCreativeWorkJsonLd({
|
||||
name: photoTitle,
|
||||
description: photoDescription,
|
||||
url: pageUrl,
|
||||
image: photo.url,
|
||||
creator: 'Justin Edmund',
|
||||
dateCreated: exif?.dateTaken,
|
||||
keywords: ['photography', album.title, ...(exif?.location ? [exif.location] : [])]
|
||||
})
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if photo && album}
|
||||
<title
|
||||
>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title
|
||||
>
|
||||
<meta
|
||||
name="description"
|
||||
content={photo.description || photo.caption || `Photo from ${album.title}`}
|
||||
/>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta
|
||||
property="og:title"
|
||||
content="{photo.title ||
|
||||
photo.caption ||
|
||||
`Photo ${navigation?.currentIndex}`} - {album.title}"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={photo.description || photo.caption || `Photo from ${album.title}`}
|
||||
/>
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:image" content={photo.url} />
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Article meta -->
|
||||
<meta property="article:author" content="jedmund" />
|
||||
{#if exif?.dateTaken}
|
||||
<meta property="article:published_time" content={exif.dateTaken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<title>Photo Not Found</title>
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Other meta tags -->
|
||||
{#if metaTags.other.canonical}
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
{/if}
|
||||
{#if metaTags.other.robots}
|
||||
<meta name="robots" content={metaTags.other.robots} />
|
||||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if photoJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||
import BackButton from '$components/BackButton.svelte'
|
||||
import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
|
@ -28,14 +30,66 @@
|
|||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata
|
||||
const metaTags = $derived(
|
||||
album
|
||||
? generateMetaTags({
|
||||
title: album.title,
|
||||
description:
|
||||
album.description ||
|
||||
`Photo album: ${album.title}${album.location ? ` taken in ${album.location}` : ''}`,
|
||||
url: pageUrl,
|
||||
image: album.photos?.[0]?.url,
|
||||
titleFormat: { type: 'by' }
|
||||
})
|
||||
: generateMetaTags({
|
||||
title: 'Album Not Found',
|
||||
description: 'The album you are looking for could not be found.',
|
||||
url: pageUrl,
|
||||
noindex: true
|
||||
})
|
||||
)
|
||||
|
||||
// Generate image gallery JSON-LD
|
||||
const galleryJsonLd = $derived(
|
||||
album
|
||||
? generateImageGalleryJsonLd({
|
||||
name: album.title,
|
||||
description: album.description,
|
||||
url: pageUrl,
|
||||
images:
|
||||
album.photos?.map((photo: any) => ({
|
||||
url: photo.url,
|
||||
caption: photo.caption
|
||||
})) || []
|
||||
})
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if album}
|
||||
<title>{album.title} - Photos</title>
|
||||
<meta name="description" content={album.description || `Photo album: ${album.title}`} />
|
||||
{:else}
|
||||
<title>Album Not Found - Photos</title>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if galleryJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(galleryJsonLd)}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,40 @@
|
|||
<script lang="ts">
|
||||
import UniverseFeed from '$components/UniverseFeed.svelte'
|
||||
import { generateMetaTags } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata for universe page
|
||||
const metaTags = $derived(
|
||||
generateMetaTags({
|
||||
title: 'Universe',
|
||||
description:
|
||||
'A mixed feed of posts, thoughts, and photo albums. Essays, experiments, and everything in between.',
|
||||
url: pageUrl
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Universe - jedmund</title>
|
||||
<meta name="description" content="A mixed feed of posts, thoughts, and photo albums." />
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="universe-container">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if post}
|
||||
<title>{pageTitle} - jedmund</title>
|
||||
<meta name="description" content={description} />
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="article" />
|
||||
{#if post.attachments && post.attachments.length > 0}
|
||||
<meta property="og:image" content={post.attachments[0].url} />
|
||||
{/if}
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Article meta -->
|
||||
<meta property="article:published_time" content={post.publishedAt} />
|
||||
<meta property="article:author" content="jedmund" />
|
||||
{:else}
|
||||
<title>Post Not Found - jedmund</title>
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Other meta tags -->
|
||||
{#if metaTags.other.canonical}
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
{/if}
|
||||
{#if metaTags.other.robots}
|
||||
<meta name="robots" content={metaTags.other.robots} />
|
||||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if articleJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(articleJsonLd)}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement | null>(null)
|
||||
|
||||
|
|
@ -50,6 +88,34 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Other meta tags -->
|
||||
{#if metaTags.other.canonical}
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
{/if}
|
||||
{#if metaTags.other.robots}
|
||||
<meta name="robots" content={metaTags.other.robots} />
|
||||
{/if}
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if projectJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if error}
|
||||
<div class="error-container">
|
||||
<Page>
|
||||
|
|
|
|||
0
static/favicon.ico
Normal file
0
static/favicon.ico
Normal file
20
static/robots.txt
Normal file
20
static/robots.txt
Normal file
|
|
@ -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: /
|
||||
Loading…
Reference in a new issue