mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
The loader and the Next plugin only existed to drive webpack; Next runs on Turbopack and Vite has its own plugin. kitchen-sink and the integration app serve through vite, the optimizer suite drops the webpack name, and its fixture packages join the workspace so a fresh checkout links @my/ui.
983 lines
28 KiB
TypeScript
983 lines
28 KiB
TypeScript
import generate from '@babel/generator'
|
|
import traverse from '@babel/traverse'
|
|
import * as t from '@babel/types'
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
import { basename, dirname, extname, join, relative, sep } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { createRequire } from 'node:module'
|
|
import { load, url } from '../here.ts'
|
|
|
|
const need = createRequire(url)
|
|
// @ts-ignore why
|
|
import { Color, colorLog } from '@hanzogui/cli-color'
|
|
import { type StaticConfig, type GuiInternalConfig } from '@hanzogui/web'
|
|
import esbuild from 'esbuild'
|
|
import FS from 'fs-extra'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { registerRequire, setRequireResult } from '../registerRequire.ts'
|
|
import type { GuiOptions } from '../types.ts'
|
|
import { babelParse } from './babelParse.ts'
|
|
import { esbuildLoaderConfig, esbundleGuiConfig } from './bundle.ts'
|
|
import { getGuiConfigPathFromOptionsConfig } from './getGuiConfigPathFromOptionsConfig.ts'
|
|
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
import { requireGuiCore } from '../helpers/requireGuiCore.ts'
|
|
import { detectModuleFormat } from './detectModuleFormat.ts'
|
|
|
|
// track temp files for cleanup on exit
|
|
const activeTempFiles = new Set<string>()
|
|
|
|
function getDynamicEvalOutfile(name: string, format: 'esm' | 'cjs', contents: string) {
|
|
const ext = format === 'esm' ? 'mjs' : 'cjs'
|
|
const hash = createHash('sha1')
|
|
.update(name)
|
|
.update('\0')
|
|
.update(format)
|
|
.update('\0')
|
|
.update(contents)
|
|
.digest('hex')
|
|
.slice(0, 10)
|
|
return join(process.cwd(), '.hanzogui', `dynamic-eval-${hash}-${basename(name)}.${ext}`)
|
|
}
|
|
|
|
function getEsbuildStdinLoader(filePath: string): esbuild.Loader {
|
|
if (filePath.endsWith('.tsx')) return 'tsx'
|
|
if (filePath.endsWith('.ts')) return 'ts'
|
|
if (filePath.endsWith('.jsx')) return 'jsx'
|
|
return 'js'
|
|
}
|
|
|
|
function resolvePackageEntry(packageName: string, format: 'esm' | 'cjs') {
|
|
if (format === 'cjs') {
|
|
return need.resolve(packageName)
|
|
}
|
|
|
|
const packageJsonPath = need.resolve(`${packageName}/package.json`)
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
|
|
const packageRoot = dirname(packageJsonPath)
|
|
const exportEntry = packageJson.exports?.['.']
|
|
|
|
const esmEntry =
|
|
exportEntry?.import ||
|
|
exportEntry?.module ||
|
|
exportEntry?.browser ||
|
|
packageJson.module
|
|
|
|
if (typeof esmEntry === 'string') {
|
|
return join(packageRoot, esmEntry)
|
|
}
|
|
|
|
return need.resolve(packageName)
|
|
}
|
|
|
|
function cleanupTempFiles() {
|
|
for (const f of activeTempFiles) {
|
|
try {
|
|
unlinkSync(f)
|
|
} catch {}
|
|
}
|
|
activeTempFiles.clear()
|
|
}
|
|
|
|
process.on('exit', cleanupTempFiles)
|
|
process.on('SIGINT', () => {
|
|
cleanupTempFiles()
|
|
process.exit()
|
|
})
|
|
process.on('SIGTERM', () => {
|
|
cleanupTempFiles()
|
|
process.exit()
|
|
})
|
|
|
|
type NameToPaths = {
|
|
[key: string]: Set<string>
|
|
}
|
|
|
|
export type LoadedComponents = {
|
|
moduleName: string
|
|
nameToInfo: Record<
|
|
string,
|
|
{
|
|
staticConfig: StaticConfig
|
|
}
|
|
>
|
|
}
|
|
|
|
export type GuiProjectInfo = {
|
|
components?: LoadedComponents[]
|
|
hanzoguiConfig?: GuiInternalConfig | null
|
|
nameToPaths?: NameToPaths
|
|
cached?: boolean
|
|
}
|
|
|
|
const external = [
|
|
'@hanzogui/core',
|
|
'@hanzogui/web',
|
|
'react',
|
|
'react-dom',
|
|
'react-native-svg',
|
|
]
|
|
|
|
const esbuildExtraOptions = {
|
|
define: {
|
|
__DEV__: `${process.env.NODE_ENV === 'development'}`,
|
|
},
|
|
}
|
|
|
|
// plugin to handle ESM-only features when bundling to CJS
|
|
const handleEsmFeaturesPlugin: esbuild.Plugin = {
|
|
name: 'handle-esm-features',
|
|
setup(build) {
|
|
// only apply transforms for CJS output - ESM supports these natively
|
|
const isCjs = build.initialOptions.format === 'cjs' || !build.initialOptions.format
|
|
|
|
build.onLoad({ filter: /\.(ts|tsx|js|jsx|mjs)$/ }, (args) => {
|
|
// skip if ESM output - import.meta and top-level await work natively
|
|
if (!isCjs) {
|
|
return null
|
|
}
|
|
|
|
// skip most node_modules
|
|
if (args.path.includes('node_modules') && !args.path.includes('@hanzogui')) {
|
|
return null
|
|
}
|
|
|
|
let contents = readFileSync(args.path, 'utf8')
|
|
let modified = false
|
|
|
|
// transform import.meta.env -> process.env (Vite-style env vars)
|
|
if (contents.includes('import.meta.env')) {
|
|
contents = contents.replace(/import\.meta\.env/g, 'process.env')
|
|
modified = true
|
|
}
|
|
|
|
// transform import.meta.url -> "" (not needed for static extraction)
|
|
if (contents.includes('import.meta.url')) {
|
|
contents = contents.replace(/import\.meta\.url/g, '""')
|
|
modified = true
|
|
}
|
|
|
|
// transform import.meta.main -> false
|
|
if (contents.includes('import.meta.main')) {
|
|
contents = contents.replace(/import\.meta\.main/g, 'false')
|
|
modified = true
|
|
}
|
|
|
|
// stub files with top-level await - they're typically runtime-only
|
|
if (hasTopLevelAwait(contents, args.path)) {
|
|
if (process.env.DEBUG?.startsWith('hanzogui')) {
|
|
console.info(`[hanzogui] stubbing file with top-level await: ${args.path}`)
|
|
}
|
|
return {
|
|
// Keep this as an ESM-shaped stub so esbuild doesn't inline a top-level
|
|
// `module.exports = {}` into the parent bundle and wipe its exports.
|
|
contents: `// stubbed - contains top-level await\nexport default {}`,
|
|
loader: 'js',
|
|
}
|
|
}
|
|
|
|
if (modified) {
|
|
return {
|
|
contents,
|
|
loader: args.path.endsWith('.tsx')
|
|
? 'tsx'
|
|
: args.path.endsWith('.ts')
|
|
? 'ts'
|
|
: args.path.endsWith('.jsx')
|
|
? 'jsx'
|
|
: 'js',
|
|
}
|
|
}
|
|
|
|
return null
|
|
})
|
|
},
|
|
}
|
|
|
|
// base options for transformSync (no plugins)
|
|
const esbuildTransformOptions = {
|
|
target: 'es2022',
|
|
format: 'cjs',
|
|
jsx: 'automatic',
|
|
platform: 'node',
|
|
...esbuildExtraOptions,
|
|
} satisfies esbuild.TransformOptions
|
|
|
|
// options for buildSync - NO plugins (buildSync doesn't support plugins)
|
|
export const esbuildOptions = {
|
|
...esbuildTransformOptions,
|
|
} satisfies esbuild.BuildOptions
|
|
|
|
// options for async build (with plugins)
|
|
export const esbuildOptionsWithPlugins = {
|
|
...esbuildTransformOptions,
|
|
plugins: [handleEsmFeaturesPlugin],
|
|
} satisfies esbuild.BuildOptions
|
|
|
|
export type BundledConfig = Exclude<Awaited<ReturnType<typeof bundleConfig>>, undefined>
|
|
|
|
// will use cached one if watching
|
|
let currentBundle: BundledConfig | null = null
|
|
let currentBundleKey = ''
|
|
let isBundling = false
|
|
let lastBundle: BundledConfig | null = null
|
|
const waitForBundle = new Set<Function>()
|
|
|
|
export function hasBundledConfigChanged() {
|
|
if (lastBundle === currentBundle) {
|
|
return false
|
|
}
|
|
lastBundle = currentBundle
|
|
return true
|
|
}
|
|
|
|
let loadedConfig: GuiInternalConfig | null = null
|
|
|
|
export const getLoadedConfig = () => loadedConfig
|
|
|
|
function getBundleKey(props: GuiOptions) {
|
|
return JSON.stringify({
|
|
components: props.components,
|
|
config: props.config,
|
|
platform: props.platform,
|
|
})
|
|
}
|
|
|
|
export async function getBundledConfig(props: GuiOptions, rebuild = false) {
|
|
const bundleKey = getBundleKey(props)
|
|
if (isBundling) {
|
|
await new Promise((res) => {
|
|
waitForBundle.add(res)
|
|
})
|
|
}
|
|
|
|
if (!currentBundle || currentBundleKey !== bundleKey || rebuild) {
|
|
return await bundleConfig(props)
|
|
}
|
|
|
|
return currentBundle
|
|
}
|
|
|
|
global.hanzoguiLastLoaded ||= 0
|
|
|
|
function updateLastLoaded(config: any, bundleKey: string) {
|
|
global.hanzoguiLastLoaded = Date.now()
|
|
global.hanzoguiLastBundledConfig = config
|
|
global.hanzoguiLastBundledConfigKey = bundleKey
|
|
}
|
|
|
|
let hasBundledOnce = false
|
|
|
|
// use global to dedupe logging - this works within a single process
|
|
// but may log multiple times if worker threads are recreated
|
|
// that's acceptable - better than nothing
|
|
let hasLoggedBuild = false
|
|
|
|
export async function bundleConfig(props: GuiOptions) {
|
|
const bundleKey = getBundleKey(props)
|
|
// bundlers call this per module, so the answer is cached
|
|
if (
|
|
global.hanzoguiLastBundledConfig &&
|
|
global.hanzoguiLastBundledConfigKey === bundleKey &&
|
|
Date.now() - global.hanzoguiLastLoaded < 3000
|
|
) {
|
|
// just loaded recently
|
|
return global.hanzoguiLastBundledConfig
|
|
}
|
|
|
|
try {
|
|
isBundling = true
|
|
|
|
const configEntry = props.config
|
|
? getGuiConfigPathFromOptionsConfig(props.config)
|
|
: ''
|
|
const tmpDir = join(process.cwd(), '.hanzogui')
|
|
// detect module format from config entry point
|
|
const configFormat = configEntry ? detectModuleFormat(configEntry) : 'cjs'
|
|
const configExt = configFormat === 'esm' ? '.mjs' : '.cjs'
|
|
const configOutPath = join(tmpDir, `hanzogui.config${configExt}`)
|
|
const baseComponents = (props.components || []).filter((x) => x !== '@hanzogui/core')
|
|
// detect format per component module
|
|
const componentFormats: Array<'esm' | 'cjs'> = baseComponents.map((mod) => {
|
|
try {
|
|
const pkgJson = need.resolve(mod + '/package.json')
|
|
const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'))
|
|
return pkg.type === 'module' ? 'esm' : 'cjs'
|
|
} catch {
|
|
return 'cjs'
|
|
}
|
|
})
|
|
const componentOutPaths = baseComponents.map((componentModule, i) => {
|
|
const ext = componentFormats[i] === 'esm' ? '.mjs' : '.cjs'
|
|
return join(
|
|
tmpDir,
|
|
`${componentModule
|
|
.split(sep)
|
|
.join('-')
|
|
.replace(/[^a-z0-9]+/gi, '')}-components.config${ext}`
|
|
)
|
|
})
|
|
|
|
if (
|
|
process.env.NODE_ENV === 'development' &&
|
|
process.env.DEBUG?.startsWith('hanzogui')
|
|
) {
|
|
console.info(`Building config entry`, configEntry)
|
|
}
|
|
|
|
// check if ALL output files (config + components) already exist and are recent
|
|
// (built by another worker) - this prevents duplicate builds across worker threads
|
|
// we must check ALL files, not just the config, to avoid a race where another
|
|
// worker has written the config but not yet finished writing component files
|
|
let shouldBuild = !props.disableInitialBuild
|
|
if (shouldBuild && props.config) {
|
|
const allOutFiles = [configOutPath, ...componentOutPaths]
|
|
try {
|
|
const stats = await Promise.all(
|
|
allOutFiles.map((f) => FS.stat(f).catch(() => null))
|
|
)
|
|
const allExistAndRecent = stats.every(
|
|
(s) => s !== null && Date.now() - s.mtimeMs < 3000
|
|
)
|
|
if (allExistAndRecent) {
|
|
shouldBuild = false
|
|
}
|
|
} catch {
|
|
// something went wrong checking files, just build
|
|
}
|
|
}
|
|
|
|
if (shouldBuild) {
|
|
// build them to node-compat versions
|
|
try {
|
|
await FS.ensureDir(tmpDir)
|
|
} catch {
|
|
//
|
|
}
|
|
|
|
const start = Date.now()
|
|
|
|
await Promise.all([
|
|
props.config
|
|
? esbundleGuiConfig(
|
|
{
|
|
entryPoints: [configEntry],
|
|
external,
|
|
outfile: configOutPath,
|
|
target: 'node24',
|
|
format: configFormat,
|
|
...esbuildExtraOptions,
|
|
},
|
|
props.platform || 'web'
|
|
)
|
|
: null,
|
|
...baseComponents.map((componentModule, i) => {
|
|
return esbundleGuiConfig(
|
|
{
|
|
entryPoints: [componentModule],
|
|
resolvePlatformSpecificEntries: true,
|
|
external,
|
|
outfile: componentOutPaths[i],
|
|
target: 'node24',
|
|
format: componentFormats[i],
|
|
...esbuildExtraOptions,
|
|
},
|
|
props.platform || 'web'
|
|
)
|
|
}),
|
|
])
|
|
|
|
// only log once per process to avoid duplicate messages
|
|
// also skip if _skipBuildLog is set (used during worker recycle warmup)
|
|
if (!hasLoggedBuild && !props['_skipBuildLog']) {
|
|
hasLoggedBuild = true
|
|
colorLog(
|
|
Color.FgYellow,
|
|
`
|
|
➡ [hanzogui] built config, components, prompt (${Date.now() - start}ms)`
|
|
)
|
|
|
|
if (process.env.DEBUG?.startsWith('hanzogui')) {
|
|
colorLog(
|
|
Color.Dim,
|
|
`
|
|
Config .${sep}${relative(process.cwd(), configOutPath)}
|
|
Components ${componentOutPaths.map((p) => `.${sep}${relative(process.cwd(), p)}`).join('\n ')}
|
|
`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// clear specific output file caches so we pick up the fresh (or newly discovered) build
|
|
// only clear the built output files - not all require.cache entries, since that breaks
|
|
// external requires like @hanzogui/config/v3 that are externalized in the bundled CJS
|
|
if (hasBundledOnce) {
|
|
try {
|
|
delete need.cache[need.resolve(configOutPath)]
|
|
} catch {
|
|
// file may not exist yet
|
|
}
|
|
for (const p of componentOutPaths) {
|
|
try {
|
|
delete need.cache[need.resolve(p)]
|
|
} catch {
|
|
// file may not exist yet
|
|
}
|
|
}
|
|
} else {
|
|
hasBundledOnce = true
|
|
}
|
|
|
|
let out: any
|
|
if (configFormat === 'esm') {
|
|
// use file:// URL for proper ESM resolution
|
|
out = await load(configOutPath)
|
|
} else {
|
|
out = need(configOutPath)
|
|
}
|
|
|
|
// try and find .config, even if on .default
|
|
let config = out.default || out || out.config
|
|
if (config && config.config && !config.tokens) {
|
|
config = config.config
|
|
}
|
|
|
|
if (!config) {
|
|
throw new Error(`No config: ${config}`)
|
|
}
|
|
|
|
// check for ProxyWorm - indicates a module loading error
|
|
if (config._isProxyWorm) {
|
|
throw new Error(
|
|
`Got a proxied config - likely a module loading error. Set DEBUG=hanzogui for details.`
|
|
)
|
|
}
|
|
|
|
loadedConfig = config
|
|
|
|
if (!config.parsed) {
|
|
const { createGui } = requireGuiCore(props.platform || 'web')
|
|
// need to create it
|
|
config = createGui(config)
|
|
}
|
|
|
|
if (props.outputCSS) {
|
|
await writeGuiCSS(props.outputCSS, config)
|
|
}
|
|
|
|
let components = await loadComponents({
|
|
...props,
|
|
components: componentOutPaths,
|
|
})
|
|
|
|
if (!components) {
|
|
throw new Error(`No components found: ${componentOutPaths.join(', ')}`)
|
|
}
|
|
|
|
// map from built back to original module names
|
|
for (const component of components) {
|
|
component.moduleName =
|
|
baseComponents[componentOutPaths.indexOf(component.moduleName)] ||
|
|
component.moduleName
|
|
|
|
if (!component.moduleName) {
|
|
if (process.env.DEBUG?.includes('hanzogui') || process.env.IS_GUI_DEV) {
|
|
console.warn(
|
|
`⚠️ no module name found: ${component.moduleName} ${JSON.stringify(
|
|
baseComponents
|
|
)} in ${JSON.stringify(componentOutPaths)}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.env.NODE_ENV === 'development' &&
|
|
process.env.DEBUG?.startsWith('hanzogui')
|
|
) {
|
|
console.info('Loaded components', components)
|
|
}
|
|
|
|
const res = {
|
|
components,
|
|
nameToPaths: {},
|
|
hanzoguiConfig: config,
|
|
}
|
|
|
|
currentBundle = res
|
|
currentBundleKey = bundleKey
|
|
updateLastLoaded(res, bundleKey)
|
|
|
|
return res
|
|
} catch (err: any) {
|
|
console.error(
|
|
`Error bundling hanzogui config: ${err?.message} (run with DEBUG=hanzogui to see stack)`
|
|
)
|
|
if (process.env.DEBUG?.includes('hanzogui')) {
|
|
console.error(err.stack)
|
|
}
|
|
} finally {
|
|
isBundling = false
|
|
waitForBundle.forEach((cb) => cb())
|
|
waitForBundle.clear()
|
|
}
|
|
}
|
|
|
|
export async function writeGuiCSS(outputCSS: string, config: GuiInternalConfig) {
|
|
const flush = async () => {
|
|
colorLog(Color.FgYellow, ` ➡ [hanzogui] output css: ${outputCSS}`)
|
|
await FS.writeFile(outputCSS, css)
|
|
}
|
|
|
|
const css = config.getCSS()
|
|
if (typeof css !== 'string') {
|
|
throw new Error(`Invalid CSS: ${typeof css} ${css}`)
|
|
}
|
|
try {
|
|
if (existsSync(outputCSS) && (await readFile(outputCSS, 'utf8')) === css) {
|
|
// no change
|
|
} else {
|
|
await flush()
|
|
}
|
|
} catch (err) {
|
|
console.info('Error writing themes', err)
|
|
}
|
|
}
|
|
|
|
export async function loadComponents(props: GuiOptions, forceExports = false) {
|
|
const coreComponents = getCoreComponentsSync(props)
|
|
const otherComponents = await loadComponentsInner(props, forceExports)
|
|
return [...coreComponents, ...(otherComponents || [])]
|
|
}
|
|
|
|
export function loadComponentsSync(props: GuiOptions, forceExports = false) {
|
|
const coreComponents = getCoreComponentsSync(props)
|
|
const otherComponents = loadComponentsInnerSync(props, forceExports)
|
|
return [...coreComponents, ...(otherComponents || [])]
|
|
}
|
|
|
|
function getCoreComponentsSync(props: GuiOptions) {
|
|
const loaded = loadComponentsInnerSync({
|
|
...props,
|
|
components: ['@hanzogui/core'],
|
|
})
|
|
|
|
if (!loaded) {
|
|
throw new Error(`Core should always load`)
|
|
}
|
|
|
|
// always load core so we can optimize if directly importing
|
|
return [
|
|
{
|
|
...loaded[0],
|
|
moduleName: '@hanzogui/core',
|
|
},
|
|
]
|
|
}
|
|
|
|
export async function loadComponentsInner(
|
|
props: GuiOptions,
|
|
forceExports = false
|
|
): Promise<null | LoadedComponents[]> {
|
|
const componentsModules = props.components || []
|
|
|
|
const key = componentsModules.join('\0')
|
|
|
|
if (!forceExports && cacheComponents[key]) {
|
|
return cacheComponents[key]
|
|
}
|
|
|
|
const { unregister } = registerRequire(props.platform || 'web', {
|
|
proxyWormImports: forceExports,
|
|
})
|
|
|
|
try {
|
|
const results: LoadedComponents[] = []
|
|
|
|
for (const name of componentsModules) {
|
|
const extension = extname(name)
|
|
const isLocal = Boolean(extension)
|
|
const isDynamic = isLocal && forceExports
|
|
const format = isLocal ? detectModuleFormat(name) : ('cjs' as const)
|
|
|
|
const fileContents = isDynamic ? readFileSync(name, 'utf-8') : ''
|
|
let loadModule = name
|
|
let writtenContents = fileContents
|
|
let didBabel = false
|
|
|
|
const attemptLoad = async ({ forceExports = false } = {}) => {
|
|
if (isDynamic) {
|
|
writtenContents = forceExports
|
|
? transformAddExports(babelParse(esbuildit(fileContents, 'modern'), name))
|
|
: fileContents
|
|
loadModule = getDynamicEvalOutfile(name, format, writtenContents)
|
|
|
|
FS.ensureDirSync(dirname(loadModule))
|
|
activeTempFiles.add(loadModule)
|
|
|
|
await esbuild.build({
|
|
...esbuildOptionsWithPlugins,
|
|
format,
|
|
outfile: loadModule,
|
|
stdin: {
|
|
contents: writtenContents,
|
|
resolveDir: dirname(name),
|
|
sourcefile: name,
|
|
loader: getEsbuildStdinLoader(name),
|
|
},
|
|
alias: {
|
|
'react-native': resolvePackageEntry(
|
|
'@hanzogui/react-native-web-lite',
|
|
format
|
|
),
|
|
'@hanzogui/react-native-web-lite': resolvePackageEntry(
|
|
'@hanzogui/react-native-web-lite',
|
|
format
|
|
),
|
|
'@hanzogui/react-native-web-internals': resolvePackageEntry(
|
|
'@hanzogui/react-native-web-internals',
|
|
format
|
|
),
|
|
},
|
|
bundle: true,
|
|
packages: 'external',
|
|
allowOverwrite: true,
|
|
sourcemap: false,
|
|
loader: esbuildLoaderConfig,
|
|
})
|
|
}
|
|
|
|
if (process.env.DEBUG === 'hanzogui') {
|
|
console.info(`loadModule`, loadModule, format)
|
|
}
|
|
|
|
let moduleResult: any
|
|
if (format === 'esm') {
|
|
// use file:// URL for proper ESM resolution
|
|
moduleResult = await load(loadModule)
|
|
} else {
|
|
moduleResult = need(loadModule)
|
|
}
|
|
|
|
if (!forceExports) {
|
|
setRequireResult(name, moduleResult)
|
|
}
|
|
|
|
const nameToInfo = getComponentStaticConfigByName(
|
|
name,
|
|
interopDefaultExport(moduleResult)
|
|
)
|
|
|
|
return {
|
|
moduleName: name,
|
|
nameToInfo,
|
|
}
|
|
}
|
|
|
|
const dispose = () => {
|
|
if (isDynamic) {
|
|
FS.removeSync(loadModule)
|
|
activeTempFiles.delete(loadModule)
|
|
}
|
|
}
|
|
|
|
let loaded: LoadedComponents | LoadedComponents[] | undefined
|
|
|
|
try {
|
|
loaded = await attemptLoad({ forceExports: true })
|
|
didBabel = true
|
|
} catch (err) {
|
|
console.info('babel err', err, writtenContents)
|
|
writtenContents = fileContents
|
|
if (process.env.DEBUG?.startsWith('hanzogui')) {
|
|
console.info(`Error parsing babel likely`, err)
|
|
}
|
|
|
|
try {
|
|
loaded = await attemptLoad({ forceExports: false })
|
|
} catch (err2) {
|
|
if (process.env.GUI_ENABLE_WARN_DYNAMIC_LOAD) {
|
|
console.info(
|
|
`\nGui attempted but failed to dynamically optimize components in:\n ${name}\n`
|
|
)
|
|
console.info(err2)
|
|
console.info(
|
|
`At: ${loadModule}`,
|
|
`\ndidBabel: ${didBabel}`,
|
|
`\nIn:`,
|
|
writtenContents,
|
|
`\nisDynamic: `,
|
|
isDynamic
|
|
)
|
|
}
|
|
loaded = []
|
|
}
|
|
} finally {
|
|
dispose()
|
|
}
|
|
|
|
if (Array.isArray(loaded)) {
|
|
results.push(...loaded)
|
|
} else if (loaded) {
|
|
results.push(loaded)
|
|
}
|
|
}
|
|
|
|
cacheComponents[key] = results
|
|
return results
|
|
} catch (err: any) {
|
|
console.info(`Gui error bundling components`, err.message, err.stack)
|
|
return null
|
|
} finally {
|
|
unregister()
|
|
}
|
|
}
|
|
|
|
// sync version - uses cjs format for buildSync (no plugin support)
|
|
export function loadComponentsInnerSync(
|
|
props: GuiOptions,
|
|
forceExports = false
|
|
): null | LoadedComponents[] {
|
|
const componentsModules = props.components || []
|
|
|
|
const key = componentsModules.join('\0')
|
|
|
|
if (!forceExports && cacheComponents[key]) {
|
|
return cacheComponents[key]
|
|
}
|
|
|
|
const { unregister } = registerRequire(props.platform || 'web', {
|
|
proxyWormImports: forceExports,
|
|
})
|
|
|
|
try {
|
|
const info: LoadedComponents[] = componentsModules.flatMap((name) => {
|
|
const extension = extname(name)
|
|
const isLocal = Boolean(extension)
|
|
const isDynamic = isLocal && forceExports
|
|
|
|
const fileContents = isDynamic ? readFileSync(name, 'utf-8') : ''
|
|
let loadModule = name
|
|
let writtenContents = fileContents
|
|
let didBabel = false
|
|
|
|
function attemptLoad({ forceExports = false } = {}) {
|
|
if (isDynamic) {
|
|
writtenContents = forceExports
|
|
? transformAddExports(babelParse(esbuildit(fileContents, 'modern'), name))
|
|
: fileContents
|
|
loadModule = getDynamicEvalOutfile(name, 'cjs', writtenContents)
|
|
|
|
FS.ensureDirSync(dirname(loadModule))
|
|
activeTempFiles.add(loadModule)
|
|
|
|
esbuild.buildSync({
|
|
...esbuildOptions,
|
|
outfile: loadModule,
|
|
stdin: {
|
|
contents: writtenContents,
|
|
resolveDir: dirname(name),
|
|
sourcefile: name,
|
|
loader: getEsbuildStdinLoader(name),
|
|
},
|
|
alias: {
|
|
'react-native': resolvePackageEntry(
|
|
'@hanzogui/react-native-web-lite',
|
|
'esm'
|
|
),
|
|
'@hanzogui/react-native-web-lite': resolvePackageEntry(
|
|
'@hanzogui/react-native-web-lite',
|
|
'esm'
|
|
),
|
|
'@hanzogui/react-native-web-internals': resolvePackageEntry(
|
|
'@hanzogui/react-native-web-internals',
|
|
'esm'
|
|
),
|
|
},
|
|
bundle: true,
|
|
packages: 'external',
|
|
allowOverwrite: true,
|
|
sourcemap: false,
|
|
loader: esbuildLoaderConfig,
|
|
})
|
|
}
|
|
|
|
if (process.env.DEBUG === 'hanzogui') {
|
|
console.info(`loadModule`, loadModule, need.resolve(loadModule))
|
|
}
|
|
|
|
const moduleResult = need(loadModule)
|
|
|
|
if (!forceExports) {
|
|
setRequireResult(name, moduleResult)
|
|
}
|
|
|
|
const nameToInfo = getComponentStaticConfigByName(
|
|
name,
|
|
interopDefaultExport(moduleResult)
|
|
)
|
|
|
|
return {
|
|
moduleName: name,
|
|
nameToInfo,
|
|
}
|
|
}
|
|
|
|
const dispose = () => {
|
|
if (isDynamic) {
|
|
FS.removeSync(loadModule)
|
|
activeTempFiles.delete(loadModule)
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = attemptLoad({ forceExports: true })
|
|
didBabel = true
|
|
return res
|
|
} catch (err) {
|
|
console.info('babel err', err, writtenContents)
|
|
writtenContents = fileContents
|
|
if (process.env.DEBUG?.startsWith('hanzogui')) {
|
|
console.info(`Error parsing babel likely`, err)
|
|
}
|
|
} finally {
|
|
dispose()
|
|
}
|
|
|
|
try {
|
|
return attemptLoad({ forceExports: false })
|
|
} catch (err) {
|
|
if (process.env.GUI_ENABLE_WARN_DYNAMIC_LOAD) {
|
|
console.info(
|
|
`\nGui attempted but failed to dynamically optimize components in:\n ${name}\n`
|
|
)
|
|
console.info(err)
|
|
console.info(
|
|
`At: ${loadModule}`,
|
|
`\ndidBabel: ${didBabel}`,
|
|
`\nIn:`,
|
|
writtenContents,
|
|
`\nisDynamic: `,
|
|
isDynamic
|
|
)
|
|
}
|
|
return []
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
cacheComponents[key] = info
|
|
return info
|
|
} catch (err: any) {
|
|
console.info(`Gui error bundling components`, err.message, err.stack)
|
|
return null
|
|
} finally {
|
|
unregister()
|
|
}
|
|
}
|
|
|
|
const esbuildit = (src: string, target?: 'modern') => {
|
|
return esbuild.transformSync(src, {
|
|
...esbuildTransformOptions,
|
|
...(target === 'modern' && {
|
|
target: 'es2022',
|
|
jsx: 'automatic',
|
|
loader: 'tsx',
|
|
platform: 'neutral',
|
|
format: 'esm',
|
|
}),
|
|
}).code
|
|
}
|
|
|
|
function getComponentStaticConfigByName(name: string, exported: any) {
|
|
const components: Record<string, { staticConfig: StaticConfig }> = {}
|
|
try {
|
|
if (!exported || typeof exported !== 'object' || Array.isArray(exported)) {
|
|
throw new Error(`Invalid export from package ${name}: ${typeof exported}`)
|
|
}
|
|
|
|
for (const key in exported) {
|
|
const found = getGuiComponent(key, exported[key])
|
|
if (found) {
|
|
// remove non-stringifyable
|
|
const { Component, ...sc } = found.staticConfig
|
|
components[key] = { staticConfig: sc }
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (process.env.GUI_ENABLE_WARN_DYNAMIC_LOAD) {
|
|
console.error(
|
|
`Gui failed getting components from ${name} (Disable error by setting environment variable GUI_ENABLE_WARN_DYNAMIC_LOAD=1)`
|
|
)
|
|
console.error(err)
|
|
}
|
|
}
|
|
return components
|
|
}
|
|
|
|
function getGuiComponent(
|
|
name: string,
|
|
Component: any
|
|
): undefined | { staticConfig: StaticConfig } {
|
|
if (name[0].toUpperCase() !== name[0]) {
|
|
return
|
|
}
|
|
const staticConfig = Component?.staticConfig as StaticConfig | undefined
|
|
if (staticConfig) {
|
|
return Component
|
|
}
|
|
}
|
|
|
|
function interopDefaultExport(mod: any) {
|
|
return mod?.default ?? mod
|
|
}
|
|
|
|
const cacheComponents: Record<string, LoadedComponents[]> = {}
|
|
|
|
function transformAddExports(ast: t.File) {
|
|
const usedNames = new Set<string>()
|
|
|
|
// avoid clobbering
|
|
// @ts-ignore
|
|
traverse(ast, {
|
|
ExportNamedDeclaration(nodePath) {
|
|
if (nodePath.node.specifiers) {
|
|
for (const spec of nodePath.node.specifiers) {
|
|
usedNames.add(
|
|
t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value
|
|
)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
// @ts-ignore
|
|
traverse(ast, {
|
|
VariableDeclaration(nodePath) {
|
|
// top level only
|
|
if (!t.isProgram(nodePath.parent)) return
|
|
const decs = nodePath.node.declarations
|
|
if (decs.length > 1) return
|
|
const [dec] = decs
|
|
if (!t.isIdentifier(dec.id)) return
|
|
if (!dec.init) return
|
|
if (usedNames.has(dec.id.name)) return
|
|
usedNames.add(dec.id.name)
|
|
nodePath.replaceWith(
|
|
t.exportNamedDeclaration(t.variableDeclaration('let', [dec]), [
|
|
t.exportSpecifier(t.identifier(dec.id.name), t.identifier(dec.id.name)),
|
|
])
|
|
)
|
|
},
|
|
})
|
|
|
|
// @ts-ignore
|
|
return generate(ast as any, {
|
|
concise: false,
|
|
filename: 'test.tsx',
|
|
retainLines: false,
|
|
sourceMaps: false,
|
|
}).code
|
|
}
|