From 1e9cc3ba48f28a5c3ed4c5199f2994277ce8fe33 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 4 Aug 2026 16:46:37 +0300 Subject: [PATCH] Fix Sentry error in switcher script --- .../Switcher/client/__tests__/index.spec.ts | 128 +++++++++++++++++- .../Content/Switcher/client/index.ts | 85 +++++++++--- src/components/Content/Switcher/index.astro | 20 ++- .../Switcher/server/__tests__/index.spec.ts | 46 ++++++- .../Content/Switcher/server/index.ts | 29 ++++ .../scripts/sentry/__tests__/helpers.spec.ts | 127 +++++++++++++++++ src/components/scripts/sentry/helpers.ts | 62 +++++++++ src/content/clients/digest-engine.mdx | 43 ++++++ 8 files changed, 514 insertions(+), 26 deletions(-) create mode 100644 src/content/clients/digest-engine.mdx diff --git a/src/components/Content/Switcher/client/__tests__/index.spec.ts b/src/components/Content/Switcher/client/__tests__/index.spec.ts index 3a2af4c0..88a5206e 100644 --- a/src/components/Content/Switcher/client/__tests__/index.spec.ts +++ b/src/components/Content/Switcher/client/__tests__/index.spec.ts @@ -5,7 +5,7 @@ import { experimental_AstroContainer as AstroContainer } from 'astro/container' import SwitcherAstro from '@components/Content/Switcher/index.astro' import type { ContentSwitcherElement } from '@components/Content/Switcher/client' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' -import { executeRender } from '@test/unit/helpers/litRuntime' +import { executeRender, type RenderResult } from '@test/unit/helpers/litRuntime' type SwitcherModule = WebComponentModule @@ -18,7 +18,10 @@ describe('ContentSwitcherElement', () => { const runComponentRender = async ( args: Parameters[1], - assertion: (_context: { element: ContentSwitcherElement }) => Promise | void + assertion: (_context: { + element: ContentSwitcherElement + renderResult: RenderResult + }) => Promise | void ): Promise => { await executeRender({ container, @@ -30,7 +33,7 @@ describe('ContentSwitcherElement', () => { }, assert: async ({ element, module, renderResult }) => { expect(renderResult).toContain(`<${module.registeredName}`) - await assertion({ element }) + await assertion({ element, renderResult }) }, }) } @@ -118,4 +121,123 @@ describe('ContentSwitcherElement', () => { } ) }) + + test('disables the deep-dive label and toggle when the alternate variant is missing', async () => { + await runComponentRender( + { + props: { + currentVariant: 'overview', + slug: 'article-without-deep-dive', + hasAlternate: false, + }, + }, + async ({ element }) => { + const overviewLink = element.querySelector('#overview-label') as HTMLAnchorElement | null + const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null + const switchLink = element.querySelector( + '[data-switcher-toggle]' + ) as HTMLAnchorElement | null + + expect(overviewLink?.getAttribute('href')).toBe('/articles/article-without-deep-dive') + expect(overviewLink?.getAttribute('aria-disabled')).toBe('false') + expect(deepDiveLink?.getAttribute('href')).toBeNull() + expect(deepDiveLink?.getAttribute('aria-disabled')).toBe('true') + expect(switchLink?.getAttribute('href')).toBeNull() + expect(switchLink?.getAttribute('aria-disabled')).toBe('true') + } + ) + }) + + test('disables the overview label when the alternate variant is missing on deep-dive pages', async () => { + await runComponentRender( + { + props: { + currentVariant: 'deep-dive', + slug: 'deep-dive-without-overview', + hasAlternate: false, + }, + }, + async ({ element }) => { + const overviewLink = element.querySelector('#overview-label') as HTMLAnchorElement | null + const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null + const switchLink = element.querySelector( + '[data-switcher-toggle]' + ) as HTMLAnchorElement | null + + expect(deepDiveLink?.getAttribute('href')).toBe('/deep-dive/deep-dive-without-overview') + expect(deepDiveLink?.getAttribute('aria-disabled')).toBe('false') + expect(overviewLink?.getAttribute('href')).toBeNull() + expect(overviewLink?.getAttribute('aria-disabled')).toBe('true') + expect(switchLink?.getAttribute('href')).toBeNull() + expect(switchLink?.getAttribute('aria-disabled')).toBe('true') + } + ) + }) + + test('does not prefetch the alternate variant href when it is missing', async () => { + await runComponentRender( + { + props: { + currentVariant: 'overview', + slug: 'article-without-deep-dive', + hasAlternate: false, + }, + }, + async () => { + await new Promise(resolve => setTimeout(resolve, 200)) + + const prefetchLink = document.head.querySelector( + 'link[rel="prefetch"][href="/deep-dive/article-without-deep-dive"]' + ) as HTMLLinkElement | null + + expect(prefetchLink).toBeNull() + } + ) + }) + + test('renders has-alternate="false" for articles without a deep-dive entry', async () => { + await runComponentRender( + { + props: { + path: '/articles/kubernetes-pod-disruption-budget-autoscaler-node-rotation', + }, + }, + async ({ element, renderResult }) => { + expect(renderResult).toContain('has-alternate="false"') + + const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null + const switchLink = element.querySelector( + '[data-switcher-toggle]' + ) as HTMLAnchorElement | null + + expect(deepDiveLink?.getAttribute('href')).toBeNull() + expect(switchLink?.getAttribute('href')).toBeNull() + } + ) + }) + + test('renders the has-alternate attribute for articles with a deep-dive entry', async () => { + await runComponentRender( + { + props: { + path: '/articles/reverse-engineering-documentation-legacy-systems', + }, + }, + async ({ element, renderResult }) => { + expect(renderResult).toContain('has-alternate="true"') + + const deepDiveLink = element.querySelector('#deep-dive-label') as HTMLAnchorElement | null + const switchLink = element.querySelector( + '[data-switcher-toggle]' + ) as HTMLAnchorElement | null + + expect(deepDiveLink?.getAttribute('href')).toBe( + '/deep-dive/reverse-engineering-documentation-legacy-systems' + ) + expect(switchLink?.getAttribute('href')).toBe( + '/deep-dive/reverse-engineering-documentation-legacy-systems' + ) + } + ) + }) }) diff --git a/src/components/Content/Switcher/client/index.ts b/src/components/Content/Switcher/client/index.ts index 8ca1074d..c78888d2 100644 --- a/src/components/Content/Switcher/client/index.ts +++ b/src/components/Content/Switcher/client/index.ts @@ -1,4 +1,5 @@ import { LitElement, html } from 'lit' +import { ifDefined } from 'lit/directives/if-defined.js' import { defineCustomElement } from '@components/scripts/utils' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { queryPrefetchLink } from './selectors' @@ -7,6 +8,8 @@ export type ContentVariant = 'overview' | 'deep-dive' const prefetchedHrefs = new Set() +const disabledLinkClasses = 'pointer-events-none cursor-not-allowed opacity-50' + const normalizeSlug = (slug: string): string => slug.replace(/^\/+|\/+$/g, '') const queueIdlePrefetch = (work: () => void): void => { @@ -82,15 +85,29 @@ export class ContentSwitcherElement extends LitElement { static override properties = { currentVariant: { type: String, attribute: 'current-variant' }, slug: { type: String }, + hasAlternate: { + type: Boolean, + attribute: 'has-alternate', + /** + * Astro renders boolean attributes as has-alternate="true" or + * has-alternate="false", so the default Lit converter (attribute + * presence) is not sufficient on its own. + */ + converter: { + fromAttribute: (value: string | null) => value !== null && value !== 'false', + }, + }, } declare currentVariant: ContentVariant declare slug: string + declare hasAlternate: boolean constructor() { super() this.currentVariant = 'overview' this.slug = '' + this.hasAlternate = false } protected override createRenderRoot() { @@ -109,6 +126,10 @@ export class ContentSwitcherElement extends LitElement { } private prefetchAlternateVariant(): void { + if (!this.hasAlternate) { + return + } + const alternateVariant = this.currentVariant === 'deep-dive' ? 'overview' : 'deep-dive' const href = buildVariantHref(alternateVariant, this.slug) @@ -123,34 +144,54 @@ export class ContentSwitcherElement extends LitElement { const deepDiveHref = buildVariantHref('deep-dive', this.slug) const switchHref = isDeepDive ? overviewHref : deepDiveHref + /** + * When the alternate variant route does not exist, its label link and the + * toggle are rendered inert so visitors cannot navigate to a 404 page. + */ + const overviewLinkDisabled = !this.hasAlternate && isDeepDive + const deepDiveLinkDisabled = !this.hasAlternate && !isDeepDive + const commonLabelClasses = 'text-xs no-underline hover:no-underline focus-visible:no-underline' - const overviewLabelClass = isDeepDive - ? 'content-switcher-label content-switcher-label--overview text-content-offset hover:text-content' + - ' ' + - commonLabelClasses - : 'content-switcher-label content-switcher-label--overview text-page-inverse' + - ' ' + - commonLabelClasses - - const deepDiveLabelClass = isDeepDive - ? 'content-switcher-label content-switcher-label--deep-dive text-page-inverse' + - ' ' + - commonLabelClasses - : 'content-switcher-label content-switcher-label--deep-dive text-content-offset hover:text-content' + - ' ' + - commonLabelClasses + const overviewLabelClass = + (isDeepDive + ? 'content-switcher-label content-switcher-label--overview text-content-offset hover:text-content' + + ' ' + + commonLabelClasses + : 'content-switcher-label content-switcher-label--overview text-page-inverse' + + ' ' + + commonLabelClasses) + (overviewLinkDisabled ? ' ' + disabledLinkClasses : '') + + const deepDiveLabelClass = + (isDeepDive + ? 'content-switcher-label content-switcher-label--deep-dive text-page-inverse' + + ' ' + + commonLabelClasses + : 'content-switcher-label content-switcher-label--deep-dive text-content-offset hover:text-content' + + ' ' + + commonLabelClasses) + (deepDiveLinkDisabled ? ' ' + disabledLinkClasses : '') + + const switchTrackClass = + 'content-switcher-track group relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-page-inverse transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary' + + (this.hasAlternate ? '' : ' ' + disabledLinkClasses) return html` ` } diff --git a/src/components/Content/Switcher/index.astro b/src/components/Content/Switcher/index.astro index e183d229..40d97960 100644 --- a/src/components/Content/Switcher/index.astro +++ b/src/components/Content/Switcher/index.astro @@ -1,5 +1,5 @@ --- -import { parseContentPath } from './server' +import { contentPathHasAlternateVariant, parseContentPath } from './server' import { BuildError } from '@lib/errors/BuildError' import './index.css' @@ -10,20 +10,30 @@ export type Props = { currentVariant?: 'overview' | 'deep-dive' /** Legacy slug for client rendering */ slug?: string + /** Legacy override for whether the alternate variant route exists */ + hasAlternate?: boolean } -const { path, currentVariant: legacyCurrentVariant, slug: legacySlug = '' } = Astro.props +const { + path, + currentVariant: legacyCurrentVariant, + slug: legacySlug = '', + hasAlternate: legacyHasAlternate, +} = Astro.props let currentVariant: 'overview' | 'deep-dive' let slug: string +let hasAlternateVariant: boolean if (path) { const parsed = parseContentPath(path) currentVariant = parsed.currentVariant === 'articles' ? 'overview' : 'deep-dive' slug = parsed.slug + hasAlternateVariant = await contentPathHasAlternateVariant(path) } else if (legacyCurrentVariant) { currentVariant = legacyCurrentVariant slug = legacySlug + hasAlternateVariant = legacyHasAlternate ?? true } else { throw new BuildError('Content/Switcher: expected either `path` or `currentVariant` prop.', { phase: 'compilation', @@ -33,7 +43,11 @@ if (path) { } --- -