@tai42/studio-sdk 6.2.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/components/field.d.ts +14 -1
  2. package/dist/components/field.d.ts.map +1 -1
  3. package/dist/components/field.js +5 -2
  4. package/dist/components/field.js.map +1 -1
  5. package/dist/expression/ExpressionField.d.ts +140 -0
  6. package/dist/expression/ExpressionField.d.ts.map +1 -0
  7. package/dist/expression/ExpressionField.js +88 -0
  8. package/dist/expression/ExpressionField.js.map +1 -0
  9. package/dist/expression/context.d.ts +50 -0
  10. package/dist/expression/context.d.ts.map +1 -0
  11. package/dist/expression/context.js +53 -0
  12. package/dist/expression/context.js.map +1 -0
  13. package/dist/expression/types.d.ts +157 -0
  14. package/dist/expression/types.d.ts.map +1 -0
  15. package/dist/expression/types.js +11 -0
  16. package/dist/expression/types.js.map +1 -0
  17. package/dist/hooks/useApi.d.ts +8 -0
  18. package/dist/hooks/useApi.d.ts.map +1 -1
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugin/registry.d.ts.map +1 -1
  24. package/dist/plugin/registry.js +39 -1
  25. package/dist/plugin/registry.js.map +1 -1
  26. package/dist/plugin/types.d.ts +31 -0
  27. package/dist/plugin/types.d.ts.map +1 -1
  28. package/dist/schema-editor/SchemaEditor.d.ts.map +1 -1
  29. package/dist/schema-editor/SchemaEditor.js +8 -4
  30. package/dist/schema-editor/SchemaEditor.js.map +1 -1
  31. package/dist/schema-form/classify.d.ts +5 -3
  32. package/dist/schema-form/classify.d.ts.map +1 -1
  33. package/dist/schema-form/classify.js +42 -16
  34. package/dist/schema-form/classify.js.map +1 -1
  35. package/dist/schema-form/default-value.d.ts.map +1 -1
  36. package/dist/schema-form/default-value.js +4 -1
  37. package/dist/schema-form/default-value.js.map +1 -1
  38. package/dist/schema-form/field-node.d.ts.map +1 -1
  39. package/dist/schema-form/field-node.js +3 -3
  40. package/dist/schema-form/field-node.js.map +1 -1
  41. package/dist/schema-form/json-field.d.ts +14 -0
  42. package/dist/schema-form/json-field.d.ts.map +1 -0
  43. package/dist/schema-form/json-field.js +104 -0
  44. package/dist/schema-form/json-field.js.map +1 -0
  45. package/dist/schema-form/types.d.ts +2 -2
  46. package/dist/schema-form/types.js +2 -2
  47. package/dist/schema-form/validate.d.ts.map +1 -1
  48. package/dist/schema-form/validate.js +11 -4
  49. package/dist/schema-form/validate.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/components/field.tsx +20 -2
  52. package/src/expression/ExpressionField.tsx +330 -0
  53. package/src/expression/context.tsx +83 -0
  54. package/src/expression/types.ts +164 -0
  55. package/src/index.ts +22 -0
  56. package/src/plugin/registry.ts +50 -1
  57. package/src/plugin/types.ts +32 -0
  58. package/src/schema-editor/SchemaEditor.tsx +8 -4
  59. package/src/schema-form/classify.ts +103 -18
  60. package/src/schema-form/default-value.ts +4 -1
  61. package/src/schema-form/field-node.tsx +7 -4
  62. package/src/schema-form/json-field.tsx +143 -0
  63. package/src/schema-form/types.ts +2 -2
  64. package/src/schema-form/validate.ts +10 -4
  65. package/dist/schema-form/unsupported-notice.d.ts +0 -18
  66. package/dist/schema-form/unsupported-notice.d.ts.map +0 -1
  67. package/dist/schema-form/unsupported-notice.js +0 -8
  68. package/dist/schema-form/unsupported-notice.js.map +0 -1
  69. package/src/schema-form/unsupported-notice.tsx +0 -39
