Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/build/spec.ts

Raw
import { existsSync, readFileSync, statSync } from "node:fs";
import { builtinModules } from "node:module";
import { basename, isAbsolute, join, relative } from "node:path";

/** Bare specifiers that stay external in every generated package. */
const PI_EXTERNAL_PATTERNS: readonly RegExp[] = [
	/^node:/,
	/^@earendil-works\//,
	/^@mariozechner\//,
	/^typebox(?:\/|$)/,
	/^@sinclair\/typebox(?:\/|$)/,
];

const BUILTINS = new Set(builtinModules);

interface ExtensionPackageJson {
	name: string;
	description?: string;
	license?: string;
	keywords?: string[];
	dependencies?: Record<string, string>;
	peerDependencies?: Record<string, string>;
	pi?: Partial<
		Record<"extensions" | "skills" | "prompts" | "themes", string[]>
	>;
	"pi-ext"?: {
		entries?: string[];
		/** Standalone Node entrypoints emitted as native JavaScript. */
		nodeEntries?: string[];
		/** Extra entries bundled only in non-release builds, for browser E2E against dist. */
		testEntries?: string[];
		/** Browser-only entrypoints emitted beneath browser/ with no Node runtime. */
		browserEntries?: string[];
		resources?: string[];
		/** Package-relative destination → `<dependency>/<file>` copied from a bundled dependency. */
		assets?: Record<string, string>;
		externals?: Record<string, string>;
		/** Declare externals in the package but resolve them at consume time (npm
		 * install on the consumer) instead of baking a build-platform node_modules.
		 * For native per-platform deps that cannot ship a single prebuilt tree. */
		consumeExternals?: boolean;
		/** Explicit public release declaration; absent means private. */
		publish?: boolean;
		publicName?: string;
		git?: string;
		releaseScenario?: string;
		/** README banner overrides; see scripts/readme/banners.ts. */
		logo?: { seed?: number; mutation?: number; version?: number };
	};
}

export interface ExtensionBuildSpec {
	/** Directory name under `extensions/`. */
	name: string;
	/** Absolute source directory. */
	dir: string;
	pkg: ExtensionPackageJson;
	/** Rolldown entry name → source file relative to `dir`. */
	entries: Record<string, string>;
	/** Non-release-only entries (browser E2E), keyed like `entries`. */
	testEntries: Record<string, string>;
	/** Browser-only entries, keyed like `entries`. */
	browserEntries: Record<string, string>;
	/** Files or directories copied verbatim into the package, relative to `dir`. */
	resources: string[];
	/** Package-relative destination → absolute source file taken from a dependency. */
	assets: Record<string, string>;
	/** Standalone Node entrypoints, bundled independently as native JavaScript. */
	nodeEntries: Record<string, string>;
	/** Third-party packages kept external with a recorded incompatibility reason. */
	externals: Record<string, string>;
	/** Externals resolved by the consumer's install rather than baked at build. */
	consumeExternals: boolean;
}

function fail(name: string, message: string): never {
	throw new Error(`${name}: ${message}`);
}

function safeRelative(
	name: string,
	dir: string,
	value: unknown,
	label: string,
) {
	if (typeof value !== "string" || !value)
		fail(name, `${label} must be a relative path`);
	if (isAbsolute(value) || value.includes("\\") || value.includes("\0"))
		fail(name, `${label} must be a relative slash-separated path`);
	const path = join(dir, value);
	const rel = relative(dir, path);
	if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
		fail(name, `${label} must stay inside the extension`);
	if (!existsSync(path)) fail(name, `${label} ${value} does not exist`);
	return value
		.split("/")
		.filter((part) => part !== ".")
		.join("/");
}

function findDependencyFile(
	name: string,
	dir: string,
	specifier: string,
): string {
	const [scopeOrName, ...rest] = specifier.split("/");
	const packageName = specifier.startsWith("@")
		? `${scopeOrName}/${rest.shift() ?? ""}`
		: (scopeOrName ?? "");
	const file = rest.join("/");
	if (!packageName || !file)
		fail(name, `pi-ext.assets source ${specifier} must be <package>/<file>`);
	let current = dir;
	while (true) {
		const candidate = join(current, "node_modules", packageName, file);
		if (existsSync(candidate)) return candidate;
		const parent = join(current, "..");
		if (relative(parent, current) === "" || parent === current) break;
		current = parent;
		if (current === "/" || /^[A-Za-z]:\\?$/u.test(current)) {
			const last = join(current, "node_modules", packageName, file);
			if (existsSync(last)) return last;
			break;
		}
	}
	return fail(name, `pi-ext.assets source ${specifier} is not installed`);
}

