From c61fba2fceb1c5874b334fd3907d479e7278e1ae Mon Sep 17 00:00:00 2001 From: Tom Chen Date: Tue, 22 Sep 2026 12:29:57 +0200 Subject: [PATCH 1/3] Complete SEO metadata and fix keyboard accessibility --- .github/workflows/ci.yml | 3 +- astro.config.mjs | 4 ++ docs/dev/deploy.md | 18 +++-- lighthouserc.json | 8 +-- scripts/check-dist-links.mjs | 22 ++++-- scripts/sitemap-metadata.ts | 38 ++++++++++ src/components/Nav.astro | 18 +++++ src/components/Seo.astro | 7 +- src/islands/DailyKata.tsx | 13 ++-- src/islands/Flashcards.tsx | 8 ++- src/islands/Palette.tsx | 4 +- src/islands/Playground.tsx | 2 +- src/layouts/Base.astro | 2 + src/lib/og.ts | 6 +- src/lib/seo.ts | 28 +++++++- src/pages-shared/GlossaryTerm.astro | 2 +- src/pages-shared/Kata.astro | 9 +-- src/pages-shared/PracticeTrack.astro | 2 - src/pages-shared/Topic.astro | 4 +- src/styles/global.css | 14 ++++ tests/e2e/a11y.spec.ts | 100 ++++++++++++++++++++------- tests/e2e/keyboard.spec.ts | 71 +++++++++++++++++++ tests/e2e/practice-hub.spec.ts | 2 +- tests/e2e/seo.spec.ts | 45 +++++++++++- tests/unit/seo.test.ts | 41 +++++++++++ tests/unit/sitemap.test.ts | 39 +++++++++++ 26 files changed, 443 insertions(+), 67 deletions(-) create mode 100644 scripts/sitemap-metadata.ts create mode 100644 tests/e2e/keyboard.spec.ts create mode 100644 tests/unit/sitemap.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f8329..c344a5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,8 @@ jobs: exit 1 fi npx --yes wrangler@4.135.0 pages deploy dist --project-name codewiki --branch main --commit-hash "$GITHUB_SHA" --force - - if: failure() + - name: Upload validation reports + if: always() uses: actions/upload-artifact@v4 with: name: test-artifacts diff --git a/astro.config.mjs b/astro.config.mjs index 403cae2..73b4ed7 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,6 +6,7 @@ import preact from '@astrojs/preact'; import tailwindcss from '@tailwindcss/vite'; import pwa from './scripts/build-sw.mjs'; +import { contentModificationDates, sitemapMetadata } from './scripts/sitemap-metadata.ts'; import { LEGACY_REDIRECTS } from './src/data/redirects.ts'; import { rehypeCodebox } from './src/markdown/rehype-codebox.ts'; @@ -16,6 +17,8 @@ import { remarkDepth } from './src/markdown/remark-depth.ts'; import { rehypeSectionActions } from './src/markdown/rehype-section-actions.ts'; import { shikiMetaTransformer } from './src/markdown/shiki-meta.ts'; +let sitemapDates; + export default defineConfig({ site: 'https://codewiki.com', output: 'static', @@ -61,6 +64,7 @@ export default defineConfig({ preact(), sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en', zh: 'zh-Hans' } }, + serialize: (item) => sitemapMetadata(item, (sitemapDates ??= contentModificationDates())), // Settings is `noindex`, search is a query interface and the offline notice only ever // renders without a network: none belongs in the sitemap, and listing an unindexable URL is // a Search Console warning. A 301 is not a document either. diff --git a/docs/dev/deploy.md b/docs/dev/deploy.md index 5739b7b..c7f200c 100644 --- a/docs/dev/deploy.md +++ b/docs/dev/deploy.md @@ -117,11 +117,20 @@ other static host, serves for a directory request. ## Performance budgets +Page metadata is built in `src/lib/seo.ts`: canonical URLs, language alternates, Open Graph and +Twitter cards, and page-specific JSON-LD. Topics expose their reviewed date, section and tags to +Open Graph and their generated image to JSON-LD. Publication dates and author profiles are omitted +when the content has no reliable source for them. Reused sharing images describe the source card. +`scripts/sitemap-metadata.ts` uses content review/verification dates for topic and cheatsheet +`lastmod` values; it never substitutes the build date. Undated index pages remain undated. +The link checker also validates destinations in structured data. + `lighthouserc.json` drives `pnpm exec lhci autorun`, which builds nothing itself: it serves the -existing `dist` and audits ten URLs. The four P1 surfaces are the English home page, a track hub, +existing `dist` and audits eleven URLs. The four P1 surfaces are the English home page, a track hub, and the topic page in both languages. The P2 surfaces are Practice, Python from zero, Flashcards, -the Python cheatsheet, Prompt Builder and Playground. Lighthouse uses its default mobile -emulation. All four category scores are gated at 0.95. The transferred-script ceilings are 60 KiB +the Python cheatsheet, Prompt Builder and Playground, plus the JavaScript practice catalogue. Lighthouse uses its default mobile +emulation, with three runs per URL and median assertions. Performance is gated at 0.95; accessibility, +best practices and SEO must score 1.00. The transferred-script ceilings are 66 KiB for P1, 100 KiB for Practice, paths, Flashcards, cheatsheets and Prompt Builder, and 300 KiB for Playground. @@ -136,7 +145,8 @@ Three notes on that file, since JSON cannot carry comments: and 0.99 respectively, so the 0.95 performance gate remains unchanged. - **Accessibility is release-gated.** `color-contrast` and `label-content-name-mismatch` are Lighthouse errors, and the Playwright accessibility coverage checks the contrast-sensitive UI - directly. The earlier set of ten open contrast pairs has been corrected. + directly. Playwright checks all reported WCAG 2.2 AA and best-practice violations across both + languages and themes, plus keyboard focus, mobile menus and the open search dialog. - **`network-dependency-tree-insight` is `off`.** It scores zero for any critical request chain deeper than one hop, which the HTML → stylesheet → webfont chain of a self-hosted static site always has. diff --git a/lighthouserc.json b/lighthouserc.json index c9ed0d1..5b8519e 100644 --- a/lighthouserc.json +++ b/lighthouserc.json @@ -34,25 +34,25 @@ "categories:accessibility": [ "error", { - "minScore": 0.95 + "minScore": 1 } ], "categories:best-practices": [ "error", { - "minScore": 0.95 + "minScore": 1 } ], "categories:seo": [ "error", { - "minScore": 0.95 + "minScore": 1 } ], "color-contrast": "error", "forced-reflow-insight": ["warn", {}], "unused-css-rules": ["warn", {}], - "label-content-name-mismatch": "warn", + "label-content-name-mismatch": "error", "network-dependency-tree-insight": "off" } }, diff --git a/scripts/check-dist-links.mjs b/scripts/check-dist-links.mjs index 77dc2cd..9f67d80 100644 --- a/scripts/check-dist-links.mjs +++ b/scripts/check-dist-links.mjs @@ -2,7 +2,8 @@ * Dead-link check over the built site. * * Walks `dist/**\/*.html`, collects every same-origin `href` and `src` plus the URL-valued head - * metadata (`og:image`, `twitter:image`, canonical and hreflang alternates), and resolves each one + * metadata (`og:image`, `twitter:image`, canonical and hreflang alternates), JSON-LD destinations, + * and resolves each one * against `dist/` the way the deployed site does. `trailingSlash: 'always'` in astro.config.mjs * means a directory URL `/x/` is the file `dist/x/index.html`; a path that carries an extension * is the file itself. @@ -20,8 +21,6 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const DIST = path.join(ROOT, 'dist'); -const KNOWN_LATER = ['/java/type-erasure/', '/java/wildcards-pecs/']; - /** The canonical origin, read from src/data/site.ts so the two never drift. */ async function siteOrigin() { const source = await readFile(path.join(ROOT, 'src/data/site.ts'), 'utf8'); @@ -81,6 +80,21 @@ function metadataUrls(html) { if (href !== undefined) values.push(href); } } + // Structured breadcrumbs and article images must resolve just like visible links. Schema + // identifiers and search URL templates are not page destinations and are left alone. + const urlKeys = new Set(['url', 'item', 'image', 'mainEntityOfPage']); + function visit(value, key = '') { + if (typeof value === 'string' && urlKeys.has(key)) values.push(value); + else if (Array.isArray(value)) value.forEach((item) => visit(item, key)); + else if (value && typeof value === 'object') { + for (const [key, item] of Object.entries(value)) visit(item, key); + } + } + for (const match of html.matchAll( + /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, + )) { + visit(JSON.parse(match[1])); + } return values; } @@ -189,7 +203,7 @@ for (const page of pages) { if (!pathname || seen.has(pathname)) continue; seen.add(pathname); checked += 1; - if (!resolveTarget(pathname) && !KNOWN_LATER.includes(pathname)) { + if (!resolveTarget(pathname)) { failures.push({ page: `/${page}`, target: pathname }); } } diff --git a/scripts/sitemap-metadata.ts b/scripts/sitemap-metadata.ts new file mode 100644 index 0000000..f010c43 --- /dev/null +++ b/scripts/sitemap-metadata.ts @@ -0,0 +1,38 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import matter from 'gray-matter'; +import type { SitemapItem } from '@astrojs/sitemap'; + +/** Content dates, never the build time: rebuilding an unchanged page is not a content update. */ +export function contentModificationDates(root = process.cwd()): Map { + const dates = new Map(); + for (const collection of ['topics', 'cheatsheets']) { + const directory = path.join(root, 'src/content', collection); + for (const file of readdirSync(directory, { recursive: true, encoding: 'utf8' })) { + const match = file.replaceAll(path.sep, '/').match(/^(.*)\.(en|zh)\.mdx$/); + if (!match) continue; + const { data } = matter(readFileSync(path.join(directory, file), 'utf8')); + if (data.status !== 'reviewed') continue; + const modified = data.reviewed ?? data.verified?.date; + if (!modified) continue; + const date = new Date(modified); + if (Number.isNaN(date.getTime())) throw new Error(`Invalid sitemap date: ${file}`); + const prefix = match[2] === 'zh' ? '/zh' : ''; + const section = collection === 'cheatsheets' ? '/cheatsheets' : ''; + dates.set(`${prefix}${section}/${match[1]}/`, date.toISOString().slice(0, 10)); + } + } + return dates; +} + +export function sitemapMetadata(item: SitemapItem, dates: Map): SitemapItem { + const lastmod = dates.get(new URL(item.url).pathname); + const english = item.links?.find((link) => link.lang === 'en'); + return { + ...item, + ...(lastmod ? { lastmod } : {}), + ...(english && !item.links?.some((link) => link.lang === 'x-default') + ? { links: [...(item.links ?? []), { lang: 'x-default', url: english.url }] } + : {}), + }; +} diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 7cfac66..c8cc034 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -148,6 +148,24 @@ const homeHref = localizePath('/', locale); + +