@objectstack/sdui-parser 17.0.0-rc.0 → 17.0.0-rc.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # @objectstack/sdui-parser
2
2
 
3
+ ## 17.0.0-rc.2
4
+
5
+ ## 17.0.0-rc.1
6
+
7
+ ### Patch Changes
8
+
9
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
10
+
11
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
12
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
13
+ inside the npm package and is what an upgrading agent greps after the tombstone
14
+ error." That delivery path was severed for 68 of the 69 publishable packages:
15
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
16
+ older npm versions — not `CHANGELOG.md`, and the canonical
17
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
18
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
19
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
20
+ explicitly.
21
+
22
+ The tombstone-error scenario is precisely the one where the repo is out of
23
+ reach — the upgrading agent has `node_modules` and nothing else — so the
24
+ migration text has to ride in the tarball. Every publishable package now
25
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
26
+ `["dist", "README.md", "CHANGELOG.md"]`.
27
+
28
+ The other half is the gate: `check:published-files` gains a fifth invariant,
29
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
30
+ always-required lint job, so the next package cannot silently sever the path
31
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
32
+ into the canonical set.
33
+
34
+ Consumer-visible change: one more file per install (the package's changelog,
35
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
36
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
37
+ promised.
38
+
39
+ - cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248)
40
+
41
+ These 20 packages declared no `files` field, so npm fell back to packing the
42
+ whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks`
43
+ listed **21 files** — 15 under `src/`, three of them unit tests
44
+ (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the
45
+ build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at
46
+ publish time rather than instead of it, so consumers were installing the
47
+ TypeScript sources and the test suite alongside the artifact they asked for.
48
+
49
+ Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages
50
+ that already did. Nothing a consumer imports moves: every `main` / `types` /
51
+ `exports` target in all 20 already resolved inside `dist/`, which the new
52
+ `check:published-files` guard verifies rather than assumes. The visible change
53
+ is a smaller install and a smaller dependency-scanning surface — `npm pack` on
54
+ `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`.
55
+
56
+ The other half of the fix is the gate. Half the packages declaring `files` and
57
+ half not was the #3786 shape — a hand-copied convention with nothing enforcing
58
+ it, where whoever forgets the line gets no signal at all. `check:published-files`
59
+ (new, wired into the always-required `lint` job) holds every non-private
60
+ workspace package to four invariants: `files` is **declared**; it is
61
+ **sufficient** (covers every entry point, so tightening a whitelist cannot ship
62
+ a package that fails to resolve); it is **minimal** (admits no test, test-harness
63
+ config or build script); and anything beyond `dist` + `README.md` is
64
+ **registered** with a reason, reconciled in both directions so a stale exemption
65
+ is an error rather than dead text. `@objectstack/spec` is the one package with
66
+ registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and
67
+ `CHANGELOG.md` are product, not build input.
68
+
69
+ This also closes an assumption #4206 was resting on. Excluding `<pkg>/scripts/**`
70
+ from the docs-drift implementation test is sound only while no package publishes
71
+ `scripts/` as runtime code; that held, but it held because someone read all three
72
+ offenders by hand. It is now checked on every PR.
73
+
3
74
  ## 17.0.0-rc.0
4
75
 
5
76
  ## 16.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/sdui-parser",
3
- "version": "17.0.0-rc.0",
3
+ "version": "17.0.0-rc.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.",
6
6
  "main": "dist/index.js",
@@ -34,8 +34,14 @@
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "CHANGELOG.md"
41
+ ],
37
42
  "scripts": {
38
43
  "build": "tsup --config ../../tsup.config.ts",
39
- "test": "vitest run"
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit"
40
46
  }
41
47
  }
@@ -1,22 +0,0 @@
1
-
2
- > @objectstack/sdui-parser@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/sdui-parser
3
- > tsup --config ../../tsup.config.ts
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
9
- CLI Target: es2020
10
- CLI Cleaning output folder
11
- ESM Build start
12
- CJS Build start
13
- ESM dist/index.mjs 14.78 KB
14
- ESM dist/index.mjs.map 32.61 KB
15
- ESM ⚡️ Build success in 49ms
16
- CJS dist/index.js 16.04 KB
17
- CJS dist/index.js.map 32.68 KB
18
- CJS ⚡️ Build success in 59ms
19
- DTS Build start
20
- DTS ⚡️ Build success in 628ms
21
- DTS dist/index.d.mts 7.02 KB
22
- DTS dist/index.d.ts 7.02 KB
@@ -1,80 +0,0 @@
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
- });
package/src/codegen.ts DELETED
@@ -1,138 +0,0 @@
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
- }
package/src/index.ts DELETED
@@ -1,108 +0,0 @@
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
- }
package/src/parse.ts DELETED
@@ -1,272 +0,0 @@
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
-
17
- import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode } from './types.js';
18
-
19
- /** Event handlers and raw-HTML injection are never allowed (parse ≠ execute). */
20
- const EVENT_ATTR = /^on[A-Z]/;
21
- const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);
22
-
23
- export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
24
- return new Parser(source, options).parseDocument();
25
- }
26
-
27
- const isNameStart = (c: string) => /[A-Za-z]/.test(c);
28
- const isNameChar = (c: string) => /[A-Za-z0-9:_-]/.test(c);
29
-
30
- class Parser {
31
- private pos = 0;
32
- private readonly diagnostics: Diagnostic[] = [];
33
-
34
- constructor(private readonly src: string, private readonly opts: ParseOptions) {}
35
-
36
- parseDocument(): ParseResult {
37
- this.skipTrivia();
38
- if (this.peek() !== '<') {
39
- this.error('no-root', 'Expected a single root element');
40
- return { tree: null, diagnostics: this.diagnostics };
41
- }
42
- const tree = this.parseElement();
43
- this.skipTrivia();
44
- if (tree && this.pos < this.src.length) {
45
- this.error('multiple-roots', 'A page must have exactly one root element', this.pos);
46
- }
47
- return { tree, diagnostics: this.diagnostics };
48
- }
49
-
50
- private parseElement(): SchemaElement | null {
51
- const start = this.pos;
52
- if (!this.eat('<')) {
53
- this.error('expected-element', 'Expected "<"', start);
54
- return null;
55
- }
56
- const tag = this.readName();
57
- if (!tag) {
58
- this.error('bad-tag', 'Expected a tag name after "<"', start);
59
- return null;
60
- }
61
- if (this.opts.allowedTags && !this.opts.allowedTags.has(tag)) {
62
- this.error('forbidden-tag', `<${tag}> is not an allowed component`, start, tag);
63
- }
64
-
65
- const props: Record<string, unknown> = {};
66
- for (;;) {
67
- this.skipWs();
68
- const c = this.peek();
69
- if (c === '' || c === '>' || c === '/') break;
70
- const attr = this.parseAttr(start, tag);
71
- if (!attr) break;
72
- props[attr.name] = attr.value;
73
- }
74
-
75
- this.skipWs();
76
- let children: SchemaNode[] | undefined;
77
- if (this.eat('/')) {
78
- if (!this.eat('>')) this.error('bad-self-close', `Malformed self-closing <${tag}>`, this.pos, tag);
79
- } else if (this.eat('>')) {
80
- children = this.parseChildren(tag);
81
- } else {
82
- this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
83
- }
84
-
85
- const node: SchemaElement = { type: tag, ...props };
86
- if (children && children.length) node.children = children;
87
- return node;
88
- }
89
-
90
- private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
91
- const name = this.readName();
92
- if (!name) {
93
- this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
94
- // skip one char to avoid an infinite loop on garbage
95
- this.pos++;
96
- return null;
97
- }
98
- this.skipWs();
99
- let value: unknown = true; // bare attribute => boolean true
100
- if (this.eat('=')) {
101
- this.skipWs();
102
- value = this.parseAttrValue(tag);
103
- }
104
- if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
105
- this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
106
- return { name: `__forbidden_${name}`, value: undefined };
107
- }
108
- return { name, value };
109
- }
110
-
111
- private parseAttrValue(tag: string): unknown {
112
- const c = this.peek();
113
- if (c === '"' || c === "'") return this.readString(c);
114
- if (c === '{') return interpretBrace(this.readBraced());
115
- this.error('bad-attr-value', `Expected an attribute value on <${tag}>`, this.pos, tag);
116
- return undefined;
117
- }
118
-
119
- private parseChildren(parentTag: string): SchemaNode[] {
120
- const children: SchemaNode[] = [];
121
- for (;;) {
122
- if (this.pos >= this.src.length) {
123
- this.error('unclosed-element', `Unclosed <${parentTag}>`, this.pos, parentTag);
124
- break;
125
- }
126
- // closing tag
127
- if (this.src.startsWith('</', this.pos)) {
128
- this.pos += 2;
129
- this.skipWs();
130
- const close = this.readName();
131
- this.skipWs();
132
- this.eat('>');
133
- if (close !== parentTag) {
134
- this.error('mismatched-tag', `Expected </${parentTag}> but found </${close}>`, this.pos, parentTag);
135
- }
136
- break;
137
- }
138
- // JSX comment {/* ... */}
139
- if (this.src.startsWith('{/*', this.pos)) {
140
- const end = this.src.indexOf('*/}', this.pos);
141
- if (end === -1) {
142
- this.error('unclosed-comment', 'Unclosed comment', this.pos);
143
- this.pos = this.src.length;
144
- } else {
145
- this.pos = end + 3;
146
- }
147
- continue;
148
- }
149
- // nested element
150
- if (this.peek() === '<') {
151
- const el = this.parseElement();
152
- if (el) children.push(el);
153
- continue;
154
- }
155
- // expression child {expr} — out of grammar for v1: skip with a warning
156
- if (this.peek() === '{') {
157
- const start = this.pos;
158
- this.readBraced();
159
- this.error(
160
- 'expression-child',
161
- 'Inline {expression} children are not supported yet — bind via a component prop',
162
- start,
163
- );
164
- continue;
165
- }
166
- // text
167
- const text = this.readTextRun();
168
- const trimmed = text.replace(/\s+/g, ' ').trim();
169
- if (trimmed) children.push(trimmed);
170
- }
171
- return children;
172
- }
173
-
174
- /* ----------------------------- lexing ----------------------------- */
175
-
176
- private peek(): string {
177
- return this.pos < this.src.length ? this.src[this.pos] : '';
178
- }
179
-
180
- private eat(ch: string): boolean {
181
- if (this.src[this.pos] === ch) {
182
- this.pos++;
183
- return true;
184
- }
185
- return false;
186
- }
187
-
188
- private readName(): string {
189
- if (!isNameStart(this.peek())) return '';
190
- const start = this.pos;
191
- this.pos++;
192
- while (this.pos < this.src.length && isNameChar(this.src[this.pos])) this.pos++;
193
- return this.src.slice(start, this.pos);
194
- }
195
-
196
- private readString(quote: string): string {
197
- this.pos++; // opening quote
198
- const start = this.pos;
199
- while (this.pos < this.src.length && this.src[this.pos] !== quote) this.pos++;
200
- const value = this.src.slice(start, this.pos);
201
- if (!this.eat(quote)) this.error('unterminated-string', 'Unterminated string literal', start);
202
- return value;
203
- }
204
-
205
- /** Reads a balanced `{ ... }` run and returns the inner text (no outer braces). */
206
- private readBraced(): string {
207
- const start = this.pos;
208
- let depth = 0;
209
- let inStr: string | null = null;
210
- for (; this.pos < this.src.length; this.pos++) {
211
- const ch = this.src[this.pos];
212
- if (inStr) {
213
- if (ch === inStr && this.src[this.pos - 1] !== '\\') inStr = null;
214
- continue;
215
- }
216
- if (ch === '"' || ch === "'") inStr = ch;
217
- else if (ch === '{') depth++;
218
- else if (ch === '}') {
219
- depth--;
220
- if (depth === 0) {
221
- const inner = this.src.slice(start + 1, this.pos);
222
- this.pos++; // consume closing brace
223
- return inner;
224
- }
225
- }
226
- }
227
- this.error('unterminated-brace', 'Unterminated "{"', start);
228
- return this.src.slice(start + 1);
229
- }
230
-
231
- private readTextRun(): string {
232
- const start = this.pos;
233
- while (this.pos < this.src.length && this.src[this.pos] !== '<' && this.src[this.pos] !== '{') this.pos++;
234
- return this.src.slice(start, this.pos);
235
- }
236
-
237
- private skipWs(): void {
238
- while (this.pos < this.src.length && /\s/.test(this.src[this.pos])) this.pos++;
239
- }
240
-
241
- /** whitespace + top-level JSX comments */
242
- private skipTrivia(): void {
243
- for (;;) {
244
- this.skipWs();
245
- if (this.src.startsWith('{/*', this.pos)) {
246
- const end = this.src.indexOf('*/}', this.pos);
247
- this.pos = end === -1 ? this.src.length : end + 3;
248
- continue;
249
- }
250
- break;
251
- }
252
- }
253
-
254
- private error(code: string, message: string, start?: number, tag?: string): void {
255
- this.diagnostics.push({ severity: 'error', code, message, start: start ?? this.pos, tag });
256
- }
257
- }
258
-
259
- /**
260
- * Interpret a braced attribute value `{...}`.
261
- * JSON-literal values (numbers, booleans, null, strings, arrays, objects with
262
- * quoted keys) are materialized. Anything else is kept as a deferred expression
263
- * marker `{ $expr }` — typed and validated later, NEVER evaluated here.
264
- */
265
- export function interpretBrace(raw: string): unknown {
266
- const trimmed = raw.trim();
267
- try {
268
- return JSON.parse(trimmed);
269
- } catch {
270
- return { $expr: trimmed };
271
- }
272
- }
package/src/types.ts DELETED
@@ -1,95 +0,0 @@
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
-
9
- /** A node in the compiled SDUI tree. Mirrors `@object-ui/types` BaseSchema. */
10
- export type SchemaNode = SchemaElement | string;
11
-
12
- export interface SchemaElement {
13
- type: string;
14
- children?: SchemaNode[];
15
- [prop: string]: unknown;
16
- }
17
-
18
- export type Severity = 'error' | 'warning';
19
-
20
- export interface Diagnostic {
21
- severity: Severity;
22
- /** stable machine code, e.g. 'forbidden-tag' */
23
- code: string;
24
- message: string;
25
- /** byte offset into the source where the issue starts */
26
- start?: number;
27
- /** the tag/component involved, when relevant */
28
- tag?: string;
29
- }
30
-
31
- export interface ParseOptions {
32
- /**
33
- * Whitelist of allowed tag names (= registry `type` set, from the manifest).
34
- * When provided, any tag outside it is a `forbidden-tag` error — this is the
35
- * sanitization boundary. When omitted, all tags are accepted (lexing only).
36
- */
37
- allowedTags?: Set<string>;
38
- }
39
-
40
- export interface ParseResult {
41
- /** the compiled tree, or null when the source has no valid root */
42
- tree: SchemaElement | null;
43
- diagnostics: Diagnostic[];
44
- }
45
-
46
- /* ------------------------------------------------------------------ *
47
- * Manifest — the serialized public-tier contract from the registry.
48
- * Produced by serializing `ComponentRegistry.getAllConfigs()` (ADR-0080 §3/§6).
49
- * ------------------------------------------------------------------ */
50
-
51
- export type ManifestInputType =
52
- | 'string'
53
- | 'number'
54
- | 'boolean'
55
- | 'enum'
56
- | 'array'
57
- | 'object'
58
- | 'color'
59
- | 'date'
60
- | 'code'
61
- | 'file'
62
- | 'slot';
63
-
64
- export interface ManifestInput {
65
- name: string;
66
- type: ManifestInputType;
67
- required?: boolean;
68
- /** allowed values for `enum` inputs */
69
- enum?: Array<string | { value: unknown; label?: string }>;
70
- /** marks a data-binding input the server must resolve (ADR-0080 §6.3) */
71
- binding?: 'object' | 'field';
72
- description?: string;
73
- }
74
-
75
- export interface ManifestComponent {
76
- type: string;
77
- /** plugin namespace — provenance that drives `requires` */
78
- namespace?: string;
79
- inputs: ManifestInput[];
80
- isContainer?: boolean;
81
- }
82
-
83
- export interface Manifest {
84
- /** keyed by component `type` */
85
- components: Record<string, ManifestComponent>;
86
- }
87
-
88
- /** Result of validating a compiled tree against the manifest. */
89
- export interface ValidationResult {
90
- diagnostics: Diagnostic[];
91
- /** unique plugin namespaces referenced — the page's `requires` */
92
- requires: string[];
93
- /** binding sites (object/field) the server must resolve against object schema */
94
- bindings: Array<{ tag: string; input: string; kind: 'object' | 'field'; value: unknown }>;
95
- }
package/src/validate.ts DELETED
@@ -1,144 +0,0 @@
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
-
10
- import type {
11
- Diagnostic,
12
- Manifest,
13
- ManifestInput,
14
- SchemaElement,
15
- SchemaNode,
16
- ValidationResult,
17
- } from './types.js';
18
-
19
- /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
20
- const BASE_PROPS = new Set([
21
- 'type',
22
- 'id',
23
- 'className',
24
- 'style',
25
- 'visible',
26
- 'visibleOn',
27
- 'disabled',
28
- 'disabledOn',
29
- 'children',
30
- ]);
31
-
32
- const isExpr = (v: unknown): boolean =>
33
- typeof v === 'object' && v !== null && '$expr' in (v as Record<string, unknown>);
34
-
35
- export function validateTree(tree: SchemaElement | null, manifest: Manifest): ValidationResult {
36
- const diagnostics: Diagnostic[] = [];
37
- const requires = new Set<string>();
38
- const bindings: ValidationResult['bindings'] = [];
39
-
40
- const visit = (node: SchemaNode): void => {
41
- if (typeof node === 'string') return;
42
- const comp = manifest.components[node.type];
43
- if (!comp) {
44
- diagnostics.push({
45
- severity: 'error',
46
- code: 'unknown-component',
47
- message: `<${node.type}> is not a known component`,
48
- tag: node.type,
49
- });
50
- } else {
51
- if (comp.namespace) requires.add(comp.namespace);
52
- const byName = new Map(comp.inputs.map((i) => [i.name, i]));
53
-
54
- // required present?
55
- for (const input of comp.inputs) {
56
- if (input.required && !(input.name in node)) {
57
- diagnostics.push({
58
- severity: 'error',
59
- code: 'missing-required-prop',
60
- message: `<${node.type}> is missing required prop "${input.name}"`,
61
- tag: node.type,
62
- });
63
- }
64
- }
65
-
66
- // each provided prop
67
- for (const [key, value] of Object.entries(node)) {
68
- if (BASE_PROPS.has(key)) continue;
69
- const input = byName.get(key);
70
- if (!input) {
71
- diagnostics.push({
72
- severity: 'warning',
73
- code: 'unknown-prop',
74
- message: `<${node.type}> has no prop "${key}"`,
75
- tag: node.type,
76
- });
77
- continue;
78
- }
79
- if (input.binding) {
80
- bindings.push({ tag: node.type, input: key, kind: input.binding, value });
81
- }
82
- if (!isExpr(value)) {
83
- const typeDiag = checkType(node.type, input, value);
84
- if (typeDiag) diagnostics.push(typeDiag);
85
- }
86
- }
87
-
88
- // containment
89
- if (node.children?.length && !comp.isContainer) {
90
- diagnostics.push({
91
- severity: 'warning',
92
- code: 'not-a-container',
93
- message: `<${node.type}> does not accept children`,
94
- tag: node.type,
95
- });
96
- }
97
- }
98
-
99
- if (node.children) node.children.forEach(visit);
100
- };
101
-
102
- if (tree) visit(tree);
103
- return { diagnostics, requires: [...requires], bindings };
104
- }
105
-
106
- function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
107
- const mismatch = (expected: string): Diagnostic => ({
108
- severity: 'warning',
109
- code: 'type-mismatch',
110
- message: `<${tag}> prop "${input.name}" expected ${expected}`,
111
- tag,
112
- });
113
- switch (input.type) {
114
- case 'number':
115
- return typeof value === 'number' ? null : mismatch('a number');
116
- case 'boolean':
117
- return typeof value === 'boolean' ? null : mismatch('a boolean');
118
- case 'string':
119
- case 'color':
120
- case 'date':
121
- case 'code':
122
- case 'file':
123
- return typeof value === 'string' ? null : mismatch('a string');
124
- case 'array':
125
- return Array.isArray(value) ? null : mismatch('an array');
126
- case 'object':
127
- return typeof value === 'object' && value !== null && !Array.isArray(value)
128
- ? null
129
- : mismatch('an object');
130
- case 'enum': {
131
- const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
132
- return allowed.includes(value as never)
133
- ? null
134
- : {
135
- severity: 'error',
136
- code: 'invalid-enum',
137
- message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`,
138
- tag,
139
- };
140
- }
141
- default:
142
- return null;
143
- }
144
- }
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*"],
4
- "exclude": ["node_modules", "dist", "**/*.test.ts"],
5
- "compilerOptions": {
6
- "outDir": "dist",
7
- "rootDir": "src"
8
- }
9
- }