Go<\/a><\/p>/
+ );
+ assert.match(html, /Bad<\/p>/);
+ assert.doesNotMatch(html, /javascript:/);
+ },
+ },
];
diff --git a/test/browser/docs.spec.mjs b/test/browser/docs.spec.mjs
index 4d02496..50f29b1 100644
--- a/test/browser/docs.spec.mjs
+++ b/test/browser/docs.spec.mjs
@@ -281,8 +281,11 @@ test('callouts, GitHub alerts, and code fence metadata render accessibly', async
const highlighted = page.locator('[class*="codeLineHighlighted"]');
await expect(highlighted).toHaveCount(3);
await expect(highlighted.first()).toContainText('@vitejs/plugin-react');
- await expect(page.locator('[data-line-kind="added"]')).toHaveCount(1);
- await expect(page.locator('[data-line-kind="removed"]')).toHaveCount(1);
+ const packageDiff = page
+ .locator('[data-code-title]', { hasText: /^package\.json$/ })
+ .locator('xpath=../..');
+ await expect(packageDiff.locator('[data-line-kind="added"]')).toHaveCount(1);
+ await expect(packageDiff.locator('[data-line-kind="removed"]')).toHaveCount(1);
await assertNoRuntimeErrors(errors);
});
@@ -370,3 +373,113 @@ test('doc routes ship a per-page social preview image', async ({ page, request }
/\/images\/og\/docs\/getting-started\/introduction\.svg$/
);
});
+
+test('collapsible callouts open from the keyboard', async ({ page }) => {
+ const errors = collectRuntimeErrors(page);
+ await page.goto('/docs/developer-guides/callouts-and-code', { waitUntil: 'networkidle' });
+ const details = page.locator('details.admonition-details').first();
+ await expect(details).not.toHaveAttribute('open', '');
+ await expect(details.locator('.admonition-content')).toBeHidden();
+ await details.locator('summary').focus();
+ await page.keyboard.press('Enter');
+ await expect(details.locator('.admonition-content')).toBeVisible();
+ await assertNoRuntimeErrors(errors);
+});
+
+test('code blocks support word highlights, diff colours, and a line-number toggle', async ({
+ page,
+}) => {
+ const errors = collectRuntimeErrors(page);
+ await page.goto('/docs/developer-guides/callouts-and-code', { waitUntil: 'networkidle' });
+ await expect(page.locator('mark.code-word-highlight', { hasText: 'apiKey' })).toHaveCount(2);
+ await expect(page.locator('mark.code-word-highlight', { hasText: 'fetchDocs' })).toHaveCount(1);
+
+ const diffTs = page
+ .locator('[data-code-title]', { hasText: /^greet\.ts$/ })
+ .locator('xpath=../..');
+ await expect(diffTs.locator('[data-line-kind="added"]')).toHaveCount(1);
+ // diff-ts is tokenized as TypeScript, not as plain diff text.
+ await expect(diffTs.locator('.token', { hasText: /^function$/ })).toHaveCount(1);
+
+ const plain = page
+ .locator('[data-code-title]', { hasText: 'src/greet.ts' })
+ .locator('xpath=../..');
+ await expect(plain.locator('.linenumber').first()).toBeVisible();
+ const toggle = plain.getByRole('button', { name: 'Hide line numbers' });
+ await toggle.click();
+ await expect(plain.locator('.linenumber')).toHaveCount(0);
+ await expect(plain.getByRole('button', { name: 'Show line numbers' })).toHaveAttribute(
+ 'aria-pressed',
+ 'false'
+ );
+ await assertNoRuntimeErrors(errors);
+});
+
+test('MDX nested code blocks are interactive and MDX tabs sync with Markdown tabs', async ({
+ page,
+}) => {
+ const errors = collectRuntimeErrors(page);
+ await page.goto('/docs/developer-guides/callouts-in-mdx', { waitUntil: 'networkidle' });
+ const callout = page.getByRole('note', { name: 'Install command' });
+ await expect(callout.locator('[data-code-title]', { hasText: 'install.sh' })).toBeVisible();
+ await expect(callout.getByRole('button', { name: 'Copy to clipboard' })).toBeVisible();
+
+ const tabs = page.locator('.papers-tabs[data-tabs-group="pkg"]');
+ await tabs.getByRole('tab', { name: 'pnpm', exact: true }).click();
+ await expect(tabs.getByRole('tabpanel')).toContainText('pnpm dev');
+ await expect(tabs.getByRole('button', { name: 'Copy to clipboard' })).toBeVisible();
+
+ await page.goto('/docs/developer-guides/callouts-and-code', { waitUntil: 'networkidle' });
+ await expect(
+ page
+ .locator('.md-tabs[data-tabs-group="pkg"]')
+ .first()
+ .getByRole('tab', { name: 'pnpm', exact: true })
+ ).toHaveAttribute('aria-selected', 'true');
+ await assertNoRuntimeErrors(errors);
+});
+
+test('steps and cards render in Markdown and MDX', async ({ page }) => {
+ const errors = collectRuntimeErrors(page);
+ await page.goto('/docs/developer-guides/steps-and-cards', { waitUntil: 'networkidle' });
+ await expect(page.locator('.papers-steps > ol > li')).toHaveCount(3);
+ await expect(
+ page.locator('.papers-steps').getByRole('button', { name: 'Copy to clipboard' })
+ ).toHaveCount(2);
+ const card = page.locator('.papers-card-grid .papers-card').first();
+ await card.getByRole('link', { name: 'Callouts and code' }).click();
+ await expect(page).toHaveURL(/\/docs\/developer-guides\/callouts-and-code$/);
+
+ await page.goto('/docs/developer-guides/callouts-in-mdx', { waitUntil: 'networkidle' });
+ await expect(page.locator('.papers-steps > ol > li')).toHaveCount(2);
+ await expect(page.locator('.papers-card-grid .papers-card')).toHaveCount(2);
+ await assertNoRuntimeErrors(errors);
+});
+
+test('Pagefind index supports section filters for docs: and api: scopes', async ({ page }) => {
+ await page.goto('/docs/getting-started/introduction', { waitUntil: 'networkidle' });
+ await page.getByRole('button', { name: /Search docs/i }).click();
+ await page
+ .getByPlaceholder('Search documentation or type a command...')
+ .fill('docs: content tabs');
+ await expect(page.getByRole('button', { name: /Callouts And Code Blocks/i }).first()).toBeVisible(
+ {
+ timeout: 15_000,
+ }
+ );
+ const counts = await page.evaluate(async () => {
+ const pagefind = window.pagefind;
+ const docs = await pagefind.search('character', { filters: { section: 'docs' } });
+ const api = await pagefind.search('character', { filters: { section: 'api' } });
+ const apiOnly = await pagefind.search('get', { filters: { section: 'api' } });
+ const apiData = await Promise.all(apiOnly.results.slice(0, 5).map((result) => result.data()));
+ return {
+ docs: docs.results.length,
+ api: api.results.length,
+ apiKinds: Array.from(new Set(apiData.map((data) => data.meta.kind))),
+ };
+ });
+ expect(counts.docs).toBeGreaterThan(0);
+ expect(counts.api).toBe(0);
+ for (const kind of counts.apiKinds) expect(kind).toMatch(/^openapi-/);
+});
diff --git a/test/docs-parity.test.ts b/test/docs-parity.test.ts
index a61058f..357ed20 100644
--- a/test/docs-parity.test.ts
+++ b/test/docs-parity.test.ts
@@ -7,6 +7,21 @@ import {
} from '../scripts/lib/lastUpdated.mjs';
import { DEFAULT_OG_IMAGE_PATH, socialImagePathForRoute } from '../shared/seo.js';
+import {
+ buildSearchFilters,
+ parseSearchScope,
+ rankSearchResults,
+ titleBoost,
+} from '../src/lib/searchRanking.ts';
+import { createPagefindRecords } from '../scripts/lib/pagefindArtifacts.mjs';
+
+import {
+ DEFAULT_UI_STRINGS,
+ resolveUiStrings,
+ validateUiStringsConfig,
+} from '../shared/uiStrings.js';
+import { uiStringsConfig, i18nConfig } from '../shared/documentation-config.js';
+
type TestCase = { name: string; run: () => void | Promise };
export const docsParityTests: TestCase[] = [
@@ -76,4 +91,105 @@ export const docsParityTests: TestCase[] = [
assert.equal(socialImagePathForRoute('https://x.test/docs'), DEFAULT_OG_IMAGE_PATH);
},
},
+ {
+ name: 'parseSearchScope reads docs: and api: prefixes',
+ run: () => {
+ assert.deepEqual(parseSearchScope(' api: users '), { query: 'users', section: 'api' });
+ assert.deepEqual(parseSearchScope('DOCS:tabs'), { query: 'tabs', section: 'docs' });
+ assert.deepEqual(parseSearchScope('tabs api:'), { query: 'tabs api:', section: null });
+ },
+ },
+ {
+ name: 'buildSearchFilters keeps API results unless scoped, and pins docs to the variant',
+ run: () => {
+ assert.deepEqual(buildSearchFilters({ section: null }), {});
+ assert.deepEqual(buildSearchFilters({ section: 'api', version: 'v2' }), { section: 'api' });
+ assert.deepEqual(buildSearchFilters({ section: 'docs', locale: 'fr' }), {
+ section: 'docs',
+ locale: 'fr',
+ });
+ assert.deepEqual(buildSearchFilters({ section: null, version: 'v2', locale: 'fr' }), {
+ any: [{ section: 'docs', version: 'v2', locale: 'fr' }, { section: 'api' }],
+ });
+ },
+ },
+ {
+ name: 'rankSearchResults boosts title matches and keeps ties stable',
+ run: () => {
+ assert.equal(titleBoost('Content Tabs', 'content tabs'), 3);
+ assert.equal(titleBoost('Content Tabs Guide', 'content tabs'), 2.5);
+ assert.equal(titleBoost('Guide to tabs and content', 'content tabs'), 2);
+ assert.equal(titleBoost('Tabs', 'content tabs'), 1.25);
+ assert.equal(titleBoost('Other', 'content tabs'), 1);
+ const ranked = rankSearchResults(
+ [
+ { score: 1, meta: { title: 'Install' } },
+ { score: 0.6, meta: { title: 'Content tabs' } },
+ { score: 1, meta: { title: 'Deploy' } },
+ ],
+ 'content tabs'
+ );
+ assert.deepEqual(
+ ranked.map((result) => result.meta.title),
+ ['Content tabs', 'Install', 'Deploy']
+ );
+ },
+ },
+ {
+ name: 'Pagefind records carry section, collection, version, and locale filters',
+ run: () => {
+ const records = createPagefindRecords({
+ collections: [{ id: 'docs', label: 'Docs', routePrefix: 'docs' }],
+ artifactsByCollectionId: {
+ docs: {
+ index: { paths: ['a'], titles: { a: 'A' } },
+ documents: { a: { title: 'A', content: '# A\n\nBody' } },
+ },
+ },
+ openapiArtifacts: {
+ specs: [
+ {
+ operations: [{ method: 'GET', path: '/x', summary: 'X', routePath: '/docs/x' }],
+ schemas: [],
+ },
+ ],
+ },
+ });
+ const doc = records.find((record) => record.meta.kind === 'documentation');
+ const api = records.find((record) => record.meta.kind === 'openapi-operation');
+ assert.deepEqual(doc?.filters, { section: ['docs'], collection: ['docs'] });
+ assert.deepEqual(api?.filters, { section: ['api'] });
+ },
+ },
+ {
+ name: 'resolveUiStrings merges valid per-locale overrides over English defaults',
+ run: () => {
+ const config = {
+ fr: { previous: 'Précédent', next: '', bogus: 'x' } as Record,
+ };
+ const fr = resolveUiStrings('fr', config);
+ assert.equal(fr.previous, 'Précédent');
+ assert.equal(fr.next, DEFAULT_UI_STRINGS.next);
+ assert.equal((fr as Record).bogus, undefined);
+ assert.deepEqual(resolveUiStrings('de', config), DEFAULT_UI_STRINGS);
+ assert.deepEqual(resolveUiStrings(null, config), DEFAULT_UI_STRINGS);
+ },
+ },
+ {
+ name: 'validateUiStringsConfig reports unknown locales, keys, and empty values',
+ run: () => {
+ assert.deepEqual(
+ validateUiStringsConfig({ fr: { previous: 'P', nope: 'x', next: ' ' }, de: {} }, [
+ 'en',
+ 'fr',
+ ]),
+ [
+ 'uiStringsConfig.fr.nope: unknown label',
+ 'uiStringsConfig.fr.next: must be a non-empty string',
+ 'uiStringsConfig.de: locale is not in i18nConfig.locales',
+ ]
+ );
+ assert.deepEqual(validateUiStringsConfig(uiStringsConfig, i18nConfig.locales), []);
+ },
+ },
];
diff --git a/types/shared-code-fence.d.ts b/types/shared-code-fence.d.ts
index 1c1ecd7..e5ae365 100644
--- a/types/shared-code-fence.d.ts
+++ b/types/shared-code-fence.d.ts
@@ -2,6 +2,8 @@ declare module '*/shared/codeFence.js' {
export interface CodeFenceMeta {
title?: string;
highlightLines?: number[];
+ highlightWords?: string[];
+ showLineNumbers?: boolean;
}
export interface CodeFenceInfo extends CodeFenceMeta {
@@ -13,5 +15,10 @@ declare module '*/shared/codeFence.js' {
export function parseHighlightLines(spec: string): number[];
export function parseCodeFenceMeta(meta: string): CodeFenceMeta;
export function parseCodeFenceInfo(info: string): CodeFenceInfo;
+ export function parseDiffLanguage(language: string): {
+ isDiff: boolean;
+ syntaxLanguage: string;
+ };
export function getDiffLineKind(line: string, isDiff: boolean): CodeLineKind;
+ export function sanitizeCardHref(href: string | undefined): string | null;
}
diff --git a/types/shared-ui-strings.d.ts b/types/shared-ui-strings.d.ts
new file mode 100644
index 0000000..6a0550a
--- /dev/null
+++ b/types/shared-ui-strings.d.ts
@@ -0,0 +1,29 @@
+declare module '*/shared/uiStrings.js' {
+ export type UiStrings = {
+ readonly previous: string;
+ readonly next: string;
+ readonly adjacentPages: string;
+ readonly lastUpdated: string;
+ readonly edit: string;
+ readonly issue: string;
+ readonly source: string;
+ readonly pageSourceActions: string;
+ readonly searchDocs: string;
+ readonly searchPlaceholder: string;
+ readonly noResults: string;
+ readonly copyCode: string;
+ readonly copied: string;
+ readonly showLineNumbers: string;
+ readonly hideLineNumbers: string;
+ readonly referencedBy: string;
+ };
+ export type UiStringKey = keyof UiStrings;
+ export type UiStringsConfig = Record>>;
+ export const DEFAULT_UI_STRINGS: UiStrings;
+ export const UI_STRING_KEYS: readonly UiStringKey[];
+ export function resolveUiStrings(
+ locale: string | null | undefined,
+ overridesByLocale?: UiStringsConfig
+ ): UiStrings;
+ export function validateUiStringsConfig(config: unknown, locales?: string[]): string[];
+}