From b9a20ed0a2808f51a49e772f10b94954e0bd5f1a Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:24:47 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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', + }, + }); +}; From ecb955f885316b4f511332e9b828b1110074cfbd Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:25:15 +0200 Subject: [PATCH 4/8] feat(docs): serve Markdown to clients that ask for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page already ships its Markdown source at `.md`, which is what the "View as Markdown" button and `llms.txt` link to. But the convention agents reach for first is to request the page's own URL with `Accept: text/markdown` (https://acceptmarkdown.com), and that returned HTML. The site is a static build, so a Cloudflare Pages middleware is the only layer that sees request headers. It rewrites to the `.md` twin when Markdown is asked for, answers 404s in the format that was requested, and merges `Accept` into `Vary` on both variants — without it a CDN would hand whichever variant it cached first to everyone, which is the failure this is most likely to produce in production and the least likely to be noticed. The `Vary` goes on `304` responses too, since those are exactly the ones a cache is about to act on. Markdown is served only when `text/markdown` is named explicitly and not outranked by `text/html`. Browsers send `text/html,...,*/*;q=0.8`, so honouring wildcards would serve raw Markdown to every human visitor; the tests pin the real Chrome and Firefox Accept headers against that. HTML is served as the fallback only on a `404` from the `.md` route, not on anything that is merely not `ok`: `304 Not Modified` is the normal answer to a client revalidating Markdown it already holds, and treating it as a missing page answered it with HTML. Three things about adding a root `_middleware` needed care, all three verified against the real Pages runtime with `wrangler pages dev dist`: Cloudflare documents that `_redirects` are not applied to requests served by Functions, and this repository has 99 of them. `next()` does still route through the asset server, so every redirect, the `_headers` rules, the `.md` routes and the static 404 behave exactly as before. `next()` is called with an explicit request everywhere. A bare `next()` is documented as forwarding the original request, but once the middleware has asked for the Markdown twin the runtime forwards *that* request again — so `/api` and `/cli`, which are built from `src/pages/` and have no `.md`, were answered with the Markdown 404 instead of their own HTML. The test double refuses a call with no request so this cannot come back. A root middleware otherwise turns every request into a Worker invocation, assets included — roughly thirty per page view, none of which can be negotiated. `_routes.json` excludes the bundle, the search index, the OpenGraph images and the static files by name, so only page requests reach the Function. Also ignore `.wrangler/` — running the Pages runtime locally drops generated bundles there, and eslint linted them. Change-Id: I52f9d2a1c70248b1406412129a633cc3bee219de --- .gitignore | 3 + biome.json | 1 + eslint.config.js | 1 + functions/_middleware.test.ts | 146 ++++++++++++++++++++++++++++++++ functions/_middleware.ts | 112 ++++++++++++++++++++++++ public/_routes.json | 23 +++++ src/components/HeadSEO.astro | 5 ++ src/util/acceptMarkdown.test.ts | 49 +++++++++++ src/util/acceptMarkdown.ts | 66 +++++++++++++++ 9 files changed, 406 insertions(+) create mode 100644 functions/_middleware.test.ts create mode 100644 functions/_middleware.ts create mode 100644 public/_routes.json create mode 100644 src/util/acceptMarkdown.test.ts create mode 100644 src/util/acceptMarkdown.ts diff --git a/.gitignore b/.gitignore index 9f173f5dd5..36e26714d6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ node_modules .astro/ dist +# Local Cloudflare Pages runtime (`wrangler pages dev`) build artifacts +.wrangler/ + # Statically generated images at build time, don't push them /static/og-images diff --git a/biome.json b/biome.json index 770b203402..7ed12a281c 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,7 @@ "includes": [ "**/*", "!.astro/*", + "!.wrangler/**", "!src/@types/*", "!public/api-schemas.json", "!public/cli-schema.json", diff --git a/eslint.config.js b/eslint.config.js index 894a8bf48c..934be413d4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ export default [ ignores: [ 'dist/**', '.astro/**', + '.wrangler/**', 'node_modules/**', '.github/**', '.claude/**', diff --git a/functions/_middleware.test.ts b/functions/_middleware.test.ts new file mode 100644 index 0000000000..4cc1211bc3 --- /dev/null +++ b/functions/_middleware.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { onRequest } from './_middleware'; + +/** + * Stands in for Cloudflare's asset server. `assets` maps a pathname to the + * response it would serve; anything absent 404s, as it would in production. + * + * `next()` with no argument re-runs the original request, which is how the + * middleware asks for the HTML after the Markdown twin came back missing. + */ +async function run( + path: string, + { + assets, + accept, + method = 'GET', + }: { assets: Record; accept?: string; method?: string } +): Promise<{ response: Response; seen: string[] }> { + const request = new Request(`https://docs.mergify.com${path}`, { + method, + headers: accept ? { Accept: accept } : {}, + }); + const seen: string[] = []; + const next = async (input: Request) => { + // The middleware must always name the request it wants. A bare `next()` is + // documented as re-forwarding the original, but the Pages runtime forwards + // the last request it was given instead, so relying on it served the + // Markdown 404 for pages that only have an HTML twin. + if (!input) throw new Error('next() was called without an explicit Request'); + const pathname = new URL(input.url).pathname; + seen.push(pathname); + const asset = assets[pathname]; + // Pages serves the built `404.html` for anything it does not have. + return ( + asset?.clone() ?? + new Response('not found', { + status: 404, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + ); + }; + return { response: await onRequest({ request, next }), seen }; +} + +const html = (body = '', status = 200) => + new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); +const markdown = (body = '# Merge Queue') => + new Response(body, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } }); + +describe('markdown content negotiation', () => { + it('serves the Markdown twin when Markdown is asked for', async () => { + const { response, seen } = await run('/merge-queue', { + accept: 'text/markdown', + assets: { '/merge-queue.md': markdown(), '/merge-queue': html() }, + }); + + expect(await response.text()).toBe('# Merge Queue'); + expect(response.headers.get('vary')).toBe('Accept'); + expect(seen).toEqual(['/merge-queue.md']); + }); + + it('passes a 304 straight through instead of falling back to HTML', async () => { + // Regression: `.ok` is false for 304, so a client revalidating Markdown it + // already holds was answered with the HTML page. + const { response, seen } = await run('/merge-queue', { + accept: 'text/markdown', + assets: { '/merge-queue.md': new Response(null, { status: 304 }), '/merge-queue': html() }, + }); + + expect(response.status).toBe(304); + expect(response.headers.get('vary')).toBe('Accept'); + expect(seen).toEqual(['/merge-queue.md']); + }); + + it('falls back to HTML for a page with no Markdown twin', async () => { + // `/api` and `/cli` are built from `src/pages/`, so no `.md` is generated. + const { response, seen } = await run('/api', { + accept: 'text/markdown', + assets: { '/api': html('api') }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('api'); + // The second call must ask for the original path, not repeat the `.md` one. + expect(seen).toEqual(['/api.md', '/api']); + }); + + it('answers a missing page in the format that was asked for', async () => { + const { response } = await run('/nope', { accept: 'text/markdown', assets: {} }); + + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('text/markdown'); + expect(await response.text()).toContain('llms.txt'); + + const { response: asHtml } = await run('/nope', { assets: {} }); + expect(asHtml.status).toBe(404); + expect(asHtml.headers.get('content-type')).toContain('text/html'); + }); +}); + +describe('everything else', () => { + it('serves HTML to a browser, and marks the response as negotiated', async () => { + const { response, seen } = await run('/merge-queue', { + accept: 'text/html,application/xhtml+xml,*/*;q=0.8', + assets: { '/merge-queue': html() }, + }); + + expect(response.headers.get('content-type')).toContain('text/html'); + expect(response.headers.get('vary')).toBe('Accept'); + expect(seen).toEqual(['/merge-queue']); + }); + + it('leaves assets alone', async () => { + const { response } = await run('/_astro/app.css', { + accept: 'text/markdown', + assets: { + '/_astro/app.css': new Response('body{}', { headers: { 'Content-Type': 'text/css' } }), + }, + }); + + expect(response.headers.get('vary')).toBeNull(); + }); + + it('leaves non-GET requests alone', async () => { + const { response, seen } = await run('/merge-queue', { + accept: 'text/markdown', + method: 'POST', + assets: { '/merge-queue': html() }, + }); + + expect(response.headers.get('vary')).toBeNull(); + expect(seen).toEqual(['/merge-queue']); + }); + + it('preserves a Vary the asset server already set', async () => { + const { response } = await run('/merge-queue', { + assets: { + '/merge-queue': new Response('', { + headers: { 'Content-Type': 'text/html', Vary: 'Accept-Encoding' }, + }), + }, + }); + + expect(response.headers.get('vary')).toBe('Accept-Encoding, Accept'); + }); +}); diff --git a/functions/_middleware.ts b/functions/_middleware.ts new file mode 100644 index 0000000000..a31225fc85 --- /dev/null +++ b/functions/_middleware.ts @@ -0,0 +1,112 @@ +import { isNegotiablePage, prefersMarkdown } from '../src/util/acceptMarkdown'; +import { getMarkdownPath } from '../src/util/getMarkdownPath'; + +/** + * Cloudflare Pages middleware: serve the Markdown twin of a page to clients that + * ask for it with `Accept: text/markdown` (https://acceptmarkdown.com). + * + * The site is a static build, so this is the only layer that sees request + * headers. It stays deliberately thin — the decision itself lives in + * `src/util/acceptMarkdown.ts`, where it is unit-tested. + */ + +/** + * The slice of Cloudflare's `EventContext` we use. Declared structurally rather + * than pulling in `@cloudflare/workers-types`: this is the only Worker in the + * repo, and `tsconfig.json` scopes typechecking to `src/`. + */ +interface MiddlewareContext { + request: Request; + /** + * Always call this with an explicit request. A bare `next()` is documented as + * forwarding the original one, but once the middleware has asked for the + * Markdown twin the runtime forwards *that* request again instead — which + * answered `/api`, a page with no twin, with the Markdown 404 rather than its + * HTML. + */ + next: (input: Request) => Promise; +} + +/** Body served when an agent asks for Markdown and the path does not exist. */ +const MARKDOWN_404 = `# 404 — Page not found + +This path does not exist in the Mergify documentation. + +- [Documentation index](https://docs.mergify.com/index.md) +- [llms.txt](https://docs.mergify.com/llms.txt) — every page, with descriptions +- [Sitemap](https://docs.mergify.com/sitemap-index.xml) +- [OpenAPI description](https://docs.mergify.com/openapi.json) — the Mergify REST API + +Most documentation pages also serve their Markdown source: append \`.md\` to the +URL, or send \`Accept: text/markdown\`. The generated API and CLI references are +HTML only. +`; + +/** + * Republish a response with `Accept` merged into `Vary`. + * + * Without it a CDN that cached the HTML variant first would hand it to an agent + * asking for Markdown (and vice versa). Only applied to the negotiated media + * types: putting `Vary: Accept` on images and CSS would fragment their cache + * keys for nothing. + */ +function withVaryAccept(response: Response): Response { + const contentType = response.headers.get('content-type') ?? ''; + const negotiated = + contentType.startsWith('text/html') || + contentType.startsWith('text/markdown') || + // A `304 Not Modified` carries no content type but still answers for one + // of the two variants, and needs the header most of all: it is the reply + // to a cache that is about to reuse a stored representation. + response.status === 304; + if (!negotiated) return response; + + const varied = new Response(response.body, response); + const existing = varied.headers.get('vary'); + const fields = new Set( + (existing ?? '') + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + ); + fields.add('Accept'); + varied.headers.set('Vary', Array.from(fields).join(', ')); + return varied; +} + +export async function onRequest(context: MiddlewareContext): Promise { + const { request, next } = context; + + if (request.method !== 'GET' && request.method !== 'HEAD') return next(request); + + const url = new URL(request.url); + if (!isNegotiablePage(url.pathname)) return next(request); + + if (!prefersMarkdown(request.headers.get('accept'))) { + return withVaryAccept(await next(request)); + } + + const markdownUrl = new URL(getMarkdownPath(url.pathname), url); + markdownUrl.search = url.search; + const markdown = await next(new Request(markdownUrl, request)); + + // Fall back to HTML only when there is genuinely no Markdown twin. Keying + // this off `ok` would also catch `304 Not Modified` — the normal answer to a + // client revalidating Markdown it already holds — and serve it HTML instead, + // and would turn a redirect or a 5xx from the `.md` route into HTML too. + if (markdown.status !== 404) return withVaryAccept(markdown); + + // No Markdown twin: either a page we do not generate one for (`/api` and + // `/cli` are built from `src/pages/`), or a path that does not exist at all. + // Answer 404s in the format that was asked for; hand anything else back as + // HTML. + const html = await next(request); + if (html.status !== 404) return withVaryAccept(html); + + return withVaryAccept( + new Response(MARKDOWN_404, { + status: 404, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }) + ); +} diff --git a/public/_routes.json b/public/_routes.json new file mode 100644 index 0000000000..bc9b8f72dd --- /dev/null +++ b/public/_routes.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "include": ["/*"], + "exclude": [ + "/_astro/*", + "/pagefind/*", + "/open-graph/*", + "/favicon.ico", + "/favicon.svg", + "/robots.txt", + "/sitemap-index.xml", + "/sitemap-0.xml", + "/llms.txt", + "/openapi.json", + "/api-schemas.json", + "/cli-schema.json", + "/mergify-configuration-schema.json", + "/make-scrollable-code-focusable.js", + "/sw.js", + "/mergify-count-contributors.py", + "/mergify-github-app-logo.png" + ] +} diff --git a/src/components/HeadSEO.astro b/src/components/HeadSEO.astro index 63c5d543eb..1d7fee1a1b 100644 --- a/src/components/HeadSEO.astro +++ b/src/components/HeadSEO.astro @@ -1,6 +1,7 @@ --- import type { CollectionEntry } from 'astro:content'; import { OPEN_GRAPH } from '../config'; +import { getMarkdownPath } from '../util/getMarkdownPath'; import { getOgImageUrl } from '../util/getOgImageUrl'; export interface Props { @@ -9,6 +10,7 @@ export interface Props { } const { content, canonicalURL } = Astro.props; +const markdownURL = new URL(getMarkdownPath(canonicalURL.pathname), canonicalURL); const ogImageUrl = getOgImageUrl(canonicalURL.pathname); const imageSrc = ogImageUrl; const canonicalImageSrc = imageSrc ? new URL(imageSrc, Astro.site) : undefined; @@ -20,6 +22,9 @@ const siteDescription = + + diff --git a/src/util/acceptMarkdown.test.ts b/src/util/acceptMarkdown.test.ts new file mode 100644 index 0000000000..cae8d27157 --- /dev/null +++ b/src/util/acceptMarkdown.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { isNegotiablePage, prefersMarkdown } from './acceptMarkdown'; + +describe('prefersMarkdown', () => { + it('serves Markdown when it is asked for explicitly', () => { + expect(prefersMarkdown('text/markdown')).toBe(true); + expect(prefersMarkdown('text/markdown, text/plain;q=0.5')).toBe(true); + expect(prefersMarkdown('TEXT/MARKDOWN')).toBe(true); + }); + + it('leaves browsers alone', () => { + // Chrome and Firefox both rank HTML first and end on a `*/*` catch-all. If + // wildcards counted, every human visitor would be served raw Markdown. + expect( + prefersMarkdown( + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' + ) + ).toBe(false); + expect(prefersMarkdown('*/*')).toBe(false); + expect(prefersMarkdown('text/*')).toBe(false); + }); + + it('respects q-values when both formats are named', () => { + expect(prefersMarkdown('text/markdown;q=0.9, text/html;q=0.8')).toBe(true); + expect(prefersMarkdown('text/markdown;q=0.5, text/html')).toBe(false); + expect(prefersMarkdown('text/markdown;q=0, text/html;q=0')).toBe(false); + }); + + it('falls back to HTML when nothing was asked for', () => { + expect(prefersMarkdown(null)).toBe(false); + expect(prefersMarkdown('')).toBe(false); + }); +}); + +describe('isNegotiablePage', () => { + it('matches documentation pages', () => { + expect(isNegotiablePage('/')).toBe(true); + expect(isNegotiablePage('/merge-queue')).toBe(true); + expect(isNegotiablePage('/api/usage/')).toBe(true); + }); + + it('leaves assets untouched', () => { + expect(isNegotiablePage('/merge-queue.md')).toBe(false); + expect(isNegotiablePage('/openapi.json')).toBe(false); + expect(isNegotiablePage('/llms.txt')).toBe(false); + expect(isNegotiablePage('/_astro/MainLayout.lvKlb881.css')).toBe(false); + expect(isNegotiablePage('/open-graph/index.png')).toBe(false); + }); +}); diff --git a/src/util/acceptMarkdown.ts b/src/util/acceptMarkdown.ts new file mode 100644 index 0000000000..97902afb92 --- /dev/null +++ b/src/util/acceptMarkdown.ts @@ -0,0 +1,66 @@ +/** + * Content negotiation for the Markdown twin of every docs page. + * + * Every page already ships a `.md` source at `.md` (see + * `src/pages/[...slug].md.ts`), which is what the "View as Markdown" button and + * `llms.txt` link to. The convention agents actually try first, though, is to + * ask for the page's own URL with `Accept: text/markdown` — see + * https://acceptmarkdown.com. This module decides when to honour that. + */ + +interface AcceptEntry { + type: string; + q: number; +} + +function parseAccept(header: string): AcceptEntry[] { + return header + .split(',') + .map((part) => { + const [rawType, ...params] = part.split(';'); + const type = rawType.trim().toLowerCase(); + if (!type) return undefined; + // Only `q` matters to us; any other accept-param is ignored. + let q = 1; + for (const param of params) { + const [key, value] = param.split('='); + if (key?.trim().toLowerCase() !== 'q') continue; + const parsed = Number.parseFloat(value ?? ''); + if (Number.isFinite(parsed)) q = parsed; + } + return { type, q }; + }) + .filter((entry): entry is AcceptEntry => entry !== undefined); +} + +/** + * Whether a request asking for `Accept:
` should be served Markdown. + * + * Deliberately requires `text/markdown` to be named explicitly: browsers send + * `text/html,...,*\/*;q=0.8`, so honouring wildcards would hand Markdown to + * every human visitor whose browser happens to list HTML at a lower q than the + * catch-all. An agent that wants Markdown says so. + */ +export function prefersMarkdown(header: string | null | undefined): boolean { + if (!header) return false; + + const entries = parseAccept(header); + const markdown = entries.find((entry) => entry.type === 'text/markdown'); + if (!markdown || markdown.q <= 0) return false; + + // A client that lists both and ranks HTML higher gets HTML. + const html = entries.find((entry) => entry.type === 'text/html'); + return html === undefined || markdown.q >= html.q; +} + +/** + * Whether a path is a docs page with a Markdown twin, as opposed to an asset. + * + * Pages are extensionless (`/merge-queue`, `/api/usage/`); anything carrying a + * file extension is a static asset and is served untouched. + */ +export function isNegotiablePage(pathname: string): boolean { + if (pathname === '/') return true; + const lastSegment = pathname.replace(/\/$/, '').split('/').pop() ?? ''; + return !lastSegment.includes('.'); +} From fbf4750d75ddb6e6a4548c9264f3e5d0e1ad2f36 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:25:43 +0200 Subject: [PATCH 5/8] feat(docs): tell agents what Mergify is for in llms.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `llms.txt` was a page index and nothing else: an agent could see every URL we publish and still not know whether Mergify was the right tool for the task in front of it, or how to call it. Add two sections above the index. "When to use Mergify" names the jobs concretely — keeping main green, merging at volume without the CI bill, diagnosing CI, flaky tests, merge requirements GitHub cannot express, stacked PRs — and, just as usefully, says what is out of scope. GitHub only, no other code host; GitLab appears solely as a source of CI results. That one line saves an agent from planning an integration that does not exist, which is worth more than another paragraph of capabilities. "For agents" points at the machine-readable surfaces: the `.md` twin of every page and the `Accept: text/markdown` equivalent, the OpenAPI document with its base URL and auth scheme, the sitemap, the `.mergify.yml` JSON Schema, and how to install the CLI. That last one because naming a CLI without an install path leaves an agent exactly where it started; the paths are the Homebrew tap, the install script and the Windows release zip, per /cli/usage. Change-Id: I03f7b473ced0969e850d78b48758e1849535062f --- src/pages/llms.txt.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index 56c1b8b079..7479f8cb11 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -2,6 +2,41 @@ import type { CollectionEntry } from 'astro:content'; import { allPages } from '~/content'; import navItems from '~/content/navItems'; +/** + * What Mergify is for, in the terms an agent needs to decide whether to reach + * for it. Deliberately concrete about the jobs and about what is out of scope: + * "GitHub only" saves an agent from planning a GitLab integration that does not + * exist, which is worth more than another paragraph of capabilities. + */ +const WHEN_TO_USE: string[] = [ + '## When to use Mergify', + '', + 'Mergify is a hosted platform for teams merging code on **GitHub**. Reach for it when the task is:', + '', + '- **Keeping the main branch green.** Pull requests are merged through a queue that revalidates each one against the latest main, so semantic conflicts between individually-green PRs are caught before they land.', + '- **Merging at high volume without a CI bill to match.** Speculative checks, batching and two-step CI trade queue depth against CI minutes.', + '- **Diagnosing slow or unreliable CI.** CI Insights reports job, runner and queue-time health across GitHub Actions, CircleCI, Jenkins, Buildkite, TeamCity and GitLab CI, and can auto-retry transient job failures.', + '- **Dealing with flaky tests.** Test Insights classifies tests as healthy, flaky or broken from rerun outcomes, catches new flakiness on the pull request, and can quarantine known-flaky tests.', + '- **Enforcing merge requirements GitHub cannot express.** Merge Protections evaluate conditions richer than branch protection rules.', + '- **Working on stacked pull requests.** The `mergify` CLI creates and keeps a stack of dependent PRs in sync.', + '', + 'Mergify does **not** host code, and it does **not** support GitLab, Bitbucket or any', + 'code host other than GitHub. GitLab CI is supported as a source of CI results only.', +]; + +/** How an agent should read these docs and call the API. */ +function howToRead(site: string): string[] { + return [ + '## For agents', + '', + `- Every page listed below links to its Markdown source, and each of them serves that source from its own URL too — append \`.md\` (\`${site}/merge-queue.md\`) or send \`Accept: text/markdown\`. The generated API and CLI references are HTML only.`, + `- The REST API is described by an OpenAPI 3.1 document at [${site}/openapi.json](${site}/openapi.json). Base URL \`https://api.mergify.com/v1\`; authenticate with \`Authorization: Bearer \`, using either an application key created in the dashboard (scopes: \`admin\`, \`ci\`) or a GitHub personal access token.`, + `- Full page list: [${site}/sitemap-index.xml](${site}/sitemap-index.xml).`, + `- The \`mergify\` CLI drives stacked pull requests and CI test-result upload from a terminal. Install it with \`brew install mergifyio/tap/mergify-cli\` on macOS, the install script on Linux, or the release zip on Windows — see [${site}/cli/usage](${site}/cli/usage), with the command reference at [${site}/cli](${site}/cli).`, + `- Configuration lives in \`.mergify.yml\` at the repository root; its JSON Schema is at [${site}/mergify-configuration-schema.json](${site}/mergify-configuration-schema.json).`, + ]; +} + /** * Auto-generated list of documentation pages for LLM consumption. * Uses the navigation structure (navItems) as the single source of truth for ordering & inclusion. @@ -65,6 +100,10 @@ export const GET = async () => { lines.push(''); lines.push(`> ${summary}`); lines.push(''); + lines.push(...WHEN_TO_USE); + lines.push(''); + lines.push(...howToRead(site)); + lines.push(''); for (const section of sections) { lines.push(`## ${section.title}`); lines.push(''); From a34482461044a08c8e3dad65fec311281381d42b Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 28 Aug 2026 15:25:43 +0200 Subject: [PATCH 6/8] feat(seo): describe the site with schema.org JSON-LD The docs carried no structured data, so who publishes them, how to contact Mergify and what product they document were all inferable only from prose. Search engines and assistants both use JSON-LD for that entity resolution. Emit one `@graph` per page: an Organization node with the registered address, support and sales contact points and the canonical social profiles; a SoftwareApplication node for the product; and either a WebSite node on the homepage or a TechArticle node for the page itself. The Organization is declared once and referenced by `@id` from the others rather than repeated. Every value is taken from something already published: the footer's social links, the addresses used across the docs, the pricing page. Nothing is asserted that a reader cannot check against a page, which is the whole point of publishing it. `<` is escaped to the JSON unicode escape for it in the serialized graph. Page titles and descriptions come from frontmatter in this repository, so nothing hostile is expected there, but `JSON.stringify` leaves `` intact and inlining it would end the tag early. Change-Id: Idd84bc050695e27d6d8a361538e2ef89da8dd32e --- src/components/StructuredData.astro | 116 ++++++++++++++++++++++++++++ src/layouts/BaseLayout.astro | 2 + 2 files changed, 118 insertions(+) create mode 100644 src/components/StructuredData.astro diff --git a/src/components/StructuredData.astro b/src/components/StructuredData.astro new file mode 100644 index 0000000000..c703276b8e --- /dev/null +++ b/src/components/StructuredData.astro @@ -0,0 +1,116 @@ +--- +import type { CollectionEntry } from 'astro:content'; + +/** + * Schema.org JSON-LD describing who Mergify is and what the current page is. + * + * Search engines and agents both use this for entity resolution — "who publishes + * these docs, how do I contact them, what product is this about" — which is + * otherwise only inferable from prose. Emitted as a single `@graph` so the + * Organization node is declared once and referenced by the others. + */ + +export interface Props { + content: CollectionEntry<'docs'>['data']; + canonicalURL: URL; +} + +const { content, canonicalURL } = Astro.props; +const site = Astro.site?.origin ?? 'https://docs.mergify.com'; +const isHomepage = canonicalURL.pathname === '/'; + +const organization = { + '@type': 'Organization', + '@id': 'https://mergify.com/#organization', + name: 'Mergify', + url: 'https://mergify.com/', + description: + 'Mergify is a merge queue and CI optimization platform for engineering teams on GitHub.', + email: 'support@mergify.com', + address: { + '@type': 'PostalAddress', + streetAddress: '15 rue Pierre Lauzeral', + postalCode: '31400', + addressLocality: 'Toulouse', + addressCountry: 'FR', + }, + contactPoint: [ + { + '@type': 'ContactPoint', + contactType: 'customer support', + email: 'support@mergify.com', + url: `${site}/support/`, + }, + { + '@type': 'ContactPoint', + contactType: 'sales', + email: 'sales@mergify.com', + url: 'https://mergify.com/pricing', + }, + ], + // Identity profiles, for entity reconciliation. Deliberately not derived from + // `Footer/footer.ts`: that list is what we want people to click, and includes + // the Slack invite, which is a join link rather than a page that identifies + // Mergify. GitHub is spelled in the org's canonical casing here — both + // resolve, but `sameAs` is a claim about identity. + sameAs: [ + 'https://github.com/Mergifyio', + 'https://twitter.com/mergifyio', + 'https://www.linkedin.com/company/mergify/', + 'https://www.youtube.com/@mergifyio', + ], +}; + +const softwareApplication = { + '@type': 'SoftwareApplication', + '@id': 'https://mergify.com/#software', + name: 'Mergify', + applicationCategory: 'DeveloperApplication', + applicationSubCategory: 'Continuous Integration', + operatingSystem: 'Web-based (SaaS)', + url: 'https://mergify.com/', + description: + 'Merge queue, CI Insights, Test Insights, Merge Protections and stacked pull requests for teams developing on GitHub.', + publisher: { '@id': organization['@id'] }, + offers: { + '@type': 'Offer', + url: 'https://mergify.com/pricing', + category: 'SaaS subscription', + }, +}; + +const page = isHomepage + ? { + '@type': 'WebSite', + '@id': `${site}/#website`, + name: 'Mergify Documentation', + url: `${site}/`, + description: content.description, + inLanguage: 'en', + publisher: { '@id': organization['@id'] }, + about: { '@id': softwareApplication['@id'] }, + } + : { + '@type': 'TechArticle', + '@id': `${canonicalURL.href}#article`, + headline: content.title, + description: content.description, + url: canonicalURL.href, + inLanguage: 'en', + isPartOf: { '@id': `${site}/#website` }, + publisher: { '@id': organization['@id'] }, + about: { '@id': softwareApplication['@id'] }, + }; + +const graph = { + '@context': 'https://schema.org', + '@graph': [organization, softwareApplication, page], +}; + +// `JSON.stringify` does not escape `<`, so a page whose title or description +// contained `` would close this tag early and put the rest of the +// frontmatter into the document as markup. +const json = JSON.stringify(graph).replace(/ diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 64a59b13ff..bba3330a84 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -9,6 +9,7 @@ import HeadSEO from '../components/HeadSEO.astro'; import ImageZoom from '../components/ImageZoom.astro'; import LeftSidebar from '../components/LeftSidebar/LeftSidebar.astro'; import ScrollToTop from '../components/ScrollToTop.astro'; +import StructuredData from '../components/StructuredData.astro'; import { getActivePageGroupIds } from '../util/activePageGroupIds'; export interface Props { @@ -28,6 +29,7 @@ const canonicalURL = new URL(Astro.url.pathname.replace(/([^/])$/, '$1/'), Astro + <ClientRouter /> <style lang="scss"> From 550933eef6626c8be4f905dba11370ed235ae9ef Mon Sep 17 00:00:00 2001 From: Julien Danjou <julien@danjou.info> Date: Mon, 7 Sep 2026 16:16:58 +0200 Subject: [PATCH 7/8] docs(workflow): depends-on spans a repository owner, not an organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pull Request Dependencies section said a `Depends-On:` header could point at "other repositories with Mergify installed within your organization". The constraint is the same repository *owner*, which may be a user account rather than an organization. A reference to another owner is rendered with a "depends-on conditions must have the same repository owner" warning and never satisfies. The section was also silent on what happens to a reference Mergify cannot resolve — another owner, a repository without Mergify, or a pull request that does not exist. None of those ever reach the `depends-on` attribute, so the condition stays unsatisfied and blocks the merge rather than being skipped, which is the behaviour a reader most needs to be told about. This brings the page in line with the same rules already documented for the `depends-on` merge protection in /merge-protections/builtin, which was corrected and left this page behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V4gXwb2UucysW4xmB7bAw Change-Id: I790b2c0c6e38ed5eff9ac587378765c72939f264 --- src/content/docs/workflow/actions/merge.mdx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/content/docs/workflow/actions/merge.mdx b/src/content/docs/workflow/actions/merge.mdx index a7fd597ec6..f602c02085 100644 --- a/src/content/docs/workflow/actions/merge.mdx +++ b/src/content/docs/workflow/actions/merge.mdx @@ -43,10 +43,14 @@ known incompatibilities, and how to configure bypass actors, see ## Pull Request Dependencies -You can specify dependencies between pull requests from the same repository, -or from other repositories with Mergify installed within your organization. -Mergify waits for the linked pull requests to be merged before merging any pull -request with a `Depends-On:` header. +You can specify dependencies between pull requests from the same repository, or +from another repository that has Mergify installed and belongs to the same +repository owner, whether that owner is a user or an organization. Mergify waits +for the linked pull requests to be merged before merging any pull request with a +`Depends-On:` header. A reference Mergify cannot resolve blocks the merge rather +than waiting on it, and it stays blocked until you fix the reference. That +includes a reference pointing at another owner's repository, at a repository +without Mergify, or at a pull request that does not exist. To use this feature, add the `Depends-On:` header to the body of your pull request: From c982e760d1ad68aa87229abe263f7cab5c76dc68 Mon Sep 17 00:00:00 2001 From: Julien Danjou <julien@danjou.info> Date: Tue, 1 Sep 2026 15:06:51 +0200 Subject: [PATCH 8/8] feat(diagrams): let each diagram name its own roles, and lock the door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration shim recolored the diagrams without touching them, which was the point of landing it first — but a table that infers intent from a hex can only be as good as the colors it reads, and those colors were four dialects that disagreed. Two datastores came out the same color because both were pastel greens; "tests passed" and "merged to main" fused because both were greens. Each fence now says what it means, and the inference goes away. The 19 fences drop from 713 lines of DOT to 286, because most of what they said was color. What is left is structure, plus a role per element and a layout kind per fence — `queue`, `flow` or `arch`, which set direction and spacing and nothing else. A few things the diagrams had wrong get fixed on the way through, since the rewrite makes them visible: batches.mdx had a pull request that was grey rather than queued only because it was declared implicitly by an edge and so missed the fill every other node got. The emoji come out of the lifecycle and two-step diagrams. They render differently on every platform, Graphviz cannot measure them, and they carried no meaning that the color does not now carry. `pnpm check:diagram-tokens` is what makes this permanent — the same shape as `check:internal-leaks`, a deterministic scan wired into CI. Nothing about a hardcoded color fails a build on its own: the diagram renders, it just renders wrong on half the site, which is exactly how 63 of them accumulated unnoticed. It reads inside Graphviz fences only, so a color in prose or a CSS example is untouched, and it reads the three hand-drawn diagram components end to end, since those have no fence to scan and are where two of the four dialects lived. A CI job that nothing waits on is decoration, so `diagram-tokens` joins the `CheckRuns` anchor in `.mergify.yml` — the list the merge protection, both queue rules and the review-request rule all share. Note this leaves `actionlint`, `config-examples` and `internal-leaks` outside that anchor, which they were before this change; whether they belong there is a separate question from this diff. `enterprise/architecture.mdx` keeps its click-to-zoom overlay. The diagram is 945pt now rather than 1736pt, which is what makes it legible at all, but at 80% of the prose column that still scales its labels to roughly 8px — so the overlay is still doing something. Change-Id: I6cfa48c833499804a082a59bf6199236172cdcca --- .github/workflows/ci.yaml | 17 ++ .mergify.yml | 1 + package.json | 1 + plugins/remark-graphviz.test.ts | 75 +++---- plugins/remark-graphviz.ts | 136 ++++-------- scripts/check-diagram-tokens.mjs | 196 ++++++++++++++++++ scripts/check-diagram-tokens.test.mjs | 93 +++++++++ .../docs/ci-insights/flaky-test-detection.mdx | 92 ++++---- src/content/docs/enterprise/architecture.mdx | 52 ++--- src/content/docs/integrations/buildkite.mdx | 27 +-- src/content/docs/integrations/gha.mdx | 32 ++- src/content/docs/merge-queue/batches.mdx | 70 +++---- src/content/docs/merge-queue/direct-merge.mdx | 47 ++--- src/content/docs/merge-queue/lifecycle.mdx | 86 ++------ src/content/docs/merge-queue/performance.mdx | 64 +++--- src/content/docs/merge-queue/queue-modes.mdx | 137 ++++-------- src/content/docs/merge-queue/scopes.mdx | 34 ++- src/content/docs/merge-queue/stacks.mdx | 96 ++++----- src/content/docs/merge-queue/two-step.mdx | 156 +++----------- src/styles/index.css | 10 +- src/util/diagramSvg.ts | 40 +--- 21 files changed, 675 insertions(+), 787 deletions(-) create mode 100644 scripts/check-diagram-tokens.mjs create mode 100644 scripts/check-diagram-tokens.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 386e3714ed..8e438ded28 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -104,6 +104,23 @@ jobs: - name: Check for leaked internal information run: node scripts/check-internal-leaks.mjs + diagram-tokens: + timeout-minutes: 5 + runs-on: ubuntu-24.04 + steps: + - name: Checkout 🛎️ + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node 🔧 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + + # No dependencies needed: the scanner is plain Node. Its own unit tests + # run in the `test` job. + - name: Check that diagrams resolve their colours from tokens + run: node scripts/check-diagram-tokens.mjs + build: timeout-minutes: 20 runs-on: ubuntu-24.04 diff --git a/.mergify.yml b/.mergify.yml index f4ed5738fa..ece2fc223c 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -30,6 +30,7 @@ merge_protections: - check-success = lint - check-success = build - check-success = test + - check-success = diagram-tokens - or: - label = ignore-broken-links - check-success = test-broken-links diff --git a/package.json b/package.json index 6d9907b102..71c66d63e9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "biome format src integrations plugins scripts", "check": "astro check && eslint . && biome check .", "check:config-examples": "node scripts/validate-config-examples.mjs", + "check:diagram-tokens": "node scripts/check-diagram-tokens.mjs", "check:internal-leaks": "node scripts/check-internal-leaks.mjs", "check:links": "linkinator / enterprise/ --server-root dist --config linkinator.config.mjs" }, diff --git a/plugins/remark-graphviz.test.ts b/plugins/remark-graphviz.test.ts index 18a3f0cd30..5c1eb5ed14 100644 --- a/plugins/remark-graphviz.test.ts +++ b/plugins/remark-graphviz.test.ts @@ -55,37 +55,40 @@ describe('remarkGraphvizPlugin', () => { expect(svg).not.toMatch(/\bstroke="/); }); - it('maps the colours the docs were drawn with onto roles', async () => { + it('passes the role a fence names straight through to the SVG', async () => { + // The whole system rests on this: Graphviz copies `class` verbatim, so the + // plugin never has to resolve a colour and CSS can do it at paint time. const svg = await render(`digraph { - node [style=filled]; - A [fillcolor="#347D39"]; - B [fillcolor="#6B7280"]; - C [fillcolor="#FFF4ED"]; - subgraph cluster_b { style="rounded,filled"; fillcolor="#1CB893"; label="Batch"; A; } - A -> B [color="#9CA3AF"]; + A [class="failed"]; + subgraph cluster_b { class="batch"; label="Batch"; A; } + A -> B [class="muted"]; }`); - expect(classesOf(svg, 'A')).toContain('queued'); - expect(classesOf(svg, 'B')).toContain('muted'); - expect(classesOf(svg, 'C')).toContain('pending'); - // Teal is a container on a cluster and a value on a node. - expect(classesOf(svg, 'cluster_b')).toContain('batch'); - expect(classesOf(svg, 'A->B')).toContain('muted'); + expect(classesOf(svg, 'A')).toEqual(['node', 'failed']); + expect(classesOf(svg, 'cluster_b')).toEqual(['cluster', 'batch']); + expect(classesOf(svg, 'A->B')).toEqual(['edge', 'muted']); }); - it('reads a cluster drawn with a stroke and no fill', async () => { - const svg = await render(`digraph { - subgraph cluster_w { style="rounded"; color="#6B7280"; label="Waiting"; A; } - }`); - expect(classesOf(svg, 'cluster_w')).toContain('muted'); + it('never invents a role for an element that names none', async () => { + const svg = await render('digraph { A; A -> B; }'); + expect(classesOf(svg, 'A')).toEqual(['node']); + expect(classesOf(svg, 'A->B')).toEqual(['edge']); }); - it('lets an authored role win over the substitution table', async () => { - const svg = await render(`digraph { - node [style=filled]; - A [fillcolor="#347D39", class="failed"]; - }`); - expect(classesOf(svg, 'A')).toContain('failed'); - expect(classesOf(svg, 'A')).not.toContain('queued'); + it('applies the layout defaults of the kind a fence opts into', async () => { + const flow = await render('digraph { A -> B; }', 'class="flow"'); + const queue = await render('digraph { A -> B; }', 'class="queue"'); + // `flow` is top-to-bottom and `queue` is left-to-right, so the same two + // nodes come out stacked in one and side by side in the other. + const box = (svg: string) => /viewBox="[\d.]+ [\d.]+ ([\d.]+) ([\d.]+)"/.exec(svg)!; + expect(Number(box(flow)[1])).toBeLessThan(Number(box(flow)[2])); + expect(Number(box(queue)[1])).toBeGreaterThan(Number(box(queue)[2])); + }); + + it('does not reach Object.prototype for a kind a fence made up', async () => { + // The class comes from the fence, so an unguarded lookup would splice + // `function Object() { [native code] }` into the DOT and fail the render. + const svg = await render('digraph { A -> B; }', 'class="constructor"'); + expect(svg).toMatch(/<svg\b/); }); it('marks a borderless node as plain, so it reads as a caption', async () => { @@ -115,28 +118,6 @@ describe('remarkGraphvizPlugin', () => { expect(square).toMatch(/<g id="node1"[^>]*>\s*<title>A<\/title>\s*<polygon/); }); - it('does not let a border speak for the shape it outlines', async () => { - // An unmapped fill must fall back to the element default, not to whatever - // role the node's darker border happens to match. - const svg = await render(`digraph { - node [style=filled]; - A [fillcolor="#ABCDEF", color="#374151"]; - }`); - expect(classesOf(svg, 'A')).toEqual(['node']); - }); - - it('does not guess a colour for an element whose author named a class', async () => { - // Even an unrecognised class means the author said something; appending a - // guessed role would contradict them with no warning. - const svg = await render(`digraph { - node [style=filled]; - A [class="Queued", fillcolor="#DC2626"]; - B [shape=plaintext, class="plain", fillcolor="#347D39"]; - }`); - expect(classesOf(svg, 'A')).toEqual(['node', 'Queued']); - expect(classesOf(svg, 'B')).toEqual(['node', 'plain']); - }); - it('strips the alpha Graphviz emits alongside a colour', async () => { const svg = await render(`digraph { node [style=filled]; diff --git a/plugins/remark-graphviz.ts b/plugins/remark-graphviz.ts index e92207671a..eb101802a5 100644 --- a/plugins/remark-graphviz.ts +++ b/plugins/remark-graphviz.ts @@ -3,12 +3,7 @@ import { load } from 'cheerio'; import type * as mdast from 'mdast'; import type * as unified from 'unified'; import { CONTINUE, visit } from 'unist-util-visit'; -import { - type DiagramKind, - type DiagramRole, - finishDiagramSvg, - type ShapePaint, -} from '../src/util/diagramSvg'; +import { finishDiagramSvg } from '../src/util/diagramSvg'; /** * Render `dot` / `circo` / `neato` fences to inline SVG, and hand every colour @@ -16,12 +11,17 @@ import { * * Graphviz supports a `class` attribute on graphs, nodes, edges and clusters * and copies it verbatim into the SVG (`class="node queued"`). So this plugin - * never resolves a colour: it injects shape and spacing defaults, drops the - * opaque canvas, tags each element with a *role*, and strips the inline paint - * so the `.dg` rules in `index.css` resolve surface, border and label at paint - * time from the role accents in `theme.css`. Dark mode then arrives through the - * same `:root.theme-dark` block as every other surface on the site, with no - * second render and no string matching. + * never resolves a colour and never names a role: the fence names them, and + * the plugin only injects shape and spacing defaults, drops the opaque canvas + * and strips the inline paint, so the `.dg` rules in `index.css` resolve + * surface, border and label at paint time from the role accents in + * `theme.css`. Dark mode then arrives through the same `:root.theme-dark` + * block as every other surface on the site, with no second render and no + * string matching. + * + * The one class the rendering side still adds is `plain`, in + * `finishDiagramSvg` — a shape fact (this element is a caption, not a box), + * not a colour. */ const viz = await instance(); @@ -46,103 +46,41 @@ const METRICS_FONT = 'Helvetica'; * default is below. */ const BASE = ` - graph [bgcolor="transparent", fontname="${METRICS_FONT}", fontsize=13, - labelloc="t", pad="0.12", nodesep=0.45, ranksep=0.55]; + graph [bgcolor="transparent", style="rounded", fontname="${METRICS_FONT}", + fontsize=13, labelloc="t", pad="0.12", nodesep=0.45, ranksep=0.55]; node [fontname="${METRICS_FONT}", fontsize=13, shape=box, style="rounded,filled", penwidth=1.4, margin="0.24,0.15", height=0.42]; edge [fontname="${METRICS_FONT}", fontsize=10, penwidth=1.3, arrowsize=0.7]; `; /** - * Transitional: the colours the docs were drawn with, mapped onto roles. - * - * Four independent dialects grew here — queue-green, emoji-pastel, - * nineties-pastel and near-white-blueprint — because there was no palette to be - * consistent with. This table maps by the hue family each dialect used, so two - * elements drawn in the same colour still read alike. It is lossy in the other - * direction: where one dialect used two shades of a hue for two meanings, both - * land on one role — PostgreSQL and Redis both become `datastore`, and - * "tests passed" and "merged to main" both become `merged`. That is the price - * of recolouring the whole corpus without editing a single fence, and it is - * paid back one page at a time as each fence names its own roles. - * - * It is a migration shim with a known end: once every fence names its own role, - * nothing reaches this table and it goes away. Keys are lowercase hex. + * Diagram kinds. A fence opts into one through its class — ```dot class="queue" + * — and it sets layout, never colour. A fence that names none gets BASE alone + * and lays itself out. */ -const LEGACY_ROLES: Record<string, DiagramRole> = { - // Queue dialect — batches, performance, stacks, queue-modes, scopes, - // direct-merge, gha, buildkite. - '#347d39': 'queued', // queue green: a pull request in the queue - '#1cb893': 'config', // Merge Queue teal, as a node: a scope or a config value - '#6b7280': 'muted', // skipped, waiting, not selected - '#9ca3af': 'muted', // cascaded out, dashed side-links - '#111827': 'external', // CI, ci-gate, main - '#0b1120': 'external', - '#2563eb': 'pending', // the detect-scopes step, mid-run - '#dc2626': 'failed', - '#374151': 'chrome', // the edge colour the old plugin string-matched - '#4b5563': 'chrome', - '#5b21b6': 'chrome', // stacks: edges and their labels - - // Emoji-pastel dialect — lifecycle, two-step. - '#f3f4f6': 'external', // dequeued: out of the queue - '#fff4ed': 'pending', // queueing, validating, testing - '#ede9fe': 'queued', - '#f3e8ff': 'queued', // the queue command - '#dbeafe': 'config', - '#d1fae5': 'merged', - '#ddd6fe': 'merged', // merged to main - '#fee2e2': 'failed', - '#10b981': 'merged', // the "passed" edge - '#ef4444': 'failed', // the "failed" edge - '#7c3aed': 'chrome', // the default edge colour on both pages - - // Nineties-pastel dialect — flaky-test-detection. - '#c9e7f8': 'config', // the commit under test - '#b7f5c1': 'merged', // tests passed - '#f8c9c9': 'failed', // tests failed - '#d8f0ff': 'external', // "consistent (not flaky)" - '#ffe9b3': 'pending', // "flagged as flaky" - '#999999': 'muted', // the dashed commit clusters - - // Near-white-blueprint dialect — enterprise/architecture. - '#f6f8fb': 'external', // the default node fill - '#ffffff': 'external', // GitHub - '#24292e': 'external', - '#fff3d6': 'config', // the reverse proxy: the entry point - '#e6f0ff': 'mergify', // dashboard and workers - '#f0ecfe': 'mergify', // the subscription API - '#f4fbff': 'mergify', // the on-premise cluster - '#e4f5ed': 'datastore', // PostgreSQL - '#fce3e8': 'datastore', // Redis - '#fdfeff': 'batch', // the customer-infrastructure cluster - '#8892bf': 'chrome', +const KINDS: Record<string, string> = { + queue: `rankdir="LR"; splines="polyline"; nodesep=0.32; ranksep=0.45;`, + flow: `rankdir="TB"; splines="spline"; nodesep=0.55; ranksep=0.6;`, + arch: `rankdir="TB"; splines="ortho"; nodesep=0.8; ranksep=1.0; + node [width=2.5, margin="0.34,0.24"];`, }; -/** A cluster reads its colour differently: teal is a container, not a value. */ -const LEGACY_CLUSTER_ROLES: Record<string, DiagramRole> = { - ...LEGACY_ROLES, - '#1cb893': 'batch', -}; - -/** Inject the base defaults immediately after the opening brace. */ -function injectDefaults(source: string): string { +/** + * Inject the base defaults, plus any kind the fence opted into, immediately + * after the opening brace, so anything the fence writes afterwards overrides + * them. `Object.hasOwn` because the class comes from the fence: a fence + * classed `constructor` would otherwise inject `Object`'s own into the DOT. + */ +function injectDefaults(source: string, classes: string[]): string { const brace = source.indexOf('{'); if (brace === -1) return source; - return `${source.slice(0, brace + 1)}\n${BASE}\n${source.slice(brace + 1)}`; -} -/** - * Name the role of an element the fence did not name, from the paint Graphviz - * gave it. A cluster drawn with `style=rounded` and no fill carries its colour - * on the stroke instead; nothing else falls back, because an unmapped fill must - * not let a border speak for the shape it merely outlines. - */ -function legacyRoleFor(kind: DiagramKind, { fill, stroke }: ShapePaint): DiagramRole | undefined { - if (kind === 'edge') return stroke ? LEGACY_ROLES[stroke.toLowerCase()] : undefined; - const table = kind === 'cluster' ? LEGACY_CLUSTER_ROLES : LEGACY_ROLES; - const color = !fill || fill === 'none' ? (kind === 'cluster' ? stroke : undefined) : fill; - return color ? table[color.toLowerCase()] : undefined; + let defaults = BASE; + for (const kind of classes) { + if (Object.hasOwn(KINDS, kind)) defaults += `\n ${KINDS[kind]}\n`; + } + + return `${source.slice(0, brace + 1)}\n${defaults}\n${source.slice(brace + 1)}`; } export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { @@ -165,7 +103,7 @@ export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { const attrs = attrString ? load(`<element ${attrString}></element>`)(`element`) : null; const classes = (attrs?.attr('class') ?? '').split(/\s+/).filter(Boolean); - const svgString = viz.renderString(injectDefaults(node.value), { + const svgString = viz.renderString(injectDefaults(node.value, classes), { format: 'svg', engine: lang, }); @@ -175,7 +113,7 @@ export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { // from them, so a fence can add a kind without losing `dg`. const fenceAttrs = attrs?.attr(); if (fenceAttrs) $(`svg`).attr(fenceAttrs); - finishDiagramSvg($, { classes, roleFor: legacyRoleFor }); + finishDiagramSvg($, { classes }); // Rewrite the fence in place: it stops being a code block and becomes // the rendered SVG. mdast has no in-place conversion, so the node diff --git a/scripts/check-diagram-tokens.mjs b/scripts/check-diagram-tokens.mjs new file mode 100644 index 0000000000..4047c02132 --- /dev/null +++ b/scripts/check-diagram-tokens.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Scan the docs for diagrams that name a colour. + * + * A diagram names a *role* — `queued`, `merged`, `failed`, `external` — and the + * page resolves it from the design tokens at paint time, so the diagram themes + * itself and every page uses one palette. That worked until it didn't: before + * this rule existed, 63 distinct colours had accumulated across 18 pages in four + * unrelated dialects, none of which the token system could reach and none of + * which adapted to dark mode. + * + * Nothing about a hardcoded colour fails a build on its own — the diagram + * renders, it just renders wrong on half the site — so this is the thing that + * keeps it from happening again. See DESIGN.md "Diagrams" for the roles. + * + * Usage: + * node scripts/check-diagram-tokens.mjs [paths...] + * # with no paths: the docs pages, the components, and the shared + * # post-processor — see DEFAULT_TARGETS below. + * node scripts/check-diagram-tokens.mjs --json [paths...] + * + * To allow a specific line, put a comment on the line before it naming the rule: + * + * // diagram-tokens: allow graphviz-color-attr — a legend of the palette itself + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +const DEFAULT_TARGETS = ['src/content/docs', 'src/components', 'src/util/diagramSvg.ts']; + +/** + * Page extensions, scanned fence by fence. The diagram components below are + * not pages and are matched by name instead, whatever their extension — which + * is why `.astro` is absent here and `src/util/diagramSvg.ts` is named as a + * target of its own: it lives outside every directory scanned above. + */ +const SCANNED_EXTENSIONS = ['.mdx', '.md']; + +/** + * Each rule is deliberately narrow, because a false positive lands on a docs + * contributor. `#RRGGBB` is required in full: a three-digit match would fire on + * every `PR #101` in a diagram label, which is how the diagrams talk. + */ +export const RULES = [ + { + id: 'graphviz-color-attr', + label: 'Graphviz colour attribute', + // `(?<![\w-])` so `fillcolor` is reported once, as itself, rather than also + // matching the bare `color` inside it. `none` and `transparent` are not + // colours — they are the absence of one, which is shape work, not styling, + // and the way a diagram says "draw no canvas" or "draw no border". + re: /(?<![\w-])(?:bg|fill|font|pen|label)?color\s*=\s*(?!"?(?:none|transparent)\b)/gi, + }, + { + id: 'diagram-hex', + label: 'hardcoded colour', + re: /#[0-9a-f]{6}\b/gi, + }, +]; + +/** A `dot` / `circo` / `neato` fence, and the fence-attribute line above it. */ +const FENCE_RE = /^([ \t]*)```(?:dot|circo|neato)([^\n]*)\n([\s\S]*?)^[ \t]*```/gm; + +/** + * The hand-drawn diagram components. They are not fences, so the fence scan + * cannot see them — and they are exactly where the second and third private + * palettes lived before they were folded into the shared one. + */ +const DIAGRAM_COMPONENTS = ['GitGraph.astro', 'StackMapping.astro', 'diagramSvg.ts']; + +const ALLOW_RE = /diagram-tokens:\s*allow\s+([\w-]+(?:\s*,\s*[\w-]+)*)/i; + +/** Rule IDs allowed by a directive on the line above `index` (0-based). */ +function allowedOnLine(lines, index) { + const allowed = new Set(); + for (let p = index - 1; p >= 0; p -= 1) { + if (!lines[p].trim()) continue; + const m = lines[p].match(ALLOW_RE); + if (m) for (const id of m[1].split(',')) allowed.add(id.trim().toLowerCase()); + break; + } + return allowed; +} + +/** + * The 1-based line numbers that belong to a diagram: everything inside a + * Graphviz fence, plus its attribute line. Everything else in a docs page is + * prose and code samples, where a colour is ordinary content — a CSS example, + * a screenshot description, a config value. + */ +function diagramLines(text) { + const lines = new Set(); + for (const m of text.matchAll(FENCE_RE)) { + const start = text.slice(0, m.index).split('\n').length; + const length = `${m[2]}\n${m[3]}`.split('\n').length; + for (let i = 0; i < length; i += 1) lines.add(start + i); + } + return lines; +} + +/** Scan text; returns [{line, rule, label, match}, ...]. */ +export function scanText(text, { wholeFile = false, rules = RULES } = {}) { + const lines = text.split('\n'); + const inDiagram = wholeFile ? null : diagramLines(text); + const findings = []; + + lines.forEach((line, i) => { + if (inDiagram && !inDiagram.has(i + 1)) return; + let allowed = null; + for (const rule of rules) { + rule.re.lastIndex = 0; + const matches = line.match(rule.re); + if (!matches) continue; + allowed ??= allowedOnLine(lines, i); + if (allowed.has(rule.id)) continue; + for (const match of new Set(matches)) { + findings.push({ line: i + 1, rule: rule.id, label: rule.label, match }); + } + } + }); + + return findings; +} + +export function scanFile(file) { + const wholeFile = DIAGRAM_COMPONENTS.includes(path.basename(file)); + return scanText(fs.readFileSync(file, 'utf8'), { wholeFile }).map((f) => ({ + ...f, + file: path.relative(ROOT, file), + })); +} + +export function* iterFiles(targets) { + for (const t of targets) { + const abs = path.resolve(ROOT, t); + if (!fs.existsSync(abs)) continue; + const stat = fs.statSync(abs); + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(abs, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue; + const name = entry.name; + const isComponent = DIAGRAM_COMPONENTS.includes(name); + const isPage = SCANNED_EXTENSIONS.some((ext) => name.endsWith(ext)); + if (isComponent || isPage) yield path.join(entry.parentPath ?? entry.path, name); + } + } else { + yield abs; + } + } +} + +function main(argv) { + const jsonMode = argv.includes('--json'); + const targets = argv.filter((a) => a !== '--json'); + if (targets.length === 0) targets.push(...DEFAULT_TARGETS); + + const findings = []; + let scanned = 0; + for (const file of iterFiles(targets)) { + scanned += 1; + findings.push(...scanFile(file)); + } + + if (jsonMode) { + process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`); + return findings.length === 0 ? 0 : 1; + } + + console.log(`Scanned ${scanned} file(s) for diagrams that name a colour.`); + if (findings.length === 0) { + console.log('Every diagram resolves its colours from tokens.'); + return 0; + } + console.error(`\n${findings.length} diagram colour(s) written by hand:\n`); + for (const f of findings) { + console.error(` ${f.file}:${f.line} — ${f.label} — ${f.match}`); + } + console.error( + '\nName a role instead, and let the page resolve the colour:\n' + + ' PR1 [class="queued"]; not PR1 [fillcolor="#347D39"];\n' + + 'The roles are queued, pending, merged, failed, config, mergify,\n' + + 'datastore, external, batch, muted and chrome; `plain` marks a caption\n' + + 'rather than a box. See DESIGN.md "Diagrams". If a colour is genuinely\n' + + 'the subject rather than the styling, allow it on the line above:\n' + + ' // diagram-tokens: allow <rule-id>[, <rule-id>...] — why' + ); + return 1; +} + +// Run as a CLI only when invoked directly, so tests can import the helpers. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/check-diagram-tokens.test.mjs b/scripts/check-diagram-tokens.test.mjs new file mode 100644 index 0000000000..f3dbf7d86f --- /dev/null +++ b/scripts/check-diagram-tokens.test.mjs @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { iterFiles, scanFile, scanText } from './check-diagram-tokens.mjs'; + +const fence = (body) => ['```dot class="queue"', body, '```'].join('\n'); +const rulesOf = (text, opts) => scanText(text, opts).map((f) => f.rule); + +describe('scanText', () => { + it('catches a colour written inside a diagram', () => { + expect(rulesOf(fence('A [fillcolor="#347D39"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + expect(rulesOf(fence('edge [color="#374151"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + expect(rulesOf(fence('graph [bgcolor="#FAFBFC"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + // A named colour carries no hex, and is just as unreachable by the tokens. + expect(rulesOf(fence('A [fontcolor=white];'))).toEqual(['graphviz-color-attr']); + }); + + it('reports a compound attribute once, as itself', () => { + const found = scanText(fence('A [fillcolor="#347D39"];')); + expect(found.map((f) => f.match)).toEqual(['fillcolor=', '#347D39']); + }); + + it('leaves a diagram that names roles alone', () => { + expect( + rulesOf( + fence(`digraph { + subgraph cluster_b { class="batch"; label="Batch 1"; PR1 [class="queued"]; } + CI [label="Continuous\\nintegration", class="external"]; + PR1 -> CI [class="muted", style=dashed]; +}`) + ) + ).toEqual([]); + }); + + it('treats none and transparent as shape, not colour', () => { + // These say "draw nothing", which is the one thing a role cannot express. + expect(rulesOf(fence('graph [bgcolor="transparent"];'))).toEqual([]); + expect(rulesOf(fence('A [color=none];'))).toEqual([]); + expect(rulesOf(fence('A [color="none", fillcolor="#347D39"];'))).toEqual([ + 'graphviz-color-attr', + 'diagram-hex', + ]); + }); + + it('does not fire on the pull request numbers diagrams are full of', () => { + expect(rulesOf(fence('PR1 [label="PR #101\\nScopes: frontend"];'))).toEqual([]); + }); + + it('ignores colours outside a diagram', () => { + // A docs page is mostly prose and code samples, where a colour is content. + const page = [ + 'Set the badge colour with `color="#347D39"` in your config.', + '', + '```css', + '.badge { color: #347d39; }', + '```', + '', + fence('A [class="queued"];'), + ].join('\n'); + expect(rulesOf(page)).toEqual([]); + }); + + it('scans a hand-drawn diagram component end to end', () => { + // GitGraph and StackMapping draw SVG directly, so they have no fence for the + // fence scan to find — and they are where two of the four dialects lived. + const component = 'const COLORS = { green: "#347D39" };'; + expect(rulesOf(component)).toEqual([]); + expect(rulesOf(component, { wholeFile: true })).toEqual(['diagram-hex']); + }); + + it('honours an allow directive on the line above', () => { + expect( + rulesOf( + fence(`// diagram-tokens: allow diagram-hex — this diagram is about the colour +A [label="#347D39"];`) + ) + ).toEqual([]); + }); +}); + +describe('the docs themselves', () => { + it('has no diagram that names a colour', () => { + const findings = [...iterFiles(['src/content/docs', 'src/components'])].flatMap(scanFile); + expect(findings).toEqual([]); + }); +}); diff --git a/src/content/docs/ci-insights/flaky-test-detection.mdx b/src/content/docs/ci-insights/flaky-test-detection.mdx index 469d925553..eea5bf4662 100644 --- a/src/content/docs/ci-insights/flaky-test-detection.mdx +++ b/src/content/docs/ci-insights/flaky-test-detection.mdx @@ -30,68 +30,52 @@ Below is a simplified visualization of repeated runs on one commit: two executions of the same tests on the exact same commit (same SHA1) produce different results. -```dot class="graph" -strict digraph flaky { +```dot class="queue" +digraph { + label="Two runs of one test, on three commits"; rankdir=LR; - fontname="sans-serif"; - node [style=filled, shape=box, fontname="sans-serif", color="black", fontcolor="black"]; - edge [color="black", fontname="sans-serif"]; - // Non-flaky example (third commit, consistent FAIL results) - subgraph cluster_commit3 { - label="Commit SHA1 ghi789"; - fontname="sans-serif"; - color="#999999"; - style=dashed; - commit3 [label="test_something\n(SHA1 ghi789)", shape=oval, fillcolor="#C9E7F8"]; + subgraph cluster_commit1 { + class="batch"; + label="Commit abc123"; + commit1 [label="test_something2", shape=oval, class="config"]; } + run1 [label="Run #1 — tests pass", class="merged"]; + run2 [label="Run #2 — tests fail", class="failed"]; + detector [label="Flagged as flaky", shape=note, class="pending"]; + commit1 -> run1 [label=" run 1"]; + commit1 -> run2 [label=" run 2"]; + { rank=same; run1; run2; } + run1 -> detector [class="muted", style=dashed, arrowhead=none]; + run2 -> detector [class="muted", style=dashed, arrowhead=none]; - commit3 -> run5 [label="Run #1", arrowhead=normal]; - commit3 -> run6 [label="Run #2", arrowhead=normal]; - run5 [label="Tests FAIL", fillcolor="#F8C9C9"]; - run6 [label="Tests FAIL", fillcolor="#F8C9C9"]; - {rank=same; run5; run6} - stable2 [label="Consistent\n(Not Flaky)", shape=note, fillcolor="#D8F0FF"]; - run5 -> stable2 [style=dashed, arrowhead=none]; - run6 -> stable2 [style=dashed, arrowhead=none]; - - // Non-flaky example (different commit, consistent PASS results) subgraph cluster_commit2 { - label="Commit SHA1 def456"; - fontname="sans-serif"; - color="#999999"; - style=dashed; - commit2 [label="test_something\n(SHA1 def456)", shape=oval, fillcolor="#C9E7F8"]; + class="batch"; + label="Commit def456"; + commit2 [label="test_something", shape=oval, class="config"]; } + run3 [label="Run #1 — tests pass", class="merged"]; + run4 [label="Run #2 — tests pass", class="merged"]; + stable [label="Consistent\nnot flaky", shape=note, class="external"]; + commit2 -> run3 [label=" run 1"]; + commit2 -> run4 [label=" run 2"]; + { rank=same; run3; run4; } + run3 -> stable [class="muted", style=dashed, arrowhead=none]; + run4 -> stable [class="muted", style=dashed, arrowhead=none]; - commit2 -> run3 [label="Run #1", arrowhead=normal]; - commit2 -> run4 [label="Run #2", arrowhead=normal]; - run3 [label="Tests PASS", fillcolor="#B7F5C1"]; - run4 [label="Tests PASS", fillcolor="#B7F5C1"]; - {rank=same; run3; run4} - stable [label="Consistent\n(Not Flaky)", shape=note, fillcolor="#D8F0FF"]; - run3 -> stable [style=dashed, arrowhead=none]; - run4 -> stable [style=dashed, arrowhead=none]; - - - // Flaky example (same commit, differing results) - subgraph cluster_commit1 { - label="Commit SHA1 abc123"; - fontname="sans-serif"; - color="#999999"; - style=dashed; - commit1 [label="test_something2\n(SHA1 abc123)", shape=oval, fillcolor="#C9E7F8"]; + subgraph cluster_commit3 { + class="batch"; + label="Commit ghi789"; + commit3 [label="test_something", shape=oval, class="config"]; } - - commit1 -> run1 [label="Run #1", arrowhead=normal]; - commit1 -> run2 [label="Run #2", arrowhead=normal]; - run1 [label="Tests PASS", fillcolor="#B7F5C1"]; - run2 [label="Tests FAIL", fillcolor="#F8C9C9"]; - {rank=same; run1; run2} - detector [label="Flagged as\nflaky", shape=note, fillcolor="#FFE9B3"]; - run1 -> detector [style=dashed, arrowhead=none]; - run2 -> detector [style=dashed, arrowhead=none]; - + run5 [label="Run #1 — tests fail", class="failed"]; + run6 [label="Run #2 — tests fail", class="failed"]; + stable2 [label="Consistent\nnot flaky", shape=note, class="external"]; + commit3 -> run5 [label=" run 1"]; + commit3 -> run6 [label=" run 2"]; + { rank=same; run5; run6; } + run5 -> stable2 [class="muted", style=dashed, arrowhead=none]; + run6 -> stable2 [class="muted", style=dashed, arrowhead=none]; } ``` diff --git a/src/content/docs/enterprise/architecture.mdx b/src/content/docs/enterprise/architecture.mdx index 6af075ae60..4ba57fb24d 100644 --- a/src/content/docs/enterprise/architecture.mdx +++ b/src/content/docs/enterprise/architecture.mdx @@ -26,54 +26,42 @@ full control over data flows. ## High-level diagram <div class="enterprise-graph" data-graph-zoom> - ```dot class="graph" - digraph Architecture { - rankdir=TB; - graph [label="Mergify Enterprise – High-Level Architecture", labelloc=t, fontsize=26, pad=1.2, nodesep=1.4, ranksep=1.9, splines=ortho, fontname="Helvetica"]; - node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=18, fillcolor="#F6F8FB", color="#C6D4F3", fontcolor="#1F2937", margin="0.45,0.35", width=3.4]; - - edge [color="#8892BF", arrowsize=0.9, fontsize=16, fontname="Helvetica"]; + ```dot class="arch" + digraph { + label="Mergify Enterprise — high-level architecture"; subgraph cluster_customer { - label="Customer Infrastructure"; - style="rounded,filled"; - color="#E1E8FF"; - fillcolor="#FDFEFF"; + class="batch"; + label="Customer infrastructure"; - User [shape=plaintext label="👤 User", fontname="Helvetica", fontsize=18]; - ReverseProxy [label="Reverse proxy\n(HTTPS exposed)", fillcolor="#FFF3D6", color="#E5C17C"]; + User [label="User", class="external"]; + ReverseProxy [label="Reverse proxy\n(HTTPS exposed)", class="config"]; subgraph cluster_onprem { - label="Mergify On-Premise"; - style="rounded,filled"; - color="#D8F1FF"; - fillcolor="#F4FBFF"; - - Dashboard [label="Dashboard & API\n(port 5000)", fillcolor="#E6F0FF", color="#A7C4FF"]; - Workers [label="Mergify workers", fillcolor="#E6F0FF", color="#A7C4FF"]; + class="mergify"; + label="Mergify on-premise"; + Dashboard [label="Dashboard & API\n(port 5000)", class="mergify"]; + Workers [label="Mergify workers", class="mergify"]; } - PostgreSQL [label="PostgreSQL database", fillcolor="#E4F5ED", color="#7BC9A9"]; - Redis [label="Redis cluster", fillcolor="#FCE3E8", color="#F18AA0"]; + PostgreSQL [label="PostgreSQL database", class="datastore"]; + Redis [label="Redis cluster", class="datastore"]; } - GitHub [shape=box3d label="GitHub", color="#24292E", fontcolor="#24292E", style=filled, fillcolor="#FFFFFF", fontsize=18]; - Subscription [label="Mergify Subscription API", fillcolor="#F0ECFE", color="#B9A0FF", fontsize=18]; + GitHub [label="GitHub", class="external"]; + Subscription [label="Mergify Subscription API", class="mergify"]; GitHub -> ReverseProxy [label="GitHub events"]; - ReverseProxy -> Dashboard [label="Forwarded events"]; - Dashboard -> Workers [label="Queue jobs via Redis"]; - + ReverseProxy -> Dashboard [label="forwarded events"]; + Dashboard -> Workers [label="queue jobs via Redis"]; Dashboard -> PostgreSQL; Dashboard -> Redis; Workers -> PostgreSQL; Workers -> Redis; - Workers -> GitHub [label="API requests to GitHub", dir=both]; - ReverseProxy -> User [dir=both label="UI access"]; - GitHub -> User [label="Developers", dir=both]; - - Workers -> Subscription [label="Subscription lookup"]; + ReverseProxy -> User [label="UI access", dir=both]; + GitHub -> User [label="developers", dir=both]; + Workers -> Subscription [label="subscription lookup", class="muted", style=dashed]; } ``` </div> diff --git a/src/content/docs/integrations/buildkite.mdx b/src/content/docs/integrations/buildkite.mdx index b49072abcc..c35b62f35c 100644 --- a/src/content/docs/integrations/buildkite.mdx +++ b/src/content/docs/integrations/buildkite.mdx @@ -89,30 +89,21 @@ A Buildkite pipeline driven by scopes has two parts: 2. **Use a dynamic pipeline** to conditionally upload only the steps that match the affected scopes. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - nodesep=0.9; - ranksep=1.1; - splines=polyline; +```dot class="queue" +digraph { + PR [label="Pull request\nchanges", class="queued"]; + Config [label="Scopes config\n(.mergify.yml)", class="config"]; + Detect [label="detect-scopes step\n(mergify-ci plugin)", class="pending"]; - node [shape=box, style="rounded,filled", fontname="sans-serif", margin="0.35,0.2", color="#165B33", fillcolor="#347D39", fontcolor="white"]; - edge [fontname="sans-serif", color="#374151", penwidth=1.2, arrowhead=normal]; - - PR [label="Pull request\nchanges"]; - Config [fillcolor="#1CB893", color="#0B7A5C", fontcolor="#063C2C", label="Scopes config\n(.mergify.yml)"]; - Detect [fillcolor="#2563EB", color="#1E40AF", label="detect-scopes step\n(mergify-ci plugin)"]; - - Frontend [label="frontend-tests\n(run)"]; - API [fillcolor="#6B7280", color="#4B5563", label="api-tests\n(skipped)"]; - Docs [label="docs-tests\n(run)"]; + Frontend [label="frontend-tests\n(run)", class="queued"]; + Docs [label="docs-tests\n(run)", class="queued"]; + API [label="api-tests\n(skipped)", class="muted"]; PR -> Detect; Config -> Detect; Detect -> Frontend [label="scope: frontend"]; Detect -> Docs [label="scope: docs"]; - Detect -> API [style=dashed, color="#9CA3AF", fontcolor="#9CA3AF", label="scope: api (false)"]; + Detect -> API [label="scope: api (false)", class="muted", style=dashed]; } ``` diff --git a/src/content/docs/integrations/gha.mdx b/src/content/docs/integrations/gha.mdx index a754816d90..db14a7a271 100644 --- a/src/content/docs/integrations/gha.mdx +++ b/src/content/docs/integrations/gha.mdx @@ -98,31 +98,23 @@ A GitHub Actions workflow driven by scopes has three parts: 3. **Publish a final status** (for example with a `ci-gate` job) if you want one check that reflects all the jobs that ran. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - nodesep=0.9; - ranksep=1.1; - splines=polyline; - - node [shape=box, style="rounded,filled", fontname="sans-serif", margin="0.35,0.2", color="#165B33", fillcolor="#347D39", fontcolor="white"]; - edge [fontname="sans-serif", color="#374151", penwidth=1.2, arrowhead=normal]; - - PR [label="Pull request\nchanges"]; - Config [fillcolor="#1CB893", color="#0B7A5C", fontcolor="#063C2C", label="Scopes config\n(.mergify.yml)"]; - Detect [fillcolor="#2563EB", color="#1E40AF", label="detect-scopes job\n(gha-mergify-ci)"]; - - Frontend [label="frontend-tests\n(run)"]; - API [fillcolor="#6B7280", color="#4B5563", label="api-tests\n(skipped)"]; - Docs [label="docs-tests\n(run)"]; - Gate [fillcolor="#111827", color="#0B1120", label="ci-gate\n(optional)"]; +```dot class="queue" +digraph { + PR [label="Pull request\nchanges", class="queued"]; + Config [label="Scopes config\n(.mergify.yml)", class="config"]; + Detect [label="detect-scopes job\n(gha-mergify-ci)", class="pending"]; + + Frontend [label="frontend-tests\n(run)", class="queued"]; + Docs [label="docs-tests\n(run)", class="queued"]; + API [label="api-tests\n(skipped)", class="muted"]; PR -> Detect; Config -> Detect; Detect -> Frontend [label="scope: frontend"]; Detect -> Docs [label="scope: docs"]; - Detect -> API [style=dashed, color="#9CA3AF", fontcolor="#9CA3AF", label="scope: api (false)"]; + Detect -> API [label="scope: api (false)", class="muted", style=dashed]; + + Gate [label="ci-gate\n(optional)", class="external"]; Frontend -> Gate; Docs -> Gate; } diff --git a/src/content/docs/merge-queue/batches.mdx b/src/content/docs/merge-queue/batches.mdx index 3805368003..f47aad1e74 100644 --- a/src/content/docs/merge-queue/batches.mdx +++ b/src/content/docs/merge-queue/batches.mdx @@ -51,33 +51,25 @@ queue_rules: batch_size: 3 ``` -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Merge Queue" - - node [style=filled, shape=circle, fontcolor="white", fontname="sans-serif"]; - edge [color="#374151", arrowhead=none, fontname="sans-serif", arrowhead=normal]; - - subgraph cluster_batch_0 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [style=filled, color="black", fillcolor="#347D39", fontcolor="white"]; - PR1 -> PR2; - PR2 -> PR3; - label = "Batch 1"; - } +```dot class="queue" +digraph { + label="Merge queue"; + + subgraph cluster_batch1 { + class="batch"; + label="Batch 1"; + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + PR1 -> PR2 -> PR3; + } - PR3 -> PR4; - PR4 -> PR5; - PR5 [label="…", fillcolor="#347D39"]; + PR4 [class="queued"]; + PR5 [label="…", class="queued"]; + PR3 -> PR4 -> PR5; - CI [label="Continuous\nIntegration", fixedsize=false, style="filled", fillcolor="#111827", fontcolor=white, shape=rectangle] - edge [arrowhead=none, style=dashed, arrowtail=normal, color="#9CA3AF", dir=both, fontcolor="#9CA3AF", fontsize="6pt"]; - PR3 -> CI; + CI [label="Continuous\nintegration", class="external"]; + PR3 -> CI [class="muted", style=dashed, dir=both, arrowhead=none, arrowtail=normal]; } ``` @@ -230,35 +222,23 @@ Because of this, a pull request further down the queue may join an earlier batch when it is similar to what is already there, while a closer but unrelated pull request waits for the next batch: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; +```dot class="queue" +digraph { label="batch_size: 2 — similar pull requests grouped together"; - nodesep=0.5; - ranksep=0.8; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; edge [style=invis]; subgraph cluster_batch1 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Batch 1"; - PR1 [label="PR #1\n(api/)", fillcolor="#347D39"]; - PR3 [label="PR #3\n(api/)", fillcolor="#347D39"]; + PR1 [label="PR #1\n(api/)", class="queued"]; + PR3 [label="PR #3\n(api/)", class="queued"]; } subgraph cluster_batch2 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Batch 2"; - PR2 [label="PR #2\n(docs/)", fillcolor="#347D39"]; - PR4 [label="PR #4\n(web/)", fillcolor="#347D39"]; + PR2 [label="PR #2\n(docs/)", class="queued"]; + PR4 [label="PR #4\n(web/)", class="queued"]; } PR1 -> PR3 -> PR2 -> PR4; diff --git a/src/content/docs/merge-queue/direct-merge.mdx b/src/content/docs/merge-queue/direct-merge.mdx index 242f8c1df1..ed24094094 100644 --- a/src/content/docs/merge-queue/direct-merge.mdx +++ b/src/content/docs/merge-queue/direct-merge.mdx @@ -27,51 +27,34 @@ two merges that touched `api` and `docs`. Nothing PR #42 depends on has changed, still describes the merge and the queue skips straight to merging it. Had the queue landed nothing at all in the meantime, the first question alone would have been enough. -```dot class="graph" -strict digraph { - rankdir="TB"; - label="Direct Merge — what the queue checks before skipping CI"; - labelloc="t"; - nodesep=0.55; - ranksep=0.6; +```dot class="flow" +digraph { + label="Direct merge — what the queue checks before skipping CI"; splines=polyline; - node [shape=box, style="rounded,filled", margin="0.28,0.18"]; - edge [penwidth=1.4]; - subgraph cluster_base { - style="rounded"; - color="#1CB893"; - label="Merged while PR #42 waited"; - - M1 [label="queue merge\nscope: api", fillcolor="#6B7280", color="#4B5563", width=2.6]; - M2 [label="queue merge\nscope: docs", fillcolor="#6B7280", color="#4B5563", width=2.6]; + class="batch"; + label="Merged while #42 waited"; + M1 [label="queue merge\nscope: api", class="muted"]; + M2 [label="queue merge\nscope: docs", class="muted"]; M1 -> M2; } - DELTA [shape=oval, margin="0.3,0.16", fillcolor="#1CB893", fontcolor="#063C2C", - color="#0B7A5C", label="Base delta scopes\napi, docs"]; - - PR [label="PR #42, first in its lane\nscope: frontend, CI already green", - fillcolor="#347D39", color="#165B33"]; - - Q1 [shape=diamond, margin="0.14,0.06", fillcolor="#FFF4ED", color="#FF8A3D", - fontcolor="#C2410C", label="Behind the\nbase branch?"]; + DELTA [label="Base delta scopes\napi, docs", shape=oval, class="config"]; + PR [label="PR #42, first in its lane\nscope: frontend, CI already green", class="queued"]; - Q2 [shape=diamond, margin="0.14,0.06", fillcolor="#FFF4ED", color="#FF8A3D", - fontcolor="#C2410C", label="Any scope\nin common?"]; + Q1 [label="Behind the\nbase branch?", shape=diamond, margin="0.14,0.06", class="pending"]; + Q2 [label="Any scope\nin common?", shape=diamond, margin="0.14,0.06", class="pending"]; - DIRECT [label="Direct merge\nno batch pull request, no queue CI", - fillcolor="#347D39", color="#165B33"]; - QUEUE [label="Batch pull request\nfull queue CI run", - fillcolor="#6B7280", color="#4B5563"]; + DIRECT [label="Direct merge\nno batch pull request, no queue CI", class="merged"]; + QUEUE [label="Batch pull request\nfull queue CI run", class="muted"]; M2 -> DELTA; PR -> Q1; - Q1 -> DIRECT [label=" no", color="#10B981", penwidth=2.5]; + Q1 -> DIRECT [label=" no", class="merged"]; Q1 -> Q2 [label=" yes"]; DELTA -> Q2; - Q2 -> DIRECT [label=" no", color="#10B981", penwidth=2.5]; + Q2 -> DIRECT [label=" no", class="merged"]; Q2 -> QUEUE [label=" yes"]; { rank=same; Q2; DELTA; } diff --git a/src/content/docs/merge-queue/lifecycle.mdx b/src/content/docs/merge-queue/lifecycle.mdx index 35285bb80e..68bb99e963 100644 --- a/src/content/docs/merge-queue/lifecycle.mdx +++ b/src/content/docs/merge-queue/lifecycle.mdx @@ -37,75 +37,23 @@ Once a pull request meets the specified queue conditions, it is added to the end of the merge queue. However, [priority rules](/merge-queue/priority) can be used to alter its position in the queue. -```dot class="graph" -strict digraph { - fontname="Inter, system-ui, sans-serif"; - rankdir="TB"; - bgcolor="#FAFBFC"; - - // Global node and edge styling - node [ - style="filled,rounded", - shape=rect, - fontcolor="black", - fontname="Inter, system-ui, sans-serif", - fontsize=12, - margin=0.25, - penwidth=2, - width=2.8, - height=0.9 - ]; - - edge [ - color="#7C3AED", - arrowhead=normal, - fontname="Inter, system-ui, sans-serif", - fontsize=9, - penwidth=2 - ]; - - Dequeued [ - label="⚪ Dequeued", - fillcolor="#F3F4F6", - color="#9CA3AF", - fontcolor="#4B5563" - ]; - - Queueing [ - label="⏳ Queueing", - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C" - ]; - - Queued [ - label="📋 Queued", - fillcolor="#EDE9FE", - color="#8B5CF6", - fontcolor="#5B21B6" - ]; - - Validating [ - label="🔍 Validating", - fillcolor="#DBEAFE", - color="#3B82F6", - fontcolor="#1E40AF" - ]; - - Merged [ - label="✅ Merged", - fillcolor="#D1FAE5", - color="#10B981", - fontcolor="#065F46" - ]; - - // Transitions with detailed labels - Dequeued -> Queueing [label=" queue command\n or auto_merge "]; - Queueing -> Queued [label=" matches\n queue_conditions "]; - Queued -> Dequeued [label=" unmatches queue_conditions\n or dequeue command "]; - Queued -> Validating [label=" reaches top\n of the queue "]; - Validating -> Merged [label=" matches\n merge_conditions "]; - Validating -> Dequeued [label=" unmatches queue_conditions\n or dequeue command\n or fails merge_conditions "]; +```dot class="flow" +digraph { + label="Pull request lifecycle"; + node [width=1.8]; + + Dequeued [class="external"]; + Queueing [class="pending"]; + Queued [class="queued"]; + Validating [class="pending"]; + Merged [class="merged"]; + + Dequeued -> Queueing [label=" queue command\l or auto_merge\l"]; + Queueing -> Queued [label=" matches\l queue_conditions\l"]; + Queued -> Validating [label=" reaches the top\l of the queue\l"]; + Validating -> Merged [label=" matches\l merge_conditions\l", class="merged"]; + Queued -> Dequeued [label=" unmatches queue_conditions\l or dequeue command\l", class="failed"]; + Validating -> Dequeued [label=" fails merge_conditions\l or dequeue command\l", class="failed"]; } ``` diff --git a/src/content/docs/merge-queue/performance.mdx b/src/content/docs/merge-queue/performance.mdx index 90724786bf..5cc384d061 100644 --- a/src/content/docs/merge-queue/performance.mdx +++ b/src/content/docs/merge-queue/performance.mdx @@ -174,45 +174,35 @@ batches of up to 3 PRs each in parallel. Given 7 queued PRs and a 10-minute CI pipeline, the first 6 merge in 10 minutes instead of the hour required for sequential validation. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Merge Queue" - - node [style=filled, shape=circle, fontcolor="white", fontname="sans-serif"]; - edge [color="#374151", arrowhead=none, fontname="sans-serif", arrowhead=normal]; - - subgraph cluster_batch_1 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [style=filled, color="black", fillcolor="#347D39", fontcolor="white"]; - PR3 -> PR4; - PR4 -> PR5; - PR5 -> PR6; - label = "Batch 2"; - - subgraph cluster_batch_0 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [style=filled, color="black", fillcolor="#347D39", fontcolor="white"]; - PR1 -> PR2; - PR2 -> PR3; - label = "Batch 1"; - } +```dot class="queue" +digraph { + label="Merge queue"; + + subgraph cluster_batch2 { + class="batch"; + label="Batch 2"; + + subgraph cluster_batch1 { + class="batch"; + label="Batch 1"; + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + PR1 -> PR2 -> PR3; } - PR6 -> PR7; - PR7 -> PR8; - PR8 [label="…", fillcolor="#347D39"]; + PR4 [class="queued"]; + PR5 [class="queued"]; + PR6 [class="queued"]; + PR3 -> PR4 -> PR5 -> PR6; + } - CI [label="Continuous\nIntegration", fixedsize=false, style="filled", fillcolor="#111827", fontcolor=white, shape=rectangle] - edge [arrowhead=none, style=dashed, arrowtail=normal, color="#9CA3AF", dir=both, fontcolor="#9CA3AF", fontsize="6pt"]; - PR3 -> CI; - PR6 -> CI; + PR7 [class="queued"]; + PR8 [label="…", class="queued"]; + PR6 -> PR7 -> PR8; + + CI [label="Continuous\nintegration", class="external"]; + PR3 -> CI [class="muted", style=dashed, dir=both, arrowhead=none, arrowtail=normal]; + PR6 -> CI [class="muted", style=dashed, dir=both, arrowhead=none, arrowtail=normal]; } ``` diff --git a/src/content/docs/merge-queue/queue-modes.mdx b/src/content/docs/merge-queue/queue-modes.mdx index e45e2db433..b11355ffa8 100644 --- a/src/content/docs/merge-queue/queue-modes.mdx +++ b/src/content/docs/merge-queue/queue-modes.mdx @@ -38,21 +38,12 @@ is tested on top of the one before it, forming a single ordered pipeline. This g correctness: each pull request is validated against the exact state it will merge into. The trade-off is that unrelated changes still wait for each other. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Serial Mode — Single Queue"; - nodesep=0.6; - ranksep=0.8; - - node [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; - edge [color="#374151", arrowhead=normal, penwidth=1.2, fontname="sans-serif"]; - - PR1 [label="Batch 1\nPR #1 (api)"]; - PR2 [label="Batch 2\nPR #2 (frontend)"]; - PR3 [label="Batch 3\nPR #3 (docs)"]; - +```dot class="queue" +digraph { + label="Serial mode — a single queue"; + PR1 [label="Batch 1\nPR #1 (api)", class="queued"]; + PR2 [label="Batch 2\nPR #2 (frontend)", class="queued"]; + PR3 [label="Batch 3\nPR #3 (docs)", class="queued"]; PR1 -> PR2 -> PR3; } ``` @@ -93,53 +84,33 @@ together so they are tested as a group, preventing semantic conflicts. Batches that share no scope run at the same time: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="TB"; - label="Parallel Mode\nIndependent Scope Queues"; - nodesep=0.8; - ranksep=0.6; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; +```dot class="flow" +digraph { + label="Parallel mode — independent scope queues"; edge [style=invis]; subgraph cluster_running { - style="rounded,filled"; - fillcolor="#1CB893"; - color="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Tested simultaneously"; - - PR1 [label="Batch 1\nPR #1 (api)", fillcolor="#347D39"]; - PR2 [label="Batch 2\nPR #2 (frontend)", fillcolor="#347D39"]; - PR3 [label="Batch 3\nPR #3 (docs)", fillcolor="#347D39"]; + PR1 [label="Batch 1\nPR #1 (api)", class="pending"]; + PR2 [label="Batch 2\nPR #2 (frontend)", class="pending"]; + PR3 [label="Batch 3\nPR #3 (docs)", class="pending"]; + { rank=same; PR1; PR2; PR3; } } - - { rank=same; PR1; PR2; PR3; } } ``` When scopes **do** overlap, Mergify preserves ordering within that scope to guarantee the changes are tested together: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Parallel Mode — Overlapping Scopes Create Dependencies"; - nodesep=0.6; - ranksep=1.0; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; - edge [color="#374151", arrowhead=normal, penwidth=1.2, fontname="sans-serif"]; - - PR1 [label="Batch 1\nPR #1 (api)", fillcolor="#347D39"]; - PR4 [label="Batch 2\nPR #4 (api, frontend)", fillcolor="#347D39"]; - PR3 [label="Batch 3\nPR #3 (docs)", fillcolor="#347D39"]; +```dot class="queue" +digraph { + label="Parallel mode — overlapping scopes create dependencies"; + PR1 [label="Batch 1\nPR #1 (api)", class="queued"]; + PR4 [label="Batch 2\nPR #4 (api, frontend)", class="queued"]; + PR3 [label="Batch 3\nPR #3 (docs)", class="queued"]; PR1 -> PR4 [label="same scope: api"]; - { rank=same; PR1; PR3; } } ``` @@ -293,43 +264,28 @@ Take the configuration above (`max_parallel_checks: 5`, `frontend: 2`, `backend: uncapped) and suppose the queue is ready to test three `frontend` batches, three `backend` batches, and two `docs` batches. The slots might fill like this: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="TB"; - label="Per-scope capacities — ceiling 5, frontend: 2, backend: 2, docs uncapped"; - nodesep=0.5; - ranksep=0.7; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; +```dot class="flow" +digraph { + label="Per-scope capacity — ceiling 5, frontend 2, backend 2, docs uncapped"; edge [style=invis]; subgraph cluster_running { - style="rounded,filled"; - fillcolor="#1CB893"; - color="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Running now — 5 of 5 slots used"; - - F1 [label="frontend #1", fillcolor="#347D39"]; - F2 [label="frontend #2", fillcolor="#347D39"]; - B1 [label="backend #1", fillcolor="#347D39"]; - B2 [label="backend #2", fillcolor="#347D39"]; - D1 [label="docs #1", fillcolor="#347D39"]; - + F1 [label="frontend #1", class="pending"]; + F2 [label="frontend #2", class="pending"]; + B1 [label="backend #1", class="pending"]; + B2 [label="backend #2", class="pending"]; + D1 [label="docs #1", class="pending"]; { rank=same; F1; F2; B1; B2; D1; } } subgraph cluster_waiting { - style="rounded"; - color="#6B7280"; - fontcolor="#6B7280"; + class="muted"; label="Waiting"; - - F3 [label="frontend #3\nfrontend full", fillcolor="#6B7280"]; - B3 [label="backend #3\nbackend full", fillcolor="#6B7280"]; - D2 [label="docs #2\nceiling full", fillcolor="#6B7280"]; - + F3 [label="frontend #3\nfrontend full", class="muted"]; + B3 [label="backend #3\nbackend full", class="muted"]; + D2 [label="docs #2\nceiling full", class="muted"]; { rank=same; F3; B3; D2; } } } @@ -429,30 +385,19 @@ Parallel mode keeps dependencies between batches that share a scope. **Isolated entirely: every batch is a self-contained unit that is tested and merged on its own, with no parent batch and no child batch. A failure in one batch never blocks any other. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="TB"; - label="Isolated Mode\nEvery Batch Independent"; - nodesep=0.8; - ranksep=0.6; - - node [shape=box, style="rounded,filled", fontcolor="white", fontname="sans-serif", margin="0.3,0.18"]; +```dot class="flow" +digraph { + label="Isolated mode — every batch independent"; edge [style=invis]; subgraph cluster_running { - style="rounded,filled"; - fillcolor="#1CB893"; - color="#1CB893"; - fontcolor="#000000"; + class="batch"; label="Tested simultaneously"; - - PR1 [label="Batch 1\nPR #1", fillcolor="#347D39"]; - PR2 [label="Batch 2\nPR #2", fillcolor="#347D39"]; - PR3 [label="Batch 3\nPR #3", fillcolor="#347D39"]; + PR1 [label="Batch 1\nPR #1", class="pending"]; + PR2 [label="Batch 2\nPR #2", class="pending"]; + PR3 [label="Batch 3\nPR #3", class="pending"]; + { rank=same; PR1; PR2; PR3; } } - - { rank=same; PR1; PR2; PR3; } } ``` diff --git a/src/content/docs/merge-queue/scopes.mdx b/src/content/docs/merge-queue/scopes.mdx index a830e0eda9..ee0ca0ee8c 100644 --- a/src/content/docs/merge-queue/scopes.mdx +++ b/src/content/docs/merge-queue/scopes.mdx @@ -28,33 +28,25 @@ prioritizes the combination that shares the most scopes in common. Pull requests scope are tested together first, while unrelated changes stay in the queue until a compatible batch is available or Mergify needs them to fill the requested batch size. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Scope-aware Batch Selection"; - nodesep=0.9; - ranksep=1.2; - splines=polyline; - - edge [fontname="sans-serif", color="#374151", arrowhead=none, penwidth=1.2]; +```dot class="queue" +digraph { + label="Scope-aware batch selection"; + edge [arrowhead=none]; subgraph cluster_preferred { - style="rounded"; - color="#1CB893"; + class="batch"; label="Preferred batch"; - - PR1 [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", color="#165B33", margin="0.35,0.22", fontname="sans-serif", label="PR #101\nScopes: frontend, api"]; - PR2 [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", color="#165B33", margin="0.35,0.22", fontname="sans-serif", label="PR #214\nScopes: frontend"]; - PR3 [shape=box, style="rounded,filled", fillcolor="#347D39", fontcolor="white", color="#165B33", margin="0.35,0.22", fontname="sans-serif", label="PR #305\nScopes: api, docs"]; + PR1 [label="PR #101\nScopes: frontend, api", class="queued"]; + PR2 [label="PR #214\nScopes: frontend", class="queued"]; + PR3 [label="PR #305\nScopes: api, docs", class="queued"]; } - PR4 [shape=box, style="rounded,filled", fillcolor="#6B7280", fontcolor="white", color="#4B5563", margin="0.35,0.22", fontname="sans-serif", label="PR #412\nScope: tooling"]; + PR4 [label="PR #412\nScope: tooling", class="muted"]; - frontend [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="frontend"]; - api [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="api"]; - docs [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="docs"]; - tooling [shape=oval, style="filled", fillcolor="#1CB893", fontcolor="#063C2C", color="#0B7A5C", margin="0.3,0.18", fontname="sans-serif", label="tooling"]; + frontend [shape=oval, class="config"]; + api [shape=oval, class="config"]; + docs [shape=oval, class="config"]; + tooling [shape=oval, class="config"]; PR1 -> frontend; PR1 -> api; diff --git a/src/content/docs/merge-queue/stacks.mdx b/src/content/docs/merge-queue/stacks.mdx index 636cad9718..ae322ef067 100644 --- a/src/content/docs/merge-queue/stacks.mdx +++ b/src/content/docs/merge-queue/stacks.mdx @@ -131,25 +131,18 @@ immediate parent branch. Without this, PR2 would be queued against PR1's head branch and could never reach `main`, so the queue would have nothing to merge into. -```dot class="graph" style="max-width: 320px; height: auto; display: block; margin: 1.5em auto" -strict digraph { - fontname="sans-serif"; - fontsize=10; - rankdir="LR"; - nodesep=0.2; - ranksep=0.4; - - node [style=filled, fontname="sans-serif", fontcolor="white", fontsize=10, shape=circle, width=0.45, height=0.45, fixedsize=true]; - edge [fontname="sans-serif", fontsize=9, color="#5B21B6", fontcolor="#5B21B6"]; - - PR1 [fillcolor="#347D39"]; - PR2 [fillcolor="#347D39"]; - PR3 [fillcolor="#347D39"]; - main [label="main", shape=rectangle, fillcolor="#111827", width=0.7, height=0.4, fixedsize=false]; - - PR1 -> main; - PR2 -> main; - PR3 -> main; +```dot class="queue" style="max-width: 320px" +digraph { + node [shape=circle, width=0.5, height=0.5, fixedsize=true]; + + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + main [label="main", shape=box, fixedsize=false, class="external"]; + + PR1 -> main; + PR2 -> main; + PR3 -> main; } ``` @@ -175,28 +168,23 @@ checks, a stack longer than `batch_size` (or a stack sharing its scope group with higher-priority unrelated PRs) lands across consecutive batches. Order is preserved either way. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="Merge Queue with batch_size: 5" - - node [style=filled, shape=circle, fontcolor="white", fontname="sans-serif"]; - edge [color="#374151", arrowhead=normal, fontname="sans-serif"]; - - subgraph cluster_batch_0 { - style="rounded,filled"; - color="#1CB893"; - fillcolor="#1CB893"; - fontcolor="#000000"; - node [fillcolor="#347D39"]; - PR1 -> PR2 -> PR3 -> PR_other; - PR_other [label="PR4"]; - label = "Batch 1 (stack PR1→PR2→PR3 kept together)"; - } - - PR_other -> PR5; - PR5 [label="…", fillcolor="#347D39"]; +```dot class="queue" +digraph { + label="Merge queue with batch_size: 5"; + node [shape=circle, width=0.5, height=0.5, fixedsize=true]; + + subgraph cluster_batch1 { + class="batch"; + label="Batch 1 — the stack kept together"; + PR1 [class="queued"]; + PR2 [class="queued"]; + PR3 [class="queued"]; + PR4 [class="queued"]; + PR1 -> PR2 -> PR3 -> PR4; + } + + PR5 [label="…", class="queued"]; + PR4 -> PR5; } ``` @@ -213,22 +201,20 @@ still in the queue is dequeued automatically with the reason `StackPredecessorDequeued`. This stops the queue from validating PRs whose dependency just broke. There's no point checking PR3 if PR1 just failed. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - label="" +```dot class="queue" +digraph { + ranksep=1.0; + // Not fixedsize: these labels are two lines, so let the circle grow to them. + node [shape=circle]; - node [style=filled, shape=circle, fontname="sans-serif"]; + PR1 [label="PR1\n(failed)", class="failed"]; + PR2 [label="PR2\n(cascaded)", class="muted"]; + PR3 [label="PR3\n(cascaded)", class="muted"]; + PR4 [class="queued"]; - PR1 [fillcolor="#DC2626", fontcolor="white", label="PR1\n(failed)"]; - PR2 [fillcolor="#9CA3AF", fontcolor="white", label="PR2\n(cascaded)"]; - PR3 [fillcolor="#9CA3AF", fontcolor="white", label="PR3\n(cascaded)"]; - PR4 [fillcolor="#347D39", fontcolor="white"]; - - PR1 -> PR2 [label="dequeue", color="#DC2626", fontcolor="#DC2626", fontsize="9"]; - PR2 -> PR3 [label="dequeue", color="#DC2626", fontcolor="#DC2626", fontsize="9"]; - PR3 -> PR4 [style="dashed", color="#9CA3AF"]; + PR1 -> PR2 [label="dequeue", class="failed"]; + PR2 -> PR3 [label="dequeue", class="failed"]; + PR3 -> PR4 [style=dashed, class="muted"]; } ``` diff --git a/src/content/docs/merge-queue/two-step.mdx b/src/content/docs/merge-queue/two-step.mdx index 57a2e56a0e..da28d3f8f3 100644 --- a/src/content/docs/merge-queue/two-step.mdx +++ b/src/content/docs/merge-queue/two-step.mdx @@ -24,134 +24,34 @@ expensive. ## How It Works -```dot class="graph" -strict digraph { - fontname="Inter, system-ui, sans-serif"; - rankdir="TB"; - bgcolor="#FAFBFC"; - - // Global node and edge styling - node [ - style="filled,rounded", - shape=rect, - fontcolor="black", - fontname="Inter, system-ui, sans-serif", - fontsize=12, - margin=0.2, - penwidth=2, - width=2.5, - height=0.8 - ]; - - edge [ - color="#7C3AED", - arrowhead=normal, - fontname="Inter, system-ui, sans-serif", - fontsize=10, - penwidth=2 - ]; - - // Start state - open [ - label="🔄 PR opened or updated", - fillcolor="#EDE9FE", - color="#8B5CF6", - fontcolor="#5B21B6" - ]; - - // Preliminary tests phase - preliminary [ - label="🧪 Preliminary tests\n(Unit tests, linting)", - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C" - ] - - // Success/failure branches for preliminary tests - subgraph cluster_preliminary_results { - style="invis"; - preliminary_ok [ - label="✅ Tests passed\n(Ready for queue)", - fillcolor="#D1FAE5", - color="#10B981", - fontcolor="#065F46" - ] - - preliminary_fail [ - label="❌ Tests failed\n(Needs fixes)", - fillcolor="#FEE2E2", - color="#EF4444", - fontcolor="#991B1B" - ] - } - - // Queue action - queue_req [ - label="📝 Queue command\n(@mergifyio queue)", - fillcolor="#F3E8FF", - color="#A855F7", - fontcolor="#6B21A8" - ] - - // Queue state - queued [ - label="⏳ PR queued", - shape=ellipse, - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C", - width=2, - height=1 - ]; - - // Pre-merge tests phase - premerge [ - label="🔬 Pre-merge tests\n(Integration, performance)", - fillcolor="#FFF4ED", - color="#FF8A3D", - fontcolor="#C2410C" - ] - - // Success/failure branches for pre-merge tests - subgraph cluster_premerge_results { - style="invis"; - premerge_ok [ - label="✅ All tests passed\n(Ready to merge)", - fillcolor="#D1FAE5", - color="#10B981", - fontcolor="#065F46" - ] - - premerge_fail [ - label="❌ Pre-merge failed\n(Removed from queue)", - fillcolor="#FEE2E2", - color="#EF4444", - fontcolor="#991B1B" - ] - } - - // Final merge - merged [ - label="🎉 Merged to main", - fillcolor="#DDD6FE", - color="#7C3AED", - fontcolor="#5B21B6" - ] - - // Flow connections - main path - open -> preliminary; - preliminary -> preliminary_ok [color="#10B981", penwidth=3]; - preliminary -> preliminary_fail [color="#EF4444", penwidth=3]; - preliminary_ok -> queue_req; - queue_req -> queued; - queued -> premerge; - premerge -> premerge_ok [color="#10B981", penwidth=3]; - premerge -> premerge_fail [color="#EF4444", penwidth=3]; - premerge_ok -> merged [color="#7C3AED", penwidth=3]; - - // Rank constraints for better layout - {rank=same; preliminary_ok, preliminary_fail} - {rank=same; premerge_ok, premerge_fail} +```dot class="flow" +digraph { + label="Two-step continuous integration"; + node [width=2.2]; + + open [label="Pull request opened\nor updated", class="queued"]; + preliminary [label="Preliminary tests\n(unit tests, linting)", class="pending"]; + preliminary_ok [label="Tests passed\nready for the queue", class="merged"]; + preliminary_fail [label="Tests failed\nneeds fixes", class="failed"]; + queue_req [label="Queue command\n(@mergifyio queue)", class="queued"]; + queued [label="Pull request queued", shape=oval, class="queued"]; + premerge [label="Pre-merge tests\n(integration, performance)", class="pending"]; + premerge_ok [label="All tests passed\nready to merge", class="merged"]; + premerge_fail [label="Pre-merge failed\nremoved from the queue", class="failed"]; + merged [label="Merged to main", class="merged"]; + + open -> preliminary; + preliminary -> preliminary_ok [class="merged"]; + preliminary -> preliminary_fail [class="failed"]; + preliminary_ok -> queue_req; + queue_req -> queued; + queued -> premerge; + premerge -> premerge_ok [class="merged"]; + premerge -> premerge_fail [class="failed"]; + premerge_ok -> merged [class="merged"]; + + { rank=same; preliminary_ok; preliminary_fail; } + { rank=same; premerge_ok; premerge_fail; } } ``` diff --git a/src/styles/index.css b/src/styles/index.css index c06eab5da0..8318c97786 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -954,10 +954,12 @@ html { fill: var(--dg-container); stroke: var(--dg-border); } +/* No letter-spacing: Graphviz sizes a cluster to fit the label it measured in + Helvetica, and anything that widens the painted text past that overflows the + container it labels. The weight is worth the ~4% Inter already costs. */ .dg .cluster > text { fill: var(--dg-label); font-weight: 600; - letter-spacing: 0.02em; } .dg .edge > path, @@ -993,6 +995,12 @@ html { stroke: none; } +/* An architecture diagram is the widest kind there is, and the one whose labels + suffer most from being scaled down. Give it the whole column. */ +.dg.arch { + width: 100%; +} + /* The hand-placed diagrams are drawn at a fixed size and read best at it: they carry no intrinsic width for `width: 80%` to work against. */ .dg-linear, diff --git a/src/util/diagramSvg.ts b/src/util/diagramSvg.ts index e4dae85ff9..e52c6429ef 100644 --- a/src/util/diagramSvg.ts +++ b/src/util/diagramSvg.ts @@ -39,27 +39,9 @@ export const DIAGRAM_ROLES = [ export type DiagramRole = (typeof DIAGRAM_ROLES)[number]; -/** - * The classes Graphviz puts on an element itself. Anything else on the element - * came from the source, and means the author named the element's role. - */ -const STRUCTURAL_CLASSES = new Set<string>(['graph', 'node', 'edge', 'cluster']); - -/** The paint a shape carries, for a caller that infers a role from it. */ -export interface ShapePaint { - fill?: string; - stroke?: string; -} - interface FinishOptions { /** Classes for the `<svg>`, after `dg`. */ classes?: string[]; - /** - * Transitional hook: name the role of an element that does not carry one, - * from the paint Graphviz gave it. Only called for elements whose source - * named no class of their own. - */ - roleFor?: (kind: DiagramKind, paint: ShapePaint) => DiagramRole | undefined; } /** The shapes Graphviz draws directly inside each kind of group. */ @@ -75,7 +57,7 @@ const classesOf = (value: string | undefined): string[] => /** * Turn a rendered Graphviz SVG into a themeable `.dg` diagram, in place. */ -export function finishDiagramSvg($: CheerioAPI, { classes = [], roleFor }: FinishOptions = {}) { +export function finishDiagramSvg($: CheerioAPI, { classes = [] }: FinishOptions = {}) { // Graphviz paints an opaque canvas as the first child of the graph group // whenever the source sets its own `bgcolor`. Drop it so the page shows // through — otherwise the diagram carries a light rectangle into dark mode. @@ -89,24 +71,16 @@ export function finishDiagramSvg($: CheerioAPI, { classes = [], roleFor }: Finis const $group = $(element); const existing = classesOf($group.attr('class')); const $shape = $group.children(SHAPES[kind]).first(); - const paint: ShapePaint = { fill: $shape.attr('fill'), stroke: $shape.attr('stroke') }; - - const extra: string[] = []; // Graphviz draws no border for `shape=plaintext` / `shape=none`, so a - // shape with no stroke is a caption rather than a box — even when the - // node inherited `style=filled` and so came out with a fill behind it. - // A shape that asks for a fill and `color=none` is read the same way; - // use `penwidth=0` to keep the fill. - if (paint.stroke === 'none' && !existing.includes('plain')) extra.push('plain'); - - if (roleFor && !existing.some((name) => !STRUCTURAL_CLASSES.has(name))) { - const role = roleFor(kind, paint); - if (role) extra.push(role); + // shape with no stroke is a caption rather than a box — even when it + // inherited `style=filled` and so came out with a fill behind it. A shape + // that asks for a fill and `color=none` is read the same way; use + // `penwidth=0` to keep the fill. + if ($shape.attr('stroke') === 'none' && !existing.includes('plain')) { + $group.attr('class', [...existing, 'plain'].join(' ')); } - if (extra.length > 0) $group.attr('class', [...existing, ...extra].join(' ')); - // Only direct children are painted by `.dg` in index.css; anything deeper // keeps whatever Graphviz gave it. $group