@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.
- package/dist/assets/{add-cell-with-ai-B0RdHenk.js → add-cell-with-ai-hxrzwECc.js} +1 -1
- package/dist/assets/{agent-panel-i6F-fC5e.js → agent-panel-B9BFcufg.js} +1 -1
- package/dist/assets/{ai-model-dropdown-DjQy2bo-.js → ai-model-dropdown-a4n3JDbf.js} +1 -1
- package/dist/assets/{app-config-button-COFLeJCU.js → app-config-button-BQAVAUpN.js} +1 -1
- package/dist/assets/{cell-editor-X3xHqmCs.js → cell-editor-DfhJQCKm.js} +1 -1
- package/dist/assets/{chat-display-ClG9MRI_.js → chat-display-C7EsuQmC.js} +1 -1
- package/dist/assets/{chat-panel-BRoqCSCR.js → chat-panel-DCSD5g-j.js} +1 -1
- package/dist/assets/{chat-ui-CNvkb287.js → chat-ui-C3ay3Rdi.js} +1 -1
- package/dist/assets/{command-palette-L1LZCmet.js → command-palette-D1IoMI0F.js} +1 -1
- package/dist/assets/{edit-page-DOpi9pL6.js → edit-page-B1g7AdLD.js} +4 -4
- package/dist/assets/{home-page-Chy6SA41.js → home-page-P1nAvpLK.js} +1 -1
- package/dist/assets/{index-CNnOirLv.js → index-BMQJCzfh.js} +3 -3
- package/dist/assets/{layout-DmbNDyY3.js → layout-vg4c6imf.js} +2 -2
- package/dist/assets/{packages-panel-CYLyGihe.js → packages-panel-DMZtOQYl.js} +1 -1
- package/dist/assets/{panels-BMikLSqU.js → panels-DllVd27E.js} +1 -1
- package/dist/assets/{reveal-component-6t8SeOsr.js → reveal-component-D9uCWzLS.js} +1 -1
- package/dist/assets/{run-page-BIyr61hZ.js → run-page-ah4ptdoG.js} +1 -1
- package/dist/assets/{scratchpad-panel-B2KUccqM.js → scratchpad-panel-YOarJKLy.js} +1 -1
- package/dist/assets/{skeleton-DZV2_Wnl.js → skeleton-BX017Wzj.js} +1 -1
- package/dist/assets/{useNotebookActions-BcAzvJBD.js → useNotebookActions-DKMS8mUE.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +4 -2
- package/src/plugins/__tests__/plugin-schema.test.ts +343 -0
- package/src/plugins/__tests__/plugin-schema.ts +398 -0
- 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
|
+
}
|
package/src/plugins/plugins.ts
CHANGED
|
@@ -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(),
|