@@ -0,0 +1,330 @@
1
+ /**
2
+ * `ExpressionField` — the SDK's expression-authoring control. It is a plain SDK
3
+ * text field (a `Field` wrapping a `Textarea`, or a `TextInput` when
4
+ * `multiline={false}`) that GROWS a "visual editor" affordance only when a plugin
5
+ * has contributed an editor for the field's language. With no editor registered —
6
+ * or no {@link ExpressionEditorsProvider} mounted above it — it stays exactly a
7
+ * text field, so a consumer adopts it with no dependency on any editor being
8
+ * installed (graceful absence).
9
+ *
10
+ * The launcher half — the button that lazily loads the editor behind `Suspense`,
11
+ * warms its chunk on open-intent, and turns a `load()` rejection into a loud
12
+ * inline error — is the standalone {@link ExpressionEditorLauncher}, exported so a
13
+ * dense grid can compose its OWN input + an icon-only door in one row and reuse
14
+ * this exact mount semantics rather than reimplementing them. `ExpressionField`
15
+ * consumes that same component internally, so there is one source of truth for how
16
+ * an editor is mounted.
17
+ *
18
+ * The interaction is ported from babelfish's `jq-editor-button.tsx` but styled
19
+ * with SDK primitives and generalised to any {@link ExpressionLanguage}: the
20
+ * whole {@link ExpressionFieldDeclaration} threads onto the editor, so its context
21
+ * chip, Test seeding, and server-validate hook all read from one object, and each
22
+ * degrades on its own when absent.
23
+ */
24
+ import {
25
+ Suspense,
26
+ lazy,
27
+ useMemo,
28
+ useState,
29
+ type InputHTMLAttributes,
30
+ type ReactElement,
31
+ type ReactNode,
32
+ type TextareaHTMLAttributes,
33
+ } from 'react';
34
+
35
+ import { Button, Spinner } from '../components/primitives';
36
+ import { ErrorBoundary } from '../components/error-boundary';
37
+ import { Field } from '../components/field';
38
+ import { TextInput, Textarea } from '../components/inputs';
39
+ import { GridIcon } from '../components/icons';
40
+
41
+ import { useExpressionEditor } from './context';
42
+ import type { ExpressionFieldDeclaration } from './types';
43
+
44
+ /**
45
+ * The native attributes {@link ExpressionField} FORWARDS to its underlying control
46
+ * (`Textarea` or, single-line, `TextInput`), minus the ones the field owns itself.
47
+ * Intersecting both attribute sets keeps the pass-through valid whichever element
48
+ * `multiline` selects, and omitting the owned keys keeps a caller from
49
+ * accidentally severing the value/edit wiring or the monospace class. This is the
50
+ * `inputs.tsx` pass-through doctrine (a caller's native attribute reaches the
51
+ * element) narrowed to what a field can safely surrender — `name`, `onBlur`,
52
+ * `maxLength`, `style`, `data-*`, and the like.
53
+ */
54
+ export type ExpressionControlProps = Omit<
55
+ TextareaHTMLAttributes<HTMLTextAreaElement> & InputHTMLAttributes<HTMLInputElement>,
56
+ | 'value'
57
+ | 'onChange'
58
+ | 'disabled'
59
+ | 'rows'
60
+ | 'placeholder'
61
+ | 'className'
62
+ | 'spellCheck'
63
+ | 'children'
64
+ >;
65
+
66
+ export interface ExpressionEditorLauncherProps {
67
+ /** What language the field authors, what its input is, and how to sample/validate it. */
68
+ readonly declaration: ExpressionFieldDeclaration;
69
+ /** The field's current expression ('' when unset), the editor seeds itself with. */
70
+ readonly value: string;
71
+ /** Called with the authored expression when the user saves in the editor. */
72
+ readonly onSave: (expression: string) => void;
73
+ /** The label the editor titles itself with; also enriches the launcher's name. */
74
+ readonly fieldLabel: string;
75
+ /** Icon-only launcher for narrow rows; the full name rides `aria-label`. */
76
+ readonly compact?: boolean;
77
+ /**
78
+ * Open the editor READ-ONLY (no Save). This is the launcher's ONLY blocking knob:
79
+ * a launcher has no inline textarea to disable, so — unlike {@link ExpressionField}'s
80
+ * `disabled`, which conflates textarea-disabled with editor-read-only — the door
81
+ * expresses author-blocking solely here. {@link ExpressionField} maps its own
82
+ * `disabled` onto this by default, and its `editorReadOnly` overrides that.
83
+ */
84
+ readonly editorReadOnly?: boolean;
85
+ /**
86
+ * Override the launcher's hover `title` (e.g. a rerun/side-effect hint). Absent,
87
+ * the title is derived from the declaration's shape ("Visual editor · input: …").
88
+ */
89
+ readonly title?: string;
90
+ }
91
+
92
+ /**
93
+ * The launcher + lazily-loaded editor for one expression field, standalone so a
94
+ * compact grid can drop it beside its own input. It resolves the contributed
95
+ * editor for `declaration.language` itself and renders NOTHING when none is
96
+ * registered (or no {@link ExpressionEditorsProvider} is mounted) — the same
97
+ * graceful absence {@link ExpressionField} degrades to a plain text field on.
98
+ */
99
+ export function ExpressionEditorLauncher({
100
+ declaration,
101
+ value,
102
+ onSave,
103
+ fieldLabel,
104
+ compact = false,
105
+ editorReadOnly = false,
106
+ title,
107
+ }: ExpressionEditorLauncherProps): ReactElement | null {
108
+ const contribution = useExpressionEditor(declaration.language);
109
+ const [open, setOpen] = useState(false);
110
+
111
+ // A fresh lazy wrapper per contribution: the module resolves once and React
112
+ // caches it, so re-opening the editor does not re-fetch the chunk.
113
+ const LazyEditor = useMemo(
114
+ () =>
115
+ contribution === null
116
+ ? null
117
+ : lazy(() => contribution.load().then((module) => ({ default: module.Editor }))),
118
+ [contribution],
119
+ );
120
+
121
+ if (contribution === null || LazyEditor === null) return null;
122
+
123
+ // Warm the chunk on OPEN-INTENT (hover/focus) and again on click, both
124
+ // best-effort: a preload that throws must never block opening the editor, whose
125
+ // own lazy import stays the loud path if the module genuinely cannot load.
126
+ const warm = (): void => {
127
+ contribution.preload?.();
128
+ };
129
+
130
+ const shapeLabel = declaration.shape?.label;
131
+ const openLabel = shapeLabel
132
+ ? `Open the visual editor for ${fieldLabel} (input: ${shapeLabel})`
133
+ : `Open the visual editor for ${fieldLabel}`;
134
+ const resolvedTitle =
135
+ title ?? (shapeLabel ? `Visual editor · input: ${shapeLabel}` : 'Visual editor');
136
+
137
+ return (
138
+ <>
139
+ <Button
140
+ type="button"
141
+ variant="secondary"
142
+ onClick={() => {
143
+ warm();
144
+ setOpen(true);
145
+ }}
146
+ onMouseEnter={warm}
147
+ onFocus={warm}
148
+ aria-label={openLabel}
149
+ title={resolvedTitle}
150
+ >
151
+ <GridIcon aria-hidden />
152
+ {compact ? null : ' Visual editor'}
153
+ </Button>
154
+ {open ? (
155
+ // A load() rejection throws through Suspense on render; the boundary turns
156
+ // it into a loud inline error where the editor would have appeared rather
157
+ // than blanking the field around it.
158
+ <ErrorBoundary label="Visual editor">
159
+ <Suspense fallback={<Spinner label="Loading the visual editor" />}>
160
+ <LazyEditor
161
+ declaration={declaration}
162
+ open
163
+ initialExpression={value}
164
+ fieldLabel={fieldLabel}
165
+ readOnly={editorReadOnly}
166
+ onSave={(expression) => {
167
+ onSave(expression);
168
+ setOpen(false);
169
+ }}
170
+ onClose={() => {
171
+ setOpen(false);
172
+ }}
173
+ />
174
+ </Suspense>
175
+ </ErrorBoundary>
176
+ ) : null}
177
+ </>
178
+ );
179
+ }
180
+
181
+ export interface ExpressionFieldProps {
182
+ /** The `Field` label; also the editor's title unless {@link fieldLabel} overrides it. */
183
+ readonly label: string;
184
+ /**
185
+ * Render the label for assistive tech only (visually hidden) — for a dense grid
186
+ * of otherwise-identical expression rows that each need a unique, addressable
187
+ * name but have no room for a visible label. The `label` text is still the
188
+ * accessible name (see {@link Field.hideLabel}).
189
+ */
190
+ readonly hideLabel?: boolean;
191
+ /** Optional field description rendered under the control. */
192
+ readonly description?: string;
193
+ /** Optional error rendered under the control (also flags the field invalid). */
194
+ readonly error?: string;
195
+ /** What language the field authors, what its input is, and how to sample/validate it. */
196
+ readonly declaration: ExpressionFieldDeclaration;
197
+ /** The field's current expression ('' when unset). */
198
+ readonly value: string;
199
+ /** Called with the new expression on both textarea edits and an editor save. */
200
+ readonly onChange: (expression: string) => void;
201
+ /**
202
+ * Disables INLINE editing (the textarea/input is `disabled`). By default it also
203
+ * opens the editor read-only, so a blocked value can still be inspected; pass
204
+ * {@link editorReadOnly} to decouple the two.
205
+ */
206
+ readonly disabled?: boolean;
207
+ /**
208
+ * Whether the visual editor opens READ-ONLY, independent of {@link disabled}.
209
+ * Defaults to `disabled` — the historical coupling — so a caller that never sets
210
+ * it keeps today's behaviour. Set it `false` to let the editor author a value
211
+ * whose textarea is disabled, or `true` to lock the editor while the textarea
212
+ * stays editable.
213
+ */
214
+ readonly editorReadOnly?: boolean;
215
+ /** Icon-only launcher for narrow rows; the full name rides `aria-label`. */
216
+ readonly compact?: boolean;
217
+ /** The label the editor titles itself with; defaults to {@link label}. */
218
+ readonly fieldLabel?: string;
219
+ /**
220
+ * Author the field on a SINGLE line — a `TextInput` instead of a `Textarea` —
221
+ * for a per-argument grid row. Defaults to `true` (multiline). The visual-editor
222
+ * launcher is unaffected.
223
+ */
224
+ readonly multiline?: boolean;
225
+ /**
226
+ * Render the expression content in the design system's monospace face. Defaults
227
+ * to TRUE: an expression is code, and code reads in a mono font (jq filters,
228
+ * paths, and operators align and disambiguate `l`/`1`/`I` there) — the same face
229
+ * every jq surface in the ecosystem paints. A prose-ish field can opt out with
230
+ * `false`.
231
+ */
232
+ readonly monospace?: boolean;
233
+ /**
234
+ * Override the launcher button's hover `title` (e.g. a rerun/side-effect hint),
235
+ * threaded to {@link ExpressionEditorLauncher.title}.
236
+ */
237
+ readonly launcherTitle?: string;
238
+ /** Textarea row count (defaults to the SDK `Textarea` default); ignored when single-line. */
239
+ readonly rows?: number;
240
+ /** Textarea/input placeholder. */
241
+ readonly placeholder?: string;
242
+ /**
243
+ * Narrow native-attribute pass-through to the underlying control — `name`,
244
+ * `onBlur`, `maxLength`, `style`, `data-*`, and the like. The field owns
245
+ * value/onChange/disabled/rows/placeholder/spellCheck and the monospace class, so
246
+ * those are not forwardable (see {@link ExpressionControlProps}).
247
+ */
248
+ readonly textareaProps?: ExpressionControlProps;
249
+ }
250
+
251
+ export function ExpressionField({
252
+ label,
253
+ hideLabel = false,
254
+ description,
255
+ error,
256
+ declaration,
257
+ value,
258
+ onChange,
259
+ disabled = false,
260
+ editorReadOnly,
261
+ compact = false,
262
+ fieldLabel,
263
+ multiline = true,
264
+ monospace = true,
265
+ launcherTitle,
266
+ rows,
267
+ placeholder,
268
+ textareaProps,
269
+ }: ExpressionFieldProps): ReactElement {
270
+ const contribution = useExpressionEditor(declaration.language);
271
+
272
+ const monoClassName = monospace
273
+ ? multiline
274
+ ? 'tai-textarea-mono'
275
+ : 'tai-input-mono'
276
+ : undefined;
277
+
278
+ // The editor's read-only state is INDEPENDENT of the textarea's disabled state,
279
+ // but defaults to it so the historical coupling holds for a caller that never
280
+ // asks for the two to differ.
281
+ const resolvedEditorReadOnly = editorReadOnly ?? disabled;
282
+
283
+ const control: ReactNode = multiline ? (
284
+ <Textarea
285
+ {...textareaProps}
286
+ value={value}
287
+ onChange={(event) => {
288
+ onChange(event.target.value);
289
+ }}
290
+ disabled={disabled}
291
+ spellCheck={false}
292
+ rows={rows}
293
+ placeholder={placeholder}
294
+ className={monoClassName}
295
+ />
296
+ ) : (
297
+ <TextInput
298
+ {...textareaProps}
299
+ value={value}
300
+ onChange={(event) => {
301
+ onChange(event.target.value);
302
+ }}
303
+ disabled={disabled}
304
+ spellCheck={false}
305
+ placeholder={placeholder}
306
+ className={monoClassName}
307
+ />
308
+ );
309
+
310
+ return (
311
+ <Field label={label} hideLabel={hideLabel} description={description} error={error}>
312
+ {control}
313
+ {contribution !== null ? (
314
+ <div
315
+ style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--tai-space-2)' }}
316
+ >
317
+ <ExpressionEditorLauncher
318
+ declaration={declaration}
319
+ value={value}
320
+ onSave={onChange}
321
+ fieldLabel={fieldLabel ?? label}
322
+ compact={compact}
323
+ editorReadOnly={resolvedEditorReadOnly}
324
+ title={launcherTitle}
325
+ />
326
+ </div>
327
+ ) : null}
328
+ </Field>
329
+ );
330
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The consumption bridge for contributed expression editors: the SHELL reads the
3
+ * committed contributions from `@tai42/studio-sdk/host` and mounts
4
+ * {@link ExpressionEditorsProvider} high in the tree, so both host features and
5
+ * plugin pages resolve an editor for a language through {@link useExpressionEditor}.
6
+ *
7
+ * WHY A CONTEXT AND NOT A DIRECT REGISTRY READ: the contribution registry lives in
8
+ * the host-only `@tai42/studio-sdk/host` module, which the plugin surface
9
+ * deliberately cannot import — a served plugin bundle must not be able to forge,
10
+ * wipe, or ENUMERATE the registry. React itself is the one import-map singleton
11
+ * both bundles share (the shell externalises `react` and `@tai42/studio-sdk`), so a
12
+ * context provided by the host reaches a plugin page's render. The provider carries
13
+ * the editors as a private context value the plugin surface never exports, and the
14
+ * only door through it is {@link useExpressionEditor}, which resolves ONE language
15
+ * to ONE contribution. A plugin can look up the editor for its own field and no
16
+ * more: it cannot list the languages, count the editors, or mutate the set.
17
+ *
18
+ * ABSENT PROVIDER IS A FIRST-CLASS STATE: a field rendered with no provider above
19
+ * it (a host that never wired the bridge, a test that mounts a field bare) resolves
20
+ * `null` and degrades to a plain text field. That is the graceful-absence contract
21
+ * {@link ExpressionField} relies on, so the hook returns `null` rather than throwing.
22
+ */
23
+ import { createContext, createElement, useContext, useMemo, type ReactNode } from 'react';
24
+
25
+ import type { ExpressionEditorContribution, ExpressionLanguage } from './types';
26
+
27
+ /**
28
+ * The provider's private value: a language → contribution lookup. Held as a
29
+ * function, not the raw map, so even the context value hands a consumer nothing to
30
+ * iterate — the sole operation is resolve-one-language. The context is module-local
31
+ * and never exported, so a plugin bundle cannot read it directly either.
32
+ */
33
+ type EditorLookup = (language: ExpressionLanguage) => ExpressionEditorContribution | null;
34
+
35
+ const ExpressionEditorsContext = createContext<EditorLookup | null>(null);
36
+
37
+ export interface ExpressionEditorsProviderProps {
38
+ /**
39
+ * The committed editor contributions, keyed by language — exactly the shell's
40
+ * `getContributions().expressionEditors`. Passed as the read-only map it already
41
+ * is; the provider never mutates or re-exposes it, and wraps it in a lookup so a
42
+ * consumer can only resolve, never enumerate.
43
+ */
44
+ readonly editors: ReadonlyMap<string, ExpressionEditorContribution>;
45
+ readonly children: ReactNode;
46
+ }
47
+
48
+ /**
49
+ * Publishes the contributed editors to the subtree as a lookup. The shell mounts
50
+ * this once, above both the host feature routes and the plugin pages, with the
51
+ * host registry's `expressionEditors` map. Re-rendering with a new `editors` map
52
+ * (the load pass completing) updates every consumer.
53
+ */
54
+ export function ExpressionEditorsProvider({
55
+ editors,
56
+ children,
57
+ }: ExpressionEditorsProviderProps): ReactNode {
58
+ // Memoised on the `editors` map so the context value keeps a STABLE identity
59
+ // across re-renders that do not change the map — a new closure every render would
60
+ // re-notify every consumer that reads the context (every mounted field) for
61
+ // nothing. The identity turns over exactly when the map does (the load pass
62
+ // committing a new editor), which is the one time consumers must re-resolve.
63
+ const lookup = useMemo<EditorLookup>(
64
+ () => (language) => editors.get(language) ?? null,
65
+ [editors],
66
+ );
67
+ return createElement(ExpressionEditorsContext.Provider, { value: lookup }, children);
68
+ }
69
+
70
+ /**
71
+ * Resolve the contributed editor for one expression language, or `null` when none
72
+ * is registered OR no {@link ExpressionEditorsProvider} is mounted above the caller.
73
+ * This is the ONLY way a plugin surface reaches a contributed editor: it cannot
74
+ * enumerate or mutate the registry, only ask for one language. `null` is the
75
+ * graceful-absence signal {@link ExpressionField} renders a plain text field on.
76
+ */
77
+ export function useExpressionEditor(
78
+ language: ExpressionLanguage,
79
+ ): ExpressionEditorContribution | null {
80
+ const lookup = useContext(ExpressionEditorsContext);
81
+ if (lookup === null) return null;
82
+ return lookup(language);
83
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The SDK's AGNOSTIC expression-field vocabulary: the language-neutral graduation
3
+ * of babelfish jq-studio's field-declaration surface into the platform SDK, so any
4
+ * Studio consumer (a flow product, a tai42 feature, a third-party plugin) can
5
+ * declare an expression field the SAME way and any plugin can contribute the
6
+ * editor that authors it.
7
+ *
8
+ * A host declares an expression field through these generic types — a language
9
+ * tag, a DESCRIPTOR of what the expression's input document IS (not an enum of
10
+ * shapes the editor hard-codes), a sample-input provider, and a pluggable
11
+ * server-validate hook. Nothing here names anything product-specific, so the same
12
+ * SDK serves flows' node/tool-result envelopes and tai42's webhook-body /
13
+ * auth-context / tool-result shapes without a type change. babelfish's flows
14
+ * adapter retypes its own envelopes onto these; the descriptor fields are kept
15
+ * isomorphic to jq-studio's `JqInputShapeDescriptor` so that mapping is a rename,
16
+ * not a reshape.
17
+ *
18
+ * The editor itself is NOT in the SDK — it is a plugin CONTRIBUTION
19
+ * ({@link ExpressionEditorContribution}), lazily loaded across the plugin
20
+ * boundary. This module is the shared contract both sides retype against.
21
+ */
22
+ import type { ComponentType } from 'react';
23
+
24
+ /**
25
+ * The expression language an expression field authors. `'jq'` is the only language
26
+ * that exists today; the tag is an OPEN string union so a host (or a later plugin)
27
+ * can name another pipeline language without a change to this type. The literal
28
+ * keeps `'jq'` on the autocomplete list while `string` keeps the union open.
29
+ */
30
+ export type ExpressionLanguage = 'jq' | (string & {});
31
+
32
+ /**
33
+ * One top-level key of the expression's input document, with a one-line gloss the
34
+ * editor may surface (a context chip, an input-node body, path suggestions).
35
+ * Graduated from jq-studio's `JqInputKey`.
36
+ */
37
+ export interface ExpressionInputKey {
38
+ readonly name: string;
39
+ readonly gloss: string;
40
+ }
41
+
42
+ /**
43
+ * A descriptor of what the expression's input document IS for a field — the shape
44
+ * the expression receives as its root. Deliberately open: a host builds these from
45
+ * its own domain, so ANY envelope is expressible, including ones the editor has
46
+ * never heard of. The `id` is an opaque, host-namespaced string used only for
47
+ * memoisation/telemetry. Graduated from jq-studio's `JqInputShapeDescriptor` with
48
+ * every field isomorphic, so the flows adapter maps its `JQType` onto this without
49
+ * reshaping.
50
+ */
51
+ export interface ExpressionShapeDescriptor {
52
+ /** Stable, host-namespaced id (opaque to the editor). */
53
+ readonly id: string;
54
+ /** Short chip label — what the input is here, e.g. "node envelope". */
55
+ readonly label: string;
56
+ /** One sentence: what the input document is in this field. */
57
+ readonly blurb: string;
58
+ /** The top-level keys of the input document, each with a one-liner. */
59
+ readonly keys: readonly ExpressionInputKey[];
60
+ /** What the expression must RETURN, e.g. "true or false" | "an object". */
61
+ readonly returns: string;
62
+ /** Per-field caveats (e.g. ".iterate.item is always null in a while condition"). */
63
+ readonly caveats?: readonly string[];
64
+ /** A static skeleton of the input document — the cheap, honest default a Test
65
+ * panel seeds its input with. A host may override it dynamically through the
66
+ * declaration's {@link ExpressionSampleInputProvider}. */
67
+ readonly sample?: unknown;
68
+ }
69
+
70
+ /** A provider of a concrete sample input for the editor's Test panel. A function
71
+ * (not a value) so a host can supply a static skeleton now and a live sample
72
+ * later without changing this API. Graduated from jq-studio's
73
+ * `SampleInputProvider`. */
74
+ export type ExpressionSampleInputProvider = () => unknown;
75
+
76
+ /** The answer a pluggable validator gives for an expression against its declared
77
+ * shape. Mirrors babelfish `validate_jq`'s result WITHOUT importing its types, so
78
+ * the hook stays host-agnostic. */
79
+ export interface ExpressionValidationResult {
80
+ readonly ok: boolean;
81
+ readonly compiles?: boolean;
82
+ readonly singleEmit?: boolean;
83
+ readonly message?: string;
84
+ }
85
+
86
+ /** A pluggable server-validate hook. The editor calls it with the current
87
+ * expression and the sample input; the host routes it to its own validator (flows
88
+ * wires babelfish's `validate_jq` endpoint here). Absent = no server validation
89
+ * (a local runtime may still power the Test panel). Graduated from jq-studio's
90
+ * `ServerValidateHook`. */
91
+ export type ExpressionServerValidate = (args: {
92
+ readonly expression: string;
93
+ readonly sampleInput: unknown;
94
+ }) => Promise<ExpressionValidationResult>;
95
+
96
+ /**
97
+ * The declaration a host attaches to an expression field: the language, what the
98
+ * input document is (descriptor), how to sample it, and how to server-validate it.
99
+ * Every property beyond `language` is optional so adoption is incremental — an
100
+ * undeclared-but-for-language field behaves exactly as a plain text field. No
101
+ * property names anything host-specific. Graduated from jq-studio's
102
+ * `JqFieldDeclaration`.
103
+ */
104
+ export interface ExpressionFieldDeclaration {
105
+ readonly language: ExpressionLanguage;
106
+ readonly shape?: ExpressionShapeDescriptor;
107
+ readonly sampleInput?: ExpressionSampleInputProvider;
108
+ readonly serverValidate?: ExpressionServerValidate;
109
+ }
110
+
111
+ /**
112
+ * The props a contributed editor component receives when the field opens it. The
113
+ * WHOLE {@link ExpressionFieldDeclaration} crosses (not just `shape` /
114
+ * `serverValidate`), so the editor reads the input descriptor for its context
115
+ * chip, the sample provider for Test seeding, and the validate hook for its
116
+ * Validate action from one object. `onSave` writes the authored expression back
117
+ * through the field; `onClose` dismisses without saving.
118
+ */
119
+ export interface ExpressionEditorProps {
120
+ /** What the field feeds and how to sample/validate it (see the type). */
121
+ readonly declaration: ExpressionFieldDeclaration;
122
+ /** Whether the editor is open — the field mounts it only while true. */
123
+ readonly open: boolean;
124
+ /** The field's current expression ('' when unset), the editor seeds itself with. */
125
+ readonly initialExpression: string;
126
+ /** The field label the editor titles itself with. */
127
+ readonly fieldLabel?: string;
128
+ /** Author-blocking: the editor renders read-only (no Save) when true. */
129
+ readonly readOnly?: boolean;
130
+ /** Called with the authored expression when the user saves. */
131
+ readonly onSave: (expression: string) => void;
132
+ /** Called when the user dismisses the editor without saving. */
133
+ readonly onClose: () => void;
134
+ }
135
+
136
+ /**
137
+ * The contract version an {@link ExpressionEditorContribution} must target. Bumped
138
+ * only on a BREAKING change to {@link ExpressionEditorProps} or the contribution
139
+ * shape; the registry rejects a contribution whose `contractVersion` is not
140
+ * exactly this, so a host never mounts an editor built against an incompatible
141
+ * props contract. It is distinct from `STUDIO_PLUGIN_API_VERSION`: the plugin API
142
+ * gains this whole extension point ADDITIVELY (that version does not move), and
143
+ * this narrower number then versions the editor props independently.
144
+ */
145
+ export const EXPRESSION_EDITOR_CONTRACT_VERSION = 1;
146
+
147
+ /**
148
+ * A plugin's contribution of the editor for one expression language. The editor
149
+ * rides a SEPARATE chunk (a visual canvas, converters, a language runtime), so it
150
+ * is fetched lazily: `load` resolves the module exposing the `Editor` component,
151
+ * and the optional `preload` warms that chunk on open-intent (hover/focus) so the
152
+ * editor is ready by the time the user opens it. `contractVersion` must equal
153
+ * {@link EXPRESSION_EDITOR_CONTRACT_VERSION} or the registry rejects it loudly.
154
+ */
155
+ export interface ExpressionEditorContribution {
156
+ /** The language this editor authors; the lookup key a field resolves by. */
157
+ readonly language: ExpressionLanguage;
158
+ /** The {@link ExpressionEditorProps} contract this editor was built against. */
159
+ readonly contractVersion: number;
160
+ /** Best-effort warm of the editor's chunk on open-intent; never awaited by the field. */
161
+ readonly preload?: () => void;
162
+ /** Resolve the editor component's chunk; rejection surfaces as the field's loud error. */
163
+ readonly load: () => Promise<{ readonly Editor: ComponentType<ExpressionEditorProps> }>;
164
+ }
package/src/index.ts CHANGED
@@ -48,6 +48,28 @@ export type {
48
48
  export { STUDIO_PLUGIN_API_VERSION, checkPluginApiVersion } from './plugin/version';
49
49
  export type { VersionGateResult } from './plugin/version';
50
50
 
51
+ // -- Expression fields (declaration vocabulary + editor extension point) ------
52
+ export { EXPRESSION_EDITOR_CONTRACT_VERSION } from './expression/types';
53
+ export type {
54
+ ExpressionLanguage,
55
+ ExpressionInputKey,
56
+ ExpressionShapeDescriptor,
57
+ ExpressionSampleInputProvider,
58
+ ExpressionValidationResult,
59
+ ExpressionServerValidate,
60
+ ExpressionFieldDeclaration,
61
+ ExpressionEditorProps,
62
+ ExpressionEditorContribution,
63
+ } from './expression/types';
64
+ export { ExpressionEditorsProvider, useExpressionEditor } from './expression/context';
65
+ export type { ExpressionEditorsProviderProps } from './expression/context';
66
+ export { ExpressionField, ExpressionEditorLauncher } from './expression/ExpressionField';
67
+ export type {
68
+ ExpressionFieldProps,
69
+ ExpressionEditorLauncherProps,
70
+ ExpressionControlProps,
71
+ } from './expression/ExpressionField';
72
+
51
73
  // -- Hooks -------------------------------------------------------------------
52
74
  export { ApiProvider, useApi } from './hooks/useApi';
53
75
  export type { ApiClient } from './hooks/useApi';