@object-ui/sdui-parser 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/codegen.d.ts +23 -0
- package/dist/codegen.js +120 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +66 -0
- package/dist/parse.d.ts +24 -0
- package/dist/parse.js +286 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +8 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.js +131 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 ObjectQL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
import type { Manifest } from './types.js';
|
|
11
|
+
export interface CodegenOptions {
|
|
12
|
+
/** include a self-contained minimal JSX namespace so the d.ts type-checks
|
|
13
|
+
* standalone (no React types needed). Default true. */
|
|
14
|
+
standaloneJsx?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function generateDts(manifest: Manifest, options?: CodegenOptions): string;
|
|
17
|
+
/** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */
|
|
18
|
+
export declare function propsName(type: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Generate the human-facing PUBLIC block list (the curated "清单") from a
|
|
21
|
+
* manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
|
|
22
|
+
*/
|
|
23
|
+
export declare function generateBlockList(manifest: Manifest): string;
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
export function generateDts(manifest, options = {}) {
|
|
11
|
+
const { standaloneJsx = true } = options;
|
|
12
|
+
const comps = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
|
|
13
|
+
const interfaces = comps.map(emitInterface).join('\n\n');
|
|
14
|
+
const intrinsics = comps
|
|
15
|
+
.map((c) => ` ${JSON.stringify(c.type)}: ${propsName(c.type)};`)
|
|
16
|
+
.join('\n');
|
|
17
|
+
const baseElement = standaloneJsx
|
|
18
|
+
? `
|
|
19
|
+
// minimal, so the surface type-checks without pulling React types
|
|
20
|
+
type Element = unknown;
|
|
21
|
+
interface ElementClass {}
|
|
22
|
+
interface ElementAttributesProperty {}
|
|
23
|
+
interface ElementChildrenAttribute { children: object; }`
|
|
24
|
+
: '';
|
|
25
|
+
return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT.
|
|
26
|
+
// Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen.
|
|
27
|
+
/* eslint-disable */
|
|
28
|
+
|
|
29
|
+
export interface SduiBaseProps {
|
|
30
|
+
id?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
style?: Record<string, unknown>;
|
|
33
|
+
visible?: boolean;
|
|
34
|
+
visibleOn?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
disabledOn?: string;
|
|
37
|
+
children?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
${interfaces}
|
|
41
|
+
|
|
42
|
+
declare global {
|
|
43
|
+
namespace JSX {
|
|
44
|
+
interface IntrinsicElements {
|
|
45
|
+
${intrinsics}
|
|
46
|
+
}${baseElement}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export {};
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
function emitInterface(comp) {
|
|
54
|
+
const lines = comp.inputs
|
|
55
|
+
.filter((i) => i.type !== 'slot')
|
|
56
|
+
.map((i) => ` ${propLine(i)}`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`;
|
|
59
|
+
}
|
|
60
|
+
function propLine(input) {
|
|
61
|
+
const opt = input.required ? '' : '?';
|
|
62
|
+
return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`;
|
|
63
|
+
}
|
|
64
|
+
function tsType(input) {
|
|
65
|
+
switch (input.type) {
|
|
66
|
+
case 'number':
|
|
67
|
+
return 'number';
|
|
68
|
+
case 'boolean':
|
|
69
|
+
return 'boolean';
|
|
70
|
+
case 'array':
|
|
71
|
+
return 'unknown[]';
|
|
72
|
+
case 'object':
|
|
73
|
+
return 'Record<string, unknown>';
|
|
74
|
+
case 'enum': {
|
|
75
|
+
const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
|
|
76
|
+
return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string';
|
|
77
|
+
}
|
|
78
|
+
case 'string':
|
|
79
|
+
case 'color':
|
|
80
|
+
case 'date':
|
|
81
|
+
case 'code':
|
|
82
|
+
case 'file':
|
|
83
|
+
default:
|
|
84
|
+
return 'string';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
88
|
+
const quoteKeyIfNeeded = (name) => (IDENT.test(name) ? name : JSON.stringify(name));
|
|
89
|
+
/** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */
|
|
90
|
+
export function propsName(type) {
|
|
91
|
+
const pascal = type
|
|
92
|
+
.split(/[^A-Za-z0-9]+/)
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.map((s) => s[0].toUpperCase() + s.slice(1))
|
|
95
|
+
.join('');
|
|
96
|
+
return `${pascal}Props`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Generate the human-facing PUBLIC block list (the curated "清单") from a
|
|
100
|
+
* manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
|
|
101
|
+
*/
|
|
102
|
+
export function generateBlockList(manifest) {
|
|
103
|
+
const rows = Object.values(manifest.components)
|
|
104
|
+
.sort((a, b) => a.type.localeCompare(b.type))
|
|
105
|
+
.map((c) => {
|
|
106
|
+
const req = c.inputs.filter((i) => i.required).map((i) => i.name);
|
|
107
|
+
const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
|
|
108
|
+
return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
|
|
109
|
+
});
|
|
110
|
+
return [
|
|
111
|
+
`# SDUI public blocks (${Object.keys(manifest.components).length})`,
|
|
112
|
+
'',
|
|
113
|
+
'> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
|
|
114
|
+
'',
|
|
115
|
+
'| block | plugin | container | required props | bindings |',
|
|
116
|
+
'|---|---|---|---|---|',
|
|
117
|
+
...rows,
|
|
118
|
+
'',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
export * from './types.js';
|
|
9
|
+
export { parseJsx, interpretBrace } from './parse.js';
|
|
10
|
+
export { validateTree } from './validate.js';
|
|
11
|
+
export { generateDts, propsName, generateBlockList } from './codegen.js';
|
|
12
|
+
export type { CodegenOptions } from './codegen.js';
|
|
13
|
+
import type { Diagnostic, Manifest, SchemaElement, ValidationResult } from './types.js';
|
|
14
|
+
export interface CompileResult {
|
|
15
|
+
tree: SchemaElement | null;
|
|
16
|
+
diagnostics: Diagnostic[];
|
|
17
|
+
requires: string[];
|
|
18
|
+
bindings: ValidationResult['bindings'];
|
|
19
|
+
/** true when there are no error-severity diagnostics — the save gate's pass/fail */
|
|
20
|
+
ok: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The authoritative pipeline: parse (with the manifest's tags as the whitelist)
|
|
24
|
+
* → validate against the manifest → derive `requires` + binding sites.
|
|
25
|
+
*/
|
|
26
|
+
export declare function compile(source: string, manifest: Manifest): CompileResult;
|
|
27
|
+
export interface RegistryConfigLike {
|
|
28
|
+
type: string;
|
|
29
|
+
namespace?: string;
|
|
30
|
+
isContainer?: boolean;
|
|
31
|
+
/** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */
|
|
32
|
+
tier?: 'public' | 'internal';
|
|
33
|
+
label?: string;
|
|
34
|
+
category?: string;
|
|
35
|
+
inputs?: Array<{
|
|
36
|
+
name: string;
|
|
37
|
+
type: string;
|
|
38
|
+
required?: boolean;
|
|
39
|
+
enum?: Array<string | {
|
|
40
|
+
value: unknown;
|
|
41
|
+
label?: string;
|
|
42
|
+
}>;
|
|
43
|
+
binding?: 'object' | 'field';
|
|
44
|
+
description?: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
export declare function manifestFromConfigs(configs: RegistryConfigLike[], opts?: {
|
|
48
|
+
only?: Set<string>;
|
|
49
|
+
publicOnly?: boolean;
|
|
50
|
+
}): Manifest;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
export * from './types.js';
|
|
9
|
+
export { parseJsx, interpretBrace } from './parse.js';
|
|
10
|
+
export { validateTree } from './validate.js';
|
|
11
|
+
export { generateDts, propsName, generateBlockList } from './codegen.js';
|
|
12
|
+
import { parseJsx } from './parse.js';
|
|
13
|
+
import { validateTree } from './validate.js';
|
|
14
|
+
/**
|
|
15
|
+
* The authoritative pipeline: parse (with the manifest's tags as the whitelist)
|
|
16
|
+
* → validate against the manifest → derive `requires` + binding sites.
|
|
17
|
+
*/
|
|
18
|
+
export function compile(source, manifest) {
|
|
19
|
+
const allowedTags = new Set(Object.keys(manifest.components));
|
|
20
|
+
const parsed = parseJsx(source, { allowedTags });
|
|
21
|
+
const validated = validateTree(parsed.tree, manifest);
|
|
22
|
+
const diagnostics = [...parsed.diagnostics, ...validated.diagnostics];
|
|
23
|
+
return {
|
|
24
|
+
tree: parsed.tree,
|
|
25
|
+
diagnostics,
|
|
26
|
+
requires: validated.requires,
|
|
27
|
+
bindings: validated.bindings,
|
|
28
|
+
ok: !diagnostics.some((d) => d.severity === 'error'),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const INPUT_TYPES = new Set([
|
|
32
|
+
'string',
|
|
33
|
+
'number',
|
|
34
|
+
'boolean',
|
|
35
|
+
'enum',
|
|
36
|
+
'array',
|
|
37
|
+
'object',
|
|
38
|
+
'color',
|
|
39
|
+
'date',
|
|
40
|
+
'code',
|
|
41
|
+
'file',
|
|
42
|
+
'slot',
|
|
43
|
+
]);
|
|
44
|
+
export function manifestFromConfigs(configs, opts = {}) {
|
|
45
|
+
const components = {};
|
|
46
|
+
for (const c of configs) {
|
|
47
|
+
if (opts.only && !opts.only.has(c.type))
|
|
48
|
+
continue;
|
|
49
|
+
if (opts.publicOnly && c.tier !== 'public')
|
|
50
|
+
continue;
|
|
51
|
+
components[c.type] = {
|
|
52
|
+
type: c.type,
|
|
53
|
+
namespace: c.namespace,
|
|
54
|
+
isContainer: c.isContainer,
|
|
55
|
+
inputs: (c.inputs ?? []).map((i) => ({
|
|
56
|
+
name: i.name,
|
|
57
|
+
type: (INPUT_TYPES.has(i.type) ? i.type : 'string'),
|
|
58
|
+
required: i.required,
|
|
59
|
+
enum: i.enum,
|
|
60
|
+
binding: i.binding,
|
|
61
|
+
description: i.description,
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { components };
|
|
66
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — SDUI JSX-source parser (ADR-0080)
|
|
3
|
+
*
|
|
4
|
+
* A small recursive-descent parser for a CONSTRAINED JSX subset. It is
|
|
5
|
+
* deliberately not a full JS/JSX parser: a bounded grammar is the point
|
|
6
|
+
* (Markdoc model) — it shrinks the attack surface and the expressible-but-wrong
|
|
7
|
+
* space. Output is the existing SDUI `SchemaNode` tree. Nothing is executed.
|
|
8
|
+
*
|
|
9
|
+
* Grammar (informal):
|
|
10
|
+
* document := element (exactly one root)
|
|
11
|
+
* element := openTag child-star closeTag, or a self-closing tag
|
|
12
|
+
* attr := name '=' (string | braced), or a bare name meaning true
|
|
13
|
+
* child := element | text | jsx-block-comment
|
|
14
|
+
* tag := [A-Za-z][A-Za-z0-9:_-]star (matches registry keys)
|
|
15
|
+
*/
|
|
16
|
+
import type { ParseOptions, ParseResult } from './types.js';
|
|
17
|
+
export declare function parseJsx(source: string, options?: ParseOptions): ParseResult;
|
|
18
|
+
/**
|
|
19
|
+
* Interpret a braced attribute value `{...}`.
|
|
20
|
+
* JSON-literal values (numbers, booleans, null, strings, arrays, objects with
|
|
21
|
+
* quoted keys) are materialized. Anything else is kept as a deferred expression
|
|
22
|
+
* marker `{ $expr }` — typed and validated later, NEVER evaluated here.
|
|
23
|
+
*/
|
|
24
|
+
export declare function interpretBrace(raw: string): unknown;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — SDUI JSX-source parser (ADR-0080)
|
|
3
|
+
*
|
|
4
|
+
* A small recursive-descent parser for a CONSTRAINED JSX subset. It is
|
|
5
|
+
* deliberately not a full JS/JSX parser: a bounded grammar is the point
|
|
6
|
+
* (Markdoc model) — it shrinks the attack surface and the expressible-but-wrong
|
|
7
|
+
* space. Output is the existing SDUI `SchemaNode` tree. Nothing is executed.
|
|
8
|
+
*
|
|
9
|
+
* Grammar (informal):
|
|
10
|
+
* document := element (exactly one root)
|
|
11
|
+
* element := openTag child-star closeTag, or a self-closing tag
|
|
12
|
+
* attr := name '=' (string | braced), or a bare name meaning true
|
|
13
|
+
* child := element | text | jsx-block-comment
|
|
14
|
+
* tag := [A-Za-z][A-Za-z0-9:_-]star (matches registry keys)
|
|
15
|
+
*/
|
|
16
|
+
/** Event handlers and raw-HTML injection are never allowed (parse ≠ execute). */
|
|
17
|
+
const EVENT_ATTR = /^on[A-Z]/;
|
|
18
|
+
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);
|
|
19
|
+
export function parseJsx(source, options = {}) {
|
|
20
|
+
return new Parser(source, options).parseDocument();
|
|
21
|
+
}
|
|
22
|
+
const isNameStart = (c) => /[A-Za-z]/.test(c);
|
|
23
|
+
const isNameChar = (c) => /[A-Za-z0-9:_-]/.test(c);
|
|
24
|
+
class Parser {
|
|
25
|
+
constructor(src, opts) {
|
|
26
|
+
Object.defineProperty(this, "src", {
|
|
27
|
+
enumerable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
value: src
|
|
31
|
+
});
|
|
32
|
+
Object.defineProperty(this, "opts", {
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
value: opts
|
|
37
|
+
});
|
|
38
|
+
Object.defineProperty(this, "pos", {
|
|
39
|
+
enumerable: true,
|
|
40
|
+
configurable: true,
|
|
41
|
+
writable: true,
|
|
42
|
+
value: 0
|
|
43
|
+
});
|
|
44
|
+
Object.defineProperty(this, "diagnostics", {
|
|
45
|
+
enumerable: true,
|
|
46
|
+
configurable: true,
|
|
47
|
+
writable: true,
|
|
48
|
+
value: []
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
parseDocument() {
|
|
52
|
+
this.skipTrivia();
|
|
53
|
+
if (this.peek() !== '<') {
|
|
54
|
+
this.error('no-root', 'Expected a single root element');
|
|
55
|
+
return { tree: null, diagnostics: this.diagnostics };
|
|
56
|
+
}
|
|
57
|
+
const tree = this.parseElement();
|
|
58
|
+
this.skipTrivia();
|
|
59
|
+
if (tree && this.pos < this.src.length) {
|
|
60
|
+
this.error('multiple-roots', 'A page must have exactly one root element', this.pos);
|
|
61
|
+
}
|
|
62
|
+
return { tree, diagnostics: this.diagnostics };
|
|
63
|
+
}
|
|
64
|
+
parseElement() {
|
|
65
|
+
const start = this.pos;
|
|
66
|
+
if (!this.eat('<')) {
|
|
67
|
+
this.error('expected-element', 'Expected "<"', start);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const tag = this.readName();
|
|
71
|
+
if (!tag) {
|
|
72
|
+
this.error('bad-tag', 'Expected a tag name after "<"', start);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (this.opts.allowedTags && !this.opts.allowedTags.has(tag)) {
|
|
76
|
+
this.error('forbidden-tag', `<${tag}> is not an allowed component`, start, tag);
|
|
77
|
+
}
|
|
78
|
+
const props = {};
|
|
79
|
+
for (;;) {
|
|
80
|
+
this.skipWs();
|
|
81
|
+
const c = this.peek();
|
|
82
|
+
if (c === '' || c === '>' || c === '/')
|
|
83
|
+
break;
|
|
84
|
+
const attr = this.parseAttr(start, tag);
|
|
85
|
+
if (!attr)
|
|
86
|
+
break;
|
|
87
|
+
props[attr.name] = attr.value;
|
|
88
|
+
}
|
|
89
|
+
this.skipWs();
|
|
90
|
+
let children;
|
|
91
|
+
if (this.eat('/')) {
|
|
92
|
+
if (!this.eat('>'))
|
|
93
|
+
this.error('bad-self-close', `Malformed self-closing <${tag}>`, this.pos, tag);
|
|
94
|
+
}
|
|
95
|
+
else if (this.eat('>')) {
|
|
96
|
+
children = this.parseChildren(tag);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
|
|
100
|
+
}
|
|
101
|
+
const node = { type: tag, ...props };
|
|
102
|
+
if (children && children.length)
|
|
103
|
+
node.children = children;
|
|
104
|
+
return node;
|
|
105
|
+
}
|
|
106
|
+
parseAttr(elStart, tag) {
|
|
107
|
+
const name = this.readName();
|
|
108
|
+
if (!name) {
|
|
109
|
+
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
|
|
110
|
+
// skip one char to avoid an infinite loop on garbage
|
|
111
|
+
this.pos++;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
this.skipWs();
|
|
115
|
+
let value = true; // bare attribute => boolean true
|
|
116
|
+
if (this.eat('=')) {
|
|
117
|
+
this.skipWs();
|
|
118
|
+
value = this.parseAttrValue(tag);
|
|
119
|
+
}
|
|
120
|
+
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
|
|
121
|
+
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
|
|
122
|
+
return { name: `__forbidden_${name}`, value: undefined };
|
|
123
|
+
}
|
|
124
|
+
return { name, value };
|
|
125
|
+
}
|
|
126
|
+
parseAttrValue(tag) {
|
|
127
|
+
const c = this.peek();
|
|
128
|
+
if (c === '"' || c === "'")
|
|
129
|
+
return this.readString(c);
|
|
130
|
+
if (c === '{')
|
|
131
|
+
return interpretBrace(this.readBraced());
|
|
132
|
+
this.error('bad-attr-value', `Expected an attribute value on <${tag}>`, this.pos, tag);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
parseChildren(parentTag) {
|
|
136
|
+
const children = [];
|
|
137
|
+
for (;;) {
|
|
138
|
+
if (this.pos >= this.src.length) {
|
|
139
|
+
this.error('unclosed-element', `Unclosed <${parentTag}>`, this.pos, parentTag);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
// closing tag
|
|
143
|
+
if (this.src.startsWith('</', this.pos)) {
|
|
144
|
+
this.pos += 2;
|
|
145
|
+
this.skipWs();
|
|
146
|
+
const close = this.readName();
|
|
147
|
+
this.skipWs();
|
|
148
|
+
this.eat('>');
|
|
149
|
+
if (close !== parentTag) {
|
|
150
|
+
this.error('mismatched-tag', `Expected </${parentTag}> but found </${close}>`, this.pos, parentTag);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
// JSX comment {/* ... */}
|
|
155
|
+
if (this.src.startsWith('{/*', this.pos)) {
|
|
156
|
+
const end = this.src.indexOf('*/}', this.pos);
|
|
157
|
+
if (end === -1) {
|
|
158
|
+
this.error('unclosed-comment', 'Unclosed comment', this.pos);
|
|
159
|
+
this.pos = this.src.length;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
this.pos = end + 3;
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// nested element
|
|
167
|
+
if (this.peek() === '<') {
|
|
168
|
+
const el = this.parseElement();
|
|
169
|
+
if (el)
|
|
170
|
+
children.push(el);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// expression child {expr} — out of grammar for v1: skip with a warning
|
|
174
|
+
if (this.peek() === '{') {
|
|
175
|
+
const start = this.pos;
|
|
176
|
+
this.readBraced();
|
|
177
|
+
this.error('expression-child', 'Inline {expression} children are not supported yet — bind via a component prop', start);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// text
|
|
181
|
+
const text = this.readTextRun();
|
|
182
|
+
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
183
|
+
if (trimmed)
|
|
184
|
+
children.push(trimmed);
|
|
185
|
+
}
|
|
186
|
+
return children;
|
|
187
|
+
}
|
|
188
|
+
/* ----------------------------- lexing ----------------------------- */
|
|
189
|
+
peek() {
|
|
190
|
+
return this.pos < this.src.length ? this.src[this.pos] : '';
|
|
191
|
+
}
|
|
192
|
+
eat(ch) {
|
|
193
|
+
if (this.src[this.pos] === ch) {
|
|
194
|
+
this.pos++;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
readName() {
|
|
200
|
+
if (!isNameStart(this.peek()))
|
|
201
|
+
return '';
|
|
202
|
+
const start = this.pos;
|
|
203
|
+
this.pos++;
|
|
204
|
+
while (this.pos < this.src.length && isNameChar(this.src[this.pos]))
|
|
205
|
+
this.pos++;
|
|
206
|
+
return this.src.slice(start, this.pos);
|
|
207
|
+
}
|
|
208
|
+
readString(quote) {
|
|
209
|
+
this.pos++; // opening quote
|
|
210
|
+
const start = this.pos;
|
|
211
|
+
while (this.pos < this.src.length && this.src[this.pos] !== quote)
|
|
212
|
+
this.pos++;
|
|
213
|
+
const value = this.src.slice(start, this.pos);
|
|
214
|
+
if (!this.eat(quote))
|
|
215
|
+
this.error('unterminated-string', 'Unterminated string literal', start);
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
/** Reads a balanced `{ ... }` run and returns the inner text (no outer braces). */
|
|
219
|
+
readBraced() {
|
|
220
|
+
const start = this.pos;
|
|
221
|
+
let depth = 0;
|
|
222
|
+
let inStr = null;
|
|
223
|
+
for (; this.pos < this.src.length; this.pos++) {
|
|
224
|
+
const ch = this.src[this.pos];
|
|
225
|
+
if (inStr) {
|
|
226
|
+
if (ch === inStr && this.src[this.pos - 1] !== '\\')
|
|
227
|
+
inStr = null;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (ch === '"' || ch === "'")
|
|
231
|
+
inStr = ch;
|
|
232
|
+
else if (ch === '{')
|
|
233
|
+
depth++;
|
|
234
|
+
else if (ch === '}') {
|
|
235
|
+
depth--;
|
|
236
|
+
if (depth === 0) {
|
|
237
|
+
const inner = this.src.slice(start + 1, this.pos);
|
|
238
|
+
this.pos++; // consume closing brace
|
|
239
|
+
return inner;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.error('unterminated-brace', 'Unterminated "{"', start);
|
|
244
|
+
return this.src.slice(start + 1);
|
|
245
|
+
}
|
|
246
|
+
readTextRun() {
|
|
247
|
+
const start = this.pos;
|
|
248
|
+
while (this.pos < this.src.length && this.src[this.pos] !== '<' && this.src[this.pos] !== '{')
|
|
249
|
+
this.pos++;
|
|
250
|
+
return this.src.slice(start, this.pos);
|
|
251
|
+
}
|
|
252
|
+
skipWs() {
|
|
253
|
+
while (this.pos < this.src.length && /\s/.test(this.src[this.pos]))
|
|
254
|
+
this.pos++;
|
|
255
|
+
}
|
|
256
|
+
/** whitespace + top-level JSX comments */
|
|
257
|
+
skipTrivia() {
|
|
258
|
+
for (;;) {
|
|
259
|
+
this.skipWs();
|
|
260
|
+
if (this.src.startsWith('{/*', this.pos)) {
|
|
261
|
+
const end = this.src.indexOf('*/}', this.pos);
|
|
262
|
+
this.pos = end === -1 ? this.src.length : end + 3;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
error(code, message, start, tag) {
|
|
269
|
+
this.diagnostics.push({ severity: 'error', code, message, start: start ?? this.pos, tag });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Interpret a braced attribute value `{...}`.
|
|
274
|
+
* JSON-literal values (numbers, booleans, null, strings, arrays, objects with
|
|
275
|
+
* quoted keys) are materialized. Anything else is kept as a deferred expression
|
|
276
|
+
* marker `{ $expr }` — typed and validated later, NEVER evaluated here.
|
|
277
|
+
*/
|
|
278
|
+
export function interpretBrace(raw) {
|
|
279
|
+
const trimmed = raw.trim();
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(trimmed);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return { $expr: trimmed };
|
|
285
|
+
}
|
|
286
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — SDUI JSX-source parser (ADR-0080)
|
|
3
|
+
*
|
|
4
|
+
* Types shared by the constrained JSX-source compiler. The parser turns a
|
|
5
|
+
* constrained JSX/HTML+Tailwind *text* into the existing SDUI `SchemaNode`
|
|
6
|
+
* tree. It PARSES — it never executes. No `import`, no `eval`, no JS.
|
|
7
|
+
*/
|
|
8
|
+
/** A node in the compiled SDUI tree. Mirrors `@object-ui/types` BaseSchema. */
|
|
9
|
+
export type SchemaNode = SchemaElement | string;
|
|
10
|
+
export interface SchemaElement {
|
|
11
|
+
type: string;
|
|
12
|
+
children?: SchemaNode[];
|
|
13
|
+
[prop: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export type Severity = 'error' | 'warning';
|
|
16
|
+
export interface Diagnostic {
|
|
17
|
+
severity: Severity;
|
|
18
|
+
/** stable machine code, e.g. 'forbidden-tag' */
|
|
19
|
+
code: string;
|
|
20
|
+
message: string;
|
|
21
|
+
/** byte offset into the source where the issue starts */
|
|
22
|
+
start?: number;
|
|
23
|
+
/** the tag/component involved, when relevant */
|
|
24
|
+
tag?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ParseOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Whitelist of allowed tag names (= registry `type` set, from the manifest).
|
|
29
|
+
* When provided, any tag outside it is a `forbidden-tag` error — this is the
|
|
30
|
+
* sanitization boundary. When omitted, all tags are accepted (lexing only).
|
|
31
|
+
*/
|
|
32
|
+
allowedTags?: Set<string>;
|
|
33
|
+
}
|
|
34
|
+
export interface ParseResult {
|
|
35
|
+
/** the compiled tree, or null when the source has no valid root */
|
|
36
|
+
tree: SchemaElement | null;
|
|
37
|
+
diagnostics: Diagnostic[];
|
|
38
|
+
}
|
|
39
|
+
export type ManifestInputType = 'string' | 'number' | 'boolean' | 'enum' | 'array' | 'object' | 'color' | 'date' | 'code' | 'file' | 'slot';
|
|
40
|
+
export interface ManifestInput {
|
|
41
|
+
name: string;
|
|
42
|
+
type: ManifestInputType;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
/** allowed values for `enum` inputs */
|
|
45
|
+
enum?: Array<string | {
|
|
46
|
+
value: unknown;
|
|
47
|
+
label?: string;
|
|
48
|
+
}>;
|
|
49
|
+
/** marks a data-binding input the server must resolve (ADR-0080 §6.3) */
|
|
50
|
+
binding?: 'object' | 'field';
|
|
51
|
+
description?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface ManifestComponent {
|
|
54
|
+
type: string;
|
|
55
|
+
/** plugin namespace — provenance that drives `requires` */
|
|
56
|
+
namespace?: string;
|
|
57
|
+
inputs: ManifestInput[];
|
|
58
|
+
isContainer?: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface Manifest {
|
|
61
|
+
/** keyed by component `type` */
|
|
62
|
+
components: Record<string, ManifestComponent>;
|
|
63
|
+
}
|
|
64
|
+
/** Result of validating a compiled tree against the manifest. */
|
|
65
|
+
export interface ValidationResult {
|
|
66
|
+
diagnostics: Diagnostic[];
|
|
67
|
+
/** unique plugin namespaces referenced — the page's `requires` */
|
|
68
|
+
requires: string[];
|
|
69
|
+
/** binding sites (object/field) the server must resolve against object schema */
|
|
70
|
+
bindings: Array<{
|
|
71
|
+
tag: string;
|
|
72
|
+
input: string;
|
|
73
|
+
kind: 'object' | 'field';
|
|
74
|
+
value: unknown;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — SDUI JSX-source parser (ADR-0080)
|
|
3
|
+
*
|
|
4
|
+
* Types shared by the constrained JSX-source compiler. The parser turns a
|
|
5
|
+
* constrained JSX/HTML+Tailwind *text* into the existing SDUI `SchemaNode`
|
|
6
|
+
* tree. It PARSES — it never executes. No `import`, no `eval`, no JS.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
|
|
3
|
+
*
|
|
4
|
+
* Shallow, author-time validation: unknown component, unknown/missing prop,
|
|
5
|
+
* wrong coarse type, illegal enum value. Collects `requires` (plugin provenance)
|
|
6
|
+
* and binding sites the SERVER must resolve against object schema (we cannot
|
|
7
|
+
* resolve objects/fields here — that check is framework-side by design).
|
|
8
|
+
*/
|
|
9
|
+
import type { Manifest, SchemaElement, ValidationResult } from './types.js';
|
|
10
|
+
export declare function validateTree(tree: SchemaElement | null, manifest: Manifest): ValidationResult;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
|
|
3
|
+
*
|
|
4
|
+
* Shallow, author-time validation: unknown component, unknown/missing prop,
|
|
5
|
+
* wrong coarse type, illegal enum value. Collects `requires` (plugin provenance)
|
|
6
|
+
* and binding sites the SERVER must resolve against object schema (we cannot
|
|
7
|
+
* resolve objects/fields here — that check is framework-side by design).
|
|
8
|
+
*/
|
|
9
|
+
/** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
|
|
10
|
+
const BASE_PROPS = new Set([
|
|
11
|
+
'type',
|
|
12
|
+
'id',
|
|
13
|
+
'className',
|
|
14
|
+
'style',
|
|
15
|
+
'visible',
|
|
16
|
+
'visibleOn',
|
|
17
|
+
'disabled',
|
|
18
|
+
'disabledOn',
|
|
19
|
+
'children',
|
|
20
|
+
]);
|
|
21
|
+
const isExpr = (v) => typeof v === 'object' && v !== null && '$expr' in v;
|
|
22
|
+
export function validateTree(tree, manifest) {
|
|
23
|
+
const diagnostics = [];
|
|
24
|
+
const requires = new Set();
|
|
25
|
+
const bindings = [];
|
|
26
|
+
const visit = (node) => {
|
|
27
|
+
if (typeof node === 'string')
|
|
28
|
+
return;
|
|
29
|
+
const comp = manifest.components[node.type];
|
|
30
|
+
if (!comp) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
severity: 'error',
|
|
33
|
+
code: 'unknown-component',
|
|
34
|
+
message: `<${node.type}> is not a known component`,
|
|
35
|
+
tag: node.type,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
if (comp.namespace)
|
|
40
|
+
requires.add(comp.namespace);
|
|
41
|
+
const byName = new Map(comp.inputs.map((i) => [i.name, i]));
|
|
42
|
+
// required present?
|
|
43
|
+
for (const input of comp.inputs) {
|
|
44
|
+
if (input.required && !(input.name in node)) {
|
|
45
|
+
diagnostics.push({
|
|
46
|
+
severity: 'error',
|
|
47
|
+
code: 'missing-required-prop',
|
|
48
|
+
message: `<${node.type}> is missing required prop "${input.name}"`,
|
|
49
|
+
tag: node.type,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// each provided prop
|
|
54
|
+
for (const [key, value] of Object.entries(node)) {
|
|
55
|
+
if (BASE_PROPS.has(key))
|
|
56
|
+
continue;
|
|
57
|
+
const input = byName.get(key);
|
|
58
|
+
if (!input) {
|
|
59
|
+
diagnostics.push({
|
|
60
|
+
severity: 'warning',
|
|
61
|
+
code: 'unknown-prop',
|
|
62
|
+
message: `<${node.type}> has no prop "${key}"`,
|
|
63
|
+
tag: node.type,
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (input.binding) {
|
|
68
|
+
bindings.push({ tag: node.type, input: key, kind: input.binding, value });
|
|
69
|
+
}
|
|
70
|
+
if (!isExpr(value)) {
|
|
71
|
+
const typeDiag = checkType(node.type, input, value);
|
|
72
|
+
if (typeDiag)
|
|
73
|
+
diagnostics.push(typeDiag);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// containment
|
|
77
|
+
if (node.children?.length && !comp.isContainer) {
|
|
78
|
+
diagnostics.push({
|
|
79
|
+
severity: 'warning',
|
|
80
|
+
code: 'not-a-container',
|
|
81
|
+
message: `<${node.type}> does not accept children`,
|
|
82
|
+
tag: node.type,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (node.children)
|
|
87
|
+
node.children.forEach(visit);
|
|
88
|
+
};
|
|
89
|
+
if (tree)
|
|
90
|
+
visit(tree);
|
|
91
|
+
return { diagnostics, requires: [...requires], bindings };
|
|
92
|
+
}
|
|
93
|
+
function checkType(tag, input, value) {
|
|
94
|
+
const mismatch = (expected) => ({
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
code: 'type-mismatch',
|
|
97
|
+
message: `<${tag}> prop "${input.name}" expected ${expected}`,
|
|
98
|
+
tag,
|
|
99
|
+
});
|
|
100
|
+
switch (input.type) {
|
|
101
|
+
case 'number':
|
|
102
|
+
return typeof value === 'number' ? null : mismatch('a number');
|
|
103
|
+
case 'boolean':
|
|
104
|
+
return typeof value === 'boolean' ? null : mismatch('a boolean');
|
|
105
|
+
case 'string':
|
|
106
|
+
case 'color':
|
|
107
|
+
case 'date':
|
|
108
|
+
case 'code':
|
|
109
|
+
case 'file':
|
|
110
|
+
return typeof value === 'string' ? null : mismatch('a string');
|
|
111
|
+
case 'array':
|
|
112
|
+
return Array.isArray(value) ? null : mismatch('an array');
|
|
113
|
+
case 'object':
|
|
114
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
115
|
+
? null
|
|
116
|
+
: mismatch('an object');
|
|
117
|
+
case 'enum': {
|
|
118
|
+
const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
|
|
119
|
+
return allowed.includes(value)
|
|
120
|
+
? null
|
|
121
|
+
: {
|
|
122
|
+
severity: 'error',
|
|
123
|
+
code: 'invalid-enum',
|
|
124
|
+
message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`,
|
|
125
|
+
tag,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@object-ui/sdui-parser",
|
|
3
|
+
"version": "11.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "Constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.",
|
|
8
|
+
"homepage": "https://www.objectui.org/docs/sdui-parser",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/objectstack-ai/objectui.git",
|
|
12
|
+
"directory": "packages/sdui-parser"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"devDependencies": {},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"type-check": "tsc --noEmit",
|
|
32
|
+
"lint": "eslint ."
|
|
33
|
+
}
|
|
34
|
+
}
|