From b9a20ed0a2808f51a49e772f10b94954e0bd5f1a Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:24:47 +0200 Subject: [PATCH 1/3] fix(seo): restore the homepage OpenGraph image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getOgImageUrl` strips the leading and trailing slashes off the pathname to build the image filename. For the homepage that pathname is `/`, so stripping left an empty string and the lookup missed — every docs page had an OpenGraph image and the homepage shipped `` with no content. The homepage's collection id is `index`, which is what `getStaticPaths` names its image, so fall back to that when the slug comes out empty. Covered by a regression test that fails against the old expression. The generated-image set comes from the content collection and needs the Astro build pipeline, so the test stubs it and exercises the derivation, which is the half that was wrong. Change-Id: Ifb9a23ea2caa20d28489a4f21363d85ed5e3342c --- src/util/getOgImageUrl.test.ts | 30 ++++++++++++++++++++++++++++++ src/util/getOgImageUrl.ts | 6 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/util/getOgImageUrl.test.ts diff --git a/src/util/getOgImageUrl.test.ts b/src/util/getOgImageUrl.test.ts new file mode 100644 index 0000000000..a189aace58 --- /dev/null +++ b/src/util/getOgImageUrl.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getOgImageUrl } from './getOgImageUrl'; + +// `getOgImageUrl` looks a derived filename up in the set of images +// astro-og-canvas actually generated, which comes from the content collection +// and so needs the Astro build pipeline. Stub that set and test the derivation +// — the half where the homepage bug was. +vi.mock('../pages/open-graph/[...path]', () => ({ + getStaticPaths: async () => [ + { params: { path: 'index.png' } }, + { params: { path: 'merge-queue.png' } }, + ], +})); + +describe('getOgImageUrl', () => { + it('resolves the homepage to the index image', () => { + // Regression: stripping the slashes off `/` left an empty slug, so the + // homepage was the one page that shipped an empty `og:image`. + expect(getOgImageUrl('/')).toBe('/open-graph/index.png'); + }); + + it('resolves a normal page, with or without a trailing slash', () => { + expect(getOgImageUrl('/merge-queue')).toBe('/open-graph/merge-queue.png'); + expect(getOgImageUrl('/merge-queue/')).toBe('/open-graph/merge-queue.png'); + }); + + it('returns undefined when no image was generated', () => { + expect(getOgImageUrl('/not-a-page')).toBeUndefined(); + }); +}); diff --git a/src/util/getOgImageUrl.ts b/src/util/getOgImageUrl.ts index b4987ea872..ed43b9046a 100644 --- a/src/util/getOgImageUrl.ts +++ b/src/util/getOgImageUrl.ts @@ -20,6 +20,10 @@ const paths = new Set(routes.map(({ params }) => params.path)); * @returns Path to the OpenGraph image if found. Otherwise, `undefined`. */ export function getOgImageUrl(path: string): string | undefined { - const imagePath = path.replace(/^\//, '').replace(/\/$/, '') + '.png'; + // The homepage's collection id is `index`, so stripping its slashes leaves an + // empty string and the lookup misses — which is why the homepage shipped with + // an empty `og:image` while every other page had one. + const slug = path.replace(/^\//, '').replace(/\/$/, '') || 'index'; + const imagePath = slug + '.png'; if (paths.has(imagePath)) return '/open-graph/' + imagePath; } From 0ac1500183e0a137ac2d99488f40e96f5eeb0f81 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:24:54 +0200 Subject: [PATCH 2/3] fix(a11y): stop docset grids skipping a heading level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docset cards rendered their title as `h4`. Almost every grid sits directly under an `##`, so the outline jumped h2 to h4 — including on the homepage, whose whole body is the "Products" grid. Screen readers and anything parsing the document outline read that as a missing level. Default the card heading to `h3` and make it a prop, because one grid does belong at h4: the "Components" grid in `ci-insights.mdx` is nested under an `### Components`, where h3 would make the cards siblings of their own section heading instead of children. Change-Id: Ie01f4c2b03f1f2b7f798ae89b056135a5b00800e --- src/components/DocsetGrid/Docset.astro | 15 ++++++++++++--- src/content/docs/ci-insights.mdx | 3 +++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/DocsetGrid/Docset.astro b/src/components/DocsetGrid/Docset.astro index aedf5868f4..76af8d5c2d 100644 --- a/src/components/DocsetGrid/Docset.astro +++ b/src/components/DocsetGrid/Docset.astro @@ -9,15 +9,23 @@ interface Props { icon?: string; /** Render a product icon in the neutral text color instead of its brand color. */ neutral?: boolean; + /** + * Heading level for the card title. Grids almost always sit directly under an + * `##`, so `h3` is the default; pass 4 for the handful nested under an `###`. + * Skipping a level breaks the document outline that assistive tech and + * agents read the page structure from. + */ + headingLevel?: 3 | 4; } -const { title, path, icon, neutral } = Astro.props; +const { title, path, icon, neutral, headingLevel = 3 } = Astro.props; +const Heading = `h${headingLevel}` as 'h3' | 'h4'; const productIconName = parseProductIcon(icon); const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? 'workflow' : null)); --- -

