Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
388 changes: 217 additions & 171 deletions package-lock.json

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"astro": "^6.3.3",
"astro-og-canvas": "0.11.1",
"astro-vtbot": "2.1.12",
"baseline-browser-mapping": "2.10.29",
"baseline-browser-mapping": "2.10.30",
"canvas-confetti": "1.9.4",
"confusing-browser-globals": "1.0.11",
"cross-env": "10.1.0",
Expand All @@ -154,13 +154,13 @@
"email-validator": "2.0.4",
"embla-carousel": "8.6.0",
"embla-carousel-autoplay": "8.6.0",
"eslint": "10.3.0",
"eslint": "10.4.0",
"eslint-plugin-astro": "1.7.0",
"eslint-plugin-import-lite": "0.6.0",
"eslint-plugin-jsdoc": "62.9.0",
"eslint-plugin-security": "4.0.0",
"eslint-plugin-yml": "3.3.2",
"focus-trap": "8.2.0",
"focus-trap": "8.2.1",
"globals": "17.6.0",
"gsap": "3.15.0",
"happy-dom": "20.9.0",
Expand All @@ -171,10 +171,10 @@
"husky": "9.1.7",
"install": "0.13.0",
"is-whitespace-character": "2.0.1",
"isomorphic-git": "1.37.6",
"js-cookie": "3.0.5",
"isomorphic-git": "1.38.0",
"js-cookie": "3.0.7",
"jsdom": "29.1.1",
"libphonenumber-js": "1.13.1",
"libphonenumber-js": "1.13.2",
"lighthouse": "13.3.0",
"lit": "3.3.3",
"markdownlint-cli2": "0.22.1",
Expand All @@ -190,11 +190,11 @@
"playwright-lighthouse": "4.0.0",
"postcss": "8.5.14",
"postcss-html": "1.8.1",
"preact": "10.29.1",
"preact": "10.29.2",
"prettier": "3.8.3",
"prettier-plugin-astro": "0.14.1",
"pubsub-js": "1.9.5",
"puppeteer": "24.43.1",
"puppeteer": "25.0.2",
"qr-code-styling": "1.9.2",
"rehype-accessible-emojis": "0.3.2",
"rehype-external-links": "3.0.0",
Expand Down Expand Up @@ -246,7 +246,7 @@
"unist-util-is": "6.0.1",
"unist-util-visit": "5.1.0",
"uuid": "14.0.0",
"vercel": "54.0.0",
"vercel": "54.1.0",
"vite": "7.3.2",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.6",
Expand Down
5 changes: 4 additions & 1 deletion src/components/Content/Layout/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type Props = {
author: string
/** Content collection entry */
article: CollectionEntry<'articles'> | CollectionEntry<'deepDives'>
/** Canonical URL path when it differs from the rendered page path */
canonicalPath?: string
/** Canonical URL path, e.g. '/articles/my-slug' */
path: string
/** Estimated reading time label */
Expand All @@ -27,11 +29,12 @@ export type Props = {
section: string
}

const { author, article, path, readingTime, section } = Astro.props
const { author, article, canonicalPath, path, readingTime, section } = Astro.props
---

<MarkdownLayout
author={author}
{...canonicalPath && { canonicalPath }}
collectionItem={article}
contentType="article"
description={article.data.description}
Expand Down
9 changes: 7 additions & 2 deletions src/components/Head/Meta.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import StructuredData from '@components/Head/StructuredData.astro'
export interface Props {
/** Author name */
author?: string
/** Canonical URL path when it differs from the rendered page path */
canonicalPath?: string
/** Content type for OpenGraph metadata */
contentType?: 'article' | 'website'
/** Meta description */
Expand All @@ -33,6 +35,7 @@ export interface Props {

const {
author,
canonicalPath,
contentType,
pageDescription,
modifiedDate,
Expand All @@ -46,6 +49,7 @@ const {

const defaultTheme = getMetaThemeData()
const site = resolveSiteUrl(Astro)
const canonicalUrl = absoluteUrl(canonicalPath ?? path, site)

applyRenderSentryContext({
contextName: 'headMeta',
Expand All @@ -61,7 +65,7 @@ applyRenderSentryContext({
noindex,
contentType,
pageDescriptionLength: (pageDescription || companyContactData.description).length,
canonicalUrl: Astro.url?.href,
canonicalUrl,
resolvedSiteUrl: site,
},
})
Expand Down Expand Up @@ -91,6 +95,7 @@ applyRenderSentryContext({
<StructuredData
path={path}
pageTitle={pageTitle}
{...canonicalPath && { canonicalPath }}
{...pageDescription && { pageDescription }}
{...contentType && { contentType }}
{...publishDate && { publishDate }}
Expand Down Expand Up @@ -127,6 +132,6 @@ applyRenderSentryContext({
{/* Webmentions */}
<link rel="pingback" href={`https://webmention.io/www.webstackbuilders.com/xmlrpc`} />
<link rel="webmention" href={`https://webmention.io/www.webstackbuilders.com/webmention`} />
<link rel="canonical" href={Astro.url.href} />
<link rel="canonical" href={canonicalUrl} />
{/* Sitemap */}
<link rel="sitemap" href="/sitemap-index.xml" />
13 changes: 11 additions & 2 deletions src/components/Head/StructuredData.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import { getSchemas } from '@components/Head/server/structuredData'
import { applyRenderSentryContext } from '@lib/sentry/renderContext'
export type { StructuredDataProps as Props } from '@components/Head/server/structuredData'

const { path, pageTitle, pageDescription, contentType, publishDate, modifiedDate, author } =
Astro.props
const {
path,
canonicalPath,
pageTitle,
pageDescription,
contentType,
publishDate,
modifiedDate,
author,
} = Astro.props

applyRenderSentryContext({
contextName: 'structuredDataRender',
Expand All @@ -30,6 +38,7 @@ applyRenderSentryContext({
const schemas = getSchemas({
astro: Astro,
path,
...(canonicalPath && { canonicalPath }),
pageTitle,
...(pageDescription && { pageDescription }),
...(contentType && { contentType }),
Expand Down
33 changes: 33 additions & 0 deletions src/components/Head/__tests__/Meta.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, test } from 'vitest'
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import { JSDOM } from 'jsdom'

describe('Meta (Astro)', () => {
let container: AstroContainer

beforeEach(async () => {
container = await AstroContainer.create()
})

test('renders canonical link from canonicalPath when provided', async () => {
const Meta = (await import('@components/Head/Meta.astro')).default

const response = await container.renderToResponse(Meta, {
props: {
pageTitle: 'Example Article',
pageDescription: 'Example description',
path: '/articles/example-article',
canonicalPath: '/deep-dive/example-article',
},
request: new Request('https://example.com/articles/example-article'),
partial: false,
})

const renderedHtml = await response.text()
const document = new JSDOM(renderedHtml).window.document

expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
'https://example.com/deep-dive/example-article'
)
})
})
4 changes: 4 additions & 0 deletions src/components/Head/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import ThemeInit from '@components/Head/ThemeInit.astro'
export interface Props {
/** Author name */
author?: string
/** Canonical URL path when it differs from the rendered page path */
canonicalPath?: string
/** Content type for OpenGraph metadata */
contentType?: 'article' | 'website'
/** Meta description */
Expand All @@ -27,6 +29,7 @@ export interface Props {

const {
author,
canonicalPath,
contentType,
pageDescription,
modifiedDate,
Expand All @@ -48,6 +51,7 @@ const {
<Meta
pageTitle={pageTitle}
path={path}
{...canonicalPath && { canonicalPath }}
{...pageDescription && { pageDescription }}
{...noindex && { noindex }}
{...contentType && { contentType }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export const createStructuredDataParams = (
if (overrides.pageDescription !== undefined) {
params.pageDescription = overrides.pageDescription
}
if (overrides.canonicalPath !== undefined) {
params.canonicalPath = overrides.canonicalPath
}
if (overrides.contentType !== undefined) {
params.contentType = overrides.contentType
}
Expand Down
20 changes: 20 additions & 0 deletions src/components/Head/server/__tests__/structuredData.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ describe('getSchemas', () => {
expect(contactPage?.url).toBe('https://www.webstackbuilders.com/contact')
})

it('uses canonicalPath for article schema urls while keeping breadcrumbs on the rendered path', () => {
const schemas = getSchemas(
createStructuredDataParams({
path: '/articles/example-article',
canonicalPath: '/deep-dive/example-article',
contentType: 'article',
publishDate: new Date('2026-01-01T00:00:00.000Z'),
})
)

const parsed = schemas.map(schema => JSON.parse(schema))
const article = parsed.find(schema => schema['@type'] === 'Article')
const breadcrumbs = parsed.find(schema => schema['@type'] === 'BreadcrumbList')

expect(article?.url).toBe('https://www.webstackbuilders.com/deep-dive/example-article')
expect(breadcrumbs?.itemListElement?.[1]?.item).toBe(
'https://www.webstackbuilders.com/articles'
)
})

it('wraps serialization failures in a BuildError', () => {
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
throw new TypeError('circular structure')
Expand Down
5 changes: 4 additions & 1 deletion src/components/Head/server/structuredData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ICON_PATH = '/icon-512.png'
type ContentType = 'article' | 'website'

export interface StructuredDataProps {
canonicalPath?: string
path: string
pageTitle: string
pageDescription?: string
Expand Down Expand Up @@ -66,6 +67,7 @@ export const getSchemas = (params: StructuredDataParams): string[] => {
const createSchemaContext = (params: StructuredDataParams): SchemaContext => {
const {
astro,
canonicalPath,
path,
pageTitle,
pageDescription,
Expand Down Expand Up @@ -95,7 +97,8 @@ const createSchemaContext = (params: StructuredDataParams): SchemaContext => {

const site = resolveSiteUrl(astro)
const normalizedPath = normalizePath(path)
const canonicalUrl = astro.url?.href ?? resolveRoute(normalizedPath, site)
const normalizedCanonicalPath = normalizePath(canonicalPath ?? normalizedPath)
const canonicalUrl = resolveRoute(normalizedCanonicalPath, site)
const pageDescriptionFallback = pageDescription ?? companyContactData.description
const socialImageUrl = getSocialImageLink(path, site)

Expand Down
49 changes: 48 additions & 1 deletion src/components/Navigation/client/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ import type { WebComponentModule } from '@components/scripts/@types/webComponent
import { executeRender } from '@test/unit/helpers/litRuntime'

const setOverlayPauseStateMock = vi.fn()
const handleScriptErrorMock = vi.fn()

vi.mock('@components/scripts/store', () => ({
setOverlayPauseState: setOverlayPauseStateMock,
}))

const navigateMock = vi.fn()
vi.mock('@components/scripts/errors/handler', () => ({
handleScriptError: handleScriptErrorMock,
}))

const navigateMock = vi.fn(() => Promise.resolve())
const focusTrapMock = {
activate: vi.fn(),
deactivate: vi.fn(),
Expand Down Expand Up @@ -49,6 +54,8 @@ describe('NavigationElement web component behavior', () => {
beforeEach(async () => {
container = await AstroContainer.create()
navigateMock.mockClear()
navigateMock.mockImplementation(() => Promise.resolve())
handleScriptErrorMock.mockClear()
focusTrapMock.activate.mockClear()
focusTrapMock.deactivate.mockClear()
setOverlayPauseStateMock.mockClear()
Expand Down Expand Up @@ -138,6 +145,46 @@ describe('NavigationElement web component behavior', () => {
})
})

test('aborted view transition navigation rejections are ignored', async () => {
navigateMock.mockRejectedValueOnce(
Object.assign(new Error('Transition was aborted because of invalid state'), {
name: 'InvalidStateError',
})
)

await renderNavigation(async () => {
const firstLink = document.querySelector('.main-nav-menu a')
if (!isAnchorElement(firstLink)) {
throw new TestError('Navigation link not found')
}

firstLink.click()
await Promise.resolve()

expect(handleScriptErrorMock).not.toHaveBeenCalled()
})
})

test('unexpected navigation rejections are reported', async () => {
const navigationError = new Error('Navigation failed')
navigateMock.mockRejectedValueOnce(navigationError)

await renderNavigation(async () => {
const firstLink = document.querySelector('.main-nav-menu a')
if (!isAnchorElement(firstLink)) {
throw new TestError('Navigation link not found')
}

firstLink.click()
await Promise.resolve()

expect(handleScriptErrorMock).toHaveBeenCalledWith(navigationError, {
scriptName: 'NavigationElement',
operation: 'navigate',
})
})
})

test('focus trap activate/deactivate when menu toggles', async () => {
await renderNavigation(async () => {
const toggleButton = document.querySelector('.nav-toggle-btn')
Expand Down
25 changes: 24 additions & 1 deletion src/components/Navigation/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ export const ATTRIBUTES = {
navOpen: 'data-nav-open',
}

const ABORTED_VIEW_TRANSITION_MESSAGE = 'Transition was aborted because of invalid state'

const isAbortedViewTransitionError = (error: unknown): boolean => {
if (!(error instanceof Error)) {
return false
}

return (
error.name === 'InvalidStateError' &&
typeof error.message === 'string' &&
error.message.includes(ABORTED_VIEW_TRANSITION_MESSAGE)
)
}

export class NavigationElement extends LitElement {
static registeredName = 'site-navigation'

Expand Down Expand Up @@ -201,7 +215,16 @@ export class NavigationElement extends LitElement {
this.toggleMenu(false)
}

navigate(href)
void navigate(href).catch(error => {
if (isAbortedViewTransitionError(error)) {
return
}

handleScriptError(error, {
scriptName: SCRIPT_NAME,
operation: 'navigate',
})
})
})
} catch (error) {
handleScriptError(error, {
Expand Down
Loading
Loading