From 5170812fa0c93e1b84d835f5f5adc2e0443d4184 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 22 Aug 2026 06:19:28 +0000 Subject: [PATCH] feat(json-renderer): add the shared field descriptor and widget rule pipeline Raise flow-to-blocks off the broken @fbp/evaluator@1.3.0 floor and move the field -> node rule pipeline into json-renderer so every document source shares it. --- packages/flow-to-blocks/package.json | 2 +- packages/json-renderer/README.md | 25 +++ .../json-renderer/src/__tests__/rules.test.ts | 127 ++++++++++++++++ packages/json-renderer/src/index.ts | 1 + packages/json-renderer/src/rules.ts | 142 ++++++++++++++++++ packages/json-schema-to-blocks/package.json | 3 +- packages/json-schema-to-blocks/src/convert.ts | 66 ++++---- packages/json-schema-to-blocks/src/rules.ts | 40 ++--- packages/json-schema-to-blocks/src/types.ts | 55 +++---- pnpm-lock.yaml | 16 +- pnpm-workspace.yaml | 7 - scripts/check-packed-packages.ts | 5 +- 12 files changed, 375 insertions(+), 114 deletions(-) create mode 100644 packages/json-renderer/src/__tests__/rules.test.ts create mode 100644 packages/json-renderer/src/rules.ts diff --git a/packages/flow-to-blocks/package.json b/packages/flow-to-blocks/package.json index 9f96fc4..cb1b477 100644 --- a/packages/flow-to-blocks/package.json +++ b/packages/flow-to-blocks/package.json @@ -39,7 +39,7 @@ "renderer" ], "dependencies": { - "@fbp/evaluator": "^1.3.0", + "@fbp/evaluator": "^1.5.0", "@fbp/types": "^1.4.0", "blocks-schema": "workspace:^", "json-schema-to-blocks": "workspace:^" diff --git a/packages/json-renderer/README.md b/packages/json-renderer/README.md index b6273b6..0aadca3 100644 --- a/packages/json-renderer/README.md +++ b/packages/json-renderer/README.md @@ -50,6 +50,7 @@ import { resolveBinding } from 'json-renderer/bindings'; | **Compose** | Fragment expansion, slot filling, overrides, merge | `compose.ts` | | **Bindings** | `{{ scope.path }}` resolution and scope layering | `bindings.ts` | | **Registry** | Layered node type → handler resolution | `registry.ts` | +| **Rules** | `FieldDescriptor` and the ordered field → node rule pipeline | `rules.ts` | | **Adapter** | The interface a renderer implements | `adapter.ts` | ## Document @@ -95,6 +96,30 @@ composition: composeEnvelope(document, { fragments, vocabulary: { fragmentNodeType: 'include' } }); ``` +## Field rules + +A document source (a JSON Schema, a database table, a task's input contract) +describes each field as a `FieldDescriptor` and lets rules decide the widget. +Rules are data — an ordered list, first match wins, defaults last — so a host +prepends its own instead of forking the converter, and a rule written once +applies to every source. + +```ts +import { applyWidgetRules, composeWidgetRules, fieldNodeProps } from 'json-renderer'; + +const rules = composeWidgetRules(defaults, [ + { name: 'file', match: (field) => field.format === 'uri', node: 'FileUpload' }, + { name: 'big-enum', match: (field) => (field.enumValues?.length ?? 0) > 20, node: 'Combobox' }, +]); + +const partial = applyWidgetRules(descriptor, rules, 'Input'); +const props = { ...fieldNodeProps(descriptor), ...partial.props }; +``` + +A source extends the descriptor with its own facts for rules that need them — +`json-schema-to-blocks`' `FieldContext` adds the raw schema — while every +source-neutral decision reads the shared fields. + ## Adapter contract A renderer is generic over the handler it resolves a node type to (`THandler`) diff --git a/packages/json-renderer/src/__tests__/rules.test.ts b/packages/json-renderer/src/__tests__/rules.test.ts new file mode 100644 index 0000000..42c09da --- /dev/null +++ b/packages/json-renderer/src/__tests__/rules.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyWidgetRules, + compareFieldOrder, + composeWidgetRules, + fieldNodeProps, + type FieldDescriptor, + type WidgetRule, +} from '../rules'; + +function field(overrides: Partial = {}): FieldDescriptor { + return { name: 'city', path: 'address.city', required: false, hints: {}, ...overrides }; +} + +const stringRule: WidgetRule = { name: 'string', match: (ctx) => ctx.dataType === 'string', node: 'Input' }; + +describe('composeWidgetRules', () => { + it('puts caller rules ahead of the defaults so they win', () => { + const custom: WidgetRule = { name: 'custom', match: () => true, node: 'Custom' }; + expect(composeWidgetRules([stringRule], [custom]).map((rule) => rule.name)).toEqual(['custom', 'string']); + }); + + it('returns the defaults when no rules are supplied', () => { + expect(composeWidgetRules([stringRule])).toEqual([stringRule]); + }); + + it('drops the defaults when the caller replaces them', () => { + expect(composeWidgetRules([stringRule], undefined, true)).toEqual([]); + }); + + it('does not alias the arrays it was handed', () => { + const defaults = [stringRule]; + composeWidgetRules(defaults).push({ name: 'extra', match: () => true, node: 'Extra' }); + expect(defaults).toHaveLength(1); + }); +}); + +describe('applyWidgetRules', () => { + it('takes the first matching rule', () => { + const rules: WidgetRule[] = [ + { name: 'hint', match: (ctx) => Boolean(ctx.hints.widget), node: (ctx) => ctx.hints.widget as string }, + stringRule, + ]; + expect(applyWidgetRules(field({ dataType: 'string', hints: { widget: 'Markdown' } }), rules, 'Input')).toEqual({ + type: 'Markdown', + }); + }); + + it('passes a partial node through untouched', () => { + const rules: WidgetRule[] = [ + { name: 'enum', match: (ctx) => Boolean(ctx.enumValues), node: () => ({ type: 'Select', props: { searchable: true } }) }, + ]; + expect(applyWidgetRules(field({ enumValues: ['a', 'b'] }), rules, 'Input')).toEqual({ + type: 'Select', + props: { searchable: true }, + }); + }); + + it('falls back when nothing matches', () => { + expect(applyWidgetRules(field({ dataType: 'geometry' }), [stringRule], 'JsonEditor')).toEqual({ + type: 'JsonEditor', + }); + }); +}); + +describe('fieldNodeProps', () => { + it('derives the shared props of a field node', () => { + expect( + fieldNodeProps( + field({ + required: true, + label: 'City', + description: 'Billing city', + nullable: true, + defaultValue: 'Austin', + }), + ), + ).toEqual({ + name: 'address.city', + label: 'City', + description: 'Billing city', + required: true, + nullable: true, + defaultValue: 'Austin', + }); + }); + + it('lets hints override the derived label and description', () => { + expect(fieldNodeProps(field({ label: 'City', description: 'From the schema', hints: { label: 'Town' } }))).toMatchObject( + { label: 'Town', description: 'From the schema' }, + ); + }); + + it('omits absent props rather than emitting undefined', () => { + expect(fieldNodeProps(field())).toEqual({ name: 'address.city' }); + }); + + it('disables a read-only field', () => { + expect(fieldNodeProps(field({ readOnly: true }))).toMatchObject({ disabled: true }); + }); + + it('keeps a null default, which is a value', () => { + expect(fieldNodeProps(field({ defaultValue: null }))).toMatchObject({ defaultValue: null }); + }); +}); + +describe('compareFieldOrder', () => { + it('keeps source order when no field declares one', () => { + expect([ + { index: 1, order: undefined }, + { index: 0, order: undefined }, + ].sort(compareFieldOrder)).toEqual([{ index: 0, order: undefined }, { index: 1, order: undefined }]); + }); + + it('sorts ordered fields ahead of unordered ones', () => { + expect( + [ + { index: 0 }, + { index: 1, order: 2 }, + { index: 2, order: 1 }, + ] + .sort(compareFieldOrder) + .map((entry) => entry.index), + ).toEqual([2, 1, 0]); + }); +}); diff --git a/packages/json-renderer/src/index.ts b/packages/json-renderer/src/index.ts index 25beeca..5051965 100644 --- a/packages/json-renderer/src/index.ts +++ b/packages/json-renderer/src/index.ts @@ -7,4 +7,5 @@ export * from './fields'; export * from './json-schema'; export * from './node'; export * from './registry'; +export * from './rules'; export * from './zod'; diff --git a/packages/json-renderer/src/rules.ts b/packages/json-renderer/src/rules.ts new file mode 100644 index 0000000..9c152d5 --- /dev/null +++ b/packages/json-renderer/src/rules.ts @@ -0,0 +1,142 @@ +/** + * The field lowering layer: a source-neutral description of one field, and the + * ordered rule pipeline that turns it into a node. + * + * Every document source (JSON Schema, database metadata, a flow) knows how to + * describe a field but should not own the widget decisions — "`format: uri` + * renders as a file picker", "enums over twenty values render as a combobox". + * Those decisions are data: an ordered list of rules, first match wins, defaults + * last. Rules written against `FieldDescriptor` are shared across sources, and a + * source may extend the descriptor with its own facts (the raw schema, the + * column) for rules that need them. + */ + +import type { NodeConstraints } from './constraints'; +import type { DocumentNode, NodeProps } from './node'; + +/** Author-supplied hints that override what a rule would otherwise decide. */ +export interface FieldHints { + /** Force a node type, bypassing the rules. */ + widget?: TType; + label?: string; + description?: string; + placeholder?: string; + hidden?: boolean; + disabled?: boolean; + className?: string; + /** Sort weight within its group; lower comes first, unset keeps source order. */ + order?: number; + /** Extra props merged onto the produced node. */ + props?: NodeProps; +} + +/** + * One field, described independently of where it came from. `dataType` and + * `format` are deliberately open strings: a JSON Schema contributes + * `'string'`/`'uri'`, a database column `'text'`/`'json'`, and a rule matches on + * whichever it cares about. + */ +export interface FieldDescriptor { + /** Field name within its parent, e.g. `city`. */ + name: string; + /** Path from the document root, e.g. `billing.address.city`. */ + path: string; + required: boolean; + dataType?: string; + format?: string; + label?: string; + description?: string; + enumValues?: readonly unknown[]; + nullable?: boolean; + readOnly?: boolean; + defaultValue?: unknown; + constraints?: NodeConstraints; + hints: FieldHints; +} + +/** A rule's contribution to the node built for a field. */ +export interface PartialNode = DocumentNode> { + type?: TType; + props?: NodeProps; + constraints?: NodeConstraints; + children?: TNode[]; +} + +/** + * A widget rule maps one field to a node type. Rules are tried in order and the + * first match wins, so app-specific rules are prepended rather than replacing + * the defaults. + */ +export interface WidgetRule< + TContext extends FieldDescriptor = FieldDescriptor, + TType extends string = string, + TNode extends DocumentNode = DocumentNode, +> { + /** Identifies the rule so a consumer can replace exactly one default. */ + name: string; + match: (context: TContext) => boolean; + /** Node type to render, or a partial node merged over the derived one. */ + node: TType | ((context: TContext) => TType | PartialNode); +} + +/** + * Order a rule set: caller rules first (so they win), defaults last, unless the + * caller replaces the defaults outright. + */ +export function composeWidgetRules( + defaults: readonly TRule[], + rules?: readonly TRule[], + replaceDefaults?: boolean, +): TRule[] { + if (replaceDefaults) return [...(rules ?? [])]; + return rules?.length ? [...rules, ...defaults] : [...defaults]; +} + +/** Run the pipeline: the first matching rule's contribution, else the fallback type. */ +export function applyWidgetRules>( + context: TContext, + rules: readonly WidgetRule[], + fallbackType: TType, +): PartialNode { + for (const rule of rules) { + if (!rule.match(context)) continue; + const result = typeof rule.node === 'function' ? rule.node(context) : rule.node; + return typeof result === 'string' ? { type: result } : result; + } + return { type: fallbackType }; +} + +/** + * The props every lowered field node carries, before a rule's own props are + * merged on top. Sources share this so a generated field looks the same whether + * it came from a schema or a column. + */ +export function fieldNodeProps(descriptor: FieldDescriptor): NodeProps { + const { hints } = descriptor; + const label = hints.label ?? descriptor.label; + const description = hints.description ?? descriptor.description; + + return { + name: descriptor.path, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + ...(hints.placeholder ? { placeholder: hints.placeholder } : {}), + ...(descriptor.required ? { required: true } : {}), + ...(hints.hidden ? { hidden: true } : {}), + ...(hints.disabled || descriptor.readOnly ? { disabled: true } : {}), + ...(hints.className ? { className: hints.className } : {}), + ...(descriptor.nullable ? { nullable: true } : {}), + ...(descriptor.defaultValue !== undefined ? { defaultValue: descriptor.defaultValue } : {}), + }; +} + +/** Sort key honouring `hints.order`; fields without one keep source order. */ +export function compareFieldOrder( + left: { order?: number; index: number }, + right: { order?: number; index: number }, +): number { + if (left.order == null && right.order == null) return left.index - right.index; + if (left.order == null) return 1; + if (right.order == null) return -1; + return left.order - right.order; +} diff --git a/packages/json-schema-to-blocks/package.json b/packages/json-schema-to-blocks/package.json index be03d74..3145540 100644 --- a/packages/json-schema-to-blocks/package.json +++ b/packages/json-schema-to-blocks/package.json @@ -38,7 +38,8 @@ "renderer" ], "dependencies": { - "blocks-schema": "workspace:^" + "blocks-schema": "workspace:^", + "json-renderer": "workspace:^" }, "devDependencies": { "makage": "^0.6.0", diff --git a/packages/json-schema-to-blocks/src/convert.ts b/packages/json-schema-to-blocks/src/convert.ts index e6be82c..5df78e1 100644 --- a/packages/json-schema-to-blocks/src/convert.ts +++ b/packages/json-schema-to-blocks/src/convert.ts @@ -8,10 +8,11 @@ */ import { createDocument, type UIDocument, type UINode, type UINodeType } from 'blocks-schema'; +import { applyWidgetRules, compareFieldOrder, fieldNodeProps } from 'json-renderer'; import { toConstraints } from './constraints'; import { resolveRules } from './rules'; import { annotation, createResolver, isNullable, mergeAllOf, primaryType } from './schema'; -import type { ConvertOptions, FieldContext, JSONSchema, PartialNode, WidgetRule } from './types'; +import type { ConvertOptions, FieldContext, JSONSchema, WidgetRule } from './types'; interface Lowering { rules: WidgetRule[]; @@ -44,50 +45,44 @@ function context( required: boolean, lowering: Lowering ): FieldContext { + const ui = annotation(schema); + // The document format only carries scalar defaults, so an object or array + // default is dropped rather than emitted as an invalid document. + const raw = schema.default ?? schema.const; + const constraints = toConstraints(schema); + return { - schema, name, path, required, + dataType: primaryType(schema), + ...(typeof schema.format === 'string' ? { format: schema.format } : {}), + label: schema.title ?? titleize(name), + ...(schema.description !== undefined ? { description: schema.description } : {}), + ...(schema.enum ? { enumValues: schema.enum } : {}), + nullable: isNullable(schema), + readOnly: schema.readOnly ?? false, + ...(isScalar(raw) ? { defaultValue: raw } : {}), + ...(constraints ? { constraints } : {}), + hints: ui, + schema, type: primaryType(schema), - ui: annotation(schema), + ui, resolve: lowering.resolve, }; } -function applyRules(ctx: FieldContext, rules: WidgetRule[]): PartialNode { - for (const rule of rules) { - if (!rule.match(ctx)) continue; - const result = typeof rule.node === 'function' ? rule.node(ctx) : rule.node; - return typeof result === 'string' ? { type: result } : result; - } - return { type: 'Input' }; -} - function widgetNode(ctx: FieldContext, lowering: Lowering): UINode { - const { schema, ui } = ctx; - const partial = applyRules(ctx, lowering.rules); - const constraints = { ...toConstraints(schema), ...partial.constraints }; - // The document format only carries scalar defaults, so an object or array - // default is dropped rather than emitted as an invalid document. - const raw = schema.default ?? schema.const; - const defaultValue = isScalar(raw) ? raw : undefined; + const { ui } = ctx; + const partial = applyWidgetRules(ctx, lowering.rules, 'Input'); + const constraints = { ...ctx.constraints, ...partial.constraints }; return { type: (partial.type ?? 'Input') as UINodeType, key: ctx.path || 'field', props: { - name: ctx.path, - label: ui.label ?? schema.title ?? titleize(ctx.name), - ...(ui.description ?? schema.description ? { description: ui.description ?? schema.description } : {}), - ...(ui.placeholder ? { placeholder: ui.placeholder } : {}), - ...(ctx.required ? { required: true } : {}), - ...(ui.hidden ? { hidden: true } : {}), - ...(ui.disabled || schema.readOnly ? { disabled: true } : {}), - ...(ui.className ? { className: ui.className } : {}), - ...(isNullable(schema) ? { nullable: true } : {}), - ...(defaultValue !== undefined ? { defaultValue } : {}), - ...(constraints && Object.keys(constraints).length > 0 ? { constraints } : {}), + ...fieldNodeProps(ctx), + ...(Object.keys(constraints).length > 0 ? { constraints } : {}), ...partial.props, ...ui.props, }, @@ -132,7 +127,7 @@ function cycleNode(ctx: FieldContext): UINode { key: ctx.path || 'field', props: { name: ctx.path, - label: ctx.ui.label ?? ctx.schema.title ?? titleize(ctx.name), + label: ctx.ui.label ?? ctx.label, ...(ctx.required ? { required: true } : {}), }, children: [], @@ -143,7 +138,7 @@ function containerProps(ctx: FieldContext): UINode['props'] { const { schema, ui } = ctx; return { ...(ctx.path ? { name: ctx.path } : {}), - ...(ui.label ?? schema.title ?? ctx.name ? { label: ui.label ?? schema.title ?? titleize(ctx.name) } : {}), + ...(ui.label ?? ctx.label ? { label: ui.label ?? ctx.label } : {}), ...(ui.description ?? schema.description ? { description: ui.description ?? schema.description } : {}), ...(ui.className ? { className: ui.className } : {}), ...ui.props, @@ -231,12 +226,7 @@ function childNodes(schema: JSONSchema, parentPath: string, lowering: Lowering): return { name, child, ref: localRef(rawChild), index, order: annotation(child).order }; }) .filter(({ child }) => lowering.includeReadOnly || !child.readOnly) - .sort((left, right) => { - if (left.order == null && right.order == null) return left.index - right.index; - if (left.order == null) return 1; - if (right.order == null) return -1; - return left.order - right.order; - }); + .sort(compareFieldOrder); return entries.map(({ name, child, ref }) => { const path = parentPath ? `${parentPath}.${name}` : name; diff --git a/packages/json-schema-to-blocks/src/rules.ts b/packages/json-schema-to-blocks/src/rules.ts index c34ee25..2d2fd06 100644 --- a/packages/json-schema-to-blocks/src/rules.ts +++ b/packages/json-schema-to-blocks/src/rules.ts @@ -2,10 +2,15 @@ * Default widget rules: the ordered list that decides which node type a schema * position lowers to. Rules are data, so an app prepends its own instead of * forking the converter. + * + * They read the generic field descriptor (`dataType`, `format`, `enumValues`, + * `constraints`) wherever the decision is source-neutral, so the same rule shape + * works for a document lowered from database metadata or a task's input schema; + * only genuinely schema-specific decisions reach for `ctx.schema`. */ +import { composeWidgetRules } from 'json-renderer'; import type { WidgetRule } from './types'; -import { primaryType } from './schema'; /** Formats that map straight onto a dedicated widget. */ const formatWidgets: Record = { @@ -42,14 +47,14 @@ const TEXTAREA_MIN_LENGTH = 256; export const defaultWidgetRules: WidgetRule[] = [ { name: 'annotation-widget', - match: (ctx) => Boolean(ctx.ui.widget), - node: (ctx) => ctx.ui.widget as string, + match: (ctx) => Boolean(ctx.hints.widget), + node: (ctx) => ctx.hints.widget as string, }, { name: 'enum', - match: (ctx) => Array.isArray(ctx.schema.enum) && ctx.schema.enum.length > 0, + match: (ctx) => Boolean(ctx.enumValues?.length), node: (ctx) => { - const values = ctx.schema.enum ?? []; + const values = ctx.enumValues ?? []; return { // A short enum reads better as radios than as a collapsed select. type: values.length <= 3 ? 'RadioGroup' : 'Select', @@ -59,25 +64,25 @@ export const defaultWidgetRules: WidgetRule[] = [ }, { name: 'boolean', - match: (ctx) => ctx.type === 'boolean', + match: (ctx) => ctx.dataType === 'boolean', node: 'Switch', }, { name: 'number', - match: (ctx) => ctx.type === 'number' || ctx.type === 'integer', + match: (ctx) => ctx.dataType === 'number' || ctx.dataType === 'integer', node: (ctx) => ({ type: 'NumberInput', props: { - ...(ctx.type === 'integer' ? { step: 1 } : {}), + ...(ctx.dataType === 'integer' ? { step: 1 } : {}), ...(typeof ctx.schema.multipleOf === 'number' ? { step: ctx.schema.multipleOf } : {}), }, }), }, { name: 'string-format', - match: (ctx) => ctx.type === 'string' && typeof ctx.schema.format === 'string', + match: (ctx) => ctx.dataType === 'string' && typeof ctx.format === 'string', node: (ctx) => { - const format = ctx.schema.format as string; + const format = ctx.format as string; const type = formatWidgets[format] ?? 'Input'; const inputType = inputTypes[format]; return { type, ...(inputType ? { props: { inputType } } : {}) }; @@ -85,32 +90,33 @@ export const defaultWidgetRules: WidgetRule[] = [ }, { name: 'string-long', - match: (ctx) => - ctx.type === 'string' && (ctx.schema.maxLength == null || ctx.schema.maxLength >= TEXTAREA_MIN_LENGTH), + match: (ctx) => { + const maxLength = ctx.constraints?.maxLength; + return ctx.dataType === 'string' && (maxLength == null || maxLength >= TEXTAREA_MIN_LENGTH); + }, node: 'Textarea', }, { name: 'string', - match: (ctx) => ctx.type === 'string', + match: (ctx) => ctx.dataType === 'string', node: 'Input', }, { name: 'structural', // Objects and arrays only reach the rules when they carry no fields to // lower, so raw JSON is the honest editor for them. - match: (ctx) => ctx.type === 'object' || ctx.type === 'array', + match: (ctx) => ctx.dataType === 'object' || ctx.dataType === 'array', node: 'JsonEditor', }, { name: 'unresolved', // An unresolved `$ref` or a schema with no lowerable type still needs an // editable surface, so it falls back to raw JSON rather than vanishing. - match: (ctx) => primaryType(ctx.schema) === undefined, + match: (ctx) => ctx.dataType === undefined, node: 'JsonEditor', }, ]; export function resolveRules(rules: WidgetRule[] | undefined, replaceDefaults: boolean | undefined): WidgetRule[] { - if (replaceDefaults) return rules ?? []; - return rules?.length ? [...rules, ...defaultWidgetRules] : defaultWidgetRules; + return composeWidgetRules(defaultWidgetRules, rules, replaceDefaults); } diff --git a/packages/json-schema-to-blocks/src/types.ts b/packages/json-schema-to-blocks/src/types.ts index cd052e6..1f73f4c 100644 --- a/packages/json-schema-to-blocks/src/types.ts +++ b/packages/json-schema-to-blocks/src/types.ts @@ -6,25 +6,16 @@ * lowering rules. */ -import type { UINode, UINodeConstraints, UINodeType } from 'blocks-schema'; +import type { UINode, UINodeType } from 'blocks-schema'; +import type { FieldDescriptor, FieldHints, PartialNode as CorePartialNode, WidgetRule as CoreWidgetRule } from 'json-renderer'; export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; -/** UI hints authored inline on a schema, under the `x-ui` extension keyword. */ -export interface UIAnnotation { - /** Force a node type, bypassing widget rules. */ - widget?: UINodeType; - label?: string; - description?: string; - placeholder?: string; - hidden?: boolean; - disabled?: boolean; - className?: string; - /** Sort weight within its group; lower comes first, unset keeps schema order. */ - order?: number; - /** Extra props merged onto the produced node. */ - props?: Record; -} +/** + * UI hints authored inline on a schema, under the `x-ui` extension keyword — + * the generic field hints, narrowed to the Constructive node vocabulary. + */ +export type UIAnnotation = FieldHints; export interface JSONSchema { $id?: string; @@ -61,19 +52,18 @@ export interface JSONSchema { [keyword: string]: unknown; } -/** Everything a rule needs to decide on one schema position. */ -export interface FieldContext { +/** + * Everything a rule needs to decide on one schema position: the generic field + * descriptor (`dataType`, `format`, `enumValues`, `constraints`, `hints`, …) plus + * the JSON-Schema-specific facts. Rules written against the descriptor fields + * work for any document source; rules that need the raw keywords read `schema`. + */ +export interface FieldContext extends FieldDescriptor { /** The schema at this position, with `$ref`s already resolved. */ schema: JSONSchema; - /** Property name, or `''` for the root schema. */ - name: string; - /** Dot path from the root, e.g. `billing.address.city`. */ - path: string; - /** Declared as required by the parent's `required` list. */ - required: boolean; /** Normalized primary `type`, or `undefined` when the schema omits it. */ type?: JSONSchemaType; - /** Merged `x-ui` annotation for this position. */ + /** Merged `x-ui` annotation for this position; the same object as `hints`. */ ui: UIAnnotation; /** Resolve a `$ref` against the root schema's definitions. */ resolve: (schema: JSONSchema) => JSONSchema; @@ -84,21 +74,10 @@ export interface FieldContext { * order and the first match wins, so app-specific rules are prepended rather * than replacing the defaults. */ -export interface WidgetRule { - /** Identifies the rule so a consumer can replace exactly one default. */ - name: string; - match: (ctx: FieldContext) => boolean; - /** Node type to render, or a partial node merged over the derived one. */ - node: UINodeType | ((ctx: FieldContext) => UINodeType | PartialNode); -} +export type WidgetRule = CoreWidgetRule; /** A rule's contribution to the node built for a schema position. */ -export interface PartialNode { - type?: UINodeType; - props?: Record; - constraints?: UINodeConstraints; - children?: UINode[]; -} +export type PartialNode = CorePartialNode; export interface ConvertOptions { /** Document id; defaults to the schema `$id` or `'document'`. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c1564c..bc77f39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@fbp/evaluator>@fbp/types': ^1.4.0 - importers: .: @@ -288,8 +285,8 @@ importers: packages/flow-to-blocks: dependencies: '@fbp/evaluator': - specifier: ^1.3.0 - version: 1.3.0 + specifier: ^1.5.0 + version: 1.5.0 '@fbp/types': specifier: ^1.4.0 version: 1.4.0 @@ -333,6 +330,9 @@ importers: blocks-schema: specifier: workspace:^ version: link:../blocks-schema/dist + json-renderer: + specifier: workspace:^ + version: link:../json-renderer/dist devDependencies: makage: specifier: ^0.6.0 @@ -1451,8 +1451,8 @@ packages: cpu: [x64] os: [win32] - '@fbp/evaluator@1.3.0': - resolution: {integrity: sha512-SHgBQxlrW5nwtdSCvcM3Pe5cY58Qv5os78d2uADa0H1pYaN0uOfacT3kZuewEhg5JR3NeyuYWmx/KgDKxwHY5g==} + '@fbp/evaluator@1.5.0': + resolution: {integrity: sha512-LL1KaNeJyfhPyfU2n7Kw0yaMro9k7pCQHDfnsmwhrmuX+/8dxAUTs3TBZeeviqKrNo7regTCIVoEmZ7IVu2ZqA==} '@fbp/types@1.4.0': resolution: {integrity: sha512-GcI+ku2gpHePaTt1yjj5aNhxE/Bp823UEh3onabZXol5GSzzAVrglKgUvfbHJNwKER6DyNkHdRXWUtHF8nPGwg==} @@ -9012,7 +9012,7 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@fbp/evaluator@1.3.0': + '@fbp/evaluator@1.5.0': dependencies: '@fbp/types': 1.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f0c2b2a..7b12e7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -66,10 +66,3 @@ allowBuilds: # Transitive dependencies must come from the registry, not from git or a URL. blockExoticSubdeps: true - -# @fbp/evaluator@1.3.0 was published with `"@fbp/types": "workspace:*"` in its -# dependencies — a spec no registry consumer can resolve, so installing it fails -# outright. Pin the real range until the @fbp packages are republished with -# resolved dependency ranges, then delete this block. -overrides: - "@fbp/evaluator>@fbp/types": "^1.4.0" diff --git a/scripts/check-packed-packages.ts b/scripts/check-packed-packages.ts index 0b19635..1696ae3 100644 --- a/scripts/check-packed-packages.ts +++ b/scripts/check-packed-packages.ts @@ -521,10 +521,7 @@ async function checkPackedDocumentPackages(): Promise { overrides: { 'json-renderer': `file:${coreTarball}`, 'blocks-schema': `file:${schemaTarball}`, - 'json-schema-to-blocks': `file:${jsonSchemaTarball}`, - // @fbp/evaluator@1.3.0 shipped `"@fbp/types": "workspace:*"`, which no - // registry consumer can resolve. Drop once @fbp is republished. - '@fbp/evaluator>@fbp/types': '^1.4.0' + 'json-schema-to-blocks': `file:${jsonSchemaTarball}` } } },