handoff-app
Version:
Automated documentation toolchain for building client side documentation from figma
386 lines (334 loc) • 12.9 kB
text/typescript
import esbuild from 'esbuild';
import fs from 'fs-extra';
import Handlebars from 'handlebars';
import { Types as CoreTypes } from 'handoff-core';
import { parse } from 'node-html-parser';
import path from 'path';
import prettier from 'prettier';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { Plugin, normalizePath } from 'vite';
import Handoff from '..';
import { SlotMetadata } from './preview/component';
import { OptionalPreviewRender, TransformComponentTokensResult } from './preview/types';
const ensureIds = (properties: { [key: string]: SlotMetadata }) => {
for (const key in properties) {
properties[key].id = key;
if (properties[key].items?.properties) {
ensureIds(properties[key].items.properties);
}
if (properties[key].properties) {
ensureIds(properties[key].properties);
}
}
return properties;
};
const trimPreview = (preview: string) => {
const bodyEl = parse(preview).querySelector('body');
const code = bodyEl ? bodyEl.innerHTML.trim() : preview;
return code;
};
export function handlebarsPreviewsPlugin(
data: TransformComponentTokensResult,
components: CoreTypes.IDocumentationObject['components'],
handoff: Handoff
): Plugin {
return {
name: 'vite-plugin-previews',
apply: 'build',
resolveId(id) {
if (id === 'script') {
return id;
}
},
load(id) {
if (id === 'script') {
return 'export default {}'; // dummy minimal entry
}
},
async generateBundle() {
const id = data.id;
const templatePath = path.resolve(data.entries.template);
const template = await fs.readFile(templatePath, 'utf8');
let injectFieldWrappers = false;
// Common Handlebars helpers
Handlebars.registerHelper('field', function (field, options) {
if (injectFieldWrappers) {
if (!field) {
console.error(`Missing field declaration for ${id}`);
return options.fn(this);
}
let parts = field.split('.');
let current: any = data.properties;
for (const part of parts) {
if (current?.type === 'object') current = current.properties;
else if (current?.type === 'array') current = current.items.properties;
current = current?.[part];
}
if (!current) {
console.error(`Undefined field path for ${id}`);
return options.fn(this);
}
return new Handlebars.SafeString(
`<span class="handoff-field handoff-field-${current?.type || 'unknown'}" data-handoff-field="${field}" data-handoff="${encodeURIComponent(JSON.stringify(current))}">${options.fn(this)}</span>`
);
} else {
return options.fn(this);
}
});
Handlebars.registerHelper('eq', function (a, b) {
return a === b;
});
if (!components) components = {};
const previews = {};
const renderTemplate = async (previewData: OptionalPreviewRender, inspect: boolean) => {
injectFieldWrappers = inspect;
const compiled = Handlebars.compile(template)({
style: `<link rel="stylesheet" href="/api/component/shared.css"><link rel="stylesheet" href="/api/component/${id}.css">\n<link rel="stylesheet" href="/assets/css/preview.css">`,
script: `<script src="/api/component/${id}.js"></script>\n<script src="/assets/js/preview.js"></script><script>var fields = ${JSON.stringify(data.properties)};</script>`,
properties: previewData.values || {},
fields: ensureIds(data.properties),
title: data.title,
});
return await prettier.format(`<html lang="en">${compiled}</html>`, { parser: 'html' });
};
if (components[data.id]) {
for (const instance of components[data.id].instances) {
const variationId = instance.id;
const values = Object.fromEntries(instance.variantProperties);
data.previews[variationId] = {
title: variationId,
url: '',
values,
};
}
}
for (const key in data.previews) {
const htmlNormal = await renderTemplate(data.previews[key], false);
const htmlInspect = await renderTemplate(data.previews[key], true);
this.emitFile({
type: 'asset',
fileName: `${id}-${key}.html`,
source: htmlNormal,
});
this.emitFile({
type: 'asset',
fileName: `${id}-${key}-inspect.html`,
source: htmlInspect,
});
previews[key] = htmlNormal;
data.previews[key].url = `${id}-${key}.html`;
}
data.format = 'html';
data.preview = '';
data.code = trimPreview(template);
data.html = trimPreview(previews[Object.keys(previews)[0]]);
},
};
}
async function buildAndEvaluateModule(entryPath: string, handoff?: Handoff): Promise<{ exports: any }> {
// Default esbuild configuration
const defaultBuildConfig: esbuild.BuildOptions = {
entryPoints: [entryPath],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
jsx: 'automatic',
external: ['react', 'react-dom', '@opentelemetry/api'],
};
// Apply user's SSR build config hook if provided
const buildConfig = handoff?.config?.hooks?.ssrBuildConfig ? handoff.config.hooks.ssrBuildConfig(defaultBuildConfig) : defaultBuildConfig;
// Compile the module
const build = await esbuild.build(buildConfig);
const { text: code } = build.outputFiles[0];
// Evaluate the compiled code
const mod: any = { exports: {} };
const func = new Function('require', 'module', 'exports', code);
func(require, mod, mod.exports);
return mod;
}
export function ssrRenderPlugin(
data: TransformComponentTokensResult,
components: CoreTypes.IDocumentationObject['components'],
handoff?: Handoff
): Plugin {
return {
name: 'vite-plugin-ssr-static-render',
apply: 'build',
resolveId(id) {
console.log('resolveId', id);
if (id === 'script') {
return id;
}
},
load(id) {
if (id === 'script') {
return 'export default {}'; // dummy minimal entry
}
},
async generateBundle(_, bundle) {
// Delete all JS chunks
for (const [fileName, chunkInfo] of Object.entries(bundle)) {
if (chunkInfo.type === 'chunk' && fileName.includes('script')) {
delete bundle[fileName];
}
}
const id = data.id;
const entry = path.resolve(data.entries.template);
const code = fs.readFileSync(entry, 'utf8');
// Load schema if schema entry exists
if (data.entries?.schema) {
const schemaPath = path.resolve(data.entries.schema);
const ext = path.extname(schemaPath);
if (ext === '.ts' || ext === '.tsx') {
// Build and evaluate schema module
const schemaMod = await buildAndEvaluateModule(schemaPath, handoff);
// Get schema from exports using hook or default to exports.default
const schema = handoff?.config?.hooks?.getSchemaFromExports
? handoff.config.hooks.getSchemaFromExports(schemaMod.exports)
: schemaMod.exports.default;
// Apply schema to properties if schema exists and is valid
if (schema?.type === 'object') {
if (handoff?.config?.hooks?.schemaToProperties) {
data.properties = handoff.config.hooks.schemaToProperties(schema);
}
}
}
}
// Build and evaluate component module
const mod = await buildAndEvaluateModule(entry, handoff);
const Component = mod.exports.default;
// Look for exported schema in component file only if no separate schema file was provided
if (!data.entries?.schema) {
// Get schema from exports using hook or default to exports.schema
const schema = handoff?.config?.hooks?.getSchemaFromExports
? handoff.config.hooks.getSchemaFromExports(mod.exports)
: mod.exports.schema;
if (schema?.type === 'object') {
if (handoff?.config?.hooks?.schemaToProperties) {
data.properties = handoff.config.hooks.schemaToProperties(schema);
}
}
}
if (!components) components = {};
const previews = {};
if (components[data.id]) {
for (const instance of components[data.id].instances) {
const variationId = instance.id;
const values = Object.fromEntries(instance.variantProperties);
data.previews[variationId] = {
title: variationId,
url: '',
values,
};
}
}
let html = '';
for (const key in data.previews) {
const props = data.previews[key].values;
const renderedHtml = ReactDOMServer.renderToString(
React.createElement(Component, { ...data.previews[key].values, block: { ...data.previews[key].values } })
);
const pretty = await prettier.format(renderedHtml, { parser: 'html' });
// 3. Hydration source: baked-in, references user entry
const clientSource = `
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import Component from '${normalizePath(entry)}';
const raw = document.getElementById('__APP_PROPS__')?.textContent || '{}';
const props = JSON.parse(raw);
hydrateRoot(document.getElementById('root'), <Component {...props} block={props} />);
`;
// Default client-side build configuration
const defaultClientBuildConfig: esbuild.BuildOptions = {
stdin: {
contents: clientSource,
resolveDir: process.cwd(),
loader: 'tsx',
},
bundle: true,
write: false,
format: 'esm',
platform: 'browser',
jsx: 'automatic',
sourcemap: false,
minify: false,
plugins: [handoffResolveReactEsbuildPlugin(handoff.workingPath, handoff.modulePath)],
};
// Apply user's client build config hook if provided
const clientBuildConfig = handoff?.config?.hooks?.clientBuildConfig
? handoff.config.hooks.clientBuildConfig(defaultClientBuildConfig)
: defaultClientBuildConfig;
const bundledClient = await esbuild.build(clientBuildConfig);
const inlinedJs = bundledClient.outputFiles[0].text;
// 4. Emit fully inlined HTML
html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/api/component/main.css" />
<link rel="stylesheet" href="/api/component/${id}.css" />
<link rel="stylesheet" href="/assets/css/preview.css" />
<script id="__APP_PROPS__" type="application/json">${JSON.stringify(props)}</script>
<script type="module">
${inlinedJs}
</script>
<title>${data.previews[key].title}</title>
</head>
<body>
<div id="root">${pretty}</div>
</body>
</html>`;
this.emitFile({
type: 'asset',
fileName: `${id}-${key}.html`,
source: html,
});
// TODO: remove this once we have a way to render inspect mode
this.emitFile({
type: 'asset',
fileName: `${id}-${key}-inspect.html`,
source: html,
});
previews[key] = html;
data.previews[key].url = `${id}-${key}.html`;
}
html = await prettier.format(html, { parser: 'html' });
data.format = 'react';
data.preview = '';
data.code = trimPreview(code);
data.html = trimPreview(html);
},
};
}
function resolveModule(id: string, searchDirs: string[]): string {
for (const dir of searchDirs) {
try {
const resolved = require.resolve(id, {
paths: [path.resolve(dir)],
});
return resolved;
} catch (_) {
// skip
}
}
throw new Error(`Module "${id}" not found in:\n${searchDirs.join('\n')}`);
}
function handoffResolveReactEsbuildPlugin(workingPath: string, handoffModulePath: string) {
const searchDirs = [workingPath, path.join(handoffModulePath, 'node_modules')];
return {
name: 'handoff-resolve-react',
setup(build: any) {
build.onResolve({ filter: /^react$/ }, () => ({
path: resolveModule('react', searchDirs),
}));
build.onResolve({ filter: /^react-dom\/client$/ }, () => ({
path: resolveModule('react-dom/client', searchDirs),
}));
build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({
path: resolveModule('react/jsx-runtime', searchDirs),
}));
},
};
}