handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
700 lines (615 loc) • 24 kB
text/typescript
import chalk from 'chalk';
import chokidar from 'chokidar';
import spawn from 'cross-spawn';
import fs from 'fs-extra';
import matter from 'gray-matter';
import { createServer } from 'http';
import next from 'next';
import path from 'path';
import { parse } from 'url';
import WebSocket from 'ws';
import Handoff from '.';
import { getClientConfig } from './config';
import { buildComponents } from './pipeline';
import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
import { ComponentListObject } from './transformers/preview/types';
interface ExtWebSocket extends WebSocket {
isAlive: boolean;
}
/**
* Creates a WebSocket server that broadcasts messages to connected clients.
* Designed for development mode to help with hot-reloading.
*
* @param port - Optional port number for the WebSocket server; defaults to 3001.
* @returns A function that accepts a message string and broadcasts it to all connected clients.
*/
const createWebSocketServer = async (port: number = 3001) => {
const wss = new WebSocket.Server({ port });
// Heartbeat function to mark a connection as alive.
const heartbeat = function (this: ExtWebSocket) {
this.isAlive = true;
};
// Setup a new connection
wss.on('connection', (ws) => {
const extWs = ws as ExtWebSocket;
extWs.isAlive = true;
extWs.send(JSON.stringify({ type: 'WELCOME' }));
extWs.on('error', (error) => console.error('WebSocket error:', error));
extWs.on('pong', heartbeat);
});
// Periodically ping clients to ensure they are still connected
const pingInterval = setInterval(() => {
wss.clients.forEach((client) => {
const extWs = client as ExtWebSocket;
if (!extWs.isAlive) {
console.log(chalk.yellow('Terminating inactive client'));
return client.terminate();
}
extWs.isAlive = false;
client.ping();
});
}, 30000);
// Clean up the interval when the server closes
wss.on('close', () => {
clearInterval(pingInterval);
});
console.log(chalk.green(`WebSocket server started on ws://localhost:${port}`));
// Return a function to broadcast a message to all connected clients
return (message: string) => {
console.log(chalk.green(`Broadcasting message to ${wss.clients.size} client(s)`));
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
};
};
/**
* Gets the working public directory path for a given handoff instance
* Checks for both project-specific and default public directories
*
* @param handoff - The handoff instance containing working path and figma project configuration
* @returns The resolved path to the public directory if it exists, null otherwise
*/
const getWorkingPublicPath = (handoff: Handoff): string | null => {
const paths = [
path.resolve(handoff.workingPath, `public-${handoff.config.figma_project_id}`),
path.resolve(handoff.workingPath, `public`),
];
for (const path of paths) {
if (fs.existsSync(path)) {
return path;
}
}
return null;
};
/**
* Gets the application path for a given handoff instance
* @param handoff - The handoff instance containing module path and figma project configuration
* @returns The resolved path to the application directory
*/
const getAppPath = (handoff: Handoff): string => {
return path.resolve(handoff.modulePath, '.handoff', `${handoff.config.figma_project_id}`);
};
/**
* Copy the public dir from the working dir to the module dir
* @param handoff
*/
const mergePublicDir = async (handoff: Handoff): Promise<void> => {
const appPath = getAppPath(handoff);
const workingPublicPath = getWorkingPublicPath(handoff);
if (workingPublicPath) {
fs.copySync(workingPublicPath, path.resolve(appPath, 'public'), { overwrite: true });
}
};
/**
* Publish the mdx files from the working dir to the module dir
* @param handoff
*/
const publishMDX = async (handoff: Handoff): Promise<void> => {
console.log(chalk.yellow('Merging MDX files...'));
const appPath = getAppPath(handoff);
const pages = path.resolve(handoff.workingPath, `pages`);
if (fs.existsSync(pages)) {
// Find all mdx files in path
const files = fs.readdirSync(pages);
for (const file of files) {
if (file.endsWith('.mdx')) {
// transform the file
transformMdx(path.resolve(pages, file), path.resolve(appPath, 'pages', file), file.replace('.mdx', ''));
} else if (fs.lstatSync(path.resolve(pages, file)).isDirectory()) {
// Recursion - find all mdx files in sub directories
const subFiles = fs.readdirSync(path.resolve(pages, file));
for (const subFile of subFiles) {
if (subFile.endsWith('.mdx')) {
// transform the file
const target = path.resolve(appPath, 'pages', file);
if (!fs.existsSync(target)) {
fs.mkdirSync(target, { recursive: true });
}
transformMdx(path.resolve(pages, file, subFile), path.resolve(appPath, 'pages', file, subFile), file);
} else if (fs.lstatSync(path.resolve(pages, file, subFile)).isDirectory()) {
const thirdFiles = fs.readdirSync(path.resolve(pages, file, subFile));
for (const thirdFile of thirdFiles) {
if (thirdFile.endsWith('.mdx')) {
const target = path.resolve(appPath, 'pages', file, subFile);
if (!fs.existsSync(target)) {
fs.mkdirSync(target, { recursive: true });
}
transformMdx(path.resolve(pages, file, subFile, thirdFile), path.resolve(appPath, 'pages', file, subFile, thirdFile), file);
}
}
}
}
}
}
}
};
/**
* Remove the frontmatter from the mdx file, convert it to an import, and
* add the metadata to the export. Then write the file to the destination.
* @param src
* @param dest
* @param id
*/
const transformMdx = (src: string, dest: string, id: string) => {
const content = fs.readFileSync(src);
const { data, content: body } = matter(content);
const title = data.title ?? '';
const description = data.description ? data.description.replace(/(\r\n|\n|\r)/gm, '') : '';
const metaDescription = data.metaDescription ?? '';
const metaTitle = data.metaTitle ?? '';
const weight = data.weight ?? 0;
const image = data.image ?? '';
const menuTitle = data.menuTitle ?? '';
const enabled = data.enabled ?? true;
const wide = data.wide ? 'true' : 'false';
const mdxHeader = `// This file is auto-generated by transformMdx(). Do not edit manually.
// Source: ${src}
// Generated at: ${new Date().toISOString()}
`;
const mdx = `${mdxHeader}import { getClientRuntimeConfig, getCurrentSection, staticBuildMenu } from '@handoff/app/components/util';
import fs from 'fs-extra';
import matter from 'gray-matter';
import { MDXRemote } from 'next-mdx-remote';
import { serialize } from 'next-mdx-remote/serialize';
import path from 'path';
export async function getStaticProps() {
const mdxFilePath = path.join(process.env.HANDOFF_WORKING_PATH, 'pages', '${id}.mdx');
const mdxSource = fs.readFileSync(mdxFilePath, 'utf8');
const { data, content: body } = matter(mdxSource); // extract frontmatter and body
const mdx = await serialize(body); // serialize only the body
const menu = staticBuildMenu();
const config = getClientRuntimeConfig();
return {
props: {
mdx,
menu,
config,
current: getCurrentSection(menu, "/${id}") ?? [],
title: "${title}",
description: "${description}",
image: "${image}",
},
};
}
import MarkdownLayout from "@handoff/app/components/Layout/Markdown";
import { Hero } from "@handoff/app/components/Hero";
const components = { Hero };
export default function Layout(props) {
return (
<MarkdownLayout
menu={props.menu}
metadata={{
description: "${description}",
metaDescription: "${metaDescription}",
metaTitle: "${metaTitle}",
title: "${title}",
}}
wide={${wide}}
config={props.config}
current={props.current}
>
<MDXRemote {...props.mdx} components={components} />
</MarkdownLayout>
);
}`;
fs.writeFileSync(dest.replaceAll('.mdx', '.tsx'), mdx, 'utf-8');
};
/**
* Performs cleanup of the application directory by removing the existing app directory if it exists.
* This is typically used before rebuilding the application to ensure a clean state.
*
* @param handoff - The Handoff instance containing configuration and working paths
* @returns Promise that resolves when cleanup is complete
*/
const performCleanup = async (handoff: Handoff): Promise<void> => {
const appPath = getAppPath(handoff);
// Clean project app dir
if (fs.existsSync(appPath)) {
await fs.rm(appPath, { recursive: true });
}
};
const publishTokensApi = async (handoff: Handoff) => {
const apiPath = path.resolve(path.join(handoff.workingPath, 'public/api'));
if (!fs.existsSync(apiPath)) {
fs.mkdirSync(apiPath, { recursive: true });
}
const tokens = await handoff.getDocumentationObject();
fs.writeFileSync(path.join(apiPath, 'tokens.json'), JSON.stringify(tokens, null, 2));
if (!fs.existsSync(path.join(apiPath, 'tokens'))) {
fs.mkdirSync(path.join(apiPath, 'tokens'));
}
for (const type in tokens) {
if (type === 'timestamp') continue;
for (const group in tokens[type]) {
fs.writeFileSync(path.join(apiPath, 'tokens', `${group}.json`), JSON.stringify(tokens[type][group], null, 2));
}
}
};
const prepareProjectApp = async (handoff: Handoff): Promise<string> => {
const srcPath = path.resolve(handoff.modulePath, 'src', 'app');
const appPath = getAppPath(handoff);
// Publish tokens API
publishTokensApi(handoff);
// Prepare project app dir
await fs.promises.mkdir(appPath, { recursive: true });
await fs.copy(srcPath, appPath, { overwrite: true });
await mergePublicDir(handoff);
await publishMDX(handoff);
// Prepare project app configuration
const handoffProjectId = handoff.config.figma_project_id ?? '';
const handoffAppBasePath = handoff.config.app.base_path ?? '';
const handoffWorkingPath = path.resolve(handoff.workingPath);
const handoffModulePath = path.resolve(handoff.modulePath);
const handoffExportPath = path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.config.figma_project_id);
const nextConfigPath = path.resolve(appPath, 'next.config.mjs');
const handoffUseReferences = handoff.config.useVariables ?? false;
const handoffWebsocketPort = handoff.config.app.ports?.websocket ?? 3001;
const nextConfigContent = (await fs.readFile(nextConfigPath, 'utf-8'))
.replace(/basePath:\s+\'\'/g, `basePath: '${handoffAppBasePath}'`)
.replace(/HANDOFF_PROJECT_ID:\s+\'\'/g, `HANDOFF_PROJECT_ID: '${handoffProjectId}'`)
.replace(/HANDOFF_APP_BASE_PATH:\s+\'\'/g, `HANDOFF_APP_BASE_PATH: '${handoffAppBasePath}'`)
.replace(/HANDOFF_WORKING_PATH:\s+\'\'/g, `HANDOFF_WORKING_PATH: '${handoffWorkingPath}'`)
.replace(/HANDOFF_MODULE_PATH:\s+\'\'/g, `HANDOFF_MODULE_PATH: '${handoffModulePath}'`)
.replace(/HANDOFF_EXPORT_PATH:\s+\'\'/g, `HANDOFF_EXPORT_PATH: '${handoffExportPath}'`)
.replace(/HANDOFF_WEBSOCKET_PORT:\s+\'\'/g, `HANDOFF_WEBSOCKET_PORT: '${handoffWebsocketPort}'`)
.replace(/%HANDOFF_MODULE_PATH%/g, handoffModulePath);
await fs.writeFile(nextConfigPath, nextConfigContent);
return appPath;
};
const persistRuntimeCache = (handoff: Handoff) => {
const destination = path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.config.figma_project_id, 'runtime.cache.json');
fs.writeFileSync(destination, JSON.stringify({ config: getClientConfig(handoff), ...handoff.integrationObject }, null, 2), 'utf-8');
};
/**
* Build the next js application
* @param handoff
* @returns
*/
const buildApp = async (handoff: Handoff): Promise<void> => {
if (!fs.existsSync(path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.config.figma_project_id, 'tokens.json'))) {
throw new Error('Tokens not exported. Run `handoff-app fetch` first.');
}
// Perform cleanup
await performCleanup(handoff);
// Build components
await buildComponents(handoff);
// Prepare app
const appPath = await prepareProjectApp(handoff);
persistRuntimeCache(handoff);
// Build app
const buildResult = spawn.sync('npx', ['next', 'build'], {
cwd: appPath,
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'production',
},
});
if (buildResult.status !== 0) {
let errorMsg = `Next.js build failed with exit code ${buildResult.status}`;
if (buildResult.error) {
errorMsg += `\nSpawn error: ${buildResult.error.message}`;
}
throw new Error(errorMsg);
}
// Ensure output root directory exists
const outputRoot = path.resolve(handoff.workingPath, handoff.sitesDirectory);
if (!fs.existsSync(outputRoot)) {
fs.mkdirSync(outputRoot, { recursive: true });
}
// Clean the project output directory (if exists)
const output = path.resolve(outputRoot, handoff.config.figma_project_id);
if (fs.existsSync(output)) {
fs.removeSync(output);
}
// Copy the build files into the project output directory
fs.copySync(path.resolve(appPath, 'out'), output);
};
/**
* Watch the next js application
* @param handoff
*/
export const watchApp = async (handoff: Handoff): Promise<void> => {
const tokensJsonFilePath = handoff.getTokensFilePath();
if (!fs.existsSync(tokensJsonFilePath)) {
throw new Error('Tokens not exported. Run `handoff-app fetch` first.');
}
// Initial processing of the components
await processComponents(handoff);
const appPath = await prepareProjectApp(handoff);
// Include any changes made within the app source during watch
chokidar
.watch(path.resolve(handoff.modulePath, 'src', 'app'), {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
ignoreInitial: true,
})
.on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
await prepareProjectApp(handoff);
break;
}
});
// // does a ts config exist?
// let tsconfigPath = 'tsconfig.json';
// config.typescript = {
// ...config.typescript,
// tsconfigPath,
// };
const dev = true;
const hostname = 'localhost';
const port = handoff.config.app.ports?.app ?? 3000;
// when using middleware `hostname` and `port` must be provided below
const app = next({
dev,
dir: appPath,
hostname,
port,
// conf: config,
});
const handle = app.getRequestHandler();
// purge out cache
const moduleOutput = path.resolve(appPath, 'out');
if (fs.existsSync(moduleOutput)) {
fs.removeSync(moduleOutput);
}
app.prepare().then(() => {
createServer(async (req, res) => {
try {
// Be sure to pass `true` as the second argument to `url.parse`.
// This tells it to parse the query portion of the URL.
if (!req.url) throw new Error('No url');
const parsedUrl = parse(req.url, true);
const { pathname, query } = parsedUrl;
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
res.statusCode = 500;
res.end('internal server error');
}
})
.once('error', (err: string) => {
console.error(err);
process.exit(1);
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
const wss = await createWebSocketServer(handoff.config.app.ports?.websocket ?? 3001);
const chokidarConfig = {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
ignoreInitial: true,
};
let debounce = false;
if (fs.existsSync(path.resolve(handoff.workingPath, 'exportables'))) {
chokidar.watch(path.resolve(handoff.workingPath, 'exportables'), chokidarConfig).on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (path.includes('json') && !debounce) {
console.log(chalk.yellow('Exportables changed. Handoff will fetch new tokens...'));
debounce = true;
await handoff.fetch();
debounce = false;
}
break;
}
});
}
if (fs.existsSync(path.resolve(handoff.workingPath, 'public'))) {
chokidar.watch(path.resolve(handoff.workingPath, 'public'), chokidarConfig).on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!debounce) {
debounce = true;
console.log(chalk.yellow('Public directory changed. Handoff will ingest the new data...'));
await mergePublicDir(handoff);
wss(JSON.stringify({ type: 'reload' }));
debounce = false;
}
break;
}
});
}
let runtimeComponentsWatcher: chokidar.FSWatcher | null = null;
let runtimeConfigurationWatcher: chokidar.FSWatcher | null = null;
const entryTypeToSegment = (type: keyof ComponentListObject['entries']): ComponentSegment | undefined => {
return {
js: ComponentSegment.JavaScript,
scss: ComponentSegment.Style,
templates: ComponentSegment.Previews,
}[type];
};
const watchRuntimeComponents = (runtimeComponentPathsToWatch: Map<string, keyof ComponentListObject['entries']>) => {
persistRuntimeCache(handoff);
if (runtimeComponentsWatcher) {
runtimeComponentsWatcher.close();
}
if (runtimeComponentPathsToWatch.size > 0) {
const pathsToWatch = Array.from(runtimeComponentPathsToWatch.keys());
runtimeComponentsWatcher = chokidar.watch(pathsToWatch, { ignoreInitial: true });
runtimeComponentsWatcher.on('all', async (event, file) => {
if (handoff.getConfigFilePaths().includes(file)) {
return;
}
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!debounce) {
debounce = true;
let segmentToUpdate: ComponentSegment = undefined;
const matchingPath = runtimeComponentPathsToWatch.get(file);
if (matchingPath) {
const entryType = runtimeComponentPathsToWatch.get(matchingPath);
segmentToUpdate = entryTypeToSegment(entryType);
}
const componentDir = path.basename(path.dirname(path.dirname(file)));
await processComponents(handoff, componentDir, segmentToUpdate);
debounce = false;
}
break;
}
});
}
};
const watchRuntimeConfiguration = () => {
if (runtimeConfigurationWatcher) {
runtimeConfigurationWatcher.close();
}
if (handoff.getConfigFilePaths().length > 0) {
runtimeConfigurationWatcher = chokidar.watch(handoff.getConfigFilePaths(), { ignoreInitial: true });
runtimeConfigurationWatcher.on('all', async (event, file) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!debounce) {
debounce = true;
file = path.dirname(path.dirname(file));
handoff.reload();
watchRuntimeComponents(getRuntimeComponentsPathsToWatch());
await processComponents(handoff, path.basename(file));
debounce = false;
}
break;
}
});
}
};
const getRuntimeComponentsPathsToWatch = () => {
const result: Map<string, keyof ComponentListObject['entries']> = new Map();
for (const runtimeComponentId of Object.keys(handoff.integrationObject?.entries.components ?? {})) {
for (const runtimeComponentVersion of Object.keys(handoff.integrationObject.entries.components[runtimeComponentId])) {
const runtimeComponent = handoff.integrationObject.entries.components[runtimeComponentId][runtimeComponentVersion];
for (const [runtimeComponentEntryType, runtimeComponentEntryPath] of Object.entries(runtimeComponent.entries ?? {})) {
const normalizedComponentEntryPath = runtimeComponentEntryPath as string;
if (fs.existsSync(normalizedComponentEntryPath)) {
const entryType = runtimeComponentEntryType as keyof ComponentListObject['entries'];
if (fs.statSync(normalizedComponentEntryPath).isFile()) {
result.set(path.dirname(normalizedComponentEntryPath), entryType);
} else {
result.set(normalizedComponentEntryPath, entryType);
}
}
}
}
}
return result;
};
/*
if (fs.existsSync(path.resolve(handoff.workingPath, 'handoff.config.json'))) {
chokidar.watch(path.resolve(handoff.workingPath, 'handoff.config.json'), { ignoreInitial: true }).on('all', async (event, file) => {
console.log(chalk.yellow('handoff.config.json changed. Please restart server to see changes...'));
if (!debounce) {
debounce = true;
handoff.reload();
watchRuntimeComponents(getRuntimeComponentsPathsToWatch());
watchRuntimeConfiguration();
await processComponents(handoff, undefined, sharedStyles, documentationObject.components);
debounce = false;
}
});
}
*/
watchRuntimeComponents(getRuntimeComponentsPathsToWatch());
watchRuntimeConfiguration();
if (handoff.integrationObject?.entries?.integration && fs.existsSync(handoff.integrationObject?.entries?.integration)) {
const stat = await fs.stat(handoff.integrationObject.entries.integration);
chokidar
.watch(
stat.isDirectory() ? handoff.integrationObject.entries.integration : path.dirname(handoff.integrationObject.entries.integration),
chokidarConfig
)
.on('all', async (event, file) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (!debounce) {
debounce = true;
await handoff.getSharedStyles();
debounce = false;
}
}
});
}
if (fs.existsSync(path.resolve(handoff.workingPath, 'pages'))) {
chokidar.watch(path.resolve(handoff.workingPath, 'pages'), chokidarConfig).on('all', async (event, path) => {
switch (event) {
case 'add':
case 'change':
case 'unlink':
if (path.endsWith('.mdx')) {
publishMDX(handoff);
}
console.log(chalk.yellow(`Doc page ${event}ed. Please reload browser to see changes...`), path);
break;
}
});
}
};
/**
* Watch the next js application
* @param handoff
*/
export const devApp = async (handoff: Handoff): Promise<void> => {
if (!fs.existsSync(path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.config.figma_project_id, 'tokens.json'))) {
throw new Error('Tokens not exported. Run `handoff-app fetch` first.');
}
// Prepare app
const appPath = await prepareProjectApp(handoff);
// Purge app cache
const moduleOutput = path.resolve(appPath, 'out');
if (fs.existsSync(moduleOutput)) {
fs.removeSync(moduleOutput);
}
persistRuntimeCache(handoff);
// Run
const devResult = spawn.sync('npx', ['next', 'dev', '--port', String(handoff.config.app.ports?.app ?? 3000)], {
cwd: appPath,
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'development',
},
});
if (devResult.status !== 0) {
let errorMsg = `Next.js dev failed with exit code ${devResult.status}`;
if (devResult.error) {
errorMsg += `\nSpawn error: ${devResult.error.message}`;
}
throw new Error(errorMsg);
}
};
export default buildApp;