UNPKG

@eventcatalogtest/studio

Version:

A drag and drop UI for distributed systems that keeps your diagrams where they belong – in your repo

315 lines (275 loc) 9.77 kB
// lib/content-loader.ts import fg from 'fast-glob'; import fs from 'fs/promises'; import matter from 'gray-matter'; import { z } from 'zod'; import path from 'path'; // Shared schemas used across multiple files export const badge = z.object({ content: z.string(), backgroundColor: z.string(), textColor: z.string(), icon: z.string().optional(), }); // Create a union type for owners export const ownerReference = z .union([ // The ID of the user or team z.string(), // The full object with the ID and collection (keep compatibility with `reference`) z.object({ id: z.string(), collection: z.enum(['users', 'teams']), }), ]) .transform( // This transformation is needed to keep compatibility with `reference`. // The utilities `getTeams` and `getUsers` rely on this transformation. (lookup) => ({ id: typeof lookup === 'string' ? lookup : lookup.id }) ); const resourcePointer = z.object({ id: z.string(), version: z.string().optional().default('latest'), type: z.enum(['service', 'event', 'command', 'query', 'flow', 'channel', 'domain', 'user', 'team']), }); const pointer = z.object({ id: z.string(), version: z.string().optional().default('latest'), }); const baseSchema = z.object({ id: z.string(), name: z.string(), summary: z.string().optional(), version: z.string(), draft: z.union([z.boolean(), z.object({ title: z.string().optional(), message: z.string() })]).optional(), badges: z.array(badge).optional(), owners: z.array(ownerReference).optional(), schemaPath: z.string().optional(), sidebar: z .object({ label: z.string().optional(), badge: z.string().optional(), }) .optional(), repository: z .object({ language: z.string().optional(), url: z.string().optional(), }) .optional(), specifications: z .union([ z.object({ openapiPath: z.string().optional(), asyncapiPath: z.string().optional(), }), z.array( z.object({ type: z.enum(['openapi', 'asyncapi']), path: z.string(), name: z.string().optional(), }) ), ]) .optional(), hidden: z.boolean().optional(), editUrl: z.string().optional(), resourceGroups: z .array( z.object({ id: z.string().optional(), title: z.string().optional(), items: z.array(resourcePointer), limit: z.number().optional().default(10), sidebar: z.boolean().optional().default(true), }) ) .optional(), styles: z .object({ icon: z.string().optional(), node: z .object({ color: z.string().optional(), label: z.string().optional(), }) .optional(), }) .optional(), deprecated: z .union([ z.object({ message: z.string().optional(), date: z.union([z.string(), z.date()]).optional(), }), z.boolean().optional(), ]) .optional(), visualiser: z.boolean().optional(), // Used by eventcatalog versions: z.array(z.string()).optional(), latestVersion: z.string().optional(), catalog: z .object({ path: z.string(), filePath: z.string(), astroContentFilePath: z.string(), publicPath: z.string(), type: z.string(), }) .optional(), }); const domainSchema = baseSchema.extend({ services: z.array(pointer).optional(), domains: z.array(pointer).optional(), entities: z.array(pointer).optional(), }); const messageSchema = baseSchema.extend({}); const serviceSchema = baseSchema.extend({ sends: z.array(pointer).optional(), receives: z.array(pointer).optional(), entities: z.array(pointer).optional(), }); export async function getAllEventCatalogResources() { const eventCatalogDir = await getEventCatalogPath(); const events = await fg(['**/events/*/index.(md|mdx)', '**/events/*/versioned/*/index.(md|mdx)'], { cwd: eventCatalogDir, absolute: true, }).then(paths => paths.map(path => ({ path, collection: 'events', schema: messageSchema, editor: { type: 'event', } }))); const commands = await fg(['**/commands/*/index.(md|mdx)', '**/commands/*/versioned/*/index.(md|mdx)'], { cwd: eventCatalogDir, absolute: true, }).then(paths => paths.map(path => ({ path, collection: 'commands', schema: messageSchema, editor: { type: 'command', } }))); const queries = await fg(['**/queries/*/index.(md|mdx)', '**/queries/*/versioned/*/index.(md|mdx)'], { cwd: eventCatalogDir, absolute: true, }).then(paths => paths.map(path => ({ path, collection: 'queries', schema: messageSchema, editor: { type: 'query', } }))); const services = await fg([ 'domains/*/services/*/index.(md|mdx)', 'domains/*/services/*/versioned/*/index.(md|mdx)', // Capture subdomain folders 'domains/*/subdomains/*/services/*/index.(md|mdx)', 'domains/*/subdomains/*/services/*/versioned/*/index.(md|mdx)', // Capture services in the root 'services/*/index.(md|mdx)', // ✅ Capture only services markdown files 'services/*/versioned/*/index.(md|mdx)', // ✅ Capture versioned files inside services ], { cwd: eventCatalogDir, absolute: true, }).then(paths => paths.map(path => ({ path, collection: 'services', schema: serviceSchema, editor: { type: 'service', } }))); const allResources = [...events, ...commands, ...queries, ...services]; const resources = await Promise.all( allResources.map(async ({ path, collection, schema, editor }) => { const raw = await fs.readFile(path, 'utf-8'); const { data, content } = matter(raw); const parsed = schema.safeParse(data); if (!parsed.success) { throw new Error(`Invalid frontmatter in ${path}`); } return { id: parsed.data.id, data: { ...parsed.data, }, editor, // body: content, collection, filePath: path, }; }) ); return resources; } export async function getStudioFilesFromEventCatalogDirectory(): Promise<{ id: string, creationDate: string }[]> { const eventCatalogDir = await getEventCatalogPath(); const studioFiles = await fg(['**/*/*.ecstudio'], { cwd: eventCatalogDir, absolute: true, }) const parsedStudioFiles = await Promise.all(studioFiles.map(async (path) => { try { const content = await fs.readFile(path, 'utf-8'); const parsed = JSON.parse(content); return { id: parsed.id, creationDate: parsed.creationDate, } } catch (error) { console.error(`Error parsing ${path}:`, error); return null; } })); return parsedStudioFiles.filter(Boolean) as { id: string, creationDate: string }[]; } export async function getStudioFileFromEventCatalogDirectoryById(id: string): Promise<any | null> { const eventCatalogDir = await getEventCatalogPath(); const studioFiles = await fg(['**/*/*.ecstudio'], { cwd: eventCatalogDir, absolute: true, }) const parsedStudioFiles = await Promise.all(studioFiles.map(async (path) => { try { const content = await fs.readFile(path, 'utf-8'); const parsed = JSON.parse(content); return parsed } catch (error) { console.error(`Error parsing ${path}:`, error); return null; } })); const studioFile = parsedStudioFiles.find(file => file.id === id); if (!studioFile) { return null; } return studioFile; } export async function getEventCatalogPath(): Promise<string> { const eventCatalogDir = process.env.EVENTCATALOG_DIR || path.join(process.cwd(), 'eventcatalog'); return eventCatalogDir; } export async function getStudioTemplates(): Promise<any[]> { const templateDir = process.env.TEMPLATE_DIR || process.cwd(); try { const templateFiles = await fg(['**/*/*.ectemplate'], { cwd: templateDir, absolute: true, }); const templates = await Promise.all( templateFiles.map(async (filePath) => { try { const content = await fs.readFile(filePath, 'utf-8'); const template = JSON.parse(content); const filename = path.basename(filePath, '.ectemplate'); return { id: filename, ...template, filePath }; } catch (error) { console.error(`Error parsing template ${filePath}:`, error); return null; } }) ); return templates.filter(Boolean); } catch (error) { console.error('Error loading templates:', error); return []; } }