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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/23543-f5-jsx-retire-page-kind-jsx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-protocol': patch
'@objectstack/cli': patch
'@objectstack/lint': patch
'@objectstack/platform-objects': patch
---

feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path

Clause-②: yes (narrowing)

**BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only.

Migration:

- `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it.
- A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`.

`os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders.

Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door.

The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only.

`@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`.

<!-- adr-0087: registered page-kind-jsx-retired -->
6 changes: 3 additions & 3 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,10 @@ View filter rule
| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
| **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. |
| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). |
| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). |
| **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages |
| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. |
| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page (alias 'jsx'), the kinds whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
| **source** | `string` | optional | Page source text. For kind==='html' it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. |
| **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page, the kind whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand Down
8 changes: 5 additions & 3 deletions content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const homePage = {
| `type` | `enum` | optional | Page type (see below; default `'record'`) |
| `object` | `string` | optional | Associated object (for `record` type) |
| `template` | `string` | optional | Layout template name (default: `'default'`) |
| `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; the legacy value `'jsx'` is a deprecated alias); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) |
| `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it `html`); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) |
| `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) |
| `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) |
| `variables` | `PageVariable[]` | optional | Local state variables |
Expand Down Expand Up @@ -93,10 +93,12 @@ this page describes the first one:

The two source tiers set `source` instead of `regions`. **`source` is authoritative over
`regions`**: when both are present the source wins, and `regions` holds at most a derived
cache of it. A page whose `kind` is `html`, `react` or `jsx` with no non-empty `source`
cache of it. A page whose `kind` is `html` or `react` with no non-empty `source`
is rejected by the schema rather than rendering empty.

`'jsx'` is a deprecated alias for `'html'`, still accepted and converted at load time.
`'jsx'`, the tier's first name, was retired in `@objectstack/spec` 18: an authored
`kind: 'jsx'` is refused with the prescription naming `'html'`. Stored pages written as
`'jsx'` keep loading as `html` pages.

Because `kind: 'react'` executes author JavaScript, it is gated by a host capability that
defaults **ON** and is disabled server-side per deployment with `OS_PAGE_REACT=off`. The
Expand Down
5 changes: 3 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ string** instead, for layouts and interactions the fixed schema cannot express.

Both set `source` and leave `regions` unused. `source` is **authoritative over
`regions`** on both tiers, and a page with either `kind` and no non-empty `source` is
rejected by the schema rather than rendering empty. `'jsx'` is a deprecated alias for
`'html'` that is still accepted and converted at load.
rejected by the schema rather than rendering empty. `'jsx'`, the first name of the
`'html'` tier, was retired in `@objectstack/spec` 18 and is refused with the prescription
naming `'html'`.

## Choosing between `react` and `html`

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,7 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
// live inside the untyped `properties` bag at the positions spec's
// `pageComponentSlotPositions()` derives (`page:tabs` →
// `properties.items[].children`, `page:card` → `properties.footer`, every
// container → `properties.children`), and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
// container → `properties.children`), and source-authored pages (`kind: 'html' | 'react'`) hold only a
// DERIVED region cache that the author never wrote — scaffolding translation
// keys off that cache would invent an authoring surface.
//
Expand Down Expand Up @@ -1152,7 +1152,7 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
* derived slot list, and `page:card`'s `properties.body` / `.footer` were the
* last positions lint walked and the resolver did not (`slots.<slot>` roots
* and `properties.items[].children` closed at #16772) — but it is still
* NARROWER in one way (it skips `kind: 'html' | 'react' | 'jsx'` pages, which
* NARROWER in one way (it skips `kind: 'html' | 'react'` pages, which
* `translatePage` walks), and a mismatch in either direction is one half of
* the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/utils/sdui-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ describe('countJsxGatePages — the pages the JSX gate checks against a manifest
it('counts the name-keyed map shape too, and ignores non-records', () => {
expect(
countJsxGatePages({
pages: { a: { kind: 'html', source: '<div />' }, b: { kind: 'jsx', source: '<div />' }, c: 'x', d: null },
pages: { a: { kind: 'html', source: '<div />' }, b: { kind: 'html', source: '<div />' }, c: 'x', d: null },
}),
).toBe(2);
expect(countJsxGatePages({ pages: 'nope' })).toBe(0);
Expand Down Expand Up @@ -350,7 +350,7 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {

it('absent with a page to check: parse level, and ONE notice carrying the count and every place looked', () => {
const r = resolveJsxGateManifest(
{ pages: [...HTML_STACK.pages, { name: 'b', kind: 'jsx', source: '<div />' }] },
{ pages: [...HTML_STACK.pages, { name: 'b', kind: 'html', source: '<div />' }] },
PROJECT_DIR,
ABSENT,
);
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/utils/sdui-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export function jsxGateStacks(stack: AnyRec): AnyRec[] {

/**
* The pages one stack's JSX gate checks AGAINST a manifest: `kind: 'html'`
* (and its deprecated alias `'jsx'`) with a non-empty `source`, over the
* with a non-empty `source`, over the
* collection authored either as an array or as a name-keyed map — each keyed
* by the name the gate reports it under (`page "NAME"`; a map entry's key when
* the entry carries no `name`), or by the record itself when it has neither.
Expand All @@ -314,7 +314,7 @@ function checkedPageKeys(stack: AnyRec): unknown[] {
const keys: unknown[] = [];
for (const [mapKey, page] of entries) {
if (!isRecord(page)) continue;
if (page.kind !== 'html' && page.kind !== 'jsx') continue;
if (page.kind !== 'html') continue;
if (typeof page.source !== 'string' || page.source.trim() === '') continue;
const name = typeof page.name === 'string' && page.name !== '' ? page.name : mapKey;
keys.push(name === undefined ? page : `page:${name}`);
Expand Down
Loading
Loading