@persona/layout-editor-remote-module 0.5.0 → 0.5.2

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
@@ -1,8 +1,8 @@
1
1
  # @persona/layout-editor-remote-module
2
2
 
3
3
  Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — used by
4
- app-builder Apps and `sa-*` subapps to declare a layout module directly in their own
5
- `vite.config.ts`, without editing persona-web.
4
+ Persona dashboard apps to declare a layout module directly in a typical app's `vite.config.ts`,
5
+ without editing the host.
6
6
 
7
7
  ## Install
8
8
 
@@ -81,6 +81,23 @@ import { RemoteModuleFrame } from '@persona/layout-editor-remote-module/bridge-h
81
81
 
82
82
  The required `sandbox` value is chosen by the host.
83
83
 
84
+ By default, the bridge accepts host origins matching `*.withpersona.com`,
85
+ `*.withpersona-staging.com`, `localhost`, and `127.0.0.1`. Add development or preview origins with
86
+ `allowedHostOrigins`, optionally composing the exported `personaDashboardHostOrigins` helper:
87
+
88
+ ```tsx
89
+ import {
90
+ LayoutModuleBridge,
91
+ personaDashboardHostOrigins,
92
+ } from '@persona/layout-editor-remote-module/bridge-frame';
93
+
94
+ <LayoutModuleBridge
95
+ allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)}
96
+ >
97
+ {({ data }) => <RiskSummary data={data} />}
98
+ </LayoutModuleBridge>;
99
+ ```
100
+
84
101
  The document entry uses `LayoutModuleBridge` as a render-prop component:
85
102
 
86
103
  ```tsx
@@ -108,11 +125,11 @@ cannot derive itself:
108
125
  iframe it arrives through `ready()`, `getContext()`, and the `context` render prop (all
109
126
  `RemoteModuleContext | null` until init).
110
127
 
111
- The host still appends `access_token`, `environment_id`, and `locale` as query parameters. The
112
- builder app or `sa-*` subapp hosting the module consumes and strips those parameters itself, so by
113
- the time `LayoutModuleBridge` runs they have already been handled; the bridge neither reads nor
114
- strips them. Keep the host iframe's `referrerpolicy` at its default `no-referrer` (or use
115
- `strict-origin`) so the URL parameters cannot leak through subsequent requests.
128
+ The host still appends `access_token`, `environment_id`, and `locale` as query parameters. The app
129
+ hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge`
130
+ runs they have already been handled; the bridge neither reads nor strips them. Keep the host
131
+ iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL
132
+ parameters cannot leak through subsequent requests.
116
133
 
117
134
  The bridge's security model is intentionally narrow:
118
135
 
@@ -1,6 +1,9 @@
1
1
  import { n as Json, t as RemoteModuleContext } from "./protocol-edoRdXpF.mjs";
2
2
  import { ReactNode } from "react";
3
3
 
4
+ //#region src/bridge/origin.d.ts
5
+ declare function personaDashboardHostOrigins(origin: string): boolean;
6
+ //#endregion
4
7
  //#region src/bridge/iframe/createModuleBridge.d.ts
