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.
@@ -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
+ }