@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,759 @@
1
+ import type { Adapter } from "@btst/db";
2
+ import { defineBackendPlugin } from "@btst/stack/plugins/api";
3
+ import { createEndpoint } from "@btst/stack/plugins/api";
4
+ import { z } from "zod";
5
+ import { blogSchema as dbSchema } from "../db";
6
+ import type { Post, Tag } from "../types";
7
+ import { slugify } from "../utils";
8
+ import { createPostSchema, updatePostSchema } from "../schemas";
9
+
10
+ export const PostListQuerySchema = z.object({
11
+ slug: z.string().optional(),
12
+ tagSlug: z.string().optional(),
13
+ offset: z.coerce.number().int().min(0).optional(),
14
+ limit: z.coerce.number().int().min(1).max(100).optional(),
15
+ query: z.string().optional(),
16
+ published: z
17
+ .string()
18
+ .optional()
19
+ .transform((val) => {
20
+ if (val === undefined) return undefined;
21
+ if (val === "true") return true;
22
+ if (val === "false") return false;
23
+ return undefined;
24
+ }),
25
+ });
26
+
27
+ export const NextPreviousPostsQuerySchema = z.object({
28
+ date: z.coerce.date(),
29
+ });
30
+
31
+ /**
32
+ * Context passed to blog API hooks
33
+ */
34
+ export interface BlogApiContext<TBody = any, TParams = any, TQuery = any> {
35
+ body?: TBody;
36
+ params?: TParams;
37
+ query?: TQuery;
38
+ request?: Request;
39
+ [key: string]: any;
40
+ }
41
+
42
+ /**
43
+ * Configuration hooks for blog backend plugin
44
+ * All hooks are optional and allow consumers to customize behavior
45
+ */
46
+ export interface BlogBackendHooks {
47
+ // Hooks - called before the operation
48
+ onBeforeListPosts?: (
49
+ filter: z.infer<typeof PostListQuerySchema>,
50
+ context: BlogApiContext,
51
+ ) => Promise<boolean> | boolean;
52
+ onBeforeCreatePost?: (
53
+ data: z.infer<typeof createPostSchema>,
54
+ context: BlogApiContext,
55
+ ) => Promise<boolean> | boolean;
56
+ onBeforeUpdatePost?: (
57
+ postId: string,
58
+ data: z.infer<typeof updatePostSchema>,
59
+ context: BlogApiContext,
60
+ ) => Promise<boolean> | boolean;
61
+ onBeforeDeletePost?: (
62
+ postId: string,
63
+ context: BlogApiContext,
64
+ ) => Promise<boolean> | boolean;
65
+
66
+ // Lifecycle hooks - called after the operation
67
+ onPostsRead?: (
68
+ posts: Post[],
69
+ filter: z.infer<typeof PostListQuerySchema>,
70
+ context: BlogApiContext,
71
+ ) => Promise<void> | void;
72
+ onPostCreated?: (post: Post, context: BlogApiContext) => Promise<void> | void;
73
+ onPostUpdated?: (post: Post, context: BlogApiContext) => Promise<void> | void;
74
+ onPostDeleted?: (
75
+ postId: string,
76
+ context: BlogApiContext,
77
+ ) => Promise<void> | void;
78
+
79
+ // Error hooks - called when operations fail
80
+ onListPostsError?: (
81
+ error: Error,
82
+ context: BlogApiContext,
83
+ ) => Promise<void> | void;
84
+ onCreatePostError?: (
85
+ error: Error,
86
+ context: BlogApiContext,
87
+ ) => Promise<void> | void;
88
+ onUpdatePostError?: (
89
+ error: Error,
90
+ context: BlogApiContext,
91
+ ) => Promise<void> | void;
92
+ onDeletePostError?: (
93
+ error: Error,
94
+ context: BlogApiContext,
95
+ ) => Promise<void> | void;
96
+ }
97
+
98
+ /**
99
+ * Blog backend plugin
100
+ * Provides API endpoints for managing blog posts
101
+ * Uses better-db adapter for database operations
102
+ *
103
+ * @param hooks - Optional configuration hooks for customizing plugin behavior
104
+ */
105
+ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
106
+ defineBackendPlugin({
107
+ name: "blog",
108
+
109
+ dbPlugin: dbSchema,
110
+
111
+ routes: (adapter: Adapter) => {
112
+ const createTagCache = () => {
113
+ let cache: Tag[] | null = null;
114
+ return {
115
+ getAllTags: async (): Promise<Tag[]> => {
116
+ if (!cache) {
117
+ cache = await adapter.findMany<Tag>({
118
+ model: "tag",
119
+ });
120
+ }
121
+ return cache;
122
+ },
123
+ invalidate: () => {
124
+ cache = null;
125
+ },
126
+ addTag: (tag: Tag) => {
127
+ if (cache) {
128
+ cache.push(tag);
129
+ }
130
+ },
131
+ };
132
+ };
133
+
134
+ const createPostTagCache = () => {
135
+ let cache: Array<{ postId: string; tagId: string }> | null = null;
136
+ const getAllPostTags = async (): Promise<
137
+ Array<{ postId: string; tagId: string }>
138
+ > => {
139
+ if (!cache) {
140
+ cache = await adapter.findMany<{
141
+ postId: string;
142
+ tagId: string;
143
+ }>({
144
+ model: "postTag",
145
+ });
146
+ }
147
+ return cache;
148
+ };
149
+ return {
150
+ getAllPostTags,
151
+ invalidate: () => {
152
+ cache = null;
153
+ },
154
+ getByTagId: async (
155
+ tagId: string,
156
+ ): Promise<Array<{ postId: string; tagId: string }>> => {
157
+ const allPostTags = await getAllPostTags();
158
+ return allPostTags.filter((pt) => pt.tagId === tagId);
159
+ },
160
+ getByPostId: async (
161
+ postId: string,
162
+ ): Promise<Array<{ postId: string; tagId: string }>> => {
163
+ const allPostTags = await getAllPostTags();
164
+ return allPostTags.filter((pt) => pt.postId === postId);
165
+ },
166
+ };
167
+ };
168
+
169
+ const findOrCreateTags = async (
170
+ tagInputs: Array<
171
+ { name: string } | { id: string; name: string; slug: string }
172
+ >,
173
+ tagCache: ReturnType<typeof createTagCache>,
174
+ ): Promise<Tag[]> => {
175
+ if (tagInputs.length === 0) return [];
176
+
177
+ const normalizeTagName = (name: string): string => {
178
+ return name.trim();
179
+ };
180
+
181
+ const tagsWithIds: Tag[] = [];
182
+ const tagsToFindOrCreate: Array<{ name: string }> = [];
183
+
184
+ for (const tagInput of tagInputs) {
185
+ if ("id" in tagInput && tagInput.id) {
186
+ tagsWithIds.push({
187
+ id: tagInput.id,
188
+ name: normalizeTagName(tagInput.name),
189
+ slug: tagInput.slug,
190
+ createdAt: new Date(),
191
+ updatedAt: new Date(),
192
+ } as Tag);
193
+ } else {
194
+ tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) });
195
+ }
196
+ }
197
+
198
+ if (tagsToFindOrCreate.length === 0) {
199
+ return tagsWithIds;
200
+ }
201
+
202
+ const allTags = await tagCache.getAllTags();
203
+ const tagMapBySlug = new Map<string, Tag>();
204
+ for (const tag of allTags) {
205
+ tagMapBySlug.set(tag.slug, tag);
206
+ }
207
+
208
+ const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name));
209
+ const foundTags: Tag[] = [];
210
+
211
+ for (const slug of tagSlugs) {
212
+ const tag = tagMapBySlug.get(slug);
213
+ if (tag) {
214
+ foundTags.push(tag);
215
+ }
216
+ }
217
+
218
+ const existingSlugs = new Set([
219
+ ...tagsWithIds.map((tag) => tag.slug),
220
+ ...foundTags.map((tag) => tag.slug),
221
+ ]);
222
+ const tagsToCreate = tagsToFindOrCreate.filter(
223
+ (tag) => !existingSlugs.has(slugify(tag.name)),
224
+ );
225
+
226
+ const createdTags: Tag[] = [];
227
+ for (const tag of tagsToCreate) {
228
+ const normalizedName = normalizeTagName(tag.name);
229
+ const newTag = await adapter.create<Tag>({
230
+ model: "tag",
231
+ data: {
232
+ name: normalizedName,
233
+ slug: slugify(normalizedName),
234
+ createdAt: new Date(),
235
+ updatedAt: new Date(),
236
+ },
237
+ });
238
+ createdTags.push(newTag);
239
+ tagCache.addTag(newTag);
240
+ }
241
+
242
+ return [...tagsWithIds, ...foundTags, ...createdTags];
243
+ };
244
+
245
+ const loadTagsForPosts = async (
246
+ postIds: string[],
247
+ tagCache: ReturnType<typeof createTagCache>,
248
+ postTagCache: ReturnType<typeof createPostTagCache>,
249
+ ): Promise<Map<string, Tag[]>> => {
250
+ if (postIds.length === 0) return new Map();
251
+
252
+ const allPostTags = await postTagCache.getAllPostTags();
253
+ const relevantPostTags = allPostTags.filter((pt) =>
254
+ postIds.includes(pt.postId),
255
+ );
256
+
257
+ const tagIds = [...new Set(relevantPostTags.map((pt) => pt.tagId))];
258
+ if (tagIds.length === 0) return new Map();
259
+
260
+ const allTags = await tagCache.getAllTags();
261
+ const tagMap = new Map<string, Tag>();
262
+ for (const tag of allTags) {
263
+ tagMap.set(tag.id, tag);
264
+ }
265
+
266
+ const postTagsMap = new Map<string, Tag[]>();
267
+ for (const postTag of relevantPostTags) {
268
+ const tag = tagMap.get(postTag.tagId);
269
+ if (tag) {
270
+ const existing = postTagsMap.get(postTag.postId) || [];
271
+ postTagsMap.set(postTag.postId, [...existing, { ...tag }]);
272
+ }
273
+ }
274
+
275
+ return postTagsMap;
276
+ };
277
+
278
+ const listPosts = createEndpoint(
279
+ "/posts",
280
+ {
281
+ method: "GET",
282
+ query: PostListQuerySchema,
283
+ },
284
+ async (ctx) => {
285
+ const { query } = ctx;
286
+ const context: BlogApiContext = { query };
287
+ const tagCache = createTagCache();
288
+ const postTagCache = createPostTagCache();
289
+
290
+ try {
291
+ if (hooks?.onBeforeListPosts) {
292
+ const canList = await hooks.onBeforeListPosts(query, context);
293
+ if (!canList) {
294
+ throw ctx.error(403, {
295
+ message: "Unauthorized: Cannot list posts",
296
+ });
297
+ }
298
+ }
299
+
300
+ let tagFilterPostIds: Set<string> | null = null;
301
+
302
+ if (query.tagSlug) {
303
+ const allTags = await tagCache.getAllTags();
304
+ const tag = allTags.find((t) => t.slug === query.tagSlug);
305
+
306
+ if (!tag) {
307
+ return [];
308
+ }
309
+
310
+ const postTags = await postTagCache.getByTagId(tag.id);
311
+ tagFilterPostIds = new Set(postTags.map((pt) => pt.postId));
312
+ if (tagFilterPostIds.size === 0) {
313
+ return [];
314
+ }
315
+ }
316
+
317
+ const whereConditions = [];
318
+
319
+ if (query.published !== undefined) {
320
+ whereConditions.push({
321
+ field: "published",
322
+ value: query.published,
323
+ operator: "eq" as const,
324
+ });
325
+ }
326
+
327
+ if (query.slug) {
328
+ whereConditions.push({
329
+ field: "slug",
330
+ value: query.slug,
331
+ operator: "eq" as const,
332
+ });
333
+ }
334
+
335
+ const posts = await adapter.findMany<Post>({
336
+ model: "post",
337
+ limit:
338
+ query.query || query.tagSlug ? undefined : (query.limit ?? 10),
339
+ offset:
340
+ query.query || query.tagSlug ? undefined : (query.offset ?? 0),
341
+ where: whereConditions,
342
+ sortBy: {
343
+ field: "createdAt",
344
+ direction: "desc",
345
+ },
346
+ });
347
+
348
+ const postTagsMap = await loadTagsForPosts(
349
+ posts.map((post) => post.id),
350
+ tagCache,
351
+ postTagCache,
352
+ );
353
+
354
+ let result = posts.map((post) => ({
355
+ ...post,
356
+ tags: postTagsMap.get(post.id) || [],
357
+ }));
358
+
359
+ if (tagFilterPostIds) {
360
+ result = result.filter((post) => tagFilterPostIds!.has(post.id));
361
+ }
362
+
363
+ if (query.query) {
364
+ const searchLower = query.query.toLowerCase();
365
+ result = result.filter((post) => {
366
+ const titleMatch = post.title
367
+ ?.toLowerCase()
368
+ .includes(searchLower);
369
+ const contentMatch = post.content
370
+ ?.toLowerCase()
371
+ .includes(searchLower);
372
+ const excerptMatch = post.excerpt
373
+ ?.toLowerCase()
374
+ .includes(searchLower);
375
+ return titleMatch || contentMatch || excerptMatch;
376
+ });
377
+ }
378
+
379
+ if (query.tagSlug || query.query) {
380
+ const offset = query.offset ?? 0;
381
+ const limit = query.limit ?? 10;
382
+ result = result.slice(offset, offset + limit);
383
+ }
384
+
385
+ if (hooks?.onPostsRead) {
386
+ await hooks.onPostsRead(result, query, context);
387
+ }
388
+
389
+ return result;
390
+ } catch (error) {
391
+ if (hooks?.onListPostsError) {
392
+ await hooks.onListPostsError(error as Error, context);
393
+ }
394
+ throw error;
395
+ }
396
+ },
397
+ );
398
+ const createPost = createEndpoint(
399
+ "/posts",
400
+ {
401
+ method: "POST",
402
+ body: createPostSchema,
403
+ },
404
+ async (ctx) => {
405
+ const context: BlogApiContext = { body: ctx.body };
406
+ const tagCache = createTagCache();
407
+
408
+ try {
409
+ if (hooks?.onBeforeCreatePost) {
410
+ const canCreate = await hooks.onBeforeCreatePost(
411
+ ctx.body,
412
+ context,
413
+ );
414
+ if (!canCreate) {
415
+ throw ctx.error(403, {
416
+ message: "Unauthorized: Cannot create post",
417
+ });
418
+ }
419
+ }
420
+
421
+ const { tags, ...postData } = ctx.body;
422
+ const tagNames = tags || [];
423
+
424
+ const newPost = await adapter.create<Post>({
425
+ model: "post",
426
+ data: {
427
+ ...postData,
428
+ slug: postData.slug ? postData.slug : slugify(postData.title),
429
+ tags: [],
430
+ createdAt: new Date(),
431
+ updatedAt: new Date(),
432
+ },
433
+ });
434
+
435
+ if (tagNames.length > 0) {
436
+ const createdTags = await findOrCreateTags(tagNames, tagCache);
437
+
438
+ await adapter.transaction(async (tx) => {
439
+ for (const tag of createdTags) {
440
+ await tx.create<{ postId: string; tagId: string }>({
441
+ model: "postTag",
442
+ data: {
443
+ postId: newPost.id,
444
+ tagId: tag.id,
445
+ },
446
+ });
447
+ }
448
+ });
449
+
450
+ newPost.tags = createdTags.map((tag) => ({ ...tag }));
451
+ } else {
452
+ newPost.tags = [];
453
+ }
454
+
455
+ if (hooks?.onPostCreated) {
456
+ await hooks.onPostCreated(newPost, context);
457
+ }
458
+
459
+ return newPost;
460
+ } catch (error) {
461
+ if (hooks?.onCreatePostError) {
462
+ await hooks.onCreatePostError(error as Error, context);
463
+ }
464
+ throw error;
465
+ }
466
+ },
467
+ );
468
+ const updatePost = createEndpoint(
469
+ "/posts/:id",
470
+ {
471
+ method: "PUT",
472
+ body: updatePostSchema,
473
+ },
474
+ async (ctx) => {
475
+ const context: BlogApiContext = {
476
+ body: ctx.body,
477
+ params: ctx.params,
478
+ };
479
+ const tagCache = createTagCache();
480
+
481
+ try {
482
+ if (hooks?.onBeforeUpdatePost) {
483
+ const canUpdate = await hooks.onBeforeUpdatePost(
484
+ ctx.params.id,
485
+ ctx.body,
486
+ context,
487
+ );
488
+ if (!canUpdate) {
489
+ throw ctx.error(403, {
490
+ message: "Unauthorized: Cannot update post",
491
+ });
492
+ }
493
+ }
494
+
495
+ const { tags, ...postData } = ctx.body;
496
+ const tagNames = tags || [];
497
+
498
+ const updated = await adapter.transaction(async (tx) => {
499
+ const existingPostTags = await tx.findMany<{
500
+ postId: string;
501
+ tagId: string;
502
+ }>({
503
+ model: "postTag",
504
+ where: [
505
+ {
506
+ field: "postId",
507
+ value: ctx.params.id,
508
+ operator: "eq" as const,
509
+ },
510
+ ],
511
+ });
512
+
513
+ const updatedPost = await tx.update<Post>({
514
+ model: "post",
515
+ where: [{ field: "id", value: ctx.params.id }],
516
+ update: {
517
+ ...postData,
518
+ updatedAt: new Date(),
519
+ },
520
+ });
521
+
522
+ if (!updatedPost) {
523
+ throw ctx.error(404, {
524
+ message: "Post not found",
525
+ });
526
+ }
527
+
528
+ for (const postTag of existingPostTags) {
529
+ await tx.delete<{ postId: string; tagId: string }>({
530
+ model: "postTag",
531
+ where: [
532
+ {
533
+ field: "postId",
534
+ value: postTag.postId,
535
+ operator: "eq" as const,
536
+ },
537
+ {
538
+ field: "tagId",
539
+ value: postTag.tagId,
540
+ operator: "eq" as const,
541
+ },
542
+ ],
543
+ });
544
+ }
545
+
546
+ if (tagNames.length > 0) {
547
+ const createdTags = await findOrCreateTags(tagNames, tagCache);
548
+
549
+ for (const tag of createdTags) {
550
+ await tx.create<{ postId: string; tagId: string }>({
551
+ model: "postTag",
552
+ data: {
553
+ postId: ctx.params.id,
554
+ tagId: tag.id,
555
+ },
556
+ });
557
+ }
558
+
559
+ updatedPost.tags = createdTags.map((tag) => ({ ...tag }));
560
+ } else {
561
+ updatedPost.tags = [];
562
+ }
563
+
564
+ return updatedPost;
565
+ });
566
+
567
+ if (hooks?.onPostUpdated) {
568
+ await hooks.onPostUpdated(updated, context);
569
+ }
570
+
571
+ return updated;
572
+ } catch (error) {
573
+ if (hooks?.onUpdatePostError) {
574
+ await hooks.onUpdatePostError(error as Error, context);
575
+ }
576
+ throw error;
577
+ }
578
+ },
579
+ );
580
+ const deletePost = createEndpoint(
581
+ "/posts/:id",
582
+ {
583
+ method: "DELETE",
584
+ },
585
+ async (ctx) => {
586
+ const context: BlogApiContext = { params: ctx.params };
587
+
588
+ try {
589
+ // Authorization hook
590
+ if (hooks?.onBeforeDeletePost) {
591
+ const canDelete = await hooks.onBeforeDeletePost(
592
+ ctx.params.id,
593
+ context,
594
+ );
595
+ if (!canDelete) {
596
+ throw ctx.error(403, {
597
+ message: "Unauthorized: Cannot delete post",
598
+ });
599
+ }
600
+ }
601
+
602
+ await adapter.transaction(async (tx) => {
603
+ await tx.delete({
604
+ model: "postTag",
605
+ where: [{ field: "postId", value: ctx.params.id }],
606
+ });
607
+
608
+ await tx.delete<Post>({
609
+ model: "post",
610
+ where: [{ field: "id", value: ctx.params.id }],
611
+ });
612
+ });
613
+
614
+ // Lifecycle hook
615
+ if (hooks?.onPostDeleted) {
616
+ await hooks.onPostDeleted(ctx.params.id, context);
617
+ }
618
+
619
+ return { success: true };
620
+ } catch (error) {
621
+ // Error hook
622
+ if (hooks?.onDeletePostError) {
623
+ await hooks.onDeletePostError(error as Error, context);
624
+ }
625
+ throw error;
626
+ }
627
+ },
628
+ );
629
+
630
+ const getNextPreviousPosts = createEndpoint(
631
+ "/posts/next-previous",
632
+ {
633
+ method: "GET",
634
+ query: NextPreviousPostsQuerySchema,
635
+ },
636
+ async (ctx) => {
637
+ const { query } = ctx;
638
+ const context: BlogApiContext = { query };
639
+ const tagCache = createTagCache();
640
+ const postTagCache = createPostTagCache();
641
+
642
+ try {
643
+ if (hooks?.onBeforeListPosts) {
644
+ const canList = await hooks.onBeforeListPosts(
645
+ { published: true },
646
+ context,
647
+ );
648
+ if (!canList) {
649
+ throw ctx.error(403, {
650
+ message: "Unauthorized: Cannot list posts",
651
+ });
652
+ }
653
+ }
654
+
655
+ const date = query.date;
656
+
657
+ // Get previous post (createdAt < date, newest first)
658
+ const previousPost = await adapter.findMany<Post>({
659
+ model: "post",
660
+ limit: 1,
661
+ where: [
662
+ {
663
+ field: "createdAt",
664
+ value: date,
665
+ operator: "lt" as const,
666
+ },
667
+ {
668
+ field: "published",
669
+ value: true,
670
+ operator: "eq" as const,
671
+ },
672
+ ],
673
+ sortBy: {
674
+ field: "createdAt",
675
+ direction: "desc",
676
+ },
677
+ });
678
+
679
+ const nextPost = await adapter.findMany<Post>({
680
+ model: "post",
681
+ limit: 1,
682
+ where: [
683
+ {
684
+ field: "createdAt",
685
+ value: date,
686
+ operator: "gt" as const,
687
+ },
688
+ {
689
+ field: "published",
690
+ value: true,
691
+ operator: "eq" as const,
692
+ },
693
+ ],
694
+ sortBy: {
695
+ field: "createdAt",
696
+ direction: "asc",
697
+ },
698
+ });
699
+
700
+ const postIds = [
701
+ ...(previousPost?.[0] ? [previousPost[0].id] : []),
702
+ ...(nextPost?.[0] ? [nextPost[0].id] : []),
703
+ ];
704
+ const postTagsMap = await loadTagsForPosts(
705
+ postIds,
706
+ tagCache,
707
+ postTagCache,
708
+ );
709
+
710
+ return {
711
+ previous: previousPost?.[0]
712
+ ? {
713
+ ...previousPost[0],
714
+ tags: postTagsMap.get(previousPost[0].id) || [],
715
+ }
716
+ : null,
717
+ next: nextPost?.[0]
718
+ ? {
719
+ ...nextPost[0],
720
+ tags: postTagsMap.get(nextPost[0].id) || [],
721
+ }
722
+ : null,
723
+ };
724
+ } catch (error) {
725
+ // Error hook
726
+ if (hooks?.onListPostsError) {
727
+ await hooks.onListPostsError(error as Error, context);
728
+ }
729
+ throw error;
730
+ }
731
+ },
732
+ );
733
+
734
+ const listTags = createEndpoint(
735
+ "/tags",
736
+ {
737
+ method: "GET",
738
+ },
739
+ async () => {
740
+ return await adapter.findMany<Tag>({
741
+ model: "tag",
742
+ });
743
+ },
744
+ );
745
+
746
+ return {
747
+ listPosts,
748
+ createPost,
749
+ updatePost,
750
+ deletePost,
751
+ getNextPreviousPosts,
752
+ listTags,
753
+ } as const;
754
+ },
755
+ });
756
+
757
+ export type BlogApiRouter = ReturnType<
758
+ ReturnType<typeof blogBackendPlugin>["routes"]
759
+ >;