Skip to content

Commit 012c046

Browse files
os-zhuangclaude
andauthored
feat: @objectstack/sdui-parser — parser's framework home (ADR-0080 M3b) (#2437)
* feat(sdui-parser): hoist parser to framework @objectstack/sdui-parser (ADR-0080 M3b) The constrained JSX-source -> SchemaNode compiler's canonical home (pure, isomorphic, zero React). Server-side this is what the os build save-gate will call to compile/validate kind:'jsx' page source; the client @object-ui/sdui-parser can re-export it. Builds via tsup (CJS+ESM+DTS); compile/completeness/codegen verified against the built dist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: update lockfile for @objectstack/sdui-parser (frozen-lockfile CI) --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 95c1911 commit 012c046

11 files changed

Lines changed: 890 additions & 1 deletion

File tree

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"@objectstack/objectql",
2424
"@objectstack/metadata-protocol",
2525
"@objectstack/observability",
26-
"@objectstack/formula",
26+
"@objectstack/formula", "@objectstack/sdui-parser",
2727
"@objectstack/lint",
2828
"@objectstack/platform-objects",
2929
"@objectstack/studio",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/sdui-parser": minor
3+
---
4+
5+
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.

packages/sdui-parser/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@objectstack/sdui-parser",
3+
"version": "11.1.0",
4+
"license": "Apache-2.0",
5+
"description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../tsup.config.ts",
17+
"test": "vitest run"
18+
},
19+
"devDependencies": {
20+
"typescript": "^6.0.3",
21+
"vitest": "^4.1.9"
22+
},
23+
"keywords": ["objectstack", "sdui", "jsx", "parser", "adr-0080"],
24+
"author": "ObjectStack",
25+
"repository": { "type": "git", "url": "https://github.com/objectstack-ai/framework.git", "directory": "packages/sdui-parser" },
26+
"homepage": "https://objectstack.ai/docs",
27+
"bugs": "https://github.com/objectstack-ai/framework/issues",
28+
"publishConfig": { "access": "public" }
29+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { compile, generateDts, manifestFromConfigs } from '../index.js';
3+
4+
// A tiny public-tier manifest, shaped exactly like getAllConfigs() output.
5+
const manifest = manifestFromConfigs([
6+
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
7+
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
8+
{ name: 'gap', type: 'number' },
9+
{ name: 'wrap', type: 'boolean' },
10+
] },
11+
{ type: 'card', namespace: 'ui', isContainer: true, inputs: [
12+
{ name: 'title', type: 'string' },
13+
] },
14+
{ type: 'object-table', namespace: 'plugin-grid', isContainer: false, inputs: [
15+
{ name: 'object', type: 'string', required: true, binding: 'object' },
16+
{ name: 'columns', type: 'array' },
17+
{ name: 'pageSize', type: 'number' },
18+
] },
19+
]);
20+
21+
describe('compile (parse + validate)', () => {
22+
it('compiles valid JSX to a tree and reports requires + bindings', () => {
23+
const r = compile(
24+
`<flex direction="row" gap={4} wrap>
25+
<object-table object="account" columns={["name","amount"]} pageSize={25} />
26+
</flex>`,
27+
manifest,
28+
);
29+
expect(r.ok).toBe(true);
30+
expect(r.diagnostics).toEqual([]);
31+
expect(r.tree).toMatchObject({
32+
type: 'flex',
33+
direction: 'row',
34+
gap: 4,
35+
wrap: true,
36+
children: [{ type: 'object-table', object: 'account', pageSize: 25 }],
37+
});
38+
expect(r.requires.sort()).toEqual(['plugin-grid', 'ui']);
39+
expect(r.bindings).toEqual([
40+
{ tag: 'object-table', input: 'object', kind: 'object', value: 'account' },
41+
]);
42+
});
43+
44+
it('rejects unknown components (whitelist = manifest tags)', () => {
45+
const r = compile(`<flex><script>alert(1)</script></flex>`, manifest);
46+
expect(r.ok).toBe(false);
47+
expect(r.diagnostics.map((d) => d.code)).toContain('forbidden-tag');
48+
});
49+
50+
it('flags a missing required prop (completeness)', () => {
51+
const r = compile(`<object-table columns={[]} />`, manifest);
52+
expect(r.ok).toBe(false);
53+
expect(r.diagnostics).toContainEqual(
54+
expect.objectContaining({ code: 'missing-required-prop' }),
55+
);
56+
});
57+
58+
it('flags an illegal enum value and a coarse type mismatch', () => {
59+
const r = compile(`<flex direction="diagonal" gap="big" />`, manifest);
60+
expect(r.diagnostics.map((d) => d.code)).toEqual(
61+
expect.arrayContaining(['invalid-enum', 'type-mismatch']),
62+
);
63+
});
64+
65+
it('rejects event handlers and raw-html injection', () => {
66+
const r = compile(`<card onClick="steal()" dangerouslySetInnerHTML={{}} />`, manifest);
67+
expect(r.diagnostics.filter((d) => d.code === 'forbidden-attr')).toHaveLength(2);
68+
});
69+
});
70+
71+
describe('generateDts (the JSX type surface)', () => {
72+
it('emits a JSX.IntrinsicElements augmentation from the manifest', () => {
73+
const dts = generateDts(manifest);
74+
expect(dts).toContain('"object-table": ObjectTableProps;');
75+
expect(dts).toContain('export interface ObjectTableProps extends SduiBaseProps');
76+
expect(dts).toContain('object: string;'); // required → not optional
77+
expect(dts).toContain('pageSize?: number;'); // optional
78+
expect(dts).toContain('direction?: "row" | "col";'); // enum → union
79+
});
80+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* ObjectUI — codegen the JSX type surface from the registry manifest (ADR-0080 §3)
3+
*
4+
* Emits a `.d.ts` that augments `JSX.IntrinsicElements` so a constrained
5+
* JSX-source page type-checks in `.tsx`: tag name === registry `type` key,
6+
* attributes === the component's manifest `inputs`. This is a TYPE-CHECKING
7+
* fiction — no real React intrinsic named `flex` exists; the interpreter
8+
* fulfills it via the registry at render time.
9+
*/
10+
11+
import type { Manifest, ManifestComponent, ManifestInput } from './types.js';
12+
13+
export interface CodegenOptions {
14+
/** include a self-contained minimal JSX namespace so the d.ts type-checks
15+
* standalone (no React types needed). Default true. */
16+
standaloneJsx?: boolean;
17+
}
18+
19+
export function generateDts(manifest: Manifest, options: CodegenOptions = {}): string {
20+
const { standaloneJsx = true } = options;
21+
const comps = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
22+
23+
const interfaces = comps.map(emitInterface).join('\n\n');
24+
const intrinsics = comps
25+
.map((c) => ` ${JSON.stringify(c.type)}: ${propsName(c.type)};`)
26+
.join('\n');
27+
28+
const baseElement = standaloneJsx
29+
? `
30+
// minimal, so the surface type-checks without pulling React types
31+
type Element = unknown;
32+
interface ElementClass {}
33+
interface ElementAttributesProperty {}
34+
interface ElementChildrenAttribute { children: object; }`
35+
: '';
36+
37+
return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT.
38+
// Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen.
39+
/* eslint-disable */
40+
41+
export interface SduiBaseProps {
42+
id?: string;
43+
className?: string;
44+
style?: Record<string, unknown>;
45+
visible?: boolean;
46+
visibleOn?: string;
47+
disabled?: boolean;
48+
disabledOn?: string;
49+
children?: unknown;
50+
}
51+
52+
${interfaces}
53+
54+
declare global {
55+
namespace JSX {
56+
interface IntrinsicElements {
57+
${intrinsics}
58+
}${baseElement}
59+
}
60+
}
61+
62+
export {};
63+
`;
64+
}
65+
66+
function emitInterface(comp: ManifestComponent): string {
67+
const lines = comp.inputs
68+
.filter((i) => i.type !== 'slot')
69+
.map((i) => ` ${propLine(i)}`)
70+
.join('\n');
71+
return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`;
72+
}
73+
74+
function propLine(input: ManifestInput): string {
75+
const opt = input.required ? '' : '?';
76+
return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`;
77+
}
78+
79+
function tsType(input: ManifestInput): string {
80+
switch (input.type) {
81+
case 'number':
82+
return 'number';
83+
case 'boolean':
84+
return 'boolean';
85+
case 'array':
86+
return 'unknown[]';
87+
case 'object':
88+
return 'Record<string, unknown>';
89+
case 'enum': {
90+
const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
91+
return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string';
92+
}
93+
case 'string':
94+
case 'color':
95+
case 'date':
96+
case 'code':
97+
case 'file':
98+
default:
99+
return 'string';
100+
}
101+
}
102+
103+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
104+
const quoteKeyIfNeeded = (name: string): string => (IDENT.test(name) ? name : JSON.stringify(name));
105+
106+
/** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */
107+
export function propsName(type: string): string {
108+
const pascal = type
109+
.split(/[^A-Za-z0-9]+/)
110+
.filter(Boolean)
111+
.map((s) => s[0].toUpperCase() + s.slice(1))
112+
.join('');
113+
return `${pascal}Props`;
114+
}
115+
116+
/**
117+
* Generate the human-facing PUBLIC block list (the curated "清单") from a
118+
* manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
119+
*/
120+
export function generateBlockList(manifest: Manifest): string {
121+
const rows = Object.values(manifest.components)
122+
.sort((a, b) => a.type.localeCompare(b.type))
123+
.map((c) => {
124+
const req = c.inputs.filter((i) => i.required).map((i) => i.name);
125+
const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
126+
return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
127+
});
128+
return [
129+
`# SDUI public blocks (${Object.keys(manifest.components).length})`,
130+
'',
131+
'> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
132+
'',
133+
'| block | plugin | container | required props | bindings |',
134+
'|---|---|---|---|---|',
135+
...rows,
136+
'',
137+
].join('\n');
138+
}

packages/sdui-parser/src/index.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @object-ui/sdui-parser — constrained JSX-source → SDUI SchemaNode tree (ADR-0080)
3+
*
4+
* Isomorphic, zero React. Run server-side as the authoritative save-time gate;
5+
* may also run client-side for live edit preview (re-validated on the server —
6+
* never the trust boundary). It PARSES; it never executes.
7+
*/
8+
9+
export * from './types.js';
10+
export { parseJsx, interpretBrace } from './parse.js';
11+
export { validateTree } from './validate.js';
12+
export { generateDts, propsName, generateBlockList } from './codegen.js';
13+
export type { CodegenOptions } from './codegen.js';
14+
15+
import { parseJsx } from './parse.js';
16+
import { validateTree } from './validate.js';
17+
import type { Diagnostic, Manifest, SchemaElement, ValidationResult } from './types.js';
18+
19+
export interface CompileResult {
20+
tree: SchemaElement | null;
21+
diagnostics: Diagnostic[];
22+
requires: string[];
23+
bindings: ValidationResult['bindings'];
24+
/** true when there are no error-severity diagnostics — the save gate's pass/fail */
25+
ok: boolean;
26+
}
27+
28+
/**
29+
* The authoritative pipeline: parse (with the manifest's tags as the whitelist)
30+
* → validate against the manifest → derive `requires` + binding sites.
31+
*/
32+
export function compile(source: string, manifest: Manifest): CompileResult {
33+
const allowedTags = new Set(Object.keys(manifest.components));
34+
const parsed = parseJsx(source, { allowedTags });
35+
const validated = validateTree(parsed.tree, manifest);
36+
const diagnostics = [...parsed.diagnostics, ...validated.diagnostics];
37+
return {
38+
tree: parsed.tree,
39+
diagnostics,
40+
requires: validated.requires,
41+
bindings: validated.bindings,
42+
ok: !diagnostics.some((d) => d.severity === 'error'),
43+
};
44+
}
45+
46+
/* ------------------------------------------------------------------ *
47+
* Registry → manifest adapter. Structural input (no @object-ui/core
48+
* dependency) so the package stays pure and hoistable to framework.
49+
* Feed it `ComponentRegistry.getAllConfigs()` (optionally filtered to
50+
* the `tier:'public'` set).
51+
* ------------------------------------------------------------------ */
52+
53+
export interface RegistryConfigLike {
54+
type: string;
55+
namespace?: string;
56+
isContainer?: boolean;
57+
/** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */
58+
tier?: 'public' | 'internal';
59+
label?: string;
60+
category?: string;
61+
inputs?: Array<{
62+
name: string;
63+
type: string;
64+
required?: boolean;
65+
enum?: Array<string | { value: unknown; label?: string }>;
66+
binding?: 'object' | 'field';
67+
description?: string;
68+
}>;
69+
}
70+
71+
const INPUT_TYPES = new Set([
72+
'string',
73+
'number',
74+
'boolean',
75+
'enum',
76+
'array',
77+
'object',
78+
'color',
79+
'date',
80+
'code',
81+
'file',
82+
'slot',
83+
]);
84+
85+
export function manifestFromConfigs(
86+
configs: RegistryConfigLike[],
87+
opts: { only?: Set<string>; publicOnly?: boolean } = {},
88+
): Manifest {
89+
const components: Manifest['components'] = {};
90+
for (const c of configs) {
91+
if (opts.only && !opts.only.has(c.type)) continue;
92+
if (opts.publicOnly && c.tier !== 'public') continue;
93+
components[c.type] = {
94+
type: c.type,
95+
namespace: c.namespace,
96+
isContainer: c.isContainer,
97+
inputs: (c.inputs ?? []).map((i) => ({
98+
name: i.name,
99+
type: (INPUT_TYPES.has(i.type) ? i.type : 'string') as Manifest['components'][string]['inputs'][number]['type'],
100+
required: i.required,
101+
enum: i.enum,
102+
binding: i.binding,
103+
description: i.description,
104+
})),
105+
};
106+
}
107+
return { components };
108+
}

0 commit comments

Comments
 (0)