import { readFileSync } from 'node:fs'; import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; // ── CEM type shapes ────────────────────────────────────────────────────────── type CEMType = { text?: string }; type CEMEvent = { name?: string; type?: CEMType; description?: string }; type CEMSuperclass = { name?: string; package?: string }; type CEMMember = { name?: string; fieldName?: string; attribute?: string | null; kind?: string; privacy?: string; type?: CEMType; }; type CEMDeclaration = { name?: string; customElement?: boolean; tagName?: string; superclass?: CEMSuperclass; members?: CEMMember[]; attributes?: CEMMember[]; events?: CEMEvent[]; }; type CEMModule = { path?: string; declarations?: CEMDeclaration[] }; type CEMManifest = { modules?: CEMModule[] }; type CEMElementEntry = { declaration: CEMDeclaration; modulePath: string }; // ── Type import state ──────────────────────────────────────────────────────── type TypeImportState = { importsByIdentifier: Map; ambiguousIdentifiers: Set; usedImports: Map>; wildcardExportModules: Set; }; // ── Public API return type ─────────────────────────────────────────────────── type GenerateResult = { generated: true; path: string } | { generated: false; reason: string }; // ── Constants ──────────────────────────────────────────────────────────────── const PRIMITIVE_UNION_REGEX = /^(?:\s*(?:string|number|boolean|bigint|null|undefined|unknown|any|void|'[^']*'|"[^"]*"|`[^`]*`|(?:\d+(?:\.\d+)?))\s*)(?:\|\s*(?:string|number|boolean|bigint|null|undefined|unknown|any|void|'[^']*'|"[^"]*"|`[^`]*`|(?:\d+(?:\.\d+)?))\s*)*$/; const IDENTIFIER_TOKEN_REGEX = /[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*/g; const PRIMITIVE_TOKENS = new Set([ 'true', 'false', 'null', 'undefined', 'string', 'number', 'boolean', 'bigint', 'symbol', 'unknown', 'any', 'void', 'never', ]); const KNOWN_TYPE_NAMES = new Set([ 'Array', 'ReadonlyArray', 'Promise', 'Record', 'Partial', 'Required', 'Pick', 'Omit', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Date', 'RegExp', 'Error', 'Node', 'Element', 'HTMLElement', 'SVGElement', 'Event', 'CustomEvent', 'MouseEvent', 'KeyboardEvent', 'FocusEvent', 'InputEvent', 'PointerEvent', 'WheelEvent', 'DragEvent', 'SubmitEvent', 'AbortSignal', 'DOMRect', 'Document', 'Window', 'URL', 'URLSearchParams', 'CSSStyleDeclaration', 'Intl.Locale', ]); /** * DOM event classes that map directly to `(event: T) => void`. * Excludes bare `CustomEvent` which needs special handling for its detail type. */ const DOM_EVENT_CLASS_NAMES = new Set([ 'Event', 'MouseEvent', 'KeyboardEvent', 'FocusEvent', 'InputEvent', 'PointerEvent', 'WheelEvent', 'DragEvent', 'SubmitEvent', ]); /** * React DOM reserves these `on*` prop names for native/synthetic events. When a CEM event * name maps to the same handler (e.g. `click` → `onClick`), we must not emit a duplicate * wrapper prop or it clashes with React's built-in typings. * * Resolved at startup from the consumer project's `@types/react/index.d.ts` so the set stays * current without manual maintenance. Falls back to a static snapshot when `@types/react` is * not installed (e.g. JS-only consumers — in that case the exclusion set is irrelevant anyway). */ function loadReactEventHandlerNames(): Set { try { const typesPath = require.resolve('@types/react/index.d.ts'); const content = readFileSync(typesPath, 'utf-8'); const names = new Set(); for (const m of content.matchAll(/\b(on[A-Z][a-zA-Z]+)\??\s*:/g)) { names.add(m[1]); } // oxlint-disable-next-line no-magic-numbers -- 20 is a sanity threshold for react event names if (names.size > 20) return names; } catch {} // Static snapshot — kept as fallback only. return new Set([ 'onCopy', 'onCut', 'onPaste', 'onCompositionEnd', 'onCompositionStart', 'onCompositionUpdate', 'onFocus', 'onBlur', 'onChange', 'onBeforeInput', 'onInput', 'onReset', 'onSubmit', 'onInvalid', 'onLoad', 'onError', 'onKeyDown', 'onKeyPress', 'onKeyUp', 'onAbort', 'onCanPlay', 'onCanPlayThrough', 'onDurationChange', 'onEmptied', 'onEncrypted', 'onEnded', 'onLoadedData', 'onLoadedMetadata', 'onLoadStart', 'onPause', 'onPlay', 'onPlaying', 'onProgress', 'onRateChange', 'onResize', 'onSeeked', 'onSeeking', 'onStalled', 'onSuspend', 'onTimeUpdate', 'onVolumeChange', 'onWaiting', 'onAuxClick', 'onClick', 'onContextMenu', 'onDoubleClick', 'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit', 'onDragLeave', 'onDragOver', 'onDragStart', 'onDrop', 'onMouseDown', 'onMouseEnter', 'onMouseLeave', 'onMouseMove', 'onMouseOut', 'onMouseOver', 'onMouseUp', 'onSelect', 'onTouchCancel', 'onTouchEnd', 'onTouchMove', 'onTouchStart', 'onPointerOver', 'onPointerEnter', 'onPointerDown', 'onPointerMove', 'onPointerUp', 'onPointerCancel', 'onPointerOut', 'onPointerLeave', 'onGotPointerCapture', 'onLostPointerCapture', 'onScroll', 'onWheel', 'onAnimationStart', 'onAnimationEnd', 'onAnimationIteration', 'onTransitionEnd', 'onToggle', ]); } const REACT_NATIVE_EVENT_HANDLER_NAMES = loadReactEventHandlerNames(); /** * Emitted into every react.d.ts. * onChange/onInput use method signatures for bivariant parameter checking so both * native Event and CustomEvent callbacks are accepted without cast. */ const HELPER_TYPES = `\ /** @internal Maps a web component class to its public props only. * keyof T skips private/protected members, so this avoids the TS error * "property may not be private or protected" on exported anonymous types. */ type PublicOf = { [K in keyof T]?: T[K] }; /** @internal Safe React HTML attributes for web component wrappers. * onChange/onInput use method signatures for bivariant parameter checking so both * native Event and CustomEvent callbacks are accepted. */ interface HTMLWCProps extends React.AriaAttributes { className?: string; style?: React.CSSProperties; id?: string; slot?: string; tabIndex?: number; dir?: string; lang?: string; title?: string; onClick?: React.MouseEventHandler; onDoubleClick?: React.MouseEventHandler; onContextMenu?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; onMouseDown?: React.MouseEventHandler; onMouseUp?: React.MouseEventHandler; onMouseMove?: React.MouseEventHandler; onKeyDown?: React.KeyboardEventHandler; onKeyUp?: React.KeyboardEventHandler; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; onScroll?: React.UIEventHandler; onWheel?: React.WheelEventHandler; onChange?(e: Event): void; onInput?(e: Event): void; } `; // ── String utilities ───────────────────────────────────────────────────────── function normalizeWhitespace(value: string): string { return value.replace(/\s+/g, ' ').trim(); } function normalizePropertyName(name: string): string | null { let s = name.trim(); if (!s) return null; const bracketQuoted = s.match(/^\[\s*['"](.+?)['"]\s*\]$/); if (bracketQuoted) { s = bracketQuoted[1]; } else { const bracket = s.match(/^\[\s*(.+?)\s*\]$/); if (bracket) s = bracket[1]; const quoted = s.match(/^['"](.+?)['"]$/); if (quoted) s = quoted[1]; } s = s.trim(); if (!s || s.includes('\n') || s.includes('\r')) return null; return s; } function toPascalCase(value: string): string { return value .split(/[^a-zA-Z0-9]+/) .filter(Boolean) .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) .join(''); } // ── Type resolution utilities ───────────────────────────────────────────────── function isPrimitiveToken(token: string): boolean { return PRIMITIVE_TOKENS.has(token); } function isKnownTypeIdentifier(identifier: string): boolean { return KNOWN_TYPE_NAMES.has(identifier) || identifier.startsWith('globalThis.'); } function getIdentifierTokens(typeText: string): string[] { return typeText.match(IDENTIFIER_TOKEN_REGEX) ?? []; } function createTypeImportState(): TypeImportState { return { importsByIdentifier: new Map(), ambiguousIdentifiers: new Set(), usedImports: new Map(), wildcardExportModules: new Set(), }; } function registerTypeImport( state: TypeImportState, identifier: string, moduleSpecifier: string, ): void { if (state.ambiguousIdentifiers.has(identifier)) return; const existing = state.importsByIdentifier.get(identifier); if (existing && existing !== moduleSpecifier) { state.importsByIdentifier.delete(identifier); state.ambiguousIdentifiers.add(identifier); return; } if (!existing) { state.importsByIdentifier.set(identifier, moduleSpecifier); } } function trackImportedIdentifierUsage( state: TypeImportState, identifier: string, moduleSpecifier: string, ): void { if (!state.usedImports.has(moduleSpecifier)) { state.usedImports.set(moduleSpecifier, new Set()); } state.usedImports.get(moduleSpecifier)!.add(identifier); } function isBareModuleSpecifier(moduleSpecifier: string): boolean { return !!moduleSpecifier && !moduleSpecifier.startsWith('.') && !moduleSpecifier.startsWith('/'); } function canUseComplexType(typeText: string, typeImportState?: TypeImportState): boolean { if (!typeText) return false; if (/[{};=]/.test(typeText) || /=>/.test(typeText)) return false; if (!/^[A-Za-z0-9_$<>[\]()|&,.?'"`\s:-]+$/.test(typeText)) return false; for (const token of getIdentifierTokens(typeText)) { if (isPrimitiveToken(token) || isKnownTypeIdentifier(token)) continue; const root = token.split('.')[0]; if ( typeImportState && !typeImportState.ambiguousIdentifiers.has(root) && typeImportState.importsByIdentifier.has(root) ) { trackImportedIdentifierUsage( typeImportState, root, typeImportState.importsByIdentifier.get(root)!, ); continue; } return false; } return true; } function toSafeType(typeText?: string, typeImportState?: TypeImportState): string { if (!typeText) return 'unknown'; const normalized = normalizeWhitespace(typeText); if (!normalized) return 'unknown'; if (PRIMITIVE_UNION_REGEX.test(normalized)) return normalized; // oxlint-disable-next-line no-magic-numbers -- -2 strips trailing '[]' if (normalized.endsWith('[]') && PRIMITIVE_UNION_REGEX.test(normalized.slice(0, -2).trim())) { return normalized; } return canUseComplexType(normalized, typeImportState) ? normalized : 'unknown'; } function extractDetailTypeFromDescription(description?: string): string | undefined { return description?.match(/detail:\s*`([^`]+)`/)?.[1]?.trim(); } /** * Maps a CEM event type string to a TypeScript handler signature. * * - Known DOM event classes (Event, MouseEvent, FocusEvent, etc.) → `(event: T) => void` * - `CustomEvent` → `(event: CustomEvent) => void` * - Bare `CustomEvent` or unresolvable types → `(event: CustomEvent) => void` */ function toEventHandlerType( typeText?: string, typeImportState?: TypeImportState, description?: string, ): string { if (typeText) { const normalized = typeText.trim(); // Known DOM event classes map directly — not wrapped as CustomEvent detail. if (DOM_EVENT_CLASS_NAMES.has(normalized)) { return `(event: ${normalized}) => void`; } if (normalized.startsWith('CustomEvent<')) { const match = normalized.match(/^CustomEvent<(.+)>$/); if (match) { const detailType = toSafeType(match[1]?.trim(), typeImportState); return `(event: CustomEvent<${detailType}>) => void`; } } // For any other resolvable non-DOM type, treat it as the CustomEvent detail payload. if (normalized !== 'CustomEvent') { const safeType = toSafeType(normalized, typeImportState); if (safeType !== 'unknown') { return `(event: CustomEvent<${safeType}>) => void`; } } } const detailFromDesc = extractDetailTypeFromDescription(description); if (detailFromDesc) { return `(event: CustomEvent<${toSafeType(detailFromDesc, typeImportState)}>) => void`; } return '(event: CustomEvent) => void'; } // ── Path helpers ───────────────────────────────────────────────────────────── function getCEMManifestPath(cwd: string, packageJson: Record): string { if (typeof packageJson.customElements === 'string' && packageJson.customElements.trim()) { return resolve(cwd, packageJson.customElements); } return resolve(cwd, 'dist/custom-elements.json'); } // CEM paths are relative to src/ (e.g. "src/entities/entities.ts"). // react.mjs/cjs live in dist/ while compiled JS lives in dist/esm/. function cemModulePathToJsImport(modulePath: string): string { // oxlint-disable no-magic-numbers -- numeric offsets for known file extension lengths let p = modulePath.startsWith('src/') ? modulePath.slice(4) : modulePath; if (p.endsWith('.tsx')) p = `${p.slice(0, -4)}.js`; else if (p.endsWith('.ts')) p = `${p.slice(0, -3)}.js`; // oxlint-enable no-magic-numbers return `./esm/${p}`; } function cemModulePathToDtsImport(modulePath: string): string { // oxlint-disable-next-line no-magic-numbers -- 4 = 'src/' prefix length const p = modulePath.startsWith('src/') ? modulePath.slice(4) : modulePath; const lastDot = p.lastIndexOf('.'); return `./${lastDot !== -1 ? p.slice(0, lastDot) : p}`; } // ── CEM traversal ───────────────────────────────────────────────────────────── function collectCustomElements(manifest: CEMManifest): CEMElementEntry[] { const elements: CEMElementEntry[] = []; for (const mod of manifest.modules ?? []) { const modulePath = mod.path ?? ''; for (const decl of mod.declarations ?? []) { if (decl.customElement && decl.tagName) { elements.push({ declaration: decl, modulePath }); } } } return elements; } function mergeUniqueByKey(base: T[], extra: T[], getKey: (item: T) => string | null): T[] { const merged = [...base]; const knownKeys = new Set(base.map(getKey).filter((k): k is string => !!k)); for (const item of extra) { const key = getKey(item); if (!key || !knownKeys.has(key)) { merged.push(item); if (key) knownKeys.add(key); } } return merged; } function mergeDeclarationMetadata(base: CEMDeclaration, inherited: CEMDeclaration): CEMDeclaration { const attrKey = (a: CEMMember) => normalizePropertyName(a.fieldName ?? a.name ?? ''); const nameKey = (m: CEMMember) => normalizePropertyName(m.name ?? ''); const eventKey = (e: CEMEvent) => normalizePropertyName(e.name ?? ''); return { ...base, attributes: mergeUniqueByKey(base.attributes ?? [], inherited.attributes ?? [], attrKey), members: mergeUniqueByKey(base.members ?? [], inherited.members ?? [], nameKey), events: mergeUniqueByKey(base.events ?? [], inherited.events ?? [], eventKey), }; } function createDeclarationLookup(manifest: CEMManifest): { byTagAndName: Map; byTag: Map; } { const byTagAndName = new Map(); const byTag = new Map(); for (const mod of manifest.modules ?? []) { for (const decl of mod.declarations ?? []) { if (!decl.customElement || !decl.tagName) continue; if (decl.name) byTagAndName.set(`${decl.tagName}::${decl.name}`, decl); if (!byTag.has(decl.tagName)) byTag.set(decl.tagName, decl); } } return { byTagAndName, byTag }; } async function mergeFastInheritanceFromManifest( cwd: string, entries: CEMElementEntry[], ): Promise { const fastManifestPath = resolve(cwd, 'ms-fast-components/custom-elements.json'); if (!(await fileExists(fastManifestPath))) return entries; const fastManifest = JSON.parse(await readFile(fastManifestPath, 'utf8')) as CEMManifest; const { byTagAndName, byTag } = createDeclarationLookup(fastManifest); return entries.map((entry) => { const { declaration } = entry; if (!declaration.tagName || declaration.superclass?.package !== '@microsoft/fast-components') { return entry; } const inherited = byTagAndName.get(`${declaration.tagName}::${declaration.name ?? ''}`) ?? byTagAndName.get(`${declaration.tagName}::${declaration.superclass?.name ?? ''}`) ?? byTag.get(declaration.tagName); return inherited ? { ...entry, declaration: mergeDeclarationMetadata(declaration, inherited) } : entry; }); } function createFoundationDeclarationLookup(manifest: CEMManifest): { byTag: Map; byName: Map; } { const byTag = new Map(); const byName = new Map(); for (const mod of manifest.modules ?? []) { for (const decl of mod.declarations ?? []) { if (decl.name) byName.set(decl.name, decl); if (decl.customElement && decl.tagName) byTag.set(decl.tagName, decl); } } return { byTag, byName }; } async function resolveDependencyManifestPath( cwd: string, packageName: string, ): Promise { const packageRoot = resolve(cwd, 'node_modules', packageName); const packageJsonPath = resolve(packageRoot, 'package.json'); if (!(await fileExists(packageJsonPath))) return null; const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as Record< string, unknown >; if (typeof packageJson.customElements === 'string' && packageJson.customElements.trim()) { const manifestPath = resolve(packageRoot, packageJson.customElements); if (await fileExists(manifestPath)) return manifestPath; } const distManifestPath = resolve(packageRoot, 'dist/custom-elements.json'); if (await fileExists(distManifestPath)) return distManifestPath; const rootManifestPath = resolve(packageRoot, 'custom-elements.json'); if (await fileExists(rootManifestPath)) return rootManifestPath; return null; } function findFoundationInheritedDeclaration( declaration: CEMDeclaration, lookup: ReturnType, ): CEMDeclaration | undefined { if (declaration.tagName) { const byTag = lookup.byTag.get(declaration.tagName); if (byTag) return byTag; } const superclassName = declaration.superclass?.name; if (!superclassName) return undefined; const byName = lookup.byName.get(superclassName); if (byName) return byName; const normalized = superclassName.replace(/^Foundation(?=[A-Z])/, '').replace(/^foundation/, ''); if (normalized === superclassName) return undefined; for (const [name, decl] of lookup.byName) { if (decl.customElement && name.toLowerCase() === normalized.toLowerCase()) { return decl; } } return undefined; } async function mergeFoundationInheritanceFromManifest( cwd: string, entries: CEMElementEntry[], ): Promise { const foundationManifestPath = await resolveDependencyManifestPath( cwd, '@genesislcap/foundation-ui', ); if (!foundationManifestPath) return entries; const foundationManifest = JSON.parse( await readFile(foundationManifestPath, 'utf8'), ) as CEMManifest; const lookup = createFoundationDeclarationLookup(foundationManifest); return entries.map((entry) => { const { declaration } = entry; if (!declaration.tagName || declaration.superclass?.package !== '@genesislcap/foundation-ui') { return entry; } const inherited = findFoundationInheritedDeclaration(declaration, lookup); return inherited ? { ...entry, declaration: mergeDeclarationMetadata(declaration, inherited) } : entry; }); } // ── Wrapper event helpers ───────────────────────────────────────────────────── function buildWrapperEventEntries( declaration: CEMDeclaration, ): Array<{ handlerName: string; eventName: string }> { const result: Array<{ handlerName: string; eventName: string }> = []; const seen = new Set(); for (const event of declaration.events ?? []) { if (!event.name) continue; const normalized = normalizePropertyName(event.name); if (!normalized) continue; const handlerName = `on${toPascalCase(normalized)}`; if (!handlerName || REACT_NATIVE_EVENT_HANDLER_NAMES.has(handlerName) || seen.has(handlerName)) continue; seen.add(handlerName); result.push({ handlerName, eventName: event.name }); } return result; } function groupEntriesByPath(entries: CEMElementEntry[]): Map { const byPath = new Map(); for (const entry of entries) { if (!byPath.has(entry.modulePath)) byPath.set(entry.modulePath, []); byPath.get(entry.modulePath)!.push(entry); } return byPath; } function cloneTypeImportStateForWrapper(original: TypeImportState): TypeImportState { return { importsByIdentifier: new Map(original.importsByIdentifier), ambiguousIdentifiers: new Set(original.ambiguousIdentifiers), usedImports: new Map(), wildcardExportModules: new Set(original.wildcardExportModules), }; } // ── Code generation ─────────────────────────────────────────────────────────── function renderImportLines(usedImports: Map>): string[] { return [...usedImports.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([spec, ids]) => `import type { ${[...ids].sort().join(', ')} } from '${spec}';`); } function generateReactWrapperJs(entries: CEMElementEntry[], format: 'esm' | 'cjs'): string { const valid = entries.filter((e) => e.declaration.name && e.declaration.tagName && e.modulePath); if (!valid.length) return ''; const esm = format === 'esm'; const lines: string[] = [ '/**', ' * AUTO-GENERATED FILE - DO NOT EDIT.', ' * Generated from custom-elements manifest.', ' */', '', ]; if (!esm) lines.push("'use strict';", ''); if (esm) { lines.push("import React from 'react';"); } else { lines.push("const React = require('react');"); } for (const [modulePath, pathEntries] of [...groupEntriesByPath(valid).entries()].sort()) { const sorted = [...pathEntries].sort((a, b) => a.declaration.name!.localeCompare(b.declaration.name!), ); const jsPath = cemModulePathToJsImport(modulePath); if (esm) { lines.push( `import { ${sorted.map((e) => `${e.declaration.name} as ${e.declaration.name}WC`).join(', ')} } from '${jsPath}';`, ); } else { lines.push( `const { ${sorted.map((e) => `${e.declaration.name}: ${e.declaration.name}WC`).join(', ')} } = require('${jsPath}');`, ); } } const anyHasEvents = valid.some((e) => buildWrapperEventEntries(e.declaration).length > 0); if (anyHasEvents) { lines.push( '', 'function _mergeRefs(...refs) {', ' return (value) => {', ' for (const ref of refs) {', " if (typeof ref === 'function') ref(value);", ' else if (ref != null) ref.current = value;', ' }', ' };', '}', ); } lines.push(''); for (const { declaration } of valid) { const name = declaration.name!; const tagName = declaration.tagName!; const events = buildWrapperEventEntries(declaration); const prefix = esm ? 'export const' : 'const'; lines.push(`${prefix} ${name} = React.forwardRef(function ${name}(props, ref) {`); if (events.length) { lines.push( ` const { ${events.map((e) => e.handlerName).join(', ')}, children, ...rest } = props;`, ); lines.push(' const _innerRef = React.useRef(null);'); for (const { handlerName } of events) { lines.push(` const _${handlerName}Ref = React.useRef(${handlerName});`); lines.push(` _${handlerName}Ref.current = ${handlerName};`); } lines.push(' React.useLayoutEffect(() => {'); lines.push(' const el = _innerRef.current;'); lines.push(' if (!el) return;'); for (const { handlerName, eventName } of events) { lines.push(` const _${handlerName}Fn = (e) => _${handlerName}Ref.current?.(e);`); lines.push(` el.addEventListener('${eventName}', _${handlerName}Fn);`); } lines.push(' return () => {'); for (const { handlerName, eventName } of events) { lines.push(` el.removeEventListener('${eventName}', _${handlerName}Fn);`); } lines.push(' };'); lines.push(' }, []);'); lines.push( ` return React.createElement(customElements.getName(${name}WC) ?? '${tagName}', { ...rest, ref: _mergeRefs(_innerRef, ref) }, children);`, ); } else { lines.push(' const { children, ...rest } = props;'); lines.push( ` return React.createElement(customElements.getName(${name}WC) ?? '${tagName}', { ...rest, ref }, children);`, ); } lines.push('});'); lines.push(''); } if (!esm) { lines.push('module.exports = {'); for (const { declaration } of valid) lines.push(` ${declaration.name},`); lines.push('};', ''); } return lines.join('\n'); } function generateReactWrapperDts( entries: CEMElementEntry[], typeImportState: TypeImportState, ): string { const valid = entries.filter((e) => e.declaration.name && e.declaration.tagName && e.modulePath); if (!valid.length) return ''; const wrapperTypeState = cloneTypeImportStateForWrapper(typeImportState); const classImports: string[] = []; for (const [modulePath, pathEntries] of [...groupEntriesByPath(valid).entries()].sort()) { const names = [...pathEntries] .sort((a, b) => a.declaration.name!.localeCompare(b.declaration.name!)) .map((e) => `${e.declaration.name} as ${e.declaration.name}WC`) .join(', '); classImports.push(`import type { ${names} } from '${cemModulePathToDtsImport(modulePath)}';`); } const declarationLines: string[] = []; for (const { declaration } of valid) { const name = declaration.name!; // Build event lookup scoped to this element to avoid cross-element type pollution. const eventsByName = new Map( (declaration.events ?? []).filter((e) => e.name).map((e) => [e.name!, e] as const), ); const eventLines = buildWrapperEventEntries(declaration).map(({ handlerName, eventName }) => { const event = eventsByName.get(eventName); const handlerType = toEventHandlerType( event?.type?.text, wrapperTypeState, event?.description, ); return ` ${handlerName}?: ${handlerType};`; }); declarationLines.push( `export declare const ${name}: React.ForwardRefExoticComponent<`, ` React.PropsWithChildren<`, ` Omit, 'children' | 'style'> &`, ` HTMLWCProps & {`, ...eventLines, ' }', ` > & React.RefAttributes<${name}WC>`, '>;', `export type ${name}Ref = ${name}WC;`, '', ); } return [ '/**', ' * AUTO-GENERATED FILE - DO NOT EDIT.', ' * Generated from custom-elements manifest.', ' */', '', "import type React from 'react';", ...classImports, ...renderImportLines(wrapperTypeState.usedImports), '', HELPER_TYPES, ...declarationLines, 'export {};', '', ].join('\n'); } // ── File system helpers ─────────────────────────────────────────────────────── async function fileExists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } async function collectFilesRecursively( rootDirectory: string, isTargetFile: (fileName: string) => boolean, ): Promise { const files: string[] = []; const stack: string[] = [rootDirectory]; while (stack.length) { const dir = stack.pop()!; // oxlint-disable-next-line no-await-in-loop -- iterative directory traversal, sequential by design const dirEntries = await readdir(dir, { withFileTypes: true }); for (const entry of dirEntries) { const fullPath = resolve(dir, entry.name); if (entry.isDirectory()) stack.push(fullPath); else if (entry.isFile() && isTargetFile(entry.name)) files.push(fullPath); } } return files; } async function collectTypeMetadataFiles( distDirectory: string, ): Promise<{ apiJsonFiles: string[]; dtsFiles: string[] }> { const [apiJsonFiles, dtsFiles] = await Promise.all([ collectFilesRecursively(distDirectory, (n) => n.endsWith('.api.json')), collectFilesRecursively(distDirectory, (n) => n.endsWith('.d.ts')), ]); return { apiJsonFiles, dtsFiles }; } // ── Type import state builders ──────────────────────────────────────────────── function addCanonicalReferenceFromValue(value: unknown, state: TypeImportState): void { if (!value || typeof value !== 'object') return; if (Array.isArray(value)) { for (const item of value) addCanonicalReferenceFromValue(item, state); return; } const record = value as Record; if (typeof record.canonicalReference === 'string') { const match = record.canonicalReference.match(/^(@[^!]+)!([^:]+):/); if (match) { const [, moduleSpecifier, identifier] = match; if (identifier && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) { registerTypeImport(state, identifier, moduleSpecifier); } } } for (const nested of Object.values(record)) { if (nested && typeof nested === 'object') addCanonicalReferenceFromValue(nested, state); } } function parseImportsFromDtsContent(content: string, state: TypeImportState): void { const blockRe = /(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g; for (const match of content.matchAll(blockRe)) { const moduleSpecifier = match[2] ?? ''; if (!isBareModuleSpecifier(moduleSpecifier)) continue; for (const raw of (match[1] ?? '').split(',')) { const entry = raw.trim(); if (!entry) continue; const alias = entry.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$/); if (alias) { registerTypeImport(state, alias[2], moduleSpecifier); continue; } if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(entry)) registerTypeImport(state, entry, moduleSpecifier); } } const reExportRe = /export\s+\*\s+from\s+['"]([^'"]+)['"]/g; for (const match of content.matchAll(reExportRe)) { const moduleSpecifier = match[1] ?? ''; if (isBareModuleSpecifier(moduleSpecifier)) state.wildcardExportModules.add(moduleSpecifier); } } async function parseApiJsonFiles(filePaths: string[], state: TypeImportState): Promise { await Promise.all( filePaths.map(async (filePath) => { const json = JSON.parse(await readFile(filePath, 'utf8')) as unknown; addCanonicalReferenceFromValue(json, state); }), ); } async function parseDtsFiles(filePaths: string[], state: TypeImportState): Promise { await Promise.all( filePaths.map(async (filePath) => { parseImportsFromDtsContent(await readFile(filePath, 'utf8'), state); }), ); } function mergeTypeImportStateInto(target: TypeImportState, source: TypeImportState): void { for (const id of source.ambiguousIdentifiers) { target.importsByIdentifier.delete(id); target.ambiguousIdentifiers.add(id); } for (const [identifier, spec] of source.importsByIdentifier) { registerTypeImport(target, identifier, spec); } for (const mod of source.wildcardExportModules) { target.wildcardExportModules.add(mod); } } async function findWorkspaceRoot(startDirectory: string): Promise { let dir = startDirectory; while (true) { // oxlint-disable-next-line no-await-in-loop -- upward directory search must be sequential const [hasPackages, hasPackageJson] = await Promise.all([ fileExists(resolve(dir, 'packages')), fileExists(resolve(dir, 'package.json')), ]); if (hasPackages && hasPackageJson) return dir; const parent = dirname(dir); if (parent === dir) return startDirectory; dir = parent; } } async function getWorkspacePackageDirectoryByName( workspaceRoot: string, packageName: string, ): Promise { const packagesDir = resolve(workspaceRoot, 'packages'); if (!(await fileExists(packagesDir))) return undefined; const packageJsonFiles = await collectFilesRecursively(packagesDir, (n) => n === 'package.json'); for (const jsonPath of packageJsonFiles) { // oxlint-disable-next-line no-await-in-loop -- early-exit search, sequential is correct const pkg = JSON.parse(await readFile(jsonPath, 'utf8')) as Record; if (pkg.name === packageName) return dirname(jsonPath); } return undefined; } async function enrichFromPackageDistMetadata( distDir: string, state: TypeImportState, ): Promise { const refState = createTypeImportState(); const { apiJsonFiles, dtsFiles } = await collectTypeMetadataFiles(distDir); await parseApiJsonFiles(apiJsonFiles, refState); await parseDtsFiles(dtsFiles, refState); mergeTypeImportStateInto(state, refState); return refState; } async function enrichFromWorkspaceWildcardExports( cwd: string, state: TypeImportState, ): Promise { const workspaceRoot = await findWorkspaceRoot(cwd); // for...of over a Set processes items added inside the loop (spec-guaranteed behaviour). const pending = new Set( [...state.wildcardExportModules].filter((m) => m.startsWith('@genesislcap/')), ); for (const moduleSpecifier of pending) { // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional const pkgDir = await getWorkspacePackageDirectoryByName(workspaceRoot, moduleSpecifier); if (!pkgDir) continue; const distDir = resolve(pkgDir, 'dist'); // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional if (!(await fileExists(distDir))) continue; // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional const refState = await enrichFromPackageDistMetadata(distDir, state); for (const mod of refState.wildcardExportModules) { if (mod.startsWith('@genesislcap/')) pending.add(mod); } } } /** * Elements inheriting from another `@genesislcap/*` package merge that package's CEM events * (see mergeFoundationInheritanceFromManifest), so their event descriptions may reference * detail types exported by the superclass package. Register that package's type metadata so * those identifiers resolve instead of silently degrading to `unknown`. */ async function enrichFromInheritedSuperclassPackages( cwd: string, entries: CEMElementEntry[], state: TypeImportState, ): Promise { const superclassPackages = new Set( entries .map((e) => e.declaration.superclass?.package) .filter((p): p is string => !!p && p.startsWith('@genesislcap/')), ); if (!superclassPackages.size) return; const workspaceRoot = await findWorkspaceRoot(cwd); for (const packageName of superclassPackages) { // Consumer repos resolve the superclass package from node_modules; inside this // monorepo it is a workspace package. let distDir = resolve(cwd, 'node_modules', packageName, 'dist'); // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional if (!(await fileExists(distDir))) { // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional const pkgDir = await getWorkspacePackageDirectoryByName(workspaceRoot, packageName); if (!pkgDir) continue; distDir = resolve(pkgDir, 'dist'); // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional if (!(await fileExists(distDir))) continue; } // oxlint-disable-next-line no-await-in-loop -- sequential per-package processing is intentional await enrichFromPackageDistMetadata(distDir, state); } } async function buildTypeImportState(cwd: string): Promise { const state = createTypeImportState(); const distDir = resolve(cwd, 'dist'); if (!(await fileExists(distDir))) return state; const { apiJsonFiles, dtsFiles } = await collectTypeMetadataFiles(distDir); await parseApiJsonFiles(apiJsonFiles, state); await parseDtsFiles(dtsFiles, state); await enrichFromWorkspaceWildcardExports(cwd, state); return state; } // ── Entry point ─────────────────────────────────────────────────────────────── export async function generateReactWrappers(cwd: string): Promise { const packageJsonPath = resolve(cwd, 'package.json'); if (!(await fileExists(packageJsonPath))) { return { generated: false, reason: 'No package.json found.' }; } const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as Record< string, unknown >; const manifestPath = getCEMManifestPath(cwd, packageJson); if (!(await fileExists(manifestPath))) { return { generated: false, reason: 'No custom elements manifest found.' }; } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as CEMManifest; const rawEntries = collectCustomElements(manifest); if (!rawEntries.length) { return { generated: false, reason: 'No custom elements discovered in manifest.' }; } const afterFast = await mergeFastInheritanceFromManifest(cwd, rawEntries); const entries = await mergeFoundationInheritanceFromManifest(cwd, afterFast); // A wrapper is worth generating for any element that has a tag name: the dts exposes the // element's public members as typed props (via `PublicOf`) and the runtime wrapper binds // object properties that JSX can't set as attributes. Events are layered on top when present, // so they are not a precondition — prop-only elements still produce a useful, typed wrapper. const hasWrappableElement = entries.some( (e) => e.declaration.name && e.declaration.tagName && e.modulePath, ); if (!hasWrappableElement) { return { generated: false, reason: 'No wrappable custom elements found in manifest.' }; } const dtsRoot = resolve(cwd, 'dist/dts'); await mkdir(dtsRoot, { recursive: true }); const typeImportState = await buildTypeImportState(cwd); await enrichFromInheritedSuperclassPackages(cwd, entries, typeImportState); const reactDtsPath = resolve(dtsRoot, 'react.d.ts'); await Promise.all([ writeFile(resolve(cwd, 'dist/react.mjs'), generateReactWrapperJs(entries, 'esm'), 'utf8'), writeFile(resolve(cwd, 'dist/react.cjs'), generateReactWrapperJs(entries, 'cjs'), 'utf8'), writeFile(reactDtsPath, generateReactWrapperDts(entries, typeImportState), 'utf8'), ]); return { generated: true, path: reactDtsPath }; }