Better OpenGraph and HTML metadata

This commit is contained in:
Justin Edmund 2025-06-11 23:58:11 -07:00
parent af7122a7f6
commit 46d655e8f0
16 changed files with 1119 additions and 82 deletions

View file

@ -14,18 +14,21 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac
## Goals ## Goals
### Primary Goals ### Primary Goals
- **Replace iTunes Search API** with Apple Music API for better data quality - **Replace iTunes Search API** with Apple Music API for better data quality
- **Add 30-second preview playback** for discovered music - **Add 30-second preview playback** for discovered music
- **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use - **Fetch and store enhanced metadata** (genres, release dates, track listings) for future use
- **Improve artwork quality** from 600x600 to 3000x3000 resolution - **Improve artwork quality** from 600x600 to 3000x3000 resolution
### Secondary Goals ### Secondary Goals
- **Implement proper caching** using Redis (matching other API patterns) - **Implement proper caching** using Redis (matching other API patterns)
- **Create reusable audio components** for future music features - **Create reusable audio components** for future music features
- **Maintain current UI** while preparing data structure for future enhancements - **Maintain current UI** while preparing data structure for future enhancements
- **Prepare foundation** for future user library integration - **Prepare foundation** for future user library integration
### Technical Goals ### Technical Goals
- Secure JWT token generation and management - Secure JWT token generation and management
- Efficient API response caching - Efficient API response caching
- Clean component architecture for audio playback - Clean component architecture for audio playback
@ -191,11 +194,13 @@ Integrate Apple Music API to enhance the music features on jedmund.com by replac
## Technical Architecture ## Technical Architecture
### API Flow ### API Flow
``` ```
Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend Last.fm API → Recent Albums → Apple Music Search → Enhanced Data → Redis Cache → Frontend
``` ```
### Component Hierarchy ### Component Hierarchy
``` ```
HomePage HomePage
└── AlbumGrid └── AlbumGrid
@ -206,17 +211,18 @@ HomePage
``` ```
### Data Structure ### Data Structure
```typescript ```typescript
interface EnhancedAlbum extends Album { interface EnhancedAlbum extends Album {
appleMusicId?: string; appleMusicId?: string
highResArtwork?: string; // Used immediately highResArtwork?: string // Used immediately
previewUrl?: string; // Used immediately previewUrl?: string // Used immediately
// Stored for future use (not displayed yet): // Stored for future use (not displayed yet):
genres?: string[]; genres?: string[]
releaseDate?: string; releaseDate?: string
trackCount?: number; trackCount?: number
tracks?: AppleMusicTrack[]; tracks?: AppleMusicTrack[]
} }
``` ```
@ -231,14 +237,17 @@ interface EnhancedAlbum extends Album {
## Dependencies ## Dependencies
### New Dependencies ### New Dependencies
- `jsonwebtoken`: JWT generation - `jsonwebtoken`: JWT generation
- `@types/jsonwebtoken`: TypeScript types - `@types/jsonwebtoken`: TypeScript types
### Existing Dependencies to Leverage ### Existing Dependencies to Leverage
- `redis`: Caching layer - `redis`: Caching layer
- `$lib/server/redis-client`: Existing Redis connection - `$lib/server/redis-client`: Existing Redis connection
### Dependencies to Remove ### Dependencies to Remove
- `node-itunes-search`: Replaced by Apple Music API - `node-itunes-search`: Replaced by Apple Music API
## Rollback Plan ## Rollback Plan

View 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>
```

View file

@ -7,6 +7,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
## Problem Statement ## Problem Statement
### Current Issues ### Current Issues
1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata 1. **Inconsistent Implementation**: Only 2 out of 10+ page types have proper metadata
2. **Missing Social Media Support**: No Twitter cards on any pages 2. **Missing Social Media Support**: No Twitter cards on any pages
3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps 3. **Poor Search Visibility**: Missing canonical URLs, structured data, and sitemaps
@ -14,6 +15,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement 5. **No Image Strategy**: Most pages lack OpenGraph images, reducing social media engagement
### Impact ### Impact
- Reduced search engine visibility - Reduced search engine visibility
- Poor social media sharing experience - Poor social media sharing experience
- Missed opportunities for rich snippets in search results - Missed opportunities for rich snippets in search results
@ -40,6 +42,7 @@ This PRD outlines the implementation of a comprehensive SEO and metadata system
### 1. Core Components ### 1. Core Components
#### SeoMetadata Component #### SeoMetadata Component
A centralized Svelte component that handles all metadata needs: A centralized Svelte component that handles all metadata needs:
```svelte ```svelte
@ -56,6 +59,7 @@ A centralized Svelte component that handles all metadata needs:
``` ```
Features: Features:
- Automatic title formatting (e.g., "Title | @jedmund") - Automatic title formatting (e.g., "Title | @jedmund")
- Fallback chains for missing data - Fallback chains for missing data
- Support for all OpenGraph types - Support for all OpenGraph types
@ -68,12 +72,14 @@ Features:
#### High Priority Pages #### High Priority Pages
**Home Page (/)** **Home Page (/)**
- Title: "@jedmund — Software designer and strategist" - Title: "@jedmund — Software designer and strategist"
- Description: Professional summary - Description: Professional summary
- Type: website - Type: website
- Image: Professional headshot or branded image - Image: Professional headshot or branded image
**Work Project Pages (/work/[slug])** **Work Project Pages (/work/[slug])**
- Title: "[Project Name] by @jedmund" - Title: "[Project Name] by @jedmund"
- Description: Project description - Description: Project description
- Type: article - Type: article
@ -81,6 +87,7 @@ Features:
- Structured data: CreativeWork schema - Structured data: CreativeWork schema
**Photo Pages (/photos/[slug]/[id])** **Photo Pages (/photos/[slug]/[id])**
- Title: "[Photo Title] | Photography by @jedmund" - Title: "[Photo Title] | Photography by @jedmund"
- Description: Photo caption or album context - Description: Photo caption or album context
- Type: article - Type: article
@ -88,6 +95,7 @@ Features:
- Structured data: ImageObject schema - Structured data: ImageObject schema
**Universe Posts (/universe/[slug])** **Universe Posts (/universe/[slug])**
- Essays (long-form): "[Essay Name] — @jedmund" - Essays (long-form): "[Essay Name] — @jedmund"
- Posts (short-form): "@jedmund: [Post snippet]" - Posts (short-form): "@jedmund: [Post snippet]"
- Description: Post excerpt (first 160 chars) - Description: Post excerpt (first 160 chars)
@ -98,16 +106,19 @@ Features:
#### Medium Priority Pages #### Medium Priority Pages
**Labs Projects (/labs/[slug])** **Labs Projects (/labs/[slug])**
- Similar to Work projects but with "Lab" designation - Similar to Work projects but with "Lab" designation
- Experimental project metadata - Experimental project metadata
**About Page (/about)** **About Page (/about)**
- Title: "About | @jedmund" - Title: "About | @jedmund"
- Description: Professional bio excerpt - Description: Professional bio excerpt
- Type: profile - Type: profile
- Structured data: Person schema - Structured data: Person schema
**Photo Albums (/photos/[slug])** **Photo Albums (/photos/[slug])**
- Title: "[Album Name] | Photography by @jedmund" - Title: "[Album Name] | Photography by @jedmund"
- Description: Album description - Description: Album description
- Type: website - Type: website
@ -116,6 +127,7 @@ Features:
### 3. Dynamic OG Image Generation ### 3. Dynamic OG Image Generation
Create an API endpoint (`/api/og-image`) that generates images: Create an API endpoint (`/api/og-image`) that generates images:
- For projects: Logo on brand color background - For projects: Logo on brand color background
- For photos: The photo itself with optional watermark - For photos: The photo itself with optional watermark
- For text posts: Branded template with title - For text posts: Branded template with title
@ -124,16 +136,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
### 4. Technical SEO Improvements ### 4. Technical SEO Improvements
**Sitemap Generation** **Sitemap Generation**
- Dynamic sitemap.xml generation - Dynamic sitemap.xml generation
- Include all public pages - Include all public pages
- Update frequency and priority hints - Update frequency and priority hints
**Robots.txt** **Robots.txt**
- Allow all crawlers by default - Allow all crawlers by default
- Block admin routes - Block admin routes
- Reference sitemap location - Reference sitemap location
**Canonical URLs** **Canonical URLs**
- Automatic canonical URL generation - Automatic canonical URL generation
- Handle www/non-www consistency - Handle www/non-www consistency
- Support pagination parameters - Support pagination parameters
@ -141,15 +156,18 @@ Create an API endpoint (`/api/og-image`) that generates images:
### 5. Utilities & Helpers ### 5. Utilities & Helpers
**formatSeoTitle(title, suffix = "@jedmund")** **formatSeoTitle(title, suffix = "@jedmund")**
- Consistent title formatting - Consistent title formatting
- Character limit enforcement (60 chars) - Character limit enforcement (60 chars)
**generateDescription(content, limit = 160)** **generateDescription(content, limit = 160)**
- Extract description from content - Extract description from content
- HTML stripping - HTML stripping
- Smart truncation - Smart truncation
**getCanonicalUrl(path)** **getCanonicalUrl(path)**
- Generate absolute URLs - Generate absolute URLs
- Handle query parameters - Handle query parameters
- Ensure consistency - Ensure consistency
@ -157,30 +175,35 @@ Create an API endpoint (`/api/og-image`) that generates images:
## Implementation Plan ## Implementation Plan
### Phase 1: Foundation (Week 1) ### Phase 1: Foundation (Week 1)
- [ ] Create SeoMetadata component - [ ] Create SeoMetadata component
- [ ] Implement basic meta tag support - [ ] Implement basic meta tag support
- [ ] Add title/description utilities - [ ] Add title/description utilities
- [ ] Update app.html to remove hardcoded values - [ ] Update app.html to remove hardcoded values
### Phase 2: Critical Pages (Week 2) ### Phase 2: Critical Pages (Week 2)
- [ ] Home page metadata - [ ] Home page metadata
- [ ] Work project pages - [ ] Work project pages
- [ ] Universe post pages - [ ] Universe post pages
- [ ] Photo detail pages - [ ] Photo detail pages
### Phase 3: Secondary Pages (Week 3) ### Phase 3: Secondary Pages (Week 3)
- [ ] About page - [ ] About page
- [ ] Labs page and projects - [ ] Labs page and projects
- [ ] Photo albums and index - [ ] Photo albums and index
- [ ] Universe feed - [ ] Universe feed
### Phase 4: Advanced Features (Week 4) ### Phase 4: Advanced Features (Week 4)
- [ ] Dynamic OG image generation - [ ] Dynamic OG image generation
- [ ] Structured data implementation - [ ] Structured data implementation
- [ ] Sitemap generation - [ ] Sitemap generation
- [ ] Technical SEO improvements - [ ] Technical SEO improvements
### Phase 5: Testing & Refinement (Week 5) ### Phase 5: Testing & Refinement (Week 5)
- [ ] Test all pages with social media debuggers - [ ] Test all pages with social media debuggers
- [ ] Validate structured data - [ ] Validate structured data
- [ ] Performance optimization - [ ] Performance optimization
@ -189,16 +212,19 @@ Create an API endpoint (`/api/og-image`) that generates images:
## Technical Considerations ## Technical Considerations
### Performance ### Performance
- Metadata generation should not impact page load time - Metadata generation should not impact page load time
- Cache generated OG images - Cache generated OG images
- Minimize JavaScript overhead - Minimize JavaScript overhead
### Maintenance ### Maintenance
- Centralized component reduces update complexity - Centralized component reduces update complexity
- Clear documentation for adding new pages - Clear documentation for adding new pages
- Automated testing for metadata presence - Automated testing for metadata presence
### Compatibility ### Compatibility
- Support major social platforms (Twitter, Facebook, LinkedIn) - Support major social platforms (Twitter, Facebook, LinkedIn)
- Ensure search engine compatibility - Ensure search engine compatibility
- Fallback for missing data - Fallback for missing data
@ -227,14 +253,17 @@ Create an API endpoint (`/api/og-image`) that generates images:
### Current Implementation Status ### Current Implementation Status
✅ **Good Implementation** ✅ **Good Implementation**
- /universe/[slug] - /universe/[slug]
- /photos/[albumSlug]/[photoId] - /photos/[albumSlug]/[photoId]
⚠️ **Partial Implementation** ⚠️ **Partial Implementation**
- /photos/[slug] - /photos/[slug]
- /universe - /universe
❌ **No Implementation** ❌ **No Implementation**
- / (home) - / (home)
- /about - /about
- /labs - /labs
@ -243,6 +272,7 @@ Create an API endpoint (`/api/og-image`) that generates images:
- /work/[slug] - /work/[slug]
### Resources ### Resources
- [OpenGraph Protocol](https://ogp.me/) - [OpenGraph Protocol](https://ogp.me/)
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards) - [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards)
- [Schema.org](https://schema.org/) - [Schema.org](https://schema.org/)

View file

@ -7,14 +7,6 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/> />
<meta property="og:title" content="@jedmund" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://jedmund.com" />
<meta
property="og:description"
content="Justin Edmund is a software designer living in San Francisco."
/>
<meta property="og:image" content="https://jedmund.com/images/og-image.jpg" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

364
src/lib/utils/metadata.ts Normal file
View 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
}

View file

@ -2,14 +2,27 @@
import { page } from '$app/stores' import { page } from '$app/stores'
import Header from '$components/Header.svelte' import Header from '$components/Header.svelte'
import Footer from '$components/Footer.svelte' import Footer from '$components/Footer.svelte'
import { generatePersonJsonLd } from '$lib/utils/metadata'
$: isAdminRoute = $page.url.pathname.startsWith('/admin') const isAdminRoute = $derived($page.url.pathname.startsWith('/admin'))
// Generate person structured data for the site
const personJsonLd = $derived(generatePersonJsonLd({
name: 'Justin Edmund',
jobTitle: 'Software Designer',
description: 'Software designer based in San Francisco',
url: 'https://jedmund.com',
sameAs: [
'https://twitter.com/jedmund',
'https://github.com/jedmund',
'https://www.linkedin.com/in/jedmund'
]
}))
</script> </script>
<svelte:head> <svelte:head>
<title>@jedmund is a software designer</title> <!-- Site-wide JSON-LD -->
<meta name="description" content="Justin Edmund is a software designer based in San Francisco." /> {@html `<script type="application/ld+json">${JSON.stringify(personJsonLd)}</script>`}
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
</svelte:head> </svelte:head>
<div class="layout-wrapper" class:admin-route={isAdminRoute}> <div class="layout-wrapper" class:admin-route={isAdminRoute}>

View file

@ -1,8 +1,40 @@
<script lang="ts"> <script lang="ts">
import ProjectList from '$components/ProjectList.svelte' import ProjectList from '$components/ProjectList.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data } = $props<{ data: PageData }>() let { data } = $props<{ data: PageData }>()
const pageUrl = $derived($page.url.href)
// Generate metadata for homepage
const metaTags = $derived(
generateMetaTags({
title: undefined, // Use default title for homepage
description:
'Software designer crafting thoughtful digital experiences. Currently building products at scale.',
url: pageUrl
})
)
</script> </script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head>
<ProjectList projects={data?.projects || []} /> <ProjectList projects={data?.projects || []} />

View file

@ -4,6 +4,8 @@
import MentionList from '$components/MentionList.svelte' import MentionList from '$components/MentionList.svelte'
import Page from '$components/Page.svelte' import Page from '$components/Page.svelte'
import RecentAlbums from '$components/RecentAlbums.svelte' import RecentAlbums from '$components/RecentAlbums.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
@ -12,8 +14,40 @@
let albums = $derived(data.albums) let albums = $derived(data.albums)
let games = $derived(data.games) let games = $derived(data.games)
let error = $derived(data.error) let error = $derived(data.error)
const pageUrl = $derived($page.url.href)
// Generate metadata for about page
const metaTags = $derived(
generateMetaTags({
title: 'About',
description:
'Software designer and developer living in San Francisco. Building thoughtful digital experiences and currently working on Maitsu, a hobby journaling app.',
url: pageUrl,
type: 'profile',
titleFormat: { type: 'about' }
})
)
</script> </script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head>
<section class="about-container"> <section class="about-container">
<Page> <Page>
{#snippet header()} {#snippet header()}

View file

@ -1,13 +1,44 @@
<script lang="ts"> <script lang="ts">
import LabCard from '$components/LabCard.svelte' import LabCard from '$components/LabCard.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
const { data }: { data: PageData } = $props() const { data }: { data: PageData } = $props()
const projects = $derived(data.projects || []) const projects = $derived(data.projects || [])
const error = $derived(data.error) const error = $derived(data.error)
const pageUrl = $derived($page.url.href)
// Generate metadata for labs page
const metaTags = $derived(
generateMetaTags({
title: 'Labs',
description:
'Experimental projects and prototypes. A space for exploring new ideas, technologies, and creative coding.',
url: pageUrl
})
)
</script> </script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head>
<div class="labs-container"> <div class="labs-container">
{#if error} {#if error}
<div class="error-container"> <div class="error-container">

View file

@ -4,6 +4,8 @@
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte' import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte' import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
import ProjectContent from '$lib/components/ProjectContent.svelte' import ProjectContent from '$lib/components/ProjectContent.svelte'
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
import type { Project } from '$lib/types/project' import type { Project } from '$lib/types/project'
@ -11,8 +13,71 @@
const project = $derived(data.project as Project | null) const project = $derived(data.project as Project | null)
const error = $derived(data.error as string | undefined) const error = $derived(data.error as string | undefined)
const pageUrl = $derived($page.url.href)
// Generate metadata
const metaTags = $derived(
project
? generateMetaTags({
title: project.title,
description: project.description || `${project.title} — An experimental project`,
url: pageUrl,
image: project.thumbnailUrl,
type: 'article',
titleFormat: { type: 'by' }
})
: generateMetaTags({
title: 'Project Not Found',
description: 'The project you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate creative work JSON-LD
const projectJsonLd = $derived(
project
? generateCreativeWorkJsonLd({
name: project.title,
description: project.description,
url: pageUrl,
image: project.thumbnailUrl,
creator: 'Justin Edmund',
dateCreated: project.year ? `${project.year}-01-01` : undefined,
keywords: project.tags || []
})
: null
)
</script> </script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Other meta tags -->
{#if metaTags.other.canonical}
<link rel="canonical" href={metaTags.other.canonical} />
{/if}
{#if metaTags.other.robots}
<meta name="robots" content={metaTags.other.robots} />
{/if}
<!-- JSON-LD -->
{#if projectJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
{/if}
</svelte:head>
{#if error} {#if error}
<div class="error-wrapper"> <div class="error-wrapper">
<Page> <Page>

View file

@ -1,13 +1,44 @@
<script lang="ts"> <script lang="ts">
import PhotoGrid from '$components/PhotoGrid.svelte' import PhotoGrid from '$components/PhotoGrid.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
const { data }: { data: PageData } = $props() const { data }: { data: PageData } = $props()
const photoItems = $derived(data.photoItems || []) const photoItems = $derived(data.photoItems || [])
const error = $derived(data.error) const error = $derived(data.error)
const pageUrl = $derived($page.url.href)
// Generate metadata for photos page
const metaTags = $derived(
generateMetaTags({
title: 'Photography',
description:
'A collection of photography from travels, daily life, and creative projects. Captured moments from around the world.',
url: pageUrl
})
)
</script> </script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head>
<div class="photos-container"> <div class="photos-container">
{#if error} {#if error}
<div class="error-container"> <div class="error-container">

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import BackButton from '$components/BackButton.svelte' import BackButton from '$components/BackButton.svelte'
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data }: { data: PageData } = $props() let { data }: { data: PageData } = $props()
@ -44,39 +46,74 @@
} }
const exif = $derived(photo ? formatExif(photo.exifData) : null) const exif = $derived(photo ? formatExif(photo.exifData) : null)
const pageUrl = $derived($page.url.href)
// Generate metadata
const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`)
const photoDescription = $derived(
photo?.description || photo?.caption || `Photo from ${album?.title || 'album'}`
)
const metaTags = $derived(
photo && album
? generateMetaTags({
title: photoTitle,
description: photoDescription,
url: pageUrl,
type: 'article',
image: photo.url,
publishedTime: exif?.dateTaken,
author: 'Justin Edmund',
titleFormat: { type: 'snippet', snippet: photoDescription }
})
: generateMetaTags({
title: 'Photo Not Found',
description: 'The photo you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate creative work JSON-LD
const photoJsonLd = $derived(
photo && album
? generateCreativeWorkJsonLd({
name: photoTitle,
description: photoDescription,
url: pageUrl,
image: photo.url,
creator: 'Justin Edmund',
dateCreated: exif?.dateTaken,
keywords: ['photography', album.title, ...(exif?.location ? [exif.location] : [])]
})
: null
)
</script> </script>
<svelte:head> <svelte:head>
{#if photo && album} <title>{metaTags.title}</title>
<title <meta name="description" content={metaTags.description} />
>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title
>
<meta
name="description"
content={photo.description || photo.caption || `Photo from ${album.title}`}
/>
<!-- Open Graph meta tags --> <!-- OpenGraph -->
<meta {#each Object.entries(metaTags.openGraph) as [property, content]}
property="og:title" <meta property="og:{property}" {content} />
content="{photo.title || {/each}
photo.caption ||
`Photo ${navigation?.currentIndex}`} - {album.title}"
/>
<meta
property="og:description"
content={photo.description || photo.caption || `Photo from ${album.title}`}
/>
<meta property="og:type" content="article" />
<meta property="og:image" content={photo.url} />
<!-- Article meta --> <!-- Twitter Card -->
<meta property="article:author" content="jedmund" /> {#each Object.entries(metaTags.twitter) as [property, content]}
{#if exif?.dateTaken} <meta name="twitter:{property}" {content} />
<meta property="article:published_time" content={exif.dateTaken} /> {/each}
<!-- Other meta tags -->
{#if metaTags.other.canonical}
<link rel="canonical" href={metaTags.other.canonical} />
{/if} {/if}
{:else} {#if metaTags.other.robots}
<title>Photo Not Found</title> <meta name="robots" content={metaTags.other.robots} />
{/if}
<!-- JSON-LD -->
{#if photoJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}</script>`}
{/if} {/if}
</svelte:head> </svelte:head>

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import PhotoGrid from '$components/PhotoGrid.svelte' import PhotoGrid from '$components/PhotoGrid.svelte'
import BackButton from '$components/BackButton.svelte' import BackButton from '$components/BackButton.svelte'
import { generateMetaTags, generateImageGalleryJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data }: { data: PageData } = $props() let { data }: { data: PageData } = $props()
@ -28,14 +30,66 @@
year: 'numeric' year: 'numeric'
}) })
} }
const pageUrl = $derived($page.url.href)
// Generate metadata
const metaTags = $derived(
album
? generateMetaTags({
title: album.title,
description:
album.description ||
`Photo album: ${album.title}${album.location ? ` taken in ${album.location}` : ''}`,
url: pageUrl,
image: album.photos?.[0]?.url,
titleFormat: { type: 'by' }
})
: generateMetaTags({
title: 'Album Not Found',
description: 'The album you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate image gallery JSON-LD
const galleryJsonLd = $derived(
album
? generateImageGalleryJsonLd({
name: album.title,
description: album.description,
url: pageUrl,
images:
album.photos?.map((photo: any) => ({
url: photo.url,
caption: photo.caption
})) || []
})
: null
)
</script> </script>
<svelte:head> <svelte:head>
{#if album} <title>{metaTags.title}</title>
<title>{album.title} - Photos</title> <meta name="description" content={metaTags.description} />
<meta name="description" content={album.description || `Photo album: ${album.title}`} />
{:else} <!-- OpenGraph -->
<title>Album Not Found - Photos</title> {#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
<!-- JSON-LD -->
{#if galleryJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(galleryJsonLd)}</script>`}
{/if} {/if}
</svelte:head> </svelte:head>

View file

@ -1,13 +1,40 @@
<script lang="ts"> <script lang="ts">
import UniverseFeed from '$components/UniverseFeed.svelte' import UniverseFeed from '$components/UniverseFeed.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data }: { data: PageData } = $props() let { data }: { data: PageData } = $props()
const pageUrl = $derived($page.url.href)
// Generate metadata for universe page
const metaTags = $derived(
generateMetaTags({
title: 'Universe',
description:
'A mixed feed of posts, thoughts, and photo albums. Essays, experiments, and everything in between.',
url: pageUrl
})
)
</script> </script>
<svelte:head> <svelte:head>
<title>Universe - jedmund</title> <title>{metaTags.title}</title>
<meta name="description" content="A mixed feed of posts, thoughts, and photo albums." /> <meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Canonical URL -->
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head> </svelte:head>
<div class="universe-container"> <div class="universe-container">

View file

@ -3,6 +3,8 @@
import BackButton from '$components/BackButton.svelte' import BackButton from '$components/BackButton.svelte'
import DynamicPostContent from '$components/DynamicPostContent.svelte' import DynamicPostContent from '$components/DynamicPostContent.svelte'
import { getContentExcerpt } from '$lib/utils/content' import { getContentExcerpt } from '$lib/utils/content'
import { generateMetaTags, generateArticleJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data }: { data: PageData } = $props() let { data }: { data: PageData } = $props()
@ -13,28 +15,74 @@
const description = $derived( const description = $derived(
post?.content post?.content
? getContentExcerpt(post.content, 160) ? getContentExcerpt(post.content, 160)
: `${post?.postType === 'essay' ? 'Essay' : 'Post'} by jedmund` : `${post?.postType === 'essay' ? 'Essay' : 'Post'} by @jedmund`
)
const pageUrl = $derived($page.url.href)
// Generate metadata
const metaTags = $derived(
post
? generateMetaTags({
title: pageTitle,
description,
url: pageUrl,
type: 'article',
image: post.attachments?.[0]?.url,
publishedTime: post.publishedAt,
author: 'Justin Edmund',
section: post.postType === 'essay' ? 'Essays' : 'Posts',
titleFormat:
post.postType === 'essay' ? { type: 'by' } : { type: 'snippet', snippet: description }
})
: generateMetaTags({
title: 'Post Not Found',
description: 'The post you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate article JSON-LD
const articleJsonLd = $derived(
post
? generateArticleJsonLd({
title: pageTitle,
description,
url: pageUrl,
image: post.attachments?.[0]?.url,
datePublished: post.publishedAt,
dateModified: post.updatedAt || post.publishedAt,
author: 'Justin Edmund'
})
: null
) )
</script> </script>
<svelte:head> <svelte:head>
{#if post} <title>{metaTags.title}</title>
<title>{pageTitle} - jedmund</title> <meta name="description" content={metaTags.description} />
<meta name="description" content={description} />
<!-- Open Graph meta tags --> <!-- OpenGraph -->
<meta property="og:title" content={pageTitle} /> {#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:description" content={description} /> <meta property="og:{property}" {content} />
<meta property="og:type" content="article" /> {/each}
{#if post.attachments && post.attachments.length > 0}
<meta property="og:image" content={post.attachments[0].url} /> <!-- 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} {/if}
<!-- Article meta --> <!-- JSON-LD -->
<meta property="article:published_time" content={post.publishedAt} /> {#if articleJsonLd}
<meta property="article:author" content="jedmund" /> {@html `<script type="application/ld+json">${JSON.stringify(articleJsonLd)}</script>`}
{:else}
<title>Post Not Found - jedmund</title>
{/if} {/if}
</svelte:head> </svelte:head>

View file

@ -4,6 +4,8 @@
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte' import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte' import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
import ProjectContent from '$lib/components/ProjectContent.svelte' import ProjectContent from '$lib/components/ProjectContent.svelte'
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types' import type { PageData } from './$types'
import type { Project } from '$lib/types/project' import type { Project } from '$lib/types/project'
import { spring } from 'svelte/motion' import { spring } from 'svelte/motion'
@ -12,6 +14,42 @@
const project = $derived(data.project as Project | null) const project = $derived(data.project as Project | null)
const error = $derived(data.error as string | undefined) const error = $derived(data.error as string | undefined)
const pageUrl = $derived($page.url.href)
// Generate metadata
const metaTags = $derived(
project
? generateMetaTags({
title: project.title,
description:
project.description || `${project.title} — A professional project by Justin Edmund`,
url: pageUrl,
image: project.thumbnailUrl || project.logoUrl,
type: 'article',
titleFormat: { type: 'by' }
})
: generateMetaTags({
title: 'Project Not Found',
description: 'The project you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate creative work JSON-LD
const projectJsonLd = $derived(
project
? generateCreativeWorkJsonLd({
name: project.title,
description: project.description,
url: pageUrl,
image: project.thumbnailUrl || project.logoUrl,
creator: 'Justin Edmund',
dateCreated: project.year ? `${project.year}-01-01` : undefined,
keywords: project.tags || []
})
: null
)
let headerContainer = $state<HTMLElement | null>(null) let headerContainer = $state<HTMLElement | null>(null)
@ -50,6 +88,34 @@
} }
</script> </script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Other meta tags -->
{#if metaTags.other.canonical}
<link rel="canonical" href={metaTags.other.canonical} />
{/if}
{#if metaTags.other.robots}
<meta name="robots" content={metaTags.other.robots} />
{/if}
<!-- JSON-LD -->
{#if projectJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
{/if}
</svelte:head>
{#if error} {#if error}
<div class="error-container"> <div class="error-container">
<Page> <Page>