Skip to content

Commit 8f6de71

Browse files
authored
Merge pull request #690 from webstackdev/feature/seo
Articles point to deep dive, exclude downloads and list views from se…
2 parents 5905fbc + bb173ff commit 8f6de71

20 files changed

Lines changed: 493 additions & 194 deletions

File tree

package-lock.json

Lines changed: 217 additions & 171 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
"astro": "^6.3.3",
144144
"astro-og-canvas": "0.11.1",
145145
"astro-vtbot": "2.1.12",
146-
"baseline-browser-mapping": "2.10.29",
146+
"baseline-browser-mapping": "2.10.30",
147147
"canvas-confetti": "1.9.4",
148148
"confusing-browser-globals": "1.0.11",
149149
"cross-env": "10.1.0",
@@ -154,13 +154,13 @@
154154
"email-validator": "2.0.4",
155155
"embla-carousel": "8.6.0",
156156
"embla-carousel-autoplay": "8.6.0",
157-
"eslint": "10.3.0",
157+
"eslint": "10.4.0",
158158
"eslint-plugin-astro": "1.7.0",
159159
"eslint-plugin-import-lite": "0.6.0",
160160
"eslint-plugin-jsdoc": "62.9.0",
161161
"eslint-plugin-security": "4.0.0",
162162
"eslint-plugin-yml": "3.3.2",
163-
"focus-trap": "8.2.0",
163+
"focus-trap": "8.2.1",
164164
"globals": "17.6.0",
165165
"gsap": "3.15.0",
166166
"happy-dom": "20.9.0",
@@ -171,10 +171,10 @@
171171
"husky": "9.1.7",
172172
"install": "0.13.0",
173173
"is-whitespace-character": "2.0.1",
174-
"isomorphic-git": "1.37.6",
175-
"js-cookie": "3.0.5",
174+
"isomorphic-git": "1.38.0",
175+
"js-cookie": "3.0.7",
176176
"jsdom": "29.1.1",
177-
"libphonenumber-js": "1.13.1",
177+
"libphonenumber-js": "1.13.2",
178178
"lighthouse": "13.3.0",
179179
"lit": "3.3.3",
180180
"markdownlint-cli2": "0.22.1",
@@ -190,11 +190,11 @@
190190
"playwright-lighthouse": "4.0.0",
191191
"postcss": "8.5.14",
192192
"postcss-html": "1.8.1",
193-
"preact": "10.29.1",
193+
"preact": "10.29.2",
194194
"prettier": "3.8.3",
195195
"prettier-plugin-astro": "0.14.1",
196196
"pubsub-js": "1.9.5",
197-
"puppeteer": "24.43.1",
197+
"puppeteer": "25.0.2",
198198
"qr-code-styling": "1.9.2",
199199
"rehype-accessible-emojis": "0.3.2",
200200
"rehype-external-links": "3.0.0",
@@ -246,7 +246,7 @@
246246
"unist-util-is": "6.0.1",
247247
"unist-util-visit": "5.1.0",
248248
"uuid": "14.0.0",
249-
"vercel": "54.0.0",
249+
"vercel": "54.1.0",
250250
"vite": "7.3.2",
251251
"vite-plugin-pwa": "1.3.0",
252252
"vitest": "4.1.6",