+ {productIconName && (
@@ -29,7 +37,7 @@ const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ?
)} {title} -

+
@@ -69,6 +77,7 @@ const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? border-color: var(--color-rose-700); } + h3, h4 { display: flex; align-items: center; diff --git a/src/content/docs/ci-insights.mdx b/src/content/docs/ci-insights.mdx index 3973d5d08e..36da559ed3 100644 --- a/src/content/docs/ci-insights.mdx +++ b/src/content/docs/ci-insights.mdx @@ -22,6 +22,7 @@ GitHub and covers basic configuration steps. Date: Fri, 28 Aug 2026 15:25:03 +0200 Subject: [PATCH 3/3] feat(docs): publish the API description where machines look for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mergify OpenAPI 3.1 document is already deployed — it is what the API Reference pages are generated from — but only at `/api-schemas.json`, a filename that exists nowhere outside this repository. Every OpenAPI client, SDK generator and crawler probes `/openapi.json`, so nothing finds it and the docs read as a site with no API at all. Serve the same bytes at `/openapi.json`. The route does not transform the document: the spec is synced from the engine repository, and two spellings of it that could disagree would be worse than one obscure path. Alongside it, three other entry points that were undiscoverable: - `` in every page head, the IANA relation for "the description of this site's API" (RFC 8631), plus one for `llms.txt`. - `robots.txt` had no `Sitemap:` line, so crawlers had to guess `sitemap-index.xml` rather than be told. - `/developers` is the path people and tools guess for a developer portal and was a 404; it now redirects to the API reference. Change-Id: I1b625f960363c8427d5282c052fee74111bf07fa --- public/_redirects | 5 +++++ public/robots.txt | 2 ++ src/components/HeadCommon.astro | 5 +++++ src/pages/openapi.json.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 src/pages/openapi.json.ts diff --git a/public/_redirects b/public/_redirects index d97739fbdb..eb257e3de4 100644 --- a/public/_redirects +++ b/public/_redirects @@ -101,3 +101,8 @@ /monorepo-ci/buildkite /integrations/buildkite#monorepo-ci 301 /monorepo-ci/buildkite/ /integrations/buildkite#monorepo-ci 301 /monorepo-ci/buildkite.md /integrations/buildkite.md 301 + +# `/developers` is the path people and crawlers guess for a developer portal. +# Ours is the API reference. +/developers /api 301 +/developers/ /api 301 diff --git a/public/robots.txt b/public/robots.txt index 723f48f1e1..259589adbe 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -7,3 +7,5 @@ Disallow: /support/premium/ Disallow: /support/premium/* Disallow: /merge-queue/migrate-partitions-to-scopes Allow: / + +Sitemap: https://docs.mergify.com/sitemap-index.xml diff --git a/src/components/HeadCommon.astro b/src/components/HeadCommon.astro index 93531cb759..c1d848e218 100644 --- a/src/components/HeadCommon.astro +++ b/src/components/HeadCommon.astro @@ -28,6 +28,11 @@ const { activePageGroupIds = [] } = Astro.props as Props; + + + diff --git a/src/pages/openapi.json.ts b/src/pages/openapi.json.ts new file mode 100644 index 0000000000..b0f7941045 --- /dev/null +++ b/src/pages/openapi.json.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { APIRoute } from 'astro'; + +/** + * Serves the Mergify OpenAPI description at the conventional discovery path. + * + * The spec itself is synced from the engine repository into + * `public/api-schemas.json`, which is where the API Reference pages read it + * from. That filename is ours alone, so agents crawling the docs never find + * it. Tooling — and the "is this site agent-readable" scanners — look for + * `/openapi.json`, so publish the same bytes there too. + * + * This route deliberately does not transform the document: two spellings of + * the same spec that can disagree would be worse than one obscure path. + */ +export const GET: APIRoute = async () => { + // Read as a Buffer, not a string: decoding to UTF-16 and re-encoding would + // make "the same bytes" a claim about a round trip rather than a fact. + const spec = await readFile(path.join(process.cwd(), 'public', 'api-schemas.json')); + + return new Response(spec, { + headers: { + // Plain `application/json` rather than the `application/vnd.oai.openapi+json` + // media type: every generic JSON client understands it, and clients that do + // care about OpenAPI read the `openapi` field in the body anyway. + 'Content-Type': 'application/json; charset=utf-8', + }, + }); +};