import fs from "node:fs/promises"; import path from "node:path"; import { builtinModules } from "node:module"; import { fileURLToPath } from "node:url"; import ts from "typescript"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const registryDir = path.join(repoRoot, "registry"); const registryConfigPath = path.join(registryDir, "config.json"); const registryIndexPath = path.join(registryDir, "index.json"); const args = new Set(process.argv.slice(2)); const checkMode = args.has("--check"); const uiPackagePath = path.join(repoRoot, "packages", "ui", "package.json"); const tokensPackagePath = path.join(repoRoot, "packages", "tokens", "package.json"); const rootPackagePath = path.join(repoRoot, "package.json"); function toPosixPath(value) { return value.split(path.sep).join(path.posix.sep); } async function readJson(filePath) { const source = await fs.readFile(filePath, "utf8"); return JSON.parse(source); } function isBuiltinSpecifier(specifier) { return specifier.startsWith("node:") || builtinModules.includes(specifier); } function normalizePackageName(specifier) { if (specifier.startsWith("@")) { const [scope, name] = specifier.split("/"); return `${scope}/${name}`; } return specifier.split("/")[0]; } function getDisplayName(name) { return name .split("-") .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(" "); } function getScriptSpecifiers(sourceText) { const { importedFiles } = ts.preProcessFile(sourceText, true, true); return importedFiles.map((entry) => entry.fileName); } function getCssSpecifiers(sourceText) { const specifiers = []; const pattern = /@import\s+["']([^"']+)["']/g; for (const match of sourceText.matchAll(pattern)) { specifiers.push(match[1]); } return specifiers; } async function resolveLocalImport(fromFile, specifier) { const basePath = path.resolve(path.dirname(fromFile), specifier); const candidates = [ basePath, `${basePath}.ts`, `${basePath}.tsx`, `${basePath}.js`, `${basePath}.mjs`, `${basePath}.cjs`, `${basePath}.css`, path.join(basePath, "index.ts"), path.join(basePath, "index.tsx"), path.join(basePath, "index.js") ]; for (const candidate of candidates) { try { const stats = await fs.stat(candidate); if (stats.isFile()) { return candidate; } } catch { continue; } } throw new Error( `Unable to resolve "${specifier}" from ${toPosixPath(path.relative(repoRoot, fromFile))}.` ); } async function collectSourceGraph(entrypoints) { const filesToVisit = [...entrypoints]; const visited = new Set(); const externalPackages = new Set(); while (filesToVisit.length > 0) { const currentFile = filesToVisit.pop(); if (!currentFile || visited.has(currentFile)) { continue; } visited.add(currentFile); const sourceText = await fs.readFile(currentFile, "utf8"); const localSpecifiers = currentFile.endsWith(".css") ? getCssSpecifiers(sourceText) : getScriptSpecifiers(sourceText); for (const specifier of localSpecifiers) { if (specifier.startsWith(".") || specifier.startsWith("..")) { filesToVisit.push(await resolveLocalImport(currentFile, specifier)); continue; } if (isBuiltinSpecifier(specifier)) { continue; } externalPackages.add(normalizePackageName(specifier)); } } return { externalPackages: [...externalPackages].sort(), files: [...visited] .map((filePath) => toPosixPath(path.relative(repoRoot, filePath))) .sort() }; } async function discoverComponentEntrypoints(componentsDir) { const absoluteDir = path.join(repoRoot, componentsDir); const entries = await fs.readdir(absoluteDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile()) .map((entry) => entry.name) .filter((fileName) => fileName.endsWith(".tsx")) .filter((fileName) => !fileName.endsWith(".test.tsx")) .sort() .map((fileName) => ({ description: `Source-owned ${getDisplayName(fileName.replace(/\.tsx$/, ""))} component.`, entrypoints: [toPosixPath(path.join(componentsDir, fileName))], kind: "component", name: fileName.replace(/\.tsx$/, "") })); } function createDependencyLookup(...packageJsonFiles) { const lookup = new Map(); for (const packageJson of packageJsonFiles) { for (const section of [ packageJson.peerDependencies ?? {}, packageJson.dependencies ?? {}, packageJson.devDependencies ?? {} ]) { for (const [name, range] of Object.entries(section)) { if (!lookup.has(name)) { lookup.set(name, range); } } } } return lookup; } async function buildRegistryIndex() { const [registryConfig, rootPackage, uiPackage, tokensPackage] = await Promise.all([ readJson(registryConfigPath), readJson(rootPackagePath), readJson(uiPackagePath), readJson(tokensPackagePath) ]); const dependencyLookup = createDependencyLookup(uiPackage, tokensPackage, rootPackage); const itemDefinitions = [ { ...registryConfig.tokens, kind: "tokens", requires: [], sourcePackage: tokensPackage.name }, ...(await discoverComponentEntrypoints(registryConfig.ui.componentsDir)).map((item) => ({ ...item, requires: [...registryConfig.ui.requires], sourcePackage: uiPackage.name })) ]; const items = []; for (const definition of itemDefinitions) { const entrypoints = definition.entrypoints.map((entrypoint) => path.join(repoRoot, ...entrypoint.split("/")) ); const sourceGraph = await collectSourceGraph(entrypoints); const packageDependencies = {}; for (const dependencyName of sourceGraph.externalPackages) { const range = dependencyLookup.get(dependencyName); if (!range) { throw new Error( `Missing dependency range for "${dependencyName}" while building registry item "${definition.name}".` ); } packageDependencies[dependencyName] = range; } items.push({ description: definition.description, displayName: getDisplayName(definition.name), entrypoints: definition.entrypoints, files: sourceGraph.files, kind: definition.kind, name: definition.name, packageDependencies, requires: definition.requires, sourcePackage: definition.sourcePackage, sourceVersion: definition.sourcePackage === uiPackage.name ? uiPackage.version : tokensPackage.version, targetDirectory: definition.kind === "tokens" ? toPosixPath( path.posix.join(registryConfig.defaultTargetDir, registryConfig.tokens.targetSubdir) ) : toPosixPath( path.posix.join( registryConfig.defaultTargetDir, registryConfig.ui.targetSubdir === "." ? "" : registryConfig.ui.targetSubdir ) ) }); } return { generatedBy: "scripts/build-registry.mjs", install: { defaultTargetDir: registryConfig.defaultTargetDir, manifestFile: ".install-manifest.json" }, items, library: { name: registryConfig.libraryName, packageManager: rootPackage.packageManager, packages: { [tokensPackage.name]: tokensPackage.version, [uiPackage.name]: uiPackage.version } } }; } async function main() { const nextIndex = await buildRegistryIndex(); const nextContent = `${JSON.stringify(nextIndex, null, 2)}\n`; if (checkMode) { let currentContent = ""; try { currentContent = await fs.readFile(registryIndexPath, "utf8"); } catch { currentContent = ""; } if (currentContent !== nextContent) { console.error("registry/index.json is out of date. Run `pnpm registry:build`."); process.exitCode = 1; return; } console.log("registry/index.json is up to date."); return; } await fs.mkdir(registryDir, { recursive: true }); await fs.writeFile(registryIndexPath, nextContent); console.log(`Wrote ${toPosixPath(path.relative(repoRoot, registryIndexPath))}.`); } await main();