src/components/Content/Layout/index.astro

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type Props = {
1919
author: string
2020
/** Content collection entry */
2121
article: CollectionEntry<'articles'> | CollectionEntry<'deepDives'>
22+
/** Canonical URL path when it differs from the rendered page path */
23+
canonicalPath?: string
2224
/** Canonical URL path, e.g. '/articles/my-slug' */
2325
path: string
2426
/** Estimated reading time label */
@@ -27,11 +29,12 @@ export type Props = {
2729
section: string
2830
}
2931
30-
const { author, article, path, readingTime, section } = Astro.props
32+
const { author, article, canonicalPath, path, readingTime, section } = Astro.props
3133
---
3234

3335
<MarkdownLayout
3436
author={author}
37+
{...canonicalPath && { canonicalPath }}
3538
collectionItem={article}
3639
contentType="article"
3740
description={article.data.description}

src/components/Head/Meta.astro

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import StructuredData from '@components/Head/StructuredData.astro'
1111
export interface Props {
1212
/** Author name */
1313
author?: string
14+
/** Canonical URL path when it differs from the rendered page path */
15+
canonicalPath?: string
1416
/** Content type for OpenGraph metadata */
1517
contentType?: 'article' | 'website'
1618
/** Meta description */
@@ -33,6 +35,7 @@ export interface Props {
3335
3436
const {
3537
author,
38+
canonicalPath,
3639
contentType,
3740
pageDescription,
3841
modifiedDate,
@@ -46,6 +49,7 @@ const {
4649
4750
const defaultTheme = getMetaThemeData()
4851
const site = resolveSiteUrl(Astro)
52+
const canonicalUrl = absoluteUrl(canonicalPath ?? path, site)
4953
5054
applyRenderSentryContext({
5155
contextName: 'headMeta',
@@ -61,7 +65,7 @@ applyRenderSentryContext({
6165
noindex,
6266
contentType,
6367
pageDescriptionLength: (pageDescription || companyContactData.description).length,
64-
canonicalUrl: Astro.url?.href,
68+
canonicalUrl,
6569
resolvedSiteUrl: site,
6670
},
6771
})
@@ -91,6 +95,7 @@ applyRenderSentryContext({
9195
<StructuredData
9296
path={path}
9397
pageTitle={pageTitle}
98+
{...canonicalPath && { canonicalPath }}
9499
{...pageDescription && { pageDescription }}
95100
{...contentType && { contentType }}
96101
{...publishDate && { publishDate }}
@@ -127,6 +132,6 @@ applyRenderSentryContext({
127132
{/* Webmentions */}
128133
<link rel="pingback" href={`https://webmention.io/www.webstackbuilders.com/xmlrpc`} />
129134
<link rel="webmention" href={`https://webmention.io/www.webstackbuilders.com/webmention`} />
130-
<link rel="canonical" href={Astro.url.href} />
135+
<link rel="canonical" href={canonicalUrl} />
131136
{/* Sitemap */}
132137
<link rel="sitemap" href="/sitemap-index.xml" />

src/components/Head/StructuredData.astro

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { getSchemas } from '@components/Head/server/structuredData'
33
import { applyRenderSentryContext } from '@lib/sentry/renderContext'
44
export type { StructuredDataProps as Props } from '@components/Head/server/structuredData'
55
6-
const { path, pageTitle, pageDescription, contentType, publishDate, modifiedDate, author } =
7-
Astro.props
6+
const {
7+
path,
8+
canonicalPath,
9+
pageTitle,
10+
pageDescription,
11+
contentType,
12+
publishDate,
13+
modifiedDate,
14+
author,
15+
} = Astro.props
816
917
applyRenderSentryContext({
1018
contextName: 'structuredDataRender',
@@ -30,6 +38,7 @@ applyRenderSentryContext({
3038
const schemas = getSchemas({
3139
astro: Astro,
3240
path,
41+
...(canonicalPath && { canonicalPath }),
3342
pageTitle,
3443
...(pageDescription && { pageDescription }),
3544
...(contentType && { contentType }),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { beforeEach, describe, expect, test } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import { JSDOM } from 'jsdom'
4+
5+
describe('Meta (Astro)', () => {
6+
let container: AstroContainer
7+
8+
beforeEach(async () => {
9+
container = await AstroContainer.create()
10+
})
11+
12+
test('renders canonical link from canonicalPath when provided', async () => {
13+
const Meta = (await import('@components/Head/Meta.astro')).default
14+
15+
const response = await container.renderToResponse(Meta, {
16+
props: {
17+
pageTitle: 'Example Article',
18+
pageDescription: 'Example description',
19+
path: '/articles/example-article',
20+
canonicalPath: '/deep-dive/example-article',
21+
},
22+
request: new Request('https://example.com/articles/example-article'),
23+
partial: false,
24+
})
25+
26+
const renderedHtml = await response.text()
27+
const document = new JSDOM(renderedHtml).window.document
28+
29+
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
30+
'https://example.com/deep-dive/example-article'
31+
)
32+
})
33+
})

src/components/Head/index.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import ThemeInit from '@components/Head/ThemeInit.astro'
55
export interface Props {
66
/** Author name */
77
author?: string
8+
/** Canonical URL path when it differs from the rendered page path */
9+
canonicalPath?: string
810
/** Content type for OpenGraph metadata */
911
contentType?: 'article' | 'website'
1012
/** Meta description */
@@ -27,6 +29,7 @@ export interface Props {
2729
2830
const {
2931
author,
32+
canonicalPath,
3033
contentType,
3134
pageDescription,
3235
modifiedDate,
@@ -48,6 +51,7 @@ const {
4851
<Meta
4952
pageTitle={pageTitle}
5053
path={path}
54+
{...canonicalPath && { canonicalPath }}
5155
{...pageDescription && { pageDescription }}
5256
{...noindex && { noindex }}
5357
{...contentType && { contentType }}

src/components/Head/server/__fixtures__/structuredData.fixture.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export const createStructuredDataParams = (
2727
if (overrides.pageDescription !== undefined) {
2828
params.pageDescription = overrides.pageDescription
2929
}
30+
if (overrides.canonicalPath !== undefined) {
31+
params.canonicalPath = overrides.canonicalPath
32+
}
3033
if (overrides.contentType !== undefined) {
3134
params.contentType = overrides.contentType
3235
}

src/components/Head/server/__tests__/structuredData.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,26 @@ describe('getSchemas', () => {
3535
expect(contactPage?.url).toBe('https://www.webstackbuilders.com/contact')
3636
})
3737

38+
it('uses canonicalPath for article schema urls while keeping breadcrumbs on the rendered path', () => {
39+
const schemas = getSchemas(
40+
createStructuredDataParams({
41+
path: '/articles/example-article',
42+
canonicalPath: '/deep-dive/example-article',
43+
contentType: 'article',
44+
publishDate: new Date('2026-01-01T00:00:00.000Z'),
45+
})
46+
)
47+
48+
const parsed = schemas.map(schema => JSON.parse(schema))
49+
const article = parsed.find(schema => schema['@type'] === 'Article')
50+
const breadcrumbs = parsed.find(schema => schema['@type'] === 'BreadcrumbList')
51+
52+
expect(article?.url).toBe('https://www.webstackbuilders.com/deep-dive/example-article')
53+
expect(breadcrumbs?.itemListElement?.[1]?.item).toBe(
54+
'https://www.webstackbuilders.com/articles'
55+
)
56+
})
57+
3858
it('wraps serialization failures in a BuildError', () => {
3959
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
4060
throw new TypeError('circular structure')

src/components/Head/server/structuredData.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const ICON_PATH = '/icon-512.png'
2121
type ContentType = 'article' | 'website'
2222

2323
export interface StructuredDataProps {
24+
canonicalPath?: string
2425
path: string
2526
pageTitle: string
2627
pageDescription?: string
@@ -66,6 +67,7 @@ export const getSchemas = (params: StructuredDataParams): string[] => {
6667
const createSchemaContext = (params: StructuredDataParams): SchemaContext => {
6768
const {
6869
astro,
70+
canonicalPath,
6971
path,
7072
pageTitle,
7173
pageDescription,
@@ -95,7 +97,8 @@ const createSchemaContext = (params: StructuredDataParams): SchemaContext => {
9597

9698
const site = resolveSiteUrl(astro)
9799
const normalizedPath = normalizePath(path)
98-
const canonicalUrl = astro.url?.href ?? resolveRoute(normalizedPath, site)
100+
const normalizedCanonicalPath = normalizePath(canonicalPath ?? normalizedPath)
101+
const canonicalUrl = resolveRoute(normalizedCanonicalPath, site)
99102
const pageDescriptionFallback = pageDescription ?? companyContactData.description
100103
const socialImageUrl = getSocialImageLink(path, site)
101104

0 commit comments

Comments
 (0)