Skip to content

Commit 1b3397a

Browse files
os-zhuangclaude
andcommitted
feat(components,markdown): render element:metadata_viewer + ```metadata doc fence (ADR-0051 P1)
The objectui half of ADR-0051 "inline metadata views in docs". A ```metadata fenced block in a doc is lifted into a live, read-only `element:metadata_viewer` SDUI component — the same component a page node renders, so docs and pages share one runtime. - components: new `element:metadata_viewer` renderer (Content element, read-only). Resolves the target live via useMetadataItem and renders read-only projections of: · state_machine — transition graph from a `state_machine` validation rule (initial/final badges, field-option colors) · flow — ordered step list; `detail: business` folds technical nodes · permission — object-level C/R/U/D matrix Registered in ComponentRegistry; degrades to a clear placeholder on a missing/forbidden reference. - plugin-markdown: dispatch ```metadata at the <pre> level (beside ```mermaid) into the SDUI component via the shared ComponentRegistry; flat `key: value` body parser (data, not code); `metadata` added to rehype-highlight plainText so the body is read verbatim. Verified in browser against the app-showcase backend: task/project state machines, a flow (technical steps folded), and a permission matrix all render live with no console errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 712cbd0 commit 1b3397a

3 files changed

Lines changed: 446 additions & 3 deletions

File tree

