diff --git a/packages/root-cms/core/client.test.ts b/packages/root-cms/core/client.test.ts index 892938a4..3cf40f89 100644 --- a/packages/root-cms/core/client.test.ts +++ b/packages/root-cms/core/client.test.ts @@ -88,7 +88,9 @@ describe('RootCMSClient Validation', () => { const result = await client.getCollection('TestCollection'); - expect(mockGetCollectionSchema).toHaveBeenCalledWith('TestCollection'); + expect(mockGetCollectionSchema).toHaveBeenCalledWith('TestCollection', { + rootDir: '/test', + }); expect(result).toEqual(testSchema); }); @@ -102,6 +104,22 @@ describe('RootCMSClient Validation', () => { expect(result).toBeNull(); }); + + it('passes rootDir to getCollectionSchema for filesystem fallback', async () => { + const {RootCMSClient} = await import('./client.js'); + const client = new RootCMSClient(mockRootConfig); + + mockGetCollectionSchema.mockResolvedValue(null); + + const result = await client.getCollection('Pages'); + + // Verify rootDir is passed so getCollectionSchema can use the + // filesystem fallback in non-Vite environments. + expect(mockGetCollectionSchema).toHaveBeenCalledWith('Pages', { + rootDir: '/test', + }); + expect(result).toBeNull(); + }); }); describe('saveDraftData with validation', () => { diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index cdb619df..ef4b2769 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -325,7 +325,9 @@ export class RootCMSClient { // Lazy load the project module to minimize the amount of code loaded // when the client is initialized (the project module loads all schema files). const project = await import('./project.js'); - return await project.getCollectionSchema(collectionId); + return await project.getCollectionSchema(collectionId, { + rootDir: this.rootConfig.rootDir, + }); } /** diff --git a/packages/root-cms/core/project.integration.test.ts b/packages/root-cms/core/project.integration.test.ts index 4eaf056c..14b31ccd 100644 --- a/packages/root-cms/core/project.integration.test.ts +++ b/packages/root-cms/core/project.integration.test.ts @@ -6,9 +6,13 @@ * circular dependency issues when schema files import from '@blinkk/root-cms'. */ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; import {describe, it, expect} from 'vitest'; import * as schema from './schema.js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + describe('Production Bundle Integration', () => { it('should export schema namespace without circular dependency errors', async () => { // Import the core module (which is the main entry point) @@ -98,6 +102,52 @@ describe('Production Bundle Integration', () => { }); }); +describe('getCollectionSchema', () => { + it('returns null when schema not found in SCHEMA_MODULES and no rootDir', async () => { + const projectModule = await import('./project.js'); + + // Without rootDir, should return null for non-existent collection. + const result = await projectModule.getCollectionSchema('NonExistent'); + expect(result).toBeNull(); + }); + + it('returns null when schema not found and rootDir has no matching file', async () => { + const projectModule = await import('./project.js'); + + const result = await projectModule.getCollectionSchema('NonExistent', { + rootDir: '/nonexistent/path', + }); + expect(result).toBeNull(); + }); + + it('loads schema from filesystem when rootDir is provided', async () => { + const projectModule = await import('./project.js'); + + // Use the testdata directory as rootDir. + const testRootDir = path.resolve(__dirname, 'testdata'); + const result = await projectModule.getCollectionSchema('TestPages', { + rootDir: testRootDir, + }); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('TestPages'); + expect(result!.id).toBe('TestPages'); + expect(result!.fields).toHaveLength(2); + expect(result!.fields[0].id).toBe('title'); + expect(result!.fields[0].type).toBe('string'); + expect(result!.fields[1].id).toBe('count'); + expect(result!.fields[1].type).toBe('number'); + }); + + it('throws error for invalid collection id', async () => { + const projectModule = await import('./project.js'); + + await expect( + projectModule.getCollectionSchema('invalid/id') + ).rejects.toThrow('invalid collection id'); + }); +}); + describe('SchemaPattern Resolution', () => { it('should export glob function from core module', async () => { const coreModule = await import('./core.js'); diff --git a/packages/root-cms/core/project.ts b/packages/root-cms/core/project.ts index c41501e9..3271b46e 100644 --- a/packages/root-cms/core/project.ts +++ b/packages/root-cms/core/project.ts @@ -1,25 +1,34 @@ /** * Loads various files or configurations from the project. * - * NOTE: This file needs to be loaded through vite's ssrLoadModule so that - * `import.meta.glob()` calls are resolved. + * NOTE: When loaded through Vite's ssrLoadModule, `import.meta.glob()` calls + * are resolved. In Node.js environments (e.g. scripts, CLI tools), + * `import.meta.glob` is not available and SCHEMA_MODULES defaults to an empty + * object. In that case, `getCollectionSchema()` falls back to importing the + * schema file directly from disk using the provided `rootDir`. */ +import {existsSync} from 'node:fs'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; import * as schema from './schema.js'; export interface SchemaModule { default: schema.Schema; } -export const SCHEMA_MODULES = import.meta.glob( - [ - '/**/*.schema.ts', - '!/appengine/**/*.schema.ts', - '!/functions/**/*.schema.ts', - '!/gae/**/*.schema.ts', - ], - {eager: true} -); +export const SCHEMA_MODULES: Record = + typeof import.meta.glob === 'function' + ? import.meta.glob( + [ + '/**/*.schema.ts', + '!/appengine/**/*.schema.ts', + '!/functions/**/*.schema.ts', + '!/gae/**/*.schema.ts', + ], + {eager: true} + ) + : {}; /** * Returns a map of all `schema.ts` files defined in the project as @@ -69,26 +78,57 @@ export function resolveOneOfPatterns(schemaObj: schema.Schema): schema.Schema { /** * Returns a collection's schema definition as defined in * `/collections/.schema.ts`. + * + * In Vite environments, schemas are loaded from `SCHEMA_MODULES` (populated + * by `import.meta.glob`). In Node.js environments, if `rootDir` is provided, + * falls back to importing the schema file directly from disk. */ -export function getCollectionSchema( - collectionId: string -): schema.Collection | null { +export async function getCollectionSchema( + collectionId: string, + options?: {rootDir?: string} +): Promise { if (!testValidCollectionId(collectionId)) { throw new Error(`invalid collection id: ${collectionId}`); } const fileId = `/collections/${collectionId}.schema.ts`; const module = SCHEMA_MODULES[fileId]; - if (!module.default) { - console.warn(`collection schema not exported in: ${fileId}`); - return null; + if (module && module.default) { + const collection = module.default as schema.Collection; + collection.id = collectionId; + return convertOneOfTypes(collection); } - const collection = module.default as schema.Collection; - collection.id = collectionId; - // Convert `schema.oneOf()` object types to an array of strings and move the - // type schema to `collection.types`. - return convertOneOfTypes(collection); + // Fallback for Node.js environments where import.meta.glob is not + // available (e.g., CLI tools, migration scripts). Directly import + // the schema file from disk using the provided rootDir. + const rootDir = options?.rootDir; + if (rootDir) { + const schemaPath = path.resolve( + rootDir, + `collections/${collectionId}.schema.ts` + ); + if (existsSync(schemaPath)) { + try { + const mod = await import(pathToFileURL(schemaPath).href); + if (mod.default) { + const collection = mod.default as schema.Collection; + collection.id = collectionId; + return convertOneOfTypes(collection); + } + } catch (e) { + // Schema file exists but failed to load. + console.warn(`failed to load schema from ${schemaPath}:`, e); + } + } + } + + if (!module) { + console.warn(`collection schema not found: ${fileId}`); + } else { + console.warn(`collection schema not exported in: ${fileId}`); + } + return null; } function testValidCollectionId(id: string): boolean { diff --git a/packages/root-cms/core/testdata/collections/TestPages.schema.ts b/packages/root-cms/core/testdata/collections/TestPages.schema.ts new file mode 100644 index 00000000..98b312f5 --- /dev/null +++ b/packages/root-cms/core/testdata/collections/TestPages.schema.ts @@ -0,0 +1,15 @@ +/** + * Test schema fixture for integration tests. + * Uses relative imports to avoid dependency on the built package. + */ +import {collection, string, number} from '../../schema.js'; + +export default collection({ + name: 'TestPages', + description: 'Test pages for integration tests.', + url: '/test/[slug]', + fields: [ + string({id: 'title', label: 'Title'}), + number({id: 'count', label: 'Count'}), + ], +});