|
| 1 | +import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; |
| 2 | +import { createRequire } from "node:module"; |
| 3 | +import { basename, dirname, join, relative } from "node:path"; |
| 4 | +import { fileURLToPath, pathToFileURL } from "node:url"; |
| 5 | + |
| 6 | +const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 7 | +const sourceRoot = join(pluginRoot, "src/tui"); |
| 8 | +const outputRoot = join(pluginRoot, "src/tui-compiled"); |
| 9 | +const runtimeSpecifiers = new Set([ |
| 10 | + "@opentui/core", |
| 11 | + "@opentui/core/testing", |
| 12 | + "@opentui/solid", |
| 13 | + "@opentui/solid/components", |
| 14 | + "@opentui/solid/jsx-runtime", |
| 15 | + "@opentui/solid/jsx-dev-runtime", |
| 16 | + "solid-js", |
| 17 | + "solid-js/store", |
| 18 | +]); |
| 19 | + |
| 20 | +type TransformSolidSource = ( |
| 21 | + code: string, |
| 22 | + options: { |
| 23 | + filename: string; |
| 24 | + moduleName: string; |
| 25 | + resolvePath: (specifier: string) => string | null; |
| 26 | + }, |
| 27 | +) => Promise<string>; |
| 28 | + |
| 29 | +type SolidTransformModule = { |
| 30 | + transformSolidSource?: TransformSolidSource; |
| 31 | +}; |
| 32 | + |
| 33 | +function runtimeModuleId(specifier: string): string { |
| 34 | + return `opentui:runtime-module:${encodeURIComponent(specifier)}`; |
| 35 | +} |
| 36 | + |
| 37 | +function asTransformSolidSource(mod: SolidTransformModule, from: string): TransformSolidSource { |
| 38 | + if (typeof mod.transformSolidSource !== "function") { |
| 39 | + throw new Error(`@opentui/solid transform loaded from ${from} without transformSolidSource`); |
| 40 | + } |
| 41 | + return mod.transformSolidSource; |
| 42 | +} |
| 43 | + |
| 44 | +async function importTransformModule(specifier: string): Promise<SolidTransformModule> { |
| 45 | + return (await import(specifier)) as SolidTransformModule; |
| 46 | +} |
| 47 | + |
| 48 | +async function resolveSolidTransformPath(): Promise<string> { |
| 49 | + const packageJsonSpecifier = "@opentui/solid/package.json"; |
| 50 | + const errors: string[] = []; |
| 51 | + |
| 52 | + try { |
| 53 | + const packageJsonUrl = import.meta.resolve(packageJsonSpecifier); |
| 54 | + return join(dirname(fileURLToPath(packageJsonUrl)), "scripts/solid-transform.js"); |
| 55 | + } catch (error) { |
| 56 | + errors.push(`import.meta.resolve: ${error instanceof Error ? error.message : String(error)}`); |
| 57 | + } |
| 58 | + |
| 59 | + try { |
| 60 | + const require = createRequire(import.meta.url); |
| 61 | + return join(dirname(require.resolve(packageJsonSpecifier)), "scripts/solid-transform.js"); |
| 62 | + } catch (error) { |
| 63 | + errors.push(`require.resolve: ${error instanceof Error ? error.message : String(error)}`); |
| 64 | + } |
| 65 | + |
| 66 | + throw new Error(`Unable to resolve @opentui/solid transform (${errors.join("; ")})`); |
| 67 | +} |
| 68 | + |
| 69 | +async function loadTransformSolidSource(): Promise<TransformSolidSource> { |
| 70 | + const bareTransformSpecifier = "@opentui/solid/scripts/solid-transform.js"; |
| 71 | + |
| 72 | + try { |
| 73 | + return asTransformSolidSource( |
| 74 | + await importTransformModule(bareTransformSpecifier), |
| 75 | + bareTransformSpecifier, |
| 76 | + ); |
| 77 | + } catch { |
| 78 | + const transformPath = await resolveSolidTransformPath(); |
| 79 | + return asTransformSolidSource( |
| 80 | + await importTransformModule(pathToFileURL(transformPath).href), |
| 81 | + transformPath, |
| 82 | + ); |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +function isShippedSourceFile(filePath: string): boolean { |
| 87 | + if (/\.test\.tsx?$/.test(basename(filePath))) return false; |
| 88 | + return filePath.endsWith(".tsx") || filePath.endsWith(".ts"); |
| 89 | +} |
| 90 | + |
| 91 | +async function listSourceFiles(dir: string): Promise<string[]> { |
| 92 | + const entries = await readdir(dir, { withFileTypes: true }); |
| 93 | + entries.sort((a, b) => a.name.localeCompare(b.name)); |
| 94 | + |
| 95 | + const files: string[] = []; |
| 96 | + for (const entry of entries) { |
| 97 | + const entryPath = join(dir, entry.name); |
| 98 | + if (entry.isDirectory()) { |
| 99 | + files.push(...(await listSourceFiles(entryPath))); |
| 100 | + } else if (entry.isFile() && isShippedSourceFile(entryPath)) { |
| 101 | + files.push(entryPath); |
| 102 | + } |
| 103 | + } |
| 104 | + return files; |
| 105 | +} |
| 106 | + |
| 107 | +async function copyPlainTypeScript(sourceFile: string, outputFile: string): Promise<void> { |
| 108 | + await mkdir(dirname(outputFile), { recursive: true }); |
| 109 | + await copyFile(sourceFile, outputFile); |
| 110 | +} |
| 111 | + |
| 112 | +async function compileTsx( |
| 113 | + transformSolidSource: TransformSolidSource, |
| 114 | + sourceFile: string, |
| 115 | + outputFile: string, |
| 116 | +): Promise<void> { |
| 117 | + const code = await readFile(sourceFile, "utf8"); |
| 118 | + const compiled = await transformSolidSource(code, { |
| 119 | + filename: sourceFile, |
| 120 | + moduleName: runtimeModuleId("@opentui/solid"), |
| 121 | + resolvePath: (specifier: string) => |
| 122 | + runtimeSpecifiers.has(specifier) ? runtimeModuleId(specifier) : null, |
| 123 | + }); |
| 124 | + |
| 125 | + await mkdir(dirname(outputFile), { recursive: true }); |
| 126 | + await writeFile(outputFile, compiled); |
| 127 | +} |
| 128 | + |
| 129 | +const transformSolidSource = await loadTransformSolidSource(); |
| 130 | +const files = await listSourceFiles(sourceRoot); |
| 131 | + |
| 132 | +await rm(outputRoot, { recursive: true, force: true }); |
| 133 | + |
| 134 | +for (const sourceFile of files) { |
| 135 | + const relativePath = relative(sourceRoot, sourceFile); |
| 136 | + const outputFile = join(outputRoot, relativePath); |
| 137 | + |
| 138 | + if (sourceFile.endsWith(".tsx")) { |
| 139 | + // OpenTUI skips the Solid compile-time transform for packages loaded from |
| 140 | + // node_modules. Without this precompiled copy, JSX children such as |
| 141 | + // signal-derived counts are evaluated once during element creation and |
| 142 | + // the sidebar freezes on its first paint. The virtual ids are required so |
| 143 | + // the compiled package binds the host process's single OpenTUI/Solid |
| 144 | + // runtime instead of loading a second copy from the plugin package. |
| 145 | + await compileTsx(transformSolidSource, sourceFile, outputFile); |
| 146 | + } else { |
| 147 | + await copyPlainTypeScript(sourceFile, outputFile); |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +console.log(`build-tui: wrote ${files.length} file(s) to ${relative(pluginRoot, outputRoot)}`); |
0 commit comments