handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
522 lines (448 loc) • 16.8 kB
text/typescript
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;