Skip to content

Commit 6c7ddd1

Browse files
committed
Update Head component with client folder, refactor structured data JSON-LD output with unit tests and strong typing, add e2e tests for structured-data
1 parent cc23cd6 commit 6c7ddd1

15 files changed

Lines changed: 1048 additions & 389 deletions

File tree

src/components/Head/Meta.astro

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
import contactData from '@content/contact.json'
3-
import themes from '@content/themes.json'
3+
import themeConfig from '@content/themes.json'
44
import { absoluteUrl } from '@components/scripts/utils'
55
import Seo from '@components/Head/Seo.astro'
66
import Social from '@components/Head/Social.astro'
@@ -34,7 +34,13 @@ const {
3434
noindex = false,
3535
} = Astro.props
3636
37-
const defaultTheme = themes.default
37+
const defaultThemeId = themeConfig.defaultTheme.id
38+
const defaultTheme =
39+
themeConfig.themes.find(theme => theme.id === defaultThemeId) || themeConfig.themes[0]
40+
41+
if (!defaultTheme) {
42+
throw new Error('No themes configured in themes.json; unable to set meta theme-color.')
43+
}
3844
const slug = path.replace(/^\//, '').replace(/\/$/, '') || 'home'
3945
---
4046

src/components/Head/Social.astro

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ export interface Props {
1111
1212
const { title, description, slug, image } = Astro.props
1313
14-
// Filter out undefined values to work with exactOptionalPropertyTypes
15-
const options: SocialMetadataOptions = { url: Astro.url.href }
14+
if (!(Astro.site instanceof URL)) {
15+
throw new Error('Astro.site must be configured to generate social metadata.')
16+
}
17+
18+
const options: SocialMetadataOptions = { url: Astro.url.href, baseUrl: Astro.site.origin }
1619
if (title !== undefined) options.title = title
1720
if (description !== undefined) options.description = description
1821
if (slug !== undefined) options.slug = slug
Lines changed: 16 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,31 @@
11
---
2-
import contactData from '@content/contact.json'
3-
import { absoluteUrl } from '@components/scripts/utils/absoluteUrl'
4-
5-
export interface Props {
6-
path: string
7-
pageTitle: string
8-
description?: string
9-
contentType?: 'article' | 'website'
10-
publishDate?: Date
11-
modifiedDate?: Date
12-
author?: string
13-
image?: string
14-
}
2+
import { getSchemas } from '@components/Head/server/structuredData'
3+
export type { StructuredDataProps as Props } from '@components/Head/server/structuredData'
154
165
const {
176
path,
187
pageTitle,
198
description,
20-
contentType = 'website',
9+
contentType,
2110
publishDate,
2211
modifiedDate,
2312
author,
2413
image,
2514
} = Astro.props
2615
27-
const baseUrl = Astro.site?.href ?? 'https://webstackbuilders.com'
28-
const currentUrl = absoluteUrl(path, Astro.site)
29-
30-
// Organization schema - always included
31-
const organizationSchema = {
32-
'@context': 'https://schema.org',
33-
'@type': 'Organization',
34-
'name': contactData.company.name,
35-
'url': contactData.company.url,
36-
'logo': absoluteUrl('/public/images/logos/company-card.png', Astro.site),
37-
'description': contactData.company.description,
38-
'email': contactData.company.email,
39-
'address': {
40-
'@type': 'PostalAddress',
41-
'addressLocality': contactData.company.city,
42-
'addressRegion': contactData.company.state,
43-
'addressCountry': contactData.company.country,
44-
},
45-
'sameAs': contactData.company.social.map(social => social.url),
46-
}
47-
48-
// WebSite schema - include on homepage
49-
const webSiteSchema = path === '/' || path === '' ? {
50-
'@context': 'https://schema.org',
51-
'@type': 'WebSite',
52-
'name': contactData.company.name,
53-
'url': contactData.company.url,
54-
'description': contactData.company.description,
55-
'publisher': {
56-
'@type': 'Organization',
57-
'name': contactData.company.name,
58-
'logo': {
59-
'@type': 'ImageObject',
60-
'url': absoluteUrl('icon-512.png', Astro.site),
61-
},
62-
},
63-
} : null
64-
65-
// Article schema - for blog posts
66-
const articleSchema = contentType === 'article' && publishDate ? {
67-
'@context': 'https://schema.org',
68-
'@type': 'Article',
69-
'headline': pageTitle,
70-
'description': description || contactData.company.description,
71-
'datePublished': publishDate.toISOString(),
72-
'dateModified': modifiedDate?.toISOString() || publishDate.toISOString(),
73-
'author': {
74-
'@type': 'Person',
75-
'name': author || contactData.company.author.name,
76-
},
77-
'publisher': {
78-
'@type': 'Organization',
79-
'name': contactData.company.name,
80-
'logo': {
81-
'@type': 'ImageObject',
82-
'url': absoluteUrl('icon-512.png', Astro.site),
83-
},
84-
},
85-
'url': currentUrl,
86-
...(image && { 'image': absoluteUrl(image, Astro.site) }),
87-
} : null
88-
89-
// BreadcrumbList schema - for deep pages (2+ levels)
90-
const pathSegments = path.split('/').filter(Boolean)
91-
const breadcrumbSchema = pathSegments.length >= 2 ? {
92-
'@context': 'https://schema.org',
93-
'@type': 'BreadcrumbList',
94-
'itemListElement': [
95-
{
96-
'@type': 'ListItem',
97-
'position': 1,
98-
'name': 'Home',
99-
'item': baseUrl,
100-
},
101-
...pathSegments.slice(0, -1).map((segment, index) => ({
102-
'@type': 'ListItem',
103-
'position': index + 2,
104-
'name': segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' '),
105-
'item': absoluteUrl(pathSegments.slice(0, index + 1).join('/'), Astro.site),
106-
})),
107-
],
108-
} : null
109-
110-
// Service schema - for service pages
111-
const serviceSchema = path.startsWith('/services/') && path !== '/services' && path !== '/services/' ? {
112-
'@context': 'https://schema.org',
113-
'@type': 'Service',
114-
'name': pageTitle,
115-
'description': description || contactData.company.description,
116-
'provider': {
117-
'@type': 'Organization',
118-
'name': contactData.company.name,
119-
'url': contactData.company.url,
120-
},
121-
'url': currentUrl,
122-
} : null
123-
124-
// ContactPage schema - for contact page
125-
const contactPageSchema = path === '/contact' || path === '/contact/' ? {
126-
'@context': 'https://schema.org',
127-
'@type': 'ContactPage',
128-
'name': pageTitle,
129-
'description': description || contactData.company.description,
130-
'url': currentUrl,
131-
'mainEntity': {
132-
'@type': 'Organization',
133-
'name': contactData.company.name,
134-
'email': contactData.company.email,
135-
'url': contactData.company.url,
136-
},
137-
} : null
138-
139-
// Collect all schemas
140-
const schemas = [
141-
organizationSchema,
142-
webSiteSchema,
143-
articleSchema,
144-
breadcrumbSchema,
145-
serviceSchema,
146-
contactPageSchema,
147-
].filter(Boolean)
16+
const schemas = getSchemas({
17+
astro: Astro,
18+
path,
19+
pageTitle,
20+
...(description && { description }),
21+
...(contentType && { contentType }),
22+
...(publishDate && { publishDate }),
23+
...(modifiedDate && { modifiedDate }),
24+
...(author && { author }),
25+
...(image && { image }),
26+
})
14827
---
14928

150-
{schemas.map((schema) => (
151-
<script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />
29+
{schemas.map(schema => (
30+
<script is:inline type="application/ld+json" set:html={schema} />
15231
))}

src/components/Head/ThemeInit.astro

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@
88
* CRITICAL: This script MUST be inlined (is:inline) and run as early as possible
99
* to prevent Flash of Unstyled Content (FOUC).
1010
*
11-
* !!! DO NOT EXPAND THE SCOPE OF THIS SCRIPT !!!
11+
* !!! DO NOT EXPAND THE SCOPE OF THIS SCRIPT OR MOVE OUT OF THIS FILE !!!
1212
*
1313
* It runs synchronously and blocks rendering.
1414
*/
15+
import { themeData } from './server/themeData'
16+
17+
const { defaultThemeIdJson, darkThemeIdJson, metaColorsJson } = themeData()
1518
---
1619

17-
<script is:inline>
20+
<script
21+
is:inline
22+
define:vars={{
23+
defaultThemeIdJson,
24+
darkThemeIdJson,
25+
metaColorsJson,
26+
}}
27+
>
28+
// @ts-nocheck
1829
document.addEventListener('DOMContentLoaded', () => {
1930
/**
2031
* This code is only for initial page load from the site. DOMContentLoaded does
@@ -23,39 +34,50 @@
2334
* "astro:before-swap" event.
2435
*/
2536
try {
37+
const defaultThemeId = JSON.parse(defaultThemeIdJson)
38+
const darkThemeId = JSON.parse(darkThemeIdJson)
39+
const themeMetaColors = JSON.parse(metaColorsJson)
40+
2641
// Read theme preference from localStorage (set by user's previous selection)
2742
const stored = localStorage.getItem('theme')
2843

29-
if (stored && stored !== 'light') {
44+
if (stored && stored !== defaultThemeId) {
3045
// User explicitly chose a theme - apply it immediately
3146
// <html> element
3247
document.documentElement.dataset['theme'] = stored
3348
} else if (!stored) {
3449
// No stored preference - use system preference
3550
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
3651
// <html> element
37-
document.documentElement.dataset['theme'] = prefersDark ? 'dark' : 'light'
52+
document.documentElement.dataset['theme'] = prefersDark ? darkThemeId : defaultThemeId
3853
}
3954

4055
// 2. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC. Do
4156
// here to make sure it gets turned on, in case the <meta> element selector throws.
4257
document.body.classList.remove('invisible')
4358

44-
// 3. Update meta theme-color used for PWAs
59+
// 3. Add theme entries from src/content/themes.json to the window.metaColors object
60+
window.metaColors = window.metaColors || {}
61+
Object.assign(window.metaColors, themeMetaColors)
62+
63+
// 4. Update meta theme-color used for PWAs
4564
const metaElement = document.querySelector('meta[name="theme-color"]')
46-
if (stored && stored !== 'light' && metaElement && window.metaColors) {
65+
if (stored && stored !== defaultThemeId && metaElement && window.metaColors) {
4766
// User explicitly chose a theme - apply it immediately
4867
metaElement.setAttribute('content', window.metaColors[stored] || '')
4968
} else if (!stored && metaElement && window.metaColors) {
5069
// No stored preference - use system preference
5170
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
52-
metaElement.setAttribute('content', window.metaColors[prefersDark ? 'dark' : 'light'] || '')
71+
metaElement.setAttribute(
72+
'content',
73+
window.metaColors[prefersDark ? darkThemeId : defaultThemeId] || ''
74+
)
5375
}
5476

55-
// 4. If stored === 'light', BaseLayout.astro already set it on <html> data-theme attribute
77+
// 5. If stored === defaultThemeId, BaseLayout.astro already set it on <html> data-theme attribute
5678
// and Meta.astro already set it on <meta name="theme-color">, so nothing to do
5779

58-
// 5. Success!
80+
// 6. Success!
5981
console.log('🎨 Theme init on "DOMContentLoaded" executed')
6082
} catch (error) {
6183
// localStorage access can fail (privacy mode, etc.)

src/components/Head/client.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { StructuredDataParams } from '../structuredData'
2+
3+
const DEFAULT_SITE_URL = 'https://webstackbuilders.com'
4+
5+
const normalizeFixturePath = (value: string): string => {
6+
if (value === '') {
7+
return '/'
8+
}
9+
10+
return value.startsWith('/') ? value : `/${value}`
11+
}
12+
13+
export const createStructuredDataParams = (
14+
overrides: Partial<StructuredDataParams> = {}
15+
): StructuredDataParams => {
16+
const path = normalizeFixturePath(overrides.path ?? '/')
17+
const site = overrides.astro?.site ?? new URL(DEFAULT_SITE_URL)
18+
const url = overrides.astro?.url ?? new URL(path, site)
19+
20+
const params: StructuredDataParams = {
21+
astro: overrides.astro ?? { site, url },
22+
path,
23+
pageTitle: overrides.pageTitle ?? 'Example Page Title',
24+
}
25+
26+
if (overrides.description !== undefined) {
27+
params.description = overrides.description
28+
}
29+
if (overrides.contentType !== undefined) {
30+
params.contentType = overrides.contentType
31+
}
32+
if (overrides.publishDate !== undefined) {
33+
params.publishDate = overrides.publishDate
34+
}
35+
if (overrides.modifiedDate !== undefined) {
36+
params.modifiedDate = overrides.modifiedDate
37+
}
38+
if (overrides.author !== undefined) {
39+
params.author = overrides.author
40+
}
41+
if (overrides.image !== undefined) {
42+
params.image = overrides.image
43+
}
44+
45+
return params
46+
}

0 commit comments

Comments
 (0)