Skip to content

Commit 6ff06d5

Browse files
authored
feat: [v2] add cmsRoute() for defining ssg/ssr handlers (#435)
1 parent b6060ee commit 6ff06d5

File tree

9 files changed

+115
-91
lines changed

9 files changed

+115
-91
lines changed

docs/routes/[[...page]].tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import {cmsRoute} from '@blinkk/root-cms';
12
import {
23
PageModuleFields,
34
PageModules,
4-
} from '@/components/PageModules/PageModules';
5-
import {BaseLayout} from '@/layouts/BaseLayout';
6-
import {PagesDoc} from '@/root-cms';
7-
import {cmsRoute} from '@/utils/cms-route';
5+
} from '@/components/PageModules/PageModules.js';
6+
import {BaseLayout} from '@/layouts/BaseLayout.js';
7+
import {PagesDoc} from '@/root-cms.js';
88

99
export interface PageProps {
1010
doc: PagesDoc;

docs/routes/blog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {cmsRoute} from '@/utils/cms-route';
2-
import {default as Page} from './blog/[...blog]';
1+
import {cmsRoute} from '@blinkk/root-cms';
2+
import {default as Page} from './blog/[...blog].js';
33

44
// TODO(stevenle): Create a blog listing page when we have more than 1 post.
55
export default Page;

docs/routes/blog/[...blog].tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import {CopyBlock} from '@/blocks/CopyBlock/CopyBlock';
2-
import Block from '@/components/Block/Block';
3-
import {Container} from '@/components/Container/Container';
4-
import {Text} from '@/components/Text/Text';
5-
import {BaseLayout} from '@/layouts/BaseLayout';
6-
import {BlogPostsDoc} from '@/root-cms';
7-
import {cmsRoute} from '@/utils/cms-route';
1+
import {cmsRoute} from '@blinkk/root-cms';
2+
import {CopyBlock} from '@/blocks/CopyBlock/CopyBlock.js';
3+
import Block from '@/components/Block/Block.js';
4+
import {Container} from '@/components/Container/Container.js';
5+
import {Text} from '@/components/Text/Text.js';
6+
import {BaseLayout} from '@/layouts/BaseLayout.js';
7+
import {BlogPostsDoc} from '@/root-cms.js';
88
import styles from './[...blog].module.scss';
99

1010
export interface PageProps {

docs/routes/guide/[[...guide]].tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {RequestContext, useRequestContext, useTranslations} from '@blinkk/root';
2+
import {cmsRoute} from '@blinkk/root-cms';
23
import {IconLayoutSidebarLeftExpand} from '@tabler/icons-preact';
34
import Block from '@/components/Block/Block.js';
45
import {RichText} from '@/components/RichText/RichText.js';
@@ -8,7 +9,6 @@ import {LogoToggle} from '@/islands/LogoToggle/LogoToggle.js';
89
import {BaseLayout} from '@/layouts/BaseLayout.js';
910
import {GuideDoc} from '@/root-cms.js';
1011
import {joinClassNames} from '@/utils/classes.js';
11-
import {cmsRoute} from '@/utils/cms-route.js';
1212
import styles from './[[...guide]].module.scss';
1313

1414
const GUIDE_LINKS = [

docs/routes/sandbox/[sandbox].tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {cmsRoute} from '@/utils/cms-route.js';
1+
import {cmsRoute} from '@blinkk/root-cms';
22
import Page from '../[[...page]].js';
33

44
export default Page;

packages/root-cms/core/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from 'node:crypto';
2-
import {RootConfig} from '@blinkk/root';
2+
import type {RootConfig} from '@blinkk/root';
33
import {App} from 'firebase-admin/app';
44
import {
55
FieldValue,

docs/utils/cms-route.ts renamed to packages/root-cms/core/cms-route.ts

Lines changed: 97 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
Response,
77
RouteParams,
88
} from '@blinkk/root';
9-
import {RootCMSClient, translationsForLocale} from '@blinkk/root-cms/client';
9+
import {BatchRequest, RootCMSClient} from './client.js';
1010

1111
export type CMSRequest = Request & {
1212
cmsClient: RootCMSClient;
@@ -37,6 +37,16 @@ export interface CMSRouteOptions {
3737
*/
3838
slug?: string;
3939

40+
/**
41+
* Hook that allows callers to modify the Root CMS `BatchRequest` object. If a
42+
* new `BatchRequest` is returned, it will replace the default one created by
43+
* `cmsRoute()`.
44+
*/
45+
preRequestHook?: (
46+
batchRequest: BatchRequest,
47+
context: CMSRouteContext
48+
) => void | BatchRequest;
49+
4050
/**
4151
* Callback function that returns a map of Promises that contain fetched data.
4252
* Once the promise is resolved, the values are injected into page's props
@@ -55,11 +65,6 @@ export interface CMSRouteOptions {
5565
*/
5666
setResponseHeaders?: (req: Request, res: Response) => void;
5767

58-
/**
59-
* Translations configuration.
60-
*/
61-
translations?: (context: CMSRouteContext) => {tags?: string[]};
62-
6368
/**
6469
* Sets Cache-Control header to `private`.
6570
*/
@@ -72,13 +77,18 @@ export interface CMSRouteOptions {
7277
}
7378

7479
export interface CMSDoc {
80+
id: string;
81+
slug: string;
7582
sys?: {
7683
locales?: string[];
7784
};
7885
}
7986

87+
/**
88+
* Utility for generating SSR and SSG handlers in a Root route file.
89+
*/
8090
export function cmsRoute(options: CMSRouteOptions) {
81-
let cmsClient: RootCMSClient = null;
91+
let cmsClient: RootCMSClient | null = null;
8292

8393
function getSlug(params: RouteParams) {
8494
if (options.slug) {
@@ -98,27 +108,73 @@ export function cmsRoute(options: CMSRouteOptions) {
98108
return resolvePromisesMap(promisesMap);
99109
}
100110

101-
async function generateProps(routeContext: CMSRouteContext, locale: string) {
111+
async function generateProps(
112+
routeContext: CMSRouteContext,
113+
preferredLocale: string | ((doc: CMSDoc) => string)
114+
) {
102115
const {slug, mode} = routeContext;
103-
const translationsTags = ['common', `${options.collection}/${slug}`];
104-
if (options.translations) {
105-
const tags = options.translations(routeContext)?.tags || [];
106-
translationsTags.push(...tags);
116+
117+
const primaryDocId = `${options.collection}/${slug}`;
118+
let batchRequest = routeContext.cmsClient.createBatchRequest({
119+
mode,
120+
translate: true,
121+
});
122+
batchRequest.addDoc(primaryDocId);
123+
124+
// Call the pre-request hook to allow users to modify the batch request.
125+
if (options.preRequestHook) {
126+
const overridedBatchRequest = options.preRequestHook(
127+
batchRequest,
128+
routeContext
129+
);
130+
if (overridedBatchRequest) {
131+
batchRequest = overridedBatchRequest;
132+
}
107133
}
108134

109-
const [doc, translationsMap, data] = await Promise.all([
110-
cmsClient.getDoc<CMSDoc>(options.collection, slug, {
111-
mode,
112-
}),
113-
cmsClient.loadTranslations({tags: translationsTags}),
135+
// Fetch the Root CMS BatchRequest in parallel with any other data the
136+
// caller needs to fetch to render the route.
137+
const [batchRes, data] = await Promise.all([
138+
batchRequest.fetch(),
114139
fetchData(routeContext),
115140
]);
141+
const doc = batchRes.docs[primaryDocId];
116142
if (!doc) {
117143
return {notFound: true};
118144
}
119145

120-
const translations = translationsForLocale(translationsMap, locale);
146+
// Determine the preferred locale to render.
147+
let locale: string;
148+
if (typeof preferredLocale === 'string') {
149+
locale = preferredLocale;
150+
} else {
151+
locale = preferredLocale(doc);
152+
}
153+
const docLocales = doc.sys?.locales || ['en'];
154+
if (!locale || !docLocales.includes(locale)) {
155+
return {notFound: true};
156+
}
157+
158+
// From the preferred locale, generate a translations map.
159+
const i18nFallbacks =
160+
routeContext.cmsClient.rootConfig.i18n?.fallbacks || {};
161+
const translationFallbackLocales = i18nFallbacks[locale] || [locale];
162+
const translations = batchRes.getTranslations(translationFallbackLocales);
163+
121164
let props: any = {...data, locale, mode, slug, doc};
165+
166+
// For SSR handlers, inject the user's country of origin to props.
167+
if (routeContext.req) {
168+
const country =
169+
getFirstQueryParam(routeContext.req, 'gl') ||
170+
routeContext.req.get('x-country-code') ||
171+
routeContext.req.get('x-appengine-country') ||
172+
null;
173+
props.country = country;
174+
}
175+
176+
// Call the pre-render hook which allows a caller to modify props before
177+
// it is passed to the route component.
122178
if (options.preRenderHook) {
123179
props = await options.preRenderHook(props, routeContext);
124180
}
@@ -127,8 +183,8 @@ export function cmsRoute(options: CMSRouteOptions) {
127183
}
128184

129185
// SSG handlers are disabled by default. Pass `{enableSSG: true}` to enable.
130-
let getStaticPaths: GetStaticPaths = null;
131-
let getStaticProps: GetStaticProps = null;
186+
let getStaticPaths: GetStaticPaths | null = null;
187+
let getStaticProps: GetStaticProps | null = null;
132188

133189
if (options.enableSSG) {
134190
getStaticPaths = async (ctx) => {
@@ -138,13 +194,12 @@ export function cmsRoute(options: CMSRouteOptions) {
138194
if (!cmsClient) {
139195
cmsClient = new RootCMSClient(ctx.rootConfig);
140196
}
141-
// TODO(stevenle): Add support for mode.
142197
const mode = 'published';
143-
const res = await cmsClient.listDocs(options.collection, {mode});
144-
const ssgPaths = [];
145-
res.docs.forEach((doc: {slug: string}) => {
198+
const res = await cmsClient.listDocs<CMSDoc>(options.collection, {mode});
199+
const ssgPaths: Array<{params: Record<string, string>}> = [];
200+
res.docs.forEach((doc) => {
146201
const params: Record<string, string> = {};
147-
params[options.slugParam] = doc.slug;
202+
params[options.slugParam!] = doc.slug;
148203
ssgPaths.push({params});
149204
});
150205
return {paths: ssgPaths};
@@ -155,10 +210,8 @@ export function cmsRoute(options: CMSRouteOptions) {
155210
cmsClient = new RootCMSClient(ctx.rootConfig);
156211
}
157212
const slug = getSlug(ctx.params);
158-
// TODO(stevenle): Add support for mode.
159213
const mode = 'published';
160-
const routeContext: CMSRouteContext = {req: null, slug, mode, cmsClient};
161-
214+
const routeContext: CMSRouteContext = {slug, mode, cmsClient};
162215
return generateProps(routeContext, ctx.params.$locale);
163216
};
164217
}
@@ -169,9 +222,9 @@ export function cmsRoute(options: CMSRouteOptions) {
169222
getStaticProps: getStaticProps,
170223

171224
// SSR handler.
172-
handle: async (req, res) => {
225+
handle: async (req: CMSRequest, res: Response) => {
173226
if (!cmsClient) {
174-
cmsClient = new RootCMSClient(req.rootConfig);
227+
cmsClient = new RootCMSClient(req.rootConfig!);
175228
}
176229
req.cmsClient = cmsClient;
177230
const ctx = req.handlerContext as HandlerContext;
@@ -180,57 +233,28 @@ export function cmsRoute(options: CMSRouteOptions) {
180233
res.setHeader('cache-control', 'private');
181234
return ctx.render404();
182235
}
183-
const primaryDocId = `${options.collection}/${slug}`;
184236
const mode = String(req.query.preview) === 'true' ? 'draft' : 'published';
185237
const routeContext: CMSRouteContext = {req, slug, mode, cmsClient};
186238

187-
const batchRequest = cmsClient.createBatchRequest({
188-
mode,
189-
translate: true,
190-
});
191-
batchRequest.addDoc(primaryDocId);
192-
193-
const [batchRes, data] = await Promise.all([
194-
batchRequest.fetch(),
195-
fetchData(routeContext),
196-
]);
197-
const doc = batchRes.docs[primaryDocId];
239+
function getLocale(doc: CMSDoc) {
240+
const docLocales = doc.sys?.locales || ['en'];
241+
let locale = ctx.route.locale;
242+
if (ctx.route.isDefaultLocale) {
243+
locale = ctx.getPreferredLocale(docLocales);
244+
if (docLocales.length > 0 && !docLocales.includes(locale)) {
245+
locale = docLocales[0];
246+
}
247+
}
248+
return locale;
249+
}
198250

199-
if (!doc) {
251+
const resData = await generateProps(routeContext, getLocale);
252+
if (resData.notFound) {
200253
res.setHeader('cache-control', 'private');
201254
return ctx.render404();
202255
}
203256

204-
const sys = doc.sys;
205-
const docLocales = sys.locales || ['en'];
206-
let locale = ctx.route.locale;
207-
if (ctx.route.isDefaultLocale) {
208-
locale = ctx.getPreferredLocale(docLocales);
209-
if (docLocales.length > 0 && !docLocales.includes(locale)) {
210-
locale = docLocales[0];
211-
}
212-
}
213-
const country =
214-
getFirstQueryParam(req, 'gl') ||
215-
req.get('x-country-code') ||
216-
req.get('x-appengine-country') ||
217-
null;
218-
219-
const i18nFallbacks = req.rootConfig.i18n?.fallbacks || {};
220-
const translationFallbackLocales = i18nFallbacks[locale] || [locale];
221-
const translations = batchRes.getTranslations(translationFallbackLocales);
222-
let props: any = {
223-
...data,
224-
req,
225-
locale,
226-
mode,
227-
slug,
228-
doc,
229-
country,
230-
};
231-
if (options.preRenderHook) {
232-
props = await options.preRenderHook(props, routeContext);
233-
}
257+
const {props, locale, translations} = resData;
234258

235259
if (props.$redirect) {
236260
const redirectCode = props.$redirectCode || 302;

packages/root-cms/core/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './client.js';
2+
export * from './cms-route.js';
23
export * from './runtime.js';
34
export * as schema from './schema.js';

packages/root-cms/core/plugin.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import {promises as fs} from 'node:fs';
22
import path from 'node:path';
33
import {fileURLToPath} from 'node:url';
4-
5-
import {
4+
import type {
65
ConfigureServerOptions,
76
NextFunction,
87
Plugin,

0 commit comments

Comments
 (0)