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 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.21",
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,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 { pageData, preview } = useLoaderData({
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,