298 lines
8.2 KiB
JavaScript
298 lines
8.2 KiB
JavaScript
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();
|