rankrunners-cms 0.0.20 → 0.0.22

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 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 { initialData } from '@/editor/initial-data'
545
- import { PageRendererTanstack } from 'rankrunners-cms/src/tanstack'
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
- component: () =>
550
- PageRendererTanstack({
551
- config,
552
- allPageData: initialData,
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.158.0",
13
- "@tanstack/router-core": "^1.158.0",
14
- "@types/react": "^19.2.11",
15
- "bun-types": "^1.3.8",
16
- "lucide-react": "^0.563.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.150.0",
24
- "@tanstack/router-core": "^1.150.0",
25
- "lucide-react": "^0.562.0",
26
- "next": "^16.1.2",
27
- "react": "^19.2.3",
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.158.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.158.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-kvTaO6zjq9WWPyo1wwSZx95AjJ9KOvu23cOMgKeDdDQWKF3Z9q3fwhToKMKJoC11T2Vuivz+o/anrxCcOvdRzw=="],
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.158.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-dRMcWY0UB/6OZqSCx/7iUvom0ol18rHSQladygVT8mlth7uxYx3n5BNse8C03efIE8y1Bx+VDOBAKpAZ9BgKog=="],
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.11", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g=="],
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.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
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.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
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.20",
4
+ "version": "0.0.22",
5
5
  "peerDependencies": {
6
6
  "@puckeditor/core": "^0.21.1",
7
7
  "next": "^16.1.6",
8
- "@tanstack/router-core": "^1.158.0",
9
- "@tanstack/react-router": "^1.158.0",
8
+ "@tanstack/router-core": "^1.160.0",
9
+ "@tanstack/react-router": "^1.160.2",
10
10
  "valibot": "^1.2.0",
11
- "lucide-react": "^0.563.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.11",
33
- "bun-types": "^1.3.8",
32
+ "@types/react": "^19.2.14",
33
+ "bun-types": "^1.3.9",
34
34
  "typescript": "^5.9.3",
35
- "@tanstack/router-core": "^1.158.0",
36
- "@tanstack/react-router": "^1.158.0",
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.563.0"
39
+ "lucide-react": "^0.564.0"
40
40
  },
41
41
  "dependencies": {
42
42
  "classnames": "^2.5.1",
@@ -1,2 +1,3 @@
1
+ export * from "./posts";
1
2
  export * from "./public";
2
3
  export * from "./sitemap";
@@ -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;
@@ -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
+ }
@@ -14,18 +14,66 @@ 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 { CMSBlogConfig, CMSConfig } from "./seo/types";
26
+ import type {
27
+ PublicPostDTO,
28
+ PublicPostSeoDetailsDTO,
29
+ } from "../api/types/public";
23
30
 
24
31
  // CMS-specific search entries
25
32
  const cmsSearchEntries = {
26
33
  preview: optional(string()),
27
34
  };
28
35
 
36
+ // ---------------------------------------------------------------------------
37
+ // Blog route matching helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ interface BlogMatch {
41
+ kind: "list" | "detail";
42
+ blogIndex: number;
43
+ /** Slug of the post (only set when kind === "detail") */
44
+ slug?: string;
45
+ }
46
+
47
+ function matchBlogRoute(
48
+ pathname: string,
49
+ blogs?: CMSBlogConfig[],
50
+ ): BlogMatch | null {
51
+ if (!blogs) return null;
52
+
53
+ for (const [index, blog] of blogs.entries()) {
54
+ const prefix = blog.prefix.replace(/^\/+|\/+$/g, "");
55
+
56
+ // Detail: {prefix}/{slug}
57
+ if (pathname.startsWith(`${prefix}/`)) {
58
+ const slug = pathname.slice(prefix.length + 1);
59
+ if (slug.length > 0) {
60
+ return { kind: "detail", blogIndex: index, slug };
61
+ }
62
+ }
63
+
64
+ // List: exact match on prefix
65
+ if (pathname === prefix) {
66
+ return { kind: "list", blogIndex: index };
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // withCMS
75
+ // ---------------------------------------------------------------------------
76
+
29
77
  export interface WithCMSOptions<
30
78
  TComponents extends Record<string, unknown> = Record<string, unknown>,
31
79
  TEntries extends ObjectEntries = Record<string, never>,
@@ -34,6 +82,8 @@ export interface WithCMSOptions<
34
82
  > {
35
83
  config: Config;
36
84
  allPageData: Record<string, CMSUserData<TComponents>>;
85
+ /** Optional CMS-specific settings (blog routing, etc.) */
86
+ cms?: CMSConfig;
37
87
  loader?: (args: {
38
88
  params: Record<string, string | undefined>;
39
89
  deps: TLoaderDeps & { preview?: string };
@@ -62,6 +112,7 @@ export function withCMS<
62
112
  const {
63
113
  config,
64
114
  allPageData,
115
+ cms,
65
116
  loader,
66
117
  component,
67
118
  notFoundComponent,
@@ -69,6 +120,8 @@ export function withCMS<
69
120
  loaderDeps,
70
121
  } = options;
71
122
 
123
+ const blogs = cms?.blogs;
124
+
72
125
  const mergedValidateSearch = object({
73
126
  ...(validateSearch?.entries ?? {}),
74
127
  ...cmsSearchEntries,
@@ -97,8 +150,51 @@ export function withCMS<
97
150
  deps: TLoaderDeps & { preview?: string };
98
151
  }) => {
99
152
  const { params, deps } = args;
100
- const pathname = params._splat || "";
153
+ const pathname = (params._splat || "").replace(/^\/+|\/+$/g, "");
101
154
 
155
+ // ---- Blog route handling ----
156
+ const blogMatch = matchBlogRoute(pathname, blogs);
157
+
158
+ if (blogMatch) {
159
+ const blog = blogs?.[blogMatch.blogIndex];
160
+ if (!blog) {
161
+ throw notFound();
162
+ }
163
+ if (blogMatch.kind === "detail" && blogMatch.slug) {
164
+ // Fetch full post data by slug
165
+ try {
166
+ const postData = await getPublicPostBySlug(blogMatch.slug);
167
+ return {
168
+ pageData: null,
169
+ preview: deps.preview,
170
+ blogMatch: {
171
+ kind: "detail" as const,
172
+ blogIndex: blogMatch.blogIndex,
173
+ postData,
174
+ },
175
+ };
176
+ } catch {
177
+ throw notFound();
178
+ }
179
+ }
180
+
181
+ if (blogMatch.kind === "list") {
182
+ // Fetch post list (optionally filtered by category)
183
+ const posts = await getPublicPosts(blog.category ?? undefined);
184
+
185
+ return {
186
+ pageData: null,
187
+ preview: deps.preview,
188
+ blogMatch: {
189
+ kind: "list" as const,
190
+ blogIndex: blogMatch.blogIndex,
191
+ posts,
192
+ },
193
+ };
194
+ }
195
+ }
196
+
197
+ // ---- Normal CMS page handling ----
102
198
  const pageData = await getPageContents(
103
199
  pathname,
104
200
  allPageData,
@@ -117,19 +213,46 @@ export function withCMS<
117
213
  return {
118
214
  pageData,
119
215
  preview: deps.preview,
216
+ blogMatch: null,
120
217
  ...customData,
121
218
  };
122
219
  },
123
220
  component:
124
221
  component ||
125
222
  (() => {
126
- const { pageData, preview } = useLoaderData({
223
+ const loaderData = useLoaderData({
127
224
  strict: false,
128
225
  }) as unknown as {
129
- pageData: CMSUserData<TComponents>;
226
+ pageData: CMSUserData<TComponents> | null;
130
227
  preview?: string;
228
+ blogMatch: {
229
+ kind: "list" | "detail";
230
+ blogIndex: number;
231
+ posts?: PublicPostDTO[];
232
+ postData?: PublicPostSeoDetailsDTO;
233
+ } | null;
131
234
  };
132
235
 
236
+ const { pageData, preview, blogMatch } = loaderData;
237
+
238
+ // ---- Blog rendering ----
239
+ if (blogMatch) {
240
+ const blog = blogs?.[blogMatch.blogIndex];
241
+
242
+ if (!blog) {
243
+ return <NotFound />;
244
+ }
245
+
246
+ if (blogMatch.kind === "detail" && blogMatch.postData) {
247
+ return blog.detailComponent(blogMatch.postData);
248
+ }
249
+
250
+ if (blogMatch.kind === "list" && blogMatch.posts) {
251
+ return blog.listComponent(blogMatch.posts);
252
+ }
253
+ }
254
+
255
+ // ---- Normal CMS page rendering ----
133
256
  if (preview) {
134
257
  return (
135
258
  <EditorTanstack
@@ -144,6 +267,10 @@ export function withCMS<
144
267
  );
145
268
  }
146
269
 
270
+ if (!pageData) {
271
+ return <NotFound />;
272
+ }
273
+
147
274
  return <PageRendererSSR config={config} data={pageData} />;
148
275
  }),
149
276
  notFoundComponent: notFoundComponent || NotFound,