diff --git a/packages/react-wrappers/src/types.d.ts b/packages/react-wrappers/src/types.d.ts index 4b24f57..209ffed 100644 --- a/packages/react-wrappers/src/types.d.ts +++ b/packages/react-wrappers/src/types.d.ts @@ -3,6 +3,8 @@ import { BaseOptions } from "../../../tools/configurations"; import type { Attribute } from "custom-elements-manifest"; export interface Options extends BaseOptions { + /** Path to output directory */ + outdir?: (className: string, tagName: string) => string | string; /** Used to get a specific path for a given component */ modulePath?: (className: string, tagName: string) => string; /** Indicates if the component classes are a default export rather than a named export */ diff --git a/packages/react-wrappers/src/utils.ts b/packages/react-wrappers/src/utils.ts index 36b3881..b9d64f4 100644 --- a/packages/react-wrappers/src/utils.ts +++ b/packages/react-wrappers/src/utils.ts @@ -12,7 +12,7 @@ export function getPackageJson(): any { export function getModulePath( modulePath: ((className: string, tagName: string) => string) | undefined, component: Component, - outdir: string, + outdir: (className: string, tagName: string) => string | string, packageJson: any ) { if (modulePath instanceof Function) { @@ -25,10 +25,26 @@ export function getModulePath( ); } - const directories = outdir?.split("/"); + const outdirPath = + typeof outdir === "function" + ? outdir(component.name, component.tagName!) + : outdir; + const directories = outdirPath?.split("/"); return path.join(directories.map((_) => "../").join(""), packageJson.module); } +export function normalizeOutdir(outdir: any) { + if (typeof outdir === "function") { + return outdir; + } + if (typeof outdir === "string") { + return () => outdir; + } + throw new TypeError( + "The outdir property must be either a string or a function." + ); +} + export const createEventName = (event: any) => `on${toPascalCase(event.name)}`; export const RESERVED_WORDS = [ @@ -106,7 +122,11 @@ export function saveReactUtils(outdir: string, ssrSafe?: boolean) { const reactUtils = ` import { useEffect, useLayoutEffect } from "react"; -${ssrSafe ? `const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect` : ''} +${ + ssrSafe + ? `const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect` + : "" +} export function useProperties(targetElement, propName, value) { useEffect(() => { @@ -122,7 +142,7 @@ export function useProperties(targetElement, propName, value) { } export function useEventListener(targetElement, eventName, eventHandler) { - ${ssrSafe ? 'useIsomorphicLayoutEffect' : 'useLayoutEffect'}(() => { + ${ssrSafe ? "useIsomorphicLayoutEffect" : "useLayoutEffect"}(() => { if (eventHandler !== undefined) { targetElement?.current?.addEventListener(eventName, eventHandler); } @@ -144,7 +164,7 @@ export function useEventListener(targetElement, eventName, eventHandler) { export function saveScopeProvider(outdir: string, ssrSafe?: boolean) { const scopeProvider = ` -${ssrSafe ? '"use client"' : ''} +${ssrSafe ? '"use client"' : ""} import { createContext } from 'react'; import { jsx } from "react/jsx-runtime"; diff --git a/packages/react-wrappers/src/wrapper-generator.ts b/packages/react-wrappers/src/wrapper-generator.ts index aad394d..b53dfe7 100644 --- a/packages/react-wrappers/src/wrapper-generator.ts +++ b/packages/react-wrappers/src/wrapper-generator.ts @@ -11,10 +11,16 @@ import { createEventName, getModulePath, getPackageJson, + normalizeOutdir, saveReactUtils, saveScopeProvider, } from "./utils.js"; -import { createOutDir, logBlue, logYellow, saveFile } from "../../../tools/integrations/index.js"; +import { + createOutDir, + logBlue, + logYellow, + saveFile, +} from "../../../tools/integrations/index.js"; import { CEM, Component, @@ -30,9 +36,13 @@ import type { Parameter, } from "custom-elements-manifest"; import { has, toCamelCase } from "../../../tools/utilities/index.js"; +import path from "path"; +import { BaseOptions } from "../../../tools/configurations/index.js"; const packageJson = getPackageJson(); -let config: Options = {}; +let config: Options = { + outdir: () => `./react`, +}; let globalEvents: GlobalEvent[] = []; export function generateReactWrappers( @@ -43,20 +53,44 @@ export function generateReactWrappers( logYellow("[react-wrappers] - Skipped", options.hideLogs); return; } - logBlue( - "[react-wrappers] - Generating wrappers...", - options.hideLogs - ); + logBlue("[react-wrappers] - Generating wrappers...", options.hideLogs); updateConfig(options); + const components = getComponents(customElementsManifest, config.exclude); - createOutDir(config.outdir!); - saveReactUtils(config.outdir!, config.ssrSafe); - if (config.scopedTags) { - saveScopeProvider(config.outdir!, config.ssrSafe); + + const uniqueOutDirs = new Set( + components + .map((c) => { + const outdir = config.outdir; + return typeof outdir === "function" + ? outdir(c.name, c.tagName!) + : outdir; + }) + .filter((dir): dir is string => typeof dir === "string") + ); + + const commonRoot = getCommonRoot(Array.from(uniqueOutDirs)); + + if (!commonRoot) { + throw new Error( + "Common root directory could not be determined. Ensure all component directories have a common path." + ); } + createOutDir(commonRoot); + saveScopeProvider(commonRoot, config.ssrSafe); + components.forEach((component) => { + const componentOutDir = + typeof config.outdir === "function" + ? config.outdir(component.name, component.tagName!) + : config.outdir; + if (!componentOutDir) return; + + createOutDir(componentOutDir); + saveReactUtils(commonRoot, config.ssrSafe); + const events = getEventNames(component); const { booleanAttributes, attributes } = getAttributes(component); const properties = getProperties(component, attributes, booleanAttributes); @@ -67,12 +101,17 @@ export function generateReactWrappers( packageJson ); + const relativePathToCommonRoot = path + .relative(componentOutDir, commonRoot) + .replace(/\\/g, "/"); + generateReactWrapper( component, events, booleanAttributes, attributes, componentModulePath, + relativePathToCommonRoot, properties ); @@ -86,13 +125,18 @@ export function generateReactWrappers( ); }); - generateManifests(components, config.outdir!); - logBlue(`[react-wrappers] - Generated wrappers in "${config.outdir}".`, config.hideLogs); + generateManifests(components, config.outdir!, commonRoot); + + logBlue( + `[react-wrappers] - Generated wrappers in "${config.outdir}".`, + config.hideLogs + ); } function updateConfig(options: Options) { config = { - outdir: "./react", + ...config, + outdir: normalizeOutdir(options.outdir || config.outdir), exclude: [], typesSrc: "types", attributeMapping: {}, @@ -108,18 +152,31 @@ function generateReactWrapper( booleanAttributes: Attribute[], attributes: Attribute[], componentModulePath: string, + relativePathToCommonRoot: string, properties?: ClassField[] ) { + /** Ensure outdir is always treated as a function */ + const outdir = config.outdir!; + const componentOutDir = + typeof outdir === "function" + ? outdir(component.name, component.tagName!) + : outdir; + + /** Handle empty relative path case */ + const adjustedRelativePathToCommonRoot = + relativePathToCommonRoot === "" ? "." : relativePathToCommonRoot; + const result = getReactComponentTemplate( component, events, booleanAttributes, attributes, componentModulePath, + adjustedRelativePathToCommonRoot, properties ); - saveFile(config.outdir!, `${component.name}.js`, result, "typescript"); + saveFile(componentOutDir, `${component.name}.js`, result, "typescript"); } function generateTypeDefinition( @@ -127,7 +184,7 @@ function generateTypeDefinition( events: EventName[], booleanAttributes: Attribute[], attributes: Attribute[], - componentModulePath: string, + modulePath: string, properties?: ClassField[] ) { const result = getTypeDefinitionTemplate( @@ -135,26 +192,70 @@ function generateTypeDefinition( events, booleanAttributes, attributes, - componentModulePath, + modulePath, properties ); - saveFile(config.outdir!, `${component.name}.d.ts`, result, "typescript"); + const componentOutDir = + typeof config.outdir === "function" + ? config.outdir(component.name, component.tagName!) + : config.outdir; + saveFile(componentOutDir!, `${component.name}.d.ts`, result, "typescript"); } -function generateManifests(components: Component[], outdir: string) { - saveFile( - outdir, - "index.js", - getManifestContentTemplate(components), - "typescript" - ); - saveFile( +function generateManifests( + components: Component[], + outdir: (className: string, tagName: string) => string | string, + commonRoot: string +) { + const uniqueOutDirs = new Set(); + + components.forEach((component) => { + const componentOutDir = + typeof outdir === "function" + ? outdir(component.name, component.tagName!) + : outdir; + uniqueOutDirs.add(componentOutDir); + }); + + createOutDir(commonRoot); + + const manifestContent = getManifestContentTemplate( + components, outdir, - "index.d.ts", - getManifestContentTemplate(components), - "typescript" + commonRoot ); + + saveFile(commonRoot, "index.js", manifestContent, "typescript"); + saveFile(commonRoot, "index.d.ts", manifestContent, "typescript"); +} + +function getCommonRoot(dirs: string[]): string { + if (!dirs.length) return "./react"; + + const normalizedDirs = dirs.map((dir) => path.normalize(dir)); + const splitDirs = normalizedDirs.map((dir) => dir.split(path.sep)); + const minLength = Math.min(...splitDirs.map((split) => split.length)); + const commonRootSegments: string[] = []; + + for (let i = 0; i < minLength; i++) { + const segment = splitDirs[0][i]; + if (splitDirs.every((split) => split[i] === segment)) { + commonRootSegments.push(segment); + logBlue(`Common segment '${segment}' found at index ${i}`); + } else { + logBlue(`Segment mismatch at index ${i}, stopping`); + break; + } + } + + let commonRoot = commonRootSegments.join(path.sep); + if (!commonRoot) { + commonRoot = "./react"; + } + + logBlue(`Calculated common root: ${commonRoot}`); + return commonRoot; } function getProperties( @@ -311,6 +412,7 @@ function getReactComponentTemplate( booleanAttributes: MappedAttribute[], attributes: MappedAttribute[], modulePath: string, + relativePathToCommonRoot: string, properties?: ClassField[] ) { const eventTemplates = getEventTemplates(events); @@ -325,26 +427,30 @@ function getReactComponentTemplate( return ` ${config.ssrSafe ? '"use client"' : ""} import React, { forwardRef, useImperativeHandle ${ - useEffect ? ", useRef, useEffect" : "" - } ${config.scopedTags ? ", useContext" : ""} } from "react"; + config.scopedTags ? ", useContext" : "" + } ${useEffect ? ", useRef, useEffect" : ""} } from "react"; ${!config.ssrSafe ? `import '${modulePath}';` : ""} ${ - has(eventTemplates) || has(propTemplates) - ? `import { - ${has(eventTemplates) ? "useEventListener," : ""} - ${has(propTemplates) ? "useProperties" : ""} - } from './react-utils.js';` + config.scopedTags + ? `import { ScopeContext } from "${relativePathToCommonRoot}/ScopeProvider.js";` : "" } ${ - config.scopedTags - ? 'import { ScopeContext } from "./ScopeProvider.js";' + has(eventTemplates) || has(propTemplates) + ? `import { + ${has(eventTemplates) ? "useEventListener," : ""} + ${has(propTemplates) ? "useProperties" : ""} + } from '${relativePathToCommonRoot}/react-utils.js';` : "" } export const ${component.name} = forwardRef((props, forwardedRef) => { ${useEffect ? `const ref = useRef(null);` : ""} - ${has(unusedProps) ? `const { ${unusedProps.join(", ")}, ...filteredProps } = props;` : ''} + ${ + has(unusedProps) + ? `const { ${unusedProps.join(", ")}, ...filteredProps } = props;` + : "" + } ${config.scopedTags ? "const scope = useContext(ScopeContext);" : ""} ${ @@ -358,7 +464,6 @@ function getReactComponentTemplate( : "" } - ${has(eventTemplates) ? "/** Event listeners - run once */" : ""} ${eventTemplates?.join("") || ""} @@ -393,6 +498,18 @@ function getReactComponentTemplate( `; } +function convertOptionsToBaseOptions(options: Options): BaseOptions { + return { + ...options, + outdir: + typeof options.outdir === "string" + ? options.outdir + : options.outdir + ? options.outdir("", "") + : undefined, + }; +} + function getTypeDefinitionTemplate( component: Component, events: EventName[], @@ -409,6 +526,7 @@ function getTypeDefinitionTemplate( properties ); const eventTypes = getCustomEventTypes(component); + const baseConfig = convertOptionsToBaseOptions(config); return ` import { @@ -428,7 +546,7 @@ function getTypeDefinitionTemplate( } /** - ${getComponentDetailsTemplate(component, config, true)} + ${getComponentDetailsTemplate(component, baseConfig, true)} */ export const ${component.name}: React.ForwardRefExoticComponent<${ component.name @@ -583,17 +701,31 @@ function getGlobalEventPropsTemplate(events: GlobalEvent[] | undefined) { ); } -function getManifestContentTemplate(components: Component[]) { +function getManifestContentTemplate( + components: Component[], + outdir: ((className: string, tagName: string) => string) | string, + commonRoot: string +) { + const resolveOutDir = typeof outdir === "function" ? outdir : () => outdir; + let exports = components - .map((component) => `export * from './${component.name}.js';`) - .join(""); + .map((component) => { + const componentOutDir = resolveOutDir(component.name, component.tagName!); + let relativePath = path.relative(commonRoot, componentOutDir).replace(/\\/g, "/"); + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}`; + } + return `export * from '${relativePath}/${component.name}.js';`; + }) + .join("\n"); if (config.scopedTags) { - exports += ` - export * from "./ScopeProvider.js"; - `; + exports += `\nexport * from './ScopeProvider.js';`; } + // Remove any duplicate slashes in the path + exports = exports.replace(/\/\//g, '/'); + return exports; }