rankrunners-cms 0.0.20 → 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.
- package/README.md +12 -9
- package/bun.lock +15 -15
- package/package.json +9 -9
- package/src/api/client/index.ts +1 -0
- package/src/api/client/posts.ts +50 -0
- package/src/api/types/public.ts +134 -19
- package/src/tanstack/seo/head.ts +108 -27
- package/src/tanstack/seo/scripts.ts +54 -20
- package/src/tanstack/seo/types.ts +32 -0
- package/src/tanstack/withCMS.tsx +143 -3
package/README.md
CHANGED
|
@@ -541,19 +541,22 @@ This single route handles ALL CMS pages:
|
|
|
541
541
|
|
|
542
542
|
```typescript
|
|
543
543
|
import { config } from '@/editor'
|
|
544
|
-
import {
|
|
545
|
-
import {
|
|
544
|
+
import { allPageData } from '@/editor/initial-data'
|
|
545
|
+
import { withCMS } from 'rankrunners-cms/src/tanstack'
|
|
546
546
|
import { createFileRoute } from '@tanstack/react-router'
|
|
547
547
|
|
|
548
|
-
export const Route = createFileRoute('
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
})
|
|
548
|
+
export const Route = createFileRoute('/$')(
|
|
549
|
+
withCMS({
|
|
550
|
+
config,
|
|
551
|
+
allPageData,
|
|
552
|
+
}),
|
|
553
|
+
)
|
|
555
554
|
```
|
|
556
555
|
|
|
556
|
+
The CMS functionality can be overriden like any other Tanstack route, by providing `loader`, `loaderDeps` and `component` values. If types are invalid, please let Disyer know.
|
|
557
|
+
|
|
558
|
+
The catch-all page route is responsible for both server-side and client-side rendering of all pages provided by the CMS. It will return 404 for pages that do not have any content and it will serve the editor when in edit mode.
|
|
559
|
+
|
|
557
560
|
### 8.3 How the Page Renderer Works
|
|
558
561
|
|
|
559
562
|
1. Extracts the pathname from the URL (e.g., `/contact` -> `contact`)
|
package/bun.lock
CHANGED
|
@@ -9,22 +9,22 @@
|
|
|
9
9
|
"tailwindcss": "^4.1.18",
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@tanstack/react-router": "^1.
|
|
13
|
-
"@tanstack/router-core": "^1.
|
|
14
|
-
"@types/react": "^19.2.
|
|
15
|
-
"bun-types": "^1.3.
|
|
16
|
-
"lucide-react": "^0.
|
|
12
|
+
"@tanstack/react-router": "^1.160.2",
|
|
13
|
+
"@tanstack/router-core": "^1.160.0",
|
|
14
|
+
"@types/react": "^19.2.14",
|
|
15
|
+
"bun-types": "^1.3.9",
|
|
16
|
+
"lucide-react": "^0.564.0",
|
|
17
17
|
"next": "^16.1.6",
|
|
18
18
|
"typescript": "^5.9.3",
|
|
19
19
|
"valibot": "^1.2.0",
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@puckeditor/core": "^0.21.1",
|
|
23
|
-
"@tanstack/react-router": "^1.
|
|
24
|
-
"@tanstack/router-core": "^1.
|
|
25
|
-
"lucide-react": "^0.
|
|
26
|
-
"next": "^16.1.
|
|
27
|
-
"react": "^19.2.
|
|
23
|
+
"@tanstack/react-router": "^1.160.2",
|
|
24
|
+
"@tanstack/router-core": "^1.160.0",
|
|
25
|
+
"lucide-react": "^0.564.0",
|
|
26
|
+
"next": "^16.1.6",
|
|
27
|
+
"react": "^19.2.4",
|
|
28
28
|
"valibot": "^1.2.0",
|
|
29
29
|
},
|
|
30
30
|
"optionalPeers": [
|
|
@@ -180,11 +180,11 @@
|
|
|
180
180
|
|
|
181
181
|
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
|
|
182
182
|
|
|
183
|
-
"@tanstack/react-router": ["@tanstack/react-router@1.
|
|
183
|
+
"@tanstack/react-router": ["@tanstack/react-router@1.160.2", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.160.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-EJWAMS4qCfWKNCzzYGy6ZuWTdBATYEEWieaQdmM7zUesyOQ01j7o6aKXdmCp9rWuSKjPHXagWubEnEo+Puhi3w=="],
|
|
184
184
|
|
|
185
185
|
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
|
|
186
186
|
|
|
187
|
-
"@tanstack/router-core": ["@tanstack/router-core@1.
|
|
187
|
+
"@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="],
|
|
188
188
|
|
|
189
189
|
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
|
|
190
190
|
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
|
|
241
241
|
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
|
242
242
|
|
|
243
|
-
"@types/react": ["@types/react@19.2.
|
|
243
|
+
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
|
244
244
|
|
|
245
245
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
|
246
246
|
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
|
|
257
257
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": "dist/cli.js" }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
|
|
258
258
|
|
|
259
|
-
"bun-types": ["bun-types@1.3.
|
|
259
|
+
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
|
260
260
|
|
|
261
261
|
"caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
|
|
262
262
|
|
|
@@ -294,7 +294,7 @@
|
|
|
294
294
|
|
|
295
295
|
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
|
|
296
296
|
|
|
297
|
-
"lucide-react": ["lucide-react@0.
|
|
297
|
+
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
|
298
298
|
|
|
299
299
|
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": "bin/markdown-it.mjs" }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
|
|
300
300
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rankrunners-cms",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.21",
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"@puckeditor/core": "^0.21.1",
|
|
7
7
|
"next": "^16.1.6",
|
|
8
|
-
"@tanstack/router-core": "^1.
|
|
9
|
-
"@tanstack/react-router": "^1.
|
|
8
|
+
"@tanstack/router-core": "^1.160.0",
|
|
9
|
+
"@tanstack/react-router": "^1.160.2",
|
|
10
10
|
"valibot": "^1.2.0",
|
|
11
|
-
"lucide-react": "^0.
|
|
11
|
+
"lucide-react": "^0.564.0",
|
|
12
12
|
"react": "^19.2.4"
|
|
13
13
|
},
|
|
14
14
|
"peerDependenciesMeta": {
|
|
@@ -29,14 +29,14 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/react": "^19.2.
|
|
33
|
-
"bun-types": "^1.3.
|
|
32
|
+
"@types/react": "^19.2.14",
|
|
33
|
+
"bun-types": "^1.3.9",
|
|
34
34
|
"typescript": "^5.9.3",
|
|
35
|
-
"@tanstack/router-core": "^1.
|
|
36
|
-
"@tanstack/react-router": "^1.
|
|
35
|
+
"@tanstack/router-core": "^1.160.0",
|
|
36
|
+
"@tanstack/react-router": "^1.160.2",
|
|
37
37
|
"next": "^16.1.6",
|
|
38
38
|
"valibot": "^1.2.0",
|
|
39
|
-
"lucide-react": "^0.
|
|
39
|
+
"lucide-react": "^0.564.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"classnames": "^2.5.1",
|
package/src/api/client/index.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/api/types/public.ts
CHANGED
|
@@ -1,31 +1,146 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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;
|
package/src/tanstack/seo/head.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
74
|
+
const isPost = "author" in seoDetails;
|
|
75
|
+
meta.push({ property: "og:type", content: isPost ? "article" : "website" });
|
|
68
76
|
|
|
69
|
-
// Twitter Card
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
const scripts: DetailedHTMLProps<
|
|
48
|
+
ScriptHTMLAttributes<HTMLScriptElement>,
|
|
49
|
+
HTMLScriptElement
|
|
50
|
+
>[] = [];
|
|
25
51
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
+
}
|
package/src/tanstack/withCMS.tsx
CHANGED
|
@@ -14,18 +14,69 @@ import {
|
|
|
14
14
|
type InferOutput,
|
|
15
15
|
} from "valibot";
|
|
16
16
|
import { getPageContents } from "../api/client/pages";
|
|
17
|
+
import { getPublicPosts } from "../api/client/posts";
|
|
18
|
+
import { getPublicPostBySlug } from "../api/client/posts";
|
|
17
19
|
import { EditorTanstack } from "./editor/Editor";
|
|
18
20
|
import { PageRendererSSR } from "../editor/render/PageRendererSSR";
|
|
19
21
|
import React from "react";
|
|
20
22
|
import type { Config } from "@puckeditor/core";
|
|
21
23
|
import type { CMSUserData } from "../editor/types";
|
|
22
24
|
import { NotFound } from "./components/NotFound";
|
|
25
|
+
import type {
|
|
26
|
+
CMSBlogConfig,
|
|
27
|
+
CMSConfig,
|
|
28
|
+
} from "./seo/types";
|
|
29
|
+
import type {
|
|
30
|
+
PublicPostDTO,
|
|
31
|
+
PublicPostSeoDetailsDTO,
|
|
32
|
+
} from "../api/types/public";
|
|
23
33
|
|
|
24
34
|
// CMS-specific search entries
|
|
25
35
|
const cmsSearchEntries = {
|
|
26
36
|
preview: optional(string()),
|
|
27
37
|
};
|
|
28
38
|
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Blog route matching helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
interface BlogMatch {
|
|
44
|
+
kind: "list" | "detail";
|
|
45
|
+
blog: CMSBlogConfig;
|
|
46
|
+
/** Slug of the post (only set when kind === "detail") */
|
|
47
|
+
slug?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function matchBlogRoute(
|
|
51
|
+
pathname: string,
|
|
52
|
+
blogs?: CMSBlogConfig[],
|
|
53
|
+
): BlogMatch | null {
|
|
54
|
+
if (!blogs) return null;
|
|
55
|
+
|
|
56
|
+
for (const blog of blogs) {
|
|
57
|
+
const prefix = blog.prefix.replace(/^\/+|\/+$/g, "");
|
|
58
|
+
|
|
59
|
+
// Detail: {prefix}/{slug}
|
|
60
|
+
if (pathname.startsWith(`${prefix}/`)) {
|
|
61
|
+
const slug = pathname.slice(prefix.length + 1);
|
|
62
|
+
if (slug.length > 0) {
|
|
63
|
+
return { kind: "detail", blog, slug };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// List: exact match on prefix
|
|
68
|
+
if (pathname === prefix) {
|
|
69
|
+
return { kind: "list", blog };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// withCMS
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
29
80
|
export interface WithCMSOptions<
|
|
30
81
|
TComponents extends Record<string, unknown> = Record<string, unknown>,
|
|
31
82
|
TEntries extends ObjectEntries = Record<string, never>,
|
|
@@ -34,6 +85,8 @@ export interface WithCMSOptions<
|
|
|
34
85
|
> {
|
|
35
86
|
config: Config;
|
|
36
87
|
allPageData: Record<string, CMSUserData<TComponents>>;
|
|
88
|
+
/** Optional CMS-specific settings (blog routing, etc.) */
|
|
89
|
+
cms?: CMSConfig;
|
|
37
90
|
loader?: (args: {
|
|
38
91
|
params: Record<string, string | undefined>;
|
|
39
92
|
deps: TLoaderDeps & { preview?: string };
|
|
@@ -62,6 +115,7 @@ export function withCMS<
|
|
|
62
115
|
const {
|
|
63
116
|
config,
|
|
64
117
|
allPageData,
|
|
118
|
+
cms,
|
|
65
119
|
loader,
|
|
66
120
|
component,
|
|
67
121
|
notFoundComponent,
|
|
@@ -69,6 +123,8 @@ export function withCMS<
|
|
|
69
123
|
loaderDeps,
|
|
70
124
|
} = options;
|
|
71
125
|
|
|
126
|
+
const blogs = cms?.blogs;
|
|
127
|
+
|
|
72
128
|
const mergedValidateSearch = object({
|
|
73
129
|
...(validateSearch?.entries ?? {}),
|
|
74
130
|
...cmsSearchEntries,
|
|
@@ -97,8 +153,57 @@ export function withCMS<
|
|
|
97
153
|
deps: TLoaderDeps & { preview?: string };
|
|
98
154
|
}) => {
|
|
99
155
|
const { params, deps } = args;
|
|
100
|
-
const pathname = params._splat || "";
|
|
156
|
+
const pathname = (params._splat || "").replace(/^\/+|\/+$/g, "");
|
|
157
|
+
|
|
158
|
+
// ---- Blog route handling ----
|
|
159
|
+
const blogMatch = matchBlogRoute(pathname, blogs);
|
|
160
|
+
|
|
161
|
+
if (blogMatch) {
|
|
162
|
+
if (blogMatch.kind === "detail" && blogMatch.slug) {
|
|
163
|
+
// Fetch full post data by slug
|
|
164
|
+
try {
|
|
165
|
+
const postData = await getPublicPostBySlug(blogMatch.slug);
|
|
166
|
+
return {
|
|
167
|
+
pageData: null,
|
|
168
|
+
preview: deps.preview,
|
|
169
|
+
blogMatch: {
|
|
170
|
+
kind: "detail" as const,
|
|
171
|
+
blog: blogMatch.blog,
|
|
172
|
+
postData,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
throw notFound();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
101
179
|
|
|
180
|
+
if (blogMatch.kind === "list") {
|
|
181
|
+
// Fetch post list (optionally filtered by category)
|
|
182
|
+
const posts = await getPublicPosts(
|
|
183
|
+
blogMatch.blog.category ?? undefined,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Also try to load CMS page data for the list page itself
|
|
187
|
+
// (the user may have created a CMS page at the prefix path)
|
|
188
|
+
const pageData = await getPageContents(
|
|
189
|
+
pathname,
|
|
190
|
+
allPageData,
|
|
191
|
+
deps.preview,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
pageData,
|
|
196
|
+
preview: deps.preview,
|
|
197
|
+
blogMatch: {
|
|
198
|
+
kind: "list" as const,
|
|
199
|
+
blog: blogMatch.blog,
|
|
200
|
+
posts,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- Normal CMS page handling ----
|
|
102
207
|
const pageData = await getPageContents(
|
|
103
208
|
pathname,
|
|
104
209
|
allPageData,
|
|
@@ -117,19 +222,50 @@ export function withCMS<
|
|
|
117
222
|
return {
|
|
118
223
|
pageData,
|
|
119
224
|
preview: deps.preview,
|
|
225
|
+
blogMatch: null,
|
|
120
226
|
...customData,
|
|
121
227
|
};
|
|
122
228
|
},
|
|
123
229
|
component:
|
|
124
230
|
component ||
|
|
125
231
|
(() => {
|
|
126
|
-
const
|
|
232
|
+
const loaderData = useLoaderData({
|
|
127
233
|
strict: false,
|
|
128
234
|
}) as unknown as {
|
|
129
|
-
pageData: CMSUserData<TComponents
|
|
235
|
+
pageData: CMSUserData<TComponents> | null;
|
|
130
236
|
preview?: string;
|
|
237
|
+
blogMatch?: {
|
|
238
|
+
kind: "list" | "detail";
|
|
239
|
+
blog: CMSBlogConfig;
|
|
240
|
+
posts?: PublicPostDTO[];
|
|
241
|
+
postData?: PublicPostSeoDetailsDTO;
|
|
242
|
+
} | null;
|
|
131
243
|
};
|
|
132
244
|
|
|
245
|
+
const { pageData, preview, blogMatch } = loaderData;
|
|
246
|
+
|
|
247
|
+
// ---- Blog rendering ----
|
|
248
|
+
if (blogMatch) {
|
|
249
|
+
if (blogMatch.kind === "detail" && blogMatch.postData) {
|
|
250
|
+
return <>{blogMatch.blog.detailComponent(blogMatch.postData)}</>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (blogMatch.kind === "list" && blogMatch.posts) {
|
|
254
|
+
// If there's also a CMS page for the list prefix, render it
|
|
255
|
+
// above/around the blog list -- or just render the list component.
|
|
256
|
+
if (pageData && !preview) {
|
|
257
|
+
return (
|
|
258
|
+
<>
|
|
259
|
+
<PageRendererSSR config={config} data={pageData} />
|
|
260
|
+
{blogMatch.blog.listComponent(blogMatch.posts)}
|
|
261
|
+
</>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return <>{blogMatch.blog.listComponent(blogMatch.posts)}</>;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- Normal CMS page rendering ----
|
|
133
269
|
if (preview) {
|
|
134
270
|
return (
|
|
135
271
|
<EditorTanstack
|
|
@@ -144,6 +280,10 @@ export function withCMS<
|
|
|
144
280
|
);
|
|
145
281
|
}
|
|
146
282
|
|
|
283
|
+
if (!pageData) {
|
|
284
|
+
return <NotFound />;
|
|
285
|
+
}
|
|
286
|
+
|
|
147
287
|
return <PageRendererSSR config={config} data={pageData} />;
|
|
148
288
|
}),
|
|
149
289
|
notFoundComponent: notFoundComponent || NotFound,
|