@btst/stack 1.0.1 → 1.1.1

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.
Files changed (282) hide show
  1. package/README.md +156 -709
  2. package/dist/api/index.cjs +2 -1
  3. package/dist/api/index.d.cts +4 -3
  4. package/dist/api/index.d.mts +4 -3
  5. package/dist/api/index.d.ts +4 -3
  6. package/dist/api/index.mjs +1 -1
  7. package/dist/client/components/compose.cjs +68 -0
  8. package/dist/client/components/compose.mjs +65 -0
  9. package/dist/client/components/error-boundary.cjs +24 -0
  10. package/dist/client/components/error-boundary.mjs +22 -0
  11. package/dist/client/components/index.cjs +10 -0
  12. package/dist/client/components/index.d.cts +52 -0
  13. package/dist/client/components/index.d.mts +52 -0
  14. package/dist/client/components/index.d.ts +52 -0
  15. package/dist/client/components/index.mjs +2 -0
  16. package/dist/client/index.cjs +24 -5
  17. package/dist/client/index.d.cts +125 -8
  18. package/dist/client/index.d.mts +125 -8
  19. package/dist/client/index.d.ts +125 -8
  20. package/dist/client/index.mjs +21 -4
  21. package/dist/client/meta-utils.cjs +162 -0
  22. package/dist/client/meta-utils.mjs +160 -0
  23. package/dist/client/path-utils.cjs +15 -0
  24. package/dist/client/path-utils.mjs +13 -0
  25. package/dist/client/sitemap-utils.cjs +14 -0
  26. package/dist/client/sitemap-utils.mjs +12 -0
  27. package/dist/context/index.cjs +6 -63
  28. package/dist/context/index.d.cts +21 -24
  29. package/dist/context/index.d.mts +21 -24
  30. package/dist/context/index.d.ts +21 -24
  31. package/dist/context/index.mjs +1 -61
  32. package/dist/context/provider.cjs +51 -0
  33. package/dist/context/provider.mjs +46 -0
  34. package/dist/index.cjs +2 -3
  35. package/dist/index.d.cts +3 -2
  36. package/dist/index.d.mts +3 -2
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.mjs +1 -2
  39. package/dist/plugins/api/index.cjs +13 -0
  40. package/dist/plugins/api/index.d.cts +40 -0
  41. package/dist/plugins/api/index.d.mts +40 -0
  42. package/dist/plugins/api/index.d.ts +40 -0
  43. package/dist/plugins/api/index.mjs +8 -0
  44. package/dist/plugins/blog/api/index.cjs +11 -0
  45. package/dist/plugins/blog/api/index.d.cts +7 -0
  46. package/dist/plugins/blog/api/index.d.mts +7 -0
  47. package/dist/plugins/blog/api/index.d.ts +7 -0
  48. package/dist/plugins/blog/api/index.mjs +2 -0
  49. package/dist/plugins/blog/api/plugin.cjs +569 -0
  50. package/dist/plugins/blog/api/plugin.mjs +565 -0
  51. package/dist/plugins/blog/client/components/forms/image-field.cjs +133 -0
  52. package/dist/plugins/blog/client/components/forms/image-field.mjs +131 -0
  53. package/dist/plugins/blog/client/components/forms/markdown-editor-styles.css +30 -0
  54. package/dist/plugins/blog/client/components/forms/markdown-editor.cjs +106 -0
  55. package/dist/plugins/blog/client/components/forms/markdown-editor.mjs +104 -0
  56. package/dist/plugins/blog/client/components/forms/post-forms.cjs +401 -0
  57. package/dist/plugins/blog/client/components/forms/post-forms.mjs +398 -0
  58. package/dist/plugins/blog/client/components/forms/tags-multiselect.cjs +71 -0
  59. package/dist/plugins/blog/client/components/forms/tags-multiselect.mjs +65 -0
  60. package/dist/plugins/blog/client/components/index.cjs +17 -0
  61. package/dist/plugins/blog/client/components/index.d.cts +22 -0
  62. package/dist/plugins/blog/client/components/index.d.mts +22 -0
  63. package/dist/plugins/blog/client/components/index.d.ts +22 -0
  64. package/dist/plugins/blog/client/components/index.mjs +12 -0
  65. package/dist/plugins/blog/client/components/loading/form-page-skeleton.cjs +62 -0
  66. package/dist/plugins/blog/client/components/loading/form-page-skeleton.mjs +60 -0
  67. package/dist/plugins/blog/client/components/loading/index.cjs +20 -0
  68. package/dist/plugins/blog/client/components/loading/index.mjs +16 -0
  69. package/dist/plugins/blog/client/components/loading/list-page-skeleton.cjs +26 -0
  70. package/dist/plugins/blog/client/components/loading/list-page-skeleton.mjs +24 -0
  71. package/dist/plugins/blog/client/components/loading/page-header-skeleton.cjs +13 -0
  72. package/dist/plugins/blog/client/components/loading/page-header-skeleton.mjs +11 -0
  73. package/dist/plugins/blog/client/components/loading/post-card-skeleton.cjs +22 -0
  74. package/dist/plugins/blog/client/components/loading/post-card-skeleton.mjs +20 -0
  75. package/dist/plugins/blog/client/components/loading/post-page-skeleton.cjs +56 -0
  76. package/dist/plugins/blog/client/components/loading/post-page-skeleton.mjs +54 -0
  77. package/dist/plugins/blog/client/components/pages/404-page.cjs +19 -0
  78. package/dist/plugins/blog/client/components/pages/404-page.mjs +17 -0
  79. package/dist/plugins/blog/client/components/pages/edit-post-page.cjs +41 -0
  80. package/dist/plugins/blog/client/components/pages/edit-post-page.internal.cjs +57 -0
  81. package/dist/plugins/blog/client/components/pages/edit-post-page.internal.mjs +55 -0
  82. package/dist/plugins/blog/client/components/pages/edit-post-page.mjs +39 -0
  83. package/dist/plugins/blog/client/components/pages/home-page.cjs +41 -0
  84. package/dist/plugins/blog/client/components/pages/home-page.internal.cjs +61 -0
  85. package/dist/plugins/blog/client/components/pages/home-page.internal.mjs +59 -0
  86. package/dist/plugins/blog/client/components/pages/home-page.mjs +39 -0
  87. package/dist/plugins/blog/client/components/pages/new-post-page.cjs +37 -0
  88. package/dist/plugins/blog/client/components/pages/new-post-page.internal.cjs +53 -0
  89. package/dist/plugins/blog/client/components/pages/new-post-page.internal.mjs +51 -0
  90. package/dist/plugins/blog/client/components/pages/new-post-page.mjs +35 -0
  91. package/dist/plugins/blog/client/components/pages/post-page.cjs +39 -0
  92. package/dist/plugins/blog/client/components/pages/post-page.internal.cjs +101 -0
  93. package/dist/plugins/blog/client/components/pages/post-page.internal.mjs +99 -0
  94. package/dist/plugins/blog/client/components/pages/post-page.mjs +37 -0
  95. package/dist/plugins/blog/client/components/pages/tag-page.cjs +39 -0
  96. package/dist/plugins/blog/client/components/pages/tag-page.internal.cjs +61 -0
  97. package/dist/plugins/blog/client/components/pages/tag-page.internal.mjs +59 -0
  98. package/dist/plugins/blog/client/components/pages/tag-page.mjs +37 -0
  99. package/dist/plugins/blog/client/components/shared/better-blog-attribution.cjs +24 -0
  100. package/dist/plugins/blog/client/components/shared/better-blog-attribution.mjs +22 -0
  101. package/dist/plugins/blog/client/components/shared/default-error.cjs +18 -0
  102. package/dist/plugins/blog/client/components/shared/default-error.mjs +16 -0
  103. package/dist/plugins/blog/client/components/shared/defaults.cjs +13 -0
  104. package/dist/plugins/blog/client/components/shared/defaults.mjs +10 -0
  105. package/dist/plugins/blog/client/components/shared/empty-list.cjs +21 -0
  106. package/dist/plugins/blog/client/components/shared/empty-list.mjs +19 -0
  107. package/dist/plugins/blog/client/components/shared/error-placeholder.cjs +24 -0
  108. package/dist/plugins/blog/client/components/shared/error-placeholder.mjs +22 -0
  109. package/dist/plugins/blog/client/components/shared/highlight-text.cjs +53 -0
  110. package/dist/plugins/blog/client/components/shared/highlight-text.mjs +51 -0
  111. package/dist/plugins/blog/client/components/shared/markdown-content-styles.css +328 -0
  112. package/dist/plugins/blog/client/components/shared/markdown-content.cjs +324 -0
  113. package/dist/plugins/blog/client/components/shared/markdown-content.mjs +315 -0
  114. package/dist/plugins/blog/client/components/shared/on-this-page.cjs +161 -0
  115. package/dist/plugins/blog/client/components/shared/on-this-page.mjs +158 -0
  116. package/dist/plugins/blog/client/components/shared/page-header.cjs +40 -0
  117. package/dist/plugins/blog/client/components/shared/page-header.mjs +38 -0
  118. package/dist/plugins/blog/client/components/shared/page-layout.cjs +24 -0
  119. package/dist/plugins/blog/client/components/shared/page-layout.mjs +22 -0
  120. package/dist/plugins/blog/client/components/shared/page-wrapper.cjs +23 -0
  121. package/dist/plugins/blog/client/components/shared/page-wrapper.mjs +21 -0
  122. package/dist/plugins/blog/client/components/shared/post-card.cjs +279 -0
  123. package/dist/plugins/blog/client/components/shared/post-card.mjs +277 -0
  124. package/dist/plugins/blog/client/components/shared/post-navigation.cjs +74 -0
  125. package/dist/plugins/blog/client/components/shared/post-navigation.mjs +72 -0
  126. package/dist/plugins/blog/client/components/shared/posts-list.cjs +48 -0
  127. package/dist/plugins/blog/client/components/shared/posts-list.mjs +46 -0
  128. package/dist/plugins/blog/client/components/shared/recent-posts-carousel.cjs +59 -0
  129. package/dist/plugins/blog/client/components/shared/recent-posts-carousel.mjs +57 -0
  130. package/dist/plugins/blog/client/components/shared/search-input.cjs +136 -0
  131. package/dist/plugins/blog/client/components/shared/search-input.mjs +117 -0
  132. package/dist/plugins/blog/client/components/shared/search-modal.cjs +135 -0
  133. package/dist/plugins/blog/client/components/shared/search-modal.mjs +116 -0
  134. package/dist/plugins/blog/client/components/shared/tags-list.cjs +22 -0
  135. package/dist/plugins/blog/client/components/shared/tags-list.mjs +20 -0
  136. package/dist/plugins/blog/client/components/shared/use-route-lifecycle.cjs +50 -0
  137. package/dist/plugins/blog/client/components/shared/use-route-lifecycle.mjs +48 -0
  138. package/dist/plugins/blog/client/hooks/blog-hooks.cjs +380 -0
  139. package/dist/plugins/blog/client/hooks/blog-hooks.mjs +368 -0
  140. package/dist/plugins/blog/client/hooks/index.cjs +17 -0
  141. package/dist/plugins/blog/client/hooks/index.d.cts +150 -0
  142. package/dist/plugins/blog/client/hooks/index.d.mts +150 -0
  143. package/dist/plugins/blog/client/hooks/index.d.ts +150 -0
  144. package/dist/plugins/blog/client/hooks/index.mjs +1 -0
  145. package/dist/plugins/blog/client/hooks/use-debounce.cjs +16 -0
  146. package/dist/plugins/blog/client/hooks/use-debounce.mjs +14 -0
  147. package/dist/plugins/blog/client/index.cjs +7 -0
  148. package/dist/plugins/blog/client/index.d.cts +414 -0
  149. package/dist/plugins/blog/client/index.d.mts +414 -0
  150. package/dist/plugins/blog/client/index.d.ts +414 -0
  151. package/dist/plugins/blog/client/index.mjs +1 -0
  152. package/dist/plugins/blog/client/localization/blog-card.cjs +7 -0
  153. package/dist/plugins/blog/client/localization/blog-card.mjs +5 -0
  154. package/dist/plugins/blog/client/localization/blog-common.cjs +10 -0
  155. package/dist/plugins/blog/client/localization/blog-common.mjs +8 -0
  156. package/dist/plugins/blog/client/localization/blog-forms.cjs +40 -0
  157. package/dist/plugins/blog/client/localization/blog-forms.mjs +38 -0
  158. package/dist/plugins/blog/client/localization/blog-list.cjs +18 -0
  159. package/dist/plugins/blog/client/localization/blog-list.mjs +16 -0
  160. package/dist/plugins/blog/client/localization/blog-post.cjs +13 -0
  161. package/dist/plugins/blog/client/localization/blog-post.mjs +11 -0
  162. package/dist/plugins/blog/client/localization/index.cjs +17 -0
  163. package/dist/plugins/blog/client/localization/index.mjs +15 -0
  164. package/dist/plugins/blog/client/plugin.cjs +462 -0
  165. package/dist/plugins/blog/client/plugin.mjs +460 -0
  166. package/dist/plugins/blog/client.css +3 -0
  167. package/dist/plugins/blog/db.cjs +90 -0
  168. package/dist/plugins/blog/db.mjs +88 -0
  169. package/dist/plugins/blog/query-keys.cjs +181 -0
  170. package/dist/plugins/blog/query-keys.d.cts +530 -0
  171. package/dist/plugins/blog/query-keys.d.mts +530 -0
  172. package/dist/plugins/blog/query-keys.d.ts +530 -0
  173. package/dist/plugins/blog/query-keys.mjs +179 -0
  174. package/dist/plugins/blog/schemas.cjs +39 -0
  175. package/dist/plugins/blog/schemas.mjs +35 -0
  176. package/dist/plugins/blog/style.css +22 -0
  177. package/dist/plugins/blog/utils.cjs +97 -0
  178. package/dist/plugins/blog/utils.mjs +87 -0
  179. package/dist/plugins/client/index.cjs +15 -0
  180. package/dist/plugins/client/index.d.cts +57 -0
  181. package/dist/plugins/client/index.d.mts +57 -0
  182. package/dist/plugins/client/index.d.ts +57 -0
  183. package/dist/plugins/client/index.mjs +9 -0
  184. package/dist/{shared/stack.3OUyGp_E.mjs → plugins/utils.mjs} +1 -1
  185. package/dist/shared/{stack.DORw_1ps.d.cts → stack.ByOugz9d.d.cts} +17 -1
  186. package/dist/shared/{stack.DORw_1ps.d.mts → stack.ByOugz9d.d.mts} +17 -1
  187. package/dist/shared/{stack.DORw_1ps.d.ts → stack.ByOugz9d.d.ts} +17 -1
  188. package/dist/shared/stack.CoPoHVfV.d.cts +76 -0
  189. package/dist/shared/stack.CoPoHVfV.d.mts +76 -0
  190. package/dist/shared/stack.CoPoHVfV.d.ts +76 -0
  191. package/package.json +102 -14
  192. package/src/__tests__/plugins.test.tsx +539 -0
  193. package/src/__tests__/sitemap.test.ts +60 -0
  194. package/src/api/index.ts +75 -0
  195. package/src/client/components/compose.tsx +116 -0
  196. package/src/client/components/error-boundary.tsx +30 -0
  197. package/src/client/components/index.tsx +2 -0
  198. package/src/client/index.ts +109 -0
  199. package/src/client/meta-utils.ts +228 -0
  200. package/src/client/path-utils.ts +38 -0
  201. package/src/client/sitemap-utils.ts +46 -0
  202. package/src/context/index.ts +1 -0
  203. package/src/context/provider.tsx +157 -0
  204. package/src/index.ts +1 -0
  205. package/src/plugins/api/index.ts +50 -0
  206. package/src/plugins/blog/api/index.ts +2 -0
  207. package/src/plugins/blog/api/plugin.ts +759 -0
  208. package/src/plugins/blog/client/components/forms/image-field.tsx +165 -0
  209. package/src/plugins/blog/client/components/forms/markdown-editor-styles.css +30 -0
  210. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +136 -0
  211. package/src/plugins/blog/client/components/forms/post-forms.tsx +531 -0
  212. package/src/plugins/blog/client/components/forms/tags-multiselect.tsx +79 -0
  213. package/src/plugins/blog/client/components/index.tsx +11 -0
  214. package/src/plugins/blog/client/components/loading/form-page-skeleton.tsx +75 -0
  215. package/src/plugins/blog/client/components/loading/index.tsx +27 -0
  216. package/src/plugins/blog/client/components/loading/list-page-skeleton.tsx +38 -0
  217. package/src/plugins/blog/client/components/loading/page-header-skeleton.tsx +10 -0
  218. package/src/plugins/blog/client/components/loading/post-card-skeleton.tsx +30 -0
  219. package/src/plugins/blog/client/components/loading/post-page-skeleton.tsx +75 -0
  220. package/src/plugins/blog/client/components/pages/404-page.tsx +23 -0
  221. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +60 -0
  222. package/src/plugins/blog/client/components/pages/edit-post-page.tsx +40 -0
  223. package/src/plugins/blog/client/components/pages/home-page.internal.tsx +71 -0
  224. package/src/plugins/blog/client/components/pages/home-page.tsx +42 -0
  225. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +59 -0
  226. package/src/plugins/blog/client/components/pages/new-post-page.tsx +36 -0
  227. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +142 -0
  228. package/src/plugins/blog/client/components/pages/post-page.tsx +38 -0
  229. package/src/plugins/blog/client/components/pages/tag-page.internal.tsx +74 -0
  230. package/src/plugins/blog/client/components/pages/tag-page.tsx +38 -0
  231. package/src/plugins/blog/client/components/shared/better-blog-attribution.tsx +19 -0
  232. package/src/plugins/blog/client/components/shared/default-error.tsx +20 -0
  233. package/src/plugins/blog/client/components/shared/defaults.tsx +9 -0
  234. package/src/plugins/blog/client/components/shared/empty-list.tsx +25 -0
  235. package/src/plugins/blog/client/components/shared/error-placeholder.tsx +20 -0
  236. package/src/plugins/blog/client/components/shared/highlight-text.tsx +80 -0
  237. package/src/plugins/blog/client/components/shared/markdown-content-styles.css +328 -0
  238. package/src/plugins/blog/client/components/shared/markdown-content.tsx +448 -0
  239. package/src/plugins/blog/client/components/shared/on-this-page.tsx +234 -0
  240. package/src/plugins/blog/client/components/shared/page-header.tsx +35 -0
  241. package/src/plugins/blog/client/components/shared/page-layout.tsx +23 -0
  242. package/src/plugins/blog/client/components/shared/page-wrapper.tsx +32 -0
  243. package/src/plugins/blog/client/components/shared/post-card.tsx +308 -0
  244. package/src/plugins/blog/client/components/shared/post-navigation.tsx +98 -0
  245. package/src/plugins/blog/client/components/shared/posts-list.tsx +67 -0
  246. package/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +79 -0
  247. package/src/plugins/blog/client/components/shared/search-input.tsx +146 -0
  248. package/src/plugins/blog/client/components/shared/search-modal.tsx +162 -0
  249. package/src/plugins/blog/client/components/shared/tags-list.tsx +34 -0
  250. package/src/plugins/blog/client/components/shared/use-route-lifecycle.tsx +68 -0
  251. package/src/plugins/blog/client/hooks/blog-hooks.tsx +623 -0
  252. package/src/plugins/blog/client/hooks/index.tsx +1 -0
  253. package/src/plugins/blog/client/hooks/use-debounce.ts +43 -0
  254. package/src/plugins/blog/client/index.ts +9 -0
  255. package/src/plugins/blog/client/localization/blog-card.ts +3 -0
  256. package/src/plugins/blog/client/localization/blog-common.ts +7 -0
  257. package/src/plugins/blog/client/localization/blog-forms.ts +45 -0
  258. package/src/plugins/blog/client/localization/blog-list.ts +14 -0
  259. package/src/plugins/blog/client/localization/blog-post.ts +9 -0
  260. package/src/plugins/blog/client/localization/index.ts +15 -0
  261. package/src/plugins/blog/client/overrides.ts +123 -0
  262. package/src/plugins/blog/client/plugin.tsx +672 -0
  263. package/src/plugins/blog/client.css +3 -0
  264. package/src/plugins/blog/db.ts +90 -0
  265. package/src/plugins/blog/query-keys.ts +267 -0
  266. package/src/plugins/blog/schemas.ts +39 -0
  267. package/src/plugins/blog/style.css +22 -0
  268. package/src/plugins/blog/types.ts +37 -0
  269. package/src/plugins/blog/utils.ts +144 -0
  270. package/src/plugins/client/index.ts +53 -0
  271. package/src/plugins/index.ts +0 -0
  272. package/src/plugins/utils.ts +35 -0
  273. package/src/types.ts +209 -0
  274. package/dist/plugins/index.cjs +0 -15
  275. package/dist/plugins/index.d.cts +0 -64
  276. package/dist/plugins/index.d.mts +0 -64
  277. package/dist/plugins/index.d.ts +0 -64
  278. package/dist/plugins/index.mjs +0 -11
  279. package/dist/shared/stack.DrUAVfIH.d.cts +0 -17
  280. package/dist/shared/stack.DrUAVfIH.d.mts +0 -17
  281. package/dist/shared/stack.DrUAVfIH.d.ts +0 -17
  282. /package/dist/{shared/stack.CktCg4PJ.cjs → plugins/utils.cjs} +0 -0
