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('', this.pos)) {
+ this.pos += 2;
+ this.skipWs();
+ const close = this.readName();
+ this.skipWs();
+ this.eat('>');
+ if (close !== parentTag) {
+ this.error('mismatched-tag', `Expected ${parentTag}> but found ${close}>`, 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':