Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import remarkSmartypants from 'remark-smartypants';
import { asideAutoImport, astroAsides } from './integrations/astro-asides';
import { astroYoutubeEmbeds, youtubeAutoImport } from './integrations/astro-youtube-embed';
import { PagefindIndex } from './integrations/pagefind-index';
import { validateApiNavPaths } from './integrations/validate-api-nav-paths';
import { validateDataTypeAnchors } from './integrations/validate-data-type-anchors';
import { autolinkConfig } from './plugins/rehype-autolink-config';
import { rehypeOptimizeStatic } from './plugins/rehype-optimize-static';
Expand Down Expand Up @@ -54,6 +55,7 @@ export default defineConfig({
}),
PagefindIndex(),
validateDataTypeAnchors(),
validateApiNavPaths(),
icon({
include: {
lucide: ['*'],
Expand Down
39 changes: 39 additions & 0 deletions integrations/validate-api-nav-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { AstroIntegration } from 'astro';
import rawApiSchema from '../public/api-schemas.json';
import { type OpenAPISpec, preprocessSchema } from '../src/components/ApiReference/openapi';
import { danglingApiNavPaths } from '../src/util/apiNavPaths';

/**
* Fail the build when the sidebar points at an `/api/*` page that no longer
* exists. Those pages are generated from the OpenAPI spec's tags, so renaming
* a tag silently deletes a route — which is how the `eventlogs` →
* `activity_log` rename left `/api/eventlogs` 404ing from every page on the
* site.
*
* This has to run at build time rather than only in the link check: the spec
* arrives by bot sync pushed straight to main, which opens no pull request,
* and CI runs on `pull_request` only. The deploy build is the sole gate a
* sync passes through. apiNavPaths.test.ts runs the same check in pull
* request CI for earlier feedback on docs-side edits.
*/
export function validateApiNavPaths(): AstroIntegration {
return {
name: 'validate-api-nav-paths',
hooks: {
'astro:build:start': () => {
const dangling = danglingApiNavPaths(
preprocessSchema(rawApiSchema as unknown as OpenAPISpec)
);

if (dangling.length > 0) {
throw new Error(
`src/content/navItems.tsx links to ${dangling.join(', ')}, which no longer ` +
`exist. Every /api/* route comes from a tag in public/api-schemas.json or a ` +
`page in src/content/docs/api/ — point the entry at the tag's current slug ` +
`(and add a redirect in public/_redirects, since the old URL was published).`
);
}
},
},
};
}
69 changes: 69 additions & 0 deletions src/util/apiNavPaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import type { OpenAPISpec } from '~/components/ApiReference/openapi';
import apiSchema from '../../public/api-schemas.json';
import { danglingApiNavPaths } from './apiNavPaths';

const schema = apiSchema as unknown as OpenAPISpec;

/** The spec with `from` renamed to `to` wherever an operation is tagged with it. */
function renameTag(from: string, to: string): OpenAPISpec {
const renamed = structuredClone(schema);
for (const pathItem of Object.values(renamed.paths)) {
for (const operation of Object.values(pathItem)) {
if (Array.isArray(operation?.tags)) {
operation.tags = operation.tags.map((tag) => (tag === from ? to : tag));
}
}
}
return renamed;
}

describe('danglingApiNavPaths', () => {
it('accepts the sidebar as it stands', () => {
expect(danglingApiNavPaths(schema)).toEqual([]);
});

// The regression: a tag rename in a synced spec deletes the route the
// sidebar points at, and every page of the site carries that link.
it('reports a nav path whose tag is gone from the spec', () => {
expect(danglingApiNavPaths(renameTag('activity_log', 'eventlogs'))).toEqual([
'/api/activity-log',
]);
});

// /api/usage is src/content/docs/api/usage.mdx. It has no tag behind it, so
// it is only ever accepted by the hand-written half of the check — which
// makes this the case that fails if that half breaks.
it('accepts a hand-written page with no tag behind it', () => {
expect(danglingApiNavPaths(renameTag('activity_log', 'eventlogs'))).not.toContain('/api/usage');
// Every tag gone: only the hand-written page and the index may survive.
const untagged = structuredClone(schema);
for (const pathItem of Object.values(untagged.paths)) {
for (const operation of Object.values(pathItem)) {
if (Array.isArray(operation?.tags)) operation.tags = [];
}
}
expect(danglingApiNavPaths(untagged)).not.toContain('/api/usage');
});

// navItems already ships anchored entries elsewhere
// (/test-insights#test-framework-configuration), and the /api pages are the
// ones with a heading per endpoint, so anchoring one is the natural next
// edit. Reading the anchor as part of the slug would fail the deploy build
// on a link that resolves.
it('resolves an anchored or queried nav path against the page it points at', () => {
const nav = [
{ title: 'Anchored tag page', path: '/api/activity-log#list-events' },
{ title: 'Anchored hand-written page', path: '/api/usage?utm=nav' },
];
expect(danglingApiNavPaths(schema, nav)).toEqual([]);
});

it('accepts both spellings of the reference index', () => {
const nav = [
{ title: 'Reference', path: '/api/' },
{ title: 'Reference, no slash', path: '/api' },
];
expect(danglingApiNavPaths(schema, nav)).toEqual([]);
});
});
53 changes: 53 additions & 0 deletions src/util/apiNavPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Node-only: reads the hand-written /api pages from disk. Kept out of
// navItems.tsx itself so components can keep importing the nav tree without
// dragging node:fs into a client bundle.
import { readdirSync } from 'node:fs';
import { groupByTag, type OpenAPISpec, slugifyTag } from '~/components/ApiReference/openapi';
import navItems, { type NavItem } from '~/content/navItems';
import { flattenNavItems } from '~/util/flattenNavItems';

const API_DOCS_URL = new URL('../content/docs/api/', import.meta.url);

/**
* The slugs of the pages written by hand under `src/content/docs/api/`,
* mirroring the glob the docs collection loads them with
* (src/content.config.ts): recursive, and `_`-prefixed partials excluded,
* because those never become routes.
*/
function handwrittenApiPages(): Set<string> {
return new Set(
readdirSync(API_DOCS_URL, { recursive: true, encoding: 'utf8' })
.filter((name) => name.endsWith('.mdx'))
.map((name) => name.replace(/\.mdx$/, ''))
.filter((slug) => !slug.split('/').some((segment) => segment.startsWith('_')))
);
}

/**
* The `/api/*` sidebar links that resolve to nothing.
*
* Everything under `/api/` is either generated from an OpenAPI tag by
* `src/pages/api/[tag].astro` — handed the same preprocessed schema here, and
* grouped and slugified by the same helpers, so the two cannot disagree about
* which routes exist — or a page written by hand under
* `src/content/docs/api/`. A path matching neither is a 404 on every page of
* the site, since the sidebar renders site-wide.
*/
export function danglingApiNavPaths(schema: OpenAPISpec, items: NavItem[] = navItems): string[] {
const generated = new Set([...groupByTag(schema).keys()].map(slugifyTag));
const handwritten = handwrittenApiPages();

const dangling = flattenNavItems(items)
.flatMap((item) => (item.path && /^\/api(\/|$)/.test(item.path) ? [item.path] : []))
.filter((path) => {
// A nav path may carry an anchor or query onto an otherwise real page.
const slug = path
.replace(/[#?].*$/, '')
.replace(/^\/api\/?/, '')
.replace(/\/$/, '');
// `/api` itself is the reference index, src/pages/api/index.astro.
if (slug === '') return false;
return !generated.has(slug) && !handwritten.has(slug);
});
return [...new Set(dangling)].sort();
}