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}