@persona/layout-editor-remote-module 0.5.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,11 +10,7 @@ without editing the host.
10
10
  yarn add @persona/layout-editor-remote-module
11
11
  ```
12
12
 
13
- `vite`, `esbuild`, and `zod` are peer dependencies. The plugin's dev-mode bundling uses `esbuild`
14
- directly, and declaring it as a peer means it resolves to the same copy `vite` already pulls in
15
- rather than a second version. With Yarn's `node-modules` linker the hoisted transitive copy usually
16
- satisfies it; add `esbuild` to your own `devDependencies` if your package manager warns about an
17
- unmet peer.
13
+ `vite` and `zod` are peer dependencies.
18
14
 
19
15
  The optional `react` and `react-dom` peer dependencies are required only when using the React bridge
20
16
  components.
@@ -42,9 +38,8 @@ export default defineConfig({
42
38
  allowAddingToLayout: true,
43
39
  layouts: ['case'],
44
40
  defaultSize: { case: { width: 4, height: 20 } },
45
- // `.tsx`/`.js` — a `code` delivery, imported and rendered directly
46
- // in the host's own React tree.
47
- render: { entry: './src/layout-modules/risk-summary/render.tsx' },
41
+ // The host loads this HTML page in an iframe.
42
+ render: { entry: './src/layout-modules/risk-summary/index.html' },
48
43
  }),
49
44
  ],
50
45
  }),
@@ -55,9 +50,9 @@ export default defineConfig({
55
50
  An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
56
51
  specific layouts, `defaultSize` must provide an entry for each one.
57
52
 
58
- An `.html` entry (rather than `.tsx`/`.js`) is a `document` delivery instead — the host iframes it
59
- rather than importing anything. See `LayoutModuleEntry`'s doc comment in `src/define.ts` for the
60
- full contract, including the optional `path` field for a client-routed SPA.
53
+ Entries must be `.html` documents, which the host loads in an iframe. Non-HTML entries fail during
54
+ Vite config resolution. See `LayoutModuleEntry`'s doc comment in `src/define.ts` for the full
55
+ contract, including the optional `path` field for a client-routed SPA.
61
56
 
62
57
  ## Communicating with the host
63
58
 
@@ -141,9 +136,9 @@ The bridge's security model is intentionally narrow:
141
136
  `init` until the frame answers.
142
137
  - Messages use a versioned protocol that stays compatible with frames built against older releases
143
138
  (from the release after 0.5.1): receivers ignore message types they don't know and strip keys they
144
- don't know, and fields are never removed, renamed or narrowed. `context.layoutType` and
145
- `defaultConfig`'s `layoutType` can be layout types added later, so handle values you don't know.
146
- Credentials remain in the iframe URL and are never handled through bridge messages.
139
+ don't know, and fields are never removed, renamed or narrowed. `context.layoutType` can be a
140
+ layout type added later, so handle values you don't know. Credentials remain in the iframe URL and
141
+ are never handled through bridge messages.
147
142
  - `extraParams` rejects reserved names and credential-like keys.
148
143
 
149
144
  ### Consuming a manifest
@@ -1,4 +1,4 @@
1
- import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-BgGlnuFn.mjs";
1
+ import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-Dr_M54Kt.mjs";
2
2
  import { Component, useEffect, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -1,4 +1,4 @@
1
- import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-BgGlnuFn.mjs";
1
+ import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-Dr_M54Kt.mjs";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -19,15 +19,11 @@ declare const moduleBadgeSchema: z.ZodEnum<{
19
19
  new: "new";
20
20
  }>;
21
21
  /**
22
- * `code` (a chunk URL, direct-`import()`ed) and `document` (a page URL, host
23
- * iframes it instead) are mutually exclusive — a delivery is one or the
24
- * other, never both, never neither.
22
+ * The module's main view is a page URL that the host iframes.
25
23
  */
26
- declare const remoteDeliverySchema: z.ZodUnion<readonly [z.ZodObject<{
27
- code: z.ZodString;
28
- }, z.core.$strip>, z.ZodObject<{
24
+ declare const remoteDeliverySchema: z.ZodObject<{
29
25
  document: z.ZodString;
30
- }, z.core.$strip>]>;
26
+ }, z.core.$strip>;
31
27
  declare const remoteModuleRegistrationManifestV1Schema: z.ZodObject<{
32
28
  allowAddingToLayout: z.ZodBoolean;
33
29
  allowDynamicHeight: z.ZodOptional<z.ZodBoolean>;
@@ -43,6 +39,7 @@ declare const remoteModuleRegistrationManifestV1Schema: z.ZodObject<{
43
39
  management: "management";
44
40
  related_records: "related_records";
45
41
  }>>;
42
+ categoryCustom: z.ZodOptional<z.ZodString>;
46
43
  configSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
47
44
  defaultSize: z.ZodRecord<z.ZodEnum<{
48
45
  account: "account";
@@ -64,12 +61,9 @@ declare const remoteModuleRegistrationManifestV1Schema: z.ZodObject<{
64
61
  }>>>;
65
62
  manifestVersion: z.ZodLiteral<"v1">;
66
63
  remote: z.ZodObject<{
67
- css: z.ZodOptional<z.ZodArray<z.ZodString>>;
68
- render: z.ZodUnion<readonly [z.ZodObject<{
69
- code: z.ZodString;
70
- }, z.core.$strip>, z.ZodObject<{
64
+ render: z.ZodObject<{
71
65
  document: z.ZodString;
72
- }, z.core.$strip>]>;
66
+ }, z.core.$strip>;
73
67
  }, z.core.$strip>;
74
68
  uiConfigSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
75
69
  }, z.core.$strip>;
@@ -89,36 +83,8 @@ type LayoutType = z.infer<typeof layoutTypeSchema>;
89
83
  type ModuleBadge = z.infer<typeof moduleBadgeSchema>;
90
84
  /** Mirrors the host's module-library categories. */
91
85
  type ModuleCategory = z.infer<typeof moduleCategorySchema>;
92
- /**
93
- * Options passed to a remote module's `defaultConfig` export. Narrower than the
94
- * host's default-config options; host-internal flags are not exposed to remote
95
- * modules.
96
- */
97
- interface RemoteModuleDefaultConfigOptions {
98
- /** Can be a layout type added after the module's release; handle values you don't know. */
99
- layoutType: LayoutType | (string & {});
100
- position?: {
101
- x: number;
102
- y: number;
103
- };
104
- }
105
- /** See `RemoteModuleRegistrationManifestV1['remote']['render']`. */
86
+ /** A page URL loaded by the host in an iframe. */
106
87
  type RemoteModuleDelivery = z.infer<typeof remoteDeliverySchema>;
107
- /**
108
- * Conventional named exports a `{ code }` delivery's module may provide. All
109
- * optional and feature-detected by name — see the "one URL, conventional
110
- * named exports" design decision. Rendering/sandboxing `render` is out of
111
- * scope for this package; only `defaultConfig` has a concrete consumer here
112
- * (the host's default-config loader). Not applicable to a `document`
113
- * delivery — nothing is ever imported for those, so none of these exports are
114
- * reachable.
115
- */
116
- interface RemoteModuleExports {
117
- defaultConfig?: (opts: RemoteModuleDefaultConfigOptions) => {
118
- config: Json;
119
- };
120
- render?: unknown;
121
- }
122
88
  /**
123
89
  * Wire format for a remotely-defined layout module. Must stay
124
90
  * JSON-serializable: no functions, no ReactNode, no zod.
@@ -138,9 +104,8 @@ type JsonValue = Json;
138
104
  * it carries a source/page path (`render`, resolved relative to the Vite root)
139
105
  * and a live zod schema (`configSchema`), which
140
106
  * the Vite plugin (./vite.ts) resolves into a `RemoteModuleRegistrationManifestV1`
141
- * at build time: a code entry becomes a hashed chunk URL, an html entry
142
- * becomes its (possibly `path`-overridden) served URL, and `configSchema`
143
- * becomes JSON Schema via `z.toJSONSchema()`.
107
+ * at build time: an html entry becomes its (possibly `path`-overridden)
108
+ * served URL, and `configSchema` becomes JSON Schema via `z.toJSONSchema()`.
144
109
  */
145
110
  interface LayoutModuleDefinitionInput {
146
111
  /**
@@ -154,6 +119,8 @@ interface LayoutModuleDefinitionInput {
154
119
  allowDynamicHeight?: boolean;
155
120
  badge?: ModuleBadge;
156
121
  category?: ModuleCategory;
122
+ /** Free-form module-library category label; takes precedence over `category` when both are set. */
123
+ categoryCustom?: string;
157
124
  /** Authored as zod; converted to JSON Schema by the plugin. */
158
125
  configSchema?: z.ZodType;
159
126
  defaultSize: Partial<Record<LayoutType, {
@@ -189,24 +156,17 @@ interface LayoutModuleDefinitionInput {
189
156
  * definition file anymore; modules are configured directly wherever
190
157
  * `personaLayoutModules()` is called (typically vite.config.ts).
191
158
  *
192
- * `entry`'s FILE EXTENSION decides delivery kind:
193
- * - `.html` — a `document` delivery: the host iframes it instead of
194
- * importing anything. The plugin ensures Vite actually builds/serves
195
- * this file, detecting whether it's already one of the app's own build
196
- * entries (e.g. its main `index.html`) and doing nothing extra if so —
197
- * only injecting it into the build when it isn't already there.
198
- * - anything else (`.ts`/`.tsx`/`.js`/`.jsx`) — a `code` delivery: built
199
- * into a chunk and executed directly in the host's own React tree.
200
- * Exports are feature-detected by conventional name — see
201
- * `RemoteModuleExports` in types.ts.
159
+ * `entry` must point to an `.html` document that the host iframes. The plugin
160
+ * ensures Vite builds/serves the file, detecting whether it's already one of
161
+ * the app's own build entries (e.g. its main `index.html`) and injecting it
162
+ * into the build only when it isn't already there.
202
163
  *
203
- * `path` is only meaningful alongside an `.html` entry, for a client-routed
204
- * SPA whose `index.html` already renders the whole app: rather than iframing
164
+ * `path` is for a client-routed SPA whose `index.html` already renders the
165
+ * whole app: rather than iframing
205
166
  * the SPA's root, this names the LOGICAL route its own router should land on
206
167
  * (e.g. `/embed/runs/1`) — the final `document` URL IS that path, not the
207
168
  * html file's physical output path, since a static/SPA-fallback server
208
- * serves the same `index.html` either way. Ignored (and meaningless) for a
209
- * non-`.html` entry.
169
+ * serves the same `index.html` either way.
210
170
  */
211
171
  interface LayoutModuleEntry {
212
172
  entry: string;
@@ -221,4 +181,4 @@ interface LayoutModuleEntry {
221
181
  */
222
182
  declare function defineLayoutModule(input: LayoutModuleDefinitionInput): LayoutModuleDefinitionInput;
223
183
  //#endregion
224
- export { JsonSchemaObject as a, ModuleCategory as c, RemoteModuleExports as d, RemoteModuleRegistrationManifestV1 as f, Json as i, RemoteModuleDefaultConfigOptions as l, remoteModuleRegistrationManifestV1Schema as m, LayoutModuleEntry as n, LayoutType as o, RemoteModuleRegistrationManifestV1Schema as p, defineLayoutModule as r, ModuleBadge as s, LayoutModuleDefinitionInput as t, RemoteModuleDelivery as u };
184
+ export { JsonSchemaObject as a, ModuleCategory as c, RemoteModuleRegistrationManifestV1Schema as d, remoteModuleRegistrationManifestV1Schema as f, Json as i, RemoteModuleDelivery as l, LayoutModuleEntry as n, LayoutType as o, defineLayoutModule as r, ModuleBadge as s, LayoutModuleDefinitionInput as t, RemoteModuleRegistrationManifestV1 as u };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as JsonSchemaObject, c as ModuleCategory, d as RemoteModuleExports, f as RemoteModuleRegistrationManifestV1, i as Json, l as RemoteModuleDefaultConfigOptions, m as remoteModuleRegistrationManifestV1Schema, n as LayoutModuleEntry, o as LayoutType, p as RemoteModuleRegistrationManifestV1Schema, r as defineLayoutModule, s as ModuleBadge, t as LayoutModuleDefinitionInput, u as RemoteModuleDelivery } from "./define-D0nvEX_B.mjs";
1
+ import { a as JsonSchemaObject, c as ModuleCategory, d as RemoteModuleRegistrationManifestV1Schema, f as remoteModuleRegistrationManifestV1Schema, i as Json, l as RemoteModuleDelivery, n as LayoutModuleEntry, o as LayoutType, r as defineLayoutModule, s as ModuleBadge, t as LayoutModuleDefinitionInput, u as RemoteModuleRegistrationManifestV1 } from "./define-Bxk_kfEr.mjs";
2
2
  import { z } from "zod";
3
3
 
4
4
  //#region src/manifest-path.d.ts
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  * one place that actually uses it) so it can also be re-exported from the
11
11
  * runtime-safe `.` entry — a consumer wiring up something like CORS for the
12
12
  * manifest route in its own server, outside of vite.config.ts, needs this
13
- * default without pulling in vite/esbuild.
13
+ * default without pulling in Vite.
14
14
  */
15
15
  declare const DEFAULT_MANIFEST_PATH = "/_layout_modules_manifest.json";
16
16
  //#endregion
@@ -44,6 +44,7 @@ declare const remoteModuleManifestDocumentSchema: z.ZodObject<{
44
44
  management: "management";
45
45
  related_records: "related_records";
46
46
  }>>;
47
+ categoryCustom: z.ZodOptional<z.ZodString>;
47
48
  configSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
48
49
  defaultSize: z.ZodRecord<z.ZodEnum<{
49
50
  account: "account";
@@ -65,12 +66,9 @@ declare const remoteModuleManifestDocumentSchema: z.ZodObject<{
65
66
  }>>>;
66
67
  manifestVersion: z.ZodLiteral<"v1">;
67
68
  remote: z.ZodObject<{
68
- css: z.ZodOptional<z.ZodArray<z.ZodString>>;
69
- render: z.ZodUnion<readonly [z.ZodObject<{
70
- code: z.ZodString;
71
- }, z.core.$strip>, z.ZodObject<{
69
+ render: z.ZodObject<{
72
70
  document: z.ZodString;
73
- }, z.core.$strip>]>;
71
+ }, z.core.$strip>;
74
72
  }, z.core.$strip>;
75
73
  uiConfigSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
76
74
  }, z.core.$strip>>;
@@ -95,4 +93,4 @@ declare function parseRemoteModuleManifest(json: unknown): RemoteModuleManifestD
95
93
  /** Non-throwing variant of `parseRemoteModuleManifest`. */
96
94
  declare function safeParseRemoteModuleManifest(json: unknown): RemoteModuleManifestParseResult;
97
95
  //#endregion
98
- export { DEFAULT_MANIFEST_PATH, type Json, type JsonSchemaObject, type LayoutModuleDefinitionInput, type LayoutModuleEntry, type LayoutType, type ModuleBadge, type ModuleCategory, type RemoteModuleDefaultConfigOptions, type RemoteModuleDelivery, type RemoteModuleExports, type RemoteModuleManifestDocument, RemoteModuleManifestParseError, type RemoteModuleManifestParseResult, type RemoteModuleRegistrationManifestV1, type RemoteModuleRegistrationManifestV1Schema, defineLayoutModule, parseRemoteModuleManifest, remoteModuleManifestDocumentSchema, remoteModuleRegistrationManifestV1Schema, safeParseRemoteModuleManifest };
96
+ export { DEFAULT_MANIFEST_PATH, type Json, type JsonSchemaObject, type LayoutModuleDefinitionInput, type LayoutModuleEntry, type LayoutType, type ModuleBadge, type ModuleCategory, type RemoteModuleDelivery, type RemoteModuleManifestDocument, RemoteModuleManifestParseError, type RemoteModuleManifestParseResult, type RemoteModuleRegistrationManifestV1, type RemoteModuleRegistrationManifestV1Schema, defineLayoutModule, parseRemoteModuleManifest, remoteModuleManifestDocumentSchema, remoteModuleRegistrationManifestV1Schema, safeParseRemoteModuleManifest };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-Bc3ZwB8y.mjs";
1
+ import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-DU2LTDDC.mjs";
2
2
  import { z } from "zod";
3
3
 
4
4
  //#region src/parse.ts
@@ -63,15 +63,10 @@ const gridSizeSchema = z.object({
63
63
  width: z.number().int().min(1).max(MAX_GRID_COLUMNS)
64
64
  });
65
65
  /**
66
- * `code` (a chunk URL, direct-`import()`ed) and `document` (a page URL, host
67
- * iframes it instead) are mutually exclusive — a delivery is one or the
68
- * other, never both, never neither.
66
+ * The module's main view is a page URL that the host iframes.
69
67
  */
70
- const remoteDeliverySchema = z.union([z.object({ code: z.string().min(1) }), z.object({ document: z.string().min(1) })]);
71
- const remoteEntrySchema = z.object({
72
- css: z.array(z.string()).optional(),
73
- render: remoteDeliverySchema
74
- });
68
+ const remoteDeliverySchema = z.object({ document: z.string().min(1) });
69
+ const remoteEntrySchema = z.object({ render: remoteDeliverySchema });
75
70
  /**
76
71
  * Loose check shared by `configSchema` and `uiConfigSchema` — this validates
77
72
  * the manifest envelope, not the arbitrary object either one carries. Both
@@ -85,6 +80,7 @@ const remoteModuleRegistrationManifestV1Schema = z.object({
85
80
  allowDynamicHeight: z.boolean().optional(),
86
81
  badge: moduleBadgeSchema.optional(),
87
82
  category: moduleCategorySchema.optional(),
83
+ categoryCustom: z.string().trim().min(1).max(80).optional(),
88
84
  configSchema: looseObjectSchema.optional(),
89
85
  defaultSize: z.partialRecord(layoutTypeSchema, gridSizeSchema),
90
86
  descriptionShort: z.string().min(1),
@@ -21,7 +21,7 @@ function defineLayoutModule(input) {
21
21
  * one place that actually uses it) so it can also be re-exported from the
22
22
  * runtime-safe `.` entry — a consumer wiring up something like CORS for the
23
23
  * manifest route in its own server, outside of vite.config.ts, needs this
24
- * default without pulling in vite/esbuild.
24
+ * default without pulling in Vite.
25
25
  */
26
26
  const DEFAULT_MANIFEST_PATH = "/_layout_modules_manifest.json";
27
27
 
@@ -61,15 +61,10 @@ const gridSizeSchema = z.object({
61
61
  width: z.number().int().min(1).max(MAX_GRID_COLUMNS)
62
62
  });
63
63
  /**
64
- * `code` (a chunk URL, direct-`import()`ed) and `document` (a page URL, host
65
- * iframes it instead) are mutually exclusive — a delivery is one or the
66
- * other, never both, never neither.
64
+ * The module's main view is a page URL that the host iframes.
67
65
  */
68
- const remoteDeliverySchema = z.union([z.object({ code: z.string().min(1) }), z.object({ document: z.string().min(1) })]);
69
- const remoteEntrySchema = z.object({
70
- css: z.array(z.string()).optional(),
71
- render: remoteDeliverySchema
72
- });
66
+ const remoteDeliverySchema = z.object({ document: z.string().min(1) });
67
+ const remoteEntrySchema = z.object({ render: remoteDeliverySchema });
73
68
  /**
74
69
  * Loose check shared by `configSchema` and `uiConfigSchema` — this validates
75
70
  * the manifest envelope, not the arbitrary object either one carries. Both
@@ -83,6 +78,7 @@ const remoteModuleRegistrationManifestV1Schema = z.object({
83
78
  allowDynamicHeight: z.boolean().optional(),
84
79
  badge: moduleBadgeSchema.optional(),
85
80
  category: moduleCategorySchema.optional(),
81
+ categoryCustom: z.string().trim().min(1).max(80).optional(),
86
82
  configSchema: looseObjectSchema.optional(),
87
83
  defaultSize: z.partialRecord(layoutTypeSchema, gridSizeSchema),
88
84
  descriptionShort: z.string().min(1),
@@ -1,4 +1,4 @@
1
- import { n as LayoutModuleEntry, r as defineLayoutModule, t as LayoutModuleDefinitionInput } from "./define-D0nvEX_B.mjs";
1
+ import { n as LayoutModuleEntry, r as defineLayoutModule, t as LayoutModuleDefinitionInput } from "./define-Bxk_kfEr.mjs";
2
2
  import { Plugin } from "vite";
3
3
 
4
4
  //#region src/vite-plugin.d.ts
@@ -12,28 +12,12 @@ interface PersonaLayoutModulesOptions {
12
12
  * either form resolves to the same path.
13
13
  */
14
14
  manifestPath?: string;
15
- /**
16
- * Plugins the production build of `code`-delivered layout-module entries
17
- * needs (e.g. `react()`, `tailwindcss()`) — called once. These entries are
18
- * built via a SEPARATE, isolated `vite.build()` call (see the
19
- * `closeBundle` hook), deliberately not part of the app's own build graph
20
- * — see its doc comment for why — so the app's own plugin list isn't
21
- * inherited automatically. Omit if your modules only need plain TS/JSX
22
- * (Vite's built-in esbuild transform already handles that with no plugin).
23
- * `document` (`.html`) entries never need this — they run through the
24
- * app's own build graph and its own configured plugins directly.
25
- */
26
- modulePlugins?: () => Plugin[];
27
15
  /** Every module this app registers — passed directly, not discovered. */
28
16
  modules: LayoutModuleDefinitionInput[];
29
17
  }
30
18
  /**
31
- * Vite plugin that takes a statically-declared list of layout modules
32
- * (`options.modules`, authored via `defineLayoutModule` — see ./define.ts),
33
- * wires every `code`-delivered entry into the build as an addressable ESM
34
- * chunk, ensures every `document`-delivered (`.html`) entry is actually
35
- * built/served, and emits a `RemoteModuleRegistrationManifestV1[]` document
36
- * the host can consume.
19
+ * Vite plugin that ensures declared module documents are built/served and
20
+ * emits a `RemoteModuleRegistrationManifestV1[]` document for the host.
37
21
  *
38
22
  * Modules are configured directly here, not discovered by globbing the
39
23
  * source tree — a module IS whatever's in `options.modules`, full stop.
@@ -1,129 +1,13 @@
1
- import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-Bc3ZwB8y.mjs";
1
+ import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-DU2LTDDC.mjs";
2
2
  import { z } from "zod";
3
- import { build } from "esbuild";
4
3
  import { existsSync } from "node:fs";
5
- import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
4
+ import { mkdir, writeFile } from "node:fs/promises";
6
5
  import path from "node:path";
7
- import { build as build$1 } from "vite";
8
- import { tmpdir } from "node:os";
9
6
 
10
- //#region src/remote-shims.ts
11
- /**
12
- * Specifiers a remote module bundle must NOT carry its own copy of — each is
13
- * redirected (via esbuild/Vite `alias`) to a tiny CJS shim that reads the
14
- * host's own instance off `globalThis.__PERSONA_REMOTE_MODULE_DEPS__` instead
15
- * of bundling the real package.
16
- *
17
- * Why this exists: hooks require the calling code's React module instance to
18
- * be the exact one actively rendering the tree. A remote module built with
19
- * its own bundled `react` (the default, before this) creates a SECOND,
20
- * unrelated React instance — any hook call from code using that instance
21
- * (including styled-components' own `useContext` inside `ThemeProvider`, and
22
- * anything in `@persona/design-system`, which itself just imports `react`)
23
- * reads a dispatcher that was never set, producing exactly
24
- * "Cannot read properties of null (reading 'useContext')". Sharing the
25
- * instance via a runtime global — rather than bundling it — fixes this for
26
- * every remote module, not just one that happens to avoid hooks.
27
- *
28
- * `styled-components` needs the same treatment for a subtler reason: even
29
- * with a shared React, a SEPARATE `styled-components` package instance would
30
- * call `React.createContext()` itself and produce a DIFFERENT Context object
31
- * than the host's — a `<ThemeProvider>` from one instance is invisible to
32
- * `styled.div` definitions from the other, no matter how the React inside
33
- * them is shared.
34
- */
35
- /**
36
- * Order matters: Vite's alias resolution (via its wrapped
37
- * `@rollup/plugin-alias`) treats a string `find` as a PREFIX match, not just
38
- * exact — `importee === find || importee.startsWith(find + '/')` — and takes
39
- * the FIRST entry (in object key / array order) that matches. Since
40
- * `'react/jsx-runtime'.startsWith('react/')` is true, listing `'react'`
41
- * before `'react/jsx-runtime'` would make every `react/jsx-runtime` import
42
- * resolve against the 'react' shim's path (e.g.
43
- * `.../react.cjs/jsx-runtime`, ENOTDIR) instead of its own shim. The more
44
- * specific specifier must come first.
45
- */
46
- const SHIMMABLE_SPECIFIERS = [
47
- "react/jsx-runtime",
48
- "react/jsx-dev-runtime",
49
- "react",
50
- "react-dom",
51
- "styled-components"
52
- ];
53
- const HOST_DEPS_GLOBAL = "__PERSONA_REMOTE_MODULE_DEPS__";
54
- /**
55
- * `module.exports = ...` (not a static ESM re-export) deliberately — an ESM
56
- * `export` list has to be known at author time, but the shimmed value's
57
- * actual shape lives on a runtime global. esbuild's own CJS interop
58
- * (`__toESM`) forwards named-import access to a CJS module's properties
59
- * dynamically, at RUNTIME, regardless of what the bundler could see
60
- * statically — the same mechanism that already makes `import { useState }
61
- * from 'react'` work today, since React itself ships as CJS.
62
- *
63
- * Consuming apps with `esModuleInterop` off in tsconfig (allowed by TS —
64
- * `allowSyntheticDefaultImports` permits the import *syntax* without it) get
65
- * esbuild's "Node mode" CJS interop for a DEFAULT import of an aliased CJS
66
- * file: `target.default = mod` (the whole `module.exports`), unconditionally
67
- * — `mod.__esModule` is NOT consulted in that mode, so it can't be used to
68
- * steer this. That's a correct model for a module whose "the module" and "the
69
- * default export" are the same thing (true for `react`/`react-dom`, which
70
- * have no own `.default`). It's wrong for `styled-components`: its
71
- * module.exports has BOTH a whole-module shape (ThemeProvider, css, ... as
72
- * plain properties) AND a separate own `.default` (the callable `styled` tag
73
- * function) — Node mode's `target.default = mod` picks the whole-module
74
- * shape, so `styled.div`/`.attrs(...)` throws on `undefined`.
75
- *
76
- * The fix: make OUR module.exports itself resolve the ambiguity — if the
77
- * host's real module has its own `.default` distinct from itself, export
78
- * THAT (with the rest of the module's named properties copied onto it, so
79
- * `styled.ThemeProvider`-style access — not used by styled-components itself,
80
- * but keeps this general — and named imports both still resolve). Mutating
81
- * the host's real default value (rather than building a fresh wrapper) is
82
- * deliberate: styled-components' internals sometimes compare a component
83
- * against the exact `styled` function reference (e.g. `isStyledComponent`);
84
- * a copy would break that. Ordinary functions/objects aren't frozen, so this
85
- * is safe for every specifier actually on this list.
86
- */
87
- function shimModuleSource(specifier) {
88
- const globalAccess = `globalThis.${HOST_DEPS_GLOBAL} && globalThis.${HOST_DEPS_GLOBAL}[${JSON.stringify(specifier)}]`;
89
- const missingMessage = `[persona-layout-modules] the host did not expose "${specifier}" via globalThis.${HOST_DEPS_GLOBAL} before this module loaded`;
90
- return [
91
- `var __persona_dep = ${globalAccess} || (() => { throw new Error(${JSON.stringify(missingMessage)}); })();`,
92
- `var __persona_base = (__persona_dep && 'default' in __persona_dep && __persona_dep.default !== __persona_dep) ? __persona_dep.default : __persona_dep;`,
93
- `module.exports = Object.assign(__persona_base, __persona_dep, { __esModule: true, default: __persona_base });`,
94
- ""
95
- ].join("\n");
96
- }
97
- let cachedShimPaths;
98
- /**
99
- * Writes one `.cjs` shim file per specifier to a temp dir (once per process
100
- * — the content is static, not per-request) and returns a specifier -> path
101
- * map suitable for esbuild's/Vite's `alias` option. `.cjs` is deliberate: it
102
- * forces CommonJS treatment regardless of any ambient `package.json#type`.
103
- */
104
- function ensureRemoteShimFiles() {
105
- if (!cachedShimPaths) cachedShimPaths = (async () => {
106
- const dir = await mkdtemp(path.join(tmpdir(), "persona-layout-module-shims-"));
107
- const entries = await Promise.all(SHIMMABLE_SPECIFIERS.map(async (specifier) => {
108
- const fileName = `${specifier.replace(/[\\/]/g, "_")}.cjs`;
109
- const filePath = path.join(dir, fileName);
110
- await writeFile(filePath, shimModuleSource(specifier), "utf8");
111
- return [specifier, filePath];
112
- }));
113
- return Object.fromEntries(entries);
114
- })();
115
- return cachedShimPaths;
116
- }
117
-
118
- //#endregion
119
7
  //#region src/vite-plugin.ts
