File size: 9,232 Bytes
51c026d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
import { readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { type BundleFacetsResult, bundleFacets } from "./bundle.ts";

export interface BundleFacetPackageOptions {
	/** Plugin package directory or its package.json path. */
	readonly packagePath: string;
	readonly outdir: string;
	/** Application conventions applied when the corresponding source file exists. */
	readonly defaultFacets?: Readonly<Record<string, string>>;
}

export interface BundleFacetPackageResult extends BundleFacetsResult {
	readonly packageDirectory: string;
	readonly packageJsonPath: string;
}

interface FacetPackageMetadata {
	readonly packageDirectory: string;
	readonly packageJsonPath: string;
	readonly name: string;
	readonly version: string;
	readonly peerDependencies: readonly string[];
	readonly configuredFacets: Readonly<Record<string, string | false>>;
	readonly external: readonly string[];
	readonly sourceMap: boolean;
}

/** Build a plugin package using package.json metadata and application-provided facet conventions. */
export async function bundleFacetPackage(options: BundleFacetPackageOptions): Promise<BundleFacetPackageResult> {
	const metadata = await readFacetPackageMetadata(options.packagePath);
	const entries = await resolveFacetEntries(metadata, options.defaultFacets ?? {});
	const external = [...metadata.peerDependencies, ...metadata.external].flatMap((specifier) => [
		specifier,
		`${specifier}/*`,
	]);
	const result = await bundleFacets({
		plugin: { id: metadata.name, version: metadata.version },
		entries,
		outdir: options.outdir,
		workingDirectory: metadata.packageDirectory,
		external,
		sourceMap: metadata.sourceMap,
	});
	return Object.freeze({
		...result,
		packageDirectory: metadata.packageDirectory,
		packageJsonPath: metadata.packageJsonPath,
	});
}

async function readFacetPackageMetadata(packagePath: string): Promise<FacetPackageMetadata> {
	if (packagePath.length === 0) throw new TypeError("Facet package path must not be empty");
	const candidate = resolve(packagePath);
	let packageDirectory: string;
	let packageJsonPath: string;
	let candidateStats: Awaited<ReturnType<typeof stat>>;
	try {
		candidateStats = await stat(candidate);
	} catch (error) {
		throw new Error(`Could not access facet package ${candidate}`, { cause: error });
	}
	if (candidateStats.isDirectory()) {
		packageDirectory = await realpath(candidate);
		packageJsonPath = join(packageDirectory, "package.json");
	} else if (candidateStats.isFile() && basename(candidate) === "package.json") {
		packageJsonPath = await realpath(candidate);
		packageDirectory = dirname(packageJsonPath);
	} else {
		throw new Error(`Facet package path must name a directory or package.json: ${candidate}`);
	}

	let parsed: unknown;
	try {
		parsed = JSON.parse(await readFile(packageJsonPath, "utf8"));
	} catch (error) {
		throw new Error(`Could not read facet package metadata ${packageJsonPath}`, { cause: error });
	}
	if (!isRecord(parsed)) throw new Error(`Facet package metadata must be an object: ${packageJsonPath}`);
	if (typeof parsed.name !== "string" || parsed.name.length === 0) {
		throw new Error(`Facet package must have a non-empty name: ${packageJsonPath}`);
	}
	if (typeof parsed.version !== "string" || parsed.version.length === 0) {
		throw new Error(`Facet package must have a non-empty version: ${packageJsonPath}`);
	}
	const peerDependencies = parsePeerDependencies(parsed.peerDependencies, packageJsonPath);
	const chord = parseChordConfiguration(parsed.chord, packageJsonPath);
	return {
		packageDirectory,
		packageJsonPath,
		name: parsed.name,
		version: parsed.version,
		peerDependencies,
		configuredFacets: chord.facets,
		external: chord.external,
		sourceMap: chord.sourceMap,
	};
}

function parsePeerDependencies(value: unknown, packageJsonPath: string): readonly string[] {
	if (value === undefined) return [];
	if (
		!isRecord(value) ||
		Object.entries(value).some(([name, version]) => name.length === 0 || typeof version !== "string")
	) {
		throw new Error(`Facet package has invalid peerDependencies: ${packageJsonPath}`);
	}
	return Object.freeze(Object.keys(value).sort());
}

function parseChordConfiguration(
	value: unknown,
	packageJsonPath: string,
): {
	readonly facets: Readonly<Record<string, string | false>>;
	readonly external: readonly string[];
	readonly sourceMap: boolean;
} {
	if (value === undefined) return { facets: Object.freeze({}), external: Object.freeze([]), sourceMap: true };
	if (!isRecord(value)) throw new Error(`Facet package chord configuration must be an object: ${packageJsonPath}`);
	if (Object.keys(value).some((key) => key !== "facets" && key !== "external" && key !== "sourceMap")) {
		throw new Error(`Facet package chord configuration has an unknown field: ${packageJsonPath}`);
	}
	const facets: Record<string, string | false> = {};
	if (value.facets !== undefined) {
		if (!isRecord(value.facets)) {
			throw new Error(`Facet package chord.facets must be an object: ${packageJsonPath}`);
		}
		for (const [name, source] of Object.entries(value.facets)) {
			if (name.length === 0 || (typeof source !== "string" && source !== false) || source === "") {
				throw new Error(`Facet package has an invalid chord.facets entry: ${packageJsonPath}`);
			}
			facets[name] = source;
		}
	}
	let external: readonly string[] = [];
	if (value.external !== undefined) {
		if (
			!Array.isArray(value.external) ||
			value.external.some((specifier) => typeof specifier !== "string" || specifier.length === 0)
		) {
			throw new Error(`Facet package chord.external must contain non-empty strings: ${packageJsonPath}`);
		}
		external = Object.freeze([...new Set(value.external as readonly string[])].sort());
	}
	if (value.sourceMap !== undefined && typeof value.sourceMap !== "boolean") {
		throw new Error(`Facet package chord.sourceMap must be a boolean: ${packageJsonPath}`);
	}
	return {
		facets: Object.freeze(facets),
		external,
		sourceMap: value.sourceMap ?? true,
	};
}

async function resolveFacetEntries(
	metadata: FacetPackageMetadata,
	defaultFacets: Readonly<Record<string, string>>,
): Promise<Readonly<Record<string, string>>> {
	const entries: Record<string, string> = {};
	for (const [name, source] of Object.entries(defaultFacets)) {
		validateFacetMapping(name, source, "default");
		const path = resolvePackageEntry(metadata.packageDirectory, source, name);
		try {
			const entryStats = await stat(path);
			if (!entryStats.isFile()) throw new Error(`Default facet entry ${name} is not a file: ${path}`);
			const canonicalPath = await realpath(path);
			validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name);
			entries[name] = canonicalPath;
		} catch (error) {
			if (isMissingPath(error)) continue;
			throw error;
		}
	}
	for (const [name, source] of Object.entries(metadata.configuredFacets)) {
		if (source === false) {
			delete entries[name];
			continue;
		}
		validateFacetMapping(name, source, "configured");
		const path = resolvePackageEntry(metadata.packageDirectory, source, name);
		let entryStats: Awaited<ReturnType<typeof stat>>;
		try {
			entryStats = await stat(path);
		} catch (error) {
			throw new Error(`Could not access configured facet entry ${name}: ${path}`, { cause: error });
		}
		if (!entryStats.isFile()) throw new Error(`Configured facet entry ${name} is not a file: ${path}`);
		const canonicalPath = await realpath(path);
		validateCanonicalPackageEntry(metadata.packageDirectory, canonicalPath, name);
		entries[name] = canonicalPath;
	}
	if (Object.keys(entries).length === 0) {
		throw new Error(`Facet package ${metadata.name} has no configured or conventional facet entries`);
	}
	return Object.freeze(entries);
}

function validateFacetMapping(name: string, source: string, kind: string): void {
	if (name.length === 0) throw new Error(`Facet package ${kind} entry name must not be empty`);
	if (source.length === 0) throw new Error(`Facet package ${kind} entry ${name} must have a source path`);
}

function resolvePackageEntry(packageDirectory: string, source: string, name: string): string {
	if (isAbsolute(source)) throw new Error(`Facet package entry ${name} must be relative to the package directory`);
	const path = resolve(packageDirectory, source);
	const relativePath = relative(packageDirectory, path);
	if (
		relativePath.length === 0 ||
		relativePath === ".." ||
		relativePath.startsWith(`..${sep}`) ||
		isAbsolute(relativePath)
	) {
		throw new Error(`Facet package entry ${name} escapes the package directory`);
	}
	return path;
}

function validateCanonicalPackageEntry(packageDirectory: string, path: string, name: string): void {
	const relativePath = relative(packageDirectory, path);
	if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
		throw new Error(`Facet package entry ${name} resolves outside the package directory`);
	}
}

function isMissingPath(error: unknown): boolean {
	return isRecord(error) && error.code === "ENOENT";
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === "object" && value !== null && !Array.isArray(value);
}