rankrunners-cms 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/bun.lock +421 -0
- package/package.json +11 -11
- 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/components/NotFound.tsx +79 -0
- package/src/tanstack/index.ts +4 -3
- 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 +291 -0
|
@@ -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;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
export const NotFound = () => (
|
|
4
|
+
<div
|
|
5
|
+
style={{
|
|
6
|
+
minHeight: '100vh',
|
|
7
|
+
display: 'flex',
|
|
8
|
+
alignItems: 'center',
|
|
9
|
+
justifyContent: 'center',
|
|
10
|
+
backgroundColor: '#f3f4f6',
|
|
11
|
+
}}
|
|
12
|
+
>
|
|
13
|
+
<div
|
|
14
|
+
style={{
|
|
15
|
+
textAlign: 'center',
|
|
16
|
+
paddingLeft: '1.5rem',
|
|
17
|
+
paddingRight: '1.5rem',
|
|
18
|
+
paddingTop: '3rem',
|
|
19
|
+
paddingBottom: '3rem',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<h1
|
|
23
|
+
style={{
|
|
24
|
+
fontSize: '8rem',
|
|
25
|
+
lineHeight: '1',
|
|
26
|
+
fontWeight: 'bold',
|
|
27
|
+
color: '#1f2937',
|
|
28
|
+
margin: 0,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
404
|
|
32
|
+
</h1>
|
|
33
|
+
<h2
|
|
34
|
+
style={{
|
|
35
|
+
marginTop: '1rem',
|
|
36
|
+
fontSize: '1.875rem',
|
|
37
|
+
lineHeight: '2.25rem',
|
|
38
|
+
fontWeight: '600',
|
|
39
|
+
color: '#374151',
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
Page Not Found
|
|
43
|
+
</h2>
|
|
44
|
+
<p
|
|
45
|
+
style={{
|
|
46
|
+
marginTop: '1rem',
|
|
47
|
+
fontSize: '1.125rem',
|
|
48
|
+
lineHeight: '1.75rem',
|
|
49
|
+
color: '#4b5563',
|
|
50
|
+
maxWidth: '28rem',
|
|
51
|
+
marginLeft: 'auto',
|
|
52
|
+
marginRight: 'auto',
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
Sorry, the page you're looking for doesn't exist or has been moved.
|
|
56
|
+
</p>
|
|
57
|
+
<div style={{ marginTop: '2rem' }}>
|
|
58
|
+
<Link
|
|
59
|
+
to={'/' as '.'}
|
|
60
|
+
style={{
|
|
61
|
+
display: 'inline-block',
|
|
62
|
+
paddingLeft: '1.5rem',
|
|
63
|
+
paddingRight: '1.5rem',
|
|
64
|
+
paddingTop: '0.75rem',
|
|
65
|
+
paddingBottom: '0.75rem',
|
|
66
|
+
backgroundColor: '#2563eb',
|
|
67
|
+
color: '#ffffff',
|
|
68
|
+
fontWeight: '500',
|
|
69
|
+
borderRadius: '0.5rem',
|
|
70
|
+
textDecoration: 'none',
|
|
71
|
+
transition: 'background-color 0.2s',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
Go to Home
|
|
75
|
+
</Link>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
package/src/tanstack/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
3
|
-
export * from
|
|
1
|
+
export * from './seo/index'
|
|
2
|
+
export * from './editor/index'
|
|
3
|
+
export * from './hooks'
|
|
4
|
+
export * from './withCMS'
|
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
|
+
}
|