120
8
  /**
121
- * Vite plugin that takes a statically-declared list of layout modules
122
- * (`options.modules`, authored via `defineLayoutModule` — see ./define.ts),
123
- * wires every `code`-delivered entry into the build as an addressable ESM
124
- * chunk, ensures every `document`-delivered (`.html`) entry is actually
125
- * built/served, and emits a `RemoteModuleRegistrationManifestV1[]` document
126
- * the host can consume.
9
+ * Vite plugin that ensures declared module documents are built/served and
10
+ * emits a `RemoteModuleRegistrationManifestV1[]` document for the host.
127
11
  *
128
12
  * Modules are configured directly here, not discovered by globbing the
129
13
  * source tree — a module IS whatever's in `options.modules`, full stop.
@@ -139,63 +23,27 @@ function personaLayoutModules(options) {
139
23
  if (definitions.length === 0 || envConfig?.command !== "build") return;
140
24
  if (envConfig.consumer !== "client") return;
141
25
  const outDir = path.isAbsolute(envConfig.build.outDir) ? envConfig.build.outDir : path.resolve(root, envConfig.build.outDir);
142
- const input = {};
143
- for (const definition of definitions) if (definition.render.kind === "code") input[entryNameFor(definition)] = definition.render.absolutePath;
144
- let emitted = [];
145
- if (Object.keys(input).length > 0) {
146
- const shimAlias = await ensureRemoteShimFiles();
147
- const result = await build$1({
148
- base: envConfig.base,
149
- build: {
150
- emptyOutDir: false,
151
- outDir,
152
- rollupOptions: {
153
- input,
154
- preserveEntrySignatures: "exports-only"
155
- },
156
- write: true
157
- },
158
- configFile: false,
159
- esbuild: { jsx: "automatic" },
160
- logLevel: "warn",
161
- mode: "production",
162
- plugins: options.modulePlugins?.() ?? [],
163
- resolve: { alias: {
164
- ...resolveAliasForEsbuild(envConfig),
165
- ...shimAlias
166
- } },
167
- root
168
- });
169
- emitted = (Array.isArray(result) ? result : [result]).flatMap((output) => "output" in output ? output.output : []);
170
- }
171
26
  const base = envConfig.base ?? "/";
172
- const manifests = [];
173
- for (const definition of definitions) {
174
- const render = resolveManifestDelivery(definition, definition.render, emitted, root, base);
175
- const css = /* @__PURE__ */ new Set();
176
- if (render.chunk) collectCss(render.chunk, css);
27
+ const manifests = definitions.map((definition) => {
177
28
  const manifest = {
178
29
  ...buildManifestBase(definition),
179
- remote: {
180
- css: css.size > 0 ? [...css].map((fileName) => joinUrl(base, fileName)) : void 0,
181
- render: render.value
182
- }
30
+ remote: { render: { document: documentUrlFor(root, base, definition.render) } }
183
31
  };
184
- const result2 = remoteModuleRegistrationManifestV1Schema.safeParse(manifest);
185
- if (!result2.success) throw new Error(`[persona-layout-modules] ${definition.definitionFilePath}: invalid manifest for "${definition.input.key}":\n${z.prettifyError(result2.error)}`);
186
- manifests.push(manifest);
187
- }
32
+ const result = remoteModuleRegistrationManifestV1Schema.safeParse(manifest);
33
+ if (!result.success) throw new Error(`[persona-layout-modules] ${definition.definitionFilePath}: invalid manifest for "${definition.input.key}":\n${z.prettifyError(result.error)}`);
34
+ return manifest;
35
+ });
188
36
  assertUniqueKeys(manifests);
