UNPKG

handoff-app

Version:

Automated documentation toolchain for building client side documentation from figma

414 lines (361 loc) 14 kB
import * as p from '@clack/prompts'; import archiver from 'archiver'; import 'dotenv/config'; import fs from 'fs-extra'; import { Types as HandoffTypes, Transformers } from 'handoff-core'; import { sortedUniq } from 'lodash'; import * as stream from 'node:stream'; import path from 'path'; import Handoff from '.'; import buildApp from './app'; import { createDocumentationObject } from './documentation-object'; import { componentTransformer } from './transformers/preview/component'; import { FontFamily } from './types/font'; import { Logger } from './utils/logger'; /** * Read Previous Json File * @param path * @returns */ export const readPrevJSONFile = async (path: string) => { try { return await fs.readJSON(path); } catch (e) { return undefined; } }; /** * Zips the contents of a directory and writes the resulting archive to a writable stream. * * @param dirPath - The path to the directory whose contents will be zipped. * @param destination - A writable stream where the zip archive will be written. * @returns A Promise that resolves with the destination stream when the archive has been finalized. * @throws Will throw an error if the archiving process fails. */ const zip = async (dirPath: string, destination: stream.Writable): Promise<stream.Writable> => { return new Promise((resolve, reject) => { const archive = archiver('zip', { zlib: { level: 9 }, }); // Set up event handlers archive.on('error', reject); destination.on('error', reject); // When the destination closes, resolve the promise destination.on('close', () => resolve(destination)); archive.pipe(destination); fs.readdir(dirPath) .then((fontDir) => { for (const file of fontDir) { const filePath = path.join(dirPath, file); archive.append(fs.createReadStream(filePath), { name: path.basename(file) }); } return archive.finalize(); }) .catch(reject); }); }; export const zipAssets = async (assets: HandoffTypes.IAssetObject[], destination: stream.Writable) => { const archive = archiver('zip', { zlib: { level: 9 }, // Sets the compression level. }); // good practice to catch this error explicitly archive.on('error', function (err) { throw err; }); archive.pipe(destination); assets.forEach((asset) => { archive.append(asset.data, { name: asset.path }); }); await archive.finalize(); return destination; }; /** * Build just the custom fonts * @param documentationObject * @returns */ const buildCustomFonts = async (handoff: Handoff, documentationObject: HandoffTypes.IDocumentationObject) => { const { localStyles } = documentationObject; const fontLocation = path.join(handoff?.workingPath, 'fonts'); const families: FontFamily = localStyles.typography.reduce((result, current) => { return { ...result, [current.values.fontFamily]: result[current.values.fontFamily] ? // sorts and returns unique font weights sortedUniq([...result[current.values.fontFamily], current.values.fontWeight].sort((a, b) => a - b)) : [current.values.fontWeight], }; }, {} as FontFamily); Object.keys(families).map(async (key) => { const name = key.replace(/\s/g, ''); const fontDirName = path.join(fontLocation, name); if (fs.existsSync(fontDirName)) { const stream = fs.createWriteStream(path.join(fontLocation, `${name}.zip`)); await zip(fontDirName, stream); const fontsFolder = path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId(), 'fonts'); if (!fs.existsSync(fontsFolder)) { fs.mkdirSync(fontsFolder); } fs.copySync(fontDirName, fontsFolder); } }); }; /** * Build previews * @param documentationObject * @returns */ export const buildComponents = async (handoff: Handoff) => { await Promise.all([componentTransformer(handoff)]); }; /** * Build only the styles pipeline * @param documentationObject */ const buildStyles = async (handoff: Handoff, documentationObject: HandoffTypes.IDocumentationObject) => { // Core transformers that should always be included const coreTransformers = [ { transformer: Transformers.ScssTransformer, outDir: 'sass', format: 'scss', }, { transformer: Transformers.ScssTypesTransformer, outDir: 'types', format: 'scss', }, { transformer: Transformers.CssTransformer, outDir: 'css', format: 'css', }, ]; // Get user-configured transformers const userTransformers = handoff.config?.pipeline?.transformers || []; // Merge core transformers with user transformers // If a user transformer matches a core transformer, use user's outDir and format const transformers = coreTransformers.map((coreTransformer) => { const userTransformer = userTransformers.find((t) => t.transformer === coreTransformer.transformer); return userTransformer ? { ...coreTransformer, outDir: userTransformer.outDir, format: userTransformer.format } : coreTransformer; }); // Add any additional user transformers that aren't core transformers userTransformers.forEach((userTransformer) => { if (!coreTransformers.some((core) => core.transformer === userTransformer.transformer)) { transformers.push(userTransformer); } }); const baseDir = handoff.getVariablesFilePath(); const runner = await handoff.getRunner(); // Create transformer instances and transform documentation object const transformedFiles = transformers.map(({ transformer }) => ({ transformer, files: runner.transform(transformer({ useVariables: handoff.config?.useVariables }), documentationObject), })); // Ensure base directory exists await fs.ensureDir(baseDir); // Create all necessary subdirectories const directories = transformers.map(({ outDir }) => path.join(baseDir, outDir)); await Promise.all(directories.map((dir) => fs.ensureDir(dir))); // Special case for SD tokens components directory const sdTransformer = transformers.find((t) => t.transformer === Transformers.StyleDictionaryTransformer); if (sdTransformer) { const sdFiles = transformedFiles.find((t) => t.transformer === Transformers.StyleDictionaryTransformer)?.files; if (sdFiles?.components) { await Promise.all(Object.keys(sdFiles.components).map((name) => fs.ensureDir(path.join(baseDir, sdTransformer.outDir, name)))); } } // Write all files const writePromises = transformedFiles.flatMap(({ transformer: TransformerClass, files }) => { const { outDir, format } = transformers.find((t) => t.transformer === TransformerClass) || {}; if (!outDir || !files) return []; const componentPromises = Object.entries(files.components || {}).map(([name, content]) => { const filePath = TransformerClass === Transformers.StyleDictionaryTransformer ? path.join(baseDir, outDir, name, `${name}.tokens.json`) : path.join(baseDir, outDir, `${name}.${format}`); return fs.writeFile(filePath, content); }); const designPromises = Object.entries(files.design || {}).map(([name, content]) => { const filePath = TransformerClass === Transformers.StyleDictionaryTransformer ? path.join(baseDir, outDir, `${name}.tokens.json`) : path.join(baseDir, outDir, `${name}.${format}`); return fs.writeFile(filePath, content); }); return [...componentPromises, ...designPromises]; }); // Generate tokens-map.json const mapFiles = transformedFiles.find((t) => t.transformer === Transformers.MapTransformer)?.files; if (mapFiles) { const tokensMapContent = JSON.stringify( Object.entries(mapFiles.components || {}).reduce( (acc, [_, data]) => ({ ...acc, ...JSON.parse(data as string), }), { ...JSON.parse(mapFiles.design.colors as string), ...JSON.parse(mapFiles.design.typography as string), ...JSON.parse(mapFiles.design.effects as string), } ), null, 2 ); writePromises.push(fs.writeFile(path.join(handoff.getOutputPath(), 'tokens-map.json'), tokensMapContent)); } // Write all files await Promise.all(writePromises); }; const validateHandoffRequirements = async (handoff: Handoff) => { let requirements = false; const result = process.versions; if (result && result.node) { if (parseInt(result.node) >= 16) { requirements = true; } } else { // couldn't find the right version, but ... } if (!requirements) { Logger.error('Handoff installation failed.'); Logger.warn( '- Please update node to at least Node 16 https://nodejs.org/en/download. \n- You can read more about installing handoff at https://www.handoff.com/docs/' ); throw new Error('Could not run handoff'); } }; /** * Validate the figma auth tokens * @param handoff */ const validateFigmaAuth = async (handoff: Handoff): Promise<void> => { let DEV_ACCESS_TOKEN = handoff.config.dev_access_token; let FIGMA_PROJECT_ID = handoff.config.figma_project_id; if (DEV_ACCESS_TOKEN && FIGMA_PROJECT_ID) { return; } let missingEnvVars = false; if (!DEV_ACCESS_TOKEN) { missingEnvVars = true; p.log.warn( `Figma developer access token not found. You can supply it as an environment variable or .env file at HANDOFF_DEV_ACCESS_TOKEN.\n` + `Use these instructions to generate them: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens` ); const token = await p.password({ message: 'Figma Developer Key:', }); if (p.isCancel(token)) { p.cancel('Authentication cancelled.'); process.exit(0); } DEV_ACCESS_TOKEN = token as string; } if (!FIGMA_PROJECT_ID) { missingEnvVars = true; p.log.warn( `Figma project ID not found. Provide HANDOFF_FIGMA_PROJECT_ID via environment variable or .env file.\n` + `Find it in your Figma file URL (e.g., figma.com/file/{PROJECT_ID}/...).` ); const projectId = await p.text({ message: 'Figma Project Id:', validate: (value) => { if (!value.trim()) return 'Project ID is required'; }, }); if (p.isCancel(projectId)) { p.cancel('Authentication cancelled.'); process.exit(0); } FIGMA_PROJECT_ID = projectId as string; } if (missingEnvVars) { p.log.info(`To simplify future runs, we can save these variables to a local .env file.`); const writeEnvFile = await p.confirm({ message: 'Write environment variables to .env file?', initialValue: true, }); if (p.isCancel(writeEnvFile) || writeEnvFile === false) { p.log.info(`Skipped .env file creation. Please provide these variables manually.`); } else { const envFilePath = path.resolve(handoff.workingPath, '.env'); const envFileContent = ` HANDOFF_DEV_ACCESS_TOKEN="${DEV_ACCESS_TOKEN}" HANDOFF_FIGMA_PROJECT_ID="${FIGMA_PROJECT_ID}" `; try { const fileExists = await fs .access(envFilePath) .then(() => true) .catch(() => false); if (fileExists) { await fs.appendFile(envFilePath, envFileContent); p.log.success( `The .env file was found and updated with new content. Since these are sensitive variables, please do not commit this file.` ); } else { await fs.writeFile(envFilePath, envFileContent.replace(/^\s*[\r\n]/gm, '')); p.log.success(`Created .env file. Do not commit sensitive variables.`); } } catch (error) { Logger.error('Error handling the .env file:', error); } } } handoff.config.dev_access_token = DEV_ACCESS_TOKEN; handoff.config.figma_project_id = FIGMA_PROJECT_ID; }; const figmaExtract = async (handoff: Handoff) => { Logger.success(`Starting Figma data extraction.`); await fs.emptyDir(handoff.getOutputPath()); const documentationObject = await createDocumentationObject(handoff); await Promise.all([ fs.writeJSON(handoff.getTokensFilePath(), documentationObject, { spaces: 2 }), ...(!process.env.HANDOFF_CREATE_ASSETS_ZIP_FILES || process.env.HANDOFF_CREATE_ASSETS_ZIP_FILES !== 'false' ? [ zipAssets(documentationObject.assets.icons, fs.createWriteStream(handoff.getIconsZipFilePath())).then((writeStream) => stream.promises.finished(writeStream) ), zipAssets(documentationObject.assets.logos, fs.createWriteStream(handoff.getLogosZipFilePath())).then((writeStream) => stream.promises.finished(writeStream) ), ] : []), ]); // define the output folder const outputFolder = path.resolve(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`, 'public'); // ensure output folder exists if (!fs.existsSync(outputFolder)) { await fs.promises.mkdir(outputFolder, { recursive: true }); } // copy assets to output folder fs.copyFileSync( handoff.getIconsZipFilePath(), path.join(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`, 'public', 'icons.zip') ); fs.copyFileSync( handoff.getLogosZipFilePath(), path.join(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`, 'public', 'logos.zip') ); return documentationObject; }; /** * Run the entire pipeline */ const pipeline = async (handoff: Handoff, build?: boolean) => { if (!handoff.config) { throw new Error('Handoff config not found'); } Logger.success(`Starting Handoff Figma data pipeline. Checking for environment and config.`); await validateHandoffRequirements(handoff); await validateFigmaAuth(handoff); const documentationObject = await figmaExtract(handoff); await buildCustomFonts(handoff, documentationObject); await buildStyles(handoff, documentationObject); // await buildComponents(handoff); if (build) { await buildApp(handoff); } }; export default pipeline;