@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
text/typescript
// 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 [];
}
}