From 155b3e9fdc9f9a186a0d8575919fdd90c96bca24 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 15:20:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(metadata-admin):=20book=20metadata=20displ?= =?UTF-8?q?ay=20UI=20(ADR-0046=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework added a `book` metadata type (documentation navigation spine — ordered groups with membership derived over docs via glob/tag rules) in ADR-0046 §6, but objectui had no display surface for it. Add the standard per-type display layer the metadata-admin engine expects, mirroring the existing types: - BookPreview: visualises the authored spine — identity/access header (label, slug, audience chip), groups sorted by order, each group's include rule (glob or {tag}) and package scope, and any explicit `pages` override (doc entries, `---` separators, `...` rest, badges, external links). Defensive reads; degrades to a hint, never throws. Toolbar links to GET /meta/book/:name/tree for the live-resolved tree. - default-schemas: fallback JSONSchema for the Form tab. - i18n: type label (EN "Documentation Book" / ZH "文档手册"). - builtinComponents: registerMetadataResource (domain system, columns). - BookPreview.test: 8 cases (empty/glob/tag/override/orphan/order/bad draft). Note: the metadata-admin type list is server-driven from /meta/types, so `book` only surfaces once the running backend serves a framework build that includes it (not in the published @objectstack/spec@9.5.1 yet). This is the ready-and-waiting client display layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/services/builtinComponents.tsx | 36 ++ .../views/metadata-admin/default-schemas.ts | 47 +++ .../src/views/metadata-admin/i18n.ts | 2 + .../previews/BookPreview.test.tsx | 103 +++++ .../metadata-admin/previews/BookPreview.tsx | 347 ++++++++++++++++ .../views/metadata-admin/previews/index.ts | 4 + .../components/src/renderers/basic/index.ts | 1 + .../src/renderers/basic/metadata-viewer.tsx | 386 ++++++++++++++++++ packages/plugin-markdown/src/MarkdownImpl.tsx | 62 ++- 9 files changed, 985 insertions(+), 3 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/previews/BookPreview.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/previews/BookPreview.tsx create mode 100644 packages/components/src/renderers/basic/metadata-viewer.tsx diff --git a/packages/app-shell/src/services/builtinComponents.tsx b/packages/app-shell/src/services/builtinComponents.tsx index 98f9306a5..74805e0c5 100644 --- a/packages/app-shell/src/services/builtinComponents.tsx +++ b/packages/app-shell/src/services/builtinComponents.tsx @@ -165,3 +165,39 @@ registerMetadataResource({ { key: 'route', label: 'Route' }, ], }); + +/* -------------------------------------------------------------------------- */ +/* 5) Documentation — the `book` navigation spine (ADR-0046 §6). A book is an */ +/* ordered set of groups whose membership over docs is DERIVED from each */ +/* group's include rule; the Preview tab renders that spine for authoring. */ +/* -------------------------------------------------------------------------- */ + +registerMetadataResource({ + type: 'book', + label: 'Documentation Books', + description: 'Documentation navigation spine — ordered groups with membership derived over docs (glob/tag rules), plus an explicit pages override for curated order.', + domain: 'system', + searchableFields: ['name', 'label', 'description', 'slug'], + listColumns: [ + { key: 'name', label: 'Name', width: '25%' }, + { key: 'label', label: 'Label', width: '25%' }, + { + key: 'audience', + label: 'Audience', + width: '12%', + render: (v) => + v == null + ? 'org' + : typeof v === 'object' && v && 'profile' in (v as Record) + ? `profile: ${(v as { profile: string }).profile}` + : String(v), + }, + { + key: 'groups', + label: 'Groups', + width: '10%', + render: (v) => (Array.isArray(v) ? String(v.length) : '0'), + }, + { key: 'description', label: 'Description' }, + ], +}); diff --git a/packages/app-shell/src/views/metadata-admin/default-schemas.ts b/packages/app-shell/src/views/metadata-admin/default-schemas.ts index 4f14f3d04..40c5f262e 100644 --- a/packages/app-shell/src/views/metadata-admin/default-schemas.ts +++ b/packages/app-shell/src/views/metadata-admin/default-schemas.ts @@ -241,6 +241,53 @@ const SCHEMAS: Record> = { }, }, + book: { + type: 'object', + title: 'Documentation Book', + required: ['name', 'groups'], + properties: { + ...headerProps, + slug: { + type: 'string', + title: 'Slug', + description: 'Portal URL segment; defaults to the name without its package prefix.', + }, + icon: { type: 'string', title: 'Icon' }, + order: { + type: 'number', + title: 'Order', + description: 'Orders books within the portal.', + }, + audience: { + title: 'Audience', + description: "Access audience. 'org' (default) inherits the package grant; 'public' is anonymously readable; { profile } gates by role.", + // Union of the two scalar literals and the { profile } object — kept lax + // so the role-gated object form round-trips untouched through the form. + oneOf: [ + { type: 'string', enum: ['org', 'public'] }, + { type: 'object', title: 'Role-gated', properties: { profile: { type: 'string', title: 'Profile' } }, required: ['profile'] }, + ], + }, + groups: { + type: 'array', + title: 'Groups (spine)', + description: 'Ordered sections. Membership is derived from each group\'s include rule — edit the structure visually in the Preview tab.', + items: { + type: 'object', + required: ['key', 'label'], + properties: { + key: { type: 'string', title: 'Key', pattern: '^[a-z][a-z0-9_]*$' }, + label: { type: 'string', title: 'Label' }, + order: { type: 'number', title: 'Order' }, + include: { title: 'Include rule', description: 'Glob over doc names (e.g. crm_guide_*) or { tag }.' }, + package: { type: 'string', title: 'Package scope' }, + pages: { type: 'array', title: 'Explicit pages (override)', items: {} }, + }, + }, + }, + }, + }, + email_template: { type: 'object', title: 'Email Template', diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 2edbe644f..d83d2c986 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -49,6 +49,7 @@ const TYPE_LABELS_EN: Record = { function: 'Function', service: 'Service', email_template: 'Email Template', + book: 'Documentation Book', // Security permission: 'Permission Set', profile: 'Profile', @@ -94,6 +95,7 @@ const TYPE_LABELS_ZH: Record = { function: '函数', service: '服务', email_template: '邮件模板', + book: '文档手册', permission: '权限集', profile: '配置文件', role: '角色', diff --git a/packages/app-shell/src/views/metadata-admin/previews/BookPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/BookPreview.test.tsx new file mode 100644 index 000000000..831747f7e --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/BookPreview.test.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +import { BookPreview } from './BookPreview'; + +afterEach(cleanup); + +/** Minimal helper — BookPreview only reads `name` + `draft`. */ +function renderBook(draft: Record, name = '') { + return render(); +} + +describe('BookPreview', () => { + it('prompts for a name before anything is authored', () => { + renderBook({}); + expect(screen.getByText('Name your book')).toBeInTheDocument(); + }); + + it('warns when a named book has no groups', () => { + renderBook({ name: 'crm_manual', label: 'CRM Manual' }); + expect(screen.getByText('CRM Manual')).toBeInTheDocument(); + expect(screen.getByText('No groups yet')).toBeInTheDocument(); + }); + + it('renders identity + a derived-membership group with its include glob', () => { + renderBook({ + name: 'crm_manual', + label: 'CRM Manual', + slug: 'manual', + audience: 'public', + groups: [{ key: 'guides', label: 'Guides', include: 'crm_guide_*' }], + }); + expect(screen.getByText('CRM Manual')).toBeInTheDocument(); + expect(screen.getByText('manual')).toBeInTheDocument(); + expect(screen.getByText('Public')).toBeInTheDocument(); // audience chip + expect(screen.getByText('Guides')).toBeInTheDocument(); + expect(screen.getByText('crm_guide_*')).toBeInTheDocument(); // include glob chip + expect(screen.getByText(/Members derived from the rule/)).toBeInTheDocument(); + }); + + it('renders a tag include rule', () => { + renderBook({ + name: 'crm_manual', + groups: [{ key: 'start', label: 'Getting started', include: { tag: 'getting-started' } }], + }); + expect(screen.getByText('getting-started')).toBeInTheDocument(); + }); + + it('renders an explicit pages override incl. separator, rest, badge and external link', () => { + renderBook({ + name: 'crm_manual', + groups: [ + { + key: 'curated', + label: 'Curated', + pages: [ + 'crm_intro', + '---', + { doc: 'crm_advanced', label: 'Advanced', badge: 'beta' }, + { href: 'https://example.com/api', label: 'API ref' }, + '...', + ], + }, + ], + }); + expect(screen.getByText('crm_intro')).toBeInTheDocument(); + expect(screen.getByText('separator')).toBeInTheDocument(); + expect(screen.getByText('Advanced')).toBeInTheDocument(); + expect(screen.getByText('beta')).toBeInTheDocument(); + expect(screen.getByText('API ref')).toBeInTheDocument(); + expect(screen.getByText(/…rest/)).toBeInTheDocument(); + }); + + it('flags a group that can never match (no include, no pages)', () => { + renderBook({ + name: 'crm_manual', + groups: [{ key: 'orphan', label: 'Orphan' }], + }); + expect(screen.getByText(/matches\s*nothing/)).toBeInTheDocument(); + }); + + it('orders groups by their `order` then authored index', () => { + renderBook({ + name: 'crm_manual', + groups: [ + { key: 'second', label: 'Second', order: 2, include: 'b_*' }, + { key: 'first', label: 'First', order: 1, include: 'a_*' }, + ], + }); + const labels = screen.getAllByText(/First|Second/).map((el) => el.textContent); + expect(labels[0]).toBe('First'); + expect(labels[1]).toBe('Second'); + }); + + it('degrades gracefully on a malformed draft instead of throwing', () => { + expect(() => + renderBook({ name: 'crm_manual', groups: 'not-an-array' as unknown }), + ).not.toThrow(); + expect(screen.getByText('No groups yet')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/BookPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/BookPreview.tsx new file mode 100644 index 000000000..14ba67c7a --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/BookPreview.tsx @@ -0,0 +1,347 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * BookPreview — visualises a `book` metadata record's navigation spine + * (ADR-0046 §6). A book is the *spine* of a table of contents: an ordered + * set of groups (sections) plus identity and access. It deliberately does + * NOT store its members — membership is **derived** at render time from a + * rule on each group (`include` glob/tag) plus per-doc `order`/`group`. + * + * Because the derived doc set isn't present in the draft (it's resolved + * server-side from the current doc set via `GET /meta/book/:name/tree`), + * this preview renders the *authored spine*: each group, its membership + * rule, and any explicit `pages` override (the curated-order escape hatch, + * incl. `---` separators, `...` rest, and external links). A toolbar link + * opens the live-resolved tree so authors can verify which docs land where. + * + * Drafts can be incomplete or invalid (validation may be in progress), so + * every field is read defensively and we degrade to a hint rather than throw. + */ + +import * as React from 'react'; +import { + BookOpen, + FolderTree, + FileText, + ExternalLink, + Globe, + Lock, + Building2, + Tag, + Minus, + MoreHorizontal, + Package, +} from 'lucide-react'; +import type { MetadataPreviewProps } from '../preview-registry'; +import { PreviewShell, PreviewErrorBoundary, PreviewEmptyState } from './PreviewShell'; + +type Audience = 'org' | 'public' | { profile: string } | undefined; +type Include = string | { tag: string } | undefined; + +interface PageNode { + /** A bare doc name, the `---` separator, or the `...` rest marker. */ + literal?: string; + doc?: string; + href?: string; + label?: string; + badge?: string; + icon?: string; +} + +interface Group { + key: string; + label: string; + order?: number; + include?: Include; + package?: string; + pages?: PageNode[]; +} + +/** Normalise an untrusted `groups` array into a defensively-typed shape. */ +function normalizeGroups(raw: unknown): Group[] { + if (!Array.isArray(raw)) return []; + const groups = raw + .map((g: any, i: number): { g: Group; i: number } | null => { + if (!g || typeof g !== 'object') return null; + const key = String(g.key ?? '').trim(); + const label = String(g.label ?? key ?? '').trim(); + if (!key && !label) return null; + return { + g: { + key: key || '(no key)', + label: label || key || '(unnamed)', + order: typeof g.order === 'number' ? g.order : undefined, + include: normalizeInclude(g.include), + package: typeof g.package === 'string' ? g.package : undefined, + pages: normalizePages(g.pages), + }, + i, + }; + }) + .filter((x): x is { g: Group; i: number } => x !== null); + // Mirror the resolver's ordering: by `order` then authored index (stable). + return groups + .sort((a, b) => (a.g.order ?? 0) - (b.g.order ?? 0) || a.i - b.i) + .map((x) => x.g); +} + +function normalizeInclude(raw: unknown): Include { + if (typeof raw === 'string') return raw; + if (raw && typeof raw === 'object' && typeof (raw as any).tag === 'string') { + return { tag: (raw as any).tag }; + } + return undefined; +} + +function normalizePages(raw: unknown): PageNode[] | undefined { + if (!Array.isArray(raw)) return undefined; + return raw + .map((n: any): PageNode | null => { + if (typeof n === 'string') return { literal: n }; + if (n && typeof n === 'object') { + return { + doc: typeof n.doc === 'string' ? n.doc : undefined, + href: typeof n.href === 'string' ? n.href : undefined, + label: typeof n.label === 'string' ? n.label : undefined, + badge: typeof n.badge === 'string' ? n.badge : undefined, + icon: typeof n.icon === 'string' ? n.icon : undefined, + }; + } + return null; + }) + .filter((x): x is PageNode => x !== null); +} + +function audienceChip(audience: Audience): { icon: React.ReactNode; label: string; tone: string } { + if (audience === 'public') { + return { icon: , label: 'Public', tone: 'text-emerald-700 bg-emerald-50 border-emerald-200' }; + } + if (audience && typeof audience === 'object' && typeof audience.profile === 'string') { + return { icon: , label: `Profile: ${audience.profile}`, tone: 'text-amber-800 bg-amber-50 border-amber-200' }; + } + // 'org' (default) or absent — inherits the package grant. + return { icon: , label: 'Org', tone: 'text-muted-foreground bg-muted/40 border-muted' }; +} + +export function BookPreview({ name, draft }: MetadataPreviewProps) { + const d = draft as any; + const bookName = String(d.name ?? name ?? '').trim(); + const label = String(d.label ?? bookName); + const description = typeof d.description === 'string' ? d.description : ''; + const slug = typeof d.slug === 'string' ? d.slug : ''; + const groups = React.useMemo(() => normalizeGroups(d.groups), [d.groups]); + const aud = audienceChip(d.audience as Audience); + + if (!bookName) { + return ( + + } + title="Name your book" + description="Enter a name in the Form tab to start authoring the documentation spine." + /> + + ); + } + + // The live-resolved tree (actual docs per group) is server-derived. Offer a + // link so authors can verify membership against the current doc set. + const treeUrl = `/meta/book/${encodeURIComponent(bookName)}/tree`; + + return ( + + Resolved tree + + } + > + +
+ {/* Identity / access header */} +
+
+ + {label} + + {aud.icon} + {aud.label} + +
+
{bookName}
+ {slug && ( +
+ Slug: {slug} +
+ )} + {description &&
{description}
} +
+ + {/* The spine */} + {groups.length === 0 ? ( + } + title="No groups yet" + tone="warn" + description={ + <> + A book needs at least one group in its groups spine. Each group + derives its members from an include rule (a glob like{' '} + crm_guide_* or a {'{ tag }'}). + + } + /> + ) : ( +
+ {groups.map((g, i) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function GroupCard({ group }: { group: Group }) { + return ( +
+
+ + {group.label} + {group.key} + {typeof group.order === 'number' && ( + + #{group.order} + + )} +
+ {group.package && ( + + + {group.package} + + )} + +
+
+ +
+ {group.pages?.length ? ( +
    + {group.pages.map((node, i) => ( + + ))} +
+ ) : group.include != null ? ( +
+ Members derived from the rule above — resolved against the live doc set. +
+ ) : ( +
+ No include rule and no explicit pages — this group matches + nothing unless docs set group: "{group.key}". +
+ )} +
+
+ ); +} + +function IncludeChip({ include, hasOverride }: { include: Include; hasOverride: boolean }) { + if (include == null) { + return hasOverride ? ( + + curated + + ) : null; + } + if (typeof include === 'string') { + return ( + + {include} + + ); + } + return ( + + + {include.tag} + + ); +} + +function PageRow({ node }: { node: PageNode }) { + // Separator (`---`). + if (node.literal === '---') { + return ( +
  • + + separator +
  • + ); + } + // Rest marker (`...`) — expands to the group's unpinned members at resolve time. + if (node.literal === '...') { + return ( +
  • + + …rest (remaining matched docs, by order) +
  • + ); + } + + const isExternal = !!node.href; + const docName = node.literal ?? node.doc; + const display = node.label ?? docName ?? node.href ?? '(empty)'; + + return ( +
  • + {isExternal ? ( + + ) : ( + + )} + {display} + {!isExternal && docName && node.label && ( + {docName} + )} + {node.badge && ( + + {node.badge} + + )} + {isExternal && ( + e.stopPropagation()} + className="ml-auto font-mono text-[10px] text-muted-foreground hover:text-foreground truncate max-w-[40%]" + title={node.href} + > + {node.href} + + )} +
  • + ); +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/index.ts b/packages/app-shell/src/views/metadata-admin/previews/index.ts index 3a9df2aac..57757eb81 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/index.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/index.ts @@ -29,6 +29,7 @@ import { SkillPreview } from './SkillPreview'; import { DatasourcePreview } from './DatasourcePreview'; import { ValidationPreview } from './ValidationPreview'; import { DatasetPreview } from './DatasetPreview'; +import { BookPreview } from './BookPreview'; export function registerBuiltinPreviews(): void { // UI surfaces @@ -47,6 +48,9 @@ export function registerBuiltinPreviews(): void { // System registerMetadataPreview('email_template', EmailTemplatePreview); registerMetadataPreview('translation', TranslationPreview); + // Documentation navigation spine (ADR-0046 §6): ordered groups with + // derived membership over docs. + registerMetadataPreview('book', BookPreview); // Automation // Approval is a flow node (`type: 'approval'`) since ADR-0019 — it renders on // the Flow canvas with its `approve` / `reject` branches; no standalone diff --git a/packages/components/src/renderers/basic/index.ts b/packages/components/src/renderers/basic/index.ts index 2ee9289f2..ed2a2e988 100644 --- a/packages/components/src/renderers/basic/index.ts +++ b/packages/components/src/renderers/basic/index.ts @@ -17,4 +17,5 @@ import './button-group'; import './pagination'; import './navigation-menu'; import './elements'; +import './metadata-viewer'; import './data-list'; diff --git a/packages/components/src/renderers/basic/metadata-viewer.tsx b/packages/components/src/renderers/basic/metadata-viewer.tsx new file mode 100644 index 000000000..eecc6267c --- /dev/null +++ b/packages/components/src/renderers/basic/metadata-viewer.tsx @@ -0,0 +1,386 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `element:metadata_viewer` — a read-only, live-resolved view of a metadata + * item, embedded inline in content (ADR-0051). This is the SDUI component a + * ```` ```metadata ```` doc fence compiles to: the inline form of ADR-0046 §3.5 + * ("derived content is rendered, never written"). It resolves the target + * metadata by name at render time (so it never goes stale) and renders a + * read-only projection — it carries no expressions or actions, staying on the + * data side of the §3.4 trust boundary. + * + * Spec: `framework/packages/spec/src/ui/component.zod.ts` + * ElementMetadataViewerPropsSchema → { type, name, object?, mode?, detail? } + * type ∈ state_machine | flow | permission (`object` embeds deferred) + */ + +import * as React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { useMetadataItem } from '@object-ui/react'; +import { + ArrowRight, + CircleDot, + Flag, + Workflow, + GitBranch, + ShieldCheck, + Check, + Minus, + AlertTriangle, +} from 'lucide-react'; +import { cn } from '../../lib/utils'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +interface ViewerProps { + type?: 'state_machine' | 'flow' | 'permission'; + name?: string; + object?: string; + mode?: 'diagram' | 'matrix' | 'summary'; + detail?: 'business' | 'technical'; +} + +function readProps(schema: any): ViewerProps { + const fromProperties = (schema?.properties ?? {}) as ViewerProps; + const fromProps = (schema?.props ?? {}) as ViewerProps; + return { ...fromProps, ...fromProperties }; +} + +/** Tolerate `fields` as either an object map or an array of `{name,...}`. */ +function getField(obj: any, name?: string): any { + if (!obj || !name) return undefined; + const f = obj.fields; + if (!f) return undefined; + if (Array.isArray(f)) return f.find((x: any) => x?.name === name); + return f[name]; +} + +function Shell({ + hint, + icon: Icon, + title, + subtitle, + children, +}: { + hint: string; + icon: React.ComponentType<{ className?: string }>; + title: React.ReactNode; + subtitle?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
    +
    + +
    +
    + {title} + {hint} +
    + {subtitle &&
    {subtitle}
    } +
    +
    +
    {children}
    +
    + ); +} + +function Placeholder({ tone = 'muted', children }: { tone?: 'muted' | 'warn'; children: React.ReactNode }) { + return ( +
    + {tone === 'warn' && } + {children} +
    + ); +} + +// --------------------------------------------------------------------------- +// state_machine — live transition graph from a `state_machine` validation rule +// --------------------------------------------------------------------------- + +interface SelectOption { + label?: string; + value: string; + color?: string; + default?: boolean; +} + +function StateMachineView({ object, name }: ViewerProps) { + const { item: obj, loading, error } = useMetadataItem('object', object ?? null); + + const rule = React.useMemo(() => { + const rules: any[] = obj?.validations ?? obj?.validationRules ?? []; + if (!Array.isArray(rules)) return undefined; + return rules.find( + (r) => r?.type === 'state_machine' && (!name || r?.name === name), + ); + }, [obj, name]); + + if (!object) return Missing object for the state machine.; + if (loading) return Loading {object}; + if (error) return Failed to load object {object}: {error.message}; + if (!obj) return Object {object} not found.; + if (!rule) { + return ( + + No state machine{name ? ` named “${name}”` : ''} on {object}. + + ); + } + + const transitions: Record = + rule.transitions && typeof rule.transitions === 'object' && !Array.isArray(rule.transitions) + ? rule.transitions + : {}; + const field = getField(obj, rule.field); + const options: SelectOption[] = Array.isArray(field?.options) ? field.options : []; + const optByValue = new Map(options.map((o) => [o.value, o])); + const labelOf = (v: string) => optByValue.get(v)?.label ?? v; + const colorOf = (v: string) => optByValue.get(v)?.color; + const initial = options.find((o) => o.default)?.value; + + // All states = union of declared options, transition sources, and targets — + // ordered: initial first, then option order, then any extras. + const ordered = new Set(); + if (initial) ordered.add(initial); + for (const o of options) ordered.add(o.value); + for (const [from, tos] of Object.entries(transitions)) { + ordered.add(from); + for (const t of tos) ordered.add(t); + } + const states = [...ordered]; + + const Chip = ({ value }: { value: string }) => { + const color = colorOf(value); + return ( + + + {labelOf(value)} + + ); + }; + + return ( + + Lifecycle of {object} + {rule.field ? <> · field {rule.field} : null} + + } + > +
      + {states.map((s) => { + const tos = transitions[s] ?? []; + const isInitial = s === initial; + const isFinal = tos.length === 0; + return ( +
    1. + + {isInitial && ( + + initial + + )} + {isFinal ? ( + + final + + ) : ( + <> + + + {tos.map((t) => ( + + ))} + + + )} +
    2. + ); + })} +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// flow — ordered step summary of a flow's nodes (read-only) +// --------------------------------------------------------------------------- + +// Node-action families that are infrastructure, not business steps. With +// `detail: business` (the default), these are folded away so a non-technical +// reader sees the process, not the plumbing (ADR-0051 §3.4 altitude projection). +const TECHNICAL_NODE_TYPES = new Set([ + 'script', + 'http_request', + 'connector_action', + 'assignment', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'boundary_event', +]); + +function FlowView({ name, detail }: ViewerProps) { + const { item: flow, loading, error } = useMetadataItem('flow', name ?? null); + + if (!name) return Missing name for the flow.; + if (loading) return Loading flow {name}; + if (error) return Failed to load flow {name}: {error.message}; + if (!flow) return Flow {name} not found.; + + const allNodes: any[] = Array.isArray(flow.nodes) ? flow.nodes : []; + const business = (detail ?? 'business') === 'business'; + const nodes = business ? allNodes.filter((n) => !TECHNICAL_NODE_TYPES.has(n?.type)) : allNodes; + const hiddenCount = allNodes.length - nodes.length; + + return ( + Process with {allNodes.length} step{allNodes.length === 1 ? '' : 's'}} + > + {nodes.length === 0 ? ( + No steps to show. + ) : ( +
      + {nodes.map((n, i) => ( +
    1. + + {i + 1} + + {n?.label ?? n?.id ?? '(step)'} + {n?.type && ( + + {n.type} + + )} +
    2. + ))} +
    + )} + {business && hiddenCount > 0 && ( +
    + {hiddenCount} technical step{hiddenCount === 1 ? '' : 's'} hidden · set detail: technical to show all +
    + )} +
    + ); +} + +// --------------------------------------------------------------------------- +// permission — compact object-level CRUD matrix (read-only) +// --------------------------------------------------------------------------- + +const CRUD: Array<{ key: string; label: string }> = [ + { key: 'allowCreate', label: 'C' }, + { key: 'allowRead', label: 'R' }, + { key: 'allowEdit', label: 'U' }, + { key: 'allowDelete', label: 'D' }, +]; + +function PermissionView({ name }: ViewerProps) { + const { item: perm, loading, error } = useMetadataItem('permission', name ?? null); + + if (!name) return Missing name for the permission set.; + if (loading) return Loading permission set {name}; + if (error) return Failed to load permission {name}: {error.message}; + if (!perm) return Permission set {name} not found.; + + const objects: Record = perm.objects && typeof perm.objects === 'object' ? perm.objects : {}; + const entries = Object.entries(objects); + + return ( + Object access · {entries.length} object{entries.length === 1 ? '' : 's'}{perm.isProfile ? ' · profile' : ''}} + > + {entries.length === 0 ? ( + No object permissions declared. + ) : ( + + + + + {CRUD.map((c) => ( + + ))} + + + + {entries.map(([objName, perms]) => ( + + + {CRUD.map((c) => ( + + ))} + + ))} + +
    Object + {c.label} +
    {objName} + {perms?.[c.key] ? ( + + ) : ( + + )} +
    + )} +
    + ); +} + +// --------------------------------------------------------------------------- +// Dispatcher + registration +// --------------------------------------------------------------------------- + +export function ElementMetadataViewerRenderer({ schema }: { schema: any }) { + const props = readProps(schema); + switch (props.type) { + case 'state_machine': + return ; + case 'flow': + return ; + case 'permission': + return ; + default: + return ( + + Unknown metadata view type{props.type ? ` “${props.type}”` : ''}. Expected{' '} + state_machine, flow, or{' '} + permission. + + ); + } +} + +ComponentRegistry.register('element:metadata_viewer', ElementMetadataViewerRenderer, { + namespace: 'element', + label: 'Metadata Viewer', + category: 'content', +}); diff --git a/packages/plugin-markdown/src/MarkdownImpl.tsx b/packages/plugin-markdown/src/MarkdownImpl.tsx index 3b1b10bc1..42076711b 100644 --- a/packages/plugin-markdown/src/MarkdownImpl.tsx +++ b/packages/plugin-markdown/src/MarkdownImpl.tsx @@ -14,6 +14,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize" import rehypeSlug from "rehype-slug" import rehypeHighlight from "rehype-highlight" import rehypeAutolinkHeadings from "rehype-autolink-headings" +import { ComponentRegistry } from "@object-ui/core" import { ensureMarkdownStyles } from "./markdown-theme" import { Mermaid } from "./Mermaid" @@ -100,7 +101,7 @@ const rehypePlugins = [ rehypeNormalizeClassName, // Skip code blocks tagged with an unknown language instead of throwing; // highlight only what it recognises. - [rehypeHighlight, { detect: true, ignoreMissing: true, plainText: ["mermaid"] }], + [rehypeHighlight, { detect: true, ignoreMissing: true, plainText: ["mermaid", "metadata"] }], [rehypeAutolinkHeadings, { behavior: "append", properties: { className: ["md-anchor"], ariaHidden: true, tabIndex: -1 }, content: { type: "text", value: "#" } }], [rehypeSanitize, sanitizeSchema], ] as React.ComponentProps["rehypePlugins"] @@ -116,8 +117,59 @@ function nodeText(node: React.ReactNode): string { return "" } -// Intercept fenced ```mermaid blocks at the
     level (so no 
     wrapper is
    -// emitted) and render them as diagrams; every other block renders normally.
    +// ── ```metadata fence (ADR-0051) ─────────────────────────────────────────
    +// A ```metadata block is a declarative reference to a metadata item that is
    +// lifted into the live, read-only `element:metadata_viewer` SDUI component —
    +// the same component an `element:metadata_viewer` node renders on a page, so
    +// docs and pages share one runtime. The body is a flat `key: value` block
    +// (data, not code: type / name / object / mode / detail), parsed here. Like
    +// mermaid, the language is excluded from rehype-highlight (plainText above) so
    +// nodeText yields the body verbatim.
    +function parseMetadataFence(src: string): Record {
    +  const out: Record = {}
    +  for (const raw of src.split("\n")) {
    +    const line = raw.trim()
    +    if (!line || line.startsWith("#")) continue
    +    const i = line.indexOf(":")
    +    if (i < 1) continue
    +    const key = line.slice(0, i).trim()
    +    let value = line.slice(i + 1).trim()
    +    if (
    +      (value.startsWith('"') && value.endsWith('"')) ||
    +      (value.startsWith("'") && value.endsWith("'"))
    +    ) {
    +      value = value.slice(1, -1)
    +    }
    +    if (key) out[key] = value
    +  }
    +  return out
    +}
    +
    +function MetadataFence({ source }: { source: string }) {
    +  const properties = React.useMemo(() => parseMetadataFence(source), [source])
    +  const Comp = ComponentRegistry.get("element:metadata_viewer") as
    +    | React.ComponentType<{ schema: unknown }>
    +    | undefined
    +  // Degrade gracefully when the SDUI component isn't registered (e.g. a
    +  // consumer that renders Markdown without @object-ui/components loaded):
    +  // show the raw reference rather than throwing.
    +  if (!Comp) {
    +    return (
    +      
    +        {source}
    +      
    + ) + } + return ( +
    + +
    + ) +} + +// Intercept fenced ```mermaid / ```metadata blocks at the
     level (so no
    +// 
     wrapper is emitted) and render them as live views; every other block
    +// renders normally.
     const mdComponents: Components = {
       pre({ node: _node, children, ...rest }) {
         const child = React.Children.toArray(children)[0]
    @@ -129,6 +181,10 @@ const mdComponents: Components = {
           const source = nodeText((child as React.ReactElement<{ children?: React.ReactNode }>).props.children)
           return 
         }
    +    if (/\blanguage-metadata\b/.test(className)) {
    +      const source = nodeText((child as React.ReactElement<{ children?: React.ReactNode }>).props.children)
    +      return 
    +    }
         return 
    {children}
    }, }