Files

512 lines
19 KiB
JavaScript

#!/usr/bin/env node
/**
* Sync antd metadata for all major versions (v4, v5, v6).
*
* For each major version:
* 1. Fetches all release tags from the antd GitHub repo
* 2. Extracts the latest snapshot as data/v{major}.json
* 3. Extracts per-minor snapshots (e.g. data/v5.3.2.json) for new minor series
* 4. Updates data/versions.json with the minor→tag index
*
* Usage:
* npx tsx scripts/sync.ts --antd-dir antd-source
*/
import { execFileSync, execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const MAJORS = [4, 5, 6];
const ANTD_REMOTE = 'https://github.com/ant-design/ant-design.git';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const EXTRACT_SCRIPT = path.join(__dirname, 'extract.ts');
const DATA_DIR = path.join(PROJECT_ROOT, 'data');
// antd ships a hand-curated DESIGN.md (the design-language doc) at its repo root.
// It is major-grained (rewritten only across major releases), so we sync it per
// major into data/design-v{major}.md. See syncDesignDocs().
const DESIGN_SOURCE = 'DESIGN.md';
const BUG_VERSIONS_SOURCE = 'BUG_VERSIONS.json';
const designTarget = (major: number) => path.join(DATA_DIR, `design-v${major}.md`);
const bugVersionsTarget = () => path.join(DATA_DIR, 'bug-versions.json');
export function getNpmInvocation(
platform = process.platform,
nodeExecutable = process.execPath,
npmExecPath = process.env.npm_execpath,
): { command: string; args: string[] } {
if (platform !== 'win32') {
return { command: 'npm', args: [] };
}
const npmCli = npmExecPath && !npmExecPath.toLowerCase().endsWith('.cmd')
? npmExecPath
: path.win32.join(path.win32.dirname(nodeExecutable), 'node_modules', 'npm', 'bin', 'npm-cli.js');
return { command: nodeExecutable, args: [npmCli] };
}
function parseArgs(args: string[]): { antdDir: string } {
let antdDir = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--antd-dir' && args[i + 1]) {
antdDir = path.resolve(args[++i]);
}
}
if (!antdDir) {
console.error('Usage: tsx scripts/sync.ts --antd-dir <path>');
process.exit(1);
}
// Verify we're running from the project root
if (!fs.existsSync(path.join(PROJECT_ROOT, 'package.json'))) {
console.error(`Error: Cannot find package.json in ${PROJECT_ROOT}. Run this script from the project root.`);
process.exit(1);
}
return { antdDir };
}
/** Returns all release tags for a major version, sorted ascending by semver. */
export function fetchTags(major: number): string[] {
try {
const out = execSync(
`git ls-remote --tags --sort=v:refname ${ANTD_REMOTE} "refs/tags/${major}.*"`,
{ encoding: 'utf8' },
);
const tags = out
.split('\n')
.filter((line) => line && !line.includes('^{}'))
.map((line) => line.replace(/.*refs\/tags\//, ''));
if (tags.length === 0) {
throw new Error(`No release tags found for v${major}`);
}
return tags;
} catch (err) {
const stderr = err && typeof err === 'object' && 'stderr' in err && err.stderr
? `\n${Buffer.isBuffer(err.stderr) ? err.stderr.toString('utf8') : String(err.stderr)}`
: '';
throw new Error(`Error fetching tags for v${major}: ${err instanceof Error ? err.message : err}${stderr}`);
}
}
/** For each minor series (X.Y), picks the highest patch tag. Excludes pre-releases (e.g. 5.0.0-rc.1). */
function buildMinorMap(tags: string[]): Map<string, string> {
const map = new Map<string, string>();
for (const tag of tags) {
if (tag.includes('-')) continue; // skip pre-releases
const parts = tag.split('.');
if (parts.length < 3) continue;
const [major, minor, patch] = parts;
const minorKey = `${major}.${minor}`;
const cur = map.get(minorKey);
if (!cur || parseInt(patch) > parseInt(cur.split('.')[2])) {
map.set(minorKey, tag);
}
}
return map;
}
export function checkout(antdDir: string, tag: string): boolean {
console.log(` git checkout ${tag}`);
try {
execFileSync('git', ['checkout', tag], { cwd: antdDir, stdio: 'pipe' });
return true;
} catch (err) {
console.error(` Error checking out ${tag}: ${err instanceof Error ? err.message : err}`);
return false;
}
}
function extract(antdDir: string, output: string) {
const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
execFileSync(npxCmd, ['tsx', EXTRACT_SCRIPT, '--antd-dir', antdDir, '--output', output], {
stdio: 'inherit',
});
}
interface SyncMinorSnapshotDeps {
checkoutTag?: (antdDir: string, tag: string) => boolean;
fetchTokenMetaForTag?: (antdDir: string, tag: string) => void;
extractSnapshot?: (antdDir: string, output: string) => void;
}
export function syncMinorSnapshot(
antdDir: string,
dataDir: string,
minorKey: string,
tag: string,
deps: SyncMinorSnapshotDeps = {},
) {
const checkoutTag = deps.checkoutTag ?? checkout;
const fetchTokenMetaForTag = deps.fetchTokenMetaForTag ?? fetchTokenMeta;
const extractSnapshot = deps.extractSnapshot ?? extract;
const snapshot = path.join(dataDir, `v${tag}.json`);
if (fs.existsSync(snapshot)) {
console.log(` Snapshot v${tag}.json already exists, skipping`);
return;
}
console.log(` Extracting ${minorKey}${tag}`);
if (!checkoutTag(antdDir, tag)) {
throw new Error(`Failed to checkout ${tag}`);
}
fetchTokenMetaForTag(antdDir, tag);
extractSnapshot(antdDir, snapshot);
const stalePattern = new RegExp(`^v${minorKey.replaceAll('.', '\\.')}\\.\\d+\\.json$`);
const staleFiles = fs.readdirSync(dataDir).filter((f) => f !== `v${tag}.json` && stalePattern.test(f));
for (const stale of staleFiles) {
fs.unlinkSync(path.join(dataDir, stale));
console.log(` Removed stale snapshot: data/${stale}`);
}
}
/**
* Fetch token-meta.json from the published npm package for the given antd version.
* This file is a build artifact that doesn't exist in raw git source,
* so we extract it from `npm pack` and place it where extractors expect it.
*
* For v5+, token-meta.json is required — missing it would produce data files
* with empty tokens, silently breaking the `antd token` command. We throw
* instead of continuing with empty data.
*/
export function fetchTokenMeta(antdDir: string, tag: string) {
const targetDir = path.join(antdDir, 'components', 'version');
const targetFile = path.join(targetDir, 'token-meta.json');
if (fs.existsSync(targetFile)) {
fs.unlinkSync(targetFile);
}
const majorVersion = parseInt(tag.split('.')[0], 10);
const requiresTokens = majorVersion >= 5;
const tmpDir = path.join(antdDir, '.tmp-npm-pack');
try {
fs.mkdirSync(tmpDir, { recursive: true });
console.log(` Fetching token-meta.json from antd@${tag}...`);
const npm = getNpmInvocation();
execFileSync(npm.command, [...npm.args, 'pack', `antd@${tag}`, '--quiet'], { cwd: tmpDir, stdio: 'pipe' });
const tarball = fs.readdirSync(tmpDir).find((f) => f.endsWith('.tgz'));
if (!tarball) throw new Error('tarball not found');
// Try package/es/version/ (v5+) first, then package/lib/version/ (v4)
const paths = [
'package/es/version/token-meta.json',
'package/lib/version/token-meta.json',
];
let extracted = false;
for (const p of paths) {
try {
execFileSync('tar', ['-xzf', tarball, p], { cwd: tmpDir, stdio: 'pipe' });
const extractedPath = path.join(tmpDir, p);
if (fs.existsSync(extractedPath)) {
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(extractedPath, targetFile);
extracted = true;
break;
}
} catch {
// This path may not exist in this version's package
}
}
if (!extracted) {
const msg = `token-meta.json not found in antd@${tag}`;
if (requiresTokens) {
throw new Error(`${msg} — token data is required for v5+ and cannot be empty`);
}
console.log(` ${msg}, tokens will be empty (v4/v3)`);
}
} catch (err) {
if (requiresTokens) {
throw err; // Re-throw for v5+ — don't silently produce empty token data
}
console.warn(` Warning: Failed to fetch token-meta.json for antd@${tag}: ${err instanceof Error ? err.message : err}`);
} finally {
// Clean up temp dir
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
export function updateVersionsJson(dataDir: string, major: number, minorMap: Map<string, string>) {
const file = path.join(dataDir, 'versions.json');
let index: Record<string, Record<string, string>> = {};
try {
if (fs.existsSync(file)) {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('versions.json must contain an object index');
}
index = parsed;
}
} catch (err) {
throw new Error(`Failed to parse versions.json; refusing to overwrite existing version index: ${err instanceof Error ? err.message : err}`);
}
const majorKey = `v${major}`;
if (!index[majorKey]) index[majorKey] = {};
for (const [minorKey, tag] of minorMap) {
index[majorKey][minorKey] = tag;
}
for (const minorIndex of Object.values(index)) {
if (!minorIndex || typeof minorIndex !== 'object' || Array.isArray(minorIndex)) {
throw new Error('versions.json major entries must contain object indexes');
}
for (const tag of Object.values(minorIndex)) {
const snapshot = path.join(dataDir, `v${tag}.json`);
if (!fs.existsSync(snapshot)) {
throw new Error(`versions.json would reference missing snapshot data/v${tag}.json`);
}
}
}
// Atomic write: write to temp file in same directory then rename (avoids EXDEV across filesystems)
const tmpFile = path.join(dataDir, `.versions-${Date.now()}.tmp`);
try {
fs.writeFileSync(tmpFile, JSON.stringify(index, null, 2) + '\n');
fs.renameSync(tmpFile, file);
} catch (err) {
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
throw err;
}
}
/** Remove snapshot files no longer referenced by versions.json. */
function cleanStaleSnapshots() {
const versionsFile = path.join(DATA_DIR, 'versions.json');
let index: Record<string, Record<string, string>>;
try {
index = JSON.parse(fs.readFileSync(versionsFile, 'utf8'));
} catch (err) {
console.error(` Warning: Failed to parse versions.json, skipping stale snapshot cleanup: ${err instanceof Error ? err.message : err}`);
return;
}
if (Object.keys(index).length === 0) {
console.warn(' versions.json is empty, skipping stale snapshot cleanup');
return;
}
// Collect all referenced snapshot filenames (e.g. "v6.3.7.json")
const referenced = new Set<string>();
for (const [majorKey, minorIndex] of Object.entries(index)) {
referenced.add(`${majorKey}.json`); // primary snapshot (e.g. v6.json)
for (const tag of Object.values(minorIndex ?? {})) {
referenced.add(`v${tag}.json`);
}
}
// Scan data/ for stale .json files matching the v{X}.{Y}.{Z}.json pattern
const files = fs.readdirSync(DATA_DIR).filter((f) => /^v\d+\.\d+\.\d+\.json$/.test(f));
let removed = 0;
for (const file of files) {
if (!referenced.has(file)) {
fs.unlinkSync(path.join(DATA_DIR, file));
console.log(` Removed stale snapshot: data/${file}`);
removed++;
}
}
if (removed === 0) {
console.log(' No stale snapshots to remove');
}
}
/** Validate that primary data files are well-formed after sync. */
function validateData() {
let errors = 0;
for (const major of MAJORS) {
const file = path.join(DATA_DIR, `v${major}.json`);
if (!fs.existsSync(file)) {
console.error(` FAIL: ${file} does not exist`);
errors++;
continue;
}
let data: any;
try {
data = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (err) {
console.error(` FAIL: ${file} is not valid JSON: ${err instanceof Error ? err.message : err}`);
errors++;
continue;
}
if (!data.version || typeof data.version !== 'string') {
console.error(` FAIL: ${file} missing or invalid "version" field`);
errors++;
}
if (!data.majorVersion || typeof data.majorVersion !== 'string') {
console.error(` FAIL: ${file} missing or invalid "majorVersion" field`);
errors++;
}
if (!Array.isArray(data.components) || data.components.length === 0) {
console.error(` FAIL: ${file} missing or empty "components" array`);
errors++;
}
}
const bugVersionsFile = bugVersionsTarget();
try {
const data = JSON.parse(fs.readFileSync(bugVersionsFile, 'utf8')) as Record<string, unknown>;
if (Object.keys(data).length === 0) {
console.error(` FAIL: ${bugVersionsFile} is empty`);
errors++;
}
for (const [range, urls] of Object.entries(data)) {
if (!Array.isArray(urls) || urls.some((url) => typeof url !== 'string')) {
console.error(` FAIL: ${bugVersionsFile} has invalid URLs for range "${range}"`);
errors++;
}
}
} catch (err) {
console.error(` FAIL: ${bugVersionsFile} is not valid JSON: ${err instanceof Error ? err.message : err}`);
errors++;
}
if (errors > 0) {
console.error(`\nValidation failed with ${errors} error(s)`);
process.exit(1);
}
console.log(' All data files valid');
}
/**
* Sync per-major design.md files from the antd source checkout.
*
* antd keeps a hand-curated DESIGN.md at its repo root (the design-language
* document described in ant-design/ant-design#58011). It is not version-specific
* data we can extract — we just copy it verbatim, like other bundled assets.
*
* The doc is major-grained (rewritten only across major releases), so for each
* major we check out its latest tag and copy DESIGN.md → data/design-v{major}.md.
* We check out explicitly because the loop may have skipped checkout when already
* up-to-date, and the CI clone uses --no-checkout. If the source file is absent
* (e.g. a major that predates DESIGN.md, or not yet released in that tag), we keep
* any existing data/design-v{major}.md rather than deleting it — so an already
* bundled copy survives until upstream publishes the file.
*/
function syncDesignDocs(antdDir: string, latestTagByMajor: Map<number, string>) {
for (const major of MAJORS) {
const tag = latestTagByMajor.get(major);
if (!tag) {
console.log(` v${major}: no latest tag, keeping existing data/design-v${major}.md`);
continue;
}
if (!checkout(antdDir, tag)) {
console.log(` v${major}: could not check out ${tag}, keeping existing data/design-v${major}.md`);
continue;
}
const source = path.join(antdDir, DESIGN_SOURCE);
if (!fs.existsSync(source)) {
console.log(` v${major}: ${DESIGN_SOURCE} not found in antd@${tag}, keeping existing data/design-v${major}.md`);
continue;
}
fs.copyFileSync(source, designTarget(major));
console.log(` v${major}: synced ${DESIGN_SOURCE} from antd@${tag} → data/design-v${major}.md`);
}
}
/**
* Sync the root BUG_VERSIONS.json from the latest stable antd checkout.
*
* This file powers `antd doctor`'s known-bug version check. Keeping it in the
* normal sync pipeline preserves the CLI's offline runtime behavior without
* letting the bundled bug database drift from upstream.
*/
function syncBugVersions(antdDir: string, latestTagByMajor: Map<number, string>) {
const latestMajor = Math.max(...latestTagByMajor.keys());
const tag = latestTagByMajor.get(latestMajor);
if (!tag) {
console.log(` no latest tag, keeping existing data/bug-versions.json`);
return;
}
if (!checkout(antdDir, tag)) {
console.log(` could not check out ${tag}, keeping existing data/bug-versions.json`);
return;
}
const source = path.join(antdDir, BUG_VERSIONS_SOURCE);
if (!fs.existsSync(source)) {
console.log(` ${BUG_VERSIONS_SOURCE} not found in antd@${tag}, keeping existing data/bug-versions.json`);
return;
}
fs.copyFileSync(source, bugVersionsTarget());
console.log(` synced ${BUG_VERSIONS_SOURCE} from antd@${tag} → data/bug-versions.json`);
}
function main() {
const { antdDir } = parseArgs(process.argv.slice(2));
// Track the latest stable tag per major, so we can sync version-independent
// assets (DESIGN.md) from the newest major's checkout after the loop.
const latestTagByMajor = new Map<number, string>();
for (const major of MAJORS) {
console.log(`\n=== Syncing v${major} ===`);
const tags = fetchTags(major);
const minorMap = buildMinorMap(tags); // pre-releases already excluded
const latestTag = [...minorMap.values()].at(-1);
if (!latestTag) {
console.warn(`No stable tags found for v${major}, skipping`);
continue;
}
latestTagByMajor.set(major, latestTag);
console.log(`Latest v${major}: ${latestTag}`);
// Extract primary (latest) snapshot → data/v{major}.json (skip if already up-to-date)
const primaryFile = path.join(DATA_DIR, `v${major}.json`);
const currentVersion = fs.existsSync(primaryFile)
? JSON.parse(fs.readFileSync(primaryFile, 'utf8')).version
: null;
if (currentVersion === latestTag) {
console.log(` v${major} already at ${latestTag}, skipping primary extract`);
} else {
console.log(` v${major}: ${currentVersion}${latestTag}`);
if (!checkout(antdDir, latestTag)) continue;
try {
fetchTokenMeta(antdDir, latestTag);
} catch (err) {
console.error(` ERROR: ${err instanceof Error ? err.message : err}`);
console.error(` Skipping v${major} primary extract — cannot proceed without token data`);
continue;
}
extract(antdDir, primaryFile);
}
// Extract per-minor snapshots, replacing stale ones whose patch version changed
for (const [minorKey, tag] of minorMap) {
try {
syncMinorSnapshot(antdDir, DATA_DIR, minorKey, tag);
} catch (err) {
console.error(` ERROR: ${err instanceof Error ? err.message : err}`);
throw new Error(`Failed to sync ${minorKey} snapshot ${tag}; refusing to update versions.json`);
}
}
updateVersionsJson(DATA_DIR, major, minorMap);
console.log(` Updated versions.json for v${major}`);
}
// Final sweep: remove any snapshot files not referenced by versions.json
console.log('\n=== Cleaning stale snapshots ===');
cleanStaleSnapshots();
// Sync per-major design.md files (DESIGN.md) from each major's latest checkout.
console.log('\n=== Syncing design.md ===');
syncDesignDocs(antdDir, latestTagByMajor);
// Sync known-bug version data from latest stable antd.
console.log('\n=== Syncing BUG_VERSIONS.json ===');
syncBugVersions(antdDir, latestTagByMajor);
// Validate extracted data
console.log('\n=== Validating extracted data ===');
validateData();
console.log('\nSync complete.');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main();
}