Initial commit: Rapport Website (Hugo + Hextra)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
const WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag22aa"];
|
||||
// TODO: Re-enable once known baseline issues are resolved and tracked.
|
||||
const DISABLED_RULES = ["color-contrast", "target-size"];
|
||||
const EXCLUDED_SELECTORS = [
|
||||
// Third-party player internals are outside the theme's control and can change
|
||||
// independently, while the iframe element itself remains covered by page HTML.
|
||||
"iframe[src*=\"youtube.com/embed\"]",
|
||||
"iframe[src*=\"youtube-nocookie.com/embed\"]",
|
||||
];
|
||||
|
||||
type Violation = Awaited<
|
||||
ReturnType<InstanceType<typeof AxeBuilder>["analyze"]>
|
||||
>["violations"][number];
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function parseLocUrlsFromSitemap(xml: string): string[] {
|
||||
const locRegex = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
|
||||
const urls: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = locRegex.exec(xml)) !== null) {
|
||||
urls.push(decodeXmlEntities(match[1]));
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function getEnglishPages(baseURL: string): Promise<string[]> {
|
||||
const sitemapUrl = `${baseURL}/en/sitemap.xml`;
|
||||
const response = await fetch(sitemapUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch sitemap (${response.status} ${response.statusText}) at ${sitemapUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const pages = parseLocUrlsFromSitemap(xml)
|
||||
.map((url) => {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
});
|
||||
|
||||
if (pages.length === 0) {
|
||||
throw new Error(`Sitemap at ${sitemapUrl} returned no URLs.`);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function formatViolation(v: Violation): string {
|
||||
return `• ${v.id} (${v.impact}) — ${v.nodes.length} element(s)\n ${v.help}\n ${v.helpUrl}`;
|
||||
}
|
||||
|
||||
test("all English pages pass axe-core WCAG AA", async ({ page, baseURL }) => {
|
||||
const pages = await getEnglishPages(baseURL!);
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const path of pages) {
|
||||
await test.step(path, async () => {
|
||||
await page.goto(path, { waitUntil: "load" });
|
||||
|
||||
const axe = new AxeBuilder({ page })
|
||||
.withTags(WCAG_TAGS)
|
||||
.disableRules(DISABLED_RULES);
|
||||
|
||||
for (const selector of EXCLUDED_SELECTORS) {
|
||||
axe.exclude(selector);
|
||||
}
|
||||
|
||||
const results = await axe.analyze();
|
||||
|
||||
if (results.violations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
failures.push(
|
||||
`--- ${path} ---\n${results.violations
|
||||
.map(formatViolation)
|
||||
.join("\n\n")}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
failures,
|
||||
`Accessibility violations found:\n\n${failures.join("\n\n")}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("clicking mobile hamburger does not focus the sidebar search input", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
|
||||
const menuButton = page.locator(".hextra-hamburger-menu");
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
const sidebarSearchInput = page.locator(".hextra-sidebar-container .hextra-search-input").first();
|
||||
await expect(sidebarSearchInput).toBeVisible();
|
||||
|
||||
await menuButton.click();
|
||||
|
||||
await expect(menuButton).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(sidebarSearchInput).not.toBeFocused();
|
||||
});
|
||||
|
||||
test("mobile sidebar exposes main menu dropdown children", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
|
||||
await page.getByRole("button", { name: "Menu" }).click();
|
||||
|
||||
const sidebar = page.locator("aside.hextra-sidebar-container");
|
||||
await expect(sidebar.getByRole("link", { name: "Development ↗" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "v0.10 ↗" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "v0.11 ↗" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile sidebar uses localized page titles for zh-cn docs navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/zh-cn/", { waitUntil: "load" });
|
||||
|
||||
await page.locator(".hextra-hamburger-menu").click();
|
||||
|
||||
const sidebar = page.locator("aside.hextra-sidebar-container");
|
||||
const gettingStarted = sidebar.locator('a[href="/zh-cn/docs/getting-started/"]');
|
||||
const guide = sidebar.locator('a[href="/zh-cn/docs/guide/"]');
|
||||
const organizeFiles = sidebar.locator('a[href="/zh-cn/docs/guide/organize-files/"]');
|
||||
|
||||
await expect(gettingStarted).toBeVisible();
|
||||
await expect(gettingStarted).toHaveText("快速开始");
|
||||
await expect(guide).toBeVisible();
|
||||
await expect(guide).toHaveText("指南");
|
||||
await expect(organizeFiles).toBeVisible();
|
||||
await expect(organizeFiles).toHaveText("文件组织");
|
||||
await expect(gettingStarted).not.toHaveText("Getting Started");
|
||||
await expect(guide).not.toHaveText("Guide");
|
||||
});
|
||||
|
||||
test("mobile sidebar falls back to content tree when main menu has no eligible entries", async ({
|
||||
page,
|
||||
}) => {
|
||||
const siteDir = mkdtempSync(join(tmpdir(), "hextra-mobile-menu-"));
|
||||
const contentDir = join(siteDir, "content");
|
||||
const publishDir = join(siteDir, "public");
|
||||
const themesDir = join(siteDir, "themes");
|
||||
|
||||
mkdirSync(join(contentDir, "docs"), { recursive: true });
|
||||
mkdirSync(join(contentDir, "donate"), { recursive: true });
|
||||
mkdirSync(themesDir);
|
||||
symlinkSync(process.cwd(), join(themesDir, "hextra"), "dir");
|
||||
writeFileSync(
|
||||
join(siteDir, "hugo.yaml"),
|
||||
`title: Test
|
||||
theme: hextra
|
||||
menu:
|
||||
main:
|
||||
- name: Donate
|
||||
pageRef: /donate
|
||||
weight: 1
|
||||
- name: Search
|
||||
weight: 2
|
||||
params:
|
||||
type: search
|
||||
- name: GitHub
|
||||
weight: 3
|
||||
url: "https://github.com/imfing/hextra"
|
||||
params:
|
||||
icon: github
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(contentDir, "_index.md"),
|
||||
`---
|
||||
title: Home
|
||||
cascade:
|
||||
type: docs
|
||||
---
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(contentDir, "docs", "_index.md"),
|
||||
`---
|
||||
title: Docs
|
||||
---
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(contentDir, "docs", "getting-started.md"),
|
||||
`---
|
||||
title: Getting Started
|
||||
---
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(contentDir, "donate", "index.md"),
|
||||
`---
|
||||
title: Donate
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
`,
|
||||
);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"hugo",
|
||||
[
|
||||
"--source",
|
||||
siteDir,
|
||||
"--themesDir",
|
||||
themesDir,
|
||||
"--destination",
|
||||
publishDir,
|
||||
],
|
||||
{ cwd: process.cwd(), stdio: "pipe" },
|
||||
);
|
||||
|
||||
const html = readFileSync(join(publishDir, "index.html"), "utf8");
|
||||
await page.setContent(html);
|
||||
|
||||
const mobileSidebar = page
|
||||
.locator("aside.hextra-sidebar-container ul")
|
||||
.filter({ has: page.locator('a[href="/docs/"]') })
|
||||
.first();
|
||||
|
||||
await expect(mobileSidebar.locator('a[href="/docs/"]')).toHaveText("Docs");
|
||||
await expect(
|
||||
mobileSidebar.locator('a[href="/docs/getting-started/"]'),
|
||||
).toHaveText("Getting Started");
|
||||
} finally {
|
||||
rmSync(siteDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user