rankrunners-cms 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ import { CMS_BASE_URL, SITE_ID } from "../constants";
2
+ import { fetchAs } from "../../libs/validator";
3
+ import {
4
+ PublicPostSeoDetailsSchema,
5
+ PublicSeoDetailsSchema,
6
+ type PublicPostDTO,
7
+ type PublicPostSeoDetailsDTO,
8
+ } from "../types/public";
9
+
10
+ /**
11
+ * Get a list of published blog posts for the current site.
12
+ * Optionally filter by a post category name.
13
+ */
14
+ export const getPublicPosts = async (
15
+ category?: string,
16
+ ): Promise<PublicPostDTO[]> => {
17
+ const url = new URL(`${CMS_BASE_URL}/public/seo/${SITE_ID}`);
18
+ if (category) {
19
+ url.searchParams.set("category", category);
20
+ }
21
+
22
+ const response = await fetchAs(PublicSeoDetailsSchema, url.toString());
23
+ return response.posts;
24
+ };
25
+
26
+ /**
27
+ * Get a single blog post's full SEO details by its slug.
28
+ */
29
+ export const getPublicPostBySlug = async (
30
+ slug: string,
31
+ ): Promise<PublicPostSeoDetailsDTO> => {
32
+ const response = await fetchAs(
33
+ PublicPostSeoDetailsSchema,
34
+ `${CMS_BASE_URL}/public/seo/${SITE_ID}/posts/${encodeURIComponent(slug)}`,
35
+ );
36
+ return response;
37
+ };
38
+
39
+ /**
40
+ * Get a single blog post's full SEO details by its ID (or slug).
41
+ */
42
+ export const getPublicPostById = async (
43
+ postId: string,
44
+ ): Promise<PublicPostSeoDetailsDTO> => {
45
+ const response = await fetchAs(
46
+ PublicPostSeoDetailsSchema,
47
+ `${CMS_BASE_URL}/public/sites/${SITE_ID}/posts/${encodeURIComponent(postId)}`,
48
+ );
49
+ return response;
50
+ };
@@ -1,31 +1,146 @@
1
- import { object, string, array, optional, type InferOutput } from "valibot";
1
+ import {
2
+ object,
3
+ string,
4
+ array,
5
+ optional,
6
+ nullable,
7
+ boolean,
8
+ type InferOutput,
9
+ } from "valibot";
2
10
  import { IdSchema } from "../types/common";
3
11
 
