feat(custom-widgets): add extensible widget platform
This commit is contained in:
1 parent
6cca757493
commit
8f152f1929
317 files changed
+32326
-1788
No files matched your search
@@ -0,0 +1,41 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
|
||||
import { buildEditorLanguageLibraries } from "./editor-language-libraries";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../../..");
|
||||
const destination = resolve(repositoryRoot, "apps/nextjs/public/__widget-editor");
|
||||
const libraries = buildEditorLanguageLibraries(repositoryRoot);
|
||||
await mkdir(destination, { recursive: true });
|
||||
await writeFile(resolve(destination, "types.json"), JSON.stringify(libraries));
|
||||
await build({
|
||||
absWorkingDir: repositoryRoot,
|
||||
entryPoints: ["packages/custom-widgets/src/workbench/package-language/worker.ts"],
|
||||
outfile: resolve(destination, "worker.js"),
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
minify: true,
|
||||
legalComments: "linked",
|
||||
plugins: [
|
||||
{
|
||||
name: "browser-only-typescript-host",
|
||||
setup(builder) {
|
||||
// TypeScript detects browsers before creating ts.sys; no Node filesystem is available in the worker.
|
||||
builder.onResolve({ filter: /^(?:node:)?(?:fs|path|os|crypto|buffer|inspector|perf_hooks)$/ }, (args) => {
|
||||
if (isBuiltin(args.path)) return { path: args.path, namespace: "browser-typescript-node" };
|
||||
return undefined;
|
||||
});
|
||||
builder.onLoad({ filter: /.*/, namespace: "browser-typescript-node" }, () => ({
|
||||
contents: "export default {};",
|
||||
}));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
console.info(
|
||||
`Built local widget editor worker with ${Object.keys(libraries.files).length} declaration files (SDK ${libraries.sdkVersion}, TypeScript ${libraries.typescriptVersion})`,
|
||||
);
|
||||
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user