Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/sdui-parser-framework.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions packages/sdui-parser/package.json
Original file line number Diff line number Diff line change
@@ -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" }
}
80 changes: 80 additions & 0 deletions packages/sdui-parser/src/__tests__/compile.test.ts
Original file line number Diff line number Diff line change
@@ -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(
`<flex direction="row" gap={4} wrap>
<object-table object="account" columns={["name","amount"]} pageSize={25} />
</flex>`,
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(`<flex><script>alert(1)</script></flex>`, 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(`<object-table columns={[]} />`, 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(`<flex direction="diagonal" gap="big" />`, 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(`<card onClick="steal()" dangerouslySetInnerHTML={{}} />`, 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
});
});
138 changes: 138 additions & 0 deletions packages/sdui-parser/src/codegen.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>';
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');
}
108 changes: 108 additions & 0 deletions packages/sdui-parser/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string | { value: unknown; label?: string }>;
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<string>; 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 };
}
Loading