diff --git a/.changeset/config.json b/.changeset/config.json index f34c2fa1d9..549fdfce38 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -23,7 +23,7 @@ "@objectstack/objectql", "@objectstack/metadata-protocol", "@objectstack/observability", - "@objectstack/formula", + "@objectstack/formula", "@objectstack/sdui-parser", "@objectstack/lint", "@objectstack/platform-objects", "@objectstack/studio", diff --git a/.changeset/sdui-parser-framework.md b/.changeset/sdui-parser-framework.md new file mode 100644 index 0000000000..6221818e83 --- /dev/null +++ b/.changeset/sdui-parser-framework.md @@ -0,0 +1,5 @@ +--- +"@objectstack/sdui-parser": minor +--- + +ADR-0080 M3b: hoist the constrained JSX-source → SchemaNode compiler into framework as `@objectstack/sdui-parser` (its canonical home — pure, isomorphic, zero React). Parse, never execute: whitelist-sanitizing parser + manifest validation + `JSX.IntrinsicElements` codegen. Consumed server-side by the (forthcoming) `os build` save-gate for `kind:'jsx'` pages, and re-exportable by `@object-ui/sdui-parser` on the client. diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json new file mode 100644 index 0000000000..76d2c93351 --- /dev/null +++ b/packages/sdui-parser/package.json @@ -0,0 +1,29 @@ +{ + "name": "@objectstack/sdui-parser", + "version": "11.1.0", + "license": "Apache-2.0", + "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../tsup.config.ts", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "keywords": ["objectstack", "sdui", "jsx", "parser", "adr-0080"], + "author": "ObjectStack", + "repository": { "type": "git", "url": "https://github.com/objectstack-ai/framework.git", "directory": "packages/sdui-parser" }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/framework/issues", + "publishConfig": { "access": "public" } +} diff --git a/packages/sdui-parser/src/__tests__/compile.test.ts b/packages/sdui-parser/src/__tests__/compile.test.ts new file mode 100644 index 0000000000..6a2ea8cbfd --- /dev/null +++ b/packages/sdui-parser/src/__tests__/compile.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { compile, generateDts, manifestFromConfigs } from '../index.js'; + +// A tiny public-tier manifest, shaped exactly like getAllConfigs() output. +const manifest = manifestFromConfigs([ + { type: 'flex', namespace: 'ui', isContainer: true, inputs: [ + { name: 'direction', type: 'enum', enum: ['row', 'col'] }, + { name: 'gap', type: 'number' }, + { name: 'wrap', type: 'boolean' }, + ] }, + { type: 'card', namespace: 'ui', isContainer: true, inputs: [ + { name: 'title', type: 'string' }, + ] }, + { type: 'object-table', namespace: 'plugin-grid', isContainer: false, inputs: [ + { name: 'object', type: 'string', required: true, binding: 'object' }, + { name: 'columns', type: 'array' }, + { name: 'pageSize', type: 'number' }, + ] }, +]); + +describe('compile (parse + validate)', () => { + it('compiles valid JSX to a tree and reports requires + bindings', () => { + const r = compile( + ` + + `, + manifest, + ); + expect(r.ok).toBe(true); + expect(r.diagnostics).toEqual([]); + expect(r.tree).toMatchObject({ + type: 'flex', + direction: 'row', + gap: 4, + wrap: true, + children: [{ type: 'object-table', object: 'account', pageSize: 25 }], + }); + expect(r.requires.sort()).toEqual(['plugin-grid', 'ui']); + expect(r.bindings).toEqual([ + { tag: 'object-table', input: 'object', kind: 'object', value: 'account' }, + ]); + }); + + it('rejects unknown components (whitelist = manifest tags)', () => { + const r = compile(``, manifest); + expect(r.ok).toBe(false); + expect(r.diagnostics.map((d) => d.code)).toContain('forbidden-tag'); + }); + + it('flags a missing required prop (completeness)', () => { + const r = compile(``, manifest); + expect(r.ok).toBe(false); + expect(r.diagnostics).toContainEqual( + expect.objectContaining({ code: 'missing-required-prop' }), + ); + }); + + it('flags an illegal enum value and a coarse type mismatch', () => { + const r = compile(``, manifest); + expect(r.diagnostics.map((d) => d.code)).toEqual( + expect.arrayContaining(['invalid-enum', 'type-mismatch']), + ); + }); + + it('rejects event handlers and raw-html injection', () => { + const r = compile(``, manifest); + expect(r.diagnostics.filter((d) => d.code === 'forbidden-attr')).toHaveLength(2); + }); +}); + +describe('generateDts (the JSX type surface)', () => { + it('emits a JSX.IntrinsicElements augmentation from the manifest', () => { + const dts = generateDts(manifest); + expect(dts).toContain('"object-table": ObjectTableProps;'); + expect(dts).toContain('export interface ObjectTableProps extends SduiBaseProps'); + expect(dts).toContain('object: string;'); // required → not optional + expect(dts).toContain('pageSize?: number;'); // optional + expect(dts).toContain('direction?: "row" | "col";'); // enum → union + }); +}); diff --git a/packages/sdui-parser/src/codegen.ts b/packages/sdui-parser/src/codegen.ts new file mode 100644 index 0000000000..571715aeaa --- /dev/null +++ b/packages/sdui-parser/src/codegen.ts @@ -0,0 +1,138 @@ +/** + * ObjectUI — codegen the JSX type surface from the registry manifest (ADR-0080 §3) + * + * Emits a `.d.ts` that augments `JSX.IntrinsicElements` so a constrained + * JSX-source page type-checks in `.tsx`: tag name === registry `type` key, + * attributes === the component's manifest `inputs`. This is a TYPE-CHECKING + * fiction — no real React intrinsic named `flex` exists; the interpreter + * fulfills it via the registry at render time. + */ + +import type { Manifest, ManifestComponent, ManifestInput } from './types.js'; + +export interface CodegenOptions { + /** include a self-contained minimal JSX namespace so the d.ts type-checks + * standalone (no React types needed). Default true. */ + standaloneJsx?: boolean; +} + +export function generateDts(manifest: Manifest, options: CodegenOptions = {}): string { + const { standaloneJsx = true } = options; + const comps = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type)); + + const interfaces = comps.map(emitInterface).join('\n\n'); + const intrinsics = comps + .map((c) => ` ${JSON.stringify(c.type)}: ${propsName(c.type)};`) + .join('\n'); + + const baseElement = standaloneJsx + ? ` + // minimal, so the surface type-checks without pulling React types + type Element = unknown; + interface ElementClass {} + interface ElementAttributesProperty {} + interface ElementChildrenAttribute { children: object; }` + : ''; + + return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT. +// Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen. +/* eslint-disable */ + +export interface SduiBaseProps { + id?: string; + className?: string; + style?: Record; + visible?: boolean; + visibleOn?: string; + disabled?: boolean; + disabledOn?: string; + children?: unknown; +} + +${interfaces} + +declare global { + namespace JSX { + interface IntrinsicElements { +${intrinsics} + }${baseElement} + } +} + +export {}; +`; +} + +function emitInterface(comp: ManifestComponent): string { + const lines = comp.inputs + .filter((i) => i.type !== 'slot') + .map((i) => ` ${propLine(i)}`) + .join('\n'); + return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`; +} + +function propLine(input: ManifestInput): string { + const opt = input.required ? '' : '?'; + return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`; +} + +function tsType(input: ManifestInput): string { + switch (input.type) { + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'array': + return 'unknown[]'; + case 'object': + return 'Record'; + case 'enum': { + const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); + return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string'; + } + case 'string': + case 'color': + case 'date': + case 'code': + case 'file': + default: + return 'string'; + } +} + +const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const quoteKeyIfNeeded = (name: string): string => (IDENT.test(name) ? name : JSON.stringify(name)); + +/** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */ +export function propsName(type: string): string { + const pascal = type + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((s) => s[0].toUpperCase() + s.slice(1)) + .join(''); + return `${pascal}Props`; +} + +/** + * Generate the human-facing PUBLIC block list (the curated "清单") from a + * manifest — a Markdown table. Derived, never hand-maintained (ADR-0046). + */ +export function generateBlockList(manifest: Manifest): string { + const rows = Object.values(manifest.components) + .sort((a, b) => a.type.localeCompare(b.type)) + .map((c) => { + const req = c.inputs.filter((i) => i.required).map((i) => i.name); + const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`); + return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`; + }); + return [ + `# SDUI public blocks (${Object.keys(manifest.components).length})`, + '', + '> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.', + '', + '| block | plugin | container | required props | bindings |', + '|---|---|---|---|---|', + ...rows, + '', + ].join('\n'); +} diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts new file mode 100644 index 0000000000..12ae7c31c2 --- /dev/null +++ b/packages/sdui-parser/src/index.ts @@ -0,0 +1,108 @@ +/** + * @object-ui/sdui-parser — constrained JSX-source → SDUI SchemaNode tree (ADR-0080) + * + * Isomorphic, zero React. Run server-side as the authoritative save-time gate; + * may also run client-side for live edit preview (re-validated on the server — + * never the trust boundary). It PARSES; it never executes. + */ + +export * from './types.js'; +export { parseJsx, interpretBrace } from './parse.js'; +export { validateTree } from './validate.js'; +export { generateDts, propsName, generateBlockList } from './codegen.js'; +export type { CodegenOptions } from './codegen.js'; + +import { parseJsx } from './parse.js'; +import { validateTree } from './validate.js'; +import type { Diagnostic, Manifest, SchemaElement, ValidationResult } from './types.js'; + +export interface CompileResult { + tree: SchemaElement | null; + diagnostics: Diagnostic[]; + requires: string[]; + bindings: ValidationResult['bindings']; + /** true when there are no error-severity diagnostics — the save gate's pass/fail */ + ok: boolean; +} + +/** + * The authoritative pipeline: parse (with the manifest's tags as the whitelist) + * → validate against the manifest → derive `requires` + binding sites. + */ +export function compile(source: string, manifest: Manifest): CompileResult { + const allowedTags = new Set(Object.keys(manifest.components)); + const parsed = parseJsx(source, { allowedTags }); + const validated = validateTree(parsed.tree, manifest); + const diagnostics = [...parsed.diagnostics, ...validated.diagnostics]; + return { + tree: parsed.tree, + diagnostics, + requires: validated.requires, + bindings: validated.bindings, + ok: !diagnostics.some((d) => d.severity === 'error'), + }; +} + +/* ------------------------------------------------------------------ * + * Registry → manifest adapter. Structural input (no @object-ui/core + * dependency) so the package stays pure and hoistable to framework. + * Feed it `ComponentRegistry.getAllConfigs()` (optionally filtered to + * the `tier:'public'` set). + * ------------------------------------------------------------------ */ + +export interface RegistryConfigLike { + type: string; + namespace?: string; + isContainer?: boolean; + /** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */ + tier?: 'public' | 'internal'; + label?: string; + category?: string; + inputs?: Array<{ + name: string; + type: string; + required?: boolean; + enum?: Array; + binding?: 'object' | 'field'; + description?: string; + }>; +} + +const INPUT_TYPES = new Set([ + 'string', + 'number', + 'boolean', + 'enum', + 'array', + 'object', + 'color', + 'date', + 'code', + 'file', + 'slot', +]); + +export function manifestFromConfigs( + configs: RegistryConfigLike[], + opts: { only?: Set; publicOnly?: boolean } = {}, +): Manifest { + const components: Manifest['components'] = {}; + for (const c of configs) { + if (opts.only && !opts.only.has(c.type)) continue; + if (opts.publicOnly && c.tier !== 'public') continue; + components[c.type] = { + type: c.type, + namespace: c.namespace, + isContainer: c.isContainer, + inputs: (c.inputs ?? []).map((i) => ({ + name: i.name, + type: (INPUT_TYPES.has(i.type) ? i.type : 'string') as Manifest['components'][string]['inputs'][number]['type'], + required: i.required, + enum: i.enum, + binding: i.binding, + description: i.description, + })), + }; + } + return { components }; +} diff --git a/packages/sdui-parser/src/parse.ts b/packages/sdui-parser/src/parse.ts new file mode 100644 index 0000000000..d2b21b3d6c --- /dev/null +++ b/packages/sdui-parser/src/parse.ts @@ -0,0 +1,272 @@ +/** + * ObjectUI — SDUI JSX-source parser (ADR-0080) + * + * A small recursive-descent parser for a CONSTRAINED JSX subset. It is + * deliberately not a full JS/JSX parser: a bounded grammar is the point + * (Markdoc model) — it shrinks the attack surface and the expressible-but-wrong + * space. Output is the existing SDUI `SchemaNode` tree. Nothing is executed. + * + * Grammar (informal): + * document := element (exactly one root) + * element := openTag child-star closeTag, or a self-closing tag + * attr := name '=' (string | braced), or a bare name meaning true + * child := element | text | jsx-block-comment + * tag := [A-Za-z][A-Za-z0-9:_-]star (matches registry keys) + */ + +import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode } from './types.js'; + +/** Event handlers and raw-HTML injection are never allowed (parse ≠ execute). */ +const EVENT_ATTR = /^on[A-Z]/; +const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']); + +export function parseJsx(source: string, options: ParseOptions = {}): ParseResult { + return new Parser(source, options).parseDocument(); +} + +const isNameStart = (c: string) => /[A-Za-z]/.test(c); +const isNameChar = (c: string) => /[A-Za-z0-9:_-]/.test(c); + +class Parser { + private pos = 0; + private readonly diagnostics: Diagnostic[] = []; + + constructor(private readonly src: string, private readonly opts: ParseOptions) {} + + parseDocument(): ParseResult { + this.skipTrivia(); + if (this.peek() !== '<') { + this.error('no-root', 'Expected a single root element'); + return { tree: null, diagnostics: this.diagnostics }; + } + const tree = this.parseElement(); + this.skipTrivia(); + if (tree && this.pos < this.src.length) { + this.error('multiple-roots', 'A page must have exactly one root element', this.pos); + } + return { tree, diagnostics: this.diagnostics }; + } + + private parseElement(): SchemaElement | null { + const start = this.pos; + if (!this.eat('<')) { + this.error('expected-element', 'Expected "<"', start); + return null; + } + const tag = this.readName(); + if (!tag) { + this.error('bad-tag', 'Expected a tag name after "<"', start); + return null; + } + if (this.opts.allowedTags && !this.opts.allowedTags.has(tag)) { + this.error('forbidden-tag', `<${tag}> is not an allowed component`, start, tag); + } + + const props: Record = {}; + for (;;) { + this.skipWs(); + const c = this.peek(); + if (c === '' || c === '>' || c === '/') break; + const attr = this.parseAttr(start, tag); + if (!attr) break; + props[attr.name] = attr.value; + } + + this.skipWs(); + let children: SchemaNode[] | undefined; + if (this.eat('/')) { + if (!this.eat('>')) this.error('bad-self-close', `Malformed self-closing <${tag}>`, this.pos, tag); + } else if (this.eat('>')) { + children = this.parseChildren(tag); + } else { + this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag); + } + + const node: SchemaElement = { type: tag, ...props }; + if (children && children.length) node.children = children; + return node; + } + + private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null { + const name = this.readName(); + if (!name) { + this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag); + // skip one char to avoid an infinite loop on garbage + this.pos++; + return null; + } + this.skipWs(); + let value: unknown = true; // bare attribute => boolean true + if (this.eat('=')) { + this.skipWs(); + value = this.parseAttrValue(tag); + } + if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) { + this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag); + return { name: `__forbidden_${name}`, value: undefined }; + } + return { name, value }; + } + + private parseAttrValue(tag: string): unknown { + const c = this.peek(); + if (c === '"' || c === "'") return this.readString(c); + if (c === '{') return interpretBrace(this.readBraced()); + this.error('bad-attr-value', `Expected an attribute value on <${tag}>`, this.pos, tag); + return undefined; + } + + private parseChildren(parentTag: string): SchemaNode[] { + const children: SchemaNode[] = []; + for (;;) { + if (this.pos >= this.src.length) { + this.error('unclosed-element', `Unclosed <${parentTag}>`, this.pos, parentTag); + break; + } + // closing tag + if (this.src.startsWith(''); + if (close !== parentTag) { + this.error('mismatched-tag', `Expected but found `, this.pos, parentTag); + } + break; + } + // JSX comment {/* ... */} + if (this.src.startsWith('{/*', this.pos)) { + const end = this.src.indexOf('*/}', this.pos); + if (end === -1) { + this.error('unclosed-comment', 'Unclosed comment', this.pos); + this.pos = this.src.length; + } else { + this.pos = end + 3; + } + continue; + } + // nested element + if (this.peek() === '<') { + const el = this.parseElement(); + if (el) children.push(el); + continue; + } + // expression child {expr} — out of grammar for v1: skip with a warning + if (this.peek() === '{') { + const start = this.pos; + this.readBraced(); + this.error( + 'expression-child', + 'Inline {expression} children are not supported yet — bind via a component prop', + start, + ); + continue; + } + // text + const text = this.readTextRun(); + const trimmed = text.replace(/\s+/g, ' ').trim(); + if (trimmed) children.push(trimmed); + } + return children; + } + + /* ----------------------------- lexing ----------------------------- */ + + private peek(): string { + return this.pos < this.src.length ? this.src[this.pos] : ''; + } + + private eat(ch: string): boolean { + if (this.src[this.pos] === ch) { + this.pos++; + return true; + } + return false; + } + + private readName(): string { + if (!isNameStart(this.peek())) return ''; + const start = this.pos; + this.pos++; + while (this.pos < this.src.length && isNameChar(this.src[this.pos])) this.pos++; + return this.src.slice(start, this.pos); + } + + private readString(quote: string): string { + this.pos++; // opening quote + const start = this.pos; + while (this.pos < this.src.length && this.src[this.pos] !== quote) this.pos++; + const value = this.src.slice(start, this.pos); + if (!this.eat(quote)) this.error('unterminated-string', 'Unterminated string literal', start); + return value; + } + + /** Reads a balanced `{ ... }` run and returns the inner text (no outer braces). */ + private readBraced(): string { + const start = this.pos; + let depth = 0; + let inStr: string | null = null; + for (; this.pos < this.src.length; this.pos++) { + const ch = this.src[this.pos]; + if (inStr) { + if (ch === inStr && this.src[this.pos - 1] !== '\\') inStr = null; + continue; + } + if (ch === '"' || ch === "'") inStr = ch; + else if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + const inner = this.src.slice(start + 1, this.pos); + this.pos++; // consume closing brace + return inner; + } + } + } + this.error('unterminated-brace', 'Unterminated "{"', start); + return this.src.slice(start + 1); + } + + private readTextRun(): string { + const start = this.pos; + while (this.pos < this.src.length && this.src[this.pos] !== '<' && this.src[this.pos] !== '{') this.pos++; + return this.src.slice(start, this.pos); + } + + private skipWs(): void { + while (this.pos < this.src.length && /\s/.test(this.src[this.pos])) this.pos++; + } + + /** whitespace + top-level JSX comments */ + private skipTrivia(): void { + for (;;) { + this.skipWs(); + if (this.src.startsWith('{/*', this.pos)) { + const end = this.src.indexOf('*/}', this.pos); + this.pos = end === -1 ? this.src.length : end + 3; + continue; + } + break; + } + } + + private error(code: string, message: string, start?: number, tag?: string): void { + this.diagnostics.push({ severity: 'error', code, message, start: start ?? this.pos, tag }); + } +} + +/** + * Interpret a braced attribute value `{...}`. + * JSON-literal values (numbers, booleans, null, strings, arrays, objects with + * quoted keys) are materialized. Anything else is kept as a deferred expression + * marker `{ $expr }` — typed and validated later, NEVER evaluated here. + */ +export function interpretBrace(raw: string): unknown { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed); + } catch { + return { $expr: trimmed }; + } +} diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts new file mode 100644 index 0000000000..ace9766471 --- /dev/null +++ b/packages/sdui-parser/src/types.ts @@ -0,0 +1,95 @@ +/** + * ObjectUI — SDUI JSX-source parser (ADR-0080) + * + * Types shared by the constrained JSX-source compiler. The parser turns a + * constrained JSX/HTML+Tailwind *text* into the existing SDUI `SchemaNode` + * tree. It PARSES — it never executes. No `import`, no `eval`, no JS. + */ + +/** A node in the compiled SDUI tree. Mirrors `@object-ui/types` BaseSchema. */ +export type SchemaNode = SchemaElement | string; + +export interface SchemaElement { + type: string; + children?: SchemaNode[]; + [prop: string]: unknown; +} + +export type Severity = 'error' | 'warning'; + +export interface Diagnostic { + severity: Severity; + /** stable machine code, e.g. 'forbidden-tag' */ + code: string; + message: string; + /** byte offset into the source where the issue starts */ + start?: number; + /** the tag/component involved, when relevant */ + tag?: string; +} + +export interface ParseOptions { + /** + * Whitelist of allowed tag names (= registry `type` set, from the manifest). + * When provided, any tag outside it is a `forbidden-tag` error — this is the + * sanitization boundary. When omitted, all tags are accepted (lexing only). + */ + allowedTags?: Set; +} + +export interface ParseResult { + /** the compiled tree, or null when the source has no valid root */ + tree: SchemaElement | null; + diagnostics: Diagnostic[]; +} + +/* ------------------------------------------------------------------ * + * Manifest — the serialized public-tier contract from the registry. + * Produced by serializing `ComponentRegistry.getAllConfigs()` (ADR-0080 §3/§6). + * ------------------------------------------------------------------ */ + +export type ManifestInputType = + | 'string' + | 'number' + | 'boolean' + | 'enum' + | 'array' + | 'object' + | 'color' + | 'date' + | 'code' + | 'file' + | 'slot'; + +export interface ManifestInput { + name: string; + type: ManifestInputType; + required?: boolean; + /** allowed values for `enum` inputs */ + enum?: Array; + /** marks a data-binding input the server must resolve (ADR-0080 §6.3) */ + binding?: 'object' | 'field'; + description?: string; +} + +export interface ManifestComponent { + type: string; + /** plugin namespace — provenance that drives `requires` */ + namespace?: string; + inputs: ManifestInput[]; + isContainer?: boolean; +} + +export interface Manifest { + /** keyed by component `type` */ + components: Record; +} + +/** Result of validating a compiled tree against the manifest. */ +export interface ValidationResult { + diagnostics: Diagnostic[]; + /** unique plugin namespaces referenced — the page's `requires` */ + requires: string[]; + /** binding sites (object/field) the server must resolve against object schema */ + bindings: Array<{ tag: string; input: string; kind: 'object' | 'field'; value: unknown }>; +} diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts new file mode 100644 index 0000000000..c220cb8777 --- /dev/null +++ b/packages/sdui-parser/src/validate.ts @@ -0,0 +1,144 @@ +/** + * ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6) + * + * Shallow, author-time validation: unknown component, unknown/missing prop, + * wrong coarse type, illegal enum value. Collects `requires` (plugin provenance) + * and binding sites the SERVER must resolve against object schema (we cannot + * resolve objects/fields here — that check is framework-side by design). + */ + +import type { + Diagnostic, + Manifest, + ManifestInput, + SchemaElement, + SchemaNode, + ValidationResult, +} from './types.js'; + +/** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */ +const BASE_PROPS = new Set([ + 'type', + 'id', + 'className', + 'style', + 'visible', + 'visibleOn', + 'disabled', + 'disabledOn', + 'children', +]); + +const isExpr = (v: unknown): boolean => + typeof v === 'object' && v !== null && '$expr' in (v as Record); + +export function validateTree(tree: SchemaElement | null, manifest: Manifest): ValidationResult { + const diagnostics: Diagnostic[] = []; + const requires = new Set(); + const bindings: ValidationResult['bindings'] = []; + + const visit = (node: SchemaNode): void => { + if (typeof node === 'string') return; + const comp = manifest.components[node.type]; + if (!comp) { + diagnostics.push({ + severity: 'error', + code: 'unknown-component', + message: `<${node.type}> is not a known component`, + tag: node.type, + }); + } else { + if (comp.namespace) requires.add(comp.namespace); + const byName = new Map(comp.inputs.map((i) => [i.name, i])); + + // required present? + for (const input of comp.inputs) { + if (input.required && !(input.name in node)) { + diagnostics.push({ + severity: 'error', + code: 'missing-required-prop', + message: `<${node.type}> is missing required prop "${input.name}"`, + tag: node.type, + }); + } + } + + // each provided prop + for (const [key, value] of Object.entries(node)) { + if (BASE_PROPS.has(key)) continue; + const input = byName.get(key); + if (!input) { + diagnostics.push({ + severity: 'warning', + code: 'unknown-prop', + message: `<${node.type}> has no prop "${key}"`, + tag: node.type, + }); + continue; + } + if (input.binding) { + bindings.push({ tag: node.type, input: key, kind: input.binding, value }); + } + if (!isExpr(value)) { + const typeDiag = checkType(node.type, input, value); + if (typeDiag) diagnostics.push(typeDiag); + } + } + + // containment + if (node.children?.length && !comp.isContainer) { + diagnostics.push({ + severity: 'warning', + code: 'not-a-container', + message: `<${node.type}> does not accept children`, + tag: node.type, + }); + } + } + + if (node.children) node.children.forEach(visit); + }; + + if (tree) visit(tree); + return { diagnostics, requires: [...requires], bindings }; +} + +function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null { + const mismatch = (expected: string): Diagnostic => ({ + severity: 'warning', + code: 'type-mismatch', + message: `<${tag}> prop "${input.name}" expected ${expected}`, + tag, + }); + switch (input.type) { + case 'number': + return typeof value === 'number' ? null : mismatch('a number'); + case 'boolean': + return typeof value === 'boolean' ? null : mismatch('a boolean'); + case 'string': + case 'color': + case 'date': + case 'code': + case 'file': + return typeof value === 'string' ? null : mismatch('a string'); + case 'array': + return Array.isArray(value) ? null : mismatch('an array'); + case 'object': + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? null + : mismatch('an object'); + case 'enum': { + const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); + return allowed.includes(value as never) + ? null + : { + severity: 'error', + code: 'invalid-enum', + message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`, + tag, + }; + } + default: + return null; + } +} diff --git a/packages/sdui-parser/tsconfig.json b/packages/sdui-parser/tsconfig.json new file mode 100644 index 0000000000..84f5d95f7e --- /dev/null +++ b/packages/sdui-parser/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8dc729e28..cb89012fc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1661,6 +1661,15 @@ importers: specifier: workspace:* version: link:../plugins/driver-mongodb + packages/sdui-parser: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.0.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/services/service-analytics: dependencies: '@objectstack/core':