diff --git a/docusaurus.config.js b/docusaurus.config.js index 21a533c2..7dc14196 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -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', @@ -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/**'], }, }), ], diff --git a/tests/e2e/architecture-detail.spec.js b/tests/e2e/architecture-detail.spec.js index 951c9636..f8689056 100644 --- a/tests/e2e/architecture-detail.spec.js +++ b/tests/e2e/architecture-detail.spec.js @@ -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(); diff --git a/tests/e2e/blog-indexes.spec.js b/tests/e2e/blog-indexes.spec.js index 19bcd539..175bf26b 100644 --- a/tests/e2e/blog-indexes.spec.js +++ b/tests/e2e/blog-indexes.spec.js @@ -142,7 +142,7 @@ test.describe('blog archive', () => { // The link text is " - ", 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, @@ -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); }); @@ -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, @@ -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( diff --git a/tests/e2e/blog.spec.js b/tests/e2e/blog.spec.js index e4bed58d..f88d0d96 100644 --- a/tests/e2e/blog.spec.js +++ b/tests/e2e/blog.spec.js @@ -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}/`, ); } @@ -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); } @@ -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(); } } diff --git a/tests/e2e/data-fixtures.spec.js b/tests/e2e/data-fixtures.spec.js index 9c33d042..48c197a2 100644 --- a/tests/e2e/data-fixtures.spec.js +++ b/tests/e2e/data-fixtures.spec.js @@ -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( @@ -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( @@ -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 @@ -320,7 +320,7 @@ 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); @@ -328,7 +328,7 @@ describeCoverage('drifted upstream data', () => { // 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); diff --git a/tests/e2e/search.spec.js b/tests/e2e/search.spec.js index db12a8e0..9ac0396b 100644 --- a/tests/e2e/search.spec.js +++ b/tests/e2e/search.spec.js @@ -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); @@ -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); @@ -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"]'); diff --git a/tests/site-config.test.mjs b/tests/site-config.test.mjs index 78852b75..31b6b61a 100644 --- a/tests/site-config.test.mjs +++ b/tests/site-config.test.mjs @@ -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, diff --git a/tests/sitemap-exclusions.test.mjs b/tests/sitemap-exclusions.test.mjs index cbd1b8bf..b7373fc6 100644 --- a/tests/sitemap-exclusions.test.mjs +++ b/tests/sitemap-exclusions.test.mjs @@ -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 '/': @@ -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', () => {