189
37
  const manifestFilePath = path.join(outDir, manifestPath);
190
38
  await mkdir(path.dirname(manifestFilePath), { recursive: true });
191
39
  await writeFile(manifestFilePath, JSON.stringify({ modules: manifests }, null, 2), "utf8");
192
40
  },
193
- async config(config) {
41
+ config(config) {
194
42
  root = config.root ? path.resolve(config.root) : process.cwd();
195
43
  definitions = options.modules.map((input) => ({
196
44
  definitionFilePath: input.key,
197
45
  input,
198
- render: resolveEntry(root, input.render)
46
+ render: resolveEntry(root, input.key, input.render)
199
47
  }));
200
48
  },
201
49
  configEnvironment(name, envConfig, env) {
@@ -205,7 +53,7 @@ function personaLayoutModules(options) {
205
53
  const toInject = {};
206
54
  for (const definition of definitions) {
207
55
  const delivery = definition.render;
208
- if (delivery.kind === "document" && !existingInputs.has(delivery.absolutePath)) {
56
+ if (!existingInputs.has(delivery.absolutePath)) {
209
57
  toInject[entryNameFor(definition)] = delivery.absolutePath;
210
58
  existingInputs.add(delivery.absolutePath);
211
59
  }
@@ -220,47 +68,28 @@ function personaLayoutModules(options) {
220
68
  for (const definition of definitions) checkDeliveryExists(definition, definition.render);
221
69
  },
222
70
  configureServer(server) {
223
- server.middlewares.use(async (req, res, next) => {
224
- if (req.url === `/${manifestPath}`) {
225
- const base = resolvedConfig.base ?? "/";
226
- const devManifests = definitions.map((definition$1) => ({
227
- ...buildManifestBase(definition$1),
228
- remote: { render: devManifestDelivery(root, base, definition$1, definition$1.render) }
229
- }));
230
- res.setHeader("Content-Type", "application/json");
231
- res.setHeader("Access-Control-Allow-Origin", "*");
232
- res.end(JSON.stringify({ modules: devManifests }, null, 2));
233
- return;
234
- }
235
- const bundleTarget = matchDevBundleRequest(req.url, definitions);
236
- if (!bundleTarget) {
71
+ server.middlewares.use((req, res, next) => {
72
+ if (req.url !== `/${manifestPath}`) {
237
73
  next();
238
74
  return;
239
75
  }
240
- const { absolutePath, definition } = bundleTarget;
241
- try {
242
- const code = await bundleForDev(absolutePath, root, resolvedConfig);
243
- res.setHeader("Content-Type", "application/javascript; charset=utf-8");
244
- res.setHeader("Access-Control-Allow-Origin", "*");
245
- res.setHeader("Cache-Control", "no-store");
246
- res.end(code);
247
- } catch (error) {
248
- server.config.logger.error(`[persona-layout-modules] failed to dev-bundle ${definition.definitionFilePath}: ${String(error)}`);
249
- res.statusCode = 500;
250
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
251
- res.end(`[persona-layout-modules] failed to bundle: ${String(error)}`);
252
- }
76
+ const base = resolvedConfig.base ?? "/";
77
+ const devManifests = definitions.map((definition) => ({
78
+ ...buildManifestBase(definition),
79
+ remote: { render: { document: documentUrlFor(root, base, definition.render) } }
80
+ }));
81
+ res.setHeader("Content-Type", "application/json");
82
+ res.setHeader("Access-Control-Allow-Origin", "*");
83
+ res.end(JSON.stringify({ modules: devManifests }, null, 2));
253
84
  });
254
85
  },
255
86
  name: "persona-layout-modules"
256
87
  };
257
88
  }
258
- const DEV_BUNDLE_PREFIX = "/@persona-layout-module";
259
89
  /**
260
- * Every absolute path Vite is ALREADY going to build, per whatever the app's
261
- * own `build.rollupOptions.input` says (or the implicit default — Vite
262
- * treats `<root>/index.html` as the one entry when nothing is configured).
263
- * Used to decide whether a `document` entry needs injecting at all.
90
+ * Every absolute path Vite is already going to build, per the app's
91
+ * `build.rollupOptions.input` (or the implicit default — Vite treats
92
+ * `<root>/index.html` as the one entry when nothing is configured).
264
93
  */
265
94
  function absoluteInputPaths(config, root) {
266
95
  const input = config.build?.rollupOptions?.input;
@@ -284,6 +113,7 @@ function buildManifestBase(definition) {
284
113
  allowDynamicHeight: input.allowDynamicHeight,
285
114
  badge: input.badge,
286
115
  category: input.category,
116
+ categoryCustom: input.categoryCustom,
287
117
  configSchema: input.configSchema ? z.toJSONSchema(input.configSchema) : void 0,
288
118
  defaultSize: input.defaultSize,
289
119
  descriptionShort: input.descriptionShort,
@@ -296,50 +126,16 @@ function buildManifestBase(definition) {
296
126
  uiConfigSchema: input.uiConfigSchema
297
127
  };
298
128
  }
299
- /**
300
- * Bundles a single `code` entry (with its dependencies inlined) for dev-mode
301
- * cross-origin serving. Deliberately re-run on every request rather than
302
- * cached — these are small files and esbuild is fast enough that freshness
303
- * on every reload matters more than shaving milliseconds.
304
- */
305
- async function bundleForDev(absolutePath, root, resolvedConfig) {
306
- const shimAlias = await ensureRemoteShimFiles();
307
- const [output] = (await build({
308
- absWorkingDir: root,
309
- alias: {
310
- ...resolveAliasForEsbuild(resolvedConfig),
311
- ...shimAlias
312
- },
313
- bundle: true,
314
- entryPoints: [absolutePath],
315
- format: "esm",
316
- jsx: "automatic",
317
- sourcemap: "inline",
318
- write: false
319
- })).outputFiles;
320
- if (!output) throw new Error("esbuild produced no output");
321
- return output.text;
322
- }
323
129
  function checkDeliveryExists(definition, delivery) {
324
130
  if (!existsSync(delivery.absolutePath)) throw new Error(`[persona-layout-modules] ${definition.definitionFilePath}: render "${delivery.sourcePath}" does not resolve to a file (${delivery.absolutePath})`);
325
131
  }
326
- function collectCss(chunk, into) {
327
- for (const fileName of chunk.viteMetadata?.importedCss ?? []) into.add(fileName);
328
- }
329
- function devBundleUrl(definition) {
330
- return `${DEV_BUNDLE_PREFIX}/${entryNameFor(definition)}.js`;
331
- }
332
- function devManifestDelivery(root, base, definition, delivery) {
333
- if (delivery.kind === "document") return { document: documentUrlFor(root, base, delivery) };
334
- return { code: devBundleUrl(definition) };
335
- }
336
132
  /**
337
- * A `document` delivery's final PRODUCTION URL: the author's `path` override
338
- * when given (a logical SPA route, not a file location — used as-is, never
339
- * joined with `base`, matching how a subapp's own manifest URLs work),
340
- * otherwise the html file's own path relative to root (which Vite preserves
341
- * in its build output for multi-page entries) joined with `base`, since
342
- * that IS a real build-output location.
133
+ * A document's manifest URL: the author's `path` override when given (a
134
+ * logical SPA route, not a file location — used as-is, never joined with
135
+ * `base`), otherwise the html file's path relative to root (which Vite
136
+ * preserves in its build output for multi-page entries) joined with `base`.
137
+ * Vite's dev server serves files at the same `base`-relative paths, so dev and
138
+ * build share this.
343
139
  */
344
140
  function documentUrlFor(root, base, delivery) {
345
141
  if (delivery.path) return delivery.path;
@@ -348,72 +144,24 @@ function documentUrlFor(root, base, delivery) {
348
144
  function entryNameFor(definition) {
349
145
  return `layout-module--${definition.input.key}`;
350
146
  }
351
- function findChunkByFacadeModule(emitted, facadeModuleId) {
352
- for (const file of emitted) if (file.type === "chunk" && file.facadeModuleId === facadeModuleId) return file;
353
- }
354
147
  function joinUrl(base, fileName) {
355
148
  return `${base.endsWith("/") ? base : `${base}/`}${fileName}`.replace(/\/{2,}/g, "/");
356
149
  }
357
- function matchDevBundleRequest(url, definitions) {
358
- if (!url?.startsWith(`${DEV_BUNDLE_PREFIX}/`) || !url.endsWith(".js")) return;
359
- const name = url.slice(24, -3);
360
- for (const definition of definitions) if (definition.render.kind === "code" && name === entryNameFor(definition)) return {
361
- absolutePath: definition.render.absolutePath,
362
- definition
363
- };
364
- }
365
- /**
366
- * Vite normalizes `resolve.alias` (however the user wrote it — object or
367
- * array) into an array of `{ find, replacement }`. esbuild's own `alias`
368
- * option only accepts exact string keys (no RegExp, no partial-path
369
- * matching) — a real limitation this doesn't try to hide, just the common
370
- * case: a plain string-to-string alias map, which many apps use.
371
- */
372
- function resolveAliasForEsbuild(resolvedConfig) {
373
- const alias = {};
374
- for (const entry of resolvedConfig.resolve?.alias ?? []) if (typeof entry.find === "string") alias[entry.find] = entry.replacement;
375
- return alias;
376
- }
377
- /** `.html` → `document`; anything else (`.ts`/`.tsx`/`.js`/`.jsx`) → `code`. */
378
- function resolveEntry(root, spec) {
379
- const absolutePath = path.resolve(root, spec.entry);
380
- if (absolutePath.endsWith(".html")) return {
381
- absolutePath,
382
- kind: "document",
383
- path: spec.path,
384
- sourcePath: spec.entry
385
- };
150
+ function resolveEntry(root, key, spec) {
151
+ 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`);
386
152
  return {
387
- absolutePath,
388
- kind: "code",
153
+ absolutePath: path.resolve(root, spec.entry),
154
+ path: spec.path,
389
155
  sourcePath: spec.entry
390
156
  };
391
157
  }
392
158
  /**
393
- * Resolves the `render` delivery into its final manifest value for the
394
- * PRODUCTION path. `document` uses `documentUrlFor` and touches nothing else —
395
- * no chunk lookup, since it was never part of this plugin's own isolated
396
- * build. `code` becomes `{ code: <hashed chunk URL> }`, throwing if the
397
- * isolated build didn't emit a chunk for it. Also returns the chunk itself
398
- * (when there is one) so the caller can collect its CSS without a second
399
- * lookup.
400
- */
401
- function resolveManifestDelivery(definition, delivery, emitted, root, base) {
402
- if (delivery.kind === "document") return { value: { document: documentUrlFor(root, base, delivery) } };
403
- const chunk = findChunkByFacadeModule(emitted, delivery.absolutePath);
404
- if (!chunk) throw new Error(`[persona-layout-modules] ${definition.definitionFilePath}: could not locate the emitted chunk for render "${delivery.sourcePath}" in the isolated module build.`);
405
- return {
406
- chunk,
407
- value: { code: joinUrl(base, chunk.fileName) }
408
- };
409
- }
410
- /**
411
159
  * Normalizes whatever shape `build.rollupOptions.input` currently has (unset,
412
- * a string, an array, or already an object) into the object form, so a newly
413
- * injected `document` entry can be merged in as an additional key WITHOUT
414
- * dropping whatever was already configured — including the implicit default
415
- * `index.html` entry when nothing was configured at all, which would
416
- * otherwise silently disappear the moment this plugin assigns an object here.
160
+ * a string, an array, or already an object) into the object form, so an
161
+ * injected document entry can be merged in as an additional key WITHOUT
162
+ * dropping what was already configured — including the implicit default
163
+ * `index.html` entry, which would otherwise silently disappear the moment this
164
+ * plugin assigns an object here.
417
165
  */
418
166
  function toInputRecord(input, root) {
419
167
  if (input == null) return { index: path.join(root, "index.html") };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persona/layout-editor-remote-module",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.",
5
5
  "license": "SEE LICENSE",
6
6
  "type": "module",
@@ -38,7 +38,6 @@
38
38
  "release": "semantic-release"
39
39
  },
40
40
  "peerDependencies": {
41
- "esbuild": ">=0.21.0 <1",
42
41
  "react": "^17 || ^18 || ^19",
43
42
  "react-dom": "^17 || ^18 || ^19",
44
43
  "vite": "^6 || ^7 || ^8",
@@ -68,10 +67,8 @@
68
67
  "playwright": "1.62.1",
69
68
  "react": "^18.3.1",
70
69
  "react-dom": "^18.3.1",
71
- "rollup": "^4.0.0",
72
70
  "semantic-release": "^25.0.7",
73
71
  "semantic-release-monorepo": "^8.0.2",
74
- "styled-components": "^5.3.5",
75
72
  "tsdown": "^0.18.1",
76
73
  "typescript": "npm:@typescript/typescript6@^6.0.2",
77
74
  "vite": "^7.0.0",
@@ -95,7 +92,7 @@
95
92
  "@semantic-release/exec",
96
93
  {
97
94
  "prepareCmd": "yarn version ${nextRelease.version}",
98
- "publishCmd": "yarn npm publish --staged --access public"
95
+ "publishCmd": "yarn npm publish --access public"
99
96
  }
100
97
  ],
101
98
  "@semantic-release/github"
@@ -20,7 +20,7 @@ metadata:
20
20
 
21
21
  Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets Persona
22
22
  dashboard apps declare a layout module directly in a typical app's `vite.config.ts`, without editing
23
- the host. The plugin builds each module and emits a
23
+ the host. The plugin ensures each module's HTML document is built and emits a
24
24
  `{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document the host fetches and validates.
25
25
 
26
26
  This package is publicly available from the npm registry.
@@ -31,10 +31,8 @@ This package is publicly available from the npm registry.
31
31
  yarn add @persona/layout-editor-remote-module
32
32
  ```
33
33
 
34
- `vite`, `esbuild`, and `zod` are peer dependencies; `react` / `react-dom` (`^17 || ^18 || ^19`) are
35
- optional peers needed only by the `bridge-frame` entry. Add `esbuild` to your own `devDependencies`
36
- if your package manager warns about an unmet peer — the plugin's dev-mode bundling calls esbuild
37
- directly and should resolve to the same copy Vite already pulls in.
34
+ `vite` and `zod` are peer dependencies; `react` / `react-dom` (`^17 || ^18 || ^19`) are optional
35
+ peers needed only by the `bridge-frame` entry.
38
36
 
39
37
  ## Setup (vite.config.ts)
40
38
 
@@ -70,7 +68,12 @@ specific layouts, `defaultSize` must provide an entry for each one.
70
68
  Optional fields: `allowDynamicHeight` (expose the dynamic-height option in the module's
71
69
  configuration), `badge` (`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
72
70
  (`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to
73
- `build_your_own`), `icon` (an image path relative to the module's origin, such as
71
+ `build_your_own`), `categoryCustom` (a free-form module-library category label, up to 80 characters;
72
+ the host renders it verbatim — it is not translated — and groups modules that share the same
73
+ trimmed, case-sensitive string, even across different module sources. When both are set,
74
+ `categoryCustom` overrides `category`, and a label equal to one of the host's built-in category
75
+ labels lands in "Other"; keep setting `category` as a fallback for hosts that predate
76
+ `categoryCustom`), `icon` (an image path relative to the module's origin, such as
74
77
  `/assets/icon.svg`; the library shows a generic icon when it's omitted, fails to load, or is
75
78
  rejected as a cross-origin or non-http(s) URL), `internalUsersOnly` (a hint the host may use to
76
79
  narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below).
@@ -82,11 +85,10 @@ not relative to `vite.config.ts`'s own directory when `root` is overridden elsew
82
85
 
83
86
  ### Document delivery — iframed page
84
87
 
85
- A module's `render.entry` must end in `.html`: the host iframes it rather than importing anything.
86
- Any other extension (`.ts`/`.tsx`/`.js`) builds as a `code` delivery, which the plugin and manifest
87
- still accept but the host does not render yet — the module shows up in the library and renders
88
- nothing. Use `path` for a client-routed SPA whose `index.html` already renders the whole app — it
89
- names the logical route the SPA's own router should land on, not the file's build output path:
88
+ A module's `render.entry` must end in `.html`: the host iframes the page. Any other extension fails
89
+ during Vite config resolution with a clear error. Use `path` for a client-routed SPA whose
90
+ `index.html` already renders the whole app — it names the logical route the SPA's own router should
91
+ land on, not the file's build output path:
90
92
 
91
93
  ```ts
92
94
  defineLayoutModule({
@@ -449,6 +451,13 @@ requirement, and the host falls back to its own default size.
449
451
 
450
452
  Source: `src/schema.ts` (`remoteModuleRegistrationManifestV1Schema` `.refine`).
451
453
 
454
+ ### [MEDIUM] `render.entry` is not an HTML document
455
+
456
+ The entry must be an `.html` document. Other extensions fail in `config()` before the plugin checks
457
+ whether the file exists:
458
+
459
+ `[persona-layout-modules] <key>: render "<entry>" must be an .html document — only document-delivered modules are supported`
460
+
452
461
  ### [MEDIUM] `render.entry` doesn't resolve to a real file
453
462
 
454
463
  `personaLayoutModules` fails fast in `configResolved` — before running a full build — when an entry
@@ -478,7 +487,7 @@ personaLayoutModules({ manifestPath: '/manifests/my-app.json', modules: [] });
478
487
 
479
488
  Code outside `vite.config.ts` that needs the default path (e.g. a subapp server adding CORS on the
480
489
  manifest route) should import the constant from the runtime-safe `.` entry rather than hardcoding it
481
- — this avoids pulling in vite/esbuild:
490
+ — this avoids pulling in Vite:
482
491
 
483
492
  ```ts
484
493
  import { DEFAULT_MANIFEST_PATH } from '@persona/layout-editor-remote-module';