From f58b4d703c483de4b7d5e804877f276814e87077 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Mon, 16 Mar 2026 15:33:37 +0000 Subject: [PATCH] feat(core): add export to PDF for service pages Add summary and documentation pack print views for services with: - Summary artifact: overview cards, sends/receives with type badges, version history - Documentation artifact: cover page, health scorecard, mermaid visualization, per-message detail pages with schemas/examples/producers/consumers - Sidebar navigation with page TOC, section dividers with message previews - Settings panel for toggling sections, print-optimized layouts - Fix z-index on CopyPage dropdown menu - Consolidate print color constants into shared utils Co-Authored-By: Claude Sonnet 4.5 --- .changeset/bright-rivers-flow.md | 5 + .../integrations/eventcatalog-features.ts | 9 + .../src/components/CopyAsMarkdown.tsx | 2 +- .../src/enterprise/print/_service.data.ts | 48 + .../print/components/PrintLayout.astro | 5 + .../print/components/PrintMessagesTable.astro | 70 + .../components/PrintServiceDocsLayout.astro | 425 +++ .../print/components/PrintServiceHeader.astro | 134 + .../print/components/PrintServiceLayout.astro | 290 ++ .../src/enterprise/print/service-docs.astro | 2586 +++++++++++++++++ .../src/enterprise/print/service.astro | 350 +++ .../src/enterprise/print/utils.ts | 12 + .../docs/[type]/[id]/[version]/index.astro | 4 +- 13 files changed, 3937 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-rivers-flow.md create mode 100644 packages/core/eventcatalog/src/enterprise/print/_service.data.ts create mode 100644 packages/core/eventcatalog/src/enterprise/print/components/PrintMessagesTable.astro create mode 100644 packages/core/eventcatalog/src/enterprise/print/components/PrintServiceDocsLayout.astro create mode 100644 packages/core/eventcatalog/src/enterprise/print/components/PrintServiceHeader.astro create mode 100644 packages/core/eventcatalog/src/enterprise/print/components/PrintServiceLayout.astro create mode 100644 packages/core/eventcatalog/src/enterprise/print/service-docs.astro create mode 100644 packages/core/eventcatalog/src/enterprise/print/service.astro diff --git a/.changeset/bright-rivers-flow.md b/.changeset/bright-rivers-flow.md new file mode 100644 index 000000000..b6c841e57 --- /dev/null +++ b/.changeset/bright-rivers-flow.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": minor +--- + +Add export to PDF for service pages with summary and documentation pack views diff --git a/packages/core/eventcatalog/integrations/eventcatalog-features.ts b/packages/core/eventcatalog/integrations/eventcatalog-features.ts index de1f06932..a0100f34c 100644 --- a/packages/core/eventcatalog/integrations/eventcatalog-features.ts +++ b/packages/core/eventcatalog/integrations/eventcatalog-features.ts @@ -88,6 +88,15 @@ export default function eventCatalogIntegration(): AstroIntegration { // Export to PDF print pages (Scale plan) if (isExportPDFEnabled()) { + // Service print pages must be registered before the generic [type] route + params.injectRoute({ + pattern: '/docs/print/services/[id]/[version]/docs', + entrypoint: path.join(catalogDirectory, 'src/enterprise/print/service-docs.astro'), + }); + params.injectRoute({ + pattern: '/docs/print/services/[id]/[version]', + entrypoint: path.join(catalogDirectory, 'src/enterprise/print/service.astro'), + }); params.injectRoute({ pattern: '/docs/print/[type]/[id]/[version]', entrypoint: path.join(catalogDirectory, 'src/enterprise/print/message.astro'), diff --git a/packages/core/eventcatalog/src/components/CopyAsMarkdown.tsx b/packages/core/eventcatalog/src/components/CopyAsMarkdown.tsx index 7f493adcd..f2eb7f075 100644 --- a/packages/core/eventcatalog/src/components/CopyAsMarkdown.tsx +++ b/packages/core/eventcatalog/src/components/CopyAsMarkdown.tsx @@ -258,7 +258,7 @@ export function CopyPageMenu({ {/* Adjust styling for the content dropdown */} diff --git a/packages/core/eventcatalog/src/enterprise/print/_service.data.ts b/packages/core/eventcatalog/src/enterprise/print/_service.data.ts new file mode 100644 index 000000000..928186af1 --- /dev/null +++ b/packages/core/eventcatalog/src/enterprise/print/_service.data.ts @@ -0,0 +1,48 @@ +import { isSSR } from '@utils/feature'; +import { HybridPage } from '@utils/page-loaders/hybrid-page'; +import { pageDataLoader } from '@utils/page-loaders/page-data-loader'; + +export class ServicePrintPage extends HybridPage { + static async getStaticPaths() { + if (isSSR()) { + return []; + } + + const items = await pageDataLoader['services'](); + + return items.map((item) => ({ + params: { + id: item.data.id, + version: item.data.version, + }, + props: {}, + })); + } + + protected static async fetchData(params: any) { + const { id, version } = params; + + if (!id || !version) { + return null; + } + + const items = await pageDataLoader['services'](); + const item = items.find((i) => i.data.id === id && i.data.version === version); + + if (!item) { + return null; + } + + return { + type: 'services', + ...item, + }; + } + + protected static createNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Service not found', + }); + } +} diff --git a/packages/core/eventcatalog/src/enterprise/print/components/PrintLayout.astro b/packages/core/eventcatalog/src/enterprise/print/components/PrintLayout.astro index 078e94db1..c3c0c7740 100644 --- a/packages/core/eventcatalog/src/enterprise/print/components/PrintLayout.astro +++ b/packages/core/eventcatalog/src/enterprise/print/components/PrintLayout.astro @@ -15,12 +15,17 @@ const { title, stamp } = Astro.props; + + + + + + +
+
+ + +
+ +
+ + +
+ { + stamp && ( +
+ +
+ ) + } + +
+ + + + diff --git a/packages/core/eventcatalog/src/enterprise/print/components/PrintServiceHeader.astro b/packages/core/eventcatalog/src/enterprise/print/components/PrintServiceHeader.astro new file mode 100644 index 000000000..5c6bfe42c --- /dev/null +++ b/packages/core/eventcatalog/src/enterprise/print/components/PrintServiceHeader.astro @@ -0,0 +1,134 @@ +--- +import { marked } from 'marked'; + +interface Props { + name: string; + version: string; + summary?: string; + catalogName?: string; + draft?: boolean | { title?: string; message?: string }; + deprecated?: boolean | { message?: string; date?: string | Date }; +} + +const { name, version, summary, catalogName = 'EventCatalog', draft, deprecated } = Astro.props; + +const isDraft = !!draft; +const draftMessage = typeof draft === 'object' ? draft.message : undefined; +const draftMessageHtml = draftMessage ? await marked.parse(draftMessage) : null; + +const isDeprecated = !!deprecated; +const deprecatedMessage = typeof deprecated === 'object' ? deprecated.message : undefined; +const deprecatedMessageHtml = deprecatedMessage ? await marked.parse(deprecatedMessage) : null; +const deprecatedDate = + typeof deprecated === 'object' && deprecated.date + ? new Date(deprecated.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + : undefined; + +const now = new Date(); +const exportDate = now.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', +}); +--- + +
+
+ +
+
+
+ + + + + + + + Service + + v{version} +
+

+ {name} +

+
+
+
+ {catalogName} +
+
+ {exportDate} +
+
+
+ + {summary &&

{summary}

} + + { + isDraft && ( +
+
+ This service is currently in draft + {draftMessageHtml ? ( +
+ ) : ( +

This document has not been finalized and may be subject to change.

+ )} +
+
+ ) + } + + { + isDeprecated && ( +
+ + + + + + + +
+ + This service has been deprecated + {deprecatedDate && as of {deprecatedDate}} + + {deprecatedMessageHtml ? ( +
+ ) : ( +

+ This service is no longer actively maintained and may be removed in a future version. +

+ )} +
+
+ ) + } + +
+
diff --git a/packages/core/eventcatalog/src/enterprise/print/components/PrintServiceLayout.astro b/packages/core/eventcatalog/src/enterprise/print/components/PrintServiceLayout.astro new file mode 100644 index 000000000..4c0aa1e7d --- /dev/null +++ b/packages/core/eventcatalog/src/enterprise/print/components/PrintServiceLayout.astro @@ -0,0 +1,290 @@ +--- +interface Props { + title: string; + stamp?: 'draft' | 'deprecated'; +} + +const { title, stamp } = Astro.props; + +// Build the docs artifact URL from the current path +const docsUrl = `${Astro.url.pathname.replace(/\/$/, '')}/docs`; +--- + + + + + + {title} + + + + + + + +
+
+ + +
+ +
+ + + + + + + diff --git a/packages/core/eventcatalog/src/enterprise/print/service-docs.astro b/packages/core/eventcatalog/src/enterprise/print/service-docs.astro new file mode 100644 index 000000000..f269e5fa4 --- /dev/null +++ b/packages/core/eventcatalog/src/enterprise/print/service-docs.astro @@ -0,0 +1,2586 @@ +--- +import { render } from 'astro:content'; +import PrintServiceLayout from './components/PrintServiceDocsLayout.astro'; +import PrintServiceHeader from './components/PrintServiceHeader.astro'; +import PrintSection from './components/PrintSection.astro'; +import PrintSchemaViewer from './components/PrintSchemaViewer'; +import PrintSchemaPropertiesTable from './components/PrintSchemaPropertiesTable.astro'; +import config from '@config'; +import { ServicePrintPage } from './_service.data'; +import fs from 'node:fs/promises'; +import { getAbsoluteFilePathForAstroFile, isAvroSchema } from '@utils/files'; +import components from '@components/MDX/components'; +import { getOwnerDetails } from '@utils/collections/owners'; +import { getDomainsForService } from '@utils/collections/domains'; +import { getSpecificationsForService } from '@utils/collections/services'; +import { satisfies, collectionToResourceMap, getPointerField } from '@utils/collections/util'; +import { getExamplesForResource } from '@utils/collections/examples'; +import { getEvents } from '@utils/collections/events'; +import { getCommands } from '@utils/collections/commands'; +import { getQueries } from '@utils/collections/queries'; +import { getDomains } from '@utils/collections/domains'; +import { hydrateParticipants, PRINT_DOT_COLORS as dotColors, PRINT_BADGE_COLORS as badgeColors } from './utils'; +import type { HydratedParticipant } from './utils'; +import PrintHeader from './components/PrintHeader.astro'; +import PrintParticipantsTable from './components/PrintParticipantsTable.astro'; +import yaml from 'js-yaml'; +import { getNodesAndEdges as getNodesAndEdgesForService } from '@utils/node-graphs/services-node-graph'; +import { convertToMermaid } from '@utils/node-graphs/export-mermaid'; + +export const prerender = ServicePrintPage.prerender; +export const getStaticPaths = ServicePrintPage.getStaticPaths; + +const props = await ServicePrintPage.getData(Astro); +const catalogName = config?.title || 'EventCatalog'; + +// Raw message entries for detailed rendering +const rawSends: any[] = props.data.sends || []; +const rawReceives: any[] = props.data.receives || []; + +// Mapped summaries for overview tables +const sends = rawSends.map((msg: any) => ({ + name: msg.data?.name || msg.data?.id || msg.id, + version: msg.data?.version || 'unknown', + type: (collectionToResourceMap as Record)[msg.collection] || 'message', + isLatest: msg.data?.version === (msg.data?.latestVersion || msg.data?.version), +})); + +const receives = rawReceives.map((msg: any) => ({ + name: msg.data?.name || msg.data?.id || msg.id, + version: msg.data?.version || 'unknown', + type: (collectionToResourceMap as Record)[msg.collection] || 'message', + isLatest: msg.data?.version === (msg.data?.latestVersion || msg.data?.version), +})); + +// Parallel: hydrate owners, domains, and service graph +const specifications = getSpecificationsForService(props); +const [hydratedOwners, domains, { nodes: serviceGraphNodes, edges: serviceGraphEdges }] = await Promise.all([ + getOwnerDetails(props.data.owners || []), + getDomainsForService(props), + getNodesAndEdgesForService({ id: props.data.id, version: props.data.version, mode: 'simple' }), +]); +const domainNames = domains.map((d) => d.data.name || d.data.id).join(', '); + +// Load specification contents (best-effort) +const specContents: { name: string; type: string; content: string; extension: string }[] = []; +for (const spec of specifications) { + try { + const specPath = getAbsoluteFilePathForAstroFile(props.filePath, spec.path); + const content = await fs.readFile(specPath, 'utf-8'); + const ext = specPath.split('.').pop() || ''; + specContents.push({ name: spec.name, type: spec.type, content, extension: ext }); + } catch { + // Spec loading is best-effort + } +} +const serviceMermaidDiagram = convertToMermaid(serviceGraphNodes as any, serviceGraphEdges as any, { + direction: 'LR', + includeStyles: true, +}); + +const currentPath = Astro.url.pathname; +const latestVersion = props.data.latestVersion || props.data.version; +const stamp = props.data.draft ? 'draft' : props.data.deprecated ? 'deprecated' : undefined; +const allVersions = [...new Set([props.data.version, ...(props.data.versions || [])])]; + +// Load enriched messages (with producers/consumers) from proper loaders +const [allEvents, allCommands, allQueries, allDomainsForPointers] = await Promise.all([ + getEvents(), + getCommands(), + getQueries(), + getDomains({ getAllVersions: false }), +]); +const enrichedMessageMap = new Map(); +for (const msg of [...allEvents, ...allCommands, ...allQueries]) { + const key = `${msg.collection}-${msg.data?.id}-${msg.data?.version}`; + enrichedMessageMap.set(key, msg); +} + +// Hydrate full message details for each send/receive +interface MessageDetail { + name: string; + id: string; + version: string; + type: 'event' | 'command' | 'query'; + summary?: string; + draft?: boolean | { title?: string; message?: string }; + deprecated?: boolean | { message?: string; date?: string | Date }; + Content: any; + schemaContent: string | null; + schemaFormat: string | null; + schemaExtension: string; + schemaParsed: any; + schemaIsAvro: boolean; + examples: any[]; + entry: any; + owners: Awaited>; + producers: any[]; + consumers: any[]; + producersWithOwners: HydratedParticipant[]; + consumersWithOwners: HydratedParticipant[]; + latestVersion: string; + allVersions: string[]; + versionConsumerCounts: Map; + dotColor: string; +} + +async function hydrateMessageDetail(rawMsg: any): Promise { + // Look up the enriched message (with producers/consumers) from the proper loader + const enrichKey = `${rawMsg.collection}-${rawMsg.data?.id}-${rawMsg.data?.version}`; + const msg = enrichedMessageMap.get(enrichKey) || rawMsg; + + const msgType = (collectionToResourceMap as Record)[msg.collection] as 'event' | 'command' | 'query'; + const { Content } = await render(msg); + + // Schema + let schemaContent: string | null = null; + let schemaFormat: string | null = null; + let schemaExtension = 'json'; + let schemaParsed: any = null; + let schemaIsAvro = false; + + try { + if (msg.data.schemaPath) { + const schemaPath = getAbsoluteFilePathForAstroFile(msg.filePath, msg.data.schemaPath); + schemaContent = await fs.readFile(schemaPath, 'utf-8'); + const ext = schemaPath.split('.').pop() || ''; + schemaExtension = ext; + schemaIsAvro = isAvroSchema(schemaPath); + schemaFormat = + ext === 'json' ? 'JSON' : ext === 'avsc' ? 'Avro' : ext === 'yaml' || ext === 'yml' ? 'YAML' : ext.toUpperCase(); + + try { + if (ext === 'yaml' || ext === 'yml') { + schemaParsed = yaml.load(schemaContent); + } else { + schemaParsed = JSON.parse(schemaContent); + } + } catch {} + + if (schemaParsed && (ext === 'json' || ext === 'avsc')) { + schemaContent = JSON.stringify(schemaParsed, null, 2); + } + } + } catch {} + + // Examples + const examples = getExamplesForResource(msg); + + // Owners + const msgOwners = await getOwnerDetails(msg.data.owners || []); + + // Producers and consumers + const msgProducers = msg.data.producers || []; + const msgConsumers = msg.data.consumers || []; + const messageId = msg.data?.id || msg.id; + const msgLatestVersion = msg.data.latestVersion || msg.data.version; + + const [producersWithOwners, consumersWithOwners] = await Promise.all([ + hydrateParticipants(msgProducers, 'sends', messageId, msgLatestVersion), + hydrateParticipants(msgConsumers, 'receives', messageId, msgLatestVersion), + ]); + + // Version history with consumer counts + const allVersions = [...new Set([msg.data.version, ...(msg.data.versions || [])])]; + const sortedVersions = [...allVersions].sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + + const versionConsumerCounts = new Map(); + for (const v of allVersions) versionConsumerCounts.set(v, 0); + + const consumerPointers: { id: string; version?: string }[] = []; + for (const c of msgConsumers) { + const field = getPointerField(c.collection, 'receives'); + for (const pointer of c.data?.[field] || []) { + if (pointer.id === messageId) consumerPointers.push(pointer); + } + } + for (const domain of allDomainsForPointers) { + for (const pointer of (domain.data as any).receives || []) { + if (pointer.id === messageId) consumerPointers.push(pointer); + } + } + for (const pointer of consumerPointers) { + let matched: string | undefined; + if (!pointer.version || pointer.version === 'latest') { + matched = msgLatestVersion; + } else { + matched = sortedVersions.find((v) => satisfies(v, pointer.version as string)); + } + if (matched && versionConsumerCounts.has(matched)) { + versionConsumerCounts.set(matched, (versionConsumerCounts.get(matched) || 0) + 1); + } + } + + const dotColor = msgType === 'event' ? '#ea580c' : msgType === 'command' ? '#2563eb' : '#16a34a'; + + return { + name: msg.data?.name || msg.data?.id || msg.id, + id: messageId, + version: msg.data?.version || 'unknown', + type: msgType, + summary: msg.data?.summary, + draft: msg.data?.draft, + deprecated: msg.data?.deprecated, + Content, + schemaContent, + schemaFormat, + schemaExtension, + schemaParsed, + schemaIsAvro, + examples, + entry: msg, + owners: msgOwners, + producers: msgProducers, + consumers: msgConsumers, + producersWithOwners, + consumersWithOwners, + latestVersion: msgLatestVersion, + allVersions, + versionConsumerCounts, + dotColor, + }; +} + +// Hydrate sends and receives separately (deduplicate within each group) +const seenSends = new Set(); +const uniqueSends: any[] = []; +for (const msg of rawSends) { + const key = `${msg.collection}-${msg.data?.id}-${msg.data?.version}`; + if (!seenSends.has(key)) { + seenSends.add(key); + uniqueSends.push(msg); + } +} + +const seenReceives = new Set(); +const uniqueReceives: any[] = []; +for (const msg of rawReceives) { + const key = `${msg.collection}-${msg.data?.id}-${msg.data?.version}`; + if (!seenReceives.has(key)) { + seenReceives.add(key); + uniqueReceives.push(msg); + } +} + +const sendDetails = await Promise.all(uniqueSends.map(hydrateMessageDetail)); +const receiveDetails = await Promise.all(uniqueReceives.map(hydrateMessageDetail)); +const messageDetails = [...sendDetails, ...receiveDetails]; + +const exportDate = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', +}); + +// Compute service health signals +interface HealthSignal { + label: string; + status: 'good' | 'warning' | 'missing'; + detail: string; + missingLabel?: string; + missingItems?: string[]; +} + +const messagesWithSchemas = messageDetails.filter((m) => m.schemaContent); +const messagesWithoutSchemas = messageDetails.filter((m) => !m.schemaContent); +const messagesWithExamples = messageDetails.filter((m) => m.examples.length > 0); +const messagesWithoutExamples = messageDetails.filter((m) => m.examples.length === 0); + +// Architecture signals +const unusedSends = sendDetails.filter((m) => m.consumers.length === 0); +const highImpactThreshold = 3; +const highImpactSends = sendDetails.filter((m) => m.consumers.length >= highImpactThreshold); +const draftMessages = messageDetails.filter((m) => m.draft); +const deprecatedMessages = messageDetails.filter((m) => m.deprecated); + +// Documentation signals (included in score) +const docSignals: HealthSignal[] = [ + { + label: 'Ownership', + status: hydratedOwners.length > 0 ? 'good' : 'missing', + detail: + hydratedOwners.length > 0 + ? `${hydratedOwners.length} owner${hydratedOwners.length !== 1 ? 's' : ''} assigned` + : 'No owners assigned', + }, + { + label: 'Message Schemas', + status: + messageDetails.length === 0 + ? 'warning' + : messagesWithSchemas.length === messageDetails.length + ? 'good' + : messagesWithSchemas.length > 0 + ? 'warning' + : 'missing', + detail: + messageDetails.length === 0 + ? 'No messages defined' + : `${messagesWithSchemas.length} of ${messageDetails.length} messages have schemas`, + missingLabel: messagesWithoutSchemas.length > 0 ? 'Missing schema' : undefined, + missingItems: messagesWithoutSchemas.length > 0 ? messagesWithoutSchemas.map((m) => m.name) : undefined, + }, + { + label: 'Examples', + status: + messageDetails.length === 0 + ? 'warning' + : messagesWithExamples.length === messageDetails.length + ? 'good' + : messagesWithExamples.length > 0 + ? 'warning' + : 'missing', + detail: + messageDetails.length === 0 + ? 'No messages defined' + : `${messagesWithExamples.length} of ${messageDetails.length} messages have examples`, + missingLabel: messagesWithoutExamples.length > 0 ? 'Missing examples' : undefined, + missingItems: messagesWithoutExamples.length > 0 ? messagesWithoutExamples.map((m) => m.name) : undefined, + }, + { + label: 'Specifications', + status: specContents.length > 0 ? 'good' : 'missing', + detail: + specContents.length > 0 + ? `${specContents.length} spec${specContents.length !== 1 ? 's' : ''} (${specContents.map((s) => s.type).join(', ')})` + : 'No specifications attached', + }, + { + label: 'Lifecycle', + status: props.data.deprecated ? 'warning' : props.data.draft ? 'warning' : 'good', + detail: props.data.deprecated ? 'Deprecated' : props.data.draft ? 'Draft' : 'Active', + }, + { + label: 'Versioning', + status: allVersions.length > 1 ? 'good' : 'warning', + detail: `${allVersions.length} version${allVersions.length !== 1 ? 's' : ''} tracked`, + }, +]; + +// Architecture signals (warnings/insights, not scored) +const archSignals: HealthSignal[] = [ + { + label: 'Unused Messages', + status: + sendDetails.length === 0 + ? 'good' + : unusedSends.length === 0 + ? 'good' + : unusedSends.length === sendDetails.length + ? 'missing' + : 'warning', + detail: + sendDetails.length === 0 + ? 'No messages sent' + : unusedSends.length === 0 + ? `All ${sendDetails.length} sent messages have consumers` + : `${unusedSends.length} message${unusedSends.length !== 1 ? 's have' : ' has'} no consumers`, + missingLabel: unusedSends.length > 0 ? 'No consumers' : undefined, + missingItems: unusedSends.length > 0 ? unusedSends.map((m) => m.name) : undefined, + }, + { + label: 'Consumer Impact', + status: highImpactSends.length > 0 ? 'warning' : 'good', + detail: + highImpactSends.length > 0 + ? `${highImpactSends.length} high-impact message${highImpactSends.length !== 1 ? 's' : ''} — changes may affect multiple consumers` + : 'No high-impact messages detected', + missingLabel: highImpactSends.length > 0 ? 'Be careful changing' : undefined, + missingItems: + highImpactSends.length > 0 ? highImpactSends.map((m) => `${m.name} (${m.consumers.length} consumers)`) : undefined, + }, + { + label: 'Lifecycle Risk', + status: + draftMessages.length === 0 && deprecatedMessages.length === 0 + ? 'good' + : deprecatedMessages.length > 0 + ? 'warning' + : 'warning', + detail: + draftMessages.length === 0 && deprecatedMessages.length === 0 + ? 'All messages are active and stable' + : [ + draftMessages.length > 0 ? `${draftMessages.length} still in draft` : '', + deprecatedMessages.length > 0 ? `${deprecatedMessages.length} deprecated` : '', + ] + .filter(Boolean) + .join(', '), + missingLabel: + draftMessages.length > 0 && deprecatedMessages.length > 0 + ? 'Needs attention' + : draftMessages.length > 0 + ? 'Still in draft' + : deprecatedMessages.length > 0 + ? 'Deprecated' + : undefined, + missingItems: + draftMessages.length > 0 || deprecatedMessages.length > 0 + ? [...draftMessages.map((m) => m.name), ...deprecatedMessages.map((m) => m.name)] + : undefined, + }, + { + label: 'Dependencies', + status: sends.length > 0 || receives.length > 0 ? 'good' : 'missing', + detail: + sends.length > 0 || receives.length > 0 ? `${sends.length} sent, ${receives.length} received` : 'No messages documented', + }, +]; + +// Metadata (not scored) +const metadataItems = [ + { label: 'Domain', value: domains.length > 0 ? domainNames : 'Not assigned' }, + { label: 'Current Version', value: `v${props.data.version}` }, + { label: 'Total Versions', value: `${allVersions.length}` }, +]; + +// Score based only on documentation signals +const docGoodCount = docSignals.filter((s) => s.status === 'good').length; +const healthScore = Math.round((docGoodCount / docSignals.length) * 100); +const healthGoodCount = docGoodCount; +const healthSignals = [...docSignals, ...archSignals]; + +// Precompute type breakdowns for divider pages +const sendTypeCounts = Array.from( + sendDetails.reduce((map, m) => map.set(m.type, (map.get(m.type) || 0) + 1), new Map()), + ([type, count]) => ({ type, count, color: dotColors[type] || '#6b7280' }) +); +const receiveTypeCounts = Array.from( + receiveDetails.reduce((map, m) => map.set(m.type, (map.get(m.type) || 0) + 1), new Map()), + ([type, count]) => ({ type, count, color: dotColors[type] || '#6b7280' }) +); +--- + + + +
+ +
+
+ + + + +
+
+

{props.data.name}

+ v{props.data.version} +
+

{props.data.summary}

+ +
+ { + hydratedOwners.length > 0 && ( + + Owners: {hydratedOwners.map((o) => o.name).join(', ')} + + ) + } + { + domainNames && ( + + Domain: {domainNames} + + ) + } +
+ +
+ + +
+
+
+
+ + + + +
+ + + + + + + + { + messageDetails.map((msg, msgIndex) => { + const badge = badgeColors[msg.type] || ''; + const dotColor = dotColors[msg.type] || '#6b7280'; + const hasSchema = !!msg.schemaContent; + const hasExamples = msg.examples.length > 0; + return ( + + ); + }) + } + { + (messagesWithSchemas.length > 0 || specContents.length > 0) && ( + + ) + } +
+
+ + + + + + + + + + + + + + + + + +