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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/frontend",
3
- "version": "0.24.1-dev51",
3
+ "version": "0.24.1-dev52",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -179,6 +179,7 @@
179
179
  "lint:oxlint": "oxlint --fix",
180
180
  "lint:stylelint": "stylelint src/**/*.css --fix",
181
181
  "preview": "vite preview",
182
+ "plugins:generate-schema": "cross-env UPDATE_PLUGIN_SCHEMA=1 vitest run src/plugins/__tests__/plugin-schema.test.ts",
182
183
  "dev:quarto": "VITE_MARIMO_ISLANDS=true vite",
183
184
  "dev:islands": "cross-env VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts",
184
185
  "build:islands": "cross-env VITE_MARIMO_ISLANDS=true vite --config islands/vite.config.mts build",
@@ -228,6 +229,7 @@
228
229
  "vega-typings": "^2.1.0",
229
230
  "vite": "^8.2.1",
230
231
  "vite-plugin-wasm": "^3.6.0",
231
- "vitest": "^4.1.10"
232
+ "vitest": "^4.1.10",
233
+ "yaml": "^2.8.3"
232
234
  }
233
235
  }
@@ -0,0 +1,343 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { parse, stringify } from "yaml";
7
+ import { z } from "zod";
8
+ import { LAYOUT_PLUGINS, UI_PLUGINS } from "../plugins";
9
+ import {
10
+ buildPluginSpec,
11
+ type BuildPluginSpecOptions,
12
+ type PluginContract,
13
+ type PluginOpenAPIDocument,
14
+ } from "./plugin-schema";
15
+
16
+ const SCHEMA_PATH = resolve(
17
+ import.meta.dirname,
18
+ "../../../plugins.openapi.yaml",
19
+ );
20
+ const REGENERATE_COMMAND =
21
+ "pnpm --filter @marimo-team/frontend plugins:generate-schema";
22
+
23
+ // Keep this aggregation test-only: production initialization consumes the two
24
+ // registries directly and should not allocate or import schema-generation code.
25
+ const ALL_PLUGINS = [...UI_PLUGINS, ...LAYOUT_PLUGINS];
26
+
27
+ function schemaPath(...segments: PropertyKey[]): string {
28
+ return JSON.stringify(segments);
29
+ }
30
+
31
+ const ANY_WIDGET_MODEL_ID_PATH = schemaPath("properties", "modelId");
32
+ const DATA_TABLE_STATS_COMPONENTS = new Set([
33
+ "marimo-table.get_column_summaries.output",
34
+ "marimo-table.preview_column.output",
35
+ ]);
36
+ const DATA_TABLE_STATS_FIELDS = [
37
+ "min",
38
+ "max",
39
+ "std",
40
+ "mean",
41
+ "median",
42
+ "p5",
43
+ "p25",
44
+ "p75",
45
+ "p95",
46
+ ] as const;
47
+ const DATA_TABLE_NAN_PATHS = new Set(
48
+ DATA_TABLE_STATS_FIELDS.flatMap((field) => [
49
+ schemaPath("properties", "stats", "properties", field, "anyOf", 1),
50
+ schemaPath(
51
+ "properties",
52
+ "stats",
53
+ "additionalProperties",
54
+ "properties",
55
+ field,
56
+ "anyOf",
57
+ 1,
58
+ ),
59
+ ]),
60
+ );
61
+ const DATA_TABLE_BIN_DATE_PATHS = new Set([
62
+ schemaPath(
63
+ "properties",
64
+ "bin_values",
65
+ "additionalProperties",
66
+ "items",
67
+ "properties",
68
+ "bin_start",
69
+ "anyOf",
70
+ 2,
71
+ ),
72
+ schemaPath(
73
+ "properties",
74
+ "bin_values",
75
+ "additionalProperties",
76
+ "items",
77
+ "properties",
78
+ "bin_end",
79
+ "anyOf",
80
+ 2,
81
+ ),
82
+ ]);
83
+
84
+ const livePluginOverrides: BuildPluginSpecOptions = {
85
+ unrepresentableSchemaOverride: ({ componentName, path, type }) => {
86
+ const pathKey = schemaPath(...path);
87
+
88
+ // These validators accept values that JSON cannot encode directly. Their
89
+ // explicit wire representations live here to avoid adding schema metadata
90
+ // or allocations to production plugin initialization.
91
+ if (
92
+ componentName === "marimo-anywidget.data" &&
93
+ type === "custom" &&
94
+ pathKey === ANY_WIDGET_MODEL_ID_PATH
95
+ ) {
96
+ return { type: "string", minLength: 1 };
97
+ }
98
+ if (
99
+ DATA_TABLE_STATS_COMPONENTS.has(componentName) &&
100
+ type === "nan" &&
101
+ DATA_TABLE_NAN_PATHS.has(pathKey)
102
+ ) {
103
+ return { type: "number" };
104
+ }
105
+ if (
106
+ componentName === "marimo-table.get_column_summaries.output" &&
107
+ type === "custom" &&
108
+ DATA_TABLE_BIN_DATE_PATHS.has(pathKey)
109
+ ) {
110
+ return { type: "string", format: "date-time" };
111
+ }
112
+ return undefined;
113
+ },
114
+ };
115
+
116
+ const document = buildPluginSpec(ALL_PLUGINS, livePluginOverrides);
117
+
118
+ if (process.env.UPDATE_PLUGIN_SCHEMA) {
119
+ writeFileSync(
120
+ SCHEMA_PATH,
121
+ stringify(document, { aliasDuplicateObjects: false }),
122
+ );
123
+ process.stdout.write(`Updated ${SCHEMA_PATH}\n`);
124
+ }
125
+
126
+ function plugin(tagName: string, validator: z.ZodType): PluginContract {
127
+ return { tagName, validator };
128
+ }
129
+
130
+ function contractRefs(
131
+ doc: PluginOpenAPIDocument,
132
+ path: string,
133
+ ): { read: string; write: string } {
134
+ const contract = doc.paths[path] as {
135
+ get: {
136
+ responses: {
137
+ "200": {
138
+ content: { "application/json": { schema: { $ref: string } } };
139
+ };
140
+ };
141
+ };
142
+ put: {
143
+ requestBody: {
144
+ content: { "application/json": { schema: { $ref: string } } };
145
+ };
146
+ };
147
+ };
148
+ return {
149
+ read: contract.get.responses["200"].content["application/json"].schema.$ref,
150
+ write: contract.put.requestBody.content["application/json"].schema.$ref,
151
+ };
152
+ }
153
+
154
+ describe("frontend/plugins.openapi.yaml", () => {
155
+ it(`is in sync with the registered plugin schemas (run \`${REGENERATE_COMMAND}\` to update)`, () => {
156
+ if (!existsSync(SCHEMA_PATH)) {
157
+ throw new Error(
158
+ `Missing generated plugin schema at ${SCHEMA_PATH}. Run \`${REGENERATE_COMMAND}\` and commit the result.`,
159
+ );
160
+ }
161
+ const committed = parse(readFileSync(SCHEMA_PATH, "utf8"));
162
+ expect(
163
+ committed,
164
+ `The committed plugin schema is stale. Run \`${REGENERATE_COMMAND}\` and commit frontend/plugins.openapi.yaml.`,
165
+ ).toEqual(document);
166
+ });
167
+
168
+ it("contains every plugin data and RPC schema", () => {
169
+ const expectedContracts = ALL_PLUGINS.reduce(
170
+ (count, registeredPlugin) =>
171
+ count +
172
+ 1 +
173
+ 2 *
174
+ Object.keys(
175
+ "functions" in registeredPlugin
176
+ ? (registeredPlugin.functions ?? {})
177
+ : {},
178
+ ).length,
179
+ 0,
180
+ );
181
+ expect(
182
+ Object.keys(document.components.schemas).length,
183
+ ).toBeGreaterThanOrEqual(expectedContracts);
184
+ expect(Object.keys(document.paths)).toHaveLength(expectedContracts);
185
+
186
+ for (const registeredPlugin of ALL_PLUGINS as PluginContract[]) {
187
+ const dataName = `${registeredPlugin.tagName}.data`;
188
+ expect(
189
+ Object.hasOwn(document.components.schemas, dataName),
190
+ `Missing data component ${dataName}`,
191
+ ).toBe(true);
192
+ expect(
193
+ Object.hasOwn(
194
+ document.paths,
195
+ `/plugins/${registeredPlugin.tagName}/data`,
196
+ ),
197
+ `Missing data path for ${registeredPlugin.tagName}`,
198
+ ).toBe(true);
199
+
200
+ for (const functionName of Object.keys(
201
+ registeredPlugin.functions ?? {},
202
+ )) {
203
+ for (const direction of ["input", "output"] as const) {
204
+ const componentName = `${registeredPlugin.tagName}.${functionName}.${direction}`;
205
+ expect(
206
+ Object.hasOwn(document.components.schemas, componentName),
207
+ `Missing RPC component ${componentName}`,
208
+ ).toBe(true);
209
+ expect(
210
+ Object.hasOwn(
211
+ document.paths,
212
+ `/plugins/${registeredPlugin.tagName}/functions/${functionName}/${direction}`,
213
+ ),
214
+ `Missing RPC path for ${componentName}`,
215
+ ).toBe(true);
216
+ }
217
+ }
218
+ }
219
+ });
220
+
221
+ it("models representative data and RPC schemas bidirectionally", () => {
222
+ expect(contractRefs(document, "/plugins/marimo-button/data")).toEqual({
223
+ read: "#/components/schemas/marimo-button.data",
224
+ write: "#/components/schemas/marimo-button.data",
225
+ });
226
+ expect(
227
+ contractRefs(document, "/plugins/marimo-table/functions/search/input"),
228
+ ).toEqual({
229
+ read: "#/components/schemas/marimo-table.search.input",
230
+ write: "#/components/schemas/marimo-table.search.input",
231
+ });
232
+ expect(
233
+ contractRefs(document, "/plugins/marimo-table/functions/search/output"),
234
+ ).toEqual({
235
+ read: "#/components/schemas/marimo-table.search.output",
236
+ write: "#/components/schemas/marimo-table.search.output",
237
+ });
238
+ });
239
+
240
+ it("contains no dangling Zod definitions", () => {
241
+ expect(JSON.stringify(document)).not.toContain("#/$defs");
242
+ });
243
+ });
244
+
245
+ describe("buildPluginSpec", () => {
246
+ it("emits accepted input shapes for defaults and transforms", () => {
247
+ const doc = buildPluginSpec([
248
+ plugin(
249
+ "test-input",
250
+ z.object({
251
+ defaulted: z.string().default("default"),
252
+ transformed: z.string().transform((value) => value.length),
253
+ }),
254
+ ),
255
+ ]);
256
+ const schema = doc.components.schemas["test-input.data"] as {
257
+ properties: Record<string, { type: string; default?: unknown }>;
258
+ required?: string[];
259
+ };
260
+
261
+ expect(schema.properties.defaulted).toEqual({
262
+ type: "string",
263
+ default: "default",
264
+ });
265
+ expect(schema.properties.transformed.type).toBe("string");
266
+ expect(schema.required).toEqual(["transformed"]);
267
+ });
268
+
269
+ it("only rewrites shared-definition references", () => {
270
+ const referenceLikeValue = "#/components/schemas/__shared#/$defs/schema0";
271
+ const doc = buildPluginSpec([
272
+ plugin("test-reference-like-value", z.literal(referenceLikeValue)),
273
+ ]);
274
+
275
+ expect(
276
+ doc.components.schemas["test-reference-like-value.data"],
277
+ ).toMatchObject({ const: referenceLikeValue });
278
+ });
279
+
280
+ it("resolves recursive schemas through their OpenAPI component", () => {
281
+ interface RecursiveValue {
282
+ children: RecursiveValue[];
283
+ }
284
+ const recursive: z.ZodType<RecursiveValue> = z.lazy(() =>
285
+ z.object({ children: z.array(recursive) }),
286
+ );
287
+ const doc = buildPluginSpec([plugin("test-recursive", recursive)]);
288
+
289
+ expect(
290
+ JSON.stringify(doc.components.schemas["test-recursive.data"]),
291
+ ).toContain('"$ref":"#/components/schemas/test-recursive.data"');
292
+ expect(JSON.stringify(doc)).not.toContain("#/$defs");
293
+ });
294
+
295
+ it("uses explicit metadata for JSON-unrepresentable schemas", () => {
296
+ const doc = buildPluginSpec([
297
+ plugin(
298
+ "test-date",
299
+ z.instanceof(Date).meta({ type: "string", format: "date-time" }),
300
+ ),
301
+ ]);
302
+ expect(doc.components.schemas["test-date.data"]).toEqual({
303
+ type: "string",
304
+ format: "date-time",
305
+ });
306
+
307
+ expect(() =>
308
+ buildPluginSpec([
309
+ plugin(
310
+ "test-custom",
311
+ z.custom(() => true),
312
+ ),
313
+ ]),
314
+ ).toThrow(/explicit JSON Schema metadata.*test-only override/);
315
+ });
316
+
317
+ it("rejects duplicate tags and operation IDs", () => {
318
+ expect(() =>
319
+ buildPluginSpec([
320
+ plugin("duplicate", z.string()),
321
+ plugin("duplicate", z.number()),
322
+ ]),
323
+ ).toThrow("Duplicate plugin tag name duplicate");
324
+
325
+ expect(() =>
326
+ buildPluginSpec([
327
+ plugin("operation-a-b", z.string()),
328
+ plugin("operation-a_b", z.string()),
329
+ ]),
330
+ ).toThrow("Duplicate OpenAPI operationId");
331
+ });
332
+
333
+ it("rejects unresolved local component references", () => {
334
+ expect(() =>
335
+ buildPluginSpec([
336
+ plugin(
337
+ "test-missing-ref",
338
+ z.string().meta({ $ref: "#/components/schemas/Missing" }),
339
+ ),
340
+ ]),
341
+ ).toThrow("Unresolved OpenAPI component reference");
342
+ });
343
+ });
@@ -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(),