UNPKG

@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
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 };