Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/root-cms/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/root-cms/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

/**
Expand Down
50 changes: 50 additions & 0 deletions packages/root-cms/core/project.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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');
Expand Down
84 changes: 62 additions & 22 deletions packages/root-cms/core/project.ts
Original file line number Diff line number Diff line change
@@ -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<SchemaModule>(
[
'/**/*.schema.ts',
'!/appengine/**/*.schema.ts',
'!/functions/**/*.schema.ts',
'!/gae/**/*.schema.ts',
],
{eager: true}
);
export const SCHEMA_MODULES: Record<string, SchemaModule> =
typeof import.meta.glob === 'function'
? import.meta.glob<SchemaModule>(
[
'/**/*.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
Expand Down Expand Up @@ -69,26 +78,57 @@ export function resolveOneOfPatterns(schemaObj: schema.Schema): schema.Schema {
/**
* Returns a collection's schema definition as defined in
* `/collections/<id>.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<schema.Collection | null> {
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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/root-cms/core/testdata/collections/TestPages.schema.ts
Original file line number Diff line number Diff line change
@@ -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'}),
],
});