function entryName(file: string): string {
	return file.replace(/\.[cm]?[jt]sx?$/u, "");
}

export function readBuildSpec(dir: string): ExtensionBuildSpec {
	const name = basename(dir);
	const pkgPath = join(dir, "package.json");
	if (!existsSync(pkgPath)) fail(name, "missing package.json");
	const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ExtensionPackageJson;
	const declared = pkg["pi-ext"] ?? {};
	const entries: Record<string, string> = {};
	const piEntries = pkg.pi?.extensions?.length
		? pkg.pi.extensions
		: ["index.ts"];
	for (const entry of piEntries) {
		const file = safeRelative(name, dir, entry, "pi.extensions entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi.extensions entry ${file} must be a file`);
		entries[entryName(file)] = file;
	}
	for (const entry of declared.entries ?? []) {
		const file = safeRelative(name, dir, entry, "pi-ext.entries entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi-ext.entries entry ${file} must be a file`);
		const key = entryName(file);
		if (key in entries) fail(name, `duplicate entry ${file}`);
		entries[key] = file;
	}
	const nodeEntries: Record<string, string> = {};
	for (const entry of declared.nodeEntries ?? []) {
		const file = safeRelative(name, dir, entry, "pi-ext.nodeEntries entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi-ext.nodeEntries entry ${file} must be a file`);
		const key = entryName(file);
		if (key in entries || key in nodeEntries)
			fail(name, `duplicate entry ${file}`);
		nodeEntries[key] = file;
	}
	const testEntries: Record<string, string> = {};
	for (const entry of declared.testEntries ?? []) {
		const file = safeRelative(name, dir, entry, "pi-ext.testEntries entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi-ext.testEntries entry ${file} must be a file`);
		const key = entryName(file);
		if (key in entries || key in testEntries || key in nodeEntries)
			fail(name, `duplicate entry ${file}`);
		testEntries[key] = file;
	}
	const browserEntries: Record<string, string> = {};
	for (const entry of declared.browserEntries ?? []) {
		const file = safeRelative(name, dir, entry, "pi-ext.browserEntries entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi-ext.browserEntries entry ${file} must be a file`);
		const key = basename(entryName(file));
		if (key in browserEntries) fail(name, `duplicate entry ${file}`);
		browserEntries[key] = file;
	}
	const resources = (declared.resources ?? []).map((resource) =>
		safeRelative(name, dir, resource, "pi-ext.resources entry"),
	);
	const assets: Record<string, string> = {};
	for (const [destination, source] of Object.entries(declared.assets ?? {})) {
		if (
			typeof destination !== "string" ||
			!destination ||
			isAbsolute(destination) ||
			destination.split("/").some((part) => !part || part === "..")
		)
			fail(
				name,
				`pi-ext.assets destination ${destination} must be a relative path`,
			);
		if (typeof source !== "string")
			fail(name, `pi-ext.assets.${destination} must name <package>/<file>`);
		assets[destination] = findDependencyFile(name, dir, source);
	}
	const externals = declared.externals ?? {};
	for (const [dep, reason] of Object.entries(externals)) {
		if (typeof reason !== "string" || !reason.trim())
			fail(name, `pi-ext.externals.${dep} needs an incompatibility reason`);
		if (!pkg.dependencies?.[dep])
			fail(name, `pi-ext.externals.${dep} is not a declared dependency`);
	}
	return {
		name,
		dir,
		pkg,
		entries,
		testEntries,
		browserEntries,
		resources,
		nodeEntries,
		assets,
		externals,
		consumeExternals: declared.consumeExternals === true,
	};
}

export function packageOf(specifier: string): string {
	return specifier.startsWith("@")
		? specifier.split("/").slice(0, 2).join("/")
		: (specifier.split("/")[0] ?? specifier);
}

/** True when a bare specifier stays outside the generated bundle. */
export function isExternal(
	specifier: string,
	externals: Record<string, string>,
): boolean {
	if (specifier.startsWith(".") || isAbsolute(specifier)) return false;
	if (BUILTINS.has(specifier)) return true;
	if (PI_EXTERNAL_PATTERNS.some((pattern) => pattern.test(specifier)))
		return true;
	return packageOf(specifier) in externals;
}