5
8
  type ModuleBridgeInitPayload = {
6
9
  data: Json;
@@ -42,4 +45,4 @@ declare function LayoutModuleBridge({
42
45
  children
43
46
  }: LayoutModuleBridgeProps): ReactNode;
44
47
  //#endregion
45
- export { type Json, LayoutModuleBridge, type LayoutModuleBridgeProps, type LayoutModuleBridgeRenderProps, type RemoteModuleContext };
48
+ export { type Json, LayoutModuleBridge, type LayoutModuleBridgeProps, type LayoutModuleBridgeRenderProps, type RemoteModuleContext, personaDashboardHostOrigins };
@@ -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-DMsysRFd.mjs";
1
+ import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-Dz3ZX59L.mjs";
2
2
  import { Component, useEffect, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -347,4 +347,4 @@ function LayoutModuleBridge({ allowedHostOrigins, resolveTimeoutMs, observeHeigh
347
347
  }
348
348
 
349
349
  //#endregion
350
- export { LayoutModuleBridge };
350
+ export { LayoutModuleBridge, personaDashboardHostOrigins };
@@ -1,4 +1,4 @@
1
- import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-DMsysRFd.mjs";
1
+ import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-Dz3ZX59L.mjs";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -79,31 +79,20 @@ type RemoteModuleRegistrationManifestV1Schema = typeof remoteModuleRegistrationM
79
79
  type Json = boolean | JsonObject | JsonValue[] | null | number | string;
80
80
  /**
81
81
  * Loose JSON Schema object. Deliberately not a full JSON Schema type system —
82
- * this package only needs to carry what `z.toJSONSchema()` emits from one side
83
- * to whatever a consumer (e.g. persona-web's own `JsonSchemaObject`) does with
84
- * it on the other.
82
+ * this package only needs to carry what `z.toJSONSchema()` emits for consumers
83
+ * to validate or render.
85
84
  */
86
85
  type JsonSchemaObject = Record<string, unknown>;
87
- /**
88
- * Mirrors persona-web's `LayoutType`
89
- * (front-end/dashboard/modules/lib/types.ts).
90
- */
86
+ /** Mirrors the host's supported layout values. */
91
87
  type LayoutType = z.infer<typeof layoutTypeSchema>;
92
- /**
93
- * Mirrors persona-web's `ModuleBadge`
94
- * (front-end/dashboard/modules/lib/config-constants.tsx).
95
- */
88
+ /** Mirrors the host's supported module badges. */
96
89
  type ModuleBadge = z.infer<typeof moduleBadgeSchema>;
97
- /**
98
- * Mirrors persona-web's `ModuleCategory` values
99
- * (front-end/dashboard/modules/lib/module-library-config.ts).
100
- */
90
+ /** Mirrors the host's module-library categories. */
101
91
  type ModuleCategory = z.infer<typeof moduleCategorySchema>;
102
92
  /**
103
- * Options passed to a remote module's `defaultConfig` export. Deliberately
104
- * narrower than persona-web's own `ModuleDefaultConfigOptions` — the migration
105
- * flags on that type (e.g. `unifiedDataDisplayEnabled`) are host-internal
106
- * scaffolding a remote module has no business branching on.
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.
107
96
  */
108
97
  interface RemoteModuleDefaultConfigOptions {
109
98
  layoutType: LayoutType;
@@ -119,7 +108,7 @@ type RemoteModuleDelivery = z.infer<typeof remoteDeliverySchema>;
119
108
  * optional and feature-detected by name — see the "one URL, conventional
120
109
  * named exports" design decision. Rendering/sandboxing `render` is out of
121
110
  * scope for this package; only `defaultConfig` has a concrete consumer here
122
- * (persona-web's default-config loader). Not applicable to a `document`
111
+ * (the host's default-config loader). Not applicable to a `document`
123
112
  * delivery — nothing is ever imported for those, so none of these exports are
124
113
  * reachable.
125
114
  */
@@ -133,10 +122,8 @@ interface RemoteModuleExports {
133
122
  * Wire format for a remotely-defined layout module. Must stay
134
123
  * JSON-serializable: no functions, no ReactNode, no zod.
135
124
  *
136
- * Producers: `defineLayoutModule()` + the Vite plugin (this package), or
137
- * app-builder's own build pipeline using the same shape.
138
- * Consumers: persona-web's `manifestToRegistration()`
139
- * (front-end/dashboard/modules/lib/remote/adapt.ts).
125
+ * Producers: `defineLayoutModule()` and this package's Vite plugin.
126
+ * Consumers: the host application.
140
127
  */
141
128
  type RemoteModuleRegistrationManifestV1 = z.infer<typeof remoteModuleRegistrationManifestV1Schema>;
142
129
  type JsonObject = {
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-Dt8sbrVO.mjs";
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-C-3P4CFz.mjs";
2
2
  import { z } from "zod";
3
3
 
4
4
  //#region src/manifest-path.d.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-BeI8GdXS.mjs";
1
+ import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-Bc3ZwB8y.mjs";
2
2
  import { z } from "zod";
3
3
 
4
4
  //#region src/parse.ts
@@ -2,11 +2,9 @@ import { z } from "zod";
2
2
 
3
3
  //#region src/bridge/origin.ts
4
4
  const dashboardHostOriginPattern = /^https:\/\/([a-z0-9-]+\.)*withpersona(-staging)?\.com$/;
5
- const pagesHostOriginPattern = /^https:\/\/[a-z0-9-]+\.persona-web\.pages\.dev$/;
6
- const coderHostOriginPattern = /^https:\/\/[a-z0-9-]+\.coder\.development\.personerds\.com$/;
7
5
  const localHostOriginPattern = /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/;
8
6
  function personaDashboardHostOrigins(origin) {
9
- return dashboardHostOriginPattern.test(origin) || pagesHostOriginPattern.test(origin) || coderHostOriginPattern.test(origin) || localHostOriginPattern.test(origin);
7
+ return dashboardHostOriginPattern.test(origin) || localHostOriginPattern.test(origin);
10
8
  }
11
9
  function resolveAllowedHostOrigins(allowedHostOrigins) {
12
10
  if (allowedHostOrigins === void 0) return personaDashboardHostOrigins;
@@ -32,10 +30,8 @@ function assertNotSelfOrigin(moduleOrigin) {
32
30
  //#endregion
33
31
  //#region src/schema.ts
34
32
  /**
35
- * Mirrors persona-web's `LAYOUT_COLUMN_TOTAL`
36
- * (front-end/dashboard/modules/lib/constants.ts). The enum schemas below
37
- * mirror persona-web's corresponding enum values and are kept in sync by hand;
38
- * this package has no dependency on persona-web.
33
+ * Mirrors the host's layout grid column count; the enum schemas below must
34
+ * match the host's values.
39
35
  */
40
36
  const MAX_GRID_COLUMNS = 12;
41
37
  const layoutTypeSchema = z.enum([
@@ -28,10 +28,8 @@ const DEFAULT_MANIFEST_PATH = "/_layout_modules_manifest.json";
28
28
  //#endregion
29
29
  //#region src/schema.ts
30
30
  /**
31
- * Mirrors persona-web's `LAYOUT_COLUMN_TOTAL`
32
- * (front-end/dashboard/modules/lib/constants.ts). The enum schemas below
33
- * mirror persona-web's corresponding enum values and are kept in sync by hand;
34
- * this package has no dependency on persona-web.
31
+ * Mirrors the host's layout grid column count; the enum schemas below must
32
+ * match the host's values.
35
33
  */
36
34
  const MAX_GRID_COLUMNS = 12;
37
35
  const layoutTypeSchema = z.enum([
@@ -1,4 +1,4 @@
1
- import { n as LayoutModuleEntry, r as defineLayoutModule, t as LayoutModuleDefinitionInput } from "./define-Dt8sbrVO.mjs";
1
+ import { n as LayoutModuleEntry, r as defineLayoutModule, t as LayoutModuleDefinitionInput } from "./define-C-3P4CFz.mjs";
2
2
  import { Plugin } from "vite";
3
3
 
4
4
  //#region src/vite-plugin.d.ts
@@ -33,7 +33,7 @@ interface PersonaLayoutModulesOptions {
33
33
  * wires every `code`-delivered entry into the build as an addressable ESM
34
34
  * chunk, ensures every `document`-delivered (`.html`) entry is actually
35
35
  * built/served, and emits a `RemoteModuleRegistrationManifestV1[]` document
36
- * persona-web can intake.
36
+ * the host can consume.
37
37
  *
38
38
  * Modules are configured directly here, not discovered by globbing the
39
39
  * source tree — a module IS whatever's in `options.modules`, full stop.
@@ -1,4 +1,4 @@
1
- import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-BeI8GdXS.mjs";
1
+ import { n as DEFAULT_MANIFEST_PATH, r as defineLayoutModule, t as remoteModuleRegistrationManifestV1Schema } from "./schema-Bc3ZwB8y.mjs";
2
2
  import { z } from "zod";
3
3
  import { build } from "esbuild";
4
4
  import { existsSync } from "node:fs";
@@ -123,7 +123,7 @@ function ensureRemoteShimFiles() {
123
123
  * wires every `code`-delivered entry into the build as an addressable ESM
124
124
  * chunk, ensures every `document`-delivered (`.html`) entry is actually
125
125
  * built/served, and emits a `RemoteModuleRegistrationManifestV1[]` document
126
- * persona-web can intake.
126
+ * the host can consume.
127
127
  *
128
128
  * Modules are configured directly here, not discovered by globbing the
129
129
  * source tree — a module IS whatever's in `options.modules`, full stop.
@@ -135,15 +135,17 @@ function personaLayoutModules(options) {
135
135
  let definitions = [];
136
136
  return {
137
137
  async closeBundle() {
138
- if (definitions.length === 0 || resolvedConfig.command !== "build") return;
139
- const outDir = path.isAbsolute(resolvedConfig.build.outDir) ? resolvedConfig.build.outDir : path.resolve(root, resolvedConfig.build.outDir);
138
+ const envConfig = this.environment?.config;
139
+ if (definitions.length === 0 || envConfig?.command !== "build") return;
140
+ if (envConfig.consumer !== "client") return;
141
+ const outDir = path.isAbsolute(envConfig.build.outDir) ? envConfig.build.outDir : path.resolve(root, envConfig.build.outDir);
140
142
  const input = {};
141
143
  for (const definition of definitions) if (definition.render.kind === "code") input[entryNameFor(definition)] = definition.render.absolutePath;
142
144
  let emitted = [];
143
145
  if (Object.keys(input).length > 0) {
144
146
  const shimAlias = await ensureRemoteShimFiles();
145
147
  const result = await build$1({
146
- base: resolvedConfig.base,
148
+ base: envConfig.base,
147
149
  build: {
148
150
  emptyOutDir: false,
149
151
  outDir,
@@ -159,14 +161,14 @@ function personaLayoutModules(options) {
159
161
  mode: "production",
160
162
  plugins: options.modulePlugins?.() ?? [],
161
163
  resolve: { alias: {
162
- ...resolveAliasForEsbuild(resolvedConfig),
164
+ ...resolveAliasForEsbuild(envConfig),
163
165
  ...shimAlias
164
166
  } },
165
167
  root
166
168
  });
167
169
  emitted = (Array.isArray(result) ? result : [result]).flatMap((output) => "output" in output ? output.output : []);
168
170
  }
169
- const base = resolvedConfig.base ?? "/";
171
+ const base = envConfig.base ?? "/";
170
172
  const manifests = [];
171
173
  for (const definition of definitions) {
172
174
  const render = resolveManifestDelivery(definition, definition.render, emitted, root, base);
@@ -188,15 +190,18 @@ function personaLayoutModules(options) {
188
190
  await mkdir(path.dirname(manifestFilePath), { recursive: true });
189
191
  await writeFile(manifestFilePath, JSON.stringify({ modules: manifests }, null, 2), "utf8");
190
192
  },
191
- async config(config, env) {
193
+ async config(config) {
192
194
  root = config.root ? path.resolve(config.root) : process.cwd();
193
195
  definitions = options.modules.map((input) => ({
194
196
  definitionFilePath: input.key,
195
197
  input,
196
198
  render: resolveEntry(root, input.render)
197
199
  }));
200
+ },
201
+ configEnvironment(name, envConfig, env) {
198
202
  if (env.command !== "build") return;
199
- const existingInputs = absoluteInputPaths(config, root);
203
+ if ((envConfig.consumer ?? (name === "client" ? "client" : "server")) !== "client") return;
204
+ const existingInputs = absoluteInputPaths(envConfig, root);
200
205
  const toInject = {};
201
206
  for (const definition of definitions) {
202
207
  const delivery = definition.render;
@@ -205,14 +210,10 @@ function personaLayoutModules(options) {
205
210
  existingInputs.add(delivery.absolutePath);
206
211
  }
207
212
  }
208
- if (Object.keys(toInject).length > 0) {
209
- config.build ??= {};
210
- config.build.rollupOptions ??= {};
211
- config.build.rollupOptions.input = {
212
- ...toInputRecord(config.build.rollupOptions.input, root),
213
- ...toInject
214
- };
215
- }
213
+ if (Object.keys(toInject).length > 0) return { build: { rollupOptions: { input: {
214
+ ...toInputRecord(envConfig.build?.rollupOptions?.input, root),
215
+ ...toInject
216
+ } } } };
216
217
  },
217
218
  configResolved(config) {
218
219
  resolvedConfig = config;
@@ -366,8 +367,7 @@ function matchDevBundleRequest(url, definitions) {
366
367
  * array) into an array of `{ find, replacement }`. esbuild's own `alias`
367
368
  * option only accepts exact string keys (no RegExp, no partial-path
368
369
  * matching) — a real limitation this doesn't try to hide, just the common
369
- * case: a plain string-to-string alias map, which is what every `sa-*`
370
- * subapp's `vite.config.ts` actually uses.
370
+ * case: a plain string-to-string alias map, which many apps use.
371
371
  */
372
372
  function resolveAliasForEsbuild(resolvedConfig) {
373
373
  const alias = {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@persona/layout-editor-remote-module",
3
- "version": "0.5.0",
4
- "description": "Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules (app-builder Apps and sa-* subapps).",
3
+ "version": "0.5.2",
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",
7
7
  "types": "./dist/index.d.mts",
@@ -41,7 +41,7 @@
41
41
  "esbuild": ">=0.21.0 <1",
42
42
  "react": "^17 || ^18 || ^19",
43
43
  "react-dom": "^17 || ^18 || ^19",
44
- "vite": "^5 || ^6 || ^7 || ^8",
44
+ "vite": "^6 || ^7 || ^8",
45
45
  "zod": "^4.3"
46
46
  },
47
47
  "peerDependenciesMeta": {
@@ -1,37 +1,27 @@
1
1
  ---
2
2
  name: getting-started
3
3
  description: >
4
- Wire up @persona/layout-editor-remote-module in a sa-* subapp or app-builder app: register
5
- personaLayoutModules in vite.config.ts, author modules with defineLayoutModule using a document
6
- (.html) render delivery, add a zod configSchema and an RJSF uiConfigSchema, and talk to the host
7
- from the iframed document with LayoutModuleBridge from the bridge-frame entry (layout data, host
8
- context such as layoutType / layoutObjectId / isReadOnly, resolveDataPath, and height). Activate
9
- when adding a new layout module to an app, integrating this package for the first time, wiring a
10
- module document to the dashboard host, or debugging a manifest that fails to build or validate
11
- (duplicate key, missing defaultSize entry, render path not found) or a module that never receives
12
- data from the host.
4
+ Wire up @persona/layout-editor-remote-module in a Persona dashboard app: register
5
+ personaLayoutModules in a typical app's vite.config.ts, author modules with defineLayoutModule
6
+ using a document (.html) render delivery, add a zod configSchema and an RJSF uiConfigSchema
7
+ (labels, field order, DataSourceField data-source pickers), and talk to the host from the iframed
8
+ document with LayoutModuleBridge from the bridge-frame entry (layout data, host context such as
9
+ layoutType / layoutObjectId / isReadOnly, resolveDataPath, and height). Activate when adding a new
10
+ layout module to an app, integrating this package for the first time, wiring a module document to
11
+ the dashboard host, or debugging a manifest that fails to build or validate (duplicate key,
12
+ missing defaultSize entry, render path not found) or a module that never receives data from the
13
+ host.
13
14
  metadata:
14
15
  type: core
15
16
  library: '@persona/layout-editor-remote-module'
16
- library_version: '0.4.0'
17
- sources:
18
- - 'persona-id/persona-layout-editor:packages/remote-module/src/vite-plugin.ts'
19
- - 'persona-id/persona-layout-editor:packages/remote-module/src/define.ts'
20
- - 'persona-id/persona-layout-editor:packages/remote-module/src/schema.ts'
21
- - 'persona-id/persona-layout-editor:packages/remote-module/src/manifest-path.ts'
22
- - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/iframe/index.ts'
23
- - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/iframe/LayoutModuleBridge.tsx'
24
- - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/iframe/createModuleBridge.ts'
25
- - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/protocol.ts'
26
- - 'persona-id/persona-layout-editor:packages/remote-module/README.md'
27
17
  ---
28
18
 
29
19
  # Getting started with @persona/layout-editor-remote-module
30
20
 
31
- Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets a `sa-*`
32
- subapp or app-builder App declare a layout module directly in its own `vite.config.ts`, without
33
- editing persona-web. The plugin builds each module and emits a
34
- `{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document persona-web fetches and intakes.
21
+ Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets Persona
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
24
+ `{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document the host fetches and validates.
35
25
 
36
26
  This package is publicly available from the npm registry.
37
27
 
@@ -92,11 +82,11 @@ not relative to `vite.config.ts`'s own directory when `root` is overridden elsew
92
82
 
93
83
  ### Document delivery — iframed page
94
84
 
95
- A module's `render.entry` must end in `.html`: persona-web iframes it rather than importing
96
- anything. Any other extension (`.ts`/`.tsx`/`.js`) builds as a `code` delivery, which the plugin and
97
- manifest still accept but persona-web does not render yet — the module shows up in the library and
98
- renders nothing. Use `path` for a client-routed SPA whose `index.html` already renders the whole app
99
- — it names the logical route the SPA's own router should land on, not the file's build output path:
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:
100
90
 
101
91
  ```ts
102
92
  defineLayoutModule({
@@ -115,7 +105,7 @@ plugin detects that and doesn't inject it again.
115
105
 
116
106
  ### `configSchema` — validated module configuration
117
107
 
118
- Author config as zod; the plugin converts it to JSON Schema (`z.toJSONSchema()`) for persona-web's
108
+ Author config as zod; the plugin converts it to JSON Schema (`z.toJSONSchema()`) for the host's
119
109
  config panel:
120
110
 
121
111
  ```ts
@@ -134,9 +124,9 @@ as static JSON Schema, not re-executed as zod on the consuming side.
134
124
 
135
125
  ### `uiConfigSchema` — config panel layout
136
126
 
137
- persona-web renders the config panel with RJSF. Pass an RJSF `uiSchema` (widget choice, field order,
138
- labels) as `uiConfigSchema`; it is already plain data, so the plugin copies it into the manifest
139
- unchanged:
127
+ The host renders the config panel with RJSF. Pass an RJSF `uiSchema` as `uiConfigSchema`; it is
128
+ already plain data, so the plugin copies it into the manifest unchanged. Its top-level keys are the
129
+ `configSchema` property names, and each one holds that property's `ui:*` entries:
140
130
 
141
131
  ```ts
142
132
  defineLayoutModule({
@@ -152,11 +142,109 @@ defineLayoutModule({
152
142
  });
153
143
  ```
154
144
 
145
+ - **Labels and help text** come from the property's JSON Schema `title` / `description` — set them
146
+ with zod's `.meta({ title, description })` — and fall back to the property name. Override them per
147
+ field with `'ui:options': { title, description }`; the description shows as the label's tooltip.
148
+ - **Field order** is `'ui:order'` at the top level (use `'*'` for "everything else").
149
+ - **Controls are picked from the schema** without a `uiConfigSchema` entry: an enum renders as a
150
+ dropdown, an array of enum values with `uniqueItems: true` in its JSON Schema as a multi-select, a
151
+ boolean as a checkbox, and an array of objects as a list whose items open in an editing panel.
152
+ - **Everything is JSON.** The manifest is static JSON, so function-valued RJSF options
153
+ (`createItem`, `itemLabel`, …) and custom React fields/widgets cannot be supplied by a module —
154
+ only the `ui:field` names and options the host already registers, listed below.
155
+
156
+ #### `DataSourceField` — pick a value from the record
157
+
158
+ `'ui:field': 'DataSourceField'` turns a property into the host's data-source input: an
159
+ autocompleting method-chain input (e.g. `current_case.id`, or `query.<variable>` for a layout
160
+ variable) checked against the platform schema. Declare the property as a **string** — the field then
161
+ stores the chain itself as a string:
162
+
163
+ ```ts
164
+ defineLayoutModule({
165
+ // ...
166
+ layouts: ['account'],
167
+ defaultSize: { account: { width: 6, height: 24 } },
168
+ configSchema: z.object({
169
+ accountId: z.string().optional(),
170
+ }),
171
+ uiConfigSchema: {
172
+ accountId: {
173
+ 'ui:field': 'DataSourceField',
174
+ 'ui:options': {
175
+ title: 'Account ID',
176
+ description: 'Account whose transactions are monitored',
177
+ },
178
+ },
179
+ },
180
+ });
181
+ ```
182
+
183
+ `ui:options` it reads:
184
+
185
+ - `title`, `description` — label and tooltip; default to the schema's `title` / `description`.
186
+ - `expectedSchemaType` — the platform object type the chain must resolve to (e.g. `'case'`,
187
+ `'user'`); a chain resolving to another type is flagged. Unset means any chain is accepted. An
188
+ empty value is never validated.
189
+ - `rootPath` — limits validation to chains under one top-level property (e.g. `'current_case'`).
190
+ Only set it when `layouts` names a single layout that has that root; a chain outside `rootPath`
191
+ still saves but is not validated.
192
+
193
+ The config holds the **chain, not its value**. Resolve it at render time with `resolveDataPath` from
194
+ `LayoutModuleBridge` (below), skipping an unset field:
195
+
196
+ ```tsx
197
+ import { useEffect, useState } from 'react';
198
+ import {
199
+ type Json,
200
+ type LayoutModuleBridgeRenderProps,
201
+ } from '@persona/layout-editor-remote-module/bridge-frame';
202
+
203
+ function TransactionMonitor({
204
+ data,
205
+ resolveDataPath,
206
+ }: Pick<LayoutModuleBridgeRenderProps, 'data' | 'resolveDataPath'>) {
207
+ const chain = (data as { accountId?: string } | null)?.accountId;
208
+ const [accountId, setAccountId] = useState<Json>(null);
209
+
210
+ useEffect(() => {
211
+ setAccountId(null);
212
+ if (!chain) return;
213
+ let current = true;
214
+ resolveDataPath(chain).then(
215
+ (value) => current && setAccountId(value),
216
+ () => current && setAccountId(null),
217
+ );
218
+ return () => {
219
+ current = false;
220
+ };
221
+ }, [chain, resolveDataPath]);
222
+
223
+ // ...
224
+ }
225
+ ```
226
+
227
+ The host pushes config updates into the mounted module, so the chain can change or be cleared at any
228
+ time: reset the resolved value whenever it does, and ignore responses to an outdated chain.
229
+ `resolveDataPath` resolves to `null` when the chain can't be resolved on the current record and
230
+ rejects on a host error or timeout, so handle both.
231
+
232
+ #### Other `ui:options`
233
+
234
+ - `errorMessages` — per-keyword validation messages, e.g.
235
+ `{ 'ui:options': { errorMessages: { pattern: 'Use letters only.' } } }` (keys are JSON Schema
236
+ keywords: `pattern`, `minLength`, `maximum`, …).
237
+ - On an enum multi-select: `unsetSelectsAll: true` shows an unset value as every option selected,
238
+ for a key whose absence means "all" and should stay absent.
239
+ - On an array of objects: `identifierField` (item property that labels each row), `emptyText`,
240
+ `editLabel` (the editing panel's title), `listAriaLabel`, and `inlineItems: true` to render the
241
+ items inline instead of as a list with an editing panel.
242
+
155
243
  ### `bridge-frame` — talking to the host from the iframed document
156
244
 
157
- The host (persona-web / layout editor) renders a document-delivered module in an iframe and speaks
158
- to it over `postMessage`. Wrap the module's React root in `LayoutModuleBridge` from the
159
- `bridge-frame` entry; it owns the whole handshake and hands the module a render prop:
245
+ The host renders a document-delivered module in an iframe and speaks to it over `postMessage`. Wrap
246
+ the module's React root in `LayoutModuleBridge` from the `bridge-frame` entry; it owns the whole
247
+ handshake and hands the module a render prop:
160
248
 
161
249
  ```tsx
162
250
  import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';
@@ -179,7 +267,7 @@ export function App() {
179
267
  - `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module
180
268
  cannot derive itself. It arrives with the initial data and re-renders whenever it changes (see
181
269
  below).
182
- - `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host (persona-web) owns the
270
+ - `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host owns the
183
271
  implementation. It rejects if the host reports an error or does not answer within
184
272
  `resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened
185
273
  directly in a tab still renders.
@@ -189,9 +277,23 @@ export function App() {
189
277
  - Mount exactly one `LayoutModuleBridge` per document.
190
278
 
191
279
  Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`),
192
- `resolveTimeoutMs?: number`, `allowedHostOrigins?` (only needed to embed from a host origin outside
193
- the built-in Persona allow-list — `*.withpersona.com`, staging, `*.persona-web.pages.dev`, Coder,
194
- `localhost`).
280
+ `resolveTimeoutMs?: number`, `allowedHostOrigins?` (to add host origins outside the built-in
281
+ allow-list). Built-in origins include `*.withpersona.com`, `*.withpersona-staging.com`, `localhost`,
282
+ and `127.0.0.1`. Dev and preview dashboards must be added explicitly; compose the built-in predicate
283
+ with an app-specific check:
284
+
285
+ ```tsx
286
+ import {
287
+ LayoutModuleBridge,
288
+ personaDashboardHostOrigins,
289
+ } from '@persona/layout-editor-remote-module/bridge-frame';
290
+
291
+ <LayoutModuleBridge
292
+ allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)}
293
+ >
294
+ {({ data }) => <RiskSummary data={data} />}
295
+ </LayoutModuleBridge>;
296
+ ```
195
297
 
196
298
  #### Host context
197
299
 
@@ -223,11 +325,11 @@ function RiskSummary({ context }: { context: RemoteModuleContext | null }) {
223
325
 
224
326
  #### Access token and environment id
225
327
 
226
- The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The
227
- builder app or `sa-*` subapp hosting the module consumes and strips those parameters itself, so by
228
- the time `LayoutModuleBridge` runs they have already been handled; the bridge neither reads nor
229
- strips them. Keep the host iframe's `referrerpolicy` at its default `no-referrer` (or use
230
- `strict-origin`) so the URL parameters cannot leak through subsequent requests.
328
+ The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The app
329
+ hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge`
330
+ runs they have already been handled; the bridge neither reads nor strips them. Keep the host
331
+ iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL
332
+ parameters cannot leak through subsequent requests.
231
333
 
232
334
  ## Common Mistakes
233
335
 
@@ -276,6 +378,21 @@ resolve to `null` and `children` renders, so a stuck fallback is always a host/o
276
378
 
277
379
  Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`.
278
380
 
381
+ ### [HIGH] `DataSourceField` on a non-string property, or read as the resolved value
382
+
383
+ Declare a `DataSourceField` property as `z.string()`. On a non-string property the host stores a
384
+ structured data-source object instead of the chain string, and `resolveDataPath` only accepts a
385
+ string — it resolves to `null`. On a string property the stored value is the method chain the user
386
+ typed (e.g. `"current_account.id"`), not the account ID: pass `data.accountId` to `resolveDataPath`
387
+ rather than using it directly.
388
+
389
+ ### [MEDIUM] A `ui:field` name the host doesn't register
390
+
391
+ A `ui:field` name the host has no component for falls back to RJSF's default field for the
392
+ property's type — no build or validation error, just a plain input instead of the intended control.
393
+ Spell `'DataSourceField'` exactly; module-supplied field components are not possible (see
394
+ "Everything is JSON" above).
395
+
279
396
  ### [HIGH] `key` uses the wrong charset or collides with another module
280
397
 
281
398
  Wrong: