Better OpenGraph and HTML metadata
This commit is contained in:
parent
af7122a7f6
commit
46d655e8f0
16 changed files with 1119 additions and 82 deletions
|
|
@ -14,18 +14,21 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
### Primary Goals
|
### Primary Goals
|
||||||
|
|
||||||
- **Replace iTunes Search API** with Apple Music API for better data quality
|
- **Replace iTunes Search API** with Apple Music API for better data quality
|
||||||
- **Add 30-second preview playback** for discovered music
|
- **Add 30-second preview playback** for discovered music
|
||||||
- **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use
|
- **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use
|
||||||
- **Improve artwork quality** from 600x600 to 3000x3000 resolution
|
- **Improve artwork quality** from 600x600 to 3000x3000 resolution
|
||||||
|
|
||||||
### Secondary Goals
|
### Secondary Goals
|
||||||
|
|
||||||
- **Implement proper caching** using Redis (matching other API patterns)
|
- **Implement proper caching** using Redis (matching other API patterns)
|
||||||
- **Create reusable audio components** for future music features
|
- **Create reusable audio components** for future music features
|
||||||
- **Maintain current UI** while preparing data structure for future enhancements
|
- **Maintain current UI** while preparing data structure for future enhancements
|
||||||
- **Prepare foundation** for future user library integration
|
- **Prepare foundation** for future user library integration
|
||||||
|
|
||||||
### Technical Goals
|
### Technical Goals
|
||||||
|
|
||||||
- Secure JWT token generation and management
|
- Secure JWT token generation and management
|
||||||
- Efficient API response caching
|
- Efficient API response caching
|
||||||
- Clean component architecture for audio playback
|
- 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
|
## Technical Architecture
|
||||||
|
|
||||||
### API Flow
|
### API Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend
|
Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
### Component Hierarchy
|
### Component Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
HomePage
|
HomePage
|
||||||
└── AlbumGrid
|
└── AlbumGrid
|
||||||
|
|
@ -206,17 +211,18 @@ HomePage
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Structure
|
### Data Structure
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface EnhancedAlbum extends Album {
|
interface EnhancedAlbum extends Album {
|
||||||
appleMusicId?: string;
|
appleMusicId?: string
|
||||||
highResArtwork?: string; // Used immediately
|
highResArtwork?: string // Used immediately
|
||||||
previewUrl?: string; // Used immediately
|
previewUrl?: string // Used immediately
|
||||||
|
|
||||||
// Stored for future use (not displayed yet):
|
// Stored for future use (not displayed yet):
|
||||||
genres?: string[];
|
genres?: string[]
|
||||||
releaseDate?: string;
|
releaseDate?: string
|
||||||
trackCount?: number;
|
trackCount?: number
|
||||||
tracks?: AppleMusicTrack[];
|
tracks?: AppleMusicTrack[]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -231,14 +237,17 @@ interface EnhancedAlbum extends Album {
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
### New Dependencies
|
### New Dependencies
|
||||||
|
|
||||||
- `jsonwebtoken`: JWT generation
|
- `jsonwebtoken`: JWT generation
|
||||||
- `@types/jsonwebtoken`: TypeScript types
|
- `@types/jsonwebtoken`: TypeScript types
|
||||||
|
|
||||||
### Existing Dependencies to Leverage
|
### Existing Dependencies to Leverage
|
||||||
|
|
||||||
- `redis`: Caching layer
|
- `redis`: Caching layer
|
||||||
- `$lib/server/redis-client`: Existing Redis connection
|
- `$lib/server/redis-client`: Existing Redis connection
|
||||||
|
|
||||||
### Dependencies to Remove
|
### Dependencies to Remove
|
||||||
|
|
||||||
- `node-itunes-search`: Replaced by Apple Music API
|
- `node-itunes-search`: Replaced by Apple Music API
|
||||||
|
|
||||||
## Rollback Plan
|
## Rollback Plan
|
||||||
|
|
@ -280,4 +289,4 @@ interface EnhancedAlbum extends Album {
|
||||||
- [Apple Music API Documentation](https://developer.apple.com/documentation/applemusicapi)
|
- [Apple Music API Documentation](https://developer.apple.com/documentation/applemusicapi)
|
||||||
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
|
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
|
||||||
- [Original Deno Implementation](https://gist.github.com/NetOpWibby/fca4e7942617095677831d6c74187f84)
|
- [Original Deno Implementation](https://gist.github.com/NetOpWibby/fca4e7942617095677831d6c74187f84)
|
||||||
- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features)
|
- [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>
|
||||||
|
```
|
||||||
|
|
@ -7,6 +7,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
|
||||||
## Problem Statement
|
## Problem Statement
|
||||||
|
|
||||||
### Current Issues
|
### Current Issues
|
||||||
|
|
||||||
1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata
|
1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata
|
||||||
2. **Missing Social Media Support**: No Twitter cards on any pages
|
2. **Missing Social Media Support**: No Twitter cards on any pages
|
||||||
3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps
|
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
|
5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement
|
||||||
|
|
||||||
### Impact
|
### Impact
|
||||||
|
|
||||||
- Reduced search engine visibility
|
- Reduced search engine visibility
|
||||||
- Poor social media sharing experience
|
- Poor social media sharing experience
|
||||||
- Missed opportunities for rich snippets in search results
|
- 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
|
### 1. Core Components
|
||||||
|
|
||||||
#### SeoMetadata Component
|
#### SeoMetadata Component
|
||||||
|
|
||||||
A centralized Svelte component that handles all metadata needs:
|
A centralized Svelte component that handles all metadata needs:
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<SeoMetadata
|
<SeoMetadata
|
||||||
title="Project Title"
|
title="Project Title"
|
||||||
description="Project description"
|
description="Project description"
|
||||||
type="article"
|
type="article"
|
||||||
image="/path/to/image.jpg"
|
image="/path/to/image.jpg"
|
||||||
author="@jedmund"
|
author="@jedmund"
|
||||||
publishedTime={date}
|
publishedTime={date}
|
||||||
modifiedTime={date}
|
modifiedTime={date}
|
||||||
tags={['tag1', 'tag2']}
|
tags={['tag1', 'tag2']}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
- Automatic title formatting (e.g., "Title | @jedmund")
|
- Automatic title formatting (e.g., "Title | @jedmund")
|
||||||
- Fallback chains for missing data
|
- Fallback chains for missing data
|
||||||
- Support for all OpenGraph types
|
- Support for all OpenGraph types
|
||||||
|
|
@ -68,12 +72,14 @@ Features:
|
||||||
#### High Priority Pages
|
#### High Priority Pages
|
||||||
|
|
||||||
**Home Page (/)**
|
**Home Page (/)**
|
||||||
|
|
||||||
- Title: "@jedmund — Software designer and strategist"
|
- Title: "@jedmund — Software designer and strategist"
|
||||||
- Description: Professional summary
|
- Description: Professional summary
|
||||||
- Type: website
|
- Type: website
|
||||||
- Image: Professional headshot or branded image
|
- Image: Professional headshot or branded image
|
||||||
|
|
||||||
**Work Project Pages (/work/[slug])**
|
**Work Project Pages (/work/[slug])**
|
||||||
|
|
||||||
- Title: "[Project Name] by @jedmund"
|
- Title: "[Project Name] by @jedmund"
|
||||||
- Description: Project description
|
- Description: Project description
|
||||||
- Type: article
|
- Type: article
|
||||||
|
|
@ -81,6 +87,7 @@ Features:
|
||||||
- Structured data: CreativeWork schema
|
- Structured data: CreativeWork schema
|
||||||
|
|
||||||
**Photo Pages (/photos/[slug]/[id])**
|
**Photo Pages (/photos/[slug]/[id])**
|
||||||
|
|
||||||
- Title: "[Photo Title] | Photography by @jedmund"
|
- Title: "[Photo Title] | Photography by @jedmund"
|
||||||
- Description: Photo caption or album context
|
- Description: Photo caption or album context
|
||||||
- Type: article
|
- Type: article
|
||||||
|
|
@ -88,6 +95,7 @@ Features:
|
||||||
- Structured data: ImageObject schema
|
- Structured data: ImageObject schema
|
||||||
|
|
||||||
**Universe Posts (/universe/[slug])**
|
**Universe Posts (/universe/[slug])**
|
||||||
|
|
||||||
- Essays (long-form): "[Essay Name] — @jedmund"
|
- Essays (long-form): "[Essay Name] — @jedmund"
|
||||||
- Posts (short-form): "@jedmund: [Post snippet]"
|
- Posts (short-form): "@jedmund: [Post snippet]"
|
||||||
- Description: Post excerpt (first 160 chars)
|
- Description: Post excerpt (first 160 chars)
|
||||||
|
|
@ -98,16 +106,19 @@ Features:
|
||||||
#### Medium Priority Pages
|
#### Medium Priority Pages
|
||||||
|
|
||||||
**Labs Projects (/labs/[slug])**
|
**Labs Projects (/labs/[slug])**
|
||||||
|
|
||||||
- Similar to Work projects but with "Lab" designation
|
- Similar to Work projects but with "Lab" designation
|
||||||
- Experimental project metadata
|
- Experimental project metadata
|
||||||
|
|
||||||
**About Page (/about)**
|
**About Page (/about)**
|
||||||
|
|
||||||
- Title: "About | @jedmund"
|
- Title: "About | @jedmund"
|
||||||
- Description: Professional bio excerpt
|
- Description: Professional bio excerpt
|
||||||
- Type: profile
|
- Type: profile
|
||||||
- Structured data: Person schema
|
- Structured data: Person schema
|
||||||
|
|
||||||
**Photo Albums (/photos/[slug])**
|
**Photo Albums (/photos/[slug])**
|
||||||
|
|
||||||
- Title: "[Album Name] | Photography by @jedmund"
|
- Title: "[Album Name] | Photography by @jedmund"
|
||||||
- Description: Album description
|
- Description: Album description
|
||||||
- Type: website
|
- Type: website
|
||||||
|
|
@ -116,6 +127,7 @@ Features:
|
||||||
### 3. Dynamic OG Image Generation
|
### 3. Dynamic OG Image Generation
|
||||||
|
|
||||||
Create an API endpoint (`/api/og-image`) that generates images:
|
Create an API endpoint (`/api/og-image`) that generates images:
|
||||||
|
|
||||||
- For projects: Logo on brand color background
|
- For projects: Logo on brand color background
|
||||||
- For photos: The photo itself with optional watermark
|
- For photos: The photo itself with optional watermark
|
||||||
- For text posts: Branded template with title
|
- 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
|
### 4. Technical SEO Improvements
|
||||||
|
|
||||||
**Sitemap Generation**
|
**Sitemap Generation**
|
||||||
|
|
||||||
- Dynamic sitemap.xml generation
|
- Dynamic sitemap.xml generation
|
||||||
- Include all public pages
|
- Include all public pages
|
||||||
- Update frequency and priority hints
|
- Update frequency and priority hints
|
||||||
|
|
||||||
**Robots.txt**
|
**Robots.txt**
|
||||||
|
|
||||||
- Allow all crawlers by default
|
- Allow all crawlers by default
|
||||||
- Block admin routes
|
- Block admin routes
|
||||||
- Reference sitemap location
|
- Reference sitemap location
|
||||||
|
|
||||||
**Canonical URLs**
|
**Canonical URLs**
|
||||||
|
|
||||||
- Automatic canonical URL generation
|
- Automatic canonical URL generation
|
||||||
- Handle www/non-www consistency
|
- Handle www/non-www consistency
|
||||||
- Support pagination parameters
|
- Support pagination parameters
|
||||||
|
|
@ -141,15 +156,18 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
||||||
### 5. Utilities & Helpers
|
### 5. Utilities & Helpers
|
||||||
|
|
||||||
**formatSeoTitle(title, suffix = "@jedmund")**
|
**formatSeoTitle(title, suffix = "@jedmund")**
|
||||||
|
|
||||||
- Consistent title formatting
|
- Consistent title formatting
|
||||||
- Character limit enforcement (60 chars)
|
- Character limit enforcement (60 chars)
|
||||||
|
|
||||||
**generateDescription(content, limit = 160)**
|
**generateDescription(content, limit = 160)**
|
||||||
|
|
||||||
- Extract description from content
|
- Extract description from content
|
||||||
- HTML stripping
|
- HTML stripping
|
||||||
- Smart truncation
|
- Smart truncation
|
||||||
|
|
||||||
**getCanonicalUrl(path)**
|
**getCanonicalUrl(path)**
|
||||||
|
|
||||||
- Generate absolute URLs
|
- Generate absolute URLs
|
||||||
- Handle query parameters
|
- Handle query parameters
|
||||||
- Ensure consistency
|
- Ensure consistency
|
||||||
|
|
@ -157,30 +175,35 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|
||||||
### Phase 1: Foundation (Week 1)
|
### Phase 1: Foundation (Week 1)
|
||||||
|
|
||||||
- [ ] Create SeoMetadata component
|
- [ ] Create SeoMetadata component
|
||||||
- [ ] Implement basic meta tag support
|
- [ ] Implement basic meta tag support
|
||||||
- [ ] Add title/description utilities
|
- [ ] Add title/description utilities
|
||||||
- [ ] Update app.html to remove hardcoded values
|
- [ ] Update app.html to remove hardcoded values
|
||||||
|
|
||||||
### Phase 2: Critical Pages (Week 2)
|
### Phase 2: Critical Pages (Week 2)
|
||||||
|
|
||||||
- [ ] Home page metadata
|
- [ ] Home page metadata
|
||||||
- [ ] Work project pages
|
- [ ] Work project pages
|
||||||
- [ ] Universe post pages
|
- [ ] Universe post pages
|
||||||
- [ ] Photo detail pages
|
- [ ] Photo detail pages
|
||||||
|
|
||||||
### Phase 3: Secondary Pages (Week 3)
|
### Phase 3: Secondary Pages (Week 3)
|
||||||
|
|
||||||
- [ ] About page
|
- [ ] About page
|
||||||
- [ ] Labs page and projects
|
- [ ] Labs page and projects
|
||||||
- [ ] Photo albums and index
|
- [ ] Photo albums and index
|
||||||
- [ ] Universe feed
|
- [ ] Universe feed
|
||||||
|
|
||||||
### Phase 4: Advanced Features (Week 4)
|
### Phase 4: Advanced Features (Week 4)
|
||||||
|
|
||||||
- [ ] Dynamic OG image generation
|
- [ ] Dynamic OG image generation
|
||||||
- [ ] Structured data implementation
|
- [ ] Structured data implementation
|
||||||
- [ ] Sitemap generation
|
- [ ] Sitemap generation
|
||||||
- [ ] Technical SEO improvements
|
- [ ] Technical SEO improvements
|
||||||
|
|
||||||
### Phase 5: Testing & Refinement (Week 5)
|
### Phase 5: Testing & Refinement (Week 5)
|
||||||
|
|
||||||
- [ ] Test all pages with social media debuggers
|
- [ ] Test all pages with social media debuggers
|
||||||
- [ ] Validate structured data
|
- [ ] Validate structured data
|
||||||
- [ ] Performance optimization
|
- [ ] Performance optimization
|
||||||
|
|
@ -189,16 +212,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
||||||
## Technical Considerations
|
## Technical Considerations
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Metadata generation should not impact page load time
|
- Metadata generation should not impact page load time
|
||||||
- Cache generated OG images
|
- Cache generated OG images
|
||||||
- Minimize JavaScript overhead
|
- Minimize JavaScript overhead
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
|
|
||||||
- Centralized component reduces update complexity
|
- Centralized component reduces update complexity
|
||||||
- Clear documentation for adding new pages
|
- Clear documentation for adding new pages
|
||||||
- Automated testing for metadata presence
|
- Automated testing for metadata presence
|
||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
- Support major social platforms (Twitter, Facebook, LinkedIn)
|
- Support major social platforms (Twitter, Facebook, LinkedIn)
|
||||||
- Ensure search engine compatibility
|
- Ensure search engine compatibility
|
||||||
- Fallback for missing data
|
- Fallback for missing data
|
||||||
|
|
@ -227,14 +253,17 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
||||||
### Current Implementation Status
|
### Current Implementation Status
|
||||||
|
|
||||||
✅ **Good Implementation**
|
✅ **Good Implementation**
|
||||||
|
|
||||||
- /universe/[slug]
|
- /universe/[slug]
|
||||||
- /photos/[albumSlug]/[photoId]
|
- /photos/[albumSlug]/[photoId]
|
||||||
|
|
||||||
⚠️ **Partial Implementation**
|
⚠️ **Partial Implementation**
|
||||||
|
|
||||||
- /photos/[slug]
|
- /photos/[slug]
|
||||||
- /universe
|
- /universe
|
||||||
|
|
||||||
❌ **No Implementation**
|
❌ **No Implementation**
|
||||||
|
|
||||||
- / (home)
|
- / (home)
|
||||||
- /about
|
- /about
|
||||||
- /labs
|
- /labs
|
||||||
|
|
@ -243,7 +272,8 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
||||||
- /work/[slug]
|
- /work/[slug]
|
||||||
|
|
||||||
### Resources
|
### Resources
|
||||||
|
|
||||||
- [OpenGraph Protocol](https://ogp.me/)
|
- [OpenGraph Protocol](https://ogp.me/)
|
||||||
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards)
|
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards)
|
||||||
- [Schema.org](https://schema.org/)
|
- [Schema.org](https://schema.org/)
|
||||||
- [Google SEO Guidelines](https://developers.google.com/search/docs)
|
- [Google SEO Guidelines](https://developers.google.com/search/docs)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<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 { page } from '$app/stores'
|
||||||
import Header from '$components/Header.svelte'
|
import Header from '$components/Header.svelte'
|
||||||
import Footer from '$components/Footer.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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>@jedmund is a software designer</title>
|
<!-- Site-wide JSON-LD -->
|
||||||
<meta name="description" content="Justin Edmund is a software designer based in San Francisco." />
|
{@html `<script type="application/ld+json">${JSON.stringify(personJsonLd)}</script>`}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="layout-wrapper" class:admin-route={isAdminRoute}>
|
<div class="layout-wrapper" class:admin-route={isAdminRoute}>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,40 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ProjectList from '$components/ProjectList.svelte'
|
import ProjectList from '$components/ProjectList.svelte'
|
||||||
|
import { generateMetaTags } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data } = $props<{ data: PageData }>()
|
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>
|
</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 || []} />
|
<ProjectList projects={data?.projects || []} />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import MentionList from '$components/MentionList.svelte'
|
import MentionList from '$components/MentionList.svelte'
|
||||||
import Page from '$components/Page.svelte'
|
import Page from '$components/Page.svelte'
|
||||||
import RecentAlbums from '$components/RecentAlbums.svelte'
|
import RecentAlbums from '$components/RecentAlbums.svelte'
|
||||||
|
import { generateMetaTags } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
|
@ -12,8 +14,40 @@
|
||||||
let albums = $derived(data.albums)
|
let albums = $derived(data.albums)
|
||||||
let games = $derived(data.games)
|
let games = $derived(data.games)
|
||||||
let error = $derived(data.error)
|
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>
|
</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">
|
<section class="about-container">
|
||||||
<Page>
|
<Page>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,44 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LabCard from '$components/LabCard.svelte'
|
import LabCard from '$components/LabCard.svelte'
|
||||||
|
import { generateMetaTags } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
const { data }: { data: PageData } = $props()
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
const projects = $derived(data.projects || [])
|
const projects = $derived(data.projects || [])
|
||||||
const error = $derived(data.error)
|
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>
|
</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">
|
<div class="labs-container">
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
||||||
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
||||||
import ProjectContent from '$lib/components/ProjectContent.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 { PageData } from './$types'
|
||||||
import type { Project } from '$lib/types/project'
|
import type { Project } from '$lib/types/project'
|
||||||
|
|
||||||
|
|
@ -11,8 +13,71 @@
|
||||||
|
|
||||||
const project = $derived(data.project as Project | null)
|
const project = $derived(data.project as Project | null)
|
||||||
const error = $derived(data.error as string | undefined)
|
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>
|
</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}
|
{#if error}
|
||||||
<div class="error-wrapper">
|
<div class="error-wrapper">
|
||||||
<Page>
|
<Page>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,44 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||||
|
import { generateMetaTags } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
const { data }: { data: PageData } = $props()
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
const photoItems = $derived(data.photoItems || [])
|
const photoItems = $derived(data.photoItems || [])
|
||||||
const error = $derived(data.error)
|
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>
|
</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">
|
<div class="photos-container">
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BackButton from '$components/BackButton.svelte'
|
import BackButton from '$components/BackButton.svelte'
|
||||||
|
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -44,39 +46,74 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const exif = $derived(photo ? formatExif(photo.exifData) : null)
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if photo && album}
|
<title>{metaTags.title}</title>
|
||||||
<title
|
<meta name="description" content={metaTags.description} />
|
||||||
>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title
|
|
||||||
>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content={photo.description || photo.caption || `Photo from ${album.title}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Open Graph meta tags -->
|
<!-- OpenGraph -->
|
||||||
<meta
|
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||||
property="og:title"
|
<meta property="og:{property}" {content} />
|
||||||
content="{photo.title ||
|
{/each}
|
||||||
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} />
|
|
||||||
|
|
||||||
<!-- Article meta -->
|
<!-- Twitter Card -->
|
||||||
<meta property="article:author" content="jedmund" />
|
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||||
{#if exif?.dateTaken}
|
<meta name="twitter:{property}" {content} />
|
||||||
<meta property="article:published_time" content={exif.dateTaken} />
|
{/each}
|
||||||
{/if}
|
|
||||||
{:else}
|
<!-- Other meta tags -->
|
||||||
<title>Photo Not Found</title>
|
{#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}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||||
import BackButton from '$components/BackButton.svelte'
|
import BackButton from '$components/BackButton.svelte'
|
||||||
|
import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -28,14 +30,66 @@
|
||||||
year: 'numeric'
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if album}
|
<title>{metaTags.title}</title>
|
||||||
<title>{album.title} - Photos</title>
|
<meta name="description" content={metaTags.description} />
|
||||||
<meta name="description" content={album.description || `Photo album: ${album.title}`} />
|
|
||||||
{:else}
|
<!-- OpenGraph -->
|
||||||
<title>Album Not Found - Photos</title>
|
{#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}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,40 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import UniverseFeed from '$components/UniverseFeed.svelte'
|
import UniverseFeed from '$components/UniverseFeed.svelte'
|
||||||
|
import { generateMetaTags } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Universe - jedmund</title>
|
<title>{metaTags.title}</title>
|
||||||
<meta name="description" content="A mixed feed of posts, thoughts, and photo albums." />
|
<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>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="universe-container">
|
<div class="universe-container">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import BackButton from '$components/BackButton.svelte'
|
import BackButton from '$components/BackButton.svelte'
|
||||||
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
||||||
import { getContentExcerpt } from '$lib/utils/content'
|
import { getContentExcerpt } from '$lib/utils/content'
|
||||||
|
import { generateMetaTags, generateArticleJsonLd } from '$lib/utils/metadata'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -13,28 +15,74 @@
|
||||||
const description = $derived(
|
const description = $derived(
|
||||||
post?.content
|
post?.content
|
||||||
? getContentExcerpt(post.content, 160)
|
? 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if post}
|
<title>{metaTags.title}</title>
|
||||||
<title>{pageTitle} - jedmund</title>
|
<meta name="description" content={metaTags.description} />
|
||||||
<meta name="description" content={description} />
|
|
||||||
|
|
||||||
<!-- Open Graph meta tags -->
|
<!-- OpenGraph -->
|
||||||
<meta property="og:title" content={pageTitle} />
|
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:{property}" {content} />
|
||||||
<meta property="og:type" content="article" />
|
{/each}
|
||||||
{#if post.attachments && post.attachments.length > 0}
|
|
||||||
<meta property="og:image" content={post.attachments[0].url} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Article meta -->
|
<!-- Twitter Card -->
|
||||||
<meta property="article:published_time" content={post.publishedAt} />
|
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||||
<meta property="article:author" content="jedmund" />
|
<meta name="twitter:{property}" {content} />
|
||||||
{:else}
|
{/each}
|
||||||
<title>Post Not Found - jedmund</title>
|
|
||||||
|
<!-- 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}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
||||||
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
||||||
import ProjectContent from '$lib/components/ProjectContent.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 { PageData } from './$types'
|
||||||
import type { Project } from '$lib/types/project'
|
import type { Project } from '$lib/types/project'
|
||||||
import { spring } from 'svelte/motion'
|
import { spring } from 'svelte/motion'
|
||||||
|
|
@ -12,6 +14,42 @@
|
||||||
|
|
||||||
const project = $derived(data.project as Project | null)
|
const project = $derived(data.project as Project | null)
|
||||||
const error = $derived(data.error as string | undefined)
|
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)
|
let headerContainer = $state<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
|
@ -50,6 +88,34 @@
|
||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if error}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<Page>
|
<Page>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue