@marimo-team/frontend 0.24.1-dev51 → 0.24.1-dev53

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.
Files changed (25) hide show
  1. package/dist/assets/{add-cell-with-ai-B0RdHenk.js → add-cell-with-ai-hxrzwECc.js} +1 -1
  2. package/dist/assets/{agent-panel-i6F-fC5e.js → agent-panel-B9BFcufg.js} +1 -1
  3. package/dist/assets/{ai-model-dropdown-DjQy2bo-.js → ai-model-dropdown-a4n3JDbf.js} +1 -1
  4. package/dist/assets/{app-config-button-COFLeJCU.js → app-config-button-BQAVAUpN.js} +1 -1
  5. package/dist/assets/{cell-editor-X3xHqmCs.js → cell-editor-DfhJQCKm.js} +1 -1
  6. package/dist/assets/{chat-display-ClG9MRI_.js → chat-display-C7EsuQmC.js} +1 -1
  7. package/dist/assets/{chat-panel-BRoqCSCR.js → chat-panel-DCSD5g-j.js} +1 -1
  8. package/dist/assets/{chat-ui-CNvkb287.js → chat-ui-C3ay3Rdi.js} +1 -1
  9. package/dist/assets/{command-palette-L1LZCmet.js → command-palette-D1IoMI0F.js} +1 -1
  10. package/dist/assets/{edit-page-DOpi9pL6.js → edit-page-B1g7AdLD.js} +4 -4
  11. package/dist/assets/{home-page-Chy6SA41.js → home-page-P1nAvpLK.js} +1 -1
  12. package/dist/assets/{index-CNnOirLv.js → index-BMQJCzfh.js} +3 -3
  13. package/dist/assets/{layout-DmbNDyY3.js → layout-vg4c6imf.js} +2 -2
  14. package/dist/assets/{packages-panel-CYLyGihe.js → packages-panel-DMZtOQYl.js} +1 -1
  15. package/dist/assets/{panels-BMikLSqU.js → panels-DllVd27E.js} +1 -1
  16. package/dist/assets/{reveal-component-6t8SeOsr.js → reveal-component-D9uCWzLS.js} +1 -1
  17. package/dist/assets/{run-page-BIyr61hZ.js → run-page-ah4ptdoG.js} +1 -1
  18. package/dist/assets/{scratchpad-panel-B2KUccqM.js → scratchpad-panel-YOarJKLy.js} +1 -1
  19. package/dist/assets/{skeleton-DZV2_Wnl.js → skeleton-BX017Wzj.js} +1 -1
  20. package/dist/assets/{useNotebookActions-BcAzvJBD.js → useNotebookActions-DKMS8mUE.js} +1 -1
  21. package/dist/index.html +1 -1
  22. package/package.json +4 -2
  23. package/src/plugins/__tests__/plugin-schema.test.ts +343 -0
  24. package/src/plugins/__tests__/plugin-schema.ts +398 -0
  25. package/src/plugins/plugins.ts +1 -1
