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
22 changes: 22 additions & 0 deletions .changeset/11170-node-slot-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@object-ui/types": minor
"@object-ui/cli": minor
"@object-ui/core": minor
"@object-ui/sdui-parser": minor
"@object-ui/components": minor
---

**Clause-②: yes (narrowing)**

One declaration of the per-type NODE SLOTS — the keys other than `children` through which a renderer hands authored nodes back to `SchemaRenderer` — and three readers that walk it instead of stopping at `children` (objectui#11170, the follow-up PR #11126's Acceptance notes filed).

New on `@object-ui/types`, beside `BaseSchema.children`: `NODE_SLOT_DECLARATIONS` (one row per renderer, under every registry spelling that resolves to it), `nodeSlotsFor(type)`, `nodeSlotPathSegments(path)` and `nodeSlotValues(node, path)`, with the types `NodeSlotDeclaration`, `NodeSlotRow`, `NodeSlotSegment` and `NodeSlotValue`. A position is spelled as a key path — `trigger`, `items[].content`, `regions[].components`, `items[]`, `report.sections[].content` — and the value at its end is one node or a list of nodes. The `page:*` rows are `@objectstack/spec`'s `pageComponentSlotPositions()` placed on the type whose renderer reads each position, pinned against that export in both directions; every other row is objectui's own, pinned against the live renderer. `body` stays retired as the generic child-list key (objectui#6771): it appears only on the four `page:*` types whose renderer still paints it for stored documents, marked `retired`.

Accept sets that narrow, each reader FROM → TO:

- `@object-ui/cli` — `objectui check`'s unevaluated-expression refusal (`findUnbindableTextExpressions`). FROM: the document root and every node its `children` hold. TO: those, and every node under a slot its type declares — so a `${…}` on `title` / `label` / `value` / `description` of a node under a dialog's `content`, a tab item's `content`, a page's `regions[].components`, a carousel item, a detail view's `tabs[].content` is now refused with the slot path (`items → 0 → content → value`). The false-refusal rows of PR #11126's ablation 2 stay green: a form's `fields[]`, a grid's `columns[]` and `{ "type": "multiple" }` are not slots. Measured over this repository's own JSON corpus and docs fences: no new finding.
- `@object-ui/core` — `validateSchema`. FROM: `validateChildren` recursed through `children` only. TO: it also recurses through the declared slots, so an invalid node under one (a retired `crud` spelling under `dialog.content`, an `INVALID_SCHEMA` member) is reported with its own path, spelled as `schema.items[0].content`. Measured over the same corpus: no new finding.
- `@object-ui/sdui-parser` — `validateTree`. FROM: the walk descended `children` alone, and a manifest entry carried no slot. TO: `ManifestComponent` gains `slots?: readonly string[]`, `manifestFromConfigs` gains `opts.slotsFor` (hand it `nodeSlotsFor`) and projects each entry's non-retired positions, and `validateTree` descends them — an unknown component, an unknown or mis-typed prop or an illegal enum under a slot now draws its diagnostic. A manifest built without the option serialises byte-identically and keeps the `children`-only reach. The `RETIRED_CHILD_LIST_KEY` refusals are unchanged.
- `@object-ui/components` — the `kind:'html'` page's compile manifest (`getJsxManifest`) is built with `slotsFor`, so an html-tier page whose slot-held node fails validation now fails to compile the way one under `children` does. Narrowing: a page that compiled with an unknown tag under a `dialog`'s `content` no longer does.

Docs: `content/docs/utilities/cli.mdx`'s "Component nodes only" rule, the gate's own docblock, `validateChildren`'s comment and the parser's header now say the walk follows `children` and the declared slots; the declaration's header is where the slot list is explained.
6 changes: 5 additions & 1 deletion apps/console/dev/manifest-dump.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
import './manifest-registry';
import { ComponentRegistry } from '@object-ui/core';
import { assertFullyLoaded, manifestFromConfigs } from '@object-ui/sdui-parser';
import { nodeSlotsFor } from '@object-ui/types';

const out = document.getElementById('out')!;
const win = window as unknown as { __MANIFEST?: string; __MANIFEST_ERROR?: string };

try {
const configs = ComponentRegistry.getPublicConfigs() as never;
assertFullyLoaded(configs);
const json = JSON.stringify(manifestFromConfigs(configs), null, 2);
// `slotsFor` (objectui#11170): the dumped manifest carries each entry's node
// slots from the one declaration in `@object-ui/types`, as the shipped
// `sdui.manifest.json` does.
const json = JSON.stringify(manifestFromConfigs(configs, { slotsFor: nodeSlotsFor }), null, 2);
out.textContent = json;
win.__MANIFEST = json;
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `data` | `any` | Arbitrary data attached to the node. `any` because the shape is defined by the consuming component rather than by `BaseSchema`. |
| `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. |
| `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. |
| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. |
| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. The keys OTHER than this one through which a renderer hands nodes to `SchemaRenderer` (a dialog's `trigger`, a tab item's `content`, a page's `regions[].components`) are per type and declared once as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` (objectui#11170); `objectui check`, the core schema validator and the SDUI parser walk those positions as they walk `children`. |
| `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. |
| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
Expand Down
16 changes: 11 additions & 5 deletions content/docs/utilities/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,17 @@ distinction matters:
other type, the expression is never resolved: the user sees its literal text, or
nothing. `objectui check` refuses it in every recognised file:

- **Component nodes only.** The file's root node and every node its `children`
hold, at any depth. Objects under other keys (a form's `fields`, a grid's
`columns`) are definitions, not nodes, and are not judged. A root whose `type`
is `page` keeps its own `title`: that is a page key, while its `children` are
still judged.
- **Component nodes only.** The file's root node, every node its `children`
hold, and every node under a slot the node's type declares — a dialog's
`trigger` and `content`, a tab item's `content`, a page's
`regions[].components` — at any depth. Which keys are slots is per type and
declared once, as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` in
`@object-ui/types`, the same declaration core's schema validator and the
SDUI parser walk; the check keeps no list of its own. Objects under any other
key (a form's `fields`, a grid's `columns`) are definitions, not nodes, and
are not judged. A root whose `type` is `page` keeps its own `title`: that is
a page key, while its `children` and its regions' components are still
judged.
- **The type is matched as written.** `ui:card` is not `card`, exactly as at
render time.
- **A type no registered component answers to** is warned about, not refused: a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';

import { EXPRESSION_BINDABLE_TEXT_KEYS, expressionBindableTextKeysFor } from '@objectstack/spec/ui';
import { nodeSlotsFor } from '@object-ui/types';

import { check } from '../commands/check.js';
import { formatIssuePath } from '../utils/issue-path.js';
Expand Down Expand Up @@ -250,6 +251,79 @@ describe('sub-rule (i): component nodes only', () => {
});
});

/**
* The walk reaches the node slots the node's type declares (objectui#11170):
* `nodeSlotsFor` in `@object-ui/types`, the declaration core's
* `validateChildren` and the SDUI parser read too. Each case pins the path the
* way this command prints it, and the false-refusal rows of PR #11126's
* ablation 2 — `fields[]`, `columns[]`, `{ "type": "multiple" }` — stay green
* beside them: reach is decided by the declaration, not by the shape of a value.
*/
describe('component nodes under a declared node slot are judged (objectui#11170)', () => {
it('refuses under a direct slot (`dialog.content`), printing the slot path', async () => {
expect(nodeSlotsFor('dialog').map((s) => s.path)).toContain('content');
const document = { type: 'dialog', trigger: { type: 'button', label: 'Open' }, content: [{ type: 'text', value: EXPR }] };
await checkOne(document);
expect(findUnbindableTextExpressions(document).map((f) => f.path)).toEqual([['content', 0, 'value']]);
expect(refusalLines()).toHaveLength(1);
expect(refusalLines()[0]).toContain(`at ${formatIssuePath(['content', 0, 'value'])}:`);
expect(exitCodes).toEqual([1]);
});

it('refuses under a panel list (`tabs.items[].content`) and under a page’s `regions[].components`', async () => {
const tabs = { type: 'tabs', items: [{ value: 'a', label: 'A', content: { type: 'text', value: EXPR } }] };
expect(findUnbindableTextExpressions(tabs).map((f) => f.path)).toEqual([['items', 0, 'content', 'value']]);
await checkOne(tabs);
expect(refusalLines()[0]).toContain(`at ${formatIssuePath(['items', 0, 'content', 'value'])}:`);

lines = [];
exitCodes = [];
const page = { type: 'page', title: EXPR, regions: [{ name: 'main', components: [{ type: 'action:button', label: EXPR }] }] };
await checkOne(page);
// The page root's own `title` is still a page key (sub-rule i); the region node is judged.
expect(findUnbindableTextExpressions(page).map((f) => f.path)).toEqual([['regions', 0, 'components', 0, 'label']]);
expect(refusalLines()).toHaveLength(1);
expect(exitCodes).toEqual([1]);
});

it('walks a slot under a child under a slot, every hop in the path', () => {
const document = {
type: 'flex',
children: [{ type: 'sheet', content: { type: 'card', footer: [{ type: 'text', value: EXPR }] } }],
};
expect(findUnbindableTextExpressions(document).map((f) => f.path)).toEqual([
['children', 0, 'content', 'footer', 0, 'value'],
]);
});

it('walks the retired `body` only where the renderer still paints it (`page:card`), never as a generic key', () => {
expect(nodeSlotsFor('page:card').find((s) => s.path === 'body')?.retired).toBe(true);
expect(findUnbindableTextExpressions({ type: 'page:card', body: [{ type: 'text', value: EXPR }] }).map((f) => f.path)).toEqual([
['body', 0, 'value'],
]);
expect(nodeSlotsFor('badge')).toEqual([]);
expect(findUnbindableTextExpressions({ type: 'badge', body: [{ type: 'text', value: EXPR }] })).toEqual([]);
});

it('does not walk a key that is a slot of another type, nor the ablation-2 definition lists', async () => {
// `content` is `dialog`'s slot and nothing of `text`'s.
expect(findUnbindableTextExpressions({ type: 'text', content: { type: 'text', value: EXPR } })).toEqual([]);
// PR #11126's ablation 2, verbatim: these must stay green.
const field = { name: 'total', type: 'text', label: EXPR };
await checkOne({ type: 'form', fields: [field] });
expect(refusalLines()).toEqual([]);
expect(findUnbindableTextExpressions({ type: 'data-table', columns: [{ type: 'text', label: EXPR }] })).toEqual([]);
expect(findUnbindableTextExpressions({ type: 'data-table', selection: { type: 'multiple', label: EXPR } })).toEqual([]);
expect(exitCodes).toEqual([]);
});

it('a type with no row — unknown types included — has only its `children` walked', () => {
expect(nodeSlotsFor('stat-card')).toEqual([]);
const document = { type: 'stat-card', content: { type: 'text', value: EXPR }, children: [{ type: 'text', value: EXPR }] };
expect(findUnbindableTextExpressions(document).map((f) => f.path)).toEqual([['children', 0, 'value']]);
});
});

describe('sub-rule (ii): a type no registered component answers to warns, never refuses', () => {
it('warns on `stat-card`, and the run still passes', async () => {
expect(isKnownSchemaType('stat-card')).toBe(false);
Expand Down
36 changes: 27 additions & 9 deletions packages/cli/src/utils/unbindable-text-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EXPRESSION_BINDABLE_TEXT_KEYS,
expressionBindableTextKeysFor,
} from '@objectstack/spec/ui';
import { nodeSlotValues, nodeSlotsFor } from '@object-ui/types';

import { formatIssuePath } from './issue-path.js';
import { isKnownSchemaType } from './known-schema-types.js';
Expand Down Expand Up @@ -49,7 +50,8 @@ import { isKnownSchemaType } from './known-schema-types.js';
* suite `SchemaRenderer.bindableTextKeys.test.tsx`, this gate's over every
* registered type by `check-unbindable-text-expression-4795.test.ts`.
*
* ## What a component node is: the root, and what `children` holds
* ## What a component node is: the root, what `children` holds, and the
* ## node slots the node's type declares
*
* `SchemaRenderer` does not recurse on its own. Each renderer decides which of
* its keys it hands back to `SchemaRenderer`, and many keys that hold objects
Expand All @@ -60,13 +62,17 @@ import { isKnownSchemaType } from './known-schema-types.js';
* `{ "type": "text", "label": "${…}" }` inside `fields[]` — a false refusal
* on a key this gate has no business judging.
*
* So the walk follows the protocol's ONE composition key,
* `BaseSchema.children`, from the document root: the same single spelling the
* core validator's recursive walk and the SDUI parser's child-list key follow.
* Nodes that a renderer reaches through a key of its own (`trigger`, `footer`,
* a page's `regions`, a tab's `content`) are not walked. That is a stated
* boundary, and it fails quiet in the safe direction: a node the walk does not
* reach is not refused, and nothing outside a component node is refused.
* So the walk follows two things and nothing else, from the document root:
* the protocol's ONE composition key, `BaseSchema.children`, on every node;
* and the NODE SLOTS declared for the node's type — `nodeSlotsFor(type)` in
* `@object-ui/types` (objectui#11170), the one declaration of where a
* renderer hands nodes back through a key of its own (`trigger`, `footer`, a
* page's `regions[].components`, a tab's `items[].content`). The core
* validator's recursive walk and the SDUI parser read the same declaration;
* ⛔ this gate keeps no slot list of its own. The declaration's header says
* what a slot is, how a position is spelled and which test holds each row
* against the live renderer. A type with no row — an unknown or custom type
* included — has its `children` walked and nothing else.
*
* ## What an expression is
*
Expand All @@ -78,7 +84,10 @@ import { isKnownSchemaType } from './known-schema-types.js';
/** The evaluator's interpolation pattern, as `ExpressionEvaluator.evaluate` matches it. */
const EXPRESSION_PATTERN = /\$\{[^}]+\}/;

/** The protocol's one composition key: `BaseSchema.children`. */
/**
* The protocol's one composition key: `BaseSchema.children`. Walked on every
* node; the per-type node slots beside it come from `nodeSlotsFor`.
*/
const COMPOSITION_KEY = 'children';

/**
Expand Down Expand Up @@ -152,6 +161,15 @@ function visit(
} else if (isComponentNode(children)) {
visit(children, [...path, COMPOSITION_KEY], false, into);
}
// The node slots this type's renderer reads (objectui#11170): the same
// declaration core's `validateChildren` and the SDUI parser walk. Retired
// positions are walked too — the renderer still paints them, so a `${…}`
// under one still reaches the user.
for (const slot of nodeSlotsFor(node.type)) {
for (const { segments, value } of nodeSlotValues(node, slot.path)) {
if (isComponentNode(value)) visit(value, [...path, ...segments], false, into);
}
}
}

/**
Expand Down
Loading
Loading