From 31ddfdf5f76b9b11fe339da53fc5a38530fd1977 Mon Sep 17 00:00:00 2001 From: Domagoj Gojak Date: Sat, 18 Oct 2025 16:47:09 +0200 Subject: [PATCH 1/2] Initial react-docgen-typescript support. --- dist/transformers/plugins.d.ts | 2 +- dist/transformers/plugins.js | 205 +++++++++++++++++--- dist/transformers/preview/component.d.ts | 5 +- dist/transformers/preview/component.js | 3 + package-lock.json | 10 + package.json | 1 + src/transformers/plugins.ts | 226 +++++++++++++++++++---- src/transformers/preview/component.ts | 3 + 8 files changed, 395 insertions(+), 60 deletions(-) diff --git a/dist/transformers/plugins.d.ts b/dist/transformers/plugins.d.ts index 8e8479a0..7567818f 100644 --- a/dist/transformers/plugins.d.ts +++ b/dist/transformers/plugins.d.ts @@ -3,4 +3,4 @@ import { Plugin } from 'vite'; import Handoff from '..'; import { TransformComponentTokensResult } from './preview/types'; export declare function handlebarsPreviewsPlugin(data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], handoff: Handoff): Plugin; -export declare function ssrRenderPlugin(data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], handoff?: Handoff): Plugin; +export declare function ssrRenderPlugin(data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], handoff: Handoff): Plugin; diff --git a/dist/transformers/plugins.js b/dist/transformers/plugins.js index f7186327..efae6414 100644 --- a/dist/transformers/plugins.js +++ b/dist/transformers/plugins.js @@ -21,8 +21,10 @@ 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 react_docgen_typescript_1 = require("react-docgen-typescript"); const server_1 = __importDefault(require("react-dom/server")); const vite_1 = require("vite"); +const component_1 = require("./preview/component"); const ensureIds = (properties) => { var _a; for (const key in properties) { @@ -36,6 +38,115 @@ const ensureIds = (properties) => { } return properties; }; +const convertDocgenToProperties = (docgenProps) => { + const properties = {}; + for (const prop of docgenProps) { + const { name, type, required, description, defaultValue } = prop; + // Convert react-docgen-typescript type to our SlotType enum + let propType = component_1.SlotType.TEXT; + if ((type === null || type === void 0 ? void 0 : type.name) === 'boolean') { + propType = component_1.SlotType.BOOLEAN; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'number') { + propType = component_1.SlotType.NUMBER; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'array') { + propType = component_1.SlotType.ARRAY; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'object') { + propType = component_1.SlotType.OBJECT; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'function') { + propType = component_1.SlotType.FUNCTION; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'enum') { + propType = component_1.SlotType.ENUM; + } + properties[name] = { + id: name, + name: name, + description: description || '', + generic: '', + type: propType, + default: (defaultValue === null || defaultValue === void 0 ? void 0 : defaultValue.value) || undefined, + rules: { + required: required || false, + }, + }; + } + return properties; +}; +/** + * Validates if a schema object is valid for property conversion + * @param schema - The schema object to validate + * @returns True if schema is valid, false otherwise + */ +const isValidSchemaObject = (schema) => { + return schema && + typeof schema === 'object' && + schema.type === 'object' && + schema.properties && + typeof schema.properties === 'object'; +}; +/** + * Safely loads schema from module exports + * @param moduleExports - The module exports object + * @param handoff - Handoff instance for configuration + * @param exportKey - The export key to look for ('default' or 'schema') + * @returns The schema object or null if not found/invalid + */ +const loadSchemaFromExports = (moduleExports, handoff, exportKey = 'default') => { + var _a, _b; + try { + const schema = ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.getSchemaFromExports) + ? handoff.config.hooks.getSchemaFromExports(moduleExports) + : moduleExports[exportKey]; + return schema; + } + catch (error) { + console.warn(`Failed to load schema from exports (${exportKey}):`, error); + return null; + } +}; +/** + * Generates component properties using react-docgen-typescript + * @param entry - Path to the component/schema file + * @param handoff - Handoff instance for configuration + * @returns Generated properties or null if failed + */ +const generatePropertiesFromDocgen = (entry, handoff) => __awaiter(void 0, void 0, void 0, function* () { + try { + // Use root project's tsconfig.json + const tsconfigPath = path_1.default.resolve(handoff.workingPath, 'tsconfig.json'); + // Check if tsconfig exists + if (!fs_extra_1.default.existsSync(tsconfigPath)) { + console.warn(`TypeScript config not found at ${tsconfigPath}, using default configuration`); + } + const parser = (0, react_docgen_typescript_1.withCustomConfig)(tsconfigPath, { + savePropValueAsString: true, + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => { + if (prop.parent) { + return !prop.parent.fileName.includes('node_modules'); + } + return true; + }, + }); + const docgenResults = parser.parse(entry); + if (docgenResults.length > 0) { + const componentDoc = docgenResults[0]; + if (componentDoc.props && Object.keys(componentDoc.props).length > 0) { + return convertDocgenToProperties(Object.values(componentDoc.props)); + } + } + return null; + } + catch (error) { + console.warn(`Failed to generate docs with react-docgen-typescript for ${entry}:`, error); + return null; + } +}); const trimPreview = (preview) => { const bodyEl = (0, node_html_parser_1.parse)(preview).querySelector('body'); const code = bodyEl ? bodyEl.innerHTML.trim() : preview; @@ -153,7 +264,9 @@ function buildAndEvaluateModule(entryPath, handoff) { 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; + const buildConfig = ((_b = (_a = 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]; @@ -181,7 +294,7 @@ function ssrRenderPlugin(data, components, handoff) { }, generateBundle(_, bundle) { return __awaiter(this, void 0, void 0, function* () { - var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; + var _a, _b, _c, _d, _e, _f, _g, _h; // Delete all JS chunks for (const [fileName, chunkInfo] of Object.entries(bundle)) { if (chunkInfo.type === 'chunk' && fileName.includes('script')) { @@ -191,40 +304,80 @@ function ssrRenderPlugin(data, components, handoff) { 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 + // Determine properties using a hierarchical approach + let properties = null; + let Component = null; + // Step 1: Handle separate schema file (if 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); + try { + const schemaMod = yield buildAndEvaluateModule(schemaPath, handoff); + // Get schema from exports.default (separate schema files export as default) + const schema = loadSchemaFromExports(schemaMod.exports, handoff, 'default'); + if (isValidSchemaObject(schema)) { + // Valid schema object - convert to properties + if ((_c = (_b = handoff.config) === null || _b === void 0 ? void 0 : _b.hooks) === null || _c === void 0 ? void 0 : _c.schemaToProperties) { + properties = handoff.config.hooks.schemaToProperties(schema); + } } + else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema file + properties = yield generatePropertiesFromDocgen(schemaPath, handoff); + } + } + catch (error) { + console.warn(`Failed to load separate schema file ${schemaPath}:`, error); } } + else { + console.warn(`Schema file has unsupported extension: ${ext}`); + } } - // 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); + // Step 2: Load component and handle component-embedded schema (only if no separate schema) + if (!((_d = data.entries) === null || _d === void 0 ? void 0 : _d.schema)) { + try { + const mod = yield buildAndEvaluateModule(entry, handoff); + Component = mod.exports.default; + // Check for exported schema in component file (exports.schema) + const schema = loadSchemaFromExports(mod.exports, handoff, 'schema'); + if (isValidSchemaObject(schema)) { + // Valid schema object - convert to properties + if ((_f = (_e = handoff.config) === null || _e === void 0 ? void 0 : _e.hooks) === null || _f === void 0 ? void 0 : _f.schemaToProperties) { + properties = handoff.config.hooks.schemaToProperties(schema); + } + } + else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema + properties = yield generatePropertiesFromDocgen(entry, handoff); } + else { + // No schema found - use react-docgen-typescript to analyze component props + properties = yield generatePropertiesFromDocgen(entry, handoff); + } + } + catch (error) { + console.warn(`Failed to load component file ${entry}:`, error); } } + // Step 3: Load component for rendering (if not already loaded) + if (!Component) { + try { + const mod = yield buildAndEvaluateModule(entry, handoff); + Component = mod.exports.default; + } + catch (error) { + console.error(`Failed to load component for rendering: ${entry}`, error); + return; + } + } + // Apply the determined properties + if (properties) { + data.properties = properties; + } if (!components) components = {}; const previews = {}; @@ -271,7 +424,7 @@ function ssrRenderPlugin(data, components, handoff) { 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) + const clientBuildConfig = ((_h = (_g = handoff.config) === null || _g === void 0 ? void 0 : _g.hooks) === null || _h === void 0 ? void 0 : _h.clientBuildConfig) ? handoff.config.hooks.clientBuildConfig(defaultClientBuildConfig) : defaultClientBuildConfig; const bundledClient = yield esbuild_1.default.build(clientBuildConfig); diff --git a/dist/transformers/preview/component.d.ts b/dist/transformers/preview/component.d.ts index d7d1a9b3..a3b85f59 100644 --- a/dist/transformers/preview/component.d.ts +++ b/dist/transformers/preview/component.d.ts @@ -15,7 +15,10 @@ export declare enum SlotType { ARRAY = "array", NUMBER = "number", BOOLEAN = "boolean", - OBJECT = "object" + OBJECT = "object", + FUNCTION = "function", + ENUM = "enum", + ANY = "any" } export interface SlotMetadata { id?: string; diff --git a/dist/transformers/preview/component.js b/dist/transformers/preview/component.js index 5fece291..eabe773a 100644 --- a/dist/transformers/preview/component.js +++ b/dist/transformers/preview/component.js @@ -55,6 +55,9 @@ var SlotType; SlotType["NUMBER"] = "number"; SlotType["BOOLEAN"] = "boolean"; SlotType["OBJECT"] = "object"; + SlotType["FUNCTION"] = "function"; + SlotType["ENUM"] = "enum"; + SlotType["ANY"] = "any"; })(SlotType || (exports.SlotType = SlotType = {})); const getComponentOutputPath = (handoff) => path_1.default.resolve((0, api_1.getAPIPath)(handoff), 'component'); exports.getComponentOutputPath = getComponentOutputPath; diff --git a/package-lock.json b/package-lock.json index 509ee443..e6aa4d9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.4.1", "react": "^19.1.0", + "react-docgen-typescript": "^2.4.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", "react-scroll": "^1.8.9", @@ -15700,6 +15701,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/package.json b/package.json index d8848647..4bcbff4a 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.4.1", "react": "^19.1.0", + "react-docgen-typescript": "^2.4.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", "react-scroll": "^1.8.9", diff --git a/src/transformers/plugins.ts b/src/transformers/plugins.ts index 7a3796a9..0d03920b 100644 --- a/src/transformers/plugins.ts +++ b/src/transformers/plugins.ts @@ -6,10 +6,11 @@ import { parse } from 'node-html-parser'; import path from 'path'; import prettier from 'prettier'; import React from 'react'; +import { withCustomConfig } from 'react-docgen-typescript'; import ReactDOMServer from 'react-dom/server'; import { Plugin, normalizePath } from 'vite'; import Handoff from '..'; -import { SlotMetadata } from './preview/component'; +import { SlotMetadata, SlotType } from './preview/component'; import { OptionalPreviewRender, TransformComponentTokensResult } from './preview/types'; const ensureIds = (properties: { [key: string]: SlotMetadata }) => { @@ -25,6 +26,128 @@ const ensureIds = (properties: { [key: string]: SlotMetadata }) => { return properties; }; +const convertDocgenToProperties = (docgenProps: any[]): { [key: string]: SlotMetadata } => { + const properties: { [key: string]: SlotMetadata } = {}; + + for (const prop of docgenProps) { + const { name, type, required, description, defaultValue } = prop; + + // Convert react-docgen-typescript type to our SlotType enum + let propType = SlotType.TEXT; + if (type?.name === 'boolean') { + propType = SlotType.BOOLEAN; + } else if (type?.name === 'number') { + propType = SlotType.NUMBER; + } else if (type?.name === 'array') { + propType = SlotType.ARRAY; + } else if (type?.name === 'object') { + propType = SlotType.OBJECT; + } else if (type?.name === 'function') { + propType = SlotType.FUNCTION; + } else if (type?.name === 'enum') { + propType = SlotType.ENUM; + } + + properties[name] = { + id: name, + name: name, + description: description || '', + generic: '', + type: propType, + default: defaultValue?.value || undefined, + rules: { + required: required || false, + }, + }; + } + + return properties; +}; + +/** + * Validates if a schema object is valid for property conversion + * @param schema - The schema object to validate + * @returns True if schema is valid, false otherwise + */ +const isValidSchemaObject = (schema: any): boolean => { + return schema && + typeof schema === 'object' && + schema.type === 'object' && + schema.properties && + typeof schema.properties === 'object'; +}; + +/** + * Safely loads schema from module exports + * @param moduleExports - The module exports object + * @param handoff - Handoff instance for configuration + * @param exportKey - The export key to look for ('default' or 'schema') + * @returns The schema object or null if not found/invalid + */ +const loadSchemaFromExports = ( + moduleExports: any, + handoff: Handoff, + exportKey: 'default' | 'schema' = 'default' +): any => { + try { + const schema = handoff.config?.hooks?.getSchemaFromExports + ? handoff.config.hooks.getSchemaFromExports(moduleExports) + : moduleExports[exportKey]; + + return schema; + } catch (error) { + console.warn(`Failed to load schema from exports (${exportKey}):`, error); + return null; + } +}; + +/** + * Generates component properties using react-docgen-typescript + * @param entry - Path to the component/schema file + * @param handoff - Handoff instance for configuration + * @returns Generated properties or null if failed + */ +const generatePropertiesFromDocgen = async ( + entry: string, + handoff: Handoff +): Promise<{ [key: string]: SlotMetadata } | null> => { + try { + // Use root project's tsconfig.json + const tsconfigPath = path.resolve(handoff.workingPath, 'tsconfig.json'); + + // Check if tsconfig exists + if (!fs.existsSync(tsconfigPath)) { + console.warn(`TypeScript config not found at ${tsconfigPath}, using default configuration`); + } + + const parser = withCustomConfig(tsconfigPath, { + savePropValueAsString: true, + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => { + if (prop.parent) { + return !prop.parent.fileName.includes('node_modules'); + } + return true; + }, + }); + + const docgenResults = parser.parse(entry); + + if (docgenResults.length > 0) { + const componentDoc = docgenResults[0]; + if (componentDoc.props && Object.keys(componentDoc.props).length > 0) { + return convertDocgenToProperties(Object.values(componentDoc.props)); + } + } + + return null; + } catch (error) { + console.warn(`Failed to generate docs with react-docgen-typescript for ${entry}:`, error); + return null; + } +}; + const trimPreview = (preview: string) => { const bodyEl = parse(preview).querySelector('body'); const code = bodyEl ? bodyEl.innerHTML.trim() : preview; @@ -144,7 +267,7 @@ export function handlebarsPreviewsPlugin( }; } -async function buildAndEvaluateModule(entryPath: string, handoff?: Handoff): Promise<{ exports: any }> { +async function buildAndEvaluateModule(entryPath: string, handoff: Handoff): Promise<{ exports: any }> { // Default esbuild configuration const defaultBuildConfig: esbuild.BuildOptions = { entryPoints: [entryPath], @@ -157,7 +280,9 @@ async function buildAndEvaluateModule(entryPath: string, handoff?: Handoff): Pro }; // Apply user's SSR build config hook if provided - const buildConfig = handoff?.config?.hooks?.ssrBuildConfig ? handoff.config.hooks.ssrBuildConfig(defaultBuildConfig) : defaultBuildConfig; + const buildConfig = handoff.config?.hooks?.ssrBuildConfig + ? handoff.config.hooks.ssrBuildConfig(defaultBuildConfig) + : defaultBuildConfig; // Compile the module const build = await esbuild.build(buildConfig); @@ -174,7 +299,7 @@ async function buildAndEvaluateModule(entryPath: string, handoff?: Handoff): Pro export function ssrRenderPlugin( data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], - handoff?: Handoff + handoff: Handoff ): Plugin { return { name: 'vite-plugin-ssr-static-render', @@ -202,46 +327,83 @@ export function ssrRenderPlugin( const entry = path.resolve(data.entries.template); const code = fs.readFileSync(entry, 'utf8'); - // Load schema if schema entry exists + // Determine properties using a hierarchical approach + let properties: { [key: string]: SlotMetadata } | null = null; + let Component: any = null; + + // Step 1: Handle separate schema file (if 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); + try { + const schemaMod = await buildAndEvaluateModule(schemaPath, handoff); + + // Get schema from exports.default (separate schema files export as default) + const schema = loadSchemaFromExports(schemaMod.exports, handoff, 'default'); + + if (isValidSchemaObject(schema)) { + // Valid schema object - convert to properties + if (handoff.config?.hooks?.schemaToProperties) { + properties = handoff.config.hooks.schemaToProperties(schema); + } + } else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema file + properties = await generatePropertiesFromDocgen(schemaPath, handoff); } + } catch (error) { + console.warn(`Failed to load separate schema file ${schemaPath}:`, error); } + } else { + console.warn(`Schema file has unsupported extension: ${ext}`); } } - // 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 + // Step 2: Load component and handle component-embedded schema (only if no separate schema) 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); + try { + const mod = await buildAndEvaluateModule(entry, handoff); + Component = mod.exports.default; + + // Check for exported schema in component file (exports.schema) + const schema = loadSchemaFromExports(mod.exports, handoff, 'schema'); + + if (isValidSchemaObject(schema)) { + // Valid schema object - convert to properties + if (handoff.config?.hooks?.schemaToProperties) { + properties = handoff.config.hooks.schemaToProperties(schema); + } + } else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema + properties = await generatePropertiesFromDocgen(entry, handoff); + } else { + // No schema found - use react-docgen-typescript to analyze component props + properties = await generatePropertiesFromDocgen(entry, handoff); } + } catch (error) { + console.warn(`Failed to load component file ${entry}:`, error); + } + } + + // Step 3: Load component for rendering (if not already loaded) + if (!Component) { + try { + const mod = await buildAndEvaluateModule(entry, handoff); + Component = mod.exports.default; + } catch (error) { + console.error(`Failed to load component for rendering: ${entry}`, error); + return; } } + // Apply the determined properties + if (properties) { + data.properties = properties; + } + if (!components) components = {}; const previews = {}; @@ -297,7 +459,7 @@ export function ssrRenderPlugin( }; // Apply user's client build config hook if provided - const clientBuildConfig = handoff?.config?.hooks?.clientBuildConfig + const clientBuildConfig = handoff.config?.hooks?.clientBuildConfig ? handoff.config.hooks.clientBuildConfig(defaultClientBuildConfig) : defaultClientBuildConfig; @@ -383,4 +545,4 @@ function handoffResolveReactEsbuildPlugin(workingPath: string, handoffModulePath })); }, }; -} \ No newline at end of file +} diff --git a/src/transformers/preview/component.ts b/src/transformers/preview/component.ts index f29ae861..eb192bcc 100644 --- a/src/transformers/preview/component.ts +++ b/src/transformers/preview/component.ts @@ -24,6 +24,9 @@ export enum SlotType { NUMBER = 'number', BOOLEAN = 'boolean', OBJECT = 'object', + FUNCTION = 'function', + ENUM = 'enum', + ANY = 'any', } export interface SlotMetadata { From 740211abbabd23f811dc19b13762996b6e699809 Mon Sep 17 00:00:00 2001 From: Domagoj Gojak Date: Sat, 18 Oct 2025 17:53:21 +0200 Subject: [PATCH 2/2] Restructure plugins module into focused components with better separation of concerns and enhanced TypeScript practices. --- dist/cli.js | 0 dist/commands/utils.d.ts | 2 +- dist/transformers/docgen/index.d.ts | 9 + dist/transformers/docgen/index.js | 60 ++ dist/transformers/plugins.d.ts | 19 +- dist/transformers/plugins.js | 516 +--------------- .../plugins/handlebars-previews.d.ts | 12 + .../plugins/handlebars-previews.js | 142 +++++ dist/transformers/plugins/index.d.ts | 10 + dist/transformers/plugins/index.js | 14 + dist/transformers/plugins/ssr-render.d.ts | 12 + dist/transformers/plugins/ssr-render.js | 237 ++++++++ dist/transformers/types.d.ts | 78 +++ dist/transformers/types.js | 2 + dist/transformers/utils/build.d.ts | 27 + dist/transformers/utils/build.js | 76 +++ dist/transformers/utils/handlebars.d.ts | 28 + dist/transformers/utils/handlebars.js | 61 ++ dist/transformers/utils/html.d.ts | 18 + dist/transformers/utils/html.js | 46 ++ dist/transformers/utils/index.d.ts | 12 + dist/transformers/utils/index.js | 34 ++ dist/transformers/utils/module.d.ts | 8 + dist/transformers/utils/module.js | 42 ++ dist/transformers/utils/schema-loader.d.ts | 19 + dist/transformers/utils/schema-loader.js | 79 +++ dist/transformers/utils/schema.d.ts | 33 ++ dist/transformers/utils/schema.js | 101 ++++ src/transformers/README.md | 78 +++ src/transformers/docgen/index.ts | 53 ++ src/transformers/plugins.ts | 556 +----------------- .../plugins/handlebars-previews.ts | 190 ++++++ src/transformers/plugins/index.ts | 13 + src/transformers/plugins/ssr-render.ts | 284 +++++++++ src/transformers/types.ts | 86 +++ src/transformers/utils/build.ts | 75 +++ src/transformers/utils/handlebars.ts | 67 +++ src/transformers/utils/html.ts | 31 + src/transformers/utils/index.ts | 24 + src/transformers/utils/module.ts | 36 ++ src/transformers/utils/schema-loader.ts | 73 +++ src/transformers/utils/schema.ts | 99 ++++ 42 files changed, 2311 insertions(+), 1051 deletions(-) mode change 100644 => 100755 dist/cli.js create mode 100644 dist/transformers/docgen/index.d.ts create mode 100644 dist/transformers/docgen/index.js create mode 100644 dist/transformers/plugins/handlebars-previews.d.ts create mode 100644 dist/transformers/plugins/handlebars-previews.js create mode 100644 dist/transformers/plugins/index.d.ts create mode 100644 dist/transformers/plugins/index.js create mode 100644 dist/transformers/plugins/ssr-render.d.ts create mode 100644 dist/transformers/plugins/ssr-render.js create mode 100644 dist/transformers/types.d.ts create mode 100644 dist/transformers/types.js create mode 100644 dist/transformers/utils/build.d.ts create mode 100644 dist/transformers/utils/build.js create mode 100644 dist/transformers/utils/handlebars.d.ts create mode 100644 dist/transformers/utils/handlebars.js create mode 100644 dist/transformers/utils/html.d.ts create mode 100644 dist/transformers/utils/html.js create mode 100644 dist/transformers/utils/index.d.ts create mode 100644 dist/transformers/utils/index.js create mode 100644 dist/transformers/utils/module.d.ts create mode 100644 dist/transformers/utils/module.js create mode 100644 dist/transformers/utils/schema-loader.d.ts create mode 100644 dist/transformers/utils/schema-loader.js create mode 100644 dist/transformers/utils/schema.d.ts create mode 100644 dist/transformers/utils/schema.js create mode 100644 src/transformers/README.md create mode 100644 src/transformers/docgen/index.ts create mode 100644 src/transformers/plugins/handlebars-previews.ts create mode 100644 src/transformers/plugins/index.ts create mode 100644 src/transformers/plugins/ssr-render.ts create mode 100644 src/transformers/types.ts create mode 100644 src/transformers/utils/build.ts create mode 100644 src/transformers/utils/handlebars.ts create mode 100644 src/transformers/utils/html.ts create mode 100644 src/transformers/utils/index.ts create mode 100644 src/transformers/utils/module.ts create mode 100644 src/transformers/utils/schema-loader.ts create mode 100644 src/transformers/utils/schema.ts diff --git a/dist/cli.js b/dist/cli.js old mode 100644 new mode 100755 diff --git a/dist/commands/utils.d.ts b/dist/commands/utils.d.ts index a701669d..6754bb8d 100644 --- a/dist/commands/utils.d.ts +++ b/dist/commands/utils.d.ts @@ -1,5 +1,5 @@ import { Argv } from 'yargs'; -export declare const getSharedOptions: (yargs: Argv) => Argv & import("yargs").InferredOptionTypes<{ +export declare const getSharedOptions: (yargs: Argv) => Argv & import("yargs").InferredOptionTypes<{ config: { alias: string; type: "string"; diff --git a/dist/transformers/docgen/index.d.ts b/dist/transformers/docgen/index.d.ts new file mode 100644 index 00000000..4868c489 --- /dev/null +++ b/dist/transformers/docgen/index.d.ts @@ -0,0 +1,9 @@ +/** + * Generates component properties using react-docgen-typescript + * @param entry - Path to the component/schema file + * @param handoff - Handoff instance for configuration + * @returns Generated properties or null if failed + */ +export declare const generatePropertiesFromDocgen: (entry: string, handoff: any) => Promise<{ + [key: string]: any; +} | null>; diff --git a/dist/transformers/docgen/index.js b/dist/transformers/docgen/index.js new file mode 100644 index 00000000..7a160746 --- /dev/null +++ b/dist/transformers/docgen/index.js @@ -0,0 +1,60 @@ +"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.generatePropertiesFromDocgen = void 0; +const fs_extra_1 = __importDefault(require("fs-extra")); +const path_1 = __importDefault(require("path")); +const react_docgen_typescript_1 = require("react-docgen-typescript"); +const schema_1 = require("../utils/schema"); +/** + * Generates component properties using react-docgen-typescript + * @param entry - Path to the component/schema file + * @param handoff - Handoff instance for configuration + * @returns Generated properties or null if failed + */ +const generatePropertiesFromDocgen = (entry, handoff) => __awaiter(void 0, void 0, void 0, function* () { + try { + // Use root project's tsconfig.json + const tsconfigPath = path_1.default.resolve(handoff.workingPath, 'tsconfig.json'); + // Check if tsconfig exists + if (!fs_extra_1.default.existsSync(tsconfigPath)) { + console.warn(`TypeScript config not found at ${tsconfigPath}, using default configuration`); + } + const parserConfig = { + savePropValueAsString: true, + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => { + if (prop.parent) { + return !prop.parent.fileName.includes('node_modules'); + } + return true; + }, + }; + const parser = (0, react_docgen_typescript_1.withCustomConfig)(tsconfigPath, parserConfig); + const docgenResults = parser.parse(entry); + if (docgenResults.length > 0) { + const componentDoc = docgenResults[0]; + if (componentDoc.props && Object.keys(componentDoc.props).length > 0) { + return (0, schema_1.convertDocgenToProperties)(Object.values(componentDoc.props)); + } + } + return null; + } + catch (error) { + console.warn(`Failed to generate docs with react-docgen-typescript for ${entry}:`, error); + return null; + } +}); +exports.generatePropertiesFromDocgen = generatePropertiesFromDocgen; diff --git a/dist/transformers/plugins.d.ts b/dist/transformers/plugins.d.ts index 7567818f..542ff9e9 100644 --- a/dist/transformers/plugins.d.ts +++ b/dist/transformers/plugins.d.ts @@ -1,6 +1,13 @@ -import { Types as CoreTypes } from 'handoff-core'; -import { Plugin } from 'vite'; -import Handoff from '..'; -import { TransformComponentTokensResult } from './preview/types'; -export declare function handlebarsPreviewsPlugin(data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], handoff: Handoff): Plugin; -export declare function ssrRenderPlugin(data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], handoff: Handoff): Plugin; +/** + * @fileoverview Main plugins module for the transformers package + * + * This module provides the primary entry point for Vite plugins used in + * component transformation and preview generation. It exports specialized + * plugins for different rendering approaches. + * + * Available plugins: + * - handlebarsPreviewsPlugin: Handlebars-based template rendering + * - ssrRenderPlugin: React server-side rendering with hydration + */ +export { handlebarsPreviewsPlugin } from './plugins/handlebars-previews'; +export { ssrRenderPlugin } from './plugins/ssr-render'; diff --git a/dist/transformers/plugins.js b/dist/transformers/plugins.js index efae6414..0b3123de 100644 --- a/dist/transformers/plugins.js +++ b/dist/transformers/plugins.js @@ -1,503 +1,19 @@ "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 react_docgen_typescript_1 = require("react-docgen-typescript"); -const server_1 = __importDefault(require("react-dom/server")); -const vite_1 = require("vite"); -const component_1 = require("./preview/component"); -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 convertDocgenToProperties = (docgenProps) => { - const properties = {}; - for (const prop of docgenProps) { - const { name, type, required, description, defaultValue } = prop; - // Convert react-docgen-typescript type to our SlotType enum - let propType = component_1.SlotType.TEXT; - if ((type === null || type === void 0 ? void 0 : type.name) === 'boolean') { - propType = component_1.SlotType.BOOLEAN; - } - else if ((type === null || type === void 0 ? void 0 : type.name) === 'number') { - propType = component_1.SlotType.NUMBER; - } - else if ((type === null || type === void 0 ? void 0 : type.name) === 'array') { - propType = component_1.SlotType.ARRAY; - } - else if ((type === null || type === void 0 ? void 0 : type.name) === 'object') { - propType = component_1.SlotType.OBJECT; - } - else if ((type === null || type === void 0 ? void 0 : type.name) === 'function') { - propType = component_1.SlotType.FUNCTION; - } - else if ((type === null || type === void 0 ? void 0 : type.name) === 'enum') { - propType = component_1.SlotType.ENUM; - } - properties[name] = { - id: name, - name: name, - description: description || '', - generic: '', - type: propType, - default: (defaultValue === null || defaultValue === void 0 ? void 0 : defaultValue.value) || undefined, - rules: { - required: required || false, - }, - }; - } - return properties; -}; -/** - * Validates if a schema object is valid for property conversion - * @param schema - The schema object to validate - * @returns True if schema is valid, false otherwise - */ -const isValidSchemaObject = (schema) => { - return schema && - typeof schema === 'object' && - schema.type === 'object' && - schema.properties && - typeof schema.properties === 'object'; -}; /** - * Safely loads schema from module exports - * @param moduleExports - The module exports object - * @param handoff - Handoff instance for configuration - * @param exportKey - The export key to look for ('default' or 'schema') - * @returns The schema object or null if not found/invalid + * @fileoverview Main plugins module for the transformers package + * + * This module provides the primary entry point for Vite plugins used in + * component transformation and preview generation. It exports specialized + * plugins for different rendering approaches. + * + * Available plugins: + * - handlebarsPreviewsPlugin: Handlebars-based template rendering + * - ssrRenderPlugin: React server-side rendering with hydration */ -const loadSchemaFromExports = (moduleExports, handoff, exportKey = 'default') => { - var _a, _b; - try { - const schema = ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.getSchemaFromExports) - ? handoff.config.hooks.getSchemaFromExports(moduleExports) - : moduleExports[exportKey]; - return schema; - } - catch (error) { - console.warn(`Failed to load schema from exports (${exportKey}):`, error); - return null; - } -}; -/** - * Generates component properties using react-docgen-typescript - * @param entry - Path to the component/schema file - * @param handoff - Handoff instance for configuration - * @returns Generated properties or null if failed - */ -const generatePropertiesFromDocgen = (entry, handoff) => __awaiter(void 0, void 0, void 0, function* () { - try { - // Use root project's tsconfig.json - const tsconfigPath = path_1.default.resolve(handoff.workingPath, 'tsconfig.json'); - // Check if tsconfig exists - if (!fs_extra_1.default.existsSync(tsconfigPath)) { - console.warn(`TypeScript config not found at ${tsconfigPath}, using default configuration`); - } - const parser = (0, react_docgen_typescript_1.withCustomConfig)(tsconfigPath, { - savePropValueAsString: true, - shouldExtractLiteralValuesFromEnum: true, - shouldRemoveUndefinedFromOptional: true, - propFilter: (prop) => { - if (prop.parent) { - return !prop.parent.fileName.includes('node_modules'); - } - return true; - }, - }); - const docgenResults = parser.parse(entry); - if (docgenResults.length > 0) { - const componentDoc = docgenResults[0]; - if (componentDoc.props && Object.keys(componentDoc.props).length > 0) { - return convertDocgenToProperties(Object.values(componentDoc.props)); - } - } - return null; - } - catch (error) { - console.warn(`Failed to generate docs with react-docgen-typescript for ${entry}:`, error); - return null; - } -}); -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(`${options.fn(this)}`); - } - 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: `\n`, - script: `\n`, - properties: previewData.values || {}, - fields: ensureIds(data.properties), - title: data.title, - }); - return yield prettier_1.default.format(`${compiled}`, { 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.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; - // 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'); - // Determine properties using a hierarchical approach - let properties = null; - let Component = null; - // Step 1: Handle separate schema file (if 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') { - try { - const schemaMod = yield buildAndEvaluateModule(schemaPath, handoff); - // Get schema from exports.default (separate schema files export as default) - const schema = loadSchemaFromExports(schemaMod.exports, handoff, 'default'); - if (isValidSchemaObject(schema)) { - // Valid schema object - convert to properties - if ((_c = (_b = handoff.config) === null || _b === void 0 ? void 0 : _b.hooks) === null || _c === void 0 ? void 0 : _c.schemaToProperties) { - properties = handoff.config.hooks.schemaToProperties(schema); - } - } - else if (schema) { - // Schema exists but is not a valid schema object (e.g., type/interface) - // Use react-docgen-typescript to document the schema file - properties = yield generatePropertiesFromDocgen(schemaPath, handoff); - } - } - catch (error) { - console.warn(`Failed to load separate schema file ${schemaPath}:`, error); - } - } - else { - console.warn(`Schema file has unsupported extension: ${ext}`); - } - } - // Step 2: Load component and handle component-embedded schema (only if no separate schema) - if (!((_d = data.entries) === null || _d === void 0 ? void 0 : _d.schema)) { - try { - const mod = yield buildAndEvaluateModule(entry, handoff); - Component = mod.exports.default; - // Check for exported schema in component file (exports.schema) - const schema = loadSchemaFromExports(mod.exports, handoff, 'schema'); - if (isValidSchemaObject(schema)) { - // Valid schema object - convert to properties - if ((_f = (_e = handoff.config) === null || _e === void 0 ? void 0 : _e.hooks) === null || _f === void 0 ? void 0 : _f.schemaToProperties) { - properties = handoff.config.hooks.schemaToProperties(schema); - } - } - else if (schema) { - // Schema exists but is not a valid schema object (e.g., type/interface) - // Use react-docgen-typescript to document the schema - properties = yield generatePropertiesFromDocgen(entry, handoff); - } - else { - // No schema found - use react-docgen-typescript to analyze component props - properties = yield generatePropertiesFromDocgen(entry, handoff); - } - } - catch (error) { - console.warn(`Failed to load component file ${entry}:`, error); - } - } - // Step 3: Load component for rendering (if not already loaded) - if (!Component) { - try { - const mod = yield buildAndEvaluateModule(entry, handoff); - Component = mod.exports.default; - } - catch (error) { - console.error(`Failed to load component for rendering: ${entry}`, error); - return; - } - } - // Apply the determined properties - if (properties) { - data.properties = properties; - } - 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'), ); - `; - // 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 = ((_h = (_g = handoff.config) === null || _g === void 0 ? void 0 : _g.hooks) === null || _h === void 0 ? void 0 : _h.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 = ` - - - - - - - - - ${data.previews[key].title} - - -
${pretty}
- - `; - 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), - })); - }, - }; -} +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ssrRenderPlugin = exports.handlebarsPreviewsPlugin = void 0; +// Export the main plugins +var handlebars_previews_1 = require("./plugins/handlebars-previews"); +Object.defineProperty(exports, "handlebarsPreviewsPlugin", { enumerable: true, get: function () { return handlebars_previews_1.handlebarsPreviewsPlugin; } }); +var ssr_render_1 = require("./plugins/ssr-render"); +Object.defineProperty(exports, "ssrRenderPlugin", { enumerable: true, get: function () { return ssr_render_1.ssrRenderPlugin; } }); diff --git a/dist/transformers/plugins/handlebars-previews.d.ts b/dist/transformers/plugins/handlebars-previews.d.ts new file mode 100644 index 00000000..000fba0b --- /dev/null +++ b/dist/transformers/plugins/handlebars-previews.d.ts @@ -0,0 +1,12 @@ +import { Types as CoreTypes } from 'handoff-core'; +import { Plugin } from 'vite'; +import Handoff from '../..'; +import { TransformComponentTokensResult } from '../preview/types'; +/** + * Handlebars previews plugin factory + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + * @param handoff - Handoff instance + * @returns Vite plugin for Handlebars previews + */ +export declare function handlebarsPreviewsPlugin(componentData: TransformComponentTokensResult, documentationComponents: CoreTypes.IDocumentationObject['components'], handoff: Handoff): Plugin; diff --git a/dist/transformers/plugins/handlebars-previews.js b/dist/transformers/plugins/handlebars-previews.js new file mode 100644 index 00000000..bf484b38 --- /dev/null +++ b/dist/transformers/plugins/handlebars-previews.js @@ -0,0 +1,142 @@ +"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; +const fs_extra_1 = __importDefault(require("fs-extra")); +const handlebars_1 = __importDefault(require("handlebars")); +const path_1 = __importDefault(require("path")); +const handlebars_2 = require("../utils/handlebars"); +const html_1 = require("../utils/html"); +/** + * Constants for the Handlebars previews plugin + */ +const PLUGIN_CONSTANTS = { + PLUGIN_NAME: 'vite-plugin-previews', + SCRIPT_ID: 'script', + DUMMY_EXPORT: 'export default {}', + INSPECT_SUFFIX: '-inspect', + OUTPUT_FORMAT: 'html', +}; +/** + * Processes component instances from documentation and creates preview data + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + */ +function processComponentInstances(componentData, documentationComponents) { + if (documentationComponents[componentData.id]) { + for (const instance of documentationComponents[componentData.id].instances) { + const variationId = instance.id; + const instanceValues = Object.fromEntries(instance.variantProperties); + componentData.previews[variationId] = { + title: variationId, + url: '', + values: instanceValues, + }; + } + } +} +/** + * Renders a Handlebars template with the given preview data + * @param template - Handlebars template string + * @param componentData - Component transformation data + * @param previewData - Preview data to render + * @param injectFieldWrappers - Whether to inject field wrappers for inspection + * @returns Rendered HTML string + */ +function renderHandlebarsTemplate(template, componentData, previewData, injectFieldWrappers) { + return __awaiter(this, void 0, void 0, function* () { + // Register Handlebars helpers with current injection state + (0, handlebars_2.registerHandlebarsHelpers)({ id: componentData.id, properties: componentData.properties || {} }, injectFieldWrappers); + const context = (0, handlebars_2.createHandlebarsContext)({ + id: componentData.id, + properties: componentData.properties || {}, + title: componentData.title + }, previewData); + const compiled = handlebars_1.default.compile(template)(context); + return yield (0, html_1.formatHtmlWithWrapper)(compiled); + }); +} +/** + * Generates preview files for a component variation + * @param componentId - Component identifier + * @param previewKey - Preview variation key + * @param normalHtml - Normal mode HTML + * @param inspectHtml - Inspect mode HTML + * @param emitFile - Vite emitFile function + */ +function emitPreviewFiles(componentId, previewKey, normalHtml, inspectHtml, emitFile) { + emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}.html`, + source: normalHtml, + }); + emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}${PLUGIN_CONSTANTS.INSPECT_SUFFIX}.html`, + source: inspectHtml, + }); +} +/** + * Handlebars previews plugin factory + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + * @param handoff - Handoff instance + * @returns Vite plugin for Handlebars previews + */ +function handlebarsPreviewsPlugin(componentData, documentationComponents, handoff) { + return { + name: PLUGIN_CONSTANTS.PLUGIN_NAME, + apply: 'build', + resolveId(resolveId) { + if (resolveId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return resolveId; + } + }, + load(loadId) { + if (loadId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return PLUGIN_CONSTANTS.DUMMY_EXPORT; + } + }, + generateBundle() { + return __awaiter(this, void 0, void 0, function* () { + const componentId = componentData.id; + const templatePath = path_1.default.resolve(componentData.entries.template); + const templateContent = yield fs_extra_1.default.readFile(templatePath, 'utf8'); + // Ensure components object exists + if (!documentationComponents) { + documentationComponents = {}; + } + // Process component instances from documentation + processComponentInstances(componentData, documentationComponents); + const generatedPreviews = {}; + // Generate previews for each variation + for (const previewKey in componentData.previews) { + const previewData = componentData.previews[previewKey]; + // Render both normal and inspect modes + const normalModeHtml = yield renderHandlebarsTemplate(templateContent, componentData, previewData, false); + const inspectModeHtml = yield renderHandlebarsTemplate(templateContent, componentData, previewData, true); + // Emit preview files + emitPreviewFiles(componentId, previewKey, normalModeHtml, inspectModeHtml, (file) => this.emitFile(file)); + generatedPreviews[previewKey] = normalModeHtml; + componentData.previews[previewKey].url = `${componentId}-${previewKey}.html`; + } + // Update component data with results + componentData.format = PLUGIN_CONSTANTS.OUTPUT_FORMAT; + componentData.preview = ''; + componentData.code = (0, html_1.trimPreview)(templateContent); + componentData.html = (0, html_1.trimPreview)(generatedPreviews[Object.keys(generatedPreviews)[0]]); + }); + }, + }; +} diff --git a/dist/transformers/plugins/index.d.ts b/dist/transformers/plugins/index.d.ts new file mode 100644 index 00000000..aeb57387 --- /dev/null +++ b/dist/transformers/plugins/index.d.ts @@ -0,0 +1,10 @@ +/** + * Plugin exports for the transformers module + * + * This module provides a centralized interface for all Vite plugins + * used in the Handoff application. Each plugin is focused on a specific + * rendering approach and can be easily tested and maintained. + */ +export { handlebarsPreviewsPlugin } from './handlebars-previews'; +export { ssrRenderPlugin } from './ssr-render'; +export type { PluginFactory } from '../types'; diff --git a/dist/transformers/plugins/index.js b/dist/transformers/plugins/index.js new file mode 100644 index 00000000..67fbceca --- /dev/null +++ b/dist/transformers/plugins/index.js @@ -0,0 +1,14 @@ +"use strict"; +/** + * Plugin exports for the transformers module + * + * This module provides a centralized interface for all Vite plugins + * used in the Handoff application. Each plugin is focused on a specific + * rendering approach and can be easily tested and maintained. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ssrRenderPlugin = exports.handlebarsPreviewsPlugin = void 0; +var handlebars_previews_1 = require("./handlebars-previews"); +Object.defineProperty(exports, "handlebarsPreviewsPlugin", { enumerable: true, get: function () { return handlebars_previews_1.handlebarsPreviewsPlugin; } }); +var ssr_render_1 = require("./ssr-render"); +Object.defineProperty(exports, "ssrRenderPlugin", { enumerable: true, get: function () { return ssr_render_1.ssrRenderPlugin; } }); diff --git a/dist/transformers/plugins/ssr-render.d.ts b/dist/transformers/plugins/ssr-render.d.ts new file mode 100644 index 00000000..95c09bc8 --- /dev/null +++ b/dist/transformers/plugins/ssr-render.d.ts @@ -0,0 +1,12 @@ +import { Types as CoreTypes } from 'handoff-core'; +import { Plugin } from 'vite'; +import Handoff from '../..'; +import { TransformComponentTokensResult } from '../preview/types'; +/** + * SSR render plugin factory + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + * @param handoff - Handoff instance + * @returns Vite plugin for SSR rendering + */ +export declare function ssrRenderPlugin(componentData: TransformComponentTokensResult, documentationComponents: CoreTypes.IDocumentationObject['components'], handoff: Handoff): Plugin; diff --git a/dist/transformers/plugins/ssr-render.js b/dist/transformers/plugins/ssr-render.js new file mode 100644 index 00000000..3f2998ff --- /dev/null +++ b/dist/transformers/plugins/ssr-render.js @@ -0,0 +1,237 @@ +"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.ssrRenderPlugin = ssrRenderPlugin; +const esbuild_1 = __importDefault(require("esbuild")); +const fs_extra_1 = __importDefault(require("fs-extra")); +const path_1 = __importDefault(require("path")); +const react_1 = __importDefault(require("react")); +const server_1 = __importDefault(require("react-dom/server")); +const vite_1 = require("vite"); +const docgen_1 = require("../docgen"); +const build_1 = require("../utils/build"); +const html_1 = require("../utils/html"); +const module_1 = require("../utils/module"); +const schema_loader_1 = require("../utils/schema-loader"); +/** + * Constants for the SSR render plugin + */ +const PLUGIN_CONSTANTS = { + PLUGIN_NAME: 'vite-plugin-ssr-static-render', + SCRIPT_ID: 'script', + DUMMY_EXPORT: 'export default {}', + ROOT_ELEMENT_ID: 'root', + PROPS_SCRIPT_ID: '__APP_PROPS__', + INSPECT_SUFFIX: '-inspect', +}; +/** + * Loads and processes component schema using hierarchical approach + * @param componentData - Component transformation data + * @param componentPath - Path to the component file + * @param handoff - Handoff instance + * @returns Tuple of [properties, component] or [null, null] if failed + */ +function loadComponentSchemaAndModule(componentData, componentPath, handoff) { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + let properties = null; + let component = null; + // Step 1: Handle separate schema file (if exists) + if ((_a = componentData.entries) === null || _a === void 0 ? void 0 : _a.schema) { + const schemaPath = path_1.default.resolve(componentData.entries.schema); + properties = yield (0, schema_loader_1.loadSchemaFromFile)(schemaPath, handoff); + } + // Step 2: Load component and handle component-embedded schema (only if no separate schema) + if (!((_b = componentData.entries) === null || _b === void 0 ? void 0 : _b.schema)) { + try { + const moduleExports = yield (0, module_1.buildAndEvaluateModule)(componentPath, handoff); + component = moduleExports.exports.default; + // Try to load schema from component exports + properties = yield (0, schema_loader_1.loadSchemaFromComponent)(moduleExports.exports, handoff); + // If no schema found, use react-docgen-typescript + if (!properties) { + properties = yield (0, docgen_1.generatePropertiesFromDocgen)(componentPath, handoff); + } + } + catch (error) { + console.warn(`Failed to load component file ${componentPath}:`, error); + } + } + // Step 3: Load component for rendering (if not already loaded) + if (!component) { + try { + const moduleExports = yield (0, module_1.buildAndEvaluateModule)(componentPath, handoff); + component = moduleExports.exports.default; + } + catch (error) { + console.error(`Failed to load component for rendering: ${componentPath}`, error); + return [null, null]; + } + } + return [properties, component]; + }); +} +/** + * Generates client-side hydration source code + * @param componentPath - Path to the component file + * @returns Client-side hydration source code + */ +function generateClientHydrationSource(componentPath) { + return ` + import React from 'react'; + import { hydrateRoot } from 'react-dom/client'; + import Component from '${(0, vite_1.normalizePath)(componentPath)}'; + + const raw = document.getElementById('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')?.textContent || '{}'; + const props = JSON.parse(raw); + hydrateRoot(document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}'), ); + `; +} +/** + * Generates complete HTML document with SSR content and hydration + * @param componentId - Component identifier + * @param previewTitle - Title for the preview + * @param renderedHtml - Server-rendered HTML content + * @param clientJs - Client-side JavaScript bundle + * @param props - Component props as JSON + * @returns Complete HTML document + */ +function generateHtmlDocument(componentId, previewTitle, renderedHtml, clientJs, props) { + return ` + + + + + + + + + ${previewTitle} + + +
${renderedHtml}
+ +`; +} +/** + * SSR render plugin factory + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + * @param handoff - Handoff instance + * @returns Vite plugin for SSR rendering + */ +function ssrRenderPlugin(componentData, documentationComponents, handoff) { + return { + name: PLUGIN_CONSTANTS.PLUGIN_NAME, + apply: 'build', + resolveId(resolveId) { + console.log('resolveId', resolveId); + if (resolveId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return resolveId; + } + }, + load(loadId) { + if (loadId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return PLUGIN_CONSTANTS.DUMMY_EXPORT; + } + }, + generateBundle(_, bundle) { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + // Remove all JS chunks to prevent conflicts + for (const [fileName, chunkInfo] of Object.entries(bundle)) { + if (chunkInfo.type === 'chunk' && fileName.includes(PLUGIN_CONSTANTS.SCRIPT_ID)) { + delete bundle[fileName]; + } + } + const componentId = componentData.id; + const componentPath = path_1.default.resolve(componentData.entries.template); + const componentSourceCode = fs_extra_1.default.readFileSync(componentPath, 'utf8'); + // Load component schema and module + const [schemaProperties, ReactComponent] = yield loadComponentSchemaAndModule(componentData, componentPath, handoff); + if (!ReactComponent) { + console.error(`Failed to load React component for ${componentId}`); + return; + } + // Apply schema properties if found + if (schemaProperties) { + componentData.properties = schemaProperties; + } + // Ensure components object exists + if (!documentationComponents) { + documentationComponents = {}; + } + const generatedPreviews = {}; + // Process component instances from documentation + if (documentationComponents[componentId]) { + for (const instance of documentationComponents[componentId].instances) { + const variationId = instance.id; + const instanceValues = Object.fromEntries(instance.variantProperties); + componentData.previews[variationId] = { + title: variationId, + url: '', + values: instanceValues, + }; + } + } + let finalHtml = ''; + // Generate previews for each variation + for (const previewKey in componentData.previews) { + const previewProps = componentData.previews[previewKey].values; + // Server-side render the component + const serverRenderedHtml = server_1.default.renderToString(react_1.default.createElement(ReactComponent, Object.assign(Object.assign({}, previewProps), { block: Object.assign({}, previewProps) }))); + const formattedHtml = yield (0, html_1.formatHtml)(serverRenderedHtml); + // Generate client-side hydration code + const clientHydrationSource = generateClientHydrationSource(componentPath); + // Build client-side bundle + const clientBuildConfig = Object.assign(Object.assign({}, build_1.DEFAULT_CLIENT_BUILD_CONFIG), { stdin: { + contents: clientHydrationSource, + resolveDir: process.cwd(), + loader: 'tsx', + }, plugins: [(0, build_1.createReactResolvePlugin)(handoff.workingPath, handoff.modulePath)] }); + // Apply user's client build config hook if provided + const finalClientBuildConfig = ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.clientBuildConfig) + ? handoff.config.hooks.clientBuildConfig(clientBuildConfig) + : clientBuildConfig; + const bundledClient = yield esbuild_1.default.build(finalClientBuildConfig); + const clientBundleJs = bundledClient.outputFiles[0].text; + // Generate complete HTML document + finalHtml = generateHtmlDocument(componentId, componentData.previews[previewKey].title, formattedHtml, clientBundleJs, previewProps); + // Emit preview files + this.emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}.html`, + source: finalHtml, + }); + // TODO: remove this once we have a way to render inspect mode + this.emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}${PLUGIN_CONSTANTS.INSPECT_SUFFIX}.html`, + source: finalHtml, + }); + generatedPreviews[previewKey] = finalHtml; + componentData.previews[previewKey].url = `${componentId}-${previewKey}.html`; + } + // Format final HTML and update component data + finalHtml = yield (0, html_1.formatHtml)(finalHtml); + componentData.format = 'react'; + componentData.preview = ''; + componentData.code = (0, html_1.trimPreview)(componentSourceCode); + componentData.html = (0, html_1.trimPreview)(finalHtml); + }); + }, + }; +} diff --git a/dist/transformers/types.d.ts b/dist/transformers/types.d.ts new file mode 100644 index 00000000..585fecb6 --- /dev/null +++ b/dist/transformers/types.d.ts @@ -0,0 +1,78 @@ +import { Types as CoreTypes } from 'handoff-core'; +import { Plugin } from 'vite'; +import { SlotMetadata } from './preview/component'; +import { TransformComponentTokensResult } from './preview/types'; +/** + * Configuration for react-docgen-typescript parser + */ +export interface DocgenParserConfig { + savePropValueAsString: boolean; + shouldExtractLiteralValuesFromEnum: boolean; + shouldRemoveUndefinedFromOptional: boolean; + propFilter: (prop: any) => boolean; +} +/** + * Result from react-docgen-typescript parsing + */ +export interface DocgenResult { + props: { + [key: string]: any; + }; + [key: string]: any; +} +/** + * Module evaluation result + */ +export interface ModuleEvaluationResult { + exports: any; +} +/** + * Build configuration for esbuild + */ +export interface BuildConfig { + entryPoints?: string[]; + stdin?: { + contents: string; + resolveDir: string; + loader: string; + }; + bundle: boolean; + write: boolean; + format: 'cjs' | 'esm'; + platform: 'node' | 'browser'; + jsx: 'automatic' | 'transform' | 'preserve'; + external?: string[]; + sourcemap?: boolean; + minify?: boolean; + plugins?: any[]; +} +/** + * Plugin factory function signature + */ +export type PluginFactory = (data: TransformComponentTokensResult, components: CoreTypes.IDocumentationObject['components'], handoff: any) => Plugin; +/** + * Schema loading options + */ +export interface SchemaLoadOptions { + exportKey: 'default' | 'schema'; + handoff: any; +} +/** + * Preview rendering options + */ +export interface PreviewRenderOptions { + inspect: boolean; + injectFieldWrappers: boolean; +} +/** + * Handlebars template context + */ +export interface HandlebarsContext { + style: string; + script: string; + properties: any; + fields: { + [key: string]: SlotMetadata; + }; + title: string; +} diff --git a/dist/transformers/types.js b/dist/transformers/types.js new file mode 100644 index 00000000..c8ad2e54 --- /dev/null +++ b/dist/transformers/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/transformers/utils/build.d.ts b/dist/transformers/utils/build.d.ts new file mode 100644 index 00000000..d3b1286a --- /dev/null +++ b/dist/transformers/utils/build.d.ts @@ -0,0 +1,27 @@ +import esbuild from 'esbuild'; +/** + * Default esbuild configuration for SSR builds + */ +export declare const DEFAULT_SSR_BUILD_CONFIG: esbuild.BuildOptions; +/** + * Default esbuild configuration for client builds + */ +export declare const DEFAULT_CLIENT_BUILD_CONFIG: esbuild.BuildOptions; +/** + * Resolves a module from multiple search directories + * @param id - Module ID to resolve + * @param searchDirs - Array of directories to search in + * @returns Resolved module path + * @throws Error if module not found in any directory + */ +export declare function resolveModule(id: string, searchDirs: string[]): string; +/** + * Creates an esbuild plugin for resolving React modules + * @param workingPath - Working directory path + * @param handoffModulePath - Handoff module path + * @returns Esbuild plugin configuration + */ +export declare function createReactResolvePlugin(workingPath: string, handoffModulePath: string): { + name: string; + setup(build: any): void; +}; diff --git a/dist/transformers/utils/build.js b/dist/transformers/utils/build.js new file mode 100644 index 00000000..4777ff6f --- /dev/null +++ b/dist/transformers/utils/build.js @@ -0,0 +1,76 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_CLIENT_BUILD_CONFIG = exports.DEFAULT_SSR_BUILD_CONFIG = void 0; +exports.resolveModule = resolveModule; +exports.createReactResolvePlugin = createReactResolvePlugin; +const path_1 = __importDefault(require("path")); +/** + * Default esbuild configuration for SSR builds + */ +exports.DEFAULT_SSR_BUILD_CONFIG = { + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + jsx: 'automatic', + external: ['react', 'react-dom', '@opentelemetry/api'], +}; +/** + * Default esbuild configuration for client builds + */ +exports.DEFAULT_CLIENT_BUILD_CONFIG = { + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + jsx: 'automatic', + sourcemap: false, + minify: false, +}; +/** + * Resolves a module from multiple search directories + * @param id - Module ID to resolve + * @param searchDirs - Array of directories to search in + * @returns Resolved module path + * @throws Error if module not found in any directory + */ +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')}`); +} +/** + * Creates an esbuild plugin for resolving React modules + * @param workingPath - Working directory path + * @param handoffModulePath - Handoff module path + * @returns Esbuild plugin configuration + */ +function createReactResolvePlugin(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), + })); + }, + }; +} diff --git a/dist/transformers/utils/handlebars.d.ts b/dist/transformers/utils/handlebars.d.ts new file mode 100644 index 00000000..e42e7745 --- /dev/null +++ b/dist/transformers/utils/handlebars.d.ts @@ -0,0 +1,28 @@ +import { SlotMetadata } from '../preview/component'; +import { HandlebarsContext } from '../types'; +/** + * Registers common Handlebars helpers + * @param data - Component data containing properties + * @param injectFieldWrappers - Whether to inject field wrappers for inspection + */ +export declare const registerHandlebarsHelpers: (data: { + properties: { + [key: string]: SlotMetadata; + }; + id: string; +}, injectFieldWrappers: boolean) => void; +/** + * Creates Handlebars template context + * @param data - Component data + * @param previewData - Preview data with values + * @returns Handlebars context object + */ +export declare const createHandlebarsContext: (data: { + id: string; + properties: { + [key: string]: SlotMetadata; + }; + title: string; +}, previewData: { + values?: any; +}) => HandlebarsContext; diff --git a/dist/transformers/utils/handlebars.js b/dist/transformers/utils/handlebars.js new file mode 100644 index 00000000..80197919 --- /dev/null +++ b/dist/transformers/utils/handlebars.js @@ -0,0 +1,61 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createHandlebarsContext = exports.registerHandlebarsHelpers = void 0; +const handlebars_1 = __importDefault(require("handlebars")); +/** + * Registers common Handlebars helpers + * @param data - Component data containing properties + * @param injectFieldWrappers - Whether to inject field wrappers for inspection + */ +const registerHandlebarsHelpers = (data, injectFieldWrappers) => { + // Field helper for property binding + handlebars_1.default.registerHelper('field', function (field, options) { + if (injectFieldWrappers) { + if (!field) { + console.error(`Missing field declaration for ${data.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 ${data.id}`); + return options.fn(this); + } + return new handlebars_1.default.SafeString(`${options.fn(this)}`); + } + else { + return options.fn(this); + } + }); + // Equality helper + handlebars_1.default.registerHelper('eq', function (a, b) { + return a === b; + }); +}; +exports.registerHandlebarsHelpers = registerHandlebarsHelpers; +/** + * Creates Handlebars template context + * @param data - Component data + * @param previewData - Preview data with values + * @returns Handlebars context object + */ +const createHandlebarsContext = (data, previewData) => { + return { + style: `\n`, + script: `\n`, + properties: previewData.values || {}, + fields: data.properties, + title: data.title, + }; +}; +exports.createHandlebarsContext = createHandlebarsContext; diff --git a/dist/transformers/utils/html.d.ts b/dist/transformers/utils/html.d.ts new file mode 100644 index 00000000..c388d929 --- /dev/null +++ b/dist/transformers/utils/html.d.ts @@ -0,0 +1,18 @@ +/** + * Trims HTML preview content by extracting body content + * @param preview - The HTML preview string + * @returns Trimmed HTML content + */ +export declare const trimPreview: (preview: string) => string; +/** + * Formats HTML content using Prettier + * @param html - The HTML content to format + * @returns Formatted HTML string + */ +export declare const formatHtml: (html: string) => Promise; +/** + * Formats HTML content with HTML wrapper using Prettier + * @param html - The HTML content to format + * @returns Formatted HTML string with HTML wrapper + */ +export declare const formatHtmlWithWrapper: (html: string) => Promise; diff --git a/dist/transformers/utils/html.js b/dist/transformers/utils/html.js new file mode 100644 index 00000000..95f7ec16 --- /dev/null +++ b/dist/transformers/utils/html.js @@ -0,0 +1,46 @@ +"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.formatHtmlWithWrapper = exports.formatHtml = exports.trimPreview = void 0; +const node_html_parser_1 = require("node-html-parser"); +const prettier_1 = __importDefault(require("prettier")); +/** + * Trims HTML preview content by extracting body content + * @param preview - The HTML preview string + * @returns Trimmed HTML content + */ +const trimPreview = (preview) => { + const bodyEl = (0, node_html_parser_1.parse)(preview).querySelector('body'); + const code = bodyEl ? bodyEl.innerHTML.trim() : preview; + return code; +}; +exports.trimPreview = trimPreview; +/** + * Formats HTML content using Prettier + * @param html - The HTML content to format + * @returns Formatted HTML string + */ +const formatHtml = (html) => __awaiter(void 0, void 0, void 0, function* () { + return yield prettier_1.default.format(html, { parser: 'html' }); +}); +exports.formatHtml = formatHtml; +/** + * Formats HTML content with HTML wrapper using Prettier + * @param html - The HTML content to format + * @returns Formatted HTML string with HTML wrapper + */ +const formatHtmlWithWrapper = (html) => __awaiter(void 0, void 0, void 0, function* () { + return yield prettier_1.default.format(`${html}`, { parser: 'html' }); +}); +exports.formatHtmlWithWrapper = formatHtmlWithWrapper; diff --git a/dist/transformers/utils/index.d.ts b/dist/transformers/utils/index.d.ts new file mode 100644 index 00000000..1554bf01 --- /dev/null +++ b/dist/transformers/utils/index.d.ts @@ -0,0 +1,12 @@ +/** + * Utility exports for the transformers module + * + * This module provides organized utility functions for schema processing, + * HTML manipulation, build configuration, and other common operations. + */ +export * from './schema'; +export * from './html'; +export * from './build'; +export * from './module'; +export * from './handlebars'; +export * from './schema-loader'; diff --git a/dist/transformers/utils/index.js b/dist/transformers/utils/index.js new file mode 100644 index 00000000..030670a8 --- /dev/null +++ b/dist/transformers/utils/index.js @@ -0,0 +1,34 @@ +"use strict"; +/** + * Utility exports for the transformers module + * + * This module provides organized utility functions for schema processing, + * HTML manipulation, build configuration, and other common operations. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// Schema utilities +__exportStar(require("./schema"), exports); +// HTML utilities +__exportStar(require("./html"), exports); +// Build utilities +__exportStar(require("./build"), exports); +// Module utilities +__exportStar(require("./module"), exports); +// Handlebars utilities +__exportStar(require("./handlebars"), exports); +// Schema loader utilities +__exportStar(require("./schema-loader"), exports); diff --git a/dist/transformers/utils/module.d.ts b/dist/transformers/utils/module.d.ts new file mode 100644 index 00000000..ac7869d7 --- /dev/null +++ b/dist/transformers/utils/module.d.ts @@ -0,0 +1,8 @@ +import { ModuleEvaluationResult } from '../types'; +/** + * Builds and evaluates a module using esbuild + * @param entryPath - Path to the module entry point + * @param handoff - Handoff instance for configuration + * @returns Module evaluation result with exports + */ +export declare function buildAndEvaluateModule(entryPath: string, handoff: any): Promise; diff --git a/dist/transformers/utils/module.js b/dist/transformers/utils/module.js new file mode 100644 index 00000000..637d69ee --- /dev/null +++ b/dist/transformers/utils/module.js @@ -0,0 +1,42 @@ +"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.buildAndEvaluateModule = buildAndEvaluateModule; +const esbuild_1 = __importDefault(require("esbuild")); +const build_1 = require("./build"); +/** + * Builds and evaluates a module using esbuild + * @param entryPath - Path to the module entry point + * @param handoff - Handoff instance for configuration + * @returns Module evaluation result with exports + */ +function buildAndEvaluateModule(entryPath, handoff) { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + // Default esbuild configuration + const defaultBuildConfig = Object.assign(Object.assign({}, build_1.DEFAULT_SSR_BUILD_CONFIG), { entryPoints: [entryPath] }); + // Apply user's SSR build config hook if provided + const buildConfig = ((_b = (_a = 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; + }); +} diff --git a/dist/transformers/utils/schema-loader.d.ts b/dist/transformers/utils/schema-loader.d.ts new file mode 100644 index 00000000..e5d0c2da --- /dev/null +++ b/dist/transformers/utils/schema-loader.d.ts @@ -0,0 +1,19 @@ +import { SlotMetadata } from '../preview/component'; +/** + * Loads and processes schema from a separate schema file + * @param schemaPath - Path to the schema file + * @param handoff - Handoff instance for configuration + * @returns Processed properties or null if failed + */ +export declare const loadSchemaFromFile: (schemaPath: string, handoff: any) => Promise<{ + [key: string]: SlotMetadata; +} | null>; +/** + * Loads and processes schema from component exports + * @param componentExports - Component module exports + * @param handoff - Handoff instance for configuration + * @returns Processed properties or null if failed + */ +export declare const loadSchemaFromComponent: (componentExports: any, handoff: any) => Promise<{ + [key: string]: SlotMetadata; +} | null>; diff --git a/dist/transformers/utils/schema-loader.js b/dist/transformers/utils/schema-loader.js new file mode 100644 index 00000000..d89f25c3 --- /dev/null +++ b/dist/transformers/utils/schema-loader.js @@ -0,0 +1,79 @@ +"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.loadSchemaFromComponent = exports.loadSchemaFromFile = void 0; +const path_1 = __importDefault(require("path")); +const docgen_1 = require("../docgen"); +const module_1 = require("./module"); +const schema_1 = require("./schema"); +/** + * Loads and processes schema from a separate schema file + * @param schemaPath - Path to the schema file + * @param handoff - Handoff instance for configuration + * @returns Processed properties or null if failed + */ +const loadSchemaFromFile = (schemaPath, handoff) => __awaiter(void 0, void 0, void 0, function* () { + var _a, _b; + const ext = path_1.default.extname(schemaPath); + if (ext !== '.ts' && ext !== '.tsx') { + console.warn(`Schema file has unsupported extension: ${ext}`); + return null; + } + try { + const schemaMod = yield (0, module_1.buildAndEvaluateModule)(schemaPath, handoff); + // Get schema from exports.default (separate schema files export as default) + const schema = (0, schema_1.loadSchemaFromExports)(schemaMod.exports, handoff, 'default'); + if ((0, schema_1.isValidSchemaObject)(schema)) { + // Valid schema object - convert to properties + if ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.schemaToProperties) { + return handoff.config.hooks.schemaToProperties(schema); + } + } + else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema file + return yield (0, docgen_1.generatePropertiesFromDocgen)(schemaPath, handoff); + } + return null; + } + catch (error) { + console.warn(`Failed to load separate schema file ${schemaPath}:`, error); + return null; + } +}); +exports.loadSchemaFromFile = loadSchemaFromFile; +/** + * Loads and processes schema from component exports + * @param componentExports - Component module exports + * @param handoff - Handoff instance for configuration + * @returns Processed properties or null if failed + */ +const loadSchemaFromComponent = (componentExports, handoff) => __awaiter(void 0, void 0, void 0, function* () { + var _a, _b; + // Check for exported schema in component file (exports.schema) + const schema = (0, schema_1.loadSchemaFromExports)(componentExports, handoff, 'schema'); + if ((0, schema_1.isValidSchemaObject)(schema)) { + // Valid schema object - convert to properties + if ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.schemaToProperties) { + return handoff.config.hooks.schemaToProperties(schema); + } + } + else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema + return yield (0, docgen_1.generatePropertiesFromDocgen)(componentExports.__filename || '', handoff); + } + return null; +}); +exports.loadSchemaFromComponent = loadSchemaFromComponent; diff --git a/dist/transformers/utils/schema.d.ts b/dist/transformers/utils/schema.d.ts new file mode 100644 index 00000000..18232da1 --- /dev/null +++ b/dist/transformers/utils/schema.d.ts @@ -0,0 +1,33 @@ +import { SlotMetadata } from '../preview/component'; +/** + * Ensures all properties have proper IDs assigned recursively + * @param properties - The properties object to process + * @returns The properties object with IDs assigned + */ +export declare const ensureIds: (properties: { + [key: string]: SlotMetadata; +}) => { + [key: string]: SlotMetadata; +}; +/** + * Converts react-docgen-typescript props to our SlotMetadata format + * @param docgenProps - Array of props from react-docgen-typescript + * @returns Converted properties object + */ +export declare const convertDocgenToProperties: (docgenProps: any[]) => { + [key: string]: SlotMetadata; +}; +/** + * Validates if a schema object is valid for property conversion + * @param schema - The schema object to validate + * @returns True if schema is valid, false otherwise + */ +export declare const isValidSchemaObject: (schema: any) => boolean; +/** + * Safely loads schema from module exports + * @param moduleExports - The module exports object + * @param handoff - Handoff instance for configuration + * @param exportKey - The export key to look for ('default' or 'schema') + * @returns The schema object or null if not found/invalid + */ +export declare const loadSchemaFromExports: (moduleExports: any, handoff: any, exportKey?: "default" | "schema") => any; diff --git a/dist/transformers/utils/schema.js b/dist/transformers/utils/schema.js new file mode 100644 index 00000000..1e927bac --- /dev/null +++ b/dist/transformers/utils/schema.js @@ -0,0 +1,101 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadSchemaFromExports = exports.isValidSchemaObject = exports.convertDocgenToProperties = exports.ensureIds = void 0; +const component_1 = require("../preview/component"); +/** + * Ensures all properties have proper IDs assigned recursively + * @param properties - The properties object to process + * @returns The properties object with IDs assigned + */ +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) { + (0, exports.ensureIds)(properties[key].items.properties); + } + if (properties[key].properties) { + (0, exports.ensureIds)(properties[key].properties); + } + } + return properties; +}; +exports.ensureIds = ensureIds; +/** + * Converts react-docgen-typescript props to our SlotMetadata format + * @param docgenProps - Array of props from react-docgen-typescript + * @returns Converted properties object + */ +const convertDocgenToProperties = (docgenProps) => { + const properties = {}; + for (const prop of docgenProps) { + const { name, type, required, description, defaultValue } = prop; + // Convert react-docgen-typescript type to our SlotType enum + let propType = component_1.SlotType.TEXT; + if ((type === null || type === void 0 ? void 0 : type.name) === 'boolean') { + propType = component_1.SlotType.BOOLEAN; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'number') { + propType = component_1.SlotType.NUMBER; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'array') { + propType = component_1.SlotType.ARRAY; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'object') { + propType = component_1.SlotType.OBJECT; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'function') { + propType = component_1.SlotType.FUNCTION; + } + else if ((type === null || type === void 0 ? void 0 : type.name) === 'enum') { + propType = component_1.SlotType.ENUM; + } + properties[name] = { + id: name, + name: name, + description: description || '', + generic: '', + type: propType, + default: (defaultValue === null || defaultValue === void 0 ? void 0 : defaultValue.value) || undefined, + rules: { + required: required || false, + }, + }; + } + return properties; +}; +exports.convertDocgenToProperties = convertDocgenToProperties; +/** + * Validates if a schema object is valid for property conversion + * @param schema - The schema object to validate + * @returns True if schema is valid, false otherwise + */ +const isValidSchemaObject = (schema) => { + return schema && + typeof schema === 'object' && + schema.type === 'object' && + schema.properties && + typeof schema.properties === 'object'; +}; +exports.isValidSchemaObject = isValidSchemaObject; +/** + * Safely loads schema from module exports + * @param moduleExports - The module exports object + * @param handoff - Handoff instance for configuration + * @param exportKey - The export key to look for ('default' or 'schema') + * @returns The schema object or null if not found/invalid + */ +const loadSchemaFromExports = (moduleExports, handoff, exportKey = 'default') => { + var _a, _b; + try { + const schema = ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.getSchemaFromExports) + ? handoff.config.hooks.getSchemaFromExports(moduleExports) + : moduleExports[exportKey]; + return schema; + } + catch (error) { + console.warn(`Failed to load schema from exports (${exportKey}):`, error); + return null; + } +}; +exports.loadSchemaFromExports = loadSchemaFromExports; diff --git a/src/transformers/README.md b/src/transformers/README.md new file mode 100644 index 00000000..7bc3bd3e --- /dev/null +++ b/src/transformers/README.md @@ -0,0 +1,78 @@ +# Transformers Module + +The Transformers module is responsible for processing and transforming component data into various output formats for the Handoff application. It provides Vite plugins for generating component previews using both Handlebars templates and React server-side rendering. + +## Overview + +This module handles the transformation of component tokens into interactive previews, manages schema processing from TypeScript files, and provides utilities for HTML manipulation and build configuration. It supports both traditional template-based rendering and modern React SSR approaches. + +## Architecture + +``` +src/transformers/ +├── plugins.ts # Main entry point (re-exports) +├── types.ts # TypeScript interfaces and types +├── docgen/ +│ └── index.ts # React docgen typescript integration +├── plugins/ +│ ├── index.ts # Plugin exports +│ ├── handlebars-previews.ts # Handlebars previews plugin +│ └── ssr-render.ts # SSR rendering plugin +└── utils/ + ├── index.ts # Utility exports + ├── schema.ts # Schema processing utilities + ├── html.ts # HTML manipulation utilities + ├── build.ts # Build configuration utilities + ├── module.ts # Module evaluation utilities + ├── handlebars.ts # Handlebars helper utilities + └── schema-loader.ts # Schema loading utilities +``` + +## Available Plugins + +### `handlebarsPreviewsPlugin` +Generates HTML previews using Handlebars templates. This plugin: +- Compiles Handlebars templates with component data +- Registers custom helpers for field binding and inspection +- Generates both normal and inspect-mode previews +- Supports component variations and instances + +### `ssrRenderPlugin` +Creates React-based previews using server-side rendering. This plugin: +- Renders React components to static HTML +- Generates client-side hydration code +- Handles schema loading from TypeScript files +- Supports both separate schema files and component-embedded schemas + +## Schema Processing + +The module supports multiple approaches for extracting component schemas: + +1. **Separate Schema Files**: Dedicated `.ts`/`.tsx` files exporting schema objects +2. **Component-Embedded Schemas**: Schemas exported from component files +3. **React Docgen Integration**: Automatic prop extraction using react-docgen-typescript +4. **Custom Schema Hooks**: User-defined schema processing via configuration hooks + +## Configuration Hooks + +Both plugins support extensive customization through configuration hooks: + +```typescript +// Custom schema processing +handoff.config.hooks.schemaToProperties = (schema) => { /* custom logic */ }; + +// Custom SSR build configuration +handoff.config.hooks.ssrBuildConfig = (config) => { /* custom config */ }; + +// Custom client build configuration +handoff.config.hooks.clientBuildConfig = (config) => { /* custom config */ }; + +// Custom schema extraction +handoff.config.hooks.getSchemaFromExports = (exports) => { /* custom logic */ }; +``` + +The schema processing follows a hierarchical approach: +1. Check for separate schema file (`data.entries.schema`) +2. Look for embedded schema in component exports +3. Fall back to react-docgen-typescript analysis +4. Apply custom schema processing hooks if configured diff --git a/src/transformers/docgen/index.ts b/src/transformers/docgen/index.ts new file mode 100644 index 00000000..a109e9e1 --- /dev/null +++ b/src/transformers/docgen/index.ts @@ -0,0 +1,53 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { withCustomConfig } from 'react-docgen-typescript'; +import { DocgenParserConfig, DocgenResult } from '../types'; +import { convertDocgenToProperties } from '../utils/schema'; + +/** + * Generates component properties using react-docgen-typescript + * @param entry - Path to the component/schema file + * @param handoff - Handoff instance for configuration + * @returns Generated properties or null if failed + */ +export const generatePropertiesFromDocgen = async ( + entry: string, + handoff: any +): Promise<{ [key: string]: any } | null> => { + try { + // Use root project's tsconfig.json + const tsconfigPath = path.resolve(handoff.workingPath, 'tsconfig.json'); + + // Check if tsconfig exists + if (!fs.existsSync(tsconfigPath)) { + console.warn(`TypeScript config not found at ${tsconfigPath}, using default configuration`); + } + + const parserConfig: DocgenParserConfig = { + savePropValueAsString: true, + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => { + if (prop.parent) { + return !prop.parent.fileName.includes('node_modules'); + } + return true; + }, + }; + + const parser = withCustomConfig(tsconfigPath, parserConfig); + const docgenResults: DocgenResult[] = parser.parse(entry); + + if (docgenResults.length > 0) { + const componentDoc = docgenResults[0]; + if (componentDoc.props && Object.keys(componentDoc.props).length > 0) { + return convertDocgenToProperties(Object.values(componentDoc.props)); + } + } + + return null; + } catch (error) { + console.warn(`Failed to generate docs with react-docgen-typescript for ${entry}:`, error); + return null; + } +}; diff --git a/src/transformers/plugins.ts b/src/transformers/plugins.ts index 0d03920b..84525da3 100644 --- a/src/transformers/plugins.ts +++ b/src/transformers/plugins.ts @@ -1,548 +1,16 @@ -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 { withCustomConfig } from 'react-docgen-typescript'; -import ReactDOMServer from 'react-dom/server'; -import { Plugin, normalizePath } from 'vite'; -import Handoff from '..'; -import { SlotMetadata, SlotType } 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 convertDocgenToProperties = (docgenProps: any[]): { [key: string]: SlotMetadata } => { - const properties: { [key: string]: SlotMetadata } = {}; - - for (const prop of docgenProps) { - const { name, type, required, description, defaultValue } = prop; - - // Convert react-docgen-typescript type to our SlotType enum - let propType = SlotType.TEXT; - if (type?.name === 'boolean') { - propType = SlotType.BOOLEAN; - } else if (type?.name === 'number') { - propType = SlotType.NUMBER; - } else if (type?.name === 'array') { - propType = SlotType.ARRAY; - } else if (type?.name === 'object') { - propType = SlotType.OBJECT; - } else if (type?.name === 'function') { - propType = SlotType.FUNCTION; - } else if (type?.name === 'enum') { - propType = SlotType.ENUM; - } - - properties[name] = { - id: name, - name: name, - description: description || '', - generic: '', - type: propType, - default: defaultValue?.value || undefined, - rules: { - required: required || false, - }, - }; - } - - return properties; -}; - -/** - * Validates if a schema object is valid for property conversion - * @param schema - The schema object to validate - * @returns True if schema is valid, false otherwise - */ -const isValidSchemaObject = (schema: any): boolean => { - return schema && - typeof schema === 'object' && - schema.type === 'object' && - schema.properties && - typeof schema.properties === 'object'; -}; - /** - * Safely loads schema from module exports - * @param moduleExports - The module exports object - * @param handoff - Handoff instance for configuration - * @param exportKey - The export key to look for ('default' or 'schema') - * @returns The schema object or null if not found/invalid + * @fileoverview Main plugins module for the transformers package + * + * This module provides the primary entry point for Vite plugins used in + * component transformation and preview generation. It exports specialized + * plugins for different rendering approaches. + * + * Available plugins: + * - handlebarsPreviewsPlugin: Handlebars-based template rendering + * - ssrRenderPlugin: React server-side rendering with hydration */ -const loadSchemaFromExports = ( - moduleExports: any, - handoff: Handoff, - exportKey: 'default' | 'schema' = 'default' -): any => { - try { - const schema = handoff.config?.hooks?.getSchemaFromExports - ? handoff.config.hooks.getSchemaFromExports(moduleExports) - : moduleExports[exportKey]; - - return schema; - } catch (error) { - console.warn(`Failed to load schema from exports (${exportKey}):`, error); - return null; - } -}; - -/** - * Generates component properties using react-docgen-typescript - * @param entry - Path to the component/schema file - * @param handoff - Handoff instance for configuration - * @returns Generated properties or null if failed - */ -const generatePropertiesFromDocgen = async ( - entry: string, - handoff: Handoff -): Promise<{ [key: string]: SlotMetadata } | null> => { - try { - // Use root project's tsconfig.json - const tsconfigPath = path.resolve(handoff.workingPath, 'tsconfig.json'); - - // Check if tsconfig exists - if (!fs.existsSync(tsconfigPath)) { - console.warn(`TypeScript config not found at ${tsconfigPath}, using default configuration`); - } - - const parser = withCustomConfig(tsconfigPath, { - savePropValueAsString: true, - shouldExtractLiteralValuesFromEnum: true, - shouldRemoveUndefinedFromOptional: true, - propFilter: (prop) => { - if (prop.parent) { - return !prop.parent.fileName.includes('node_modules'); - } - return true; - }, - }); - - const docgenResults = parser.parse(entry); - - if (docgenResults.length > 0) { - const componentDoc = docgenResults[0]; - if (componentDoc.props && Object.keys(componentDoc.props).length > 0) { - return convertDocgenToProperties(Object.values(componentDoc.props)); - } - } - - return null; - } catch (error) { - console.warn(`Failed to generate docs with react-docgen-typescript for ${entry}:`, error); - return null; - } -}; - -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( - `${options.fn(this)}` - ); - } 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: `\n`, - script: `\n`, - properties: previewData.values || {}, - fields: ensureIds(data.properties), - title: data.title, - }); - - return await prettier.format(`${compiled}`, { 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'); - - // Determine properties using a hierarchical approach - let properties: { [key: string]: SlotMetadata } | null = null; - let Component: any = null; - - // Step 1: Handle separate schema file (if exists) - if (data.entries?.schema) { - const schemaPath = path.resolve(data.entries.schema); - const ext = path.extname(schemaPath); - - if (ext === '.ts' || ext === '.tsx') { - try { - const schemaMod = await buildAndEvaluateModule(schemaPath, handoff); - - // Get schema from exports.default (separate schema files export as default) - const schema = loadSchemaFromExports(schemaMod.exports, handoff, 'default'); - - if (isValidSchemaObject(schema)) { - // Valid schema object - convert to properties - if (handoff.config?.hooks?.schemaToProperties) { - properties = handoff.config.hooks.schemaToProperties(schema); - } - } else if (schema) { - // Schema exists but is not a valid schema object (e.g., type/interface) - // Use react-docgen-typescript to document the schema file - properties = await generatePropertiesFromDocgen(schemaPath, handoff); - } - } catch (error) { - console.warn(`Failed to load separate schema file ${schemaPath}:`, error); - } - } else { - console.warn(`Schema file has unsupported extension: ${ext}`); - } - } - - // Step 2: Load component and handle component-embedded schema (only if no separate schema) - if (!data.entries?.schema) { - try { - const mod = await buildAndEvaluateModule(entry, handoff); - Component = mod.exports.default; - - // Check for exported schema in component file (exports.schema) - const schema = loadSchemaFromExports(mod.exports, handoff, 'schema'); - - if (isValidSchemaObject(schema)) { - // Valid schema object - convert to properties - if (handoff.config?.hooks?.schemaToProperties) { - properties = handoff.config.hooks.schemaToProperties(schema); - } - } else if (schema) { - // Schema exists but is not a valid schema object (e.g., type/interface) - // Use react-docgen-typescript to document the schema - properties = await generatePropertiesFromDocgen(entry, handoff); - } else { - // No schema found - use react-docgen-typescript to analyze component props - properties = await generatePropertiesFromDocgen(entry, handoff); - } - } catch (error) { - console.warn(`Failed to load component file ${entry}:`, error); - } - } - - // Step 3: Load component for rendering (if not already loaded) - if (!Component) { - try { - const mod = await buildAndEvaluateModule(entry, handoff); - Component = mod.exports.default; - } catch (error) { - console.error(`Failed to load component for rendering: ${entry}`, error); - return; - } - } - - // Apply the determined properties - if (properties) { - data.properties = properties; - } - - 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'), ); - `; - - // 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 = ` - - - - - - - - - ${data.previews[key].title} - - -
${pretty}
- - `; - - 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), - })); +// Export the main plugins +export { handlebarsPreviewsPlugin } from './plugins/handlebars-previews'; +export { ssrRenderPlugin } from './plugins/ssr-render'; - build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ - path: resolveModule('react/jsx-runtime', searchDirs), - })); - }, - }; -} diff --git a/src/transformers/plugins/handlebars-previews.ts b/src/transformers/plugins/handlebars-previews.ts new file mode 100644 index 00000000..890e9f5b --- /dev/null +++ b/src/transformers/plugins/handlebars-previews.ts @@ -0,0 +1,190 @@ +import fs from 'fs-extra'; +import Handlebars from 'handlebars'; +import { Types as CoreTypes } from 'handoff-core'; +import path from 'path'; +import { Plugin } from 'vite'; +import Handoff from '../..'; +import { TransformComponentTokensResult } from '../preview/types'; +import { createHandlebarsContext, registerHandlebarsHelpers } from '../utils/handlebars'; +import { formatHtmlWithWrapper, trimPreview } from '../utils/html'; + +/** + * Preview data interface for Handlebars rendering + */ +interface PreviewRenderData { + values?: Record; + title?: string; +} + +/** + * Constants for the Handlebars previews plugin + */ +const PLUGIN_CONSTANTS = { + PLUGIN_NAME: 'vite-plugin-previews', + SCRIPT_ID: 'script', + DUMMY_EXPORT: 'export default {}', + INSPECT_SUFFIX: '-inspect', + OUTPUT_FORMAT: 'html', +} as const; + +/** + * Processes component instances from documentation and creates preview data + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + */ +function processComponentInstances( + componentData: TransformComponentTokensResult, + documentationComponents: CoreTypes.IDocumentationObject['components'] +): void { + if (documentationComponents[componentData.id]) { + for (const instance of documentationComponents[componentData.id].instances) { + const variationId = instance.id; + const instanceValues = Object.fromEntries(instance.variantProperties); + + componentData.previews[variationId] = { + title: variationId, + url: '', + values: instanceValues, + }; + } + } +} + +/** + * Renders a Handlebars template with the given preview data + * @param template - Handlebars template string + * @param componentData - Component transformation data + * @param previewData - Preview data to render + * @param injectFieldWrappers - Whether to inject field wrappers for inspection + * @returns Rendered HTML string + */ +async function renderHandlebarsTemplate( + template: string, + componentData: TransformComponentTokensResult, + previewData: PreviewRenderData, + injectFieldWrappers: boolean +): Promise { + // Register Handlebars helpers with current injection state + registerHandlebarsHelpers( + { id: componentData.id, properties: componentData.properties || {} }, + injectFieldWrappers + ); + + const context = createHandlebarsContext({ + id: componentData.id, + properties: componentData.properties || {}, + title: componentData.title + }, previewData); + + const compiled = Handlebars.compile(template)(context); + return await formatHtmlWithWrapper(compiled); +} + +/** + * Generates preview files for a component variation + * @param componentId - Component identifier + * @param previewKey - Preview variation key + * @param normalHtml - Normal mode HTML + * @param inspectHtml - Inspect mode HTML + * @param emitFile - Vite emitFile function + */ +function emitPreviewFiles( + componentId: string, + previewKey: string, + normalHtml: string, + inspectHtml: string, + emitFile: (file: { type: 'asset'; fileName: string; source: string }) => void +): void { + emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}.html`, + source: normalHtml, + }); + + emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}${PLUGIN_CONSTANTS.INSPECT_SUFFIX}.html`, + source: inspectHtml, + }); +} + +/** + * Handlebars previews plugin factory + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + * @param handoff - Handoff instance + * @returns Vite plugin for Handlebars previews + */ +export function handlebarsPreviewsPlugin( + componentData: TransformComponentTokensResult, + documentationComponents: CoreTypes.IDocumentationObject['components'], + handoff: Handoff +): Plugin { + return { + name: PLUGIN_CONSTANTS.PLUGIN_NAME, + apply: 'build', + resolveId(resolveId) { + if (resolveId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return resolveId; + } + }, + load(loadId) { + if (loadId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return PLUGIN_CONSTANTS.DUMMY_EXPORT; + } + }, + async generateBundle() { + const componentId = componentData.id; + const templatePath = path.resolve(componentData.entries.template); + const templateContent = await fs.readFile(templatePath, 'utf8'); + + // Ensure components object exists + if (!documentationComponents) { + documentationComponents = {}; + } + + // Process component instances from documentation + processComponentInstances(componentData, documentationComponents); + + const generatedPreviews: { [key: string]: string } = {}; + + // Generate previews for each variation + for (const previewKey in componentData.previews) { + const previewData = componentData.previews[previewKey]; + + // Render both normal and inspect modes + const normalModeHtml = await renderHandlebarsTemplate( + templateContent, + componentData, + previewData, + false + ); + + const inspectModeHtml = await renderHandlebarsTemplate( + templateContent, + componentData, + previewData, + true + ); + + // Emit preview files + emitPreviewFiles( + componentId, + previewKey, + normalModeHtml, + inspectModeHtml, + (file) => this.emitFile(file) + ); + + generatedPreviews[previewKey] = normalModeHtml; + componentData.previews[previewKey].url = `${componentId}-${previewKey}.html`; + } + + // Update component data with results + componentData.format = PLUGIN_CONSTANTS.OUTPUT_FORMAT; + componentData.preview = ''; + componentData.code = trimPreview(templateContent); + componentData.html = trimPreview(generatedPreviews[Object.keys(generatedPreviews)[0]]); + }, + }; +} diff --git a/src/transformers/plugins/index.ts b/src/transformers/plugins/index.ts new file mode 100644 index 00000000..b8ef5359 --- /dev/null +++ b/src/transformers/plugins/index.ts @@ -0,0 +1,13 @@ +/** + * Plugin exports for the transformers module + * + * This module provides a centralized interface for all Vite plugins + * used in the Handoff application. Each plugin is focused on a specific + * rendering approach and can be easily tested and maintained. + */ + +export { handlebarsPreviewsPlugin } from './handlebars-previews'; +export { ssrRenderPlugin } from './ssr-render'; + +// Re-export types for convenience +export type { PluginFactory } from '../types'; diff --git a/src/transformers/plugins/ssr-render.ts b/src/transformers/plugins/ssr-render.ts new file mode 100644 index 00000000..3c783e6c --- /dev/null +++ b/src/transformers/plugins/ssr-render.ts @@ -0,0 +1,284 @@ +import esbuild from 'esbuild'; +import fs from 'fs-extra'; +import { Types as CoreTypes } from 'handoff-core'; +import path from 'path'; +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; +import { Plugin, normalizePath } from 'vite'; +import Handoff from '../..'; +import { generatePropertiesFromDocgen } from '../docgen'; +import { SlotMetadata } from '../preview/component'; +import { TransformComponentTokensResult } from '../preview/types'; +import { DEFAULT_CLIENT_BUILD_CONFIG, createReactResolvePlugin } from '../utils/build'; +import { formatHtml, trimPreview } from '../utils/html'; +import { buildAndEvaluateModule } from '../utils/module'; +import { loadSchemaFromComponent, loadSchemaFromFile } from '../utils/schema-loader'; + +/** + * React component type for SSR rendering + */ +type ReactComponent = React.ComponentType; + +/** + * Constants for the SSR render plugin + */ +const PLUGIN_CONSTANTS = { + PLUGIN_NAME: 'vite-plugin-ssr-static-render', + SCRIPT_ID: 'script', + DUMMY_EXPORT: 'export default {}', + ROOT_ELEMENT_ID: 'root', + PROPS_SCRIPT_ID: '__APP_PROPS__', + INSPECT_SUFFIX: '-inspect', +} as const; + +/** + * Loads and processes component schema using hierarchical approach + * @param componentData - Component transformation data + * @param componentPath - Path to the component file + * @param handoff - Handoff instance + * @returns Tuple of [properties, component] or [null, null] if failed + */ +async function loadComponentSchemaAndModule( + componentData: TransformComponentTokensResult, + componentPath: string, + handoff: Handoff +): Promise<[{ [key: string]: SlotMetadata } | null, ReactComponent | null]> { + let properties: { [key: string]: SlotMetadata } | null = null; + let component: ReactComponent | null = null; + + // Step 1: Handle separate schema file (if exists) + if (componentData.entries?.schema) { + const schemaPath = path.resolve(componentData.entries.schema); + properties = await loadSchemaFromFile(schemaPath, handoff); + } + + // Step 2: Load component and handle component-embedded schema (only if no separate schema) + if (!componentData.entries?.schema) { + try { + const moduleExports = await buildAndEvaluateModule(componentPath, handoff); + component = moduleExports.exports.default; + + // Try to load schema from component exports + properties = await loadSchemaFromComponent(moduleExports.exports, handoff); + + // If no schema found, use react-docgen-typescript + if (!properties) { + properties = await generatePropertiesFromDocgen(componentPath, handoff); + } + } catch (error) { + console.warn(`Failed to load component file ${componentPath}:`, error); + } + } + + // Step 3: Load component for rendering (if not already loaded) + if (!component) { + try { + const moduleExports = await buildAndEvaluateModule(componentPath, handoff); + component = moduleExports.exports.default; + } catch (error) { + console.error(`Failed to load component for rendering: ${componentPath}`, error); + return [null, null]; + } + } + + return [properties, component]; +} + +/** + * Generates client-side hydration source code + * @param componentPath - Path to the component file + * @returns Client-side hydration source code + */ +function generateClientHydrationSource(componentPath: string): string { + return ` + import React from 'react'; + import { hydrateRoot } from 'react-dom/client'; + import Component from '${normalizePath(componentPath)}'; + + const raw = document.getElementById('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')?.textContent || '{}'; + const props = JSON.parse(raw); + hydrateRoot(document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}'), ); + `; +} + +/** + * Generates complete HTML document with SSR content and hydration + * @param componentId - Component identifier + * @param previewTitle - Title for the preview + * @param renderedHtml - Server-rendered HTML content + * @param clientJs - Client-side JavaScript bundle + * @param props - Component props as JSON + * @returns Complete HTML document + */ +function generateHtmlDocument( + componentId: string, + previewTitle: string, + renderedHtml: string, + clientJs: string, + props: any +): string { + return ` + + + + + + + + + ${previewTitle} + + +
${renderedHtml}
+ +`; +} + +/** + * SSR render plugin factory + * @param componentData - Component transformation data + * @param documentationComponents - Documentation components + * @param handoff - Handoff instance + * @returns Vite plugin for SSR rendering + */ +export function ssrRenderPlugin( + componentData: TransformComponentTokensResult, + documentationComponents: CoreTypes.IDocumentationObject['components'], + handoff: Handoff +): Plugin { + return { + name: PLUGIN_CONSTANTS.PLUGIN_NAME, + apply: 'build', + resolveId(resolveId) { + console.log('resolveId', resolveId); + if (resolveId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return resolveId; + } + }, + load(loadId) { + if (loadId === PLUGIN_CONSTANTS.SCRIPT_ID) { + return PLUGIN_CONSTANTS.DUMMY_EXPORT; + } + }, + async generateBundle(_, bundle) { + // Remove all JS chunks to prevent conflicts + for (const [fileName, chunkInfo] of Object.entries(bundle)) { + if (chunkInfo.type === 'chunk' && fileName.includes(PLUGIN_CONSTANTS.SCRIPT_ID)) { + delete bundle[fileName]; + } + } + + const componentId = componentData.id; + const componentPath = path.resolve(componentData.entries.template); + const componentSourceCode = fs.readFileSync(componentPath, 'utf8'); + + // Load component schema and module + const [schemaProperties, ReactComponent] = await loadComponentSchemaAndModule( + componentData, + componentPath, + handoff + ); + + if (!ReactComponent) { + console.error(`Failed to load React component for ${componentId}`); + return; + } + + // Apply schema properties if found + if (schemaProperties) { + componentData.properties = schemaProperties; + } + + // Ensure components object exists + if (!documentationComponents) { + documentationComponents = {}; + } + + const generatedPreviews: { [key: string]: string } = {}; + + // Process component instances from documentation + if (documentationComponents[componentId]) { + for (const instance of documentationComponents[componentId].instances) { + const variationId = instance.id; + const instanceValues = Object.fromEntries(instance.variantProperties); + + componentData.previews[variationId] = { + title: variationId, + url: '', + values: instanceValues, + }; + } + } + + let finalHtml = ''; + + // Generate previews for each variation + for (const previewKey in componentData.previews) { + const previewProps = componentData.previews[previewKey].values; + + // Server-side render the component + const serverRenderedHtml = ReactDOMServer.renderToString( + React.createElement(ReactComponent, { ...previewProps, block: { ...previewProps } }) + ); + const formattedHtml = await formatHtml(serverRenderedHtml); + + // Generate client-side hydration code + const clientHydrationSource = generateClientHydrationSource(componentPath); + + // Build client-side bundle + const clientBuildConfig = { + ...DEFAULT_CLIENT_BUILD_CONFIG, + stdin: { + contents: clientHydrationSource, + resolveDir: process.cwd(), + loader: 'tsx' as const, + }, + plugins: [createReactResolvePlugin(handoff.workingPath, handoff.modulePath)], + }; + + // Apply user's client build config hook if provided + const finalClientBuildConfig = handoff.config?.hooks?.clientBuildConfig + ? handoff.config.hooks.clientBuildConfig(clientBuildConfig) + : clientBuildConfig; + + const bundledClient = await esbuild.build(finalClientBuildConfig); + const clientBundleJs = bundledClient.outputFiles[0].text; + + // Generate complete HTML document + finalHtml = generateHtmlDocument( + componentId, + componentData.previews[previewKey].title, + formattedHtml, + clientBundleJs, + previewProps + ); + + // Emit preview files + this.emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}.html`, + source: finalHtml, + }); + + // TODO: remove this once we have a way to render inspect mode + this.emitFile({ + type: 'asset', + fileName: `${componentId}-${previewKey}${PLUGIN_CONSTANTS.INSPECT_SUFFIX}.html`, + source: finalHtml, + }); + + generatedPreviews[previewKey] = finalHtml; + componentData.previews[previewKey].url = `${componentId}-${previewKey}.html`; + } + + // Format final HTML and update component data + finalHtml = await formatHtml(finalHtml); + componentData.format = 'react'; + componentData.preview = ''; + componentData.code = trimPreview(componentSourceCode); + componentData.html = trimPreview(finalHtml); + }, + }; +} diff --git a/src/transformers/types.ts b/src/transformers/types.ts new file mode 100644 index 00000000..be7d6328 --- /dev/null +++ b/src/transformers/types.ts @@ -0,0 +1,86 @@ +import { Types as CoreTypes } from 'handoff-core'; +import { Plugin } from 'vite'; +import { SlotMetadata } from './preview/component'; +import { TransformComponentTokensResult } from './preview/types'; + +/** + * Configuration for react-docgen-typescript parser + */ +export interface DocgenParserConfig { + savePropValueAsString: boolean; + shouldExtractLiteralValuesFromEnum: boolean; + shouldRemoveUndefinedFromOptional: boolean; + propFilter: (prop: any) => boolean; +} + +/** + * Result from react-docgen-typescript parsing + */ +export interface DocgenResult { + props: { [key: string]: any }; + [key: string]: any; +} + +/** + * Module evaluation result + */ +export interface ModuleEvaluationResult { + exports: any; +} + +/** + * Build configuration for esbuild + */ +export interface BuildConfig { + entryPoints?: string[]; + stdin?: { + contents: string; + resolveDir: string; + loader: string; + }; + bundle: boolean; + write: boolean; + format: 'cjs' | 'esm'; + platform: 'node' | 'browser'; + jsx: 'automatic' | 'transform' | 'preserve'; + external?: string[]; + sourcemap?: boolean; + minify?: boolean; + plugins?: any[]; +} + +/** + * Plugin factory function signature + */ +export type PluginFactory = ( + data: TransformComponentTokensResult, + components: CoreTypes.IDocumentationObject['components'], + handoff: any +) => Plugin; + +/** + * Schema loading options + */ +export interface SchemaLoadOptions { + exportKey: 'default' | 'schema'; + handoff: any; +} + +/** + * Preview rendering options + */ +export interface PreviewRenderOptions { + inspect: boolean; + injectFieldWrappers: boolean; +} + +/** + * Handlebars template context + */ +export interface HandlebarsContext { + style: string; + script: string; + properties: any; + fields: { [key: string]: SlotMetadata }; + title: string; +} diff --git a/src/transformers/utils/build.ts b/src/transformers/utils/build.ts new file mode 100644 index 00000000..0e8feba1 --- /dev/null +++ b/src/transformers/utils/build.ts @@ -0,0 +1,75 @@ +import esbuild from 'esbuild'; +import path from 'path'; + +/** + * Default esbuild configuration for SSR builds + */ +export const DEFAULT_SSR_BUILD_CONFIG: esbuild.BuildOptions = { + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + jsx: 'automatic', + external: ['react', 'react-dom', '@opentelemetry/api'], +}; + +/** + * Default esbuild configuration for client builds + */ +export const DEFAULT_CLIENT_BUILD_CONFIG: esbuild.BuildOptions = { + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + jsx: 'automatic', + sourcemap: false, + minify: false, +}; + +/** + * Resolves a module from multiple search directories + * @param id - Module ID to resolve + * @param searchDirs - Array of directories to search in + * @returns Resolved module path + * @throws Error if module not found in any directory + */ +export 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')}`); +} + +/** + * Creates an esbuild plugin for resolving React modules + * @param workingPath - Working directory path + * @param handoffModulePath - Handoff module path + * @returns Esbuild plugin configuration + */ +export function createReactResolvePlugin(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), + })); + }, + }; +} diff --git a/src/transformers/utils/handlebars.ts b/src/transformers/utils/handlebars.ts new file mode 100644 index 00000000..3291a825 --- /dev/null +++ b/src/transformers/utils/handlebars.ts @@ -0,0 +1,67 @@ +import Handlebars from 'handlebars'; +import { SlotMetadata } from '../preview/component'; +import { HandlebarsContext } from '../types'; + +/** + * Registers common Handlebars helpers + * @param data - Component data containing properties + * @param injectFieldWrappers - Whether to inject field wrappers for inspection + */ +export const registerHandlebarsHelpers = ( + data: { properties: { [key: string]: SlotMetadata }; id: string }, + injectFieldWrappers: boolean +): void => { + // Field helper for property binding + Handlebars.registerHelper('field', function (field: string, options: any) { + if (injectFieldWrappers) { + if (!field) { + console.error(`Missing field declaration for ${data.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 ${data.id}`); + return options.fn(this); + } + + return new Handlebars.SafeString( + `${options.fn(this)}` + ); + } else { + return options.fn(this); + } + }); + + // Equality helper + Handlebars.registerHelper('eq', function (a: any, b: any) { + return a === b; + }); +}; + +/** + * Creates Handlebars template context + * @param data - Component data + * @param previewData - Preview data with values + * @returns Handlebars context object + */ +export const createHandlebarsContext = ( + data: { id: string; properties: { [key: string]: SlotMetadata }; title: string }, + previewData: { values?: any } +): HandlebarsContext => { + return { + style: `\n`, + script: `\n`, + properties: previewData.values || {}, + fields: data.properties, + title: data.title, + }; +}; diff --git a/src/transformers/utils/html.ts b/src/transformers/utils/html.ts new file mode 100644 index 00000000..74f06b2f --- /dev/null +++ b/src/transformers/utils/html.ts @@ -0,0 +1,31 @@ +import { parse } from 'node-html-parser'; +import prettier from 'prettier'; + +/** + * Trims HTML preview content by extracting body content + * @param preview - The HTML preview string + * @returns Trimmed HTML content + */ +export const trimPreview = (preview: string): string => { + const bodyEl = parse(preview).querySelector('body'); + const code = bodyEl ? bodyEl.innerHTML.trim() : preview; + return code; +}; + +/** + * Formats HTML content using Prettier + * @param html - The HTML content to format + * @returns Formatted HTML string + */ +export const formatHtml = async (html: string): Promise => { + return await prettier.format(html, { parser: 'html' }); +}; + +/** + * Formats HTML content with HTML wrapper using Prettier + * @param html - The HTML content to format + * @returns Formatted HTML string with HTML wrapper + */ +export const formatHtmlWithWrapper = async (html: string): Promise => { + return await prettier.format(`${html}`, { parser: 'html' }); +}; diff --git a/src/transformers/utils/index.ts b/src/transformers/utils/index.ts new file mode 100644 index 00000000..1105103a --- /dev/null +++ b/src/transformers/utils/index.ts @@ -0,0 +1,24 @@ +/** + * Utility exports for the transformers module + * + * This module provides organized utility functions for schema processing, + * HTML manipulation, build configuration, and other common operations. + */ + +// Schema utilities +export * from './schema'; + +// HTML utilities +export * from './html'; + +// Build utilities +export * from './build'; + +// Module utilities +export * from './module'; + +// Handlebars utilities +export * from './handlebars'; + +// Schema loader utilities +export * from './schema-loader'; diff --git a/src/transformers/utils/module.ts b/src/transformers/utils/module.ts new file mode 100644 index 00000000..12e57eaa --- /dev/null +++ b/src/transformers/utils/module.ts @@ -0,0 +1,36 @@ +import esbuild from 'esbuild'; +import { ModuleEvaluationResult } from '../types'; +import { DEFAULT_SSR_BUILD_CONFIG } from './build'; + +/** + * Builds and evaluates a module using esbuild + * @param entryPath - Path to the module entry point + * @param handoff - Handoff instance for configuration + * @returns Module evaluation result with exports + */ +export async function buildAndEvaluateModule( + entryPath: string, + handoff: any +): Promise { + // Default esbuild configuration + const defaultBuildConfig: esbuild.BuildOptions = { + ...DEFAULT_SSR_BUILD_CONFIG, + entryPoints: [entryPath], + }; + + // 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; +} diff --git a/src/transformers/utils/schema-loader.ts b/src/transformers/utils/schema-loader.ts new file mode 100644 index 00000000..e6dea063 --- /dev/null +++ b/src/transformers/utils/schema-loader.ts @@ -0,0 +1,73 @@ +import path from 'path'; +import { generatePropertiesFromDocgen } from '../docgen'; +import { SlotMetadata } from '../preview/component'; +import { buildAndEvaluateModule } from './module'; +import { isValidSchemaObject, loadSchemaFromExports } from './schema'; + +/** + * Loads and processes schema from a separate schema file + * @param schemaPath - Path to the schema file + * @param handoff - Handoff instance for configuration + * @returns Processed properties or null if failed + */ +export const loadSchemaFromFile = async ( + schemaPath: string, + handoff: any +): Promise<{ [key: string]: SlotMetadata } | null> => { + const ext = path.extname(schemaPath); + + if (ext !== '.ts' && ext !== '.tsx') { + console.warn(`Schema file has unsupported extension: ${ext}`); + return null; + } + + try { + const schemaMod = await buildAndEvaluateModule(schemaPath, handoff); + + // Get schema from exports.default (separate schema files export as default) + const schema = loadSchemaFromExports(schemaMod.exports, handoff, 'default'); + + if (isValidSchemaObject(schema)) { + // Valid schema object - convert to properties + if (handoff.config?.hooks?.schemaToProperties) { + return handoff.config.hooks.schemaToProperties(schema); + } + } else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema file + return await generatePropertiesFromDocgen(schemaPath, handoff); + } + + return null; + } catch (error) { + console.warn(`Failed to load separate schema file ${schemaPath}:`, error); + return null; + } +}; + +/** + * Loads and processes schema from component exports + * @param componentExports - Component module exports + * @param handoff - Handoff instance for configuration + * @returns Processed properties or null if failed + */ +export const loadSchemaFromComponent = async ( + componentExports: any, + handoff: any +): Promise<{ [key: string]: SlotMetadata } | null> => { + // Check for exported schema in component file (exports.schema) + const schema = loadSchemaFromExports(componentExports, handoff, 'schema'); + + if (isValidSchemaObject(schema)) { + // Valid schema object - convert to properties + if (handoff.config?.hooks?.schemaToProperties) { + return handoff.config.hooks.schemaToProperties(schema); + } + } else if (schema) { + // Schema exists but is not a valid schema object (e.g., type/interface) + // Use react-docgen-typescript to document the schema + return await generatePropertiesFromDocgen(componentExports.__filename || '', handoff); + } + + return null; +}; diff --git a/src/transformers/utils/schema.ts b/src/transformers/utils/schema.ts new file mode 100644 index 00000000..6f69a359 --- /dev/null +++ b/src/transformers/utils/schema.ts @@ -0,0 +1,99 @@ +import { SlotMetadata, SlotType } from '../preview/component'; + +/** + * Ensures all properties have proper IDs assigned recursively + * @param properties - The properties object to process + * @returns The properties object with IDs assigned + */ +export const ensureIds = (properties: { [key: string]: SlotMetadata }): { [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; +}; + +/** + * Converts react-docgen-typescript props to our SlotMetadata format + * @param docgenProps - Array of props from react-docgen-typescript + * @returns Converted properties object + */ +export const convertDocgenToProperties = (docgenProps: any[]): { [key: string]: SlotMetadata } => { + const properties: { [key: string]: SlotMetadata } = {}; + + for (const prop of docgenProps) { + const { name, type, required, description, defaultValue } = prop; + + // Convert react-docgen-typescript type to our SlotType enum + let propType = SlotType.TEXT; + if (type?.name === 'boolean') { + propType = SlotType.BOOLEAN; + } else if (type?.name === 'number') { + propType = SlotType.NUMBER; + } else if (type?.name === 'array') { + propType = SlotType.ARRAY; + } else if (type?.name === 'object') { + propType = SlotType.OBJECT; + } else if (type?.name === 'function') { + propType = SlotType.FUNCTION; + } else if (type?.name === 'enum') { + propType = SlotType.ENUM; + } + + properties[name] = { + id: name, + name: name, + description: description || '', + generic: '', + type: propType, + default: defaultValue?.value || undefined, + rules: { + required: required || false, + }, + }; + } + + return properties; +}; + +/** + * Validates if a schema object is valid for property conversion + * @param schema - The schema object to validate + * @returns True if schema is valid, false otherwise + */ +export const isValidSchemaObject = (schema: any): boolean => { + return schema && + typeof schema === 'object' && + schema.type === 'object' && + schema.properties && + typeof schema.properties === 'object'; +}; + +/** + * Safely loads schema from module exports + * @param moduleExports - The module exports object + * @param handoff - Handoff instance for configuration + * @param exportKey - The export key to look for ('default' or 'schema') + * @returns The schema object or null if not found/invalid + */ +export const loadSchemaFromExports = ( + moduleExports: any, + handoff: any, + exportKey: 'default' | 'schema' = 'default' +): any => { + try { + const schema = handoff.config?.hooks?.getSchemaFromExports + ? handoff.config.hooks.getSchemaFromExports(moduleExports) + : moduleExports[exportKey]; + + return schema; + } catch (error) { + console.warn(`Failed to load schema from exports (${exportKey}):`, error); + return null; + } +};