@@ -0,0 +1,398 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { z, type ZodType } from "zod";
4
+ import type { PluginFunctions } from "../core/rpc";
5
+ import type { IPlugin } from "../types";
6
+
7
+ export type PluginContract = Pick<
8
+ IPlugin<unknown, unknown, PluginFunctions>,
9
+ "tagName" | "validator" | "functions"
10
+ >;
11
+
12
+ export interface PluginOpenAPIDocument {
13
+ openapi: "3.1.0";
14
+ info: {
15
+ title: string;
16
+ version: string;
17
+ description: string;
18
+ };
19
+ tags: Array<{ name: string; description: string }>;
20
+ paths: Record<string, Record<string, unknown>>;
21
+ components: {
22
+ schemas: Record<string, Record<string, unknown>>;
23
+ };
24
+ }
25
+
26
+ export interface UnrepresentableSchemaContext {
27
+ componentName: string;
28
+ path: PropertyKey[];
29
+ type: string;
30
+ }
31
+
32
+ export interface BuildPluginSpecOptions {
33
+ unrepresentableSchemaOverride?: (
34
+ context: UnrepresentableSchemaContext,
35
+ ) => Record<string, unknown> | undefined;
36
+ }
37
+
38
+ const IDENTIFIER = /^[A-Za-z0-9._-]+$/;
39
+ const UNREPRESENTABLE_ZOD_TYPES = new Set([
40
+ "bigint",
41
+ "custom",
42
+ "date",
43
+ "function",
44
+ "map",
45
+ "nan",
46
+ "set",
47
+ "symbol",
48
+ "transform",
49
+ "undefined",
50
+ "void",
51
+ ]);
52
+ const JSON_SCHEMA_SHAPE_KEYS = [
53
+ "$ref",
54
+ "allOf",
55
+ "anyOf",
56
+ "const",
57
+ "enum",
58
+ "not",
59
+ "oneOf",
60
+ "type",
61
+ ] as const;
62
+
63
+ function assertIdentifier(value: string, kind: string): void {
64
+ if (!IDENTIFIER.test(value)) {
65
+ throw new Error(
66
+ `${kind} ${JSON.stringify(value)} must match ${IDENTIFIER.source}`,
67
+ );
68
+ }
69
+ }
70
+
71
+ function hasJSONSchemaShape(metadata: Record<string, unknown>): boolean {
72
+ return JSON_SCHEMA_SHAPE_KEYS.some((key) => key in metadata);
73
+ }
74
+
75
+ function getZodSchemaType(schema: unknown): string {
76
+ const definitions = schema as unknown as {
77
+ def?: { type?: unknown };
78
+ _zod?: { def?: { type?: unknown } };
79
+ };
80
+ const type = definitions.def?.type ?? definitions._zod?.def?.type;
81
+ if (typeof type !== "string") {
82
+ throw new Error("Unable to determine the Zod schema type");
83
+ }
84
+ return type;
85
+ }
86
+
87
+ function rewriteSharedReference(
88
+ reference: string,
89
+ componentName: string,
90
+ ): string {
91
+ const prefix = "#/components/schemas/__shared#/$defs/";
92
+ return reference.startsWith(prefix)
93
+ ? `#/components/schemas/${componentName}.def.${reference.slice(prefix.length)}`
94
+ : reference;
95
+ }
96
+
97
+ function rewriteSharedReferences(
98
+ value: unknown,
99
+ componentName: string,
100
+ ): unknown {
101
+ if (Array.isArray(value)) {
102
+ return value.map((item) => rewriteSharedReferences(item, componentName));
103
+ }
104
+ if (value === null || typeof value !== "object") {
105
+ return value;
106
+ }
107
+ return Object.fromEntries(
108
+ Object.entries(value).map(([key, item]) => [
109
+ key,
110
+ key === "$ref" && typeof item === "string"
111
+ ? rewriteSharedReference(item, componentName)
112
+ : rewriteSharedReferences(item, componentName),
113
+ ]),
114
+ );
115
+ }
116
+
117
+ function withoutDocumentMetadata(
118
+ schema: Record<string, unknown>,
119
+ ): Record<string, unknown> {
120
+ const { $id: _id, $schema: _schema, ...component } = schema;
121
+ return component;
122
+ }
123
+
124
+ function toComponentSchemas(
125
+ componentName: string,
126
+ schema: ZodType,
127
+ options: BuildPluginSpecOptions,
128
+ ): Record<string, Record<string, unknown>> {
129
+ const registry = z.registry<{ id: string }>();
130
+ registry.add(schema, { id: componentName });
131
+ const unrepresentablePaths = new Set<string>();
132
+ // Zod may visit the same emitted path more than once, such as for both an
133
+ // underlying custom schema and its metadata-bearing clone. The path is valid
134
+ // when any visit provides an explicit JSON Schema representation.
135
+ const representedPaths = new Set<string>();
136
+
137
+ const result = z.toJSONSchema(registry, {
138
+ io: "input",
139
+ unrepresentable: "any",
140
+ uri: (id) => `#/components/schemas/${id}`,
141
+ override: ({ jsonSchema, path, zodSchema }) => {
142
+ const type = getZodSchemaType(zodSchema);
143
+ if (!UNREPRESENTABLE_ZOD_TYPES.has(type)) {
144
+ return;
145
+ }
146
+
147
+ const pathKey = JSON.stringify(path);
148
+ const metadata = z.globalRegistry.get(zodSchema) ?? {};
149
+ if (hasJSONSchemaShape(metadata)) {
150
+ representedPaths.add(pathKey);
151
+ return;
152
+ }
153
+
154
+ const override = options.unrepresentableSchemaOverride?.({
155
+ componentName,
156
+ path,
157
+ type,
158
+ });
159
+ if (override && hasJSONSchemaShape(override)) {
160
+ Object.assign(jsonSchema, override);
161
+ representedPaths.add(pathKey);
162
+ } else {
163
+ unrepresentablePaths.add(pathKey);
164
+ }
165
+ },
166
+ });
167
+ for (const path of unrepresentablePaths) {
168
+ if (!representedPaths.has(path)) {
169
+ throw new Error(
170
+ `Zod schema at ${path} in ${componentName} is not representable in JSON Schema; add explicit JSON Schema metadata with .meta(...) or a narrowly scoped test-only override`,
171
+ );
172
+ }
173
+ }
174
+ const generated = result.schemas[componentName];
175
+ if (!generated) {
176
+ throw new Error(`Zod did not generate component ${componentName}`);
177
+ }
178
+
179
+ const components: Record<string, Record<string, unknown>> = {
180
+ [componentName]: withoutDocumentMetadata(
181
+ rewriteSharedReferences(generated, componentName) as Record<
182
+ string,
183
+ unknown
184
+ >,
185
+ ),
186
+ };
187
+ const shared = result.schemas.__shared as
188
+ | { $defs?: Record<string, Record<string, unknown>> }
189
+ | undefined;
190
+ for (const [definitionName, definition] of Object.entries(
191
+ shared?.$defs ?? {},
192
+ )) {
193
+ const name = `${componentName}.def.${definitionName}`;
194
+ assertIdentifier(name, "Generated definition name");
195
+ components[name] = withoutDocumentMetadata(
196
+ rewriteSharedReferences(definition, componentName) as Record<
197
+ string,
198
+ unknown
199
+ >,
200
+ );
201
+ }
202
+ return components;
203
+ }
204
+
205
+ function operationSuffix(componentName: string): string {
206
+ return componentName.replaceAll(/[^A-Za-z0-9]+/g, "_");
207
+ }
208
+
209
+ function addBidirectionalContract({
210
+ paths,
211
+ operationIds,
212
+ path,
213
+ componentName,
214
+ summary,
215
+ tag,
216
+ }: {
217
+ paths: Record<string, Record<string, unknown>>;
218
+ operationIds: Set<string>;
219
+ path: string;
220
+ componentName: string;
221
+ summary: string;
222
+ tag: string;
223
+ }): void {
224
+ if (paths[path]) {
225
+ throw new Error(`Duplicate OpenAPI path ${path}`);
226
+ }
227
+
228
+ const suffix = operationSuffix(componentName);
229
+ const readOperationId = `read_${suffix}`;
230
+ const writeOperationId = `write_${suffix}`;
231
+ for (const operationId of [readOperationId, writeOperationId]) {
232
+ if (operationIds.has(operationId)) {
233
+ throw new Error(`Duplicate OpenAPI operationId ${operationId}`);
234
+ }
235
+ operationIds.add(operationId);
236
+ }
237
+
238
+ const ref = { $ref: `#/components/schemas/${componentName}` };
239
+ paths[path] = {
240
+ summary,
241
+ get: {
242
+ operationId: readOperationId,
243
+ summary: `Read ${summary}`,
244
+ tags: [tag],
245
+ responses: {
246
+ "200": {
247
+ description: `${summary} accepted by a plugin consumer.`,
248
+ content: { "application/json": { schema: ref } },
249
+ },
250
+ },
251
+ },
252
+ put: {
253
+ operationId: writeOperationId,
254
+ summary: `Write ${summary}`,
255
+ tags: [tag],
256
+ requestBody: {
257
+ required: true,
258
+ content: { "application/json": { schema: ref } },
259
+ },
260
+ responses: { "204": { description: "Accepted." } },
261
+ },
262
+ };
263
+ }
264
+
265
+ function assertLocalReferencesResolve(document: PluginOpenAPIDocument): void {
266
+ const visit = (value: unknown): void => {
267
+ if (Array.isArray(value)) {
268
+ value.forEach(visit);
269
+ return;
270
+ }
271
+ if (value === null || typeof value !== "object") {
272
+ return;
273
+ }
274
+
275
+ const record = value as Record<string, unknown>;
276
+ const ref = record.$ref;
277
+ if (typeof ref === "string") {
278
+ if (ref.startsWith("#/$defs")) {
279
+ throw new Error(`Dangling document-root Zod reference ${ref}`);
280
+ }
281
+ const prefix = "#/components/schemas/";
282
+ if (
283
+ ref.startsWith(prefix) &&
284
+ !document.components.schemas[ref.slice(prefix.length)]
285
+ ) {
286
+ throw new Error(`Unresolved OpenAPI component reference ${ref}`);
287
+ }
288
+ }
289
+ Object.values(record).forEach(visit);
290
+ };
291
+
292
+ visit(document);
293
+ }
294
+
295
+ /**
296
+ * Build a synthetic OpenAPI document for the Zod-backed plugin contracts.
297
+ *
298
+ * Each schema is exposed as both a response and request so compatibility
299
+ * checks catch narrowing/removals as well as newly required fields. The paths
300
+ * describe internal frontend/kernel contracts; they are not HTTP endpoints.
301
+ */
302
+ export function buildPluginSpec(
303
+ plugins: readonly PluginContract[],
304
+ options: BuildPluginSpecOptions = {},
305
+ ): PluginOpenAPIDocument {
306
+ const paths: Record<string, Record<string, unknown>> = {};
307
+ const schemas: Record<string, Record<string, unknown>> = {};
308
+ const operationIds = new Set<string>();
309
+ const tagNames = new Set<string>();
310
+
311
+ const addComponent = (name: string, schema: ZodType): void => {
312
+ assertIdentifier(name, "Component name");
313
+ const generated = toComponentSchemas(name, schema, options);
314
+ for (const [generatedName, generatedSchema] of Object.entries(generated)) {
315
+ if (schemas[generatedName]) {
316
+ throw new Error(`Duplicate OpenAPI component ${generatedName}`);
317
+ }
318
+ schemas[generatedName] = generatedSchema;
319
+ }
320
+ };
321
+
322
+ for (const plugin of [...plugins].toSorted((a, b) =>
323
+ a.tagName.localeCompare(b.tagName),
324
+ )) {
325
+ assertIdentifier(plugin.tagName, "Plugin tag name");
326
+ if (tagNames.has(plugin.tagName)) {
327
+ throw new Error(`Duplicate plugin tag name ${plugin.tagName}`);
328
+ }
329
+ tagNames.add(plugin.tagName);
330
+
331
+ const dataComponent = `${plugin.tagName}.data`;
332
+ addComponent(dataComponent, plugin.validator);
333
+ addBidirectionalContract({
334
+ paths,
335
+ operationIds,
336
+ path: `/plugins/${plugin.tagName}/data`,
337
+ componentName: dataComponent,
338
+ summary: `${plugin.tagName} data`,
339
+ tag: "plugin-data",
340
+ });
341
+
342
+ const functions = Object.entries(plugin.functions ?? {}).toSorted(
343
+ ([a], [b]) => a.localeCompare(b),
344
+ );
345
+ for (const [functionName, contract] of functions) {
346
+ assertIdentifier(functionName, "Plugin function name");
347
+ for (const [direction, schema] of [
348
+ ["input", contract.input],
349
+ ["output", contract.output],
350
+ ] as const) {
351
+ const componentName = `${plugin.tagName}.${functionName}.${direction}`;
352
+ addComponent(componentName, schema);
353
+ addBidirectionalContract({
354
+ paths,
355
+ operationIds,
356
+ path: `/plugins/${plugin.tagName}/functions/${functionName}/${direction}`,
357
+ componentName,
358
+ summary: `${plugin.tagName} ${functionName} ${direction}`,
359
+ tag: `rpc-${direction}`,
360
+ });
361
+ }
362
+ }
363
+ }
364
+
365
+ const document: PluginOpenAPIDocument = {
366
+ openapi: "3.1.0",
367
+ info: {
368
+ title: "marimo plugin contracts",
369
+ version: "1.0.0",
370
+ description: [
371
+ "Machine-checkable description of every registered frontend plugin's",
372
+ "Zod-backed data and RPC contracts. This is not an HTTP API: each",
373
+ "synthetic GET models what consumers must accept and each PUT models",
374
+ "what producers may write, allowing OpenAPI diff tooling to enforce",
375
+ "backward compatibility in both directions.",
376
+ "Regenerate with: pnpm --filter @marimo-team/frontend plugins:generate-schema",
377
+ ].join("\n"),
378
+ },
379
+ tags: [
380
+ {
381
+ name: "plugin-data",
382
+ description: "Data supplied when rendering a plugin.",
383
+ },
384
+ {
385
+ name: "rpc-input",
386
+ description: "Arguments supplied to a plugin RPC function.",
387
+ },
388
+ {
389
+ name: "rpc-output",
390
+ description: "Values returned by a plugin RPC function.",
391
+ },
392
+ ],
393
+ paths,
394
+ components: { schemas },
395
+ };
396
+ assertLocalReferencesResolve(document);
397
+ return document;
398
+ }
@@ -97,7 +97,7 @@ export const UI_PLUGINS: IPlugin<any, unknown>[] = [
97
97
  ];
98
98
 
99
99
  // List of output / layout plugins
100
- const LAYOUT_PLUGINS: IStatelessPlugin<unknown>[] = [
100
+ export const LAYOUT_PLUGINS: IStatelessPlugin<unknown>[] = [
101
101
  new AccordionPlugin(),
102
102
  new CalloutPlugin(),
103
103
  new CarouselPlugin(),