UNPKG

handoff-app

Version:

Automated documentation toolchain for building client side documentation from figma

522 lines (448 loc) 16.8 kB
import 'dotenv/config'; import fs from 'fs-extra'; import { Types as CoreTypes, Handoff as HandoffRunner, Providers } from 'handoff-core'; import path from 'path'; import buildApp, { devApp, watchApp } from './app'; import { ejectConfig, ejectPages, ejectTheme } from './cli/eject'; import { makeComponent, makePage, makeTemplate } from './cli/make'; import { defaultConfig } from './config'; import pipeline, { buildComponents } from './pipeline'; import { processSharedStyles } from './transformers/preview/component'; import processComponents, { ComponentSegment } from './transformers/preview/component/builder'; import { ComponentListObject } from './transformers/preview/types'; import { Config, RuntimeConfig } from './types/config'; import { Logger } from './utils/logger'; import { generateFilesystemSafeId } from './utils/path'; class Handoff { config: Config | null; debug: boolean = false; force: boolean = false; modulePath: string = path.resolve(__filename, '../..'); workingPath: string = process.cwd(); exportsDirectory: string = 'exported'; sitesDirectory: string = 'out'; runtimeConfig?: RuntimeConfig | null; designMap: { colors: {}; effects: {}; typography: {}; }; private _initialArgs: { debug?: boolean; force?: boolean; config?: Partial<Config> } = {}; private _configFilePaths: string[] = []; private _documentationObjectCache?: CoreTypes.IDocumentationObject; private _sharedStylesCache?: string | null; private _handoffRunner?: ReturnType<typeof HandoffRunner> | null; constructor(debug?: boolean, force?: boolean, config?: Partial<Config>) { this._initialArgs = { debug, force, config }; this.construct(debug, force, config); } private construct(debug?: boolean, force?: boolean, config?: Partial<Config>) { this.config = null; this.debug = debug ?? false; this.force = force ?? false; Logger.init({ debug: this.debug }); this.init(config); global.handoff = this; } init(configOverride?: Partial<Config>): Handoff { const config = initConfig(configOverride ?? {}); this.config = config; this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory; this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory; [this.runtimeConfig, this._configFilePaths] = initRuntimeConfig(this); return this; } reload(): Handoff { this.construct(this._initialArgs.debug, this._initialArgs.force, this._initialArgs.config); return this; } preRunner(validate?: boolean): Handoff { if (!this.config) { throw Error('Handoff not initialized'); } if (validate) { this.config = validateConfig(this.config); } return this; } async fetch(): Promise<Handoff> { this.preRunner(); await pipeline(this); return this; } async component(name: string | null): Promise<Handoff> { this.preRunner(); if (name) { name = name.replace('.hbs', ''); await processComponents(this, name); } else { await buildComponents(this); } return this; } async build(skipComponents?: boolean): Promise<Handoff> { this.preRunner(); await buildApp(this, skipComponents); return this; } async ejectConfig(): Promise<Handoff> { this.preRunner(); await ejectConfig(this); return this; } async ejectPages(): Promise<Handoff> { this.preRunner(); await ejectPages(this); return this; } async ejectTheme(): Promise<Handoff> { this.preRunner(); await ejectTheme(this); return this; } async makeTemplate(component: string, state: string): Promise<Handoff> { this.preRunner(); await makeTemplate(this, component, state); return this; } async makePage(name: string, parent: string): Promise<Handoff> { this.preRunner(); await makePage(this, name, parent); return this; } async makeComponent(name: string): Promise<Handoff> { this.preRunner(); await makeComponent(this, name); return this; } async start(): Promise<Handoff> { this.preRunner(); await watchApp(this); return this; } async dev(): Promise<Handoff> { this.preRunner(); await devApp(this); return this; } async validateComponents(skipBuild?: boolean): Promise<Handoff> { this.preRunner(); if (!skipBuild) { await processComponents(this, undefined, ComponentSegment.Validation); } return this; } /** * Retrieves the documentation object, using cached version if available * @returns {Promise<CoreTypes.IDocumentationObject | undefined>} The documentation object or undefined if not found */ async getDocumentationObject(): Promise<CoreTypes.IDocumentationObject | undefined> { if (this._documentationObjectCache) { return this._documentationObjectCache; } const documentationObject = await this.readJsonFile(this.getTokensFilePath()); this._documentationObjectCache = documentationObject; return documentationObject; } /** * Retrieves shared styles, using cached version if available * @returns {Promise<string | null>} The shared styles string or null if not found */ async getSharedStyles(): Promise<string | null> { if (this._sharedStylesCache !== undefined) { return this._sharedStylesCache; } const sharedStyles = await processSharedStyles(this); this._sharedStylesCache = sharedStyles; return sharedStyles; } async getRunner(): Promise<ReturnType<typeof HandoffRunner>> { if (!!this._handoffRunner) { return this._handoffRunner; } const apiCredentials = { projectId: this.config.figma_project_id, accessToken: this.config.dev_access_token, }; // Initialize the provider const provider = Providers.RestApiProvider(apiCredentials); this._handoffRunner = HandoffRunner( provider, { options: { transformer: this.runtimeConfig.options, }, }, { log: (msg: string): void => { Logger.log(msg); }, err: (msg: string): void => { Logger.error(msg); }, warn: (msg: string): void => { Logger.warn(msg); }, success: (msg: string): void => { Logger.success(msg); }, } ); return this._handoffRunner; } /** * Gets the project ID, falling back to filesystem-safe working path if figma_project_id is missing * @returns {string} The project ID to use for path construction */ getProjectId(): string { if (this.config?.figma_project_id) { return this.config.figma_project_id; } // Fallback to filesystem-safe transformation of working path return generateFilesystemSafeId(this.workingPath); } /** * Gets the output path for the current project * @returns {string} The absolute path to the output directory */ getOutputPath(): string { return path.resolve(this.workingPath, this.exportsDirectory, this.getProjectId()); } /** * Gets the path to the tokens.json file * @returns {string} The absolute path to the tokens.json file */ getTokensFilePath(): string { return path.join(this.getOutputPath(), 'tokens.json'); } /** * Gets the path to the preview.json file * @returns {string} The absolute path to the preview.json file */ getPreviewFilePath(): string { return path.join(this.getOutputPath(), 'preview.json'); } /** * Gets the path to the tokens directory * @returns {string} The absolute path to the tokens directory */ getVariablesFilePath(): string { return path.join(this.getOutputPath(), 'tokens'); } /** * Gets the path to the icons.zip file * @returns {string} The absolute path to the icons.zip file */ getIconsZipFilePath(): string { return path.join(this.getOutputPath(), 'icons.zip'); } /** * Gets the path to the logos.zip file * @returns {string} The absolute path to the logos.zip file */ getLogosZipFilePath(): string { return path.join(this.getOutputPath(), 'logos.zip'); } /** * Gets the list of config file paths * @returns {string[]} Array of absolute paths to config files */ getConfigFilePaths(): string[] { return this._configFilePaths; } /** * Clears all cached data * @returns {void} */ clearCaches(): void { this._documentationObjectCache = undefined; this._sharedStylesCache = undefined; } /** * Reads and parses a JSON file * @param {string} path - Path to the JSON file * @returns {Promise<any>} The parsed JSON content or undefined if file cannot be read */ private async readJsonFile(path: string) { try { return await fs.readJSON(path); } catch (e) { return undefined; } } } const initConfig = (configOverride?: Partial<Config>): Config => { let config = {}; const possibleConfigFiles = ['handoff.config.json', 'handoff.config.js', 'handoff.config.cjs']; // Find the first existing config file const configFile = possibleConfigFiles.find((file) => fs.existsSync(path.resolve(process.cwd(), file))); if (configFile) { const configPath = path.resolve(process.cwd(), configFile); if (configFile.endsWith('.json')) { const defBuffer = fs.readFileSync(configPath); config = JSON.parse(defBuffer.toString()) as Config; } else if (configFile.endsWith('.js') || configFile.endsWith('.cjs')) { // Invalidate require cache to ensure fresh read delete require.cache[require.resolve(configPath)]; const importedConfig = require(configPath); config = importedConfig.default || importedConfig; } } // Apply overrides if provided if (configOverride) { Object.keys(configOverride).forEach((key) => { const value = configOverride[key as keyof Config]; if (value !== undefined) { config[key as keyof Config] = value; } }); } const returnConfig = { ...defaultConfig(), ...config } as unknown as Config; return returnConfig; }; export const initRuntimeConfig = (handoff: Handoff): [runtimeConfig: RuntimeConfig, configs: string[]] => { const configFiles: string[] = []; const result: RuntimeConfig = { options: {}, entries: { scss: undefined, js: undefined, components: {}, }, }; if (!!handoff.config.entries?.scss) { result.entries.scss = path.resolve(handoff.workingPath, handoff.config.entries?.scss); } if (!!handoff.config.entries?.js) { result.entries.js = path.resolve(handoff.workingPath, handoff.config.entries?.js); } if (handoff.config.entries?.components?.length) { const componentPaths = handoff.config.entries.components.flatMap(getComponentsForPath); for (const componentPath of componentPaths) { const resolvedComponentPath = path.resolve(handoff.workingPath, componentPath); const componentBaseName = path.basename(resolvedComponentPath); const possibleConfigFiles = [`${componentBaseName}.json`, `${componentBaseName}.js`, `${componentBaseName}.cjs`]; const configFileName = possibleConfigFiles.find((file) => fs.existsSync(path.resolve(resolvedComponentPath, file))); if (!configFileName) { Logger.warn(`Missing config: ${path.resolve(resolvedComponentPath, possibleConfigFiles.join(' or '))}`); continue; } const resolvedComponentConfigPath = path.resolve(resolvedComponentPath, configFileName); configFiles.push(resolvedComponentConfigPath); let component: ComponentListObject; try { if (configFileName.endsWith('.json')) { const componentJson = fs.readFileSync(resolvedComponentConfigPath, 'utf8'); component = JSON.parse(componentJson) as ComponentListObject; } else { // Invalidate require cache to ensure fresh read delete require.cache[require.resolve(resolvedComponentConfigPath)]; const importedComponent = require(resolvedComponentConfigPath); component = importedComponent.default || importedComponent; } } catch (err) { Logger.error(`Failed to read or parse config: ${resolvedComponentConfigPath}`, err); continue; } // Use component basename as the id component.id = componentBaseName; // Resolve entry paths relative to component directory if (component.entries) { for (const entryType in component.entries) { if (component.entries[entryType]) { component.entries[entryType] = path.resolve(resolvedComponentPath, component.entries[entryType]); } } } // Initialize options with safe defaults component.options ||= { transformer: { defaults: {}, replace: {} }, }; component.options.transformer ||= { defaults: {}, replace: {} }; const transformer = component.options.transformer; transformer.cssRootClass ??= null; transformer.tokenNameSegments ??= null; // Normalize keys and values to lowercase transformer.defaults = toLowerCaseKeysAndValues({ ...transformer.defaults, }); transformer.replace = toLowerCaseKeysAndValues({ ...transformer.replace, }); // Save transformer config result.options[component.id] = transformer; // Save full component entry result.entries.components[component.id] = component; } } return [result, Array.from(configFiles)]; }; /** * Returns a list of component directories for a given path. * * This function determines whether the provided `searchPath` is: * 1. A single component directory (contains a config file named after the directory) * 2. A collection of component directories (subdirectories are components) * * A directory is considered a component if it contains a config file matching * `{dirname}.json`, `{dirname}.js`, or `{dirname}.cjs`. * * @param searchPath - The absolute path to check for components. * @returns An array of string paths to component directories. */ const getComponentsForPath = (searchPath: string): string[] => { const dirName = path.basename(searchPath); const possibleConfigFiles = [`${dirName}.json`, `${dirName}.js`, `${dirName}.cjs`]; // Check if searchPath itself is a component directory (has a config file named after the directory) const hasOwnConfig = possibleConfigFiles.some((file) => fs.existsSync(path.resolve(searchPath, file))); if (hasOwnConfig) { // This directory is a single component return [searchPath]; } // Otherwise, treat each subdirectory as a potential component const subdirectories = fs .readdirSync(searchPath, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); if (subdirectories.length > 0) { // Return full paths to each subdirectory as potential component directories return subdirectories.map((subdir) => path.join(searchPath, subdir)); } // Fallback: no config file and no subdirectories, return the path anyway // (will fail gracefully with "missing config" warning later) return [searchPath]; }; const validateConfig = (config: Config): Config => { // TODO: Check to see if the exported folder exists before we run start if (!config.figma_project_id && !process.env.HANDOFF_FIGMA_PROJECT_ID) { // check to see if we can get this from the env Logger.error('Figma Project ID missing. Please set HANDOFF_FIGMA_PROJECT_ID or run "handoff-app fetch".'); throw new Error('Cannot initialize configuration'); } if (!config.dev_access_token && !process.env.HANDOFF_DEV_ACCESS_TOKEN) { // check to see if we can get this from the env Logger.error('Figma Access Token missing. Please set HANDOFF_DEV_ACCESS_TOKEN or run "handoff-app fetch".'); throw new Error('Cannot initialize configuration'); } return config; }; const toLowerCaseKeysAndValues = (obj: Record<string, any>): Record<string, any> => { const loweredObj: Record<string, any> = {}; for (const key in obj) { const lowerKey = key.toLowerCase(); const value = obj[key]; if (typeof value === 'string') { loweredObj[lowerKey] = value.toLowerCase(); } else if (typeof value === 'object' && value !== null) { loweredObj[lowerKey] = toLowerCaseKeysAndValues(value); } else { loweredObj[lowerKey] = value; // For non-string values } } return loweredObj; }; export type { ComponentObject as Component } from './transformers/preview/types'; export type { Config } from './types/config'; // Export transformers and types from handoff-core export { Transformers as CoreTransformers, TransformerUtils as CoreTransformerUtils, Types as CoreTypes } from 'handoff-core'; export default Handoff;