175 lines
7.5 KiB
TypeScript
175 lines
7.5 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { isBuiltin } from "node:module";
|
|
import ts from "typescript-compiler-api";
|
|
|
|
import { HOST_WIDGET_MODULES } from "../src/package/schema";
|
|
import type { PackageLanguageLibraries } from "../src/workbench/package-language/types";
|
|
|
|
/** Emit the deployed SDK API and collect only installed declaration dependencies. No registry requests. */
|
|
export function buildEditorLanguageLibraries(repositoryRoot: string): PackageLanguageLibraries {
|
|
const sdkRoot = join(repositoryRoot, "packages/widget-sdk/src");
|
|
const sdkManifest = JSON.parse(readFileSync(join(sdkRoot, "../package.json"), "utf8")) as {
|
|
version: string;
|
|
exports: Record<string, string>;
|
|
};
|
|
const configPath = join(sdkRoot, "../tsconfig.json");
|
|
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n"));
|
|
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath));
|
|
const roots = Object.values(sdkManifest.exports)
|
|
.filter((file) => /\.tsx?$/u.test(file) && !file.includes("examples"))
|
|
.map((file) => resolve(sdkRoot, "..", file));
|
|
const program = ts.createProgram(roots, {
|
|
...parsed.options,
|
|
noEmit: false,
|
|
declaration: true,
|
|
emitDeclarationOnly: true,
|
|
declarationMap: false,
|
|
incremental: false,
|
|
isolatedModules: false,
|
|
});
|
|
const emitted = new Map<string, string>();
|
|
for (const file of program.getSourceFiles()) {
|
|
if (!file.fileName.startsWith(`${sdkRoot}/`)) continue;
|
|
if (file.fileName.includes(".d.")) {
|
|
emitted.set(file.fileName, file.text);
|
|
continue;
|
|
}
|
|
const result = program.emit(file, (path, text) => emitted.set(path, text), undefined, true);
|
|
if (
|
|
result.emitSkipped ||
|
|
result.diagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)
|
|
)
|
|
throw new Error(`Unable to emit SDK editor declarations: ${file.fileName}`);
|
|
}
|
|
|
|
const options: ts.CompilerOptions = {
|
|
module: ts.ModuleKind.ESNext,
|
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
target: ts.ScriptTarget.ESNext,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
allowImportingTsExtensions: true,
|
|
};
|
|
const importer = join(repositoryRoot, "packages/custom-widgets/__editor.ts");
|
|
const files: Record<string, string> = {};
|
|
const moduleRoots: Record<string, string> = {};
|
|
const queue: string[] = [];
|
|
const seen = new Set<string>();
|
|
const virtual = (file: string) => {
|
|
if (file.startsWith(`${sdkRoot}/`))
|
|
return `/node_modules/@homarr/widget-sdk/${declarationPath(file.slice(sdkRoot.length + 1))}`;
|
|
const marker = file.lastIndexOf("/node_modules/");
|
|
if (marker < 0) throw new Error(`Editor types cannot include a private workspace file: ${file}`);
|
|
return `/node_modules/${file.slice(marker + "/node_modules/".length)}`;
|
|
};
|
|
const add = (file: string) => {
|
|
if (seen.has(file) || !existsSync(file)) return;
|
|
seen.add(file);
|
|
queue.push(file);
|
|
};
|
|
const sdkModules = Object.entries(sdkManifest.exports)
|
|
.filter(([name, file]) => !name.includes("examples") && /\.tsx?$/u.test(file))
|
|
.map(([name]) => name.replace(/^\./u, "@homarr/widget-sdk"));
|
|
for (const name of new Set([...HOST_WIDGET_MODULES, ...sdkModules, "@tabler/icons-react"])) {
|
|
const module = ts.resolveModuleName(name, importer, options, ts.sys).resolvedModule;
|
|
if (!module) throw new Error(`Cannot resolve installed editor types: ${name}`);
|
|
moduleRoots[name] = virtual(module.resolvedFileName);
|
|
add(module.resolvedFileName);
|
|
}
|
|
const defaultLib = ts.getDefaultLibFilePath(options);
|
|
add(defaultLib);
|
|
const node = ts.resolveTypeReferenceDirective("node", importer, options, ts.sys).resolvedTypeReferenceDirective;
|
|
if (!node) throw new Error("Cannot resolve installed Node editor declarations");
|
|
add(node.resolvedFileName);
|
|
const printer = ts.createPrinter({ removeComments: true });
|
|
while (queue.length > 0) {
|
|
const file = queue.shift();
|
|
if (!file) break;
|
|
let source = readFileSync(file, "utf8");
|
|
if (file.startsWith(`${sdkRoot}/`)) {
|
|
const declaration = emitted.get(declarationPath(file));
|
|
if (declaration === undefined) throw new Error(`Missing emitted SDK declaration: ${file}`);
|
|
source = declaration;
|
|
}
|
|
const references = ts.preProcessFile(source, true, true);
|
|
// Keep SDK JSDoc; trim large repeated third-party comments without changing their signatures.
|
|
if (!file.startsWith(`${sdkRoot}/`))
|
|
source = printer.printFile(ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true));
|
|
files[virtual(file)] = source;
|
|
collectManifests(file, sdkRoot, files, virtual);
|
|
for (const reference of references.importedFiles) {
|
|
if (isBuiltin(reference.fileName)) continue;
|
|
const resolved = ts.resolveModuleName(reference.fileName, file, options, ts.sys).resolvedModule;
|
|
if (resolved && /\.[cm]?tsx?$/u.test(resolved.resolvedFileName)) add(resolved.resolvedFileName);
|
|
}
|
|
for (const reference of references.referencedFiles) add(resolve(dirname(file), reference.fileName));
|
|
for (const reference of references.typeReferenceDirectives) {
|
|
const resolved = ts.resolveTypeReferenceDirective(
|
|
reference.fileName,
|
|
file,
|
|
options,
|
|
ts.sys,
|
|
).resolvedTypeReferenceDirective;
|
|
if (resolved) add(resolved.resolvedFileName);
|
|
}
|
|
for (const reference of references.libReferenceDirectives)
|
|
add(join(dirname(defaultLib), `lib.${reference.fileName.toLowerCase()}.d.ts`));
|
|
}
|
|
files["/node_modules/@homarr/widget-sdk/package.json"] = JSON.stringify({
|
|
name: "@homarr/widget-sdk",
|
|
version: sdkManifest.version,
|
|
exports: Object.fromEntries(
|
|
Object.entries(moduleRoots)
|
|
.filter(([name]) => name.startsWith("@homarr/widget-sdk"))
|
|
.map(([name, file]) => [
|
|
name.replace("@homarr/widget-sdk", "."),
|
|
`./${file.slice("/node_modules/@homarr/widget-sdk/".length)}`,
|
|
]),
|
|
),
|
|
});
|
|
files["/widget-assets.d.ts"] = [
|
|
'declare module "*.css" { const classes: Record<string, string>; export default classes; }',
|
|
...["svg", "png", "jpg", "jpeg", "webp", "gif", "woff", "woff2", "txt", "md"].map(
|
|
(extension) => `declare module "*.${extension}" { const value: string; export default value; }`,
|
|
),
|
|
].join("\n");
|
|
return {
|
|
files: Object.fromEntries(Object.entries(files).toSorted(([left], [right]) => left.localeCompare(right, "en"))),
|
|
roots: moduleRoots,
|
|
globalFiles: [virtual(node.resolvedFileName), "/widget-assets.d.ts"],
|
|
defaultLib: virtual(defaultLib),
|
|
sdkVersion: sdkManifest.version,
|
|
typescriptVersion: ts.version,
|
|
};
|
|
}
|
|
|
|
function declarationPath(file: string) {
|
|
if (file.includes(".d.")) return file;
|
|
return file.replace(/\.tsx?$/u, ".d.ts");
|
|
}
|
|
|
|
function collectManifests(
|
|
file: string,
|
|
sdkRoot: string,
|
|
files: Record<string, string>,
|
|
virtual: (file: string) => string,
|
|
) {
|
|
if (file.startsWith(sdkRoot)) return;
|
|
let directory = dirname(file);
|
|
while (directory.includes("/node_modules/")) {
|
|
const path = join(directory, "package.json");
|
|
if (existsSync(path)) {
|
|
const manifest = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
files[virtual(path)] = JSON.stringify(
|
|
Object.fromEntries(
|
|
["name", "version", "types", "typings", "main", "exports", "typesVersions", "type"]
|
|
.filter((key) => manifest[key] !== undefined)
|
|
.map((key) => [key, manifest[key]]),
|
|
),
|
|
);
|
|
}
|
|
directory = dirname(directory);
|
|
}
|
|
}
|