diff --git a/packages/agilitycms/api.test.ts b/packages/agilitycms/api.test.ts new file mode 100644 index 000000000..efa1a7543 --- /dev/null +++ b/packages/agilitycms/api.test.ts @@ -0,0 +1,507 @@ +import { logEventFromContext } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { makeAgilityCmsRequest } from './client'; +import { + ContentItemSchema, + ContentModelSchema, + PageModuleSchema, + PageSchema, + SitemapNodeSchema, + SyncItemSchema, + SyncPageSchema, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import type { AgilityCmsContext } from './index'; +import { agilitycms, agilitycmsEndpointSchemas } from './index'; + +jest.mock('corsair/core', () => { + class AuthMissingError extends Error { + constructor(plugin: string, authType: string) { + super(`Missing ${authType} for ${plugin}`); + this.name = 'AuthMissingError'; + } + } + return { + AuthMissingError, + logEventFromContext: jest.fn(), + }; +}); + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; +const mockLog = jest.mocked(logEventFromContext); + +function countLeaves(tree: Record): number { + return Object.values(tree).reduce((count, value) => { + if (typeof value === 'function') return count + 1; + if (value && typeof value === 'object') { + return count + countLeaves(value as Record); + } + return count; + }, 0); +} + +function endpointPaths(tree: Record, prefix = ''): string[] { + return Object.entries(tree).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'function') return [path]; + if (value && typeof value === 'object') { + return endpointPaths(value as Record, path); + } + return []; + }); +} + +const mockCtx = { + key: 'test-api-key', + $getAccountId: () => 'test-account-id', + options: {}, + logEvent: jest.fn(), + db: {}, + keyBuilder: async () => 'test-api-key', +} as unknown as AgilityCmsContext; + +const contentItemFixture = { + contentID: 101, + properties: { + state: 2, + modified: '2023-01-01T00:00:00.000Z', + versionID: 1, + referenceName: 'posts', + definitionName: 'Post', + itemOrder: 1, + }, + fields: { + title: 'Hello World', + }, +}; + +const pageFixture = { + pageID: 2, + name: 'home', + path: '/', + title: 'Home Page', + templateName: 'MainTemplate', + zones: { + MainZone: [ + { + module: 'Hero', + item: { contentID: 101 }, + }, + ], + }, +}; + +const contentModelFixture = { + id: 50, + referenceName: 'posts', + displayName: 'Blog Post', + description: 'Model for blog articles', + fields: [{ name: 'title', type: 'Text' }], +}; + +const pageModuleFixture = { + id: 60, + displayName: 'Hero Banner', + description: 'Top banner module', + fields: [{ name: 'heading', type: 'Text' }], +}; + +const sitemapNodeFixture = { + pageID: 2, + title: 'Home', + menuText: 'Home', + path: '/', + visible: { menu: true, sitemap: true }, +}; + +const syncItemFixture = { + contentID: 101, + properties: { state: 2 }, + fields: { title: 'Updated' }, +}; + +const syncPageFixture = { + pageID: 2, + name: 'home', + title: 'Home', + templateName: 'Main', +}; + +function classify(error: Error): string { + const name = ( + Object.keys(errorHandlers) as Array + ).find((key) => errorHandlers[key].match(error)); + return name ?? 'none'; +} + +function httpError(status: number, message: string): ApiError { + return new ApiError( + { method: 'GET', url: 'https://api.aglty.io/guid/fetch/en-us/models' }, + { + url: 'https://api.aglty.io/guid/fetch/en-us/models', + ok: false, + status, + statusText: 'Error', + body: { message }, + }, + message, + ); +} + +describe('Agility CMS Zod Schemas', () => { + it('validates ContentItemSchema and rejects invalid', () => { + const parsed = ContentItemSchema.safeParse(contentItemFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.contentID).toBe(101); + expect(parsed.data.fields.title).toBe('Hello World'); + } + expect(ContentItemSchema.safeParse({ contentID: 'invalid' }).success).toBe( + false, + ); + }); + + it('validates PageSchema and rejects invalid', () => { + const parsed = PageSchema.safeParse(pageFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.pageID).toBe(2); + expect(parsed.data.name).toBe('home'); + } + expect(PageSchema.safeParse({ pageID: 'invalid' }).success).toBe(false); + }); + + it('validates ContentModelSchema and rejects invalid', () => { + const parsed = ContentModelSchema.safeParse(contentModelFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.id).toBe(50); + expect(parsed.data.displayName).toBe('Blog Post'); + } + expect(ContentModelSchema.safeParse({ id: 'invalid' }).success).toBe(false); + }); + + it('validates PageModuleSchema and rejects invalid', () => { + const parsed = PageModuleSchema.safeParse(pageModuleFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.id).toBe(60); + expect(parsed.data.displayName).toBe('Hero Banner'); + } + expect(PageModuleSchema.safeParse({ id: 'invalid' }).success).toBe(false); + }); + + it('validates SitemapNodeSchema and rejects invalid', () => { + const parsed = SitemapNodeSchema.safeParse(sitemapNodeFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.pageID).toBe(2); + expect(parsed.data.path).toBe('/'); + } + expect(SitemapNodeSchema.safeParse({ pageID: 'invalid' }).success).toBe( + false, + ); + }); + + it('validates SyncItemSchema and rejects invalid', () => { + const parsed = SyncItemSchema.safeParse(syncItemFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.contentID).toBe(101); + } + expect(SyncItemSchema.safeParse({ contentID: 'invalid' }).success).toBe( + false, + ); + }); + + it('validates SyncPageSchema and rejects invalid', () => { + const parsed = SyncPageSchema.safeParse(syncPageFixture); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.pageID).toBe(2); + } + expect(SyncPageSchema.safeParse({ pageID: 'invalid' }).success).toBe(false); + }); +}); + +describe('Agility CMS plugin structure', () => { + it('exposes all 9 operations with schemas and no webhooks', () => { + const plugin = agilitycms(); + const endpoints = plugin.endpoints as Record; + const paths = endpointPaths(endpoints).sort(); + + expect(countLeaves(endpoints)).toBe(9); + expect(Object.keys(plugin.endpointMeta ?? {}).sort()).toEqual(paths); + expect(Object.keys(agilitycmsEndpointSchemas).sort()).toEqual(paths); + expect(plugin.webhooks).toEqual({}); + expect(typeof plugin.pluginWebhookMatcher).toBe('function'); + }); + + it('supports api key auth configuration', () => { + const plugin = agilitycms(); + expect(plugin.options?.authType).toBe('api_key'); + expect(plugin.authConfig).toEqual({ + api_key: { account: ['tenant_external_id'] }, + }); + }); +}); + +describe('Agility CMS request client', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue(contentItemFixture); + }); + + it('sends APIKey header and constructs correct URL path', async () => { + await makeAgilityCmsRequest( + 'test-guid', + 'test-api-key', + 'fetch', + 'en-us/item/101', + ); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://api.aglty.io/test-guid/fetch', + HEADERS: expect.objectContaining({ + APIKey: 'test-api-key', + Accept: 'application/json', + }), + }), + expect.objectContaining({ + method: 'GET', + url: 'en-us/item/101', + }), + ); + }); + + it('passes through ApiError directly for rate limit and auth handlers', async () => { + mockRequest.mockRejectedValue(httpError(429, 'Rate limited')); + + await expect( + makeAgilityCmsRequest( + 'test-guid', + 'test-api-key', + 'fetch', + 'en-us/item/101', + ), + ).rejects.toBeInstanceOf(ApiError); + }); +}); + +describe('Agility CMS endpoint handlers', () => { + const plugin = agilitycms({ key: 'test-api-key' }); + const endpoints = plugin.endpoints as any; + + beforeEach(() => { + mockRequest.mockReset(); + mockLog.mockReset(); + }); + + it('executes content.getPage', async () => { + mockRequest.mockResolvedValueOnce(pageFixture); + const result = await endpoints.content.getPage(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + pageId: 2, + apiType: 'fetch', + }); + expect(result).toEqual(pageFixture); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/page/2', + }), + ); + }); + + it('executes content.getItem', async () => { + mockRequest.mockResolvedValueOnce(contentItemFixture); + const result = await endpoints.content.getItem(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + contentId: 101, + apiType: 'fetch', + }); + expect(result).toEqual(contentItemFixture); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/item/101', + }), + ); + }); + + it('executes content.getList with pagination query', async () => { + const listResponse = { totalCount: 1, items: [contentItemFixture] }; + mockRequest.mockResolvedValueOnce(listResponse); + const result = await endpoints.content.getList(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + referenceName: 'posts', + take: 20, + skip: 0, + sort: 'properties.modified', + direction: 'desc', + apiType: 'fetch', + }); + expect(result).toEqual(listResponse); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/list/posts', + query: { + take: 20, + skip: 0, + sort: 'properties.modified', + direction: 'desc', + }, + }), + ); + }); + + it('executes content.getContentModels', async () => { + mockRequest.mockResolvedValueOnce([contentModelFixture]); + const result = await endpoints.content.getContentModels(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + apiType: 'fetch', + }); + expect(result).toEqual([contentModelFixture]); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/models', + }), + ); + }); + + it('executes content.getPageModules', async () => { + mockRequest.mockResolvedValueOnce([pageModuleFixture]); + const result = await endpoints.content.getPageModules(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + apiType: 'fetch', + }); + expect(result).toEqual([pageModuleFixture]); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/models', + }), + ); + }); + + it('executes content.getSitemapFlat', async () => { + const sitemapResponse = { '/': sitemapNodeFixture }; + mockRequest.mockResolvedValueOnce(sitemapResponse); + const result = await endpoints.content.getSitemapFlat(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + channelName: 'website', + apiType: 'fetch', + }); + expect(result).toEqual(sitemapResponse); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/sitemap/flat/website', + }), + ); + }); + + it('executes content.getLogs', async () => { + const logsResponse = { syncToken: '12345', items: [syncItemFixture] }; + mockRequest.mockResolvedValueOnce(logsResponse); + const result = await endpoints.content.getLogs(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + syncToken: '0', + pageSize: 100, + apiType: 'fetch', + }); + expect(result).toEqual(logsResponse); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/sync/items', + query: { syncToken: '0', pageSize: 100 }, + }), + ); + }); + + it('executes content.syncPages', async () => { + const syncResponse = { syncToken: '54321', items: [syncPageFixture] }; + mockRequest.mockResolvedValueOnce(syncResponse); + const result = await endpoints.content.syncPages(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + syncToken: '0', + pageSize: 50, + apiType: 'fetch', + }); + expect(result).toEqual(syncResponse); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/sync/pages', + query: { syncToken: '0', pageSize: 50 }, + }), + ); + }); + + it('executes content.getApiTypes', async () => { + const typesResponse = { ContentStates: ['Staged', 'Published'] }; + mockRequest.mockResolvedValueOnce(typesResponse); + const result = await endpoints.content.getApiTypes(mockCtx, { + instanceGuid: 'guid-123', + locale: 'en-us', + apiType: 'fetch', + }); + expect(result).toEqual(typesResponse); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'en-us/types', + }), + ); + }); +}); + +describe('Agility CMS error handler classification', () => { + it('classifies auth, rate-limit, and default errors', () => { + expect(classify(httpError(401, 'Unauthorized'))).toBe('AUTH_ERROR'); + expect(classify(httpError(429, 'Rate limit exceeded'))).toBe( + 'RATE_LIMIT_ERROR', + ); + expect(classify(httpError(500, 'Server Error'))).toBe('DEFAULT'); + }); + + it('does not retry auth errors and retries rate limit errors', async () => { + expect((await errorHandlers.AUTH_ERROR.handler()).maxRetries).toBe(0); + expect( + ( + await errorHandlers.RATE_LIMIT_ERROR.handler( + httpError(429, 'Too many requests'), + ) + ).maxRetries, + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/agilitycms/client.ts b/packages/agilitycms/client.ts new file mode 100644 index 000000000..4693a264d --- /dev/null +++ b/packages/agilitycms/client.ts @@ -0,0 +1,44 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { request } from 'corsair/http'; + +export async function makeAgilityCmsRequest( + instanceGuid: string, + apiKey: string, + apiType: 'fetch' | 'preview' = 'fetch', + endpoint: string, + options: { + method?: 'GET' | 'POST'; + query?: Record; + body?: Record; + apiBaseUrl?: string; + } = {}, +): Promise { + const { + method = 'GET', + query, + body, + apiBaseUrl = 'https://api.aglty.io', + } = options; + + const config: OpenAPIConfig = { + BASE: `${apiBaseUrl.replace(/\/$/, '')}/${instanceGuid}/${apiType}`, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + Accept: 'application/json', + APIKey: apiKey, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + mediaType: 'application/json', + query, + body, + }; + + return await request(config, requestOptions); +} diff --git a/packages/agilitycms/endpoints/content.ts b/packages/agilitycms/endpoints/content.ts new file mode 100644 index 000000000..f067f0227 --- /dev/null +++ b/packages/agilitycms/endpoints/content.ts @@ -0,0 +1,306 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAgilityCmsRequest } from '../client'; +import type { AgilityCmsContext } from '../index'; +import type { + GetApiTypesInput, + GetApiTypesResponse, + GetContentModelsInput, + GetContentModelsResponse, + GetItemInput, + GetItemResponse, + GetListInput, + GetListResponse, + GetLogsInput, + GetLogsResponse, + GetPageInput, + GetPageModulesInput, + GetPageModulesResponse, + GetPageResponse, + GetSitemapFlatInput, + GetSitemapFlatResponse, + PageModule, + SyncPagesInput, + SyncPagesResponse, +} from './types'; +import { + GetApiTypesResponseSchema, + GetContentModelsResponseSchema, + GetItemResponseSchema, + GetListResponseSchema, + GetLogsResponseSchema, + GetPageModulesResponseSchema, + GetPageResponseSchema, + GetSitemapFlatResponseSchema, + SyncPagesResponseSchema, +} from './types'; + +function compactQuery( + query: Record, +): Record { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined), + ) as Record; +} + +export const getPage = async ( + ctx: AgilityCmsContext, + input: GetPageInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/page/${input.pageId}`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + query: compactQuery({ + contentLinkDepth: input.contentLinkDepth, + expandAllContentLinks: input.expandAllContentLinks, + }), + }, + ); + + const parsed = GetPageResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getPage', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getItem = async ( + ctx: AgilityCmsContext, + input: GetItemInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/item/${input.contentId}`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + query: compactQuery({ + contentLinkDepth: input.contentLinkDepth, + expandAllContentLinks: input.expandAllContentLinks, + }), + }, + ); + + const parsed = GetItemResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getItem', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getList = async ( + ctx: AgilityCmsContext, + input: GetListInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/list/${input.referenceName}`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + query: compactQuery({ + contentLinkDepth: input.contentLinkDepth, + expandAllContentLinks: input.expandAllContentLinks, + take: input.take, + skip: input.skip, + sort: input.sort, + direction: input.direction, + filter: input.filter, + }), + }, + ); + + const parsed = GetListResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getList', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getContentModels = async ( + ctx: AgilityCmsContext, + input: GetContentModelsInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/models`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + }, + ); + + const parsed = GetContentModelsResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getContentModels', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getPageModules = async ( + ctx: AgilityCmsContext, + input: GetPageModulesInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/models`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + }, + ); + + const filtered = (Array.isArray(response) ? response : []).filter( + (item) => !item.referenceName || item.referenceName.trim() === '', + ); + + const parsed = GetPageModulesResponseSchema.parse(filtered); + + await logEventFromContext( + ctx, + 'agilitycms.content.getPageModules', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getSitemapFlat = async ( + ctx: AgilityCmsContext, + input: GetSitemapFlatInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/sitemap/flat/${input.channelName}`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + }, + ); + + const parsed = GetSitemapFlatResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getSitemapFlat', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getLogs = async ( + ctx: AgilityCmsContext, + input: GetLogsInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/sync/items`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + query: compactQuery({ + syncToken: input.syncToken ?? '0', + pageSize: input.pageSize, + }), + }, + ); + + const parsed = GetLogsResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getLogs', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const syncPages = async ( + ctx: AgilityCmsContext, + input: SyncPagesInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale}/sync/pages`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + query: compactQuery({ + syncToken: input.syncToken ?? '0', + pageSize: input.pageSize, + }), + }, + ); + + const parsed = SyncPagesResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.syncPages', + { ...input }, + 'completed', + ); + + return parsed; +}; + +export const getApiTypes = async ( + ctx: AgilityCmsContext, + input: GetApiTypesInput, +): Promise => { + const response = await makeAgilityCmsRequest( + input.instanceGuid, + ctx.key, + input.apiType, + `${input.locale ?? 'en-us'}/types`, + { + apiBaseUrl: ctx.options?.apiBaseUrl, + }, + ); + + const parsed = GetApiTypesResponseSchema.parse(response); + + await logEventFromContext( + ctx, + 'agilitycms.content.getApiTypes', + { ...input }, + 'completed', + ); + + return parsed; +}; diff --git a/packages/agilitycms/endpoints/index.ts b/packages/agilitycms/endpoints/index.ts new file mode 100644 index 000000000..326a334e9 --- /dev/null +++ b/packages/agilitycms/endpoints/index.ts @@ -0,0 +1 @@ +export * as Content from './content'; diff --git a/packages/agilitycms/endpoints/types.ts b/packages/agilitycms/endpoints/types.ts new file mode 100644 index 000000000..8bbcc8486 --- /dev/null +++ b/packages/agilitycms/endpoints/types.ts @@ -0,0 +1,459 @@ +import { z } from 'zod'; + +export const ContentItemPropertiesSchema = z.object({ + state: z.number().int().optional().describe('Publish state code of the item'), + modified: z.string().optional().describe('Last modified ISO timestamp'), + versionID: z.number().int().optional().describe('Version identifier'), + referenceName: z.string().optional().describe('Content list reference name'), + definitionName: z + .string() + .optional() + .describe('Name of the content model definition'), + itemOrder: z.number().int().optional().describe('Sorting order index'), +}); +export type ContentItemProperties = z.infer; + +export const ContentItemSchema = z.object({ + contentID: z + .number() + .int() + .describe('Unique identifier for the content item'), + properties: ContentItemPropertiesSchema.optional().describe( + 'System metadata properties', + ), + fields: z + .record(z.string(), z.unknown()) + .describe('Dynamic content fields key-value dictionary'), +}); +export type ContentItem = z.infer; + +export const PageZoneComponentSchema = z.object({ + module: z.string().optional().describe('Name of the UI component module'), + item: z + .record(z.string(), z.unknown()) + .optional() + .describe('Content item details embedded in zone'), +}); + +export const PageSchema = z.object({ + pageID: z.number().int().describe('Unique identifier for the page'), + name: z.string().describe('Page name'), + path: z.string().nullable().optional().describe('URL path of the page'), + title: z.string().describe('Browser title of the page'), + menuText: z.string().optional().describe('Menu label text'), + pageType: z.string().optional().describe('Page type (e.g. static, dynamic)'), + templateName: z.string().describe('Page template layout name'), + redirectUrl: z + .string() + .nullable() + .optional() + .describe('Redirect URL if configured'), + securePage: z + .boolean() + .optional() + .describe('Whether the page requires authentication'), + seo: z + .object({ + metaDescription: z.string().optional(), + metaKeywords: z.string().optional(), + metaHTML: z.string().optional(), + menuVisible: z.boolean().optional(), + sitemapVisible: z.boolean().optional(), + }) + .optional() + .describe('SEO and visibility configuration'), + zones: z + .record(z.string(), z.array(PageZoneComponentSchema)) + .optional() + .describe('Content zones dictionary mapping zone name to components'), + properties: ContentItemPropertiesSchema.optional().describe( + 'Page system metadata properties', + ), +}); +export type Page = z.infer; + +export const ContentModelSchema = z.object({ + id: z.number().int().describe('Unique model identifier'), + referenceName: z + .string() + .nullable() + .optional() + .describe('Content reference name'), + displayName: z.string().describe('Human readable display name'), + description: z.string().nullable().optional().describe('Model description'), + fields: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Field definitions for this model'), +}); +export type ContentModel = z.infer; + +export const PageModuleSchema = z.object({ + id: z.number().int().describe('Unique module identifier'), + referenceName: z + .string() + .nullable() + .optional() + .describe('Reference name if applicable'), + displayName: z.string().describe('Human readable module name'), + description: z.string().nullable().optional().describe('Module description'), + fields: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe('Field schema definitions for the page module'), +}); +export type PageModule = z.infer; + +export const SitemapNodeSchema = z.object({ + pageID: z.number().int().describe('Page identifier'), + title: z.string().describe('Page title'), + menuText: z.string().describe('Menu navigation title'), + path: z.string().describe('URL path for the page'), + isFolder: z + .boolean() + .optional() + .describe('Whether the node is a navigation folder'), + redirectUrl: z + .string() + .nullable() + .optional() + .describe('Redirect destination if configured'), + visible: z + .object({ + menu: z.boolean().optional(), + sitemap: z.boolean().optional(), + }) + .optional() + .describe('Visibility flags'), +}); +export type SitemapNode = z.infer; + +export const SyncItemSchema = z.object({ + contentID: z.number().int().describe('Content item ID'), + properties: ContentItemPropertiesSchema.optional().describe( + 'System properties and change status', + ), + fields: z + .record(z.string(), z.unknown()) + .optional() + .describe('Item content fields'), +}); +export type SyncItem = z.infer; + +export const SyncPageSchema = z.object({ + pageID: z.number().int().describe('Page ID'), + name: z.string().optional().describe('Page name'), + path: z.string().nullable().optional().describe('Page path'), + title: z.string().optional().describe('Page title'), + templateName: z.string().optional().describe('Template name'), + properties: ContentItemPropertiesSchema.optional().describe( + 'System metadata and state', + ), +}); +export type SyncPage = z.infer; + +export const GetPageInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + pageId: z.number().int().positive().describe('Page ID to retrieve'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode: fetch for live, preview for staging'), + contentLinkDepth: z + .number() + .int() + .min(0) + .max(5) + .optional() + .describe('Depth of linked content items to expand (0-5)'), + expandAllContentLinks: z + .boolean() + .optional() + .describe('Whether to automatically expand all nested content links'), +}); +export type GetPageInput = z.infer; + +export const GetPageResponseSchema = PageSchema; +export type GetPageResponse = z.infer; + +export const GetItemInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + contentId: z + .number() + .int() + .positive() + .describe('Content item ID to retrieve'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode: fetch for live, preview for staging'), + contentLinkDepth: z + .number() + .int() + .min(0) + .max(5) + .optional() + .describe('Depth of linked content items to expand (0-5)'), + expandAllContentLinks: z + .boolean() + .optional() + .describe('Whether to automatically expand all nested content links'), +}); +export type GetItemInput = z.infer; + +export const GetItemResponseSchema = ContentItemSchema; +export type GetItemResponse = z.infer; + +export const GetListInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + referenceName: z + .string() + .min(1) + .describe('Content list reference name (e.g. posts, authors)'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode: fetch for live, preview for staging'), + contentLinkDepth: z + .number() + .int() + .min(0) + .max(5) + .optional() + .describe('Depth of linked content items to expand (0-5)'), + expandAllContentLinks: z + .boolean() + .optional() + .describe('Whether to automatically expand all nested content links'), + take: z + .number() + .int() + .positive() + .max(500) + .optional() + .describe('Maximum number of items to return (page size)'), + skip: z + .number() + .int() + .min(0) + .optional() + .describe('Number of items to skip for pagination'), + sort: z + .string() + .optional() + .describe('Field name to sort items by (e.g. properties.modified)'), + direction: z + .enum(['asc', 'desc']) + .optional() + .describe('Sort direction: asc or desc'), + filter: z + .string() + .optional() + .describe('OData filter expression (e.g. fields.Title[like]MyPost)'), +}); +export type GetListInput = z.infer; + +export const GetListResponseSchema = z.object({ + totalCount: z + .number() + .int() + .optional() + .describe('Total number of matching content items'), + items: z + .array(ContentItemSchema) + .describe('Array of retrieved content items'), +}); +export type GetListResponse = z.infer; + +export const GetContentModelsInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode: fetch for live, preview for staging'), +}); +export type GetContentModelsInput = z.infer; + +export const GetContentModelsResponseSchema = z.array(ContentModelSchema); +export type GetContentModelsResponse = z.infer< + typeof GetContentModelsResponseSchema +>; + +export const GetPageModulesInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode: fetch for live, preview for staging'), +}); +export type GetPageModulesInput = z.infer; + +export const GetPageModulesResponseSchema = z.array(PageModuleSchema); +export type GetPageModulesResponse = z.infer< + typeof GetPageModulesResponseSchema +>; + +export const GetSitemapFlatInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + channelName: z + .string() + .min(1) + .describe('Digital channel name (e.g. website)'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode: fetch for live, preview for staging'), +}); +export type GetSitemapFlatInput = z.infer; + +export const GetSitemapFlatResponseSchema = z.record( + z.string(), + SitemapNodeSchema, +); +export type GetSitemapFlatResponse = z.infer< + typeof GetSitemapFlatResponseSchema +>; + +export const GetLogsInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + syncToken: z + .string() + .optional() + .default('0') + .describe( + "Sync token for incremental change sync; use '0' for initial sync", + ), + pageSize: z + .number() + .int() + .positive() + .max(500) + .optional() + .describe('Number of sync items to return per page (max 500)'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode'), +}); +export type GetLogsInput = z.infer; + +export const GetLogsResponseSchema = z.object({ + syncToken: z + .string() + .describe( + "Next sync token to persist; returns '0' when sync is up to date", + ), + items: z + .array(SyncItemSchema) + .describe('List of content items created or updated since last sync'), +}); +export type GetLogsResponse = z.infer; + +export const SyncPagesInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z.string().min(1).describe('Language locale code (e.g. en-us)'), + syncToken: z + .string() + .optional() + .default('0') + .describe("Sync token for incremental page sync; use '0' for initial sync"), + pageSize: z + .number() + .int() + .positive() + .max(500) + .optional() + .describe('Number of sync pages to return per page (max 500)'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode'), +}); +export type SyncPagesInput = z.infer; + +export const SyncPagesResponseSchema = z.object({ + syncToken: z + .string() + .describe( + "Next sync token to persist; returns '0' when page sync is up to date", + ), + items: z + .array(SyncPageSchema) + .describe('List of page items created or updated since last sync'), +}); +export type SyncPagesResponse = z.infer; + +export const GetApiTypesInputSchema = z.object({ + instanceGuid: z.string().min(1).describe('Agility CMS instance GUID'), + locale: z + .string() + .optional() + .default('en-us') + .describe('Language locale code'), + apiType: z + .enum(['fetch', 'preview']) + .default('fetch') + .describe('API access mode'), +}); +export type GetApiTypesInput = z.infer; + +export const GetApiTypesResponseSchema = z.record( + z.string(), + z.array(z.string()).or(z.record(z.string(), z.unknown())), +); +export type GetApiTypesResponse = z.infer; + +export type AgilityCmsEndpointInputs = { + getPage: GetPageInput; + getItem: GetItemInput; + getList: GetListInput; + getContentModels: GetContentModelsInput; + getPageModules: GetPageModulesInput; + getSitemapFlat: GetSitemapFlatInput; + getLogs: GetLogsInput; + syncPages: SyncPagesInput; + getApiTypes: GetApiTypesInput; +}; + +export type AgilityCmsEndpointOutputs = { + getPage: GetPageResponse; + getItem: GetItemResponse; + getList: GetListResponse; + getContentModels: GetContentModelsResponse; + getPageModules: GetPageModulesResponse; + getSitemapFlat: GetSitemapFlatResponse; + getLogs: GetLogsResponse; + syncPages: SyncPagesResponse; + getApiTypes: GetApiTypesResponse; +}; + +export const AgilityCmsEndpointInputSchemas = { + getPage: GetPageInputSchema, + getItem: GetItemInputSchema, + getList: GetListInputSchema, + getContentModels: GetContentModelsInputSchema, + getPageModules: GetPageModulesInputSchema, + getSitemapFlat: GetSitemapFlatInputSchema, + getLogs: GetLogsInputSchema, + syncPages: SyncPagesInputSchema, + getApiTypes: GetApiTypesInputSchema, +} as const; + +export const AgilityCmsEndpointOutputSchemas = { + getPage: GetPageResponseSchema, + getItem: GetItemResponseSchema, + getList: GetListResponseSchema, + getContentModels: GetContentModelsResponseSchema, + getPageModules: GetPageModulesResponseSchema, + getSitemapFlat: GetSitemapFlatResponseSchema, + getLogs: GetLogsResponseSchema, + syncPages: SyncPagesResponseSchema, + getApiTypes: GetApiTypesResponseSchema, +} as const; diff --git a/packages/agilitycms/error-handlers.ts b/packages/agilitycms/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/agilitycms/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/agilitycms/index.ts b/packages/agilitycms/index.ts new file mode 100644 index 000000000..328132297 --- /dev/null +++ b/packages/agilitycms/index.ts @@ -0,0 +1,276 @@ +import type { + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { Content } from './endpoints'; +import type { + AgilityCmsEndpointInputs, + AgilityCmsEndpointOutputs, +} from './endpoints/types'; +import { + AgilityCmsEndpointInputSchemas, + AgilityCmsEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AgilityCmsSchema } from './schema'; + +export type AgilityCmsPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + apiBaseUrl?: string; + hooks?: InternalAgilityCmsPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AgilityCmsContext = CorsairPluginContext< + typeof AgilityCmsSchema, + AgilityCmsPluginOptions +>; + +export type AgilityCmsKeyBuilderContext = + KeyBuilderContext; + +export type AgilityCmsBoundEndpoints = BindEndpoints< + typeof agilitycmsEndpointsNested +>; + +type AgilityCmsEndpoint = + CorsairEndpoint< + AgilityCmsContext, + AgilityCmsEndpointInputs[K], + AgilityCmsEndpointOutputs[K] + >; + +export type AgilityCmsEndpoints = { + getPage: AgilityCmsEndpoint<'getPage'>; + getItem: AgilityCmsEndpoint<'getItem'>; + getList: AgilityCmsEndpoint<'getList'>; + getContentModels: AgilityCmsEndpoint<'getContentModels'>; + getPageModules: AgilityCmsEndpoint<'getPageModules'>; + getSitemapFlat: AgilityCmsEndpoint<'getSitemapFlat'>; + getLogs: AgilityCmsEndpoint<'getLogs'>; + syncPages: AgilityCmsEndpoint<'syncPages'>; + getApiTypes: AgilityCmsEndpoint<'getApiTypes'>; +}; + +const agilitycmsEndpointsNested = { + content: { + getPage: Content.getPage, + getItem: Content.getItem, + getList: Content.getList, + getContentModels: Content.getContentModels, + getPageModules: Content.getPageModules, + getSitemapFlat: Content.getSitemapFlat, + getLogs: Content.getLogs, + syncPages: Content.syncPages, + getApiTypes: Content.getApiTypes, + }, +} as const; + +export const agilitycmsEndpointSchemas = { + 'content.getPage': { + input: AgilityCmsEndpointInputSchemas.getPage, + output: AgilityCmsEndpointOutputSchemas.getPage, + }, + 'content.getItem': { + input: AgilityCmsEndpointInputSchemas.getItem, + output: AgilityCmsEndpointOutputSchemas.getItem, + }, + 'content.getList': { + input: AgilityCmsEndpointInputSchemas.getList, + output: AgilityCmsEndpointOutputSchemas.getList, + }, + 'content.getContentModels': { + input: AgilityCmsEndpointInputSchemas.getContentModels, + output: AgilityCmsEndpointOutputSchemas.getContentModels, + }, + 'content.getPageModules': { + input: AgilityCmsEndpointInputSchemas.getPageModules, + output: AgilityCmsEndpointOutputSchemas.getPageModules, + }, + 'content.getSitemapFlat': { + input: AgilityCmsEndpointInputSchemas.getSitemapFlat, + output: AgilityCmsEndpointOutputSchemas.getSitemapFlat, + }, + 'content.getLogs': { + input: AgilityCmsEndpointInputSchemas.getLogs, + output: AgilityCmsEndpointOutputSchemas.getLogs, + }, + 'content.syncPages': { + input: AgilityCmsEndpointInputSchemas.syncPages, + output: AgilityCmsEndpointOutputSchemas.syncPages, + }, + 'content.getApiTypes': { + input: AgilityCmsEndpointInputSchemas.getApiTypes, + output: AgilityCmsEndpointOutputSchemas.getApiTypes, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof agilitycmsEndpointsNested +>; + +const defaultAuthType = 'api_key' as const; + +const agilitycmsEndpointMeta = { + 'content.getPage': { + riskLevel: 'read', + description: + 'Retrieve details of a Page including metadata, content zones, and components by page ID', + }, + 'content.getItem': { + riskLevel: 'read', + description: + 'Fetch details of a content item by Content ID including fields and metadata', + }, + 'content.getList': { + riskLevel: 'read', + description: + 'Retrieve a paginated, filterable list of content items by reference name', + }, + 'content.getContentModels': { + riskLevel: 'read', + description: 'Retrieve content models and page module schema definitions', + }, + 'content.getPageModules': { + riskLevel: 'read', + description: + 'Retrieve page module UI component definitions for building pages', + }, + 'content.getSitemapFlat': { + riskLevel: 'read', + description: + 'Retrieve the flat sitemap dictionary for a specific channel and locale', + }, + 'content.getLogs': { + riskLevel: 'read', + description: + 'Retrieve sync items (content change logs) incrementally using sync tokens', + }, + 'content.syncPages': { + riskLevel: 'read', + description: + 'Synchronize local page data with CMS incrementally using sync tokens', + }, + 'content.getApiTypes': { + riskLevel: 'read', + description: + 'Retrieve enum types and metadata definitions used throughout Agility CMS API', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof agilitycmsEndpointsNested +>; + +export const agilitycmsAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAgilityCmsPlugin = + CorsairPlugin< + 'agilitycms', + typeof AgilityCmsSchema, + typeof agilitycmsEndpointsNested, + Record, + T, + typeof defaultAuthType + >; + +export type InternalAgilityCmsPlugin = + BaseAgilityCmsPlugin; + +export type ExternalAgilityCmsPlugin = + BaseAgilityCmsPlugin; + +export function agilitycms( + incomingOptions: AgilityCmsPluginOptions & T = {} as AgilityCmsPluginOptions & + T, +): ExternalAgilityCmsPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'agilitycms', + authConfig: agilitycmsAuthConfig, + schema: AgilityCmsSchema, + options: options, + hooks: options.hooks, + endpoints: agilitycmsEndpointsNested, + webhooks: {}, + endpointMeta: agilitycmsEndpointMeta, + endpointSchemas: agilitycmsEndpointSchemas, + webhookSchemas: {}, + pluginWebhookMatcher: () => false, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AgilityCmsKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys?.get_api_key(); + if (!res) { + throw new AuthMissingError('agilitycms', 'api_key'); + } + return res; + } + + throw new AuthMissingError('agilitycms', 'api_key'); + }, + } satisfies InternalAgilityCmsPlugin; +} + +export type { + AgilityCmsEndpointInputs, + AgilityCmsEndpointOutputs, + ContentItem, + ContentItemProperties, + ContentModel, + GetApiTypesInput, + GetApiTypesResponse, + GetContentModelsInput, + GetContentModelsResponse, + GetItemInput, + GetItemResponse, + GetListInput, + GetListResponse, + GetLogsInput, + GetLogsResponse, + GetPageInput, + GetPageModulesInput, + GetPageModulesResponse, + GetPageResponse, + GetSitemapFlatInput, + GetSitemapFlatResponse, + Page, + PageModule, + SitemapNode, + SyncItem, + SyncPage, + SyncPagesInput, + SyncPagesResponse, +} from './endpoints/types'; + +export type { + AgilityCmsContentItem, + AgilityCmsContentModel, + AgilityCmsPage, + AgilityCmsPageModule, + AgilityCmsSitemapNode, + AgilityCmsSyncItem, + AgilityCmsSyncPage, +} from './schema/database'; diff --git a/packages/agilitycms/jest.config.cjs b/packages/agilitycms/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/agilitycms/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/agilitycms/package.json b/packages/agilitycms/package.json new file mode 100644 index 000000000..2af291017 --- /dev/null +++ b/packages/agilitycms/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/agilitycms", + "version": "0.1.0", + "description": "Agility CMS plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.10.1", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "agilitycms", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/agilitycms/schema.test.ts b/packages/agilitycms/schema.test.ts new file mode 100644 index 000000000..4e1fc7949 --- /dev/null +++ b/packages/agilitycms/schema.test.ts @@ -0,0 +1,28 @@ +import { AgilityCmsSchema } from './schema'; + +describe('Agility CMS schema', () => { + it('declares a semver version', () => { + expect(AgilityCmsSchema.version).toBeDefined(); + expect(AgilityCmsSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares all 7 database entities', () => { + expect(typeof AgilityCmsSchema.entities).toBe('object'); + expect(AgilityCmsSchema.entities).not.toBeNull(); + const entityKeys = Object.keys(AgilityCmsSchema.entities); + expect(entityKeys).toContain('contentItems'); + expect(entityKeys).toContain('pages'); + expect(entityKeys).toContain('contentModels'); + expect(entityKeys).toContain('pageModules'); + expect(entityKeys).toContain('sitemapNodes'); + expect(entityKeys).toContain('syncItems'); + expect(entityKeys).toContain('syncPages'); + for (const entity of Object.values(AgilityCmsSchema.entities)) { + expect(entity).toBeDefined(); + } + }); + + it('schema version is 1.0.0', () => { + expect(AgilityCmsSchema.version).toBe('1.0.0'); + }); +}); diff --git a/packages/agilitycms/schema/database.ts b/packages/agilitycms/schema/database.ts new file mode 100644 index 000000000..bdfa43f47 --- /dev/null +++ b/packages/agilitycms/schema/database.ts @@ -0,0 +1,31 @@ +import type { z } from 'zod'; +import { + ContentItemSchema, + ContentModelSchema, + PageModuleSchema, + PageSchema, + SitemapNodeSchema, + SyncItemSchema, + SyncPageSchema, +} from '../endpoints/types'; + +export const AgilityCmsContentItem = ContentItemSchema; +export type AgilityCmsContentItem = z.infer; + +export const AgilityCmsPage = PageSchema; +export type AgilityCmsPage = z.infer; + +export const AgilityCmsContentModel = ContentModelSchema; +export type AgilityCmsContentModel = z.infer; + +export const AgilityCmsPageModule = PageModuleSchema; +export type AgilityCmsPageModule = z.infer; + +export const AgilityCmsSitemapNode = SitemapNodeSchema; +export type AgilityCmsSitemapNode = z.infer; + +export const AgilityCmsSyncItem = SyncItemSchema; +export type AgilityCmsSyncItem = z.infer; + +export const AgilityCmsSyncPage = SyncPageSchema; +export type AgilityCmsSyncPage = z.infer; diff --git a/packages/agilitycms/schema/index.ts b/packages/agilitycms/schema/index.ts new file mode 100644 index 000000000..efbf3dad1 --- /dev/null +++ b/packages/agilitycms/schema/index.ts @@ -0,0 +1,22 @@ +import { + AgilityCmsContentItem, + AgilityCmsContentModel, + AgilityCmsPage, + AgilityCmsPageModule, + AgilityCmsSitemapNode, + AgilityCmsSyncItem, + AgilityCmsSyncPage, +} from './database'; + +export const AgilityCmsSchema = { + version: '1.0.0', + entities: { + contentItems: AgilityCmsContentItem, + pages: AgilityCmsPage, + contentModels: AgilityCmsContentModel, + pageModules: AgilityCmsPageModule, + sitemapNodes: AgilityCmsSitemapNode, + syncItems: AgilityCmsSyncItem, + syncPages: AgilityCmsSyncPage, + }, +} as const; diff --git a/packages/agilitycms/tsconfig.json b/packages/agilitycms/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/agilitycms/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/agilitycms/tsup.config.ts b/packages/agilitycms/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/agilitycms/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 4fcd50909..854a38e5a 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -26,6 +26,7 @@ export const BaseProviders = [ 'agentmail', 'agentql', 'agenty', + 'agilitycms', 'ahrefs', 'aimlapi', 'allimagesai', @@ -204,6 +205,7 @@ export const ProviderDisplayNames = { agentmail: 'AgentMail', agentql: 'AgentQL', agenty: 'Agenty', + agilitycms: 'Agility CMS', ahrefs: 'Ahrefs', aimlapi: 'AI/ML API', allimagesai: 'All Images AI', @@ -389,6 +391,7 @@ export type AllProviders = | 'agentmail' | 'agentql' | 'agenty' + | 'agilitycms' | 'ahrefs' | 'aimlapi' | 'allimagesai' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40e47da31..999942593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -617,6 +617,33 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/agilitycms: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^22.10.1 + version: 22.19.7 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/ahrefs: devDependencies: '@types/jest': @@ -737,7 +764,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alphavantage: + packages/allimagesai: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -761,7 +788,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/altoviz: + packages/alphavantage: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -785,7 +812,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alttextai: + packages/altoviz: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -793,9 +820,6 @@ importers: corsair: specifier: workspace:* version: link:../corsair - dotenv: - specifier: ^17.2.3 - version: 17.4.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -812,17 +836,17 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/amara: + packages/alttextai: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 - '@types/node': - specifier: ^24.10.1 - version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair + dotenv: + specifier: ^17.2.3 + version: 17.4.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -839,11 +863,14 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambee: + packages/amara: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair @@ -863,7 +890,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambientweather: + packages/ambee: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -887,7 +914,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/allimagesai: + packages/ambientweather: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -18650,6 +18677,41 @@ snapshots: - supports-color - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.10.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 @@ -23323,6 +23385,21 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 @@ -24987,6 +25064,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -25037,6 +25133,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.7 + ts-node: 10.9.2(@types/node@22.19.7)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -25068,6 +25195,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.10.1 + ts-node: 10.9.2(@types/node@22.19.7)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -25366,6 +25524,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -28281,6 +28451,26 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.7) jest-util: 30.4.1 + ts-jest@29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 30.4.1 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 30.4.1 + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 @@ -28305,6 +28495,25 @@ snapshots: yn: 3.1.1 optional: true + ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.19.7 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1