4
- export const PublicPageSeoDetailsSchema = object({
5
- id: optional(IdSchema), // ok
6
- siteId: optional(string()), // ok
7
- title: optional(string()), // ok
8
- slug: optional(string()), // ok
9
- content: optional(string()), // ok
10
- metaTitle: string(), // ok
11
- metaDescription: string(), // ok
12
- seoKeywords: array(string()), // ok
13
- openGraphTitle: optional(string()), // ok
14
- openGraphDescription: optional(string()), // ok
15
- openGraphImage: optional(string()), // ok
16
- googleAnalyticsId: optional(string()), // ok
17
- googleTagManagerId: optional(string()), // ok
18
- metaPixelId: optional(string()), // ok
19
- universalPixelId: optional(string()), // ok
20
- universalPixelAdvertiserId: optional(string()), // ok
12
+ // --- Media Schema (for hero images) ---
13
+
14
+ export const PublicMediaSchema = object({
15
+ id: string(),
16
+ name: string(),
17
+ altText: string(),
18
+ seoKeywords: array(string()),
19
+ url: string(),
20
+ });
21
+
22
+ export type PublicMediaDTO = InferOutput<typeof PublicMediaSchema>;
23
+
24
+ // --- Shared SEO fields (reused across page & post SEO DTOs) ---
25
+
26
+ const SeoTrackingFields = {
27
+ googleAnalyticsId: optional(string()),
28
+ googleTagManagerId: optional(string()),
29
+ metaPixelId: optional(string()),
30
+ universalPixelId: optional(string()),
31
+ universalPixelAdvertiserId: optional(string()),
21
32
  googleSiteVerificationId: optional(string()),
22
33
  callrailScript: optional(string()),
23
- twitterCardType: optional(string()),
34
+ } as const;
35
+
36
+ const SeoOpenGraphFields = {
37
+ openGraphTitle: optional(string()),
38
+ openGraphDescription: optional(string()),
39
+ openGraphImage: optional(string()),
40
+ } as const;
41
+
42
+ const SeoMetaFields = {
43
+ metaTitle: string(),
44
+ metaDescription: string(),
45
+ seoKeywords: array(string()),
46
+ ...SeoOpenGraphFields,
47
+ ...SeoTrackingFields,
48
+ } as const;
49
+
50
+ const SeoScriptFields = {
24
51
  headerScripts: string(),
25
52
  footerScripts: string(),
26
53
  structuredData: string(),
54
+ } as const;
55
+
56
+ /**
57
+ * All common SEO detail fields shared between pages and posts.
58
+ */
59
+ const CommonSeoDetailFields = {
60
+ ...SeoMetaFields,
61
+ ...SeoScriptFields,
62
+ } as const;
63
+
64
+ // --- Post Schemas ---
65
+
66
+ export const PublicPostSchema = object({
67
+ id: IdSchema,
68
+ title: string(),
69
+ slug: string(),
70
+ author: nullable(string()),
71
+ publishedAt: nullable(string()),
72
+ shortContent: string(),
73
+ heroImage: nullable(PublicMediaSchema),
74
+ });
75
+
76
+ export type PublicPostDTO = InferOutput<typeof PublicPostSchema>;
77
+
78
+ export const PublicPostSeoDetailsSchema = object({
79
+ id: IdSchema,
80
+ siteId: string(),
81
+ title: string(),
82
+ slug: string(),
83
+ content: string(),
84
+ publishedAt: nullable(string()),
85
+ author: nullable(string()),
86
+ heroImage: nullable(PublicMediaSchema),
87
+ ...CommonSeoDetailFields,
88
+ twitterCardType: string(),
89
+ });
90
+
91
+ export type PublicPostSeoDetailsDTO = InferOutput<
92
+ typeof PublicPostSeoDetailsSchema
93
+ >;
94
+
95
+ // --- Page Schema ---
96
+
97
+ export const PublicPageSchema = object({
98
+ id: IdSchema,
99
+ title: string(),
100
+ slug: string(),
101
+ });
102
+
103
+ export type PublicPageDTO = InferOutput<typeof PublicPageSchema>;
104
+
105
+ // --- SEO Details Schema (includes pages + posts) ---
106
+
107
+ export const PublicSeoDetailsSchema = object({
108
+ siteTitle: string(),
109
+ siteDescription: string(),
110
+ siteKeywords: string(),
111
+ robotsTxt: string(),
112
+ ...SeoTrackingFields,
113
+ ...SeoOpenGraphFields,
114
+ twitterCardType: string(),
115
+ headerScripts: string(),
116
+ footerScripts: string(),
117
+ enableRobotsTxt: boolean(),
118
+ enableOpenGraph: boolean(),
119
+ enableTwitterCards: boolean(),
120
+ pages: array(PublicPageSchema),
121
+ posts: array(PublicPostSchema),
122
+ });
123
+
124
+ export type PublicSeoDetailsDTO = InferOutput<typeof PublicSeoDetailsSchema>;
125
+
126
+ // --- Page SEO Details Schema ---
127
+
128
+ export const PublicPageSeoDetailsSchema = object({
129
+ id: optional(IdSchema),
130
+ siteId: optional(string()),
131
+ title: optional(string()),
132
+ slug: optional(string()),
133
+ content: optional(string()),
134
+ ...CommonSeoDetailFields,
135
+ twitterCardType: optional(string()),
27
136
  });
28
137
 
29
138
  export type PublicPageSeoDetailsDTO = InferOutput<
30
139
  typeof PublicPageSeoDetailsSchema
31
140
  >;
141
+
142
+ /**
143
+ * Union type of any entity that carries the common SEO detail fields.
144
+ * Useful for building meta/scripts generically from either a page or post response.
145
+ */
146
+ export type AnySeoDetails = PublicPageSeoDetailsDTO | PublicPostSeoDetailsDTO;
@@ -0,0 +1,79 @@
1
+ import { Link } from '@tanstack/react-router'
2
+
3
+ export const NotFound = () => (
4
+ <div
5
+ style={{
6
+ minHeight: '100vh',
7
+ display: 'flex',
8
+ alignItems: 'center',
9
+ justifyContent: 'center',
10
+ backgroundColor: '#f3f4f6',
11
+ }}
12
+ >
13
+ <div
14
+ style={{
15
+ textAlign: 'center',
16
+ paddingLeft: '1.5rem',
17
+ paddingRight: '1.5rem',
18
+ paddingTop: '3rem',
19
+ paddingBottom: '3rem',
20
+ }}
21
+ >
22
+ <h1
23
+ style={{
24
+ fontSize: '8rem',
25
+ lineHeight: '1',
26
+ fontWeight: 'bold',
27
+ color: '#1f2937',
28
+ margin: 0,
29
+ }}
30
+ >
31
+ 404
32
+ </h1>
33
+ <h2
34
+ style={{
35
+ marginTop: '1rem',
36
+ fontSize: '1.875rem',
37
+ lineHeight: '2.25rem',
38
+ fontWeight: '600',
39
+ color: '#374151',
40
+ }}
41
+ >
42
+ Page Not Found
43
+ </h2>
44
+ <p
45
+ style={{
46
+ marginTop: '1rem',
47
+ fontSize: '1.125rem',
48
+ lineHeight: '1.75rem',
49
+ color: '#4b5563',
50
+ maxWidth: '28rem',
51
+ marginLeft: 'auto',
52
+ marginRight: 'auto',
53
+ }}
54
+ >
55
+ Sorry, the page you're looking for doesn't exist or has been moved.
56
+ </p>
57
+ <div style={{ marginTop: '2rem' }}>
58
+ <Link
59
+ to={'/' as '.'}
60
+ style={{
61
+ display: 'inline-block',
62
+ paddingLeft: '1.5rem',
63
+ paddingRight: '1.5rem',
64
+ paddingTop: '0.75rem',
65
+ paddingBottom: '0.75rem',
66
+ backgroundColor: '#2563eb',
67
+ color: '#ffffff',
68
+ fontWeight: '500',
69
+ borderRadius: '0.5rem',
70
+ textDecoration: 'none',
71
+ transition: 'background-color 0.2s',
72
+ }}
73
+ >
74
+ Go to Home
75
+ </Link>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ )
@@ -1,3 +1,4 @@
1
- export * from "./seo/index";
2
- export * from "./editor/index";
3
- export * from "./hooks";
1
+ export * from './seo/index'
2
+ export * from './editor/index'
3
+ export * from './hooks'
4
+ export * from './withCMS'
@@ -1,4 +1,5 @@
1
1
  import { getPublicPageSEODetails } from "../../api/client/public";
2
+ import { getPublicPostBySlug } from "../../api/client/posts";
2
3
  import type { RouteHead } from "./types";
3
4
  import { SITE_ID } from "../../api/constants";
4
5
  import type { RouteOptions } from "@tanstack/router-core";
@@ -12,19 +13,27 @@ import {
12
13
  parseAndAppendScripts,
13
14
  } from "../../libs/parser/parseScripts";
14
15
  import { extractPathname } from "./extractors";
16
+ import type { AnySeoDetails } from "../../api/types/public";
17
+ import type { CMSBlogConfig } from "./types";
15
18
 
16
- export const seoHead: RouteHead<RouteOptions<any, any>> = async (ctx) => {
17
- const { match } = ctx;
18
- const pathname = extractPathname(match);
19
- const seoDetails = await getPublicPageSEODetails(SITE_ID, pathname);
20
- const meta: DetailedHTMLProps<
21
- MetaHTMLAttributes<HTMLMetaElement>,
22
- HTMLMetaElement
23
- >[] = [{ charSet: "utf-8" }];
24
-
25
- if (!seoDetails) {
26
- return { meta };
27
- }
19
+ // ---------------------------------------------------------------------------
20
+ // Generic SEO builders (work for both page and post SEO details)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ type MetaTag = DetailedHTMLProps<
24
+ MetaHTMLAttributes<HTMLMetaElement>,
25
+ HTMLMetaElement
26
+ >;
27
+ type ScriptTag = DetailedHTMLProps<
28
+ ScriptHTMLAttributes<HTMLScriptElement>,
29
+ HTMLScriptElement
30
+ >;
31
+
32
+ /**
33
+ * Build `<meta>` tags from any SEO details object (page or post).
34
+ */
35
+ export function buildSeoMeta(seoDetails: AnySeoDetails): MetaTag[] {
36
+ const meta: MetaTag[] = [];
28
37
 
29
38
  if (seoDetails.metaTitle && seoDetails.metaTitle.length > 0) {
30
39
  meta.push({ title: seoDetails.metaTitle });
@@ -34,12 +43,10 @@ export const seoHead: RouteHead<RouteOptions<any, any>> = async (ctx) => {
34
43
  meta.push({ name: "description", content: seoDetails.metaDescription });
35
44
  }
36
45
 
37
- // SEO Keywords
38
46
  if (seoDetails.seoKeywords && seoDetails.seoKeywords.length > 0) {
39
47
  meta.push({ name: "keywords", content: seoDetails.seoKeywords.join(", ") });
40
48
  }
41
49
 
42
- // Google Site Verification
43
50
  if (seoDetails.googleSiteVerificationId) {
44
51
  meta.push({
45
52
  name: "google-site-verification",
@@ -47,7 +54,7 @@ export const seoHead: RouteHead<RouteOptions<any, any>> = async (ctx) => {
47
54
  });
48
55
  }
49
56
 
50
- // Open Graph tags
57
+ // Open Graph
51
58
  if (seoDetails.openGraphTitle && seoDetails.openGraphTitle.length > 0) {
52
59
  meta.push({ property: "og:title", content: seoDetails.openGraphTitle });
53
60
  }
@@ -64,9 +71,10 @@ export const seoHead: RouteHead<RouteOptions<any, any>> = async (ctx) => {
64
71
  meta.push({ property: "og:image", content: seoDetails.openGraphImage });
65
72
  }
66
73
 
67
- meta.push({ property: "og:type", content: "website" });
74
+ const isPost = "author" in seoDetails;
75
+ meta.push({ property: "og:type", content: isPost ? "article" : "website" });
68
76
 
69
- // Twitter Card tags
77
+ // Twitter Card
70
78
  if (seoDetails.twitterCardType && seoDetails.twitterCardType.length > 0) {
71
79
  meta.push({ name: "twitter:card", content: seoDetails.twitterCardType });
72
80
  if (seoDetails.openGraphTitle && seoDetails.openGraphTitle.length > 0) {
@@ -86,11 +94,15 @@ export const seoHead: RouteHead<RouteOptions<any, any>> = async (ctx) => {
86
94
  }
87
95
  }
88
96
 
89
- // Build scripts array for head
90
- const scripts: DetailedHTMLProps<
91
- ScriptHTMLAttributes<HTMLScriptElement>,
92
- HTMLScriptElement
93
- >[] = [];
97
+ return meta;
98
+ }
99
+
100
+ /**
101
+ * Build `<script>` tags (structured data, pixels, analytics, custom header
102
+ * scripts) from any SEO details object (page or post).
103
+ */
104
+ export function buildSeoScripts(seoDetails: AnySeoDetails): ScriptTag[] {
105
+ const scripts: ScriptTag[] = [];
94
106
 
95
107
  // Structured Data (JSON-LD)
96
108
  if (seoDetails.structuredData) {
@@ -181,11 +193,78 @@ export const seoHead: RouteHead<RouteOptions<any, any>> = async (ctx) => {
181
193
  // Custom header scripts
182
194
  parseAndAppendScripts(scripts, seoDetails.headerScripts);
183
195
 
184
- return {
185
- meta,
186
- scripts: dedupeScripts(scripts),
196
+ return scripts;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Resolve SEO details for a pathname, considering blog routes
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Given a pathname and optional blog configs, fetch the appropriate SEO details.
205
+ *
206
+ * - If the pathname matches `{prefix}/{slug}`, fetch the post by slug.
207
+ * - If the pathname matches `{prefix}` exactly, return page SEO (the list page
208
+ * itself is a CMS page; the blog posts are loaded separately in the loader).
209
+ * - Otherwise fall back to normal page SEO lookup.
210
+ */
211
+ async function resolveSeoDetails(
212
+ pathname: string,
213
+ blogs?: CMSBlogConfig[],
214
+ ): Promise<AnySeoDetails | null> {
215
+ if (blogs) {
216
+ for (const blog of blogs) {
217
+ const prefix = blog.prefix.replace(/^\/+|\/+$/g, "");
218
+
219
+ // Detail page: {prefix}/{slug}
220
+ if (pathname.startsWith(`${prefix}/`)) {
221
+ const slug = pathname.slice(prefix.length + 1);
222
+ if (slug.length > 0) {
223
+ try {
224
+ return await getPublicPostBySlug(slug);
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ // Default: fetch page SEO details
234
+ return getPublicPageSEODetails(SITE_ID, pathname);
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // seoHead — now blog-aware
239
+ // ---------------------------------------------------------------------------
240
+
241
+ export const createSeoHead = (
242
+ blogs?: CMSBlogConfig[],
243
+ ): RouteHead<RouteOptions<any, any>> =>
244
+ async (ctx) => {
245
+ const { match } = ctx;
246
+ const pathname = extractPathname(match);
247
+
248
+ const meta: MetaTag[] = [{ charSet: "utf-8" }];
249
+
250
+ const seoDetails = await resolveSeoDetails(pathname, blogs);
251
+
252
+ if (!seoDetails) {
253
+ return { meta };
254
+ }
255
+
256
+ return {
257
+ meta: [...meta, ...buildSeoMeta(seoDetails)],
258
+ scripts: dedupeScripts(buildSeoScripts(seoDetails)),
259
+ };
187
260
  };
188
- };
261
+
262
+ /** Default seoHead (no blog config — backwards-compatible). */
263
+ export const seoHead: RouteHead<RouteOptions<any, any>> = createSeoHead();
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // headWithSEO
267
+ // ---------------------------------------------------------------------------
189
268
 
190
269
  const toArray = <T>(v?: T | T[]): T[] => {
191
270
  if (v === undefined || v === null) return [];
@@ -195,10 +274,12 @@ const toArray = <T>(v?: T | T[]): T[] => {
195
274
  export const headWithSEO =
196
275
  <T extends RouteOptions<any, any>>(
197
276
  originalHead: RouteHead<T>,
277
+ blogs?: CMSBlogConfig[],
198
278
  ): RouteHead<T> =>
199
279
  async (...params) => {
280
+ const seoHeadFn = blogs ? createSeoHead(blogs) : seoHead;
200
281
  const ourHeadResult = await originalHead(...params);
201
- const seoHeadResult = await seoHead(...params);
282
+ const seoHeadResult = await seoHeadFn(...params);
202
283
 
203
284
  return {
204
285
  meta: [
@@ -1,5 +1,6 @@
1
1
  import { getPublicPageSEODetails } from "../../api/client/public";
2
- import type { RouteScripts } from "./types";
2
+ import { getPublicPostBySlug } from "../../api/client/posts";
3
+ import type { RouteScripts, CMSBlogConfig } from "./types";
3
4
  import { SITE_ID } from "../../api/constants";
4
5
  import {
5
6
  dedupeScripts,
@@ -8,27 +9,60 @@ import {
8
9
  import type { RouteOptions } from "@tanstack/router-core";
9
10
  import { extractPathname } from "./extractors";
10
11
  import type { DetailedHTMLProps, ScriptHTMLAttributes } from "react";
12
+ import type { AnySeoDetails } from "../../api/types/public";
11
13
 
12
- export const seoScripts: RouteScripts<RouteOptions<any, any>> = async (ctx) => {
13
- const { match } = ctx;
14
- const pathname = extractPathname(match);
15
- const seoDetails = await getPublicPageSEODetails(SITE_ID, pathname);
14
+ /**
15
+ * Resolve footer-level SEO details, taking blog routes into account.
16
+ */
17
+ async function resolveFooterSeoDetails(
18
+ pathname: string,
19
+ blogs?: CMSBlogConfig[],
20
+ ): Promise<AnySeoDetails | null> {
21
+ if (blogs) {
22
+ for (const blog of blogs) {
23
+ const prefix = blog.prefix.replace(/^\/+|\/+$/g, "");
24
+ if (pathname.startsWith(`${prefix}/`)) {
25
+ const slug = pathname.slice(prefix.length + 1);
26
+ if (slug.length > 0) {
27
+ try {
28
+ return await getPublicPostBySlug(slug);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ return getPublicPageSEODetails(SITE_ID, pathname);
37
+ }
16
38
 
17
- const scripts: DetailedHTMLProps<
18
- ScriptHTMLAttributes<HTMLScriptElement>,
19
- HTMLScriptElement
20
- >[] = [];
39
+ export const createSeoScripts = (
40
+ blogs?: CMSBlogConfig[],
41
+ ): RouteScripts<RouteOptions<any, any>> =>
42
+ async (ctx) => {
43
+ const { match } = ctx;
44
+ const pathname = extractPathname(match);
45
+ const seoDetails = await resolveFooterSeoDetails(pathname, blogs);
21
46
 
22
- if (!seoDetails) {
23
- return scripts;
24
- }
47
+ const scripts: DetailedHTMLProps<
48
+ ScriptHTMLAttributes<HTMLScriptElement>,
49
+ HTMLScriptElement
50
+ >[] = [];
25
51
 
26
- if (seoDetails.callrailScript) {
27
- scripts.push({
28
- src: seoDetails.callrailScript,
29
- });
30
- }
52
+ if (!seoDetails) {
53
+ return scripts;
54
+ }
55
+
56
+ if (seoDetails.callrailScript) {
57
+ scripts.push({
58
+ src: seoDetails.callrailScript,
59
+ });
60
+ }
61
+
62
+ parseAndAppendScripts(scripts, seoDetails.footerScripts);
63
+ return dedupeScripts(scripts);
64
+ };
31
65
 
32
- parseAndAppendScripts(scripts, seoDetails.footerScripts);
33
- return dedupeScripts(scripts);
34
- };
66
+ /** Default seoScripts (no blog config — backwards-compatible). */
67
+ export const seoScripts: RouteScripts<RouteOptions<any, any>> =
68
+ createSeoScripts();
@@ -1,4 +1,9 @@
1
1
  import type { RouteOptions } from "@tanstack/router-core";
2
+ import type React from "react";
3
+ import type {
4
+ PublicPostDTO,
5
+ PublicPostSeoDetailsDTO,
6
+ } from "../../api/types/public";
2
7
 
3
8
  export type RouteHead<T extends RouteOptions<any, any>> = NonNullable<
4
9
  T["head"]
@@ -7,3 +12,30 @@ export type RouteHead<T extends RouteOptions<any, any>> = NonNullable<
7
12
  export type RouteScripts<T extends RouteOptions<any, any>> = NonNullable<
8
13
  T["scripts"]
9
14
  >;
15
+
16
+ /**
17
+ * Configuration for a single blog section served by the CMS catch-all route.
18
+ *
19
+ * - `prefix` – URL prefix without leading/trailing slashes (e.g. `"blog"`).
20
+ * `/{prefix}` renders the list page, `/{prefix}/{slug}` the detail.
21
+ * - `category` – Optional post category name used to filter posts from the API.
22
+ * When `undefined`/`null`, all posts are returned.
23
+ * - `listComponent` – React component rendered at `/{prefix}` receiving the
24
+ * filtered post list.
25
+ * - `detailComponent` – React component rendered at `/{prefix}/{slug}` receiving
26
+ * the full post SEO details.
27
+ */
28
+ export interface CMSBlogConfig {
29
+ prefix: string;
30
+ category?: string | null;
31
+ listComponent: (posts: PublicPostDTO[]) => React.ReactNode;
32
+ detailComponent: (post: PublicPostSeoDetailsDTO) => React.ReactNode;
33
+ }
34
+
35
+ /**
36
+ * Optional CMS-specific configuration that can be passed to `withCMS` and
37
+ * `headWithSEO` / `createSeoHead`.
38
+ */
39
+ export interface CMSConfig {
40
+ blogs?: CMSBlogConfig[];
41
+ }