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
|
||||
|
||||
### Primary Goals
|
||||
|
||||
- **Replace iTunes Search API** with Apple Music API for better data quality
|
||||
- **Add 30-second preview playback** for discovered music
|
||||
- **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use
|
||||
- **Improve artwork quality** from 600x600 to 3000x3000 resolution
|
||||
|
||||
### Secondary Goals
|
||||
|
||||
- **Implement proper caching** using Redis (matching other API patterns)
|
||||
- **Create reusable audio components** for future music features
|
||||
- **Maintain current UI** while preparing data structure for future enhancements
|
||||
- **Prepare foundation** for future user library integration
|
||||
|
||||
### Technical Goals
|
||||
|
||||
- Secure JWT token generation and management
|
||||
- Efficient API response caching
|
||||
- Clean component architecture for audio playback
|
||||
|
|
@ -191,11 +194,13 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac
|
|||
## Technical Architecture
|
||||
|
||||
### API Flow
|
||||
|
||||
```
|
||||
Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend
|
||||
```
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
HomePage
|
||||
└── AlbumGrid
|
||||
|
|
@ -206,17 +211,18 @@ HomePage
|
|||
```
|
||||
|
||||
### Data Structure
|
||||
|
||||
```typescript
|
||||
interface EnhancedAlbum extends Album {
|
||||
appleMusicId?: string;
|
||||
highResArtwork?: string; // Used immediately
|
||||
previewUrl?: string; // Used immediately
|
||||
|
||||
// Stored for future use (not displayed yet):
|
||||
genres?: string[];
|
||||
releaseDate?: string;
|
||||
trackCount?: number;
|
||||
tracks?: AppleMusicTrack[];
|
||||
appleMusicId?: string
|
||||
highResArtwork?: string // Used immediately
|
||||
previewUrl?: string // Used immediately
|
||||
|
||||
// Stored for future use (not displayed yet):
|
||||
genres?: string[]
|
||||
releaseDate?: string
|
||||
trackCount?: number
|
||||
tracks?: AppleMusicTrack[]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -231,14 +237,17 @@ interface EnhancedAlbum extends Album {
|
|||
## Dependencies
|
||||
|
||||
### New Dependencies
|
||||
|
||||
- `jsonwebtoken`: JWT generation
|
||||
- `@types/jsonwebtoken`: TypeScript types
|
||||
|
||||
### Existing Dependencies to Leverage
|
||||
|
||||
- `redis`: Caching layer
|
||||
- `$lib/server/redis-client`: Existing Redis connection
|
||||
|
||||
### Dependencies to Remove
|
||||
|
||||
- `node-itunes-search`: Replaced by Apple Music API
|
||||
|
||||
## Rollback Plan
|
||||
|
|
@ -280,4 +289,4 @@ interface EnhancedAlbum extends Album {
|
|||
- [Apple Music API Documentation](https://developer.apple.com/documentation/applemusicapi)
|
||||
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
|
||||
- [Original Deno Implementation](https://gist.github.com/NetOpWibby/fca4e7942617095677831d6c74187f84)
|
||||
- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features)
|
||||
- [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
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata
|
||||
2. **Missing Social Media Support**: No Twitter cards on any pages
|
||||
3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps
|
||||
|
|
@ -14,6 +15,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
|
|||
5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement
|
||||
|
||||
### Impact
|
||||
|
||||
- Reduced search engine visibility
|
||||
- Poor social media sharing experience
|
||||
- Missed opportunities for rich snippets in search results
|
||||
|
|
@ -40,22 +42,24 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
|
|||
### 1. Core Components
|
||||
|
||||
#### SeoMetadata Component
|
||||
|
||||
A centralized Svelte component that handles all metadata needs:
|
||||
|
||||
```svelte
|
||||
<SeoMetadata
|
||||
title="Project Title"
|
||||
description="Project description"
|
||||
type="article"
|
||||
image="/path/to/image.jpg"
|
||||
author="@jedmund"
|
||||
publishedTime={date}
|
||||
modifiedTime={date}
|
||||
tags={['tag1', 'tag2']}
|
||||
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
|
||||
|
|
@ -68,12 +72,14 @@ Features:
|
|||
#### High Priority Pages
|
||||
|
||||
**Home Page (/)**
|
||||
|
||||
- Title: "@jedmund — Software designer and strategist"
|
||||
- Description: Professional summary
|
||||
- Type: website
|
||||
- Image: Professional headshot or branded image
|
||||
|
||||
**Work Project Pages (/work/[slug])**
|
||||
|
||||
- Title: "[Project Name] by @jedmund"
|
||||
- Description: Project description
|
||||
- Type: article
|
||||
|
|
@ -81,6 +87,7 @@ Features:
|
|||
- Structured data: CreativeWork schema
|
||||
|
||||
**Photo Pages (/photos/[slug]/[id])**
|
||||
|
||||
- Title: "[Photo Title] | Photography by @jedmund"
|
||||
- Description: Photo caption or album context
|
||||
- Type: article
|
||||
|
|
@ -88,6 +95,7 @@ Features:
|
|||
- Structured data: ImageObject schema
|
||||
|
||||
**Universe Posts (/universe/[slug])**
|
||||
|
||||
- Essays (long-form): "[Essay Name] — @jedmund"
|
||||
- Posts (short-form): "@jedmund: [Post snippet]"
|
||||
- Description: Post excerpt (first 160 chars)
|
||||
|
|
@ -98,16 +106,19 @@ Features:
|
|||
#### Medium Priority Pages
|
||||
|
||||
**Labs Projects (/labs/[slug])**
|
||||
|
||||
- Similar to Work projects but with "Lab" designation
|
||||
- Experimental project metadata
|
||||
|
||||
**About Page (/about)**
|
||||
|
||||
- Title: "About | @jedmund"
|
||||
- Description: Professional bio excerpt
|
||||
- Type: profile
|
||||
- Structured data: Person schema
|
||||
|
||||
**Photo Albums (/photos/[slug])**
|
||||
|
||||
- Title: "[Album Name] | Photography by @jedmund"
|
||||
- Description: Album description
|
||||
- Type: website
|
||||
|
|
@ -116,6 +127,7 @@ Features:
|
|||
### 3. Dynamic OG Image Generation
|
||||
|
||||
Create an API endpoint (`/api/og-image`) that generates images:
|
||||
|
||||
- For projects: Logo on brand color background
|
||||
- For photos: The photo itself with optional watermark
|
||||
- For text posts: Branded template with title
|
||||
|
|
@ -124,16 +136,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
|||
### 4. Technical SEO Improvements
|
||||
|
||||
**Sitemap Generation**
|
||||
|
||||
- Dynamic sitemap.xml generation
|
||||
- Include all public pages
|
||||
- Update frequency and priority hints
|
||||
|
||||
**Robots.txt**
|
||||
|
||||
- Allow all crawlers by default
|
||||
- Block admin routes
|
||||
- Reference sitemap location
|
||||
|
||||
**Canonical URLs**
|
||||
|
||||
- Automatic canonical URL generation
|
||||
- Handle www/non-www consistency
|
||||
- Support pagination parameters
|
||||
|
|
@ -141,15 +156,18 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
|||
### 5. Utilities & Helpers
|
||||
|
||||
**formatSeoTitle(title, suffix = "@jedmund")**
|
||||
|
||||
- Consistent title formatting
|
||||
- Character limit enforcement (60 chars)
|
||||
|
||||
**generateDescription(content, limit = 160)**
|
||||
|
||||
- Extract description from content
|
||||
- HTML stripping
|
||||
- Smart truncation
|
||||
|
||||
**getCanonicalUrl(path)**
|
||||
|
||||
- Generate absolute URLs
|
||||
- Handle query parameters
|
||||
- Ensure consistency
|
||||
|
|
@ -157,30 +175,35 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
|||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
|
||||
- [ ] Create SeoMetadata component
|
||||
- [ ] Implement basic meta tag support
|
||||
- [ ] Add title/description utilities
|
||||
- [ ] Update app.html to remove hardcoded values
|
||||
|
||||
### Phase 2: Critical Pages (Week 2)
|
||||
|
||||
- [ ] Home page metadata
|
||||
- [ ] Work project pages
|
||||
- [ ] Universe post pages
|
||||
- [ ] Photo detail pages
|
||||
|
||||
### Phase 3: Secondary Pages (Week 3)
|
||||
|
||||
- [ ] About page
|
||||
- [ ] Labs page and projects
|
||||
- [ ] Photo albums and index
|
||||
- [ ] Universe feed
|
||||
|
||||
### Phase 4: Advanced Features (Week 4)
|
||||
|
||||
- [ ] Dynamic OG image generation
|
||||
- [ ] Structured data implementation
|
||||
- [ ] Sitemap generation
|
||||
- [ ] Technical SEO improvements
|
||||
|
||||
### Phase 5: Testing & Refinement (Week 5)
|
||||
|
||||
- [ ] Test all pages with social media debuggers
|
||||
- [ ] Validate structured data
|
||||
- [ ] Performance optimization
|
||||
|
|
@ -189,16 +212,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
|||
## Technical Considerations
|
||||
|
||||
### Performance
|
||||
|
||||
- Metadata generation should not impact page load time
|
||||
- Cache generated OG images
|
||||
- Minimize JavaScript overhead
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Centralized component reduces update complexity
|
||||
- Clear documentation for adding new pages
|
||||
- Automated testing for metadata presence
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Support major social platforms (Twitter, Facebook, LinkedIn)
|
||||
- Ensure search engine compatibility
|
||||
- Fallback for missing data
|
||||
|
|
@ -227,14 +253,17 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
|||
### Current Implementation Status
|
||||
|
||||
✅ **Good Implementation**
|
||||
|
||||
- /universe/[slug]
|
||||
- /photos/[albumSlug]/[photoId]
|
||||
|
||||
⚠️ **Partial Implementation**
|
||||
|
||||
- /photos/[slug]
|
||||
- /universe
|
||||
|
||||
❌ **No Implementation**
|
||||
|
||||
- / (home)
|
||||
- /about
|
||||
- /labs
|
||||
|
|
@ -243,7 +272,8 @@ Create an API endpoint (`/api/og-image`) that generates images:
|
|||
- /work/[slug]
|
||||
|
||||
### Resources
|
||||
|
||||
- [OpenGraph Protocol](https://ogp.me/)
|
||||
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards)
|
||||
- [Schema.org](https://schema.org/)
|
||||
- [Google SEO Guidelines](https://developers.google.com/search/docs)
|
||||
- [Google SEO Guidelines](https://developers.google.com/search/docs)
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue