Files
gitbook/packages/gitbook/scripts/check-css-browser-compatibility.ts
T
2026-08-18 10:30:43 +02:00

274 lines
8.4 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
type CompatibilityDiagnostic,
getCompatibilityDiagnostics,
} from '../src/lib/cssBrowserCompatibility';
interface PullRequestEvent {
pull_request: {
base: { sha: string };
head: { sha: string };
number: number;
};
}
interface PullRequestFile {
filename: string;
previous_filename?: string;
status: 'added' | 'copied' | 'modified' | 'removed' | 'renamed' | 'unchanged';
}
interface ContentResponse {
content: string;
encoding: string;
sha: string;
}
interface GitBlobResponse {
content: string;
encoding: string;
}
class GitHubRequestError extends Error {
constructor(
readonly status: number,
message: string
) {
super(message);
}
}
class GitHubApi {
constructor(
private readonly repository: string,
private readonly token: string
) {}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
'X-GitHub-Api-Version': '2022-11-28',
...init?.headers,
},
});
const responseText = await response.text();
if (!response.ok) {
throw new GitHubRequestError(
response.status,
`${init?.method ?? 'GET'} ${path} failed: ${responseText}`
);
}
return JSON.parse(responseText) as T;
}
async listPullRequestFiles(pullRequestNumber: number): Promise<PullRequestFile[]> {
const files: PullRequestFile[] = [];
for (let page = 1; ; page += 1) {
const result = await this.request<PullRequestFile[]>(
`/repos/${this.repository}/pulls/${pullRequestNumber}/files?per_page=100&page=${page}`
);
files.push(...result);
if (result.length < 100) {
return files;
}
}
}
private async getContent(path: string, ref: string): Promise<string> {
const encodedPath = path.split('/').map(encodeURIComponent).join('/');
const content = await this.request<ContentResponse>(
`/repos/${this.repository}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`
);
const response = content.content
? content
: await this.request<GitBlobResponse>(
`/repos/${this.repository}/git/blobs/${content.sha}`
);
if (response.encoding !== 'base64') {
throw new Error(
`Unsupported GitHub content encoding for ${path}: ${response.encoding}`
);
}
return Buffer.from(response.content.replaceAll('\n', ''), 'base64').toString('utf8');
}
async getFileAtRef(path: string, ref: string): Promise<string> {
return this.getContent(path, ref);
}
}
async function getBrowserslist(api: GitHubApi, headSha: string): Promise<string[]> {
const packageJson = JSON.parse(
await api.getFileAtRef('packages/gitbook/package.json', headSha)
) as { browserslist?: string[] };
if (!packageJson.browserslist?.length) {
throw new Error('packages/gitbook/package.json must define a Browserslist configuration.');
}
return packageJson.browserslist;
}
async function getBaseContent(
api: GitHubApi,
file: PullRequestFile,
baseSha: string
): Promise<string> {
if (file.status === 'added') {
return '';
}
try {
return await api.getFileAtRef(file.previous_filename ?? file.filename, baseSha);
} catch (error) {
if (error instanceof GitHubRequestError && error.status === 404) {
return '';
}
throw error;
}
}
function report(diagnostics: CompatibilityDiagnostic[]): boolean {
if (diagnostics.length === 0) {
console.log('CSS browser compatibility check passed.');
return true;
}
console.error(
`${diagnostics.length} newly added CSS declaration(s) are not fully supported by the configured Browserslist targets:`
);
for (const diagnostic of diagnostics) {
// Workflow command so the failure is annotated on the PR diff.
console.error(
`::error file=${diagnostic.file},line=${diagnostic.line},col=${diagnostic.column}::${diagnostic.property} is not supported by ${diagnostic.unsupportedBrowsers}`
);
}
return false;
}
/** Same check as CI, but against a local `git diff` instead of the GitHub API. */
async function runLocal(baseRef: string): Promise<boolean> {
const git = (...args: string[]) =>
execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const root = git('rev-parse', '--show-toplevel').trim();
const mergeBase = git('merge-base', baseRef, 'HEAD').trim();
const browsers = (
JSON.parse(await readFile(join(root, 'packages/gitbook/package.json'), 'utf8')) as {
browserslist?: string[];
}
).browserslist;
if (!browsers?.length) {
throw new Error('packages/gitbook/package.json must define a Browserslist configuration.');
}
// Working tree, so uncommitted changes are checked too.
const changes = git(
'diff',
'--name-status',
'--find-renames',
'--diff-filter=ACMR',
mergeBase,
'--',
'*.css'
)
.split('\n')
.filter(Boolean)
.map((line) => {
const [status, ...paths] = line.split('\t');
const previousPath = paths.length > 1 ? paths[0] : undefined;
const path = paths.at(-1) as string;
return { added: status?.startsWith('A'), path, previousPath };
});
const diagnostics: CompatibilityDiagnostic[] = [];
for (const change of changes) {
let base = '';
if (!change.added) {
try {
base = git('show', `${mergeBase}:${change.previousPath ?? change.path}`);
} catch {
base = '';
}
}
diagnostics.push(
...(await getCompatibilityDiagnostics({
base,
browsers,
file: change.path,
head: await readFile(join(root, change.path), 'utf8'),
}))
);
}
console.log(`Checked ${changes.length} changed CSS file(s) against ${baseRef}.`);
return report(diagnostics);
}
async function run(): Promise<boolean> {
const token = process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY;
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!token || !repository || !eventPath) {
throw new Error('GITHUB_TOKEN, GITHUB_REPOSITORY, and GITHUB_EVENT_PATH are required.');
}
const event = JSON.parse(await readFile(eventPath, 'utf8')) as PullRequestEvent;
const pullRequest = event.pull_request;
if (!pullRequest) {
throw new Error('This script must run from a pull request event.');
}
const api = new GitHubApi(repository, token);
const browsers = await getBrowserslist(api, pullRequest.head.sha);
const files = (await api.listPullRequestFiles(pullRequest.number)).filter(
(file) => file.status !== 'removed' && file.filename.endsWith('.css')
);
const diagnostics: CompatibilityDiagnostic[] = [];
for (const file of files) {
const [base, head] = await Promise.all([
getBaseContent(api, file, pullRequest.base.sha),
api.getFileAtRef(file.filename, pullRequest.head.sha),
]);
diagnostics.push(
...(await getCompatibilityDiagnostics({
base,
browsers,
file: file.filename,
head,
}))
);
}
return report(diagnostics);
}
const localFlagIndex = process.argv.indexOf('--local');
try {
let success: boolean;
if (localFlagIndex === -1) {
success = await run();
} else {
success = await runLocal(process.argv[localFlagIndex + 1] ?? 'origin/main');
}
process.exitCode = success ? 0 : 1;
} catch (error) {
console.error(error);
process.exitCode = 1;
}