@@ -0,0 +1,672 @@
1
+ import {
2
+ defineClientPlugin,
3
+ createApiClient,
4
+ } from "@btst/stack/plugins/client";
5
+ import { createRoute } from "@btst/yar";
6
+ import type { QueryClient } from "@tanstack/react-query";
7
+ import type { BlogApiRouter } from "../api";
8
+ import { createBlogQueryKeys } from "../query-keys";
9
+ import type { Post, SerializedPost, SerializedTag } from "../types";
10
+ import { HomePageComponent } from "./components/pages/home-page";
11
+ import { NewPostPageComponent } from "./components/pages/new-post-page";
12
+ import { EditPostPageComponent } from "./components/pages/edit-post-page";
13
+ import { TagPageComponent } from "./components/pages/tag-page";
14
+ import { PostPageComponent } from "./components/pages/post-page";
15
+
16
+ /**
17
+ * Context passed to route hooks
18
+ */
19
+ export interface RouteContext {
20
+ path: string;
21
+ params?: Record<string, string>;
22
+ isSSR: boolean;
23
+ [key: string]: any;
24
+ }
25
+
26
+ /**
27
+ * Context passed to loader hooks
28
+ */
29
+ export interface LoaderContext {
30
+ path: string;
31
+ params?: Record<string, string>;
32
+ isSSR: boolean;
33
+ apiBaseURL: string;
34
+ apiBasePath: string;
35
+ [key: string]: any;
36
+ }
37
+
38
+ /**
39
+ * Configuration for blog client plugin
40
+ * Note: queryClient is passed at runtime to both loader and meta (for SSR isolation)
41
+ */
42
+ export interface BlogClientConfig {
43
+ // Required configuration
44
+ apiBaseURL: string;
45
+ apiBasePath: string;
46
+ siteBaseURL: string;
47
+ siteBasePath: string;
48
+ queryClient: QueryClient;
49
+
50
+ // Optional SEO/meta configuration
51
+ seo?: {
52
+ siteName?: string;
53
+ author?: string;
54
+ twitterHandle?: string;
55
+ locale?: string;
56
+ defaultImage?: string;
57
+ };
58
+
59
+ // Optional hooks
60
+ hooks?: BlogClientHooks;
61
+ }
62
+
63
+ /**
64
+ * Hooks for blog client plugin
65
+ * All hooks are optional and allow consumers to customize behavior
66
+ */
67
+ export interface BlogClientHooks {
68
+ // Loader Hooks - called during data loading (SSR or CSR)
69
+ beforeLoadPosts?: (
70
+ filter: { published: boolean },
71
+ context: LoaderContext,
72
+ ) => Promise<boolean> | boolean;
73
+ afterLoadPosts?: (
74
+ posts: Post[] | null,
75
+ filter: { published: boolean },
76
+ context: LoaderContext,
77
+ ) => Promise<void> | void;
78
+ beforeLoadPost?: (
79
+ slug: string,
80
+ context: LoaderContext,
81
+ ) => Promise<boolean> | boolean;
82
+ afterLoadPost?: (
83
+ post: Post | null,
84
+ slug: string,
85
+ context: LoaderContext,
86
+ ) => Promise<void> | void;
87
+ onLoadError?: (error: Error, context: LoaderContext) => Promise<void> | void;
88
+ }
89
+
90
+ // Loader for SSR prefetching with hooks - configured once
91
+ function createPostsLoader(published: boolean, config: BlogClientConfig) {
92
+ return async () => {
93
+ if (typeof window === "undefined") {
94
+ const { queryClient, apiBasePath, apiBaseURL, hooks } = config;
95
+
96
+ const context: LoaderContext = {
97
+ path: published ? "/blog" : "/blog/drafts",
98
+ isSSR: true,
99
+ apiBaseURL,
100
+ apiBasePath,
101
+ };
102
+
103
+ try {
104
+ // Before hook
105
+ if (hooks?.beforeLoadPosts) {
106
+ const canLoad = await hooks.beforeLoadPosts({ published }, context);
107
+ if (!canLoad) {
108
+ throw new Error("Load prevented by beforeLoadPosts hook");
109
+ }
110
+ }
111
+
112
+ const limit = 10;
113
+ const client = createApiClient<BlogApiRouter>({
114
+ baseURL: apiBaseURL,
115
+ basePath: apiBasePath,
116
+ });
117
+
118
+ // note: for a module not to be bundled with client, and to be shared by client and server we need to add it to build.config.ts as an entry
119
+ const queries = createBlogQueryKeys(client);
120
+ const listQuery = queries.posts.list({
121
+ query: undefined,
122
+ limit,
123
+ published: published,
124
+ });
125
+
126
+ await queryClient.prefetchInfiniteQuery({
127
+ ...listQuery,
128
+ initialPageParam: 0,
129
+ });
130
+
131
+ // Prefetch tags
132
+ const tagsQuery = queries.tags.list();
133
+ await queryClient.prefetchQuery(tagsQuery);
134
+
135
+ // Don't throw errors during SSR - let Error Boundaries catch them when components render
136
+ // React Query stores errors in query state, and Suspense/Error Boundaries handle them
137
+ // Note: We still call hooks so consumers can log/track errors
138
+
139
+ // After hook - get data from queryClient if needed
140
+ if (hooks?.afterLoadPosts) {
141
+ const posts =
142
+ queryClient.getQueryData<Post[]>(listQuery.queryKey) || null;
143
+ await hooks.afterLoadPosts(posts, { published }, context);
144
+ }
145
+
146
+ // Check if there was an error after afterLoadPosts hook
147
+ const queryState = queryClient.getQueryState(listQuery.queryKey);
148
+ if (queryState?.error) {
149
+ // Call error hook but don't throw - let Error Boundary handle it during render
150
+ if (hooks?.onLoadError) {
151
+ const error =
152
+ queryState.error instanceof Error
153
+ ? queryState.error
154
+ : new Error(String(queryState.error));
155
+ await hooks.onLoadError(error, context);
156
+ }
157
+ }
158
+ } catch (error) {
159
+ // Error hook - log the error but don't throw during SSR
160
+ // Let Error Boundaries handle errors when components render
161
+ if (hooks?.onLoadError) {
162
+ await hooks.onLoadError(error as Error, context);
163
+ }
164
+ // Don't re-throw - let Error Boundary catch it during render
165
+ }
166
+ }
167
+ };
168
+ }
169
+
170
+ function createPostLoader(slug: string, config: BlogClientConfig) {
171
+ return async () => {
172
+ if (typeof window === "undefined") {
173
+ const { queryClient, apiBasePath, apiBaseURL, hooks } = config;
174
+
175
+ const context: LoaderContext = {
176
+ path: `/blog/${slug}`,
177
+ params: { slug },
178
+ isSSR: true,
179
+ apiBaseURL,
180
+ apiBasePath,
181
+ };
182
+
183
+ try {
184
+ // Before hook
185
+ if (hooks?.beforeLoadPost) {
186
+ const canLoad = await hooks.beforeLoadPost(slug, context);
187
+ if (!canLoad) {
188
+ throw new Error("Load prevented by beforeLoadPost hook");
189
+ }
190
+ }
191
+
192
+ const client = createApiClient<BlogApiRouter>({
193
+ baseURL: apiBaseURL,
194
+ basePath: apiBasePath,
195
+ });
196
+ const queries = createBlogQueryKeys(client);
197
+ const postQuery = queries.posts.detail(slug);
198
+ await queryClient.prefetchQuery(postQuery);
199
+
200
+ // Don't throw errors during SSR - let Error Boundaries catch them when components render
201
+ // React Query stores errors in query state, and Suspense/Error Boundaries handle them
202
+ // Note: We still call hooks so consumers can log/track errors
203
+
204
+ // After hook
205
+ if (hooks?.afterLoadPost) {
206
+ const post =
207
+ queryClient.getQueryData<Post>(postQuery.queryKey) || null;
208
+ await hooks.afterLoadPost(post, slug, context);
209
+ }
210
+
211
+ // Check if there was an error after afterLoadPost hook
212
+ const queryState = queryClient.getQueryState(postQuery.queryKey);
213
+ if (queryState?.error) {
214
+ // Call error hook but don't throw - let Error Boundary handle it during render
215
+ if (hooks?.onLoadError) {
216
+ const error =
217
+ queryState.error instanceof Error
218
+ ? queryState.error
219
+ : new Error(String(queryState.error));
220
+ await hooks.onLoadError(error, context);
221
+ }
222
+ }
223
+ } catch (error) {
224
+ // Error hook - log the error but don't throw during SSR
225
+ // Let Error Boundaries handle errors when components render
226
+ if (hooks?.onLoadError) {
227
+ await hooks.onLoadError(error as Error, context);
228
+ }
229
+ // Don't re-throw - let Error Boundary catch it during render
230
+ }
231
+ }
232
+ };
233
+ }
234
+
235
+ function createTagLoader(tagSlug: string, config: BlogClientConfig) {
236
+ return async () => {
237
+ if (typeof window === "undefined") {
238
+ const { queryClient, apiBasePath, apiBaseURL, hooks } = config;
239
+
240
+ const context: LoaderContext = {
241
+ path: `/blog/tag/${tagSlug}`,
242
+ params: { tagSlug },
243
+ isSSR: true,
244
+ apiBaseURL,
245
+ apiBasePath,
246
+ };
247
+
248
+ try {
249
+ const limit = 10;
250
+ const client = createApiClient<BlogApiRouter>({
251
+ baseURL: apiBaseURL,
252
+ basePath: apiBasePath,
253
+ });
254
+
255
+ const queries = createBlogQueryKeys(client);
256
+ const listQuery = queries.posts.list({
257
+ query: undefined,
258
+ limit,
259
+ published: true,
260
+ tagSlug: tagSlug,
261
+ });
262
+
263
+ await queryClient.prefetchInfiniteQuery({
264
+ ...listQuery,
265
+ initialPageParam: 0,
266
+ });
267
+
268
+ const tagsQuery = queries.tags.list();
269
+ await queryClient.prefetchQuery(tagsQuery);
270
+
271
+ if (hooks?.onLoadError) {
272
+ const queryState = queryClient.getQueryState(listQuery.queryKey);
273
+ if (queryState?.error) {
274
+ const error =
275
+ queryState.error instanceof Error
276
+ ? queryState.error
277
+ : new Error(String(queryState.error));
278
+ await hooks.onLoadError(error, context);
279
+ }
280
+ }
281
+ } catch (error) {
282
+ if (hooks?.onLoadError) {
283
+ await hooks.onLoadError(error as Error, context);
284
+ }
285
+ }
286
+ }
287
+ };
288
+ }
289
+
290
+ // Meta generators with SEO optimization
291
+ function createPostsListMeta(published: boolean, config: BlogClientConfig) {
292
+ return () => {
293
+ const { siteBaseURL, siteBasePath, seo } = config;
294
+ const path = published ? "/blog" : "/blog/drafts";
295
+ const fullUrl = `${siteBaseURL}${siteBasePath}${path}`;
296
+ const title = published ? "Blog" : "Draft Posts";
297
+ const description = published
298
+ ? "Read our latest articles, insights, and updates on web development, technology, and more."
299
+ : "View and manage your draft blog posts.";
300
+
301
+ return [
302
+ // Primary meta tags
303
+ { title },
304
+ { name: "title", content: title },
305
+ { name: "description", content: description },
306
+ {
307
+ name: "keywords",
308
+ content: "blog, articles, technology, web development, insights",
309
+ },
310
+ ...(seo?.author ? [{ name: "author", content: seo.author }] : []),
311
+ {
312
+ name: "robots",
313
+ content: published ? "index, follow" : "noindex, nofollow",
314
+ },
315
+
316
+ // Open Graph / Facebook
317
+ { property: "og:type", content: "website" },
318
+ { property: "og:title", content: title },
319
+ { property: "og:description", content: description },
320
+ { property: "og:url", content: fullUrl },
321
+ ...(seo?.siteName
322
+ ? [{ property: "og:site_name", content: seo.siteName }]
323
+ : []),
324
+ ...(seo?.locale ? [{ property: "og:locale", content: seo.locale }] : []),
325
+ ...(seo?.defaultImage
326
+ ? [{ property: "og:image", content: seo.defaultImage }]
327
+ : []),
328
+
329
+ // Twitter Card
330
+ { name: "twitter:card", content: "summary_large_image" },
331
+ { name: "twitter:title", content: title },
332
+ { name: "twitter:description", content: description },
333
+ ...(seo?.twitterHandle
334
+ ? [{ name: "twitter:site", content: seo.twitterHandle }]
335
+ : []),
336
+ ];
337
+ };
338
+ }
339
+
340
+ function createPostMeta(slug: string, config: BlogClientConfig) {
341
+ return () => {
342
+ // Use queryClient passed at runtime (same as loader!)
343
+ const { queryClient } = config;
344
+ const { apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, seo } = config;
345
+ const queries = createBlogQueryKeys(
346
+ createApiClient<BlogApiRouter>({
347
+ baseURL: apiBaseURL,
348
+ basePath: apiBasePath,
349
+ }),
350
+ );
351
+ const post = queryClient.getQueryData<Post>(
352
+ queries.posts.detail(slug).queryKey,
353
+ );
354
+
355
+ if (!post) {
356
+ // Fallback if post not loaded
357
+ return [
358
+ { title: "Unknown route" },
359
+ { name: "title", content: "Unknown route" },
360
+ { name: "robots", content: "noindex" },
361
+ ];
362
+ }
363
+
364
+ const fullUrl = `${siteBaseURL}${siteBasePath}/blog/${post.slug}`;
365
+ const title = post.title;
366
+ const description = post.excerpt || post.content.substring(0, 160);
367
+ const publishedTime = post.publishedAt
368
+ ? new Date(post.publishedAt).toISOString()
369
+ : new Date(post.createdAt).toISOString();
370
+ const modifiedTime = new Date(post.updatedAt).toISOString();
371
+ const image = post.image || seo?.defaultImage;
372
+
373
+ return [
374
+ // Primary meta tags
375
+ { title },
376
+ { name: "title", content: title },
377
+ { name: "description", content: description },
378
+ ...(post.authorId || seo?.author
379
+ ? [{ name: "author", content: post.authorId || seo?.author }]
380
+ : []),
381
+ {
382
+ name: "robots",
383
+ content: post.published ? "index, follow" : "noindex, nofollow",
384
+ },
385
+ {
386
+ name: "keywords",
387
+ content: `blog, article, ${post.slug.replace(/-/g, ", ")}`,
388
+ },
389
+
390
+ // Open Graph / Facebook
391
+ { property: "og:type", content: "article" },
392
+ { property: "og:title", content: title },
393
+ { property: "og:description", content: description },
394
+ { property: "og:url", content: fullUrl },
395
+ ...(seo?.siteName
396
+ ? [{ property: "og:site_name", content: seo.siteName }]
397
+ : []),
398
+ ...(seo?.locale ? [{ property: "og:locale", content: seo.locale }] : []),
399
+ ...(image ? [{ property: "og:image", content: image }] : []),
400
+ ...(image
401
+ ? [
402
+ { property: "og:image:width", content: "1200" },
403
+ { property: "og:image:height", content: "630" },
404
+ { property: "og:image:alt", content: title },
405
+ ]
406
+ : []),
407
+
408
+ // Article-specific Open Graph tags
409
+ { property: "article:published_time", content: publishedTime },
410
+ { property: "article:modified_time", content: modifiedTime },
411
+ ...(post.authorId
412
+ ? [{ property: "article:author", content: post.authorId }]
413
+ : []),
414
+
415
+ // Twitter Card
416
+ {
417
+ name: "twitter:card",
418
+ content: image ? "summary_large_image" : "summary",
419
+ },
420
+ { name: "twitter:title", content: title },
421
+ { name: "twitter:description", content: description },
422
+ ...(seo?.twitterHandle
423
+ ? [{ name: "twitter:site", content: seo.twitterHandle }]
424
+ : []),
425
+ ...(post.authorId || seo?.twitterHandle
426
+ ? [
427
+ {
428
+ name: "twitter:creator",
429
+ content: post.authorId || seo?.twitterHandle,
430
+ },
431
+ ]
432
+ : []),
433
+ ...(image ? [{ name: "twitter:image", content: image }] : []),
434
+ ...(image ? [{ name: "twitter:image:alt", content: title }] : []),
435
+
436
+ // Additional SEO tags
437
+ { name: "publish_date", content: publishedTime },
438
+ ];
439
+ };
440
+ }
441
+
442
+ function createTagMeta(tagSlug: string, config: BlogClientConfig) {
443
+ return () => {
444
+ const { queryClient } = config;
445
+ const { apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, seo } = config;
446
+ const queries = createBlogQueryKeys(
447
+ createApiClient<BlogApiRouter>({
448
+ baseURL: apiBaseURL,
449
+ basePath: apiBasePath,
450
+ }),
451
+ );
452
+ const tags = queryClient.getQueryData<SerializedTag[]>(
453
+ queries.tags.list().queryKey,
454
+ );
455
+ const tag = tags?.find((t) => t.slug === tagSlug);
456
+
457
+ if (!tag) {
458
+ return [
459
+ { title: "Unknown route" },
460
+ { name: "title", content: "Unknown route" },
461
+ { name: "robots", content: "noindex" },
462
+ ];
463
+ }
464
+
465
+ const fullUrl = `${siteBaseURL}${siteBasePath}/blog/tag/${tag.slug}`;
466
+ const title = `${tag.name} Posts`;
467
+ const description = `Browse all ${tag.name} posts`;
468
+
469
+ return [
470
+ { title },
471
+ { name: "title", content: title },
472
+ { name: "description", content: description },
473
+ { name: "robots", content: "index, follow" },
474
+ { name: "keywords", content: `blog, ${tag.name}, articles` },
475
+ { property: "og:type", content: "website" },
476
+ { property: "og:title", content: title },
477
+ { property: "og:description", content: description },
478
+ { property: "og:url", content: fullUrl },
479
+ ...(seo?.siteName
480
+ ? [{ property: "og:site_name", content: seo.siteName }]
481
+ : []),
482
+ ...(seo?.defaultImage
483
+ ? [{ property: "og:image", content: seo.defaultImage }]
484
+ : []),
485
+ { name: "twitter:card", content: "summary" },
486
+ { name: "twitter:title", content: title },
487
+ ];
488
+ };
489
+ }
490
+
491
+ function createNewPostMeta(config: BlogClientConfig) {
492
+ return () => {
493
+ const { siteBaseURL, siteBasePath } = config;
494
+ const fullUrl = `${siteBaseURL}${siteBasePath}/blog/new`;
495
+
496
+ const title = "Create New Post";
497
+
498
+ return [
499
+ { title },
500
+ { name: "title", content: title },
501
+ { name: "description", content: "Write and publish a new blog post." },
502
+ { name: "robots", content: "noindex, nofollow" },
503
+
504
+ // Open Graph
505
+ { property: "og:type", content: "website" },
506
+ { property: "og:title", content: title },
507
+ {
508
+ property: "og:description",
509
+ content: "Write and publish a new blog post.",
510
+ },
511
+ { property: "og:url", content: fullUrl },
512
+
513
+ // Twitter
514
+ { name: "twitter:card", content: "summary" },
515
+ { name: "twitter:title", content: title },
516
+ ];
517
+ };
518
+ }
519
+
520
+ function createEditPostMeta(slug: string, config: BlogClientConfig) {
521
+ return () => {
522
+ // Use queryClient passed at runtime (same as loader!)
523
+ const { queryClient } = config;
524
+ const { apiBaseURL, apiBasePath, siteBaseURL, siteBasePath } = config;
525
+ const queries = createBlogQueryKeys(
526
+ createApiClient<BlogApiRouter>({
527
+ baseURL: apiBaseURL,
528
+ basePath: apiBasePath,
529
+ }),
530
+ );
531
+ const post = queryClient.getQueryData<Post>(
532
+ queries.posts.detail(slug).queryKey,
533
+ );
534
+ const fullUrl = `${siteBaseURL}${siteBasePath}/blog/${slug}/edit`;
535
+
536
+ const title = post ? `Edit: ${post.title}` : "Unknown route";
537
+
538
+ return [
539
+ { title },
540
+ { name: "title", content: title },
541
+ { name: "description", content: "Edit your blog post." },
542
+ { name: "robots", content: "noindex, nofollow" },
543
+
544
+ // Open Graph
545
+ { property: "og:type", content: "website" },
546
+ { property: "og:title", content: title },
547
+ { property: "og:url", content: fullUrl },
548
+
549
+ // Twitter
550
+ { name: "twitter:card", content: "summary" },
551
+ { name: "twitter:title", content: title },
552
+ ];
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Blog client plugin
558
+ * Provides routes, components, and React Query hooks for blog posts
559
+ *
560
+ * @param config - Configuration including queryClient, baseURL, and optional hooks
561
+ */
562
+ export const blogClientPlugin = (config: BlogClientConfig) =>
563
+ defineClientPlugin({
564
+ name: "blog",
565
+
566
+ routes: () => ({
567
+ posts: createRoute("/blog", () => {
568
+ return {
569
+ PageComponent: () => <HomePageComponent published={true} />,
570
+ loader: createPostsLoader(true, config),
571
+ meta: createPostsListMeta(true, config),
572
+ };
573
+ }),
574
+ drafts: createRoute("/blog/drafts", () => {
575
+ return {
576
+ PageComponent: () => <HomePageComponent published={false} />,
577
+ loader: createPostsLoader(false, config),
578
+ meta: createPostsListMeta(false, config),
579
+ };
580
+ }),
581
+ newPost: createRoute("/blog/new", () => {
582
+ return {
583
+ PageComponent: NewPostPageComponent,
584
+ meta: createNewPostMeta(config),
585
+ };
586
+ }),
587
+ editPost: createRoute("/blog/:slug/edit", ({ params: { slug } }) => {
588
+ return {
589
+ PageComponent: () => <EditPostPageComponent slug={slug} />,
590
+ loader: createPostLoader(slug, config),
591
+ meta: createEditPostMeta(slug, config),
592
+ };
593
+ }),
594
+ tag: createRoute("/blog/tag/:tagSlug", ({ params: { tagSlug } }) => {
595
+ return {
596
+ PageComponent: () => <TagPageComponent tagSlug={tagSlug} />,
597
+ loader: createTagLoader(tagSlug, config),
598
+ meta: createTagMeta(tagSlug, config),
599
+ };
600
+ }),
601
+ post: createRoute("/blog/:slug", ({ params: { slug } }) => {
602
+ return {
603
+ PageComponent: () => <PostPageComponent slug={slug} />,
604
+ loader: createPostLoader(slug, config),
605
+ meta: createPostMeta(slug, config),
606
+ };
607
+ }),
608
+ }),
609
+
610
+ sitemap: async () => {
611
+ const origin = `${config.siteBaseURL}${config.siteBasePath}`;
612
+ const indexUrl = `${origin}/blog`;
613
+
614
+ // Fetch all published posts via API, with pagination
615
+ const client = createApiClient<BlogApiRouter>({
616
+ baseURL: config.apiBaseURL,
617
+ basePath: config.apiBasePath,
618
+ });
619
+
620
+ const limit = 100;
621
+ let offset = 0;
622
+ const posts: SerializedPost[] = [];
623
+ // eslint-disable-next-line no-constant-condition
624
+ while (true) {
625
+ const res = await client("/posts", {
626
+ method: "GET",
627
+ query: {
628
+ offset,
629
+ limit,
630
+ published: "true",
631
+ },
632
+ });
633
+ const page = (res.data ?? []) as unknown as SerializedPost[];
634
+ posts.push(...page);
635
+ if (page.length < limit) break;
636
+ offset += limit;
637
+ }
638
+
639
+ const getLastModified = (p: SerializedPost): Date | undefined => {
640
+ const dates = [p.updatedAt, p.publishedAt, p.createdAt].filter(
641
+ Boolean,
642
+ ) as string[];
643
+ if (dates.length === 0) return undefined;
644
+ const times = dates
645
+ .map((d) => new Date(d).getTime())
646
+ .filter((t) => !Number.isNaN(t));
647
+ if (times.length === 0) return undefined;
648
+ return new Date(Math.max(...times));
649
+ };
650
+
651
+ const latestTime = posts
652
+ .map((p) => getLastModified(p)?.getTime() ?? 0)
653
+ .reduce((a, b) => Math.max(a, b), 0);
654
+
655
+ const entries = [
656
+ {
657
+ url: indexUrl,
658
+ lastModified: latestTime ? new Date(latestTime) : undefined,
659
+ changeFrequency: "daily" as const,
660
+ priority: 0.7,
661
+ },
662
+ ...posts.map((p) => ({
663
+ url: `${origin}/blog/${p.slug}`,
664
+ lastModified: getLastModified(p),
665
+ changeFrequency: "monthly" as const,
666
+ priority: 0.6,
667
+ })),
668
+ ];
669
+
670
+ return entries;
671
+ },
672
+ });
@@ -0,0 +1,3 @@
1
+ @import "./client/components/forms/markdown-editor-styles.css";
2
+ @import "./client/components/shared/markdown-content-styles.css";
3
+ @import "@milkdown/crepe/theme/common/style.css";