Skip to content

Commit 6bd997b

Browse files
authored
feat: flatten oneof types in collection schemas (#720)
1 parent ae3b1c4 commit 6bd997b

16 files changed

Lines changed: 438 additions & 230 deletions

File tree

.changeset/polite-suns-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: flatten oneof types in collection schemas

docs/collections/Pages.schema.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ export default schema.collection({
1919
},
2020
},
2121
autolock: true,
22-
sortOptions: [
23-
{id: 'title', label: 'Title A-Z', field: 'fields.meta.title'},
24-
],
22+
sortOptions: [{id: 'title', label: 'Title A-Z', field: 'fields.meta.title'}],
2523

2624
fields: [
2725
schema.object({

docs/root-cms.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,27 @@ export interface PagesFields {
220220
/** Generated from `/collections/Pages.schema.ts`. */
221221
export type PagesDoc = RootCMSDoc<PagesFields>;
222222

223+
/** Generated from `/collections/Sandbox.schema.ts`. */
224+
export interface SandboxFields {
225+
/** Meta */
226+
meta?: {
227+
/** Title. Page title. */
228+
title?: string;
229+
/** Description. Description for SEO and social shares. */
230+
description?: string;
231+
/** Image. Meta image for social shares. Recommended: 1400x600 JPG. */
232+
image?: RootCMSImage;
233+
};
234+
/** Content */
235+
content?: {
236+
/** Modules. Compose the page by adding one or more modules. */
237+
modules?: RootCMSOneOf<RootCMSOneOfOption<'Divider', DividerFields> | RootCMSOneOfOption<'Section', SectionFields> | RootCMSOneOfOption<'Spacer', SpacerFields> | RootCMSOneOfOption<'Template50x50', Template50x50Fields> | RootCMSOneOfOption<'TemplateHeadline', TemplateHeadlineFields> | RootCMSOneOfOption<'TemplateImage', TemplateImageFields> | RootCMSOneOfOption<'TemplateJumplinks', TemplateJumplinksFields> | RootCMSOneOfOption<'TemplatePoweredBy', TemplatePoweredByFields> | RootCMSOneOfOption<'TemplateSandbox', TemplateSandboxFields>>[];
238+
};
239+
}
240+
241+
/** Generated from `/collections/Sandbox.schema.ts`. */
242+
export type SandboxDoc = RootCMSDoc<SandboxFields>;
243+
223244
/** Generated from `/templates/Section/Section.schema.ts`. */
224245
export interface SectionFields {
225246
/** ID. Used for deep linking, tracking, etc. */

packages/root-cms/cli/generate-types.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export async function generateTypes() {
1717
rootConfig,
1818
modulePath
1919
)) as ProjectModule;
20-
const schemas = project.getProjectSchemas();
20+
const schemas = await project.getProjectSchemas();
2121
const outputPath = path.resolve(rootDir, 'root-cms.d.ts');
2222
await generateSchemaDts(outputPath, schemas);
2323
console.log('saved root-cms.d.ts!');
@@ -280,17 +280,24 @@ function fieldType(field: Field, options: FieldPropertyOptions): dom.Type {
280280
const oneOf = dom.create.namedTypeReference('RootCMSOneOf');
281281
if (field.types && Array.isArray(field.types)) {
282282
const unionTypes: dom.NamedTypeReference[] = [];
283-
field.types.forEach((schema: Schema) => {
283+
field.types.forEach((schema: Schema | string) => {
284+
let typeName: string;
285+
if (typeof schema === 'string') {
286+
typeName = schema;
287+
return;
288+
} else {
289+
typeName = schema.name;
290+
}
284291
// The "name" property is required.
285-
if (!schema.name) {
292+
if (!typeName) {
286293
return;
287294
}
288295

289-
const cleanName = alphanumeric(schema.name);
296+
const cleanName = alphanumeric(typeName);
290297
const oneOfTypeId = `${cleanName}Fields`;
291298

292299
// Add the oneOf type to the .d.ts file.
293-
if (!options.oneOfTypes[oneOfTypeId]) {
300+
if (typeof schema === 'object' && !options.oneOfTypes[oneOfTypeId]) {
294301
const oneOfTypeInterface = dom.create.interface(
295302
oneOfTypeId,
296303
dom.DeclarationFlags.Export
@@ -307,7 +314,7 @@ function fieldType(field: Field, options: FieldPropertyOptions): dom.Type {
307314

308315
const oneOfOption = dom.create.namedTypeReference('RootCMSOneOfOption');
309316
oneOfOption.typeArguments = [
310-
dom.type.stringLiteral(schema.name),
317+
dom.type.stringLiteral(typeName),
311318
dom.create.namedTypeReference(oneOfTypeId),
312319
];
313320
unionTypes.push(oneOfOption);

packages/root-cms/core/api.ts

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import {Server, Request, Response} from '@blinkk/root';
2-
import {multipartMiddleware} from '@blinkk/root/middleware';
31
import {promises as fs} from 'node:fs';
42
import path from 'node:path';
3+
import {Server, Request, Response} from '@blinkk/root';
4+
import {multipartMiddleware} from '@blinkk/root/middleware';
55
import {
66
ChatPrompt,
77
AiResponse,
@@ -40,6 +40,35 @@ export interface ApiOptions {
4040
* Registers API middleware handlers.
4141
*/
4242
export function api(server: Server, options: ApiOptions) {
43+
/**
44+
* Reads the collection's schema defined in `/collections/<id>.schema.ts`.
45+
*/
46+
async function getCollectionSchema(req: Request, collectionId: string) {
47+
// On dev, read the collection's `schema.ts` file directly.
48+
if (req.viteServer) {
49+
const app = await options.getRenderer(req);
50+
return await app.getCollection(collectionId);
51+
}
52+
// On prod, read the collection's schema from
53+
// `dist/collections/<id>.schema.json`. This file is built in the
54+
// `preBuild()` hook within `plugin.ts`.
55+
try {
56+
const schemaPath = path.join(
57+
req.rootConfig!.rootDir,
58+
'dist',
59+
'collections',
60+
`${collectionId}.schema.json`
61+
);
62+
const contents = await fs.readFile(schemaPath, 'utf8');
63+
return JSON.parse(contents);
64+
} catch (err) {
65+
if (err && err.code === 'ENOENT') {
66+
return null;
67+
}
68+
throw err;
69+
}
70+
}
71+
4372
/**
4473
* Returns the schema for a collection.
4574
*
@@ -74,41 +103,21 @@ export function api(server: Server, options: ApiOptions) {
74103
res.status(400).json({success: false, error: 'MISSING_COLLECTION_ID'});
75104
return;
76105
}
77-
78106
if (!testValidCollectionId(collectionId)) {
79107
res.status(400).json({success: false, error: 'INVALID_COLLECTION_ID'});
80108
return;
81109
}
82110

83111
try {
84-
if (req.viteServer) {
85-
const app = await options.getRenderer(req);
86-
const collections = app.getCollections();
87-
const collection = collections[collectionId];
88-
if (!collection) {
89-
res.status(404).json({success: false, error: 'NOT_FOUND'});
90-
return;
91-
}
92-
res.status(200).json({success: true, data: collection});
112+
const collection = await getCollectionSchema(req, collectionId);
113+
if (!collection) {
114+
res.status(404).json({success: false, error: 'NOT_FOUND'});
93115
return;
94116
}
95-
96-
const schemaPath = path.join(
97-
req.rootConfig!.rootDir,
98-
'dist',
99-
'collections',
100-
`${collectionId}.json`
101-
);
102-
const contents = await fs.readFile(schemaPath, 'utf8');
103-
const collection = JSON.parse(contents);
104117
res.status(200).json({success: true, data: collection});
105118
} catch (err: any) {
106-
if (err && err.code === 'ENOENT') {
107-
res.status(404).json({success: false, error: 'NOT_FOUND'});
108-
} else {
109-
console.error(err.stack || err);
110-
res.status(500).json({success: false, error: 'UNKNOWN'});
111-
}
119+
console.error(err.stack || err);
120+
res.status(500).json({success: false, error: 'UNKNOWN'});
112121
}
113122
});
114123

packages/root-cms/core/app.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {Request, Response, RootConfig} from '@blinkk/root';
55
import {render as renderToString} from 'preact-render-to-string';
66
import packageJson from '../package.json' assert {type: 'json'};
77
import {CMSPluginOptions} from './plugin.js';
8-
import {getProjectSchemas} from './project.js';
8+
import {getCollectionSchema, getProjectSchemas} from './project.js';
99
import {Collection} from './schema.js';
1010

1111
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -88,9 +88,11 @@ export async function renderApp(
8888
) {
8989
// Exclude "fields" from the serialized collections to reduce payload size.
9090
const collections: Record<string, Partial<Collection>> = {};
91-
Object.entries(getCollections()).forEach(([collectionId, collection]) => {
92-
collections[collectionId] = serializeCollection(collection);
93-
});
91+
Object.entries(await getCollections()).forEach(
92+
([collectionId, collection]) => {
93+
collections[collectionId] = serializeCollection(collection);
94+
}
95+
);
9496
const rootConfig = options.rootConfig || {};
9597
const cmsConfig = options.cmsConfig || {};
9698
let gci = cmsConfig.gci;
@@ -166,6 +168,7 @@ function serializeCollection(collection: Collection): Partial<Collection> {
166168
slugRegex: collection.slugRegex,
167169
autolock: collection.autolock,
168170
autolockReason: collection.autolockReason,
171+
sortOptions: collection.sortOptions,
169172
};
170173
}
171174

@@ -260,22 +263,32 @@ export async function renderSignIn(
260263
res.send(html);
261264
}
262265

263-
export function getCollections(): Record<string, Collection> {
266+
export async function getCollections(): Promise<Record<string, Collection>> {
264267
const collections: Record<string, Collection> = {};
265-
const schemas = getProjectSchemas();
268+
const schemas = await getProjectSchemas();
266269
Object.entries(schemas).forEach(([fileId, schema]) => {
267270
if (fileId.startsWith('/collections/')) {
268-
const collectionId = parseCollectionId(fileId);
269-
collections[collectionId] = {...schema, id: collectionId} as Collection;
271+
const collectionId = toCollectionId(fileId);
272+
collections[collectionId] = {id: collectionId, ...schema} as Collection;
270273
}
271274
});
272275
return collections;
273276
}
274277

278+
/**
279+
* Returns a collection's schema definition as defined in
280+
* `/collections/<id>.schema.ts`.
281+
*/
282+
export async function getCollection(
283+
collectionId: string
284+
): Promise<Collection | null> {
285+
return getCollectionSchema(collectionId);
286+
}
287+
275288
/**
276289
* Converts a fileId path like "/collections/Foo.schema.ts" and returns "Foo".
277290
*/
278-
function parseCollectionId(fileId: string) {
291+
function toCollectionId(fileId: string) {
279292
return path.basename(fileId).split('.')[0];
280293
}
281294

packages/root-cms/core/plugin.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
RootConfig,
1212
Server,
1313
} from '@blinkk/root';
14+
import {viteSsrLoadModule} from '@blinkk/root/node';
1415
import bodyParser from 'body-parser';
1516
import {
1617
App,
@@ -25,32 +26,35 @@ import sirv from 'sirv';
2526
import {type RootAiModel} from './ai.js';
2627
import {api} from './api.js';
2728
import {Action, RootCMSClient} from './client.js';
28-
import {loadRootConfig, viteSsrLoadModule} from '@blinkk/root/node';
29-
import {Schema} from './schema.js';
3029

3130
const __dirname = path.dirname(fileURLToPath(import.meta.url));
3231

3332
type AppModule = typeof import('./app.js');
3433
type ProjectModule = typeof import('./project.js');
3534

36-
async function generateSchemaJson(rootDir: string) {
37-
const rootConfig = await loadRootConfig(rootDir, {command: 'root-cms'});
35+
async function writeCollectionSchemasToJson(rootConfig: RootConfig) {
3836
const modulePath = path.resolve(__dirname, './project.js');
39-
const project = (await viteSsrLoadModule(
37+
const project = await viteSsrLoadModule<ProjectModule>(
4038
rootConfig,
4139
modulePath
42-
)) as ProjectModule;
43-
const schemas = project.getProjectSchemas();
44-
const outDir = path.join(rootDir, 'dist', 'collections');
40+
);
41+
42+
const outDir = path.join(rootConfig.rootDir, 'dist', 'collections');
4543
await fs.mkdir(outDir, {recursive: true});
46-
for (const [fileId, schema] of Object.entries(schemas) as [string, Schema][]) {
44+
const fileIds = Object.keys(project.SCHEMA_MODULES);
45+
for (const fileId of fileIds) {
4746
if (!fileId.startsWith('/collections/')) {
4847
continue;
4948
}
5049
const collectionId = path.basename(fileId).split('.')[0];
51-
const jsonPath = path.join(outDir, `${collectionId}.json`);
52-
const data = JSON.stringify({...schema, id: collectionId}, null, 2);
53-
await fs.writeFile(jsonPath, data);
50+
const schema = await project.getCollectionSchema(collectionId);
51+
if (!schema) {
52+
console.warn(`collection is missing a default export: ${fileId}`);
53+
continue;
54+
}
55+
const jsonPath = path.join(outDir, `${collectionId}.schema.json`);
56+
const data = JSON.stringify(schema, null, 2);
57+
await fs.writeFile(jsonPath, data, 'utf-8');
5458
}
5559
}
5660

@@ -486,14 +490,12 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin {
486490
},
487491

488492
hooks: {
489-
async preBuild(rootConfig: RootConfig) {
490-
if (process.env.NODE_ENV !== 'development') {
491-
try {
492-
await generateSchemaJson(rootConfig.rootDir);
493-
} catch (err) {
494-
console.error('failed to generate schema json', err);
495-
}
496-
}
493+
/**
494+
* Saves collection schema files from `collections/*.schema.ts` to
495+
* `dist/collections/*.schema.json` for prod builds.
496+
*/
497+
preBuild: async (rootConfig: RootConfig) => {
498+
await writeCollectionSchemasToJson(rootConfig);
497499
},
498500
},
499501

0 commit comments

Comments
 (0)