script/build.ts (213 lines of code) (raw):

import camelcaseKeys from 'camelcase-keys' import chalk from 'chalk' import fs from 'fs' import mkdirp from 'mkdirp' import path from 'path' import ModeCollection from './lib/mode-collection' import VariableCollection, {getFullName} from './lib/variable-collection' interface Skip { type: string name: string } let SKIP: Skip[] = (process.env['PRIMER_SKIP'] || '').split(',').map(skip => { const [type, name] = skip.split('/') return {type, name} }) const dataDir = path.join(__dirname, '..', 'data') const outDir = path.join(__dirname, '..', 'dist') const scssDir = path.join(outDir, 'scss') const tsDir = path.join(outDir, 'ts') const jsonDir = path.join(outDir, 'json') const deprecatedDir = path.join(outDir, 'deprecated') const removedDir = path.join(outDir, 'removed') async function build() { const modeTypes = fs.readdirSync(dataDir) for (const type of modeTypes) { const toSkip = SKIP.filter(skip => skip.type === type).map(skip => skip.name) let collection = await getModeCollectionForType(type, toSkip) const {isValid, errors} = collection.validate() if (!isValid) { logError(errors.join('\n')) process.exit(1) } await writeModeOutput(collection) } await writeMainTsIndex(modeTypes) } async function getModeCollectionForType(type: string, toSkip: string[]): Promise<ModeCollection> { let prefix = type const prefixFile = path.join(dataDir, type, 'prefix') if (fs.existsSync(prefixFile)) { prefix = fs.readFileSync(prefixFile, 'utf8').trim() } const collection = new ModeCollection(type, prefix) const indexFile = path.join(dataDir, type, 'index.ts') // TODO: log error if file doesn't exist if (fs.existsSync(indexFile)) { // TODO: check that modes is an object const {default: modes} = require(indexFile) for (const mode in modes) { if (toSkip.includes(mode)) { continue } const vars = new VariableCollection(mode, prefix, null) vars.addFromObject(modes[mode]) collection.add(mode, vars) } } collection.finalize() return collection } async function writeModeOutput(collection: ModeCollection): Promise<void> { writeScssOutput(collection) writeTsOutput(collection) writeJsonOutput(collection) writeTsTypeIndex(collection) writeReplacements('deprecated.json', deprecatedDir, collection, validateDeprecatedVar) writeReplacements('removed.json', removedDir, collection, validateRemovedVar) } async function writeScssOutput(collection: ModeCollection): Promise<void> { for (const [_name, vars] of collection) { let output = `@mixin primer-${collection.type}-${vars.name} {\n` output += ' & {\n' for (const variable of vars) { output += ` --${variable.name}: ${variable.value};\n` } output += ' }\n}\n' const dir = path.join(scssDir, collection.type) await mkdirp(dir) fs.writeFileSync(path.join(dir, `_${vars.name}.scss`), output) } } async function writeTsOutput(collection: ModeCollection): Promise<void> { for (const [_name, vars] of collection) { let output = JSON.stringify(camelcaseKeys(vars.tree(), {deep: true}), null, ' ') output = `export default ${output}` const dir = path.join(tsDir, collection.type) await mkdirp(dir) fs.writeFileSync(path.join(dir, `${vars.name}.ts`), output) } } async function writeJsonOutput(collection: ModeCollection): Promise<void> { for (const [_name, vars] of collection) { let output = JSON.stringify(camelcaseKeys(vars.tree(), {deep: true}), null, ' ') const dir = path.join(jsonDir, collection.type) await mkdirp(dir) fs.writeFileSync(path.join(dir, `${vars.name}.json`), output) } } async function writeTsTypeIndex(collection: ModeCollection) { let output = '' const modules = [...collection.modes.keys()] for (const mod of modules) { output += `import ${mod} from './${mod}'\n` } output += `export default { ${modules.join(', ')} }` const dir = path.join(tsDir, collection.type) await mkdirp(dir) fs.writeFileSync(path.join(dir, `index.ts`), output) } async function writeMainTsIndex(types: string[]) { let output = '' for (const type of types) { output += `import ${type} from './${type}'\n` } output += `export default { ${types.join(', ')} }` const dir = path.join(tsDir) await mkdirp(dir) fs.writeFileSync(path.join(dir, `index.ts`), output) } if (require.main === module) { build() .then(() => console.log('✨ Built mode data 🎉')) .catch(err => console.error(err)) } /** * Validates a deprecated variable. * @returns Array of error messages. If the returned array is empty, the variable is valid. */ function validateDeprecatedVar(variable: string, collection: ModeCollection, inputFile: string) { const errors = [] // Assert that deprecated variable exists if (!existsInCollection(collection, variable)) { errors.push(chalk`Cannot deprecate undefined variable {bold.red "${variable}"} in {bold ${inputFile}}`) } return errors } /** * Validates a removed variable. * @returns Array of error messages. If the returned array is empty, the variable is valid. */ function validateRemovedVar(variable: string, collection: ModeCollection, inputFile: string) { const errors = [] // Assert that removed variable doesn't exist if (existsInCollection(collection, variable)) { errors.push( chalk`Variable {bold.red "${variable}"} is marked as removed in {bold ${inputFile}} but is still defined` ) } return errors } async function writeReplacements( inputFilename: string, outputDir: string, collection: ModeCollection, // Function to validate a variable (e.g. deprecated variable or removed variable). // Returns an array of error messages. If the returned array is empty, the variable is valid. validateVar: (variable: string, collection: ModeCollection, inputFile: string) => any[] ) { const inputFile = path.join(dataDir, collection.type, inputFilename) // Do nothing if deprecated file doesn't exist if (!fs.existsSync(inputFile)) { return } try { // Parse input file const replacementMap = JSON.parse(fs.readFileSync(inputFile, 'utf8')) // Validations const errors = [] for (const [original, replacement] of Object.entries(replacementMap)) { errors.push(...validateVar(original, collection, inputFile)) // We expect `replacement` to be a variable name, an array of variable names, or null forEachReplacementVar(replacement, replacementVar => { // Assert that replacement variable is a string if (typeof replacementVar !== 'string') { errors.push( chalk`Cannot replace {bold "${original}"} with invalid variable {bold.red ${JSON.stringify( replacementVar )}} in {bold ${inputFile}}` ) return } // Assert that replacement variable exists if (!existsInCollection(collection, replacementVar)) { errors.push( chalk`Cannot replace {bold "${original}"} with undefined variable {bold.red ${JSON.stringify( replacementVar )}} in {bold ${inputFile}}` ) return } // Assert that replacement variable is not deprecated if (Object.keys(replacementMap).includes(replacementVar)) { errors.push( chalk`Cannot replace {bold "${original}"} with deprecated variable {bold.red ${JSON.stringify( replacementVar )}} in {bold ${inputFile}}` ) return } }) } if (errors.length === 0) { // Write replacements await mkdirp(outputDir) fs.writeFileSync(path.join(outputDir, `${collection.type}.json`), JSON.stringify(replacementMap, null, ' ')) } else { throw new Error(errors.join('\n')) } } catch (error) { logError(error.message) process.exit(1) } } /** Checks if a variable exists in a collection. Assumes variable name uses dot notation (e.g. `text.primary`) */ function existsInCollection(collection: ModeCollection, name: string) { const varName = getFullName(collection.prefix, name.split('.')) return Array.from(collection.modes.values()).some(mode => Boolean(mode.getByName(varName))) } function forEachReplacementVar(replacement: any, fn: (replacementVar: any) => void) { if (replacement === null) { return } if (Array.isArray(replacement)) { for (const replacementVar of replacement) { fn(replacementVar) } } else { fn(replacement) } } function logError(error: string) { console.error(chalk.red`\n===============================================`) console.error(chalk`{red [FATAL]} The build failed due to the following errors:`) console.error(error) console.error(chalk.red`===============================================\n`) }