diff --git a/prd/PRD-apple-music-integration.md b/prd/PRD-apple-music-integration.md
index bc46a13..adc581d 100644
--- a/prd/PRD-apple-music-integration.md
+++ b/prd/PRD-apple-music-integration.md
@@ -14,18 +14,21 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac
## Goals
### Primary Goals
+
- **Replace iTunes Search API** with Apple Music API for better data quality
- **Add 30-second preview playback** for discovered music
- **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use
- **Improve artwork quality** from 600x600 to 3000x3000 resolution
### Secondary Goals
+
- **Implement proper caching** using Redis (matching other API patterns)
- **Create reusable audio components** for future music features
- **Maintain current UI** while preparing data structure for future enhancements
- **Prepare foundation** for future user library integration
### Technical Goals
+
- Secure JWT token generation and management
- Efficient API response caching
- Clean component architecture for audio playback
@@ -191,11 +194,13 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac
## Technical Architecture
### API Flow
+
```
Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend
```
### Component Hierarchy
+
```
HomePage
└── AlbumGrid
@@ -206,17 +211,18 @@ HomePage
```
### Data Structure
+
```typescript
interface EnhancedAlbum extends Album {
- appleMusicId?: string;
- highResArtwork?: string; // Used immediately
- previewUrl?: string; // Used immediately
-
- // Stored for future use (not displayed yet):
- genres?: string[];
- releaseDate?: string;
- trackCount?: number;
- tracks?: AppleMusicTrack[];
+ appleMusicId?: string
+ highResArtwork?: string // Used immediately
+ previewUrl?: string // Used immediately
+
+ // Stored for future use (not displayed yet):
+ genres?: string[]
+ releaseDate?: string
+ trackCount?: number
+ tracks?: AppleMusicTrack[]
}
```
@@ -231,14 +237,17 @@ interface EnhancedAlbum extends Album {
## Dependencies
### New Dependencies
+
- `jsonwebtoken`: JWT generation
- `@types/jsonwebtoken`: TypeScript types
### Existing Dependencies to Leverage
+
- `redis`: Caching layer
- `$lib/server/redis-client`: Existing Redis connection
### Dependencies to Remove
+
- `node-itunes-search`: Replaced by Apple Music API
## Rollback Plan
@@ -280,4 +289,4 @@ interface EnhancedAlbum extends Album {
- [Apple Music API Documentation](https://developer.apple.com/documentation/applemusicapi)
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
- [Original Deno Implementation](https://gist.github.com/NetOpWibby/fca4e7942617095677831d6c74187f84)
-- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features)
\ No newline at end of file
+- [MusicKit JS](https://developer.apple.com/documentation/musickitjs) (for future client-side features)
diff --git a/prd/PRD-seo-metadata-system-v2.md b/prd/PRD-seo-metadata-system-v2.md
new file mode 100644
index 0000000..7b2e3fd
--- /dev/null
+++ b/prd/PRD-seo-metadata-system-v2.md
@@ -0,0 +1,204 @@
+# PRD: SEO & Metadata System - V2
+
+## Executive Summary
+
+This updated PRD acknowledges the existing comprehensive SEO metadata implementation on jedmund.com and focuses on the remaining gaps: dynamic sitemap generation, OG image generation for text content, and metadata testing/validation.
+
+## Current State Assessment
+
+### Already Implemented ✅
+
+- **Metadata utilities** (`/src/lib/utils/metadata.ts`) providing:
+ - Complete OpenGraph and Twitter Card support
+ - JSON-LD structured data generators
+ - Smart title formatting and fallbacks
+ - Canonical URL handling
+- **100% page coverage** with appropriate metadata
+- **Dynamic content support** with excerpt generation
+- **Error handling** with noindex for 404 pages
+
+### Remaining Gaps ❌
+
+1. **No dynamic sitemap.xml**
+2. **No OG image generation API** for text-based content
+3. **No automated metadata validation**
+4. **robots.txt doesn't reference sitemap**
+
+## Revised Goals
+
+1. **Complete technical SEO** with dynamic sitemap generation
+2. **Enhance social sharing** with generated OG images for text content
+3. **Ensure quality** with metadata validation tools
+4. **Improve discoverability** with complete robots.txt
+
+## Proposed Implementation
+
+### Phase 1: Dynamic Sitemap (Week 1)
+
+#### Create `/src/routes/sitemap.xml/+server.ts`
+
+```typescript
+export async function GET() {
+ const pages = await getAllPublicPages()
+ const xml = generateSitemapXML(pages)
+
+ return new Response(xml, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ 'Cache-Control': 'max-age=3600'
+ }
+ })
+}
+```
+
+Features:
+
+- Auto-discover all public routes
+- Include lastmod dates from content
+- Set appropriate priorities
+- Exclude admin routes
+
+### Phase 2: OG Image Generation (Week 1-2)
+
+#### Create `/src/routes/api/og-image/+server.ts`
+
+```typescript
+export async function GET({ url }) {
+ const { title, subtitle, type } = Object.fromEntries(url.searchParams)
+
+ const svg = generateOGImageSVG({
+ title,
+ subtitle,
+ type, // 'post', 'project', 'default'
+ brandColor: '#your-brand-color'
+ })
+
+ const png = await convertSVGtoPNG(svg)
+
+ return new Response(png, {
+ headers: {
+ 'Content-Type': 'image/png',
+ 'Cache-Control': 'public, max-age=31536000'
+ }
+ })
+}
+```
+
+Templates:
+
+- **Posts**: Title + excerpt on branded background
+- **Projects**: Logo placeholder + title
+- **Default**: Site logo + tagline
+
+### Phase 3: Metadata Validation (Week 2)
+
+#### Create `/src/lib/utils/metadata-validator.ts`
+
+```typescript
+export function validateMetaTags(page: string) {
+ return {
+ hasTitle: checkTitle(),
+ titleLength: getTitleLength(),
+ hasDescription: checkDescription(),
+ descriptionLength: getDescriptionLength(),
+ hasOGImage: checkOGImage(),
+ hasCanonical: checkCanonical(),
+ structuredDataValid: validateJSONLD()
+ }
+}
+```
+
+#### Add development-only validation component
+
+- Console warnings for missing/invalid metadata
+- Visual indicators in dev mode
+- Automated tests for all routes
+
+### Phase 4: Final Touches (Week 2)
+
+1. **Update robots.txt**
+
+```
+Sitemap: https://jedmund.com/sitemap.xml
+
+# Existing rules...
+```
+
+2. **Add metadata debugging route** (dev only)
+
+- `/api/meta-debug` - JSON output of all pages' metadata
+- Useful for testing social media previews
+
+## Success Metrics
+
+- [ ] Sitemap.xml validates and includes all public pages
+- [ ] OG images generate for all text-based content
+- [ ] All pages pass metadata validation
+- [ ] Google Search Console shows improved indexing
+- [ ] Social media previews display correctly
+
+## Technical Considerations
+
+### Performance
+
+- Cache generated OG images (1 year)
+- Cache sitemap (1 hour)
+- Lazy-load validation in development only
+
+### Maintenance
+
+- Sitemap auto-updates with new content
+- OG image templates easy to modify
+- Validation runs in CI/CD pipeline
+
+## Implementation Timeline
+
+**Week 1:**
+
+- Day 1-2: Implement dynamic sitemap
+- Day 3-5: Create OG image generation API
+
+**Week 2:**
+
+- Day 1-2: Add metadata validation utilities
+- Day 3-4: Testing and refinement
+- Day 5: Documentation and deployment
+
+Total: **2 weeks** (vs. original 5 weeks)
+
+## Future Enhancements
+
+1. **A/B testing** different OG images/titles
+2. **Multi-language support** with hreflang tags
+3. **Advanced schemas** (FAQ, HowTo) for specific content
+4. **Analytics integration** to track metadata performance
+
+## Appendix: Current Implementation Reference
+
+### Existing Files
+
+- `/src/lib/utils/metadata.ts` - Core utilities
+- `/src/lib/utils/content.ts` - Content extraction
+- `/src/routes/+layout.svelte` - Default metadata
+- All page routes - Individual implementations
+
+### Usage Pattern
+
+```svelte
+
+
+
+ {metaTags.title}
+
+
+```
diff --git a/prd/PRD-seo-metadata-system.md b/prd/PRD-seo-metadata-system.md
index d40876d..ba11fa2 100644
--- a/prd/PRD-seo-metadata-system.md
+++ b/prd/PRD-seo-metadata-system.md
@@ -7,6 +7,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
## Problem Statement
### Current Issues
+
1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata
2. **Missing Social Media Support**: No Twitter cards on any pages
3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps
@@ -14,6 +15,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement
### Impact
+
- Reduced search engine visibility
- Poor social media sharing experience
- Missed opportunities for rich snippets in search results
@@ -40,22 +42,24 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
### 1. Core Components
#### SeoMetadata Component
+
A centralized Svelte component that handles all metadata needs:
```svelte
```
Features:
+
- Automatic title formatting (e.g., "Title | @jedmund")
- Fallback chains for missing data
- Support for all OpenGraph types
@@ -68,12 +72,14 @@ Features:
#### High Priority Pages
**Home Page (/)**
+
- Title: "@jedmund — Software designer and strategist"
- Description: Professional summary
- Type: website
- Image: Professional headshot or branded image
**Work Project Pages (/work/[slug])**
+
- Title: "[Project Name] by @jedmund"
- Description: Project description
- Type: article
@@ -81,6 +87,7 @@ Features:
- Structured data: CreativeWork schema
**Photo Pages (/photos/[slug]/[id])**
+
- Title: "[Photo Title] | Photography by @jedmund"
- Description: Photo caption or album context
- Type: article
@@ -88,6 +95,7 @@ Features:
- Structured data: ImageObject schema
**Universe Posts (/universe/[slug])**
+
- Essays (long-form): "[Essay Name] — @jedmund"
- Posts (short-form): "@jedmund: [Post snippet]"
- Description: Post excerpt (first 160 chars)
@@ -98,16 +106,19 @@ Features:
#### Medium Priority Pages
**Labs Projects (/labs/[slug])**
+
- Similar to Work projects but with "Lab" designation
- Experimental project metadata
**About Page (/about)**
+
- Title: "About | @jedmund"
- Description: Professional bio excerpt
- Type: profile
- Structured data: Person schema
**Photo Albums (/photos/[slug])**
+
- Title: "[Album Name] | Photography by @jedmund"
- Description: Album description
- Type: website
@@ -116,6 +127,7 @@ Features:
### 3. Dynamic OG Image Generation
Create an API endpoint (`/api/og-image`) that generates images:
+
- For projects: Logo on brand color background
- For photos: The photo itself with optional watermark
- For text posts: Branded template with title
@@ -124,16 +136,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
### 4. Technical SEO Improvements
**Sitemap Generation**
+
- Dynamic sitemap.xml generation
- Include all public pages
- Update frequency and priority hints
**Robots.txt**
+
- Allow all crawlers by default
- Block admin routes
- Reference sitemap location
**Canonical URLs**
+
- Automatic canonical URL generation
- Handle www/non-www consistency
- Support pagination parameters
@@ -141,15 +156,18 @@ Create an API endpoint (`/api/og-image`) that generates images:
### 5. Utilities & Helpers
**formatSeoTitle(title, suffix = "@jedmund")**
+
- Consistent title formatting
- Character limit enforcement (60 chars)
**generateDescription(content, limit = 160)**
+
- Extract description from content
- HTML stripping
- Smart truncation
**getCanonicalUrl(path)**
+
- Generate absolute URLs
- Handle query parameters
- Ensure consistency
@@ -157,30 +175,35 @@ Create an API endpoint (`/api/og-image`) that generates images:
## Implementation Plan
### Phase 1: Foundation (Week 1)
+
- [ ] Create SeoMetadata component
- [ ] Implement basic meta tag support
- [ ] Add title/description utilities
- [ ] Update app.html to remove hardcoded values
### Phase 2: Critical Pages (Week 2)
+
- [ ] Home page metadata
- [ ] Work project pages
- [ ] Universe post pages
- [ ] Photo detail pages
### Phase 3: Secondary Pages (Week 3)
+
- [ ] About page
- [ ] Labs page and projects
- [ ] Photo albums and index
- [ ] Universe feed
### Phase 4: Advanced Features (Week 4)
+
- [ ] Dynamic OG image generation
- [ ] Structured data implementation
- [ ] Sitemap generation
- [ ] Technical SEO improvements
### Phase 5: Testing & Refinement (Week 5)
+
- [ ] Test all pages with social media debuggers
- [ ] Validate structured data
- [ ] Performance optimization
@@ -189,16 +212,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
## Technical Considerations
### Performance
+
- Metadata generation should not impact page load time
- Cache generated OG images
- Minimize JavaScript overhead
### Maintenance
+
- Centralized component reduces update complexity
- Clear documentation for adding new pages
- Automated testing for metadata presence
### Compatibility
+
- Support major social platforms (Twitter, Facebook, LinkedIn)
- Ensure search engine compatibility
- Fallback for missing data
@@ -227,14 +253,17 @@ Create an API endpoint (`/api/og-image`) that generates images:
### Current Implementation Status
✅ **Good Implementation**
+
- /universe/[slug]
- /photos/[albumSlug]/[photoId]
⚠️ **Partial Implementation**
+
- /photos/[slug]
- /universe
❌ **No Implementation**
+
- / (home)
- /about
- /labs
@@ -243,7 +272,8 @@ Create an API endpoint (`/api/og-image`) that generates images:
- /work/[slug]
### Resources
+
- [OpenGraph Protocol](https://ogp.me/)
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards)
- [Schema.org](https://schema.org/)
-- [Google SEO Guidelines](https://developers.google.com/search/docs)
\ No newline at end of file
+- [Google SEO Guidelines](https://developers.google.com/search/docs)
diff --git a/src/app.html b/src/app.html
index e1f2aae..ff20886 100644
--- a/src/app.html
+++ b/src/app.html
@@ -7,14 +7,6 @@
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
-
-
-
-
-
%sveltekit.head%
diff --git a/src/lib/utils/metadata.ts b/src/lib/utils/metadata.ts
new file mode 100644
index 0000000..b768491
--- /dev/null
+++ b/src/lib/utils/metadata.ts
@@ -0,0 +1,364 @@
+interface MetaTagsOptions {
+ title?: string
+ description?: string
+ image?: string
+ url?: string
+ type?: 'website' | 'article' | 'profile'
+ author?: string
+ publishedTime?: string
+ modifiedTime?: string
+ section?: string
+ tags?: string[]
+ twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player'
+ twitterSite?: string
+ twitterCreator?: string
+ siteName?: string
+ locale?: string
+ canonicalUrl?: string
+ noindex?: boolean
+ jsonLd?: Record
+}
+
+interface GeneratedMetaTags {
+ title: string
+ description: string
+ openGraph: Record
+ twitter: Record
+ other: Record
+ jsonLd?: Record
+}
+
+const DEFAULTS = {
+ siteName: '@jedmund',
+ siteUrl: 'https://jedmund.com',
+ defaultTitle: '@jedmund is a software designer',
+ defaultDescription: 'Justin Edmund is a software designer based in San Francisco.',
+ defaultImage: 'https://jedmund.com/images/og-image.jpg',
+ twitterSite: '@jedmund',
+ locale: 'en_US'
+}
+
+/**
+ * Format a page title based on content type
+ */
+export function formatPageTitle(
+ title?: string,
+ options?: {
+ type?: 'default' | 'by' | 'snippet' | 'about'
+ snippet?: string
+ }
+): string {
+ if (!title || title === DEFAULTS.siteName) {
+ return DEFAULTS.defaultTitle
+ }
+
+ const { type = 'default', snippet } = options || {}
+
+ switch (type) {
+ case 'by':
+ return `${title} by @jedmund`
+ case 'snippet':
+ return `@jedmund: ${snippet || title}`
+ case 'about':
+ return `About @jedmund`
+ default:
+ return `${title} — @jedmund`
+ }
+}
+
+/**
+ * Ensure a URL is absolute
+ */
+function ensureAbsoluteUrl(url: string): string {
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url
+ }
+ return `${DEFAULTS.siteUrl}${url.startsWith('/') ? '' : '/'}${url}`
+}
+
+/**
+ * Generate comprehensive meta tags for a page
+ */
+export function generateMetaTags(
+ options: MetaTagsOptions & {
+ titleFormat?: { type?: 'default' | 'by' | 'snippet' | 'about'; snippet?: string }
+ } = {}
+): GeneratedMetaTags {
+ const {
+ title,
+ description = DEFAULTS.defaultDescription,
+ image = DEFAULTS.defaultImage,
+ url = DEFAULTS.siteUrl,
+ type = 'website',
+ author,
+ publishedTime,
+ modifiedTime,
+ section,
+ tags = [],
+ twitterCard = image ? 'summary_large_image' : 'summary',
+ twitterSite = DEFAULTS.twitterSite,
+ twitterCreator = DEFAULTS.twitterSite,
+ siteName = DEFAULTS.siteName,
+ locale = DEFAULTS.locale,
+ canonicalUrl,
+ noindex = false,
+ jsonLd,
+ titleFormat
+ } = options
+
+ const formattedTitle = formatPageTitle(title, titleFormat)
+ const absoluteUrl = ensureAbsoluteUrl(url)
+ const absoluteImage = ensureAbsoluteUrl(image)
+ const canonical = canonicalUrl ? ensureAbsoluteUrl(canonicalUrl) : absoluteUrl
+
+ // Basic meta tags
+ const metaTags: GeneratedMetaTags = {
+ title: formattedTitle,
+ description,
+ openGraph: {
+ title: title || DEFAULTS.defaultTitle,
+ description,
+ type,
+ url: absoluteUrl,
+ site_name: siteName,
+ locale
+ },
+ twitter: {
+ card: twitterCard,
+ title: title || DEFAULTS.defaultTitle,
+ description
+ },
+ other: {}
+ }
+
+ // Add image tags if provided
+ if (image) {
+ metaTags.openGraph.image = absoluteImage
+ metaTags.twitter.image = absoluteImage
+ // Add image alt text if we can derive it
+ if (title) {
+ metaTags.openGraph.image_alt = `Image for ${title}`
+ metaTags.twitter.image_alt = `Image for ${title}`
+ }
+ }
+
+ // Add Twitter accounts if provided
+ if (twitterSite) {
+ metaTags.twitter.site = twitterSite
+ }
+ if (twitterCreator) {
+ metaTags.twitter.creator = twitterCreator
+ }
+
+ // Add article-specific tags
+ if (type === 'article') {
+ if (author) {
+ metaTags.openGraph.article_author = author
+ }
+ if (publishedTime) {
+ metaTags.openGraph.article_published_time = publishedTime
+ }
+ if (modifiedTime) {
+ metaTags.openGraph.article_modified_time = modifiedTime
+ }
+ if (section) {
+ metaTags.openGraph.article_section = section
+ }
+ if (tags.length > 0) {
+ metaTags.openGraph.article_tag = tags.join(',')
+ }
+ }
+
+ // Add canonical URL
+ if (canonical) {
+ metaTags.other.canonical = canonical
+ }
+
+ // Add noindex if needed
+ if (noindex) {
+ metaTags.other.robots = 'noindex,nofollow'
+ }
+
+ // Add JSON-LD if provided
+ if (jsonLd) {
+ metaTags.jsonLd = jsonLd
+ }
+
+ return metaTags
+}
+
+/**
+ * Generate JSON-LD structured data for a person
+ */
+export function generatePersonJsonLd(options: {
+ name: string
+ url?: string
+ image?: string
+ jobTitle?: string
+ description?: string
+ sameAs?: string[]
+}): Record {
+ const { name, url = DEFAULTS.siteUrl, image, jobTitle, description, sameAs = [] } = options
+
+ const jsonLd: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'Person',
+ name,
+ url
+ }
+
+ if (image) {
+ jsonLd.image = ensureAbsoluteUrl(image)
+ }
+
+ if (jobTitle) {
+ jsonLd.jobTitle = jobTitle
+ }
+
+ if (description) {
+ jsonLd.description = description
+ }
+
+ if (sameAs.length > 0) {
+ jsonLd.sameAs = sameAs
+ }
+
+ return jsonLd
+}
+
+/**
+ * Generate JSON-LD structured data for an article
+ */
+export function generateArticleJsonLd(options: {
+ title: string
+ description?: string
+ url: string
+ image?: string
+ datePublished?: string
+ dateModified?: string
+ author?: string
+}): Record {
+ const { title, description, url, image, datePublished, dateModified, author } = options
+
+ const jsonLd: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: title,
+ url: ensureAbsoluteUrl(url)
+ }
+
+ if (description) {
+ jsonLd.description = description
+ }
+
+ if (image) {
+ jsonLd.image = ensureAbsoluteUrl(image)
+ }
+
+ if (datePublished) {
+ jsonLd.datePublished = datePublished
+ }
+
+ if (dateModified) {
+ jsonLd.dateModified = dateModified
+ }
+
+ if (author) {
+ jsonLd.author = {
+ '@type': 'Person',
+ name: author
+ }
+ }
+
+ // Add publisher
+ jsonLd.publisher = {
+ '@type': 'Person',
+ name: 'Justin Edmund',
+ url: DEFAULTS.siteUrl
+ }
+
+ return jsonLd
+}
+
+/**
+ * Generate JSON-LD structured data for an image gallery
+ */
+export function generateImageGalleryJsonLd(options: {
+ name: string
+ description?: string
+ url: string
+ images: Array<{
+ url: string
+ caption?: string
+ }>
+}): Record {
+ const { name, description, url, images } = options
+
+ const jsonLd: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'ImageGallery',
+ name,
+ url: ensureAbsoluteUrl(url)
+ }
+
+ if (description) {
+ jsonLd.description = description
+ }
+
+ if (images.length > 0) {
+ jsonLd.image = images.map((img) => ({
+ '@type': 'ImageObject',
+ url: ensureAbsoluteUrl(img.url),
+ ...(img.caption && { caption: img.caption })
+ }))
+ }
+
+ return jsonLd
+}
+
+/**
+ * Generate JSON-LD structured data for a creative work (project)
+ */
+export function generateCreativeWorkJsonLd(options: {
+ name: string
+ description?: string
+ url: string
+ image?: string
+ creator?: string
+ dateCreated?: string
+ keywords?: string[]
+}): Record {
+ const { name, description, url, image, creator, dateCreated, keywords = [] } = options
+
+ const jsonLd: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'CreativeWork',
+ name,
+ url: ensureAbsoluteUrl(url)
+ }
+
+ if (description) {
+ jsonLd.description = description
+ }
+
+ if (image) {
+ jsonLd.image = ensureAbsoluteUrl(image)
+ }
+
+ if (creator) {
+ jsonLd.creator = {
+ '@type': 'Person',
+ name: creator
+ }
+ }
+
+ if (dateCreated) {
+ jsonLd.dateCreated = dateCreated
+ }
+
+ if (keywords.length > 0) {
+ jsonLd.keywords = keywords.join(', ')
+ }
+
+ return jsonLd
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 7a7d0b4..3d6cf30 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -2,14 +2,27 @@
import { page } from '$app/stores'
import Header from '$components/Header.svelte'
import Footer from '$components/Footer.svelte'
+ import { generatePersonJsonLd } from '$lib/utils/metadata'
- $: isAdminRoute = $page.url.pathname.startsWith('/admin')
+ const isAdminRoute = $derived($page.url.pathname.startsWith('/admin'))
+
+ // Generate person structured data for the site
+ const personJsonLd = $derived(generatePersonJsonLd({
+ name: 'Justin Edmund',
+ jobTitle: 'Software Designer',
+ description: 'Software designer based in San Francisco',
+ url: 'https://jedmund.com',
+ sameAs: [
+ 'https://twitter.com/jedmund',
+ 'https://github.com/jedmund',
+ 'https://www.linkedin.com/in/jedmund'
+ ]
+ }))
- @jedmund is a software designer
-
-
+
+ {@html ``}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 1e3ab66..76b757f 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,8 +1,40 @@
+
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+
+
+
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte
index 06c8c6d..db82861 100644
--- a/src/routes/about/+page.svelte
+++ b/src/routes/about/+page.svelte
@@ -4,6 +4,8 @@
import MentionList from '$components/MentionList.svelte'
import Page from '$components/Page.svelte'
import RecentAlbums from '$components/RecentAlbums.svelte'
+ import { generateMetaTags } from '$lib/utils/metadata'
+ import { page } from '$app/stores'
import type { PageData } from './$types'
@@ -12,8 +14,40 @@
let albums = $derived(data.albums)
let games = $derived(data.games)
let error = $derived(data.error)
+
+ const pageUrl = $derived($page.url.href)
+
+ // Generate metadata for about page
+ const metaTags = $derived(
+ generateMetaTags({
+ title: 'About',
+ description:
+ 'Software designer and developer living in San Francisco. Building thoughtful digital experiences and currently working on Maitsu, a hobby journaling app.',
+ url: pageUrl,
+ type: 'profile',
+ titleFormat: { type: 'about' }
+ })
+ )
+
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+
+
+
{#snippet header()}
diff --git a/src/routes/labs/+page.svelte b/src/routes/labs/+page.svelte
index c80be63..98cc40c 100644
--- a/src/routes/labs/+page.svelte
+++ b/src/routes/labs/+page.svelte
@@ -1,13 +1,44 @@
+
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+
+
+
{#if error}
diff --git a/src/routes/labs/[slug]/+page.svelte b/src/routes/labs/[slug]/+page.svelte
index f3e1e11..f220737 100644
--- a/src/routes/labs/[slug]/+page.svelte
+++ b/src/routes/labs/[slug]/+page.svelte
@@ -4,6 +4,8 @@
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
import ProjectContent from '$lib/components/ProjectContent.svelte'
+ import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
+ import { page } from '$app/stores'
import type { PageData } from './$types'
import type { Project } from '$lib/types/project'
@@ -11,8 +13,71 @@
const project = $derived(data.project as Project | null)
const error = $derived(data.error as string | undefined)
+ const pageUrl = $derived($page.url.href)
+
+ // Generate metadata
+ const metaTags = $derived(
+ project
+ ? generateMetaTags({
+ title: project.title,
+ description: project.description || `${project.title} — An experimental project`,
+ url: pageUrl,
+ image: project.thumbnailUrl,
+ type: 'article',
+ titleFormat: { type: 'by' }
+ })
+ : generateMetaTags({
+ title: 'Project Not Found',
+ description: 'The project you are looking for could not be found.',
+ url: pageUrl,
+ noindex: true
+ })
+ )
+
+ // Generate creative work JSON-LD
+ const projectJsonLd = $derived(
+ project
+ ? generateCreativeWorkJsonLd({
+ name: project.title,
+ description: project.description,
+ url: pageUrl,
+ image: project.thumbnailUrl,
+ creator: 'Justin Edmund',
+ dateCreated: project.year ? `${project.year}-01-01` : undefined,
+ keywords: project.tags || []
+ })
+ : null
+ )
+
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+ {#if metaTags.other.canonical}
+
+ {/if}
+ {#if metaTags.other.robots}
+
+ {/if}
+
+
+ {#if projectJsonLd}
+ {@html ``}
+ {/if}
+
+
{#if error}
diff --git a/src/routes/photos/+page.svelte b/src/routes/photos/+page.svelte
index 6779a51..785c740 100644
--- a/src/routes/photos/+page.svelte
+++ b/src/routes/photos/+page.svelte
@@ -1,13 +1,44 @@
+
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+
+
+
{#if error}
diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte
index 28f99cd..84825dd 100644
--- a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte
+++ b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte
@@ -1,5 +1,7 @@
- {#if photo && album}
- {photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}
-
+ {metaTags.title}
+
-
-
-
-
-
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
-
-
- {#if exif?.dateTaken}
-
- {/if}
- {:else}
- Photo Not Found
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+ {#if metaTags.other.canonical}
+
+ {/if}
+ {#if metaTags.other.robots}
+
+ {/if}
+
+
+ {#if photoJsonLd}
+ {@html ``}
{/if}
diff --git a/src/routes/photos/[slug]/+page.svelte b/src/routes/photos/[slug]/+page.svelte
index d2c5622..fe5575c 100644
--- a/src/routes/photos/[slug]/+page.svelte
+++ b/src/routes/photos/[slug]/+page.svelte
@@ -1,6 +1,8 @@
- {#if album}
- {album.title} - Photos
-
- {:else}
- Album Not Found - Photos
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+
+
+
+ {#if galleryJsonLd}
+ {@html ``}
{/if}
diff --git a/src/routes/universe/+page.svelte b/src/routes/universe/+page.svelte
index 1f4c44c..5f71103 100644
--- a/src/routes/universe/+page.svelte
+++ b/src/routes/universe/+page.svelte
@@ -1,13 +1,40 @@
- Universe - jedmund
-
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+
diff --git a/src/routes/universe/[slug]/+page.svelte b/src/routes/universe/[slug]/+page.svelte
index e4f4a98..0804b4a 100644
--- a/src/routes/universe/[slug]/+page.svelte
+++ b/src/routes/universe/[slug]/+page.svelte
@@ -3,6 +3,8 @@
import BackButton from '$components/BackButton.svelte'
import DynamicPostContent from '$components/DynamicPostContent.svelte'
import { getContentExcerpt } from '$lib/utils/content'
+ import { generateMetaTags, generateArticleJsonLd } from '$lib/utils/metadata'
+ import { page } from '$app/stores'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
@@ -13,28 +15,74 @@
const description = $derived(
post?.content
? getContentExcerpt(post.content, 160)
- : `${post?.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`
+ : `${post?.postType === 'essay' ? 'Essay' : 'Post'} by @jedmund`
+ )
+ const pageUrl = $derived($page.url.href)
+
+ // Generate metadata
+ const metaTags = $derived(
+ post
+ ? generateMetaTags({
+ title: pageTitle,
+ description,
+ url: pageUrl,
+ type: 'article',
+ image: post.attachments?.[0]?.url,
+ publishedTime: post.publishedAt,
+ author: 'Justin Edmund',
+ section: post.postType === 'essay' ? 'Essays' : 'Posts',
+ titleFormat:
+ post.postType === 'essay' ? { type: 'by' } : { type: 'snippet', snippet: description }
+ })
+ : generateMetaTags({
+ title: 'Post Not Found',
+ description: 'The post you are looking for could not be found.',
+ url: pageUrl,
+ noindex: true
+ })
+ )
+
+ // Generate article JSON-LD
+ const articleJsonLd = $derived(
+ post
+ ? generateArticleJsonLd({
+ title: pageTitle,
+ description,
+ url: pageUrl,
+ image: post.attachments?.[0]?.url,
+ datePublished: post.publishedAt,
+ dateModified: post.updatedAt || post.publishedAt,
+ author: 'Justin Edmund'
+ })
+ : null
)
- {#if post}
- {pageTitle} - jedmund
-
+ {metaTags.title}
+
-
-
-
-
- {#if post.attachments && post.attachments.length > 0}
-
- {/if}
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
-
-
-
- {:else}
- Post Not Found - jedmund
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+ {#if metaTags.other.canonical}
+
+ {/if}
+ {#if metaTags.other.robots}
+
+ {/if}
+
+
+ {#if articleJsonLd}
+ {@html ``}
{/if}
diff --git a/src/routes/work/[slug]/+page.svelte b/src/routes/work/[slug]/+page.svelte
index b3f829a..5d21b39 100644
--- a/src/routes/work/[slug]/+page.svelte
+++ b/src/routes/work/[slug]/+page.svelte
@@ -4,6 +4,8 @@
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
import ProjectContent from '$lib/components/ProjectContent.svelte'
+ import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
+ import { page } from '$app/stores'
import type { PageData } from './$types'
import type { Project } from '$lib/types/project'
import { spring } from 'svelte/motion'
@@ -12,6 +14,42 @@
const project = $derived(data.project as Project | null)
const error = $derived(data.error as string | undefined)
+ const pageUrl = $derived($page.url.href)
+
+ // Generate metadata
+ const metaTags = $derived(
+ project
+ ? generateMetaTags({
+ title: project.title,
+ description:
+ project.description || `${project.title} — A professional project by Justin Edmund`,
+ url: pageUrl,
+ image: project.thumbnailUrl || project.logoUrl,
+ type: 'article',
+ titleFormat: { type: 'by' }
+ })
+ : generateMetaTags({
+ title: 'Project Not Found',
+ description: 'The project you are looking for could not be found.',
+ url: pageUrl,
+ noindex: true
+ })
+ )
+
+ // Generate creative work JSON-LD
+ const projectJsonLd = $derived(
+ project
+ ? generateCreativeWorkJsonLd({
+ name: project.title,
+ description: project.description,
+ url: pageUrl,
+ image: project.thumbnailUrl || project.logoUrl,
+ creator: 'Justin Edmund',
+ dateCreated: project.year ? `${project.year}-01-01` : undefined,
+ keywords: project.tags || []
+ })
+ : null
+ )
let headerContainer = $state
(null)
@@ -50,6 +88,34 @@
}
+
+ {metaTags.title}
+
+
+
+ {#each Object.entries(metaTags.openGraph) as [property, content]}
+
+ {/each}
+
+
+ {#each Object.entries(metaTags.twitter) as [property, content]}
+
+ {/each}
+
+
+ {#if metaTags.other.canonical}
+
+ {/if}
+ {#if metaTags.other.robots}
+
+ {/if}
+
+
+ {#if projectJsonLd}
+ {@html ``}
+ {/if}
+
+
{#if error}