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
9 changes: 7 additions & 2 deletions docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ const config = {
// On Netlify, scripts/netlify-build.mjs derives SITE_URL from the deploy context.
url: siteUrl,
baseUrl,
// Netlify serves every page under its trailing-slash URL and 301s the bare
// form. Generate the same shape, so the canonical URL and sitemap name the
// document that answers 200 rather than a redirect to it.
trailingSlash: true,

// Preserve broken-link enforcement; do not weaken.
onBrokenLinks: 'throw',
Expand Down Expand Up @@ -389,8 +393,9 @@ const config = {
sitemap: {
// /search is thin duplicate content; the plugin's own opt-out is
// emitted as property="robots", which neither Docusaurus nor a
// crawler honours.
ignorePatterns: ['/search'],
// crawler honours. The pattern is matched against the route after
// trailingSlash is applied, so it must cover `/search/`.
ignorePatterns: ['/search/**'],
},
}),
],
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/architecture-detail.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ test.describe('reference architecture detail pages', () => {
await page.goto('/architectures');
// The listing is rendered by src/components/ArchitectureFilters after
// hydration, so wait for the entry rather than reading the static HTML.
const link = page.locator(`a[href$="/architectures/${first.id}"]`).first();
const link = page.locator(`a[href$="/architectures/${first.id}/"]`).first();
await expect(link).toBeVisible();

await link.click();
Expand Down
10 changes: 6 additions & 4 deletions tests/e2e/blog-indexes.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test.describe('blog archive', () => {
// The link text is "<Month> <day> - <title>", so the accessible name is
// not the title alone; match on the href and assert the title is part of
// the rendered text.
const link = page.locator(`main a[href$="/blog/${post.slug}"]`);
const link = page.locator(`main a[href$="/blog/${post.slug}/"]`);

await expect(
link,
Expand All @@ -159,7 +159,7 @@ test.describe('blog archive', () => {
});

await expect(
section.locator(`a[href$="/blog/${post.slug}"]`),
section.locator(`a[href$="/blog/${post.slug}/"]`),
`${post.file} should be grouped under ${post.year}`,
).toHaveCount(1);
});
Expand Down Expand Up @@ -188,7 +188,9 @@ test.describe('blog authors index', () => {
test(`lists ${key} with a link to their page`, async ({ page }) => {
await gotoIndex(page, '/blog/authors');

const link = page.locator(`main a[href$="/blog/authors/${key}"]`).first();
const link = page
.locator(`main a[href$="/blog/authors/${key}/"]`)
.first();

await expect(
link,
Expand All @@ -212,7 +214,7 @@ test.describe('blog authors index', () => {
// link and their count badge.
const entry = page
.locator('main li')
.filter({ has: page.locator(`a[href$="/blog/authors/${key}"]`) })
.filter({ has: page.locator(`a[href$="/blog/authors/${key}/"]`) })
.first();

await expect(
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/blog.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ test.describe('blog index', () => {
// renders its title, so assert the href, not just the text.
await expect(heading.getByRole('link')).toHaveAttribute(
'href',
`/blog/${post.slug}`,
`/blog/${post.slug}/`,
);
}

Expand Down Expand Up @@ -216,7 +216,7 @@ test.describe('blog tag pages', () => {
const main = page.getByRole('main');
for (const tag of taggedPosts.keys()) {
await expect(
main.locator(`a[href="/blog/tags/${tag}"]`),
main.locator(`a[href="/blog/tags/${tag}/"]`),
`/blog/tags is missing a link to the "${tag}" tag`,
).toHaveCount(1);
}
Expand Down Expand Up @@ -249,7 +249,7 @@ test.describe('blog author bylines', () => {
// An author with a page is also linked from the byline.
if (author.page) {
await expect(
article.locator(`a[href="/blog/authors/${key}"]`).first(),
article.locator(`a[href="/blog/authors/${key}/"]`).first(),
).toBeVisible();
}
}
Expand Down
10 changes: 5 additions & 5 deletions tests/e2e/data-fixtures.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ describeCoverage('drifted upstream data', () => {
name: 'Reference architecture catalog',
});
const fallbackCard = catalogSection.locator(
`a[href$="/architectures/${noIndustries.id}"]`,
`a[href$="/architectures/${noIndustries.id}/"]`,
);
await expect(fallbackCard).toHaveCount(1);
await expect(
Expand All @@ -273,7 +273,7 @@ describeCoverage('drifted upstream data', () => {
// The populated arm on the same page is what makes this evidence that the
// fallback is the branch rendering, not that every eyebrow went generic.
const populatedCard = catalogSection.locator(
`a[href$="/architectures/${someIndustries.id}"]`,
`a[href$="/architectures/${someIndustries.id}/"]`,
);
await expect(populatedCard).toHaveCount(1);
await expect(
Expand Down Expand Up @@ -307,7 +307,7 @@ describeCoverage('drifted upstream data', () => {
});

const nullishCard = catalogSection.locator(
`a[href$="/architectures/${nullishAssets.id}"]`,
`a[href$="/architectures/${nullishAssets.id}/"]`,
);
await expect(nullishCard).toHaveCount(1);
// The card still renders, and renders the no-logo shape: the chain
Expand All @@ -320,15 +320,15 @@ describeCoverage('drifted upstream data', () => {
// The entered-but-unmatched arm renders the same no-logo shape from the
// other side of the chain, so neither arm can stand in for the other.
const unmatchedCard = catalogSection.locator(
`a[href$="/architectures/${unmatchedAssets.id}"]`,
`a[href$="/architectures/${unmatchedAssets.id}/"]`,
);
await expect(unmatchedCard).toHaveCount(1);
await expect(unmatchedCard.locator('img')).toHaveCount(0);

// The matched arm on the same page is what makes this evidence that the
// chain is resolving per entry, not that every card lost its logo.
const matchedCard = catalogSection.locator(
`a[href$="/architectures/${matchedAssets.id}"]`,
`a[href$="/architectures/${matchedAssets.id}/"]`,
);
await expect(matchedCard).toHaveCount(1);
await expect(matchedCard.locator('img')).toHaveCount(1);
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/search.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ test.describe('search page', () => {
test('a known term returns results that link to real pages', async ({
page,
}) => {
await page.goto(`/search?q=${KNOWN_TERM}`);
await page.goto(`/search/?q=${KNOWN_TERM}`);

const input = page.locator('input[type="search"][name="q"]');
await expect(input).toHaveValue(KNOWN_TERM);
Expand All @@ -76,7 +76,7 @@ test.describe('search page', () => {
test('a term that matches nothing shows the empty state', async ({
page,
}) => {
await page.goto(`/search?q=${NONSENSE_TERM}`);
await page.goto(`/search/?q=${NONSENSE_TERM}`);

await expect(page.getByText('No documents were found')).toBeVisible();
expect(await page.locator('article').count()).toBe(0);
Expand All @@ -85,7 +85,7 @@ test.describe('search page', () => {
test('typing in the search box updates the results in place', async ({
page,
}) => {
await page.goto(`/search?q=${NONSENSE_TERM}`);
await page.goto(`/search/?q=${NONSENSE_TERM}`);
await expect(page.getByText('No documents were found')).toBeVisible();

const input = page.locator('input[type="search"][name="q"]');
Expand Down
7 changes: 7 additions & 0 deletions tests/site-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ test('docs are served at the site root', () => {
assert.equal(config.presets[0][1].docs.routeBasePath, '/');
});

// Netlify answers 200 only on the trailing-slash URL and 301s the bare form.
// Without this setting every page's rel=canonical and 39 of the sitemap's
// entries named the redirecting URL rather than the document.
test('generated URLs carry the trailing slash the host serves', () => {
assert.equal(config.trailingSlash, true);
});

test('no two docs claim the same route', () => {
const collisions = [...docRoutes.entries()].filter(
([, files]) => files.length > 1,
Expand Down
21 changes: 15 additions & 6 deletions tests/sitemap-exclusions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,17 @@ function frontmatterValue(file, key) {
return match ? match[1].replace(/^['"]|['"]$/g, '') : undefined;
}

// Routes take the shape the plugin matches against: Docusaurus applies the
// site-wide trailingSlash setting to every route path before the sitemap
// plugin sees it, so with `trailingSlash: true` the search page is `/search/`
// and a bare `/search` pattern no longer covers it.
const trailingSlash = config.trailingSlash === true;

function normalizeRoute(route) {
const withoutFragment = route.split('#')[0].split('?')[0];
if (withoutFragment === '/' || withoutFragment === '') return '/';
return withoutFragment.replace(/\/+$/, '');
const bare = withoutFragment.replace(/\/+$/, '');
return trailingSlash ? `${bare}/` : bare;
}

// Mirrors the Docusaurus docs-plugin route derivation for routeBasePath '/':
Expand Down Expand Up @@ -141,14 +148,16 @@ test('patternProblem accepts the usable patterns and names the fault in the rest
);
});

test('matchesPattern spans separators for ** and stops at one for *', () => {
test('matchesPattern spans separators for ** and sees routes with the trailing slash', () => {
assert.ok(matchesPattern('/skills/**', '/skills/manifest'));
assert.ok(matchesPattern('/skills/**', '/skills/nested/deep/doc'));
assert.ok(!matchesPattern('/skills/**', '/skillset/manifest'));
assert.ok(matchesPattern('/skills/*', '/skills/manifest'));
assert.ok(!matchesPattern('/skills/*', '/skills/nested/doc'));
assert.ok(matchesPattern('/search', '/search/'));
assert.ok(!matchesPattern('/search', '/searching'));
assert.ok(matchesPattern('/search/**', '/search'));
assert.ok(matchesPattern('/search/**', '/search/'));
assert.ok(!matchesPattern('/search/**', '/searching'));
// The bare form is the pattern that let /search/ leak into the sitemap
// once trailingSlash was set; the model must reject it like the plugin.
assert.ok(!matchesPattern('/search', '/search'));
});

test('every sitemap ignorePattern is a rooted glob this suite can reason about', () => {
Expand Down
Loading