diff --git a/package.json b/package.json index 5795d1e14aa4a7..a2299e841de350 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "traverse": "tsx scripts/traverse.ts", "update-browser-releases": "tsx scripts/update-browser-releases/index.ts", "bcd": "tsx scripts/bulk-editor/index.ts", + "split": "tsx scripts/split.ts", "tag-web-features": "tsx scripts/tag-web-features.ts" } } diff --git a/scripts/split.ts b/scripts/split.ts new file mode 100644 index 00000000000000..fc1139c353aedc --- /dev/null +++ b/scripts/split.ts @@ -0,0 +1,126 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join, relative, resolve, sep } from 'node:path'; + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { Identifier } from '../types/types'; + +const argv = yargs(hideBin(process.argv)) + .command('$0 [files..]', 'Split BCD JSON files', (yargs) => { + yargs.positional('files', { + describe: 'One or more JSON files to split', + type: 'string', + }); + }) + .help().argv as any as { files: string[] }; + +/** + * Extracts the base key path (as array of strings) from a JSON file path. + * + * For example, `api/AbortController.json` becomes `['api', 'AbortController']`. + * @param filePath - The path to the BCD JSON file + * @returns Array of path components without the `.json` extension + */ +const getBaseKeyFromPath = (filePath: string): string[] => { + const relativePath = filePath.replace(/\.json$/, '').split(sep); + return relativePath; +}; + +/** + * Creates the JSON for the subfeature. + * @param baseKeys - The parent keys in which to nest the data. + * @param key - The key of the subfeature. + * @param value - The value of the subfeature. + * @returns JSON for the subfeature data nested in parent structure. + */ +const createSubfeatureJSON = ( + baseKeys: string[], + key: string, + value: Identifier, +) => { + const data = {}; + let current = data; + + for (const baseKey of baseKeys) { + current[baseKey] = {}; + current = current[baseKey]; + } + + current[key] = { + __compat: value.__compat, + }; + + return data; +}; + +/** + * Checks if data contains compat data. + * @param data - The data to check for `__compat` entries. + * @returns TRUE, if the data contains any compat data. + */ +const hasCompatData = (data: any) => + '__compat' in data || + (typeof data === 'object' && Object.values(data).some(hasCompatData)); + +/** + * Writes a JSON file. + * @param path - The path to the file. + * @param data - The data to write as JSON. + * @returns Promise. + */ +const writeJSONFile = async (path: string, data: Identifier) => + writeFile(path, JSON.stringify(data, null, 2) + '\n'); + +/** + * Splits a BCD-style JSON file into separate files for each subfeature. + * Each file will include only the `__compat` data of a given property. + * Extracted entries are also removed from the original file. + * @param file - The absolute or relative path to the source JSON file + */ +const splitFile = async (file: string) => { + const fullPath = resolve(file); + const raw = await readFile(fullPath, 'utf-8'); + const data = JSON.parse(raw) as Identifier; + + const baseKeys = getBaseKeyFromPath(file); + let current = data; + + for (const key of baseKeys) { + if (!(key in current)) { + console.error(`❌ Error: Key "${key}" not found in structure.`); + return; + } + current = current[key]; + } + + const dirPath = fullPath.replace(/\.json$/, ''); + + // Write file for each subfeature, then remove subfeature from parent file. + for (const [key, value] of Object.entries(current)) { + if (key === '__compat') { + continue; + } + if (typeof value === 'object' && '__compat' in value) { + const data = createSubfeatureJSON(baseKeys, key, value); + + await mkdir(dirPath, { recursive: true }); + const outFile = join(dirPath, `${key}.json`); + await writeJSONFile(outFile, data); + console.log(`✔ Created: ${relative(process.cwd(), outFile)}`); + + Reflect.deleteProperty(current, key); + } + } + + // Check if current file still has any data. + if (hasCompatData(data)) { + await writeJSONFile(fullPath, data); + console.log(`✔ Updated: ${file}`); + } else { + await rm(fullPath); + console.log(`✔ Deleted: ${file}`); + } +}; + +await Promise.all(argv.files.map(splitFile));