packages/components/src/renderers/basic/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ import './button-group';
1717
import './pagination';
1818
import './navigation-menu';
1919
import './elements';
20+
import './metadata-viewer';
2021
import './data-list';
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* `element:metadata_viewer` — a read-only, live-resolved view of a metadata
9+
* item, embedded inline in content (ADR-0051). This is the SDUI component a
10+
* ```` ```metadata ```` doc fence compiles to: the inline form of ADR-0046 §3.5
11+
* ("derived content is rendered, never written"). It resolves the target
12+
* metadata by name at render time (so it never goes stale) and renders a
13+
* read-only projection — it carries no expressions or actions, staying on the
14+
* data side of the §3.4 trust boundary.
15+
*
16+
* Spec: `framework/packages/spec/src/ui/component.zod.ts`
17+
* ElementMetadataViewerPropsSchema → { type, name, object?, mode?, detail? }
18+
* type ∈ state_machine | flow | permission (`object` embeds deferred)
19+
*/
20+
21+
import * as React from 'react';
22+
import { ComponentRegistry } from '@object-ui/core';
23+
import { useMetadataItem } from '@object-ui/react';
24+
import {
25+
ArrowRight,
26+
CircleDot,
27+
Flag,
28+
Workflow,
29+
GitBranch,
30+
ShieldCheck,
31+
Check,
32+
Minus,
33+
AlertTriangle,
34+
} from 'lucide-react';
35+
import { cn } from '../../lib/utils';
36+
37+
// ---------------------------------------------------------------------------
38+
// Shared helpers
39+
// ---------------------------------------------------------------------------
40+
41+
interface ViewerProps {
42+
type?: 'state_machine' | 'flow' | 'permission';
43+
name?: string;
44+
object?: string;
45+
mode?: 'diagram' | 'matrix' | 'summary';
46+
detail?: 'business' | 'technical';
47+
}
48+
49+
function readProps(schema: any): ViewerProps {
50+
const fromProperties = (schema?.properties ?? {}) as ViewerProps;
51+
const fromProps = (schema?.props ?? {}) as ViewerProps;
52+
return { ...fromProps, ...fromProperties };
53+
}
54+
55+
/** Tolerate `fields` as either an object map or an array of `{name,...}`. */
56+
function getField(obj: any, name?: string): any {
57+
if (!obj || !name) return undefined;
58+
const f = obj.fields;
59+
if (!f) return undefined;
60+
if (Array.isArray(f)) return f.find((x: any) => x?.name === name);
61+
return f[name];
62+
}
63+
64+
function Shell({
65+
hint,
66+
icon: Icon,
67+
title,
68+
subtitle,
69+
children,
70+
}: {
71+
hint: string;
72+
icon: React.ComponentType<{ className?: string }>;
73+
title: React.ReactNode;
74+
subtitle?: React.ReactNode;
75+
children: React.ReactNode;
76+
}) {
77+
return (
78+
<div className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden">
79+
<div className="flex items-start gap-2.5 border-b bg-muted/40 px-3.5 py-2.5">
80+
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
81+
<div className="min-w-0 flex-1">
82+
<div className="flex flex-wrap items-baseline gap-x-2">
83+
<span className="text-sm font-semibold leading-tight">{title}</span>
84+
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">{hint}</span>
85+
</div>
86+
{subtitle && <div className="mt-0.5 text-xs text-muted-foreground">{subtitle}</div>}
87+
</div>
88+
</div>
89+
<div className="p-3.5">{children}</div>
90+
</div>
91+
);
92+
}
93+
94+
function Placeholder({ tone = 'muted', children }: { tone?: 'muted' | 'warn'; children: React.ReactNode }) {
95+
return (
96+
<div
97+
className={cn(
98+
'flex items-center gap-2 rounded-md border border-dashed px-3 py-2.5 text-xs',
99+
tone === 'warn'
100+
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300'
101+
: 'bg-muted/30 text-muted-foreground',
102+
)}
103+
>
104+
{tone === 'warn' && <AlertTriangle className="h-3.5 w-3.5 shrink-0" />}
105+
{children}
106+
</div>
107+
);
108+
}
109+
110+
// ---------------------------------------------------------------------------
111+
// state_machine — live transition graph from a `state_machine` validation rule
112+
// ---------------------------------------------------------------------------
113+
114+
interface SelectOption {
115+
label?: string;
116+
value: string;
117+
color?: string;
118+
default?: boolean;
119+
}
120+
121+
function StateMachineView({ object, name }: ViewerProps) {
122+
const { item: obj, loading, error } = useMetadataItem('object', object ?? null);
123+
124+
const rule = React.useMemo(() => {
125+
const rules: any[] = obj?.validations ?? obj?.validationRules ?? [];
126+
if (!Array.isArray(rules)) return undefined;
127+
return rules.find(
128+
(r) => r?.type === 'state_machine' && (!name || r?.name === name),
129+
);
130+
}, [obj, name]);
131+
132+
if (!object) return <Placeholder tone="warn">Missing <code className="font-mono">object</code> for the state machine.</Placeholder>;
133+
if (loading) return <Placeholder>Loading <code className="font-mono">{object}</code></Placeholder>;
134+
if (error) return <Placeholder tone="warn">Failed to load object <code className="font-mono">{object}</code>: {error.message}</Placeholder>;
135+
if (!obj) return <Placeholder tone="warn">Object <code className="font-mono">{object}</code> not found.</Placeholder>;
136+
if (!rule) {
137+
return (
138+
<Placeholder tone="warn">
139+
No state machine{name ? ` named “${name}”` : ''} on <code className="font-mono">{object}</code>.
140+
</Placeholder>
141+
);
142+
}
143+
144+
const transitions: Record<string, string[]> =
145+
rule.transitions && typeof rule.transitions === 'object' && !Array.isArray(rule.transitions)
146+
? rule.transitions
147+
: {};
148+
const field = getField(obj, rule.field);
149+
const options: SelectOption[] = Array.isArray(field?.options) ? field.options : [];
150+
const optByValue = new Map(options.map((o) => [o.value, o]));
151+
const labelOf = (v: string) => optByValue.get(v)?.label ?? v;
152+
const colorOf = (v: string) => optByValue.get(v)?.color;
153+
const initial = options.find((o) => o.default)?.value;
154+
155+
// All states = union of declared options, transition sources, and targets —
156+
// ordered: initial first, then option order, then any extras.
157+
const ordered = new Set<string>();
158+
if (initial) ordered.add(initial);
159+
for (const o of options) ordered.add(o.value);
160+
for (const [from, tos] of Object.entries(transitions)) {
161+
ordered.add(from);
162+
for (const t of tos) ordered.add(t);
163+
}
164+
const states = [...ordered];
165+
166+
const Chip = ({ value }: { value: string }) => {
167+
const color = colorOf(value);
168+
return (
169+
<span className="inline-flex items-center gap-1 rounded-full border bg-background px-2 py-0.5 text-xs font-medium">
170+
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: color ?? 'var(--muted-foreground)' }} />
171+
{labelOf(value)}
172+
</span>
173+
);
174+
};
175+
176+
return (
177+
<Shell
178+
hint="state machine"
179+
icon={Workflow}
180+
title={rule.label ?? rule.name ?? 'State machine'}
181+
subtitle={
182+
<>
183+
Lifecycle of <code className="font-mono">{object}</code>
184+
{rule.field ? <> · field <code className="font-mono">{rule.field}</code></> : null}
185+
</>
186+
}
187+
>
188+
<ol className="space-y-1.5">
189+
{states.map((s) => {
190+
const tos = transitions[s] ?? [];
191+
const isInitial = s === initial;
192+
const isFinal = tos.length === 0;
193+
return (
194+
<li
195+
key={s}
196+
className="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-md border bg-muted/20 px-2.5 py-2"
197+
>
198+
<Chip value={s} />
199+
{isInitial && (
200+
<span className="inline-flex items-center gap-1 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400">
201+
<CircleDot className="h-3 w-3" /> initial
202+
</span>
203+
)}
204+
{isFinal ? (
205+
<span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
206+
<Flag className="h-3 w-3" /> final
207+
</span>
208+
) : (
209+
<>
210+
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
211+
<span className="flex flex-wrap gap-1.5">
212+
{tos.map((t) => (
213+
<Chip key={t} value={t} />
214+
))}
215+
</span>
216+
</>
217+
)}
218+
</li>
219+
);
220+
})}
221+
</ol>
222+
</Shell>
223+
);
224+
}
225+
226+
// ---------------------------------------------------------------------------
227+
// flow — ordered step summary of a flow's nodes (read-only)
228+
// ---------------------------------------------------------------------------
229+
230+
// Node-action families that are infrastructure, not business steps. With
231+
// `detail: business` (the default), these are folded away so a non-technical
232+
// reader sees the process, not the plumbing (ADR-0051 §3.4 altitude projection).
233+
const TECHNICAL_NODE_TYPES = new Set([
234+
'script',
235+
'http_request',
236+
'connector_action',
237+
'assignment',
238+
'get_record',
239+
'create_record',
240+
'update_record',
241+
'delete_record',
242+
'boundary_event',
243+
]);
244+
245+
function FlowView({ name, detail }: ViewerProps) {
246+
const { item: flow, loading, error } = useMetadataItem('flow', name ?? null);
247+
248+
if (!name) return <Placeholder tone="warn">Missing <code className="font-mono">name</code> for the flow.</Placeholder>;
249+
if (loading) return <Placeholder>Loading flow <code className="font-mono">{name}</code></Placeholder>;
250+
if (error) return <Placeholder tone="warn">Failed to load flow <code className="font-mono">{name}</code>: {error.message}</Placeholder>;
251+
if (!flow) return <Placeholder tone="warn">Flow <code className="font-mono">{name}</code> not found.</Placeholder>;
252+
253+
const allNodes: any[] = Array.isArray(flow.nodes) ? flow.nodes : [];
254+
const business = (detail ?? 'business') === 'business';
255+
const nodes = business ? allNodes.filter((n) => !TECHNICAL_NODE_TYPES.has(n?.type)) : allNodes;
256+
const hiddenCount = allNodes.length - nodes.length;
257+
258+
return (
259+
<Shell
260+
hint="flow"
261+
icon={GitBranch}
262+
title={flow.label ?? flow.name ?? name}
263+
subtitle={flow.description ?? <>Process with {allNodes.length} step{allNodes.length === 1 ? '' : 's'}</>}
264+
>
265+
{nodes.length === 0 ? (
266+
<Placeholder>No steps to show.</Placeholder>
267+
) : (
268+
<ol className="space-y-1.5">
269+
{nodes.map((n, i) => (
270+
<li key={n?.id ?? i} className="flex items-center gap-2.5 rounded-md border bg-muted/20 px-2.5 py-2">
271+
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
272+
{i + 1}
273+
</span>
274+
<span className="text-sm">{n?.label ?? n?.id ?? '(step)'}</span>
275+
{n?.type && (
276+
<span className="ml-auto rounded border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
277+
{n.type}
278+
</span>
279+
)}
280+
</li>
281+
))}
282+
</ol>
283+
)}
284+
{business && hiddenCount > 0 && (
285+
<div className="mt-2 text-[11px] text-muted-foreground">
286+
{hiddenCount} technical step{hiddenCount === 1 ? '' : 's'} hidden · set <code className="font-mono">detail: technical</code> to show all
287+
</div>
288+
)}
289+
</Shell>
290+
);
291+
}
292+
293+
// ---------------------------------------------------------------------------
294+
// permission — compact object-level CRUD matrix (read-only)
295+
// ---------------------------------------------------------------------------
296+
297+
const CRUD: Array<{ key: string; label: string }> = [
298+
{ key: 'allowCreate', label: 'C' },
299+
{ key: 'allowRead', label: 'R' },
300+
{ key: 'allowEdit', label: 'U' },
301+
{ key: 'allowDelete', label: 'D' },
302+
];
303+
304+
function PermissionView({ name }: ViewerProps) {
305+
const { item: perm, loading, error } = useMetadataItem('permission', name ?? null);
306+
307+
if (!name) return <Placeholder tone="warn">Missing <code className="font-mono">name</code> for the permission set.</Placeholder>;
308+
if (loading) return <Placeholder>Loading permission set <code className="font-mono">{name}</code></Placeholder>;
309+
if (error) return <Placeholder tone="warn">Failed to load permission <code className="font-mono">{name}</code>: {error.message}</Placeholder>;
310+
if (!perm) return <Placeholder tone="warn">Permission set <code className="font-mono">{name}</code> not found.</Placeholder>;
311+
312+
const objects: Record<string, any> = perm.objects && typeof perm.objects === 'object' ? perm.objects : {};
313+
const entries = Object.entries(objects);
314+
315+
return (
316+
<Shell
317+
hint="permission"
318+
icon={ShieldCheck}
319+
title={perm.label ?? perm.name ?? name}
320+
subtitle={<>Object access · {entries.length} object{entries.length === 1 ? '' : 's'}{perm.isProfile ? ' · profile' : ''}</>}
321+
>
322+
{entries.length === 0 ? (
323+
<Placeholder>No object permissions declared.</Placeholder>
324+
) : (
325+
<table className="w-full border-separate border-spacing-0 text-xs">
326+
<thead>
327+
<tr className="text-muted-foreground">
328+
<th className="border-b px-2 py-1.5 text-left font-medium">Object</th>
329+
{CRUD.map((c) => (
330+
<th key={c.key} className="border-b px-2 py-1.5 text-center font-mono font-medium" title={c.key}>
331+
{c.label}
332+
</th>
333+
))}
334+
</tr>
335+
</thead>
336+
<tbody>
337+
{entries.map(([objName, perms]) => (
338+
<tr key={objName}>
339+
<td className="border-b px-2 py-1.5 font-mono">{objName}</td>
340+
{CRUD.map((c) => (
341+
<td key={c.key} className="border-b px-2 py-1.5 text-center">
342+
{perms?.[c.key] ? (
343+
<Check className="mx-auto h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
344+
) : (
345+
<Minus className="mx-auto h-3.5 w-3.5 text-muted-foreground/40" />
346+
)}
347+
</td>
348+
))}
349+
</tr>
350+
))}
351+
</tbody>
352+
</table>
353+
)}
354+
</Shell>
355+
);
356+
}
357+
358+
// ---------------------------------------------------------------------------
359+
// Dispatcher + registration
360+
// ---------------------------------------------------------------------------
361+
362+
export function ElementMetadataViewerRenderer({ schema }: { schema: any }) {
363+
const props = readProps(schema);
364+
switch (props.type) {
365+
case 'state_machine':
366+
return <StateMachineView {...props} />;
367+
case 'flow':
368+
return <FlowView {...props} />;
369+
case 'permission':
370+
return <PermissionView {...props} />;
371+
default:
372+
return (
373+
<Placeholder tone="warn">
374+
Unknown metadata view type{props.type ? ` “${props.type}”` : ''}. Expected{' '}
375+
<code className="font-mono">state_machine</code>, <code className="font-mono">flow</code>, or{' '}
376+
<code className="font-mono">permission</code>.
377+
</Placeholder>
378+
);
379+
}
380+
}
381+
382+
ComponentRegistry.register('element:metadata_viewer', ElementMetadataViewerRenderer, {
383+
namespace: 'element',
384+
label: 'Metadata Viewer',
385+
category: 'content',
386+
});

0 commit comments

Comments
 (0)