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