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,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
notFound,
|
|
3
|
+
useLoaderData,
|
|
4
|
+
type RouteComponent,
|
|
5
|
+
} from "@tanstack/react-router";
|
|
6
|
+
import {
|
|
7
|
+
object,
|
|
8
|
+
optional,
|
|
9
|
+
string,
|
|
10
|
+
type ObjectSchema,
|
|
11
|
+
type ObjectEntries,
|
|
12
|
+
type ErrorMessage,
|
|
13
|
+
type ObjectIssue,
|
|
14
|
+
type InferOutput,
|
|
15
|
+
} from "valibot";
|
|
16
|
+
import { getPageContents } from "../api/client/pages";
|
|
17
|
+
import { getPublicPosts } from "../api/client/posts";
|
|
18
|
+
import { getPublicPostBySlug } from "../api/client/posts";
|
|
19
|
+
import { EditorTanstack } from "./editor/Editor";
|
|
20
|
+
import { PageRendererSSR } from "../editor/render/PageRendererSSR";
|
|
21
|
+
import React from "react";
|
|
22
|
+
import type { Config } from "@puckeditor/core";
|
|
23
|
+
import type { CMSUserData } from "../editor/types";
|
|
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";
|
|
33
|
+
|
|
34
|
+
// CMS-specific search entries
|
|
35
|
+
const cmsSearchEntries = {
|
|
36
|
+
preview: optional(string()),
|
|
37
|
+
};
|
|
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
|
+
|
|
80
|
+
export interface WithCMSOptions<
|
|
81
|
+
TComponents extends Record<string, unknown> = Record<string, unknown>,
|
|
82
|
+
TEntries extends ObjectEntries = Record<string, never>,
|
|
83
|
+
TLoaderDeps extends Record<string, unknown> = Record<string, unknown>,
|
|
84
|
+
TLoaderReturn extends Record<string, unknown> = Record<string, unknown>,
|
|
85
|
+
> {
|
|
86
|
+
config: Config;
|
|
87
|
+
allPageData: Record<string, CMSUserData<TComponents>>;
|
|
88
|
+
/** Optional CMS-specific settings (blog routing, etc.) */
|
|
89
|
+
cms?: CMSConfig;
|
|
90
|
+
loader?: (args: {
|
|
91
|
+
params: Record<string, string | undefined>;
|
|
92
|
+
deps: TLoaderDeps & { preview?: string };
|
|
93
|
+
pageData: CMSUserData<TComponents>;
|
|
94
|
+
preview?: string;
|
|
95
|
+
}) => TLoaderReturn | Promise<TLoaderReturn>;
|
|
96
|
+
component?: RouteComponent;
|
|
97
|
+
notFoundComponent?: RouteComponent;
|
|
98
|
+
validateSearch?: ObjectSchema<
|
|
99
|
+
TEntries,
|
|
100
|
+
ErrorMessage<ObjectIssue> | undefined
|
|
101
|
+
>;
|
|
102
|
+
loaderDeps?: (args: {
|
|
103
|
+
search: InferOutput<
|
|
104
|
+
ObjectSchema<TEntries, ErrorMessage<ObjectIssue> | undefined>
|
|
105
|
+
> & { preview?: string };
|
|
106
|
+
}) => TLoaderDeps;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function withCMS<
|
|
110
|
+
TComponents extends Record<string, unknown> = Record<string, unknown>,
|
|
111
|
+
TEntries extends ObjectEntries = Record<string, never>,
|
|
112
|
+
TLoaderDeps extends Record<string, unknown> = Record<string, unknown>,
|
|
113
|
+
TLoaderReturn extends Record<string, unknown> = Record<string, unknown>,
|
|
114
|
+
>(options: WithCMSOptions<TComponents, TEntries, TLoaderDeps, TLoaderReturn>) {
|
|
115
|
+
const {
|
|
116
|
+
config,
|
|
117
|
+
allPageData,
|
|
118
|
+
cms,
|
|
119
|
+
loader,
|
|
120
|
+
component,
|
|
121
|
+
notFoundComponent,
|
|
122
|
+
validateSearch,
|
|
123
|
+
loaderDeps,
|
|
124
|
+
} = options;
|
|
125
|
+
|
|
126
|
+
const blogs = cms?.blogs;
|
|
127
|
+
|
|
128
|
+
const mergedValidateSearch = object({
|
|
129
|
+
...(validateSearch?.entries ?? {}),
|
|
130
|
+
...cmsSearchEntries,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
...options,
|
|
135
|
+
validateSearch: mergedValidateSearch,
|
|
136
|
+
loaderDeps: (args: {
|
|
137
|
+
search: InferOutput<typeof mergedValidateSearch>;
|
|
138
|
+
}): TLoaderDeps & { preview?: string } => {
|
|
139
|
+
const cmsDeps = { preview: args.search.preview };
|
|
140
|
+
const userDeps = loaderDeps
|
|
141
|
+
? loaderDeps(
|
|
142
|
+
args as unknown as {
|
|
143
|
+
search: InferOutput<
|
|
144
|
+
ObjectSchema<TEntries, ErrorMessage<ObjectIssue> | undefined>
|
|
145
|
+
> & { preview?: string };
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
: ({} as TLoaderDeps);
|
|
149
|
+
return { ...cmsDeps, ...userDeps };
|
|
150
|
+
},
|
|
151
|
+
loader: async (args: {
|
|
152
|
+
params: { _splat?: string };
|
|
153
|
+
deps: TLoaderDeps & { preview?: string };
|
|
154
|
+
}) => {
|
|
155
|
+
const { params, deps } = args;
|
|
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
|
+
}
|
|
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 ----
|
|
207
|
+
const pageData = await getPageContents(
|
|
208
|
+
pathname,
|
|
209
|
+
allPageData,
|
|
210
|
+
deps.preview,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (!pageData) {
|
|
214
|
+
throw notFound();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let customData = {} as TLoaderReturn;
|
|
218
|
+
if (loader) {
|
|
219
|
+
customData = await loader({ ...args, pageData, preview: deps.preview });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
pageData,
|
|
224
|
+
preview: deps.preview,
|
|
225
|
+
blogMatch: null,
|
|
226
|
+
...customData,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
component:
|
|
230
|
+
component ||
|
|
231
|
+
(() => {
|
|
232
|
+
const loaderData = useLoaderData({
|
|
233
|
+
strict: false,
|
|
234
|
+
}) as unknown as {
|
|
235
|
+
pageData: CMSUserData<TComponents> | null;
|
|
236
|
+
preview?: string;
|
|
237
|
+
blogMatch?: {
|
|
238
|
+
kind: "list" | "detail";
|
|
239
|
+
blog: CMSBlogConfig;
|
|
240
|
+
posts?: PublicPostDTO[];
|
|
241
|
+
postData?: PublicPostSeoDetailsDTO;
|
|
242
|
+
} | null;
|
|
243
|
+
};
|
|
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 ----
|
|
269
|
+
if (preview) {
|
|
270
|
+
return (
|
|
271
|
+
<EditorTanstack
|
|
272
|
+
config={config}
|
|
273
|
+
allPageData={
|
|
274
|
+
allPageData as unknown as Record<
|
|
275
|
+
string,
|
|
276
|
+
CMSUserData<Record<string, unknown>>
|
|
277
|
+
>
|
|
278
|
+
}
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!pageData) {
|
|
284
|
+
return <NotFound />;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return <PageRendererSSR config={config} data={pageData} />;
|
|
288
|
+
}),
|
|
289
|
+
notFoundComponent: notFoundComponent || NotFound,
|
|
290
|
+
};
|
|
291
|
+
}
|