@persona/layout-editor-remote-module
Version:
Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.
195 lines (193 loc) • 8.58 kB
JavaScript
import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-CESoldIF.mjs";
import { z } from "zod";
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
//#region src/vite-plugin.ts
/**
* Vite plugin that ensures declared module documents are built/served and
* emits a `RemoteModuleRegistrationManifestV1[]` document for the host.
*
* Modules are configured directly here, not discovered by globbing the
* source tree — a module IS whatever's in `options.modules`, full stop.
*/
function personaLayoutModules(options) {
const manifestPath = (options.manifestPath ?? DEFAULT_MANIFEST_PATH).replace(/^\/+/, "");
let root = process.cwd();
let resolvedConfig;
let definitions = [];
return {
async closeBundle() {
const envConfig = this.environment?.config;
if (definitions.length === 0 || envConfig?.command !== "build") return;
if (envConfig.consumer !== "client") return;
const outDir = path.isAbsolute(envConfig.build.outDir) ? envConfig.build.outDir : path.resolve(root, envConfig.build.outDir);
const base = envConfig.base ?? "/";
const manifests = definitions.map((definition) => {
validateDefaultConfig(definition);
const manifest = {
...buildManifestBase(definition),
remote: { render: { document: documentUrlFor(root, base, definition.render) } }
};
const result = remoteModuleRegistrationManifestV1Schema.safeParse(manifest);
if (!result.success) throw new Error(`[persona-layout-modules] ${definition.definitionFilePath}: invalid manifest for "${definition.input.key}":\n${z.prettifyError(result.error)}`);
return manifest;
});
assertUniqueKeys(manifests);
const manifestFilePath = path.join(outDir, manifestPath);
await mkdir(path.dirname(manifestFilePath), { recursive: true });
await writeFile(manifestFilePath, JSON.stringify({ modules: manifests }, null, 2), "utf8");
},
config(config) {
root = config.root ? path.resolve(config.root) : process.cwd();
definitions = options.modules.map((input) => ({
definitionFilePath: input.key,
input,
render: resolveEntry(root, input.key, input.render)
}));
},
configEnvironment(name, envConfig, env) {
if (env.command !== "build") return;
if ((envConfig.consumer ?? (name === "client" ? "client" : "server")) !== "client") return;
const existingInputs = absoluteInputPaths(envConfig, root);
const toInject = {};
for (const definition of definitions) {
const delivery = definition.render;
if (!existingInputs.has(delivery.absolutePath)) {
toInject[entryNameFor(definition)] = delivery.absolutePath;
existingInputs.add(delivery.absolutePath);
}
}
if (Object.keys(toInject).length > 0) return { build: { rollupOptions: { input: {
...toInputRecord(envConfig.build?.rollupOptions?.input, root),
...toInject
} } } };
},
configResolved(config) {
resolvedConfig = config;
for (const definition of definitions) checkDeliveryExists(definition, definition.render);
},
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== `/${manifestPath}`) {
next();
return;
}
const base = resolvedConfig.base ?? "/";
const devManifests = definitions.map((definition) => ({
...buildManifestBase(definition),
remote: { render: { document: documentUrlFor(root, base, definition.render) } }
}));
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.end(JSON.stringify({ modules: devManifests }, null, 2));
});
},
name: "persona-layout-modules"
};
}
/**
* Every absolute path Vite is already going to build, per the app's
* `build.rollupOptions.input` (or the implicit default — Vite treats
* `<root>/index.html` as the one entry when nothing is configured).
*/
function absoluteInputPaths(config, root) {
const input = config.build?.rollupOptions?.input;
if (input == null) return new Set([path.join(root, "index.html")]);
if (typeof input === "string") return new Set([path.resolve(root, input)]);
if (Array.isArray(input)) return new Set(input.map((entry) => path.resolve(root, entry)));
return new Set(Object.values(input).map((entry) => path.resolve(root, entry)));
}
function assertUniqueKeys(manifests) {
const seen = /* @__PURE__ */ new Set();
for (const manifest of manifests) {
if (seen.has(manifest.key)) throw new Error(`[persona-layout-modules] duplicate module key "${manifest.key}" — keys must be unique within an app`);
seen.add(manifest.key);
}
}
function validateDefaultConfig(definition) {
const { input, definitionFilePath } = definition;
if (input.defaultConfig == null) return;
if (input.configSchema == null) throw new Error(`[persona-layout-modules] ${definitionFilePath}: defaultConfig requires a configSchema`);
const configSchema = input.configSchema instanceof z.ZodObject ? input.configSchema.partial() : input.configSchema;
for (const [layout, overrides] of Object.entries(input.defaultConfig)) {
const result = configSchema.safeParse(overrides);
if (!result.success) throw new Error(`[persona-layout-modules] ${definitionFilePath}: defaultConfig.${layout} does not match configSchema: ${z.prettifyError(result.error)}`);
}
}
/** Fields shared between the prod and dev manifest — everything but `remote`. */
function buildManifestBase(definition) {
const { input } = definition;
return {
allowAddingToLayout: input.allowAddingToLayout,
allowDynamicHeight: input.allowDynamicHeight,
badge: input.badge,
category: input.category,
categoryCustom: input.categoryCustom,
configSchema: input.configSchema ? z.toJSONSchema(input.configSchema) : void 0,
defaultConfig: input.defaultConfig,
defaultSize: input.defaultSize,
descriptionShort: input.descriptionShort,
displayName: input.displayName,
icon: input.icon,
internalUsersOnly: input.internalUsersOnly,
key: input.key,
layouts: input.layouts ?? [],
manifestVersion: "v1",
uiConfigSchema: input.uiConfigSchema
};
}
function checkDeliveryExists(definition, delivery) {
if (!existsSync(delivery.absolutePath)) throw new Error(`[persona-layout-modules] ${definition.definitionFilePath}: render "${delivery.sourcePath}" does not resolve to a file (${delivery.absolutePath})`);
}
/**
* A document's manifest URL: the author's `path` override when given (a
* logical SPA route, not a file location — used as-is, never joined with
* `base`), otherwise the html file's path relative to root (which Vite
* preserves in its build output for multi-page entries) joined with `base`.
* Vite's dev server serves files at the same `base`-relative paths, so dev and
* build share this.
*/
function documentUrlFor(root, base, delivery) {
if (delivery.path) return delivery.path;
return joinUrl(base, path.relative(root, delivery.absolutePath).split(path.sep).join("/"));
}
function entryNameFor(definition) {
return `layout-module--${definition.input.key}`;
}
function joinUrl(base, fileName) {
return `${base.endsWith("/") ? base : `${base}/`}${fileName}`.replace(/\/{2,}/g, "/");
}
function resolveEntry(root, key, spec) {
if (path.extname(spec.entry) !== ".html") throw new Error(`[persona-layout-modules] ${key}: render "${spec.entry}" must be an .html document — only document-delivered modules are supported`);
return {
absolutePath: path.resolve(root, spec.entry),
path: spec.path,
sourcePath: spec.entry
};
}
/**
* Normalizes whatever shape `build.rollupOptions.input` currently has (unset,
* a string, an array, or already an object) into the object form, so an
* injected document entry can be merged in as an additional key WITHOUT
* dropping what was already configured — including the implicit default
* `index.html` entry, which would otherwise silently disappear the moment this
* plugin assigns an object here.
*/
function toInputRecord(input, root) {
if (input == null) return { index: path.join(root, "index.html") };
if (typeof input === "string") return { index: path.resolve(root, input) };
if (Array.isArray(input)) {
const record$1 = {};
for (const entry of input) {
const absolutePath = path.resolve(root, entry);
record$1[path.basename(absolutePath, path.extname(absolutePath))] = absolutePath;
}
return record$1;
}
const record = {};
for (const [name, entry] of Object.entries(input)) record[name] = path.resolve(root, entry);
return record;
}
//#endregion
export { defineLayoutModule, personaLayoutModules };