Skip to content

Commit 476b7a4

Browse files
os-zhuangclaude
andcommitted
feat(skill,lint): document html/react page tiers + react-source syntax gate
- objectstack-ui skill: new section on the source-authoring tiers — when to use kind:'html' (constrained, parsed) vs kind:'react' (real React, executed), the injected react scope (React/useAdapter/ObjectForm/ListView/...), a master-detail example, and the OS_PAGE_REACT availability note. The AI build agent now knows the tiers exist. - lint: validate-react-pages transpiles kind:'react' source (Sucrase, never executed) so syntax errors fail at os build, not at render. Wired into os validate (ADR-0081). +tests (93 lint tests green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 301ca81 commit 476b7a4

8 files changed

Lines changed: 225 additions & 5 deletions

File tree

.changeset/sdui-react-page-tier.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ ADR-0081: split the AI page-authoring surface into honest tiers.
1515
env toggle. The completeness gate now requires `source` for all three kinds.
1616
- `@objectstack/cli` console serving injects the disable global into the served
1717
HTML when `OS_PAGE_REACT=off` (read per request, no rebuild).
18-
- `validate-jsx-pages` lints `html`/`jsx` (constrained parse) and intentionally
19-
skips `react` (real JS, not constrained JSX).
18+
- `validate-jsx-pages` lints `html`/`jsx` (constrained parse). A new
19+
`validate-react-pages` transpiles `react` source with Sucrase (transpile-only,
20+
never executed) so syntax errors fail at `os build` instead of at render.

packages/cli/src/commands/validate.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { loadConfig } from '../utils/config.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateWidgetBindings } from '@objectstack/lint';
1313
import { validateResponsiveStyles } from '@objectstack/lint';
14-
import { validateJsxPages } from '@objectstack/lint';
14+
import { validateJsxPages, validateReactPages } from '@objectstack/lint';
1515
import {
1616
printHeader,
1717
printKV,
@@ -214,6 +214,32 @@ export default class Validate extends Command {
214214
this.exit(1);
215215
}
216216

217+
// 3c. React-source pages (ADR-0081) — a kind:'react' page's `source` is
218+
// real React executed at render. Transpile it now (Sucrase, never
219+
// executed) so syntax errors fail loudly at build, not at render.
220+
if (!flags.json) printStep('Checking React-source pages (ADR-0081)...');
221+
const reactFindings = validateReactPages(result.data as Record<string, unknown>);
222+
const reactErrors = reactFindings.filter((f) => f.severity === 'error');
223+
if (reactErrors.length > 0) {
224+
if (flags.json) {
225+
console.log(JSON.stringify({
226+
valid: false,
227+
errors: reactErrors,
228+
warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings],
229+
duration: timer.elapsed(),
230+
}, null, 2));
231+
this.exit(1);
232+
}
233+
console.log('');
234+
printError(`React-source page check failed (${reactErrors.length} issue${reactErrors.length > 1 ? 's' : ''})`);
235+
for (const f of reactErrors.slice(0, 50)) {
236+
console.log(` \u2022 ${f.where}: ${f.message}`);
237+
console.log(chalk.dim(` ${f.hint}`));
238+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
239+
}
240+
this.exit(1);
241+
}
242+
217243
// 4. Collect and display stats
218244
const stats = collectMetadataStats(config);
219245

packages/lint/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@objectstack/lint",
33
"version": "11.3.0",
44
"license": "Apache-2.0",
5-
"description": "Static, build-time validation for an ObjectStack metadata graph dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.",
5+
"description": "Static, build-time validation for an ObjectStack metadata graph \u2014 dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.",
66
"type": "module",
77
"main": "dist/index.js",
88
"types": "dist/index.d.ts",
@@ -22,7 +22,8 @@
2222
"dependencies": {
2323
"@objectstack/spec": "workspace:*",
2424
"@objectstack/formula": "workspace:*",
25-
"@objectstack/sdui-parser": "workspace:*"
25+
"@objectstack/sdui-parser": "workspace:*",
26+
"sucrase": "^3.35.0"
2627
},
2728
"devDependencies": {
2829
"@types/node": "^26.0.1",

packages/lint/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export {
3737
export type { StyleFinding, StyleSeverity } from './validate-responsive-styles.js';
3838
export { validateJsxPages } from './validate-jsx-pages.js';
3939
export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js';
40+
export { validateReactPages } from './validate-react-pages.js';
41+
export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages.js';
4042

4143
export {
4244
validateRecordTitle,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { validateReactPages } from './validate-react-pages.js';
4+
5+
describe('validateReactPages (ADR-0081 syntax gate)', () => {
6+
it('passes a well-formed react page', () => {
7+
const stack = {
8+
pages: [{
9+
name: 'wb', kind: 'react',
10+
source: 'function Page(){ const [n,setN]=React.useState(0); return <button onClick={()=>setN(n+1)}>{n}</button>; }',
11+
}],
12+
};
13+
expect(validateReactPages(stack)).toEqual([]);
14+
});
15+
16+
it('flags an empty source', () => {
17+
const f = validateReactPages({ pages: [{ name: 'wb', kind: 'react' }] });
18+
expect(f.some((x) => x.rule === 'react-page-empty-source')).toBe(true);
19+
});
20+
21+
it('flags a syntax error (unterminated JSX), at the source path', () => {
22+
const f = validateReactPages({ pages: [{ name: 'wb', kind: 'react', source: 'function Page(){ return <div>oops; }' }] });
23+
expect(f.some((x) => x.rule === 'react-page-syntax' && x.severity === 'error')).toBe(true);
24+
expect(f.every((x) => x.path === 'pages[0].source')).toBe(true);
25+
});
26+
27+
it('ignores non-react pages (html/full)', () => {
28+
expect(validateReactPages({ pages: [
29+
{ name: 'h', kind: 'html', source: '<flex/>' },
30+
{ name: 'f', kind: 'full', regions: [] },
31+
] })).toEqual([]);
32+
});
33+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Build-time syntax gate for `kind:'react'` pages (ADR-0081).
4+
//
5+
// A react page's `source` is REAL JavaScript/JSX executed at render by the
6+
// runtime — so the constrained JSX parser (validate-jsx-pages) cannot check it.
7+
// We instead transpile it with Sucrase (the same transpiler the runtime uses),
8+
// transpile-ONLY — never executed — to surface syntax errors at `os build`
9+
// instead of at render (ADR-0078: fail loudly at author time). It does NOT
10+
// validate runtime behaviour (a transpiling page can still throw at render);
11+
// the render-time error boundary owns that.
12+
13+
import { transform } from 'sucrase';
14+
15+
export type ReactPageSeverity = 'error' | 'warning';
16+
17+
export interface ReactPageFinding {
18+
severity: ReactPageSeverity;
19+
rule: string;
20+
where: string;
21+
path: string;
22+
message: string;
23+
hint: string;
24+
}
25+
26+
type AnyRec = Record<string, unknown>;
27+
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);
28+
29+
export function validateReactPages(stack: AnyRec): ReactPageFinding[] {
30+
const findings: ReactPageFinding[] = [];
31+
const pages = asArray(stack.pages);
32+
for (let p = 0; p < pages.length; p++) {
33+
const page = pages[p];
34+
if (!page || page.kind !== 'react') continue;
35+
const name = String(page.name ?? `#${p}`);
36+
const source = page.source;
37+
if (typeof source !== 'string' || source.trim() === '') {
38+
findings.push({
39+
severity: 'error',
40+
rule: 'react-page-empty-source',
41+
where: `page "${name}"`,
42+
path: `pages[${p}].source`,
43+
message: "kind:'react' page has no `source`.",
44+
hint: 'Author the page as a real React component string in `source`.',
45+
});
46+
continue;
47+
}
48+
try {
49+
// transpile-only (no eval) — catches syntax errors, unterminated JSX, etc.
50+
transform(source, { transforms: ['jsx', 'typescript'], production: true });
51+
} catch (err) {
52+
const message = err instanceof Error ? err.message : String(err);
53+
findings.push({
54+
severity: 'error',
55+
rule: 'react-page-syntax',
56+
where: `page "${name}"`,
57+
path: `pages[${p}].source`,
58+
message: `kind:'react' source has a syntax error: ${message.split('\n')[0]}`,
59+
hint: 'The source is transpiled (never executed) at build to catch syntax errors early — fix the JS/JSX.',
60+
});
61+
}
62+
}
63+
return findings;
64+
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

skills/objectstack-ui/SKILL.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,96 @@ export const LeadDetailPage = definePage({
721721
> `page:header.properties.actions`; do **not** create a sibling action node.
722722
> The header renders them inline in the action slot.
723723
724+
### AI-authored *source* pages — `kind:'html'` and `kind:'react'` (ADR-0080/0081)
725+
726+
Besides the structured `regions` model above, a page's whole body can be written
727+
as a *source string* in `source`, with `kind` choosing the authoring tier. Pick
728+
by what the page needs:
729+
730+
| `kind` | Author writes | JS runs? | Use when |
731+
|:--|:--|:--|:--|
732+
| `full` / `slotted` | structured `regions` / `slots` (no `source`) || record/detail/home layouts from the component catalogue |
733+
| `html` | constrained JSX = HTML + Tailwind + registered components, **parsed, never executed** | no | free-form layout / landing / dashboard that just *composes* blocks — AI's HTML+Tailwind strength, no interactivity |
734+
| `react` | **real React** (hooks, `.map`, `onClick`, expressions) | yes (main React tree) | complex interactive business UIs — master/detail, wizards, state-driven filters |
735+
736+
`source` is the source-of-truth in both source tiers; `regions` is ignored. A
737+
`kind:'html'`/`'react'` page with no `source` fails the build (ADR-0078). The legacy
738+
value `kind:'jsx'` is a deprecated alias for `kind:'html'`.
739+
740+
#### `kind:'html'` — constrained JSX, parsed (safe by construction)
741+
742+
Tags are the **registered components** (bare names: `<flex>`, `<grid>`, `<card>`,
743+
`<object-table>`, `<object-form>`, `<object-metric>`, …) **plus the safe native HTML
744+
set** (`<h1>``<h6>`, `<p>`, `<a>`, `<ul>/<ol>/<li>`, `<img>`, `<blockquote>`, `<strong>`,
745+
…). Props come from each component's registry `inputs` (e.g. `<text content=…>`,
746+
`<badge label=…>`). **No JavaScript**`onClick`, `{expr}` logic and `.map()` are NOT
747+
available; use `kind:'react'` for those. `os build` parses the source and fails loudly
748+
on unknown tags / missing required props / forbidden constructs (event handlers,
749+
`dangerouslySetInnerHTML`).
750+
751+
```typescript
752+
export const ReleaseNotesPage = definePage({
753+
name: 'release_notes', type: 'home', kind: 'html',
754+
source: `
755+
<section className="mx-auto max-w-3xl p-10">
756+
<h1 className="text-4xl font-bold">Release Notes</h1>
757+
<object-metric objectName="ticket" aggregate="count" label="Open tickets" className="mt-6" />
758+
</section>`,
759+
});
760+
```
761+
762+
#### `kind:'react'` — real React, executed (trusted tier)
763+
764+
The source is real React executed at render by the runtime. The injected scope are
765+
**closure variables (NOT props)** — reference them directly:
766+
767+
- `React` — hooks (`React.useState`, `React.useEffect`, …)
768+
- `useAdapter()` — live data: `adapter.find('obj', {…})` / `.findOne` / `.create` / `.update`
769+
- the public **data blocks as PascalCase components**`<ObjectForm>`, `<ListView>`,
770+
`<ObjectMetric>`, `<ObjectChart>`, `<ObjectKanban>`, … each rendering the real registered
771+
component; `<Block type="…" …/>` is the escape hatch for any other type
772+
- `data` / `variables` / `page`
773+
774+
Compose **layout with plain HTML + Tailwind** (React's strength); use the injected
775+
blocks for data. Real component props/callbacks flow through — e.g. `<ObjectForm>` honors
776+
`objectName` / `mode` / `recordId` / `onSuccess` / `onCancel`; `<ListView>` honors
777+
`objectName` / `fields` / `onRowClick` / `navigation`.
778+
779+
Master/detail (click a row → edit it → save refreshes the list):
780+
781+
```tsx
782+
export const CrmWorkbenchPage = definePage({
783+
name: 'crm_workbench', type: 'home', kind: 'react',
784+
source: `
785+
function Page() {
786+
const [sel, setSel] = React.useState(null);
787+
const [reload, setReload] = React.useState(0);
788+
return (
789+
<div className="grid grid-cols-5 gap-6 p-8">
790+
<div className="col-span-3">
791+
<ListView key={reload} objectName="project"
792+
fields={['name','status','owner']} navigation={{ mode: 'none' }}
793+
onRowClick={(r) => setSel(r)} />
794+
</div>
795+
<div className="col-span-2">
796+
{sel
797+
? <ObjectForm objectName="project" mode="edit" recordId={sel.id}
798+
onSuccess={() => { setSel(null); setReload((k) => k + 1); }} />
799+
: <p className="text-slate-400">Select a project to edit.</p>}
800+
</div>
801+
</div>
802+
);
803+
}`,
804+
});
805+
```
806+
807+
**Safety / availability.** `kind:'react'` executes author code in the app, so it is gated
808+
by the host capability `react-pages`**ON by default** (the platform trusts reviewed,
809+
draft-gated authors). A deployment that does not trust its authors turns it off server-side
810+
with `OS_PAGE_REACT=off`; the page then shows a "disabled" notice instead of executing.
811+
`os build` does NOT lint react source (it is real JS, not constrained JSX) — errors surface
812+
at render behind an error boundary, so always test a react page in the browser.
813+
724814
### Styling a page (ADR-0065) — `responsiveStyles`, NOT `className`
725815

726816
To style a metadata-authored block, give it a **`responsiveStyles`** object — a

0 commit comments

Comments
 (0)