UNPKG

handoff-app

Version:

Automated documentation toolchain for building client side documentation from figma

351 lines (349 loc) 18.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.handlebarsPreviewsPlugin = handlebarsPreviewsPlugin; exports.ssrRenderPlugin = ssrRenderPlugin; const esbuild_1 = __importDefault(require("esbuild")); const fs_extra_1 = __importDefault(require("fs-extra")); const handlebars_1 = __importDefault(require("handlebars")); const node_html_parser_1 = require("node-html-parser"); const path_1 = __importDefault(require("path")); const prettier_1 = __importDefault(require("prettier")); const react_1 = __importDefault(require("react")); const server_1 = __importDefault(require("react-dom/server")); const vite_1 = require("vite"); const ensureIds = (properties) => { var _a; for (const key in properties) { properties[key].id = key; if ((_a = properties[key].items) === null || _a === void 0 ? void 0 : _a.properties) { ensureIds(properties[key].items.properties); } if (properties[key].properties) { ensureIds(properties[key].properties); } } return properties; }; const trimPreview = (preview) => { const bodyEl = (0, node_html_parser_1.parse)(preview).querySelector('body'); const code = bodyEl ? bodyEl.innerHTML.trim() : preview; return code; }; function handlebarsPreviewsPlugin(data, components, handoff) { 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 } }, generateBundle() { return __awaiter(this, void 0, void 0, function* () { const id = data.id; const templatePath = path_1.default.resolve(data.entries.template); const template = yield fs_extra_1.default.readFile(templatePath, 'utf8'); let injectFieldWrappers = false; // Common Handlebars helpers handlebars_1.default.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 = data.properties; for (const part of parts) { if ((current === null || current === void 0 ? void 0 : current.type) === 'object') current = current.properties; else if ((current === null || current === void 0 ? void 0 : current.type) === 'array') current = current.items.properties; current = current === null || current === void 0 ? void 0 : current[part]; } if (!current) { console.error(`Undefined field path for ${id}`); return options.fn(this); } return new handlebars_1.default.SafeString(`<span class="handoff-field handoff-field-${(current === null || current === void 0 ? void 0 : current.type) || 'unknown'}" data-handoff-field="${field}" data-handoff="${encodeURIComponent(JSON.stringify(current))}">${options.fn(this)}</span>`); } else { return options.fn(this); } }); handlebars_1.default.registerHelper('eq', function (a, b) { return a === b; }); if (!components) components = {}; const previews = {}; const renderTemplate = (previewData, inspect) => __awaiter(this, void 0, void 0, function* () { injectFieldWrappers = inspect; const compiled = handlebars_1.default.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 yield prettier_1.default.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 = yield renderTemplate(data.previews[key], false); const htmlInspect = yield 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]]); }); }, }; } function buildAndEvaluateModule(entryPath, handoff) { return __awaiter(this, void 0, void 0, function* () { var _a, _b; // Default esbuild configuration const defaultBuildConfig = { 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 = ((_b = (_a = handoff === null || handoff === void 0 ? void 0 : handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.ssrBuildConfig) ? handoff.config.hooks.ssrBuildConfig(defaultBuildConfig) : defaultBuildConfig; // Compile the module const build = yield esbuild_1.default.build(buildConfig); const { text: code } = build.outputFiles[0]; // Evaluate the compiled code const mod = { exports: {} }; const func = new Function('require', 'module', 'exports', code); func(require, mod, mod.exports); return mod; }); } function ssrRenderPlugin(data, components, handoff) { 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 } }, generateBundle(_, bundle) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; // 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_1.default.resolve(data.entries.template); const code = fs_extra_1.default.readFileSync(entry, 'utf8'); // Load schema if schema entry exists if ((_a = data.entries) === null || _a === void 0 ? void 0 : _a.schema) { const schemaPath = path_1.default.resolve(data.entries.schema); const ext = path_1.default.extname(schemaPath); if (ext === '.ts' || ext === '.tsx') { // Build and evaluate schema module const schemaMod = yield buildAndEvaluateModule(schemaPath, handoff); // Get schema from exports using hook or default to exports.default const schema = ((_c = (_b = handoff === null || handoff === void 0 ? void 0 : handoff.config) === null || _b === void 0 ? void 0 : _b.hooks) === null || _c === void 0 ? void 0 : _c.getSchemaFromExports) ? handoff.config.hooks.getSchemaFromExports(schemaMod.exports) : schemaMod.exports.default; // Apply schema to properties if schema exists and is valid if ((schema === null || schema === void 0 ? void 0 : schema.type) === 'object') { if ((_e = (_d = handoff === null || handoff === void 0 ? void 0 : handoff.config) === null || _d === void 0 ? void 0 : _d.hooks) === null || _e === void 0 ? void 0 : _e.schemaToProperties) { data.properties = handoff.config.hooks.schemaToProperties(schema); } } } } // Build and evaluate component module const mod = yield buildAndEvaluateModule(entry, handoff); const Component = mod.exports.default; // Look for exported schema in component file only if no separate schema file was provided if (!((_f = data.entries) === null || _f === void 0 ? void 0 : _f.schema)) { // Get schema from exports using hook or default to exports.schema const schema = ((_h = (_g = handoff === null || handoff === void 0 ? void 0 : handoff.config) === null || _g === void 0 ? void 0 : _g.hooks) === null || _h === void 0 ? void 0 : _h.getSchemaFromExports) ? handoff.config.hooks.getSchemaFromExports(mod.exports) : mod.exports.schema; if ((schema === null || schema === void 0 ? void 0 : schema.type) === 'object') { if ((_k = (_j = handoff === null || handoff === void 0 ? void 0 : handoff.config) === null || _j === void 0 ? void 0 : _j.hooks) === null || _k === void 0 ? void 0 : _k.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 = server_1.default.renderToString(react_1.default.createElement(Component, Object.assign(Object.assign({}, data.previews[key].values), { block: Object.assign({}, data.previews[key].values) }))); const pretty = yield prettier_1.default.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 '${(0, vite_1.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 = { 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 = ((_m = (_l = handoff === null || handoff === void 0 ? void 0 : handoff.config) === null || _l === void 0 ? void 0 : _l.hooks) === null || _m === void 0 ? void 0 : _m.clientBuildConfig) ? handoff.config.hooks.clientBuildConfig(defaultClientBuildConfig) : defaultClientBuildConfig; const bundledClient = yield esbuild_1.default.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 = yield prettier_1.default.format(html, { parser: 'html' }); data.format = 'react'; data.preview = ''; data.code = trimPreview(code); data.html = trimPreview(html); }); }, }; } function resolveModule(id, searchDirs) { for (const dir of searchDirs) { try { const resolved = require.resolve(id, { paths: [path_1.default.resolve(dir)], }); return resolved; } catch (_) { // skip } } throw new Error(`Module "${id}" not found in:\n${searchDirs.join('\n')}`); } function handoffResolveReactEsbuildPlugin(workingPath, handoffModulePath) { const searchDirs = [workingPath, path_1.default.join(handoffModulePath, 'node_modules')]; return { name: 'handoff-resolve-react', setup(build) { 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), })); }, }; }