@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,448 @@
1
+ "use client";
2
+ import { cn } from "../../../utils";
3
+ import {
4
+ createElement,
5
+ isValidElement,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ useEffect,
10
+ } from "react";
11
+ import type {
12
+ ComponentPropsWithoutRef,
13
+ MouseEventHandler,
14
+ ReactNode,
15
+ } from "react";
16
+ import ReactMarkdown from "react-markdown";
17
+ import type { Components } from "react-markdown";
18
+ import rehypeHighlight from "rehype-highlight";
19
+ import rehypeRaw from "rehype-raw";
20
+ import remarkGfm from "remark-gfm";
21
+ import "../shared/markdown-content-styles.css";
22
+ import "highlight.js/styles/panda-syntax-light.css";
23
+ import { slugify } from "../../../utils";
24
+ import { CopyIcon } from "lucide-react";
25
+ import { CheckIcon } from "lucide-react";
26
+ import { usePluginOverrides } from "@btst/stack/context";
27
+ import type { BlogPluginOverrides } from "../../overrides";
28
+ import { DefaultImage, DefaultLink } from "./defaults";
29
+
30
+ // Utility to detect if markdown contains math syntax
31
+ function containsMath(markdown: string): boolean {
32
+ // Check for inline math: $...$
33
+ // Check for display math: $$...$$
34
+ // Check for block math environments
35
+ return (
36
+ /\$\$[\s\S]+?\$\$/.test(markdown) || // Display math
37
+ /(?<!\$)\$(?!\$)[^\$\n]+?\$(?!\$)/.test(markdown) || // Inline math (not $$)
38
+ /\\begin\{(equation|align|gather|math)\}/.test(markdown) // Math environments
39
+ );
40
+ }
41
+
42
+ export type MarkdownContentProps = {
43
+ markdown: string;
44
+ className?: string;
45
+ };
46
+
47
+ function getNodeText(node: ReactNode): string {
48
+ if (node == null) return "";
49
+ if (typeof node === "string" || typeof node === "number") return String(node);
50
+ if (Array.isArray(node)) return node.map(getNodeText).join("");
51
+ if (isValidElement(node)) {
52
+ const props = node.props as Record<string, unknown>;
53
+ return getNodeText(props.children as ReactNode);
54
+ }
55
+ return "";
56
+ }
57
+
58
+ // Deterministic hash function for generating consistent IDs
59
+ function simpleHash(str: string): string {
60
+ let hash = 0;
61
+ for (let i = 0; i < str.length; i++) {
62
+ const char = str.charCodeAt(i);
63
+ hash = (hash << 5) - hash + char;
64
+ hash = hash & hash; // Convert to 32-bit integer
65
+ }
66
+ // Convert to base36 and take first 6 characters (matching Math.random().toString(36).slice(2, 8))
67
+ return Math.abs(hash).toString(36).slice(0, 6);
68
+ }
69
+
70
+ function isCheckboxElement(
71
+ node: ReactNode,
72
+ ): node is ReturnType<typeof createElement> {
73
+ return (
74
+ isValidElement(node) &&
75
+ (node.type as unknown) === "input" &&
76
+ (node.props as { type?: string }).type === "checkbox"
77
+ );
78
+ }
79
+
80
+ function createTaskListItemRenderer() {
81
+ return function LiRenderer(
82
+ props: React.LiHTMLAttributes<HTMLLIElement> & { children?: ReactNode },
83
+ ) {
84
+ const { className, children, ...rest } = props;
85
+ const isTaskItem = (className ?? "").split(" ").includes("task-list-item");
86
+ if (!isTaskItem) {
87
+ return (
88
+ <li className={className} {...rest}>
89
+ {children}
90
+ </li>
91
+ );
92
+ }
93
+
94
+ const childArray = Array.isArray(children) ? children : [children];
95
+ const checkboxNode = childArray.find(isCheckboxElement);
96
+ const nonCheckboxChildren = childArray.filter((c) => !isCheckboxElement(c));
97
+
98
+ const labelText = getNodeText(nonCheckboxChildren as unknown as ReactNode);
99
+ const baseId = slugify(labelText || "task-item");
100
+ // Use deterministic hash instead of Math.random() to avoid hydration mismatches
101
+ const hashSuffix = simpleHash(labelText || "task-item");
102
+ const uniqueId = `${baseId}-${hashSuffix}`;
103
+
104
+ return (
105
+ <li className={className} {...rest}>
106
+ {checkboxNode
107
+ ? createElement(checkboxNode.type, {
108
+ ...checkboxNode.props,
109
+ id: uniqueId,
110
+ "aria-label": labelText || "Task item",
111
+ })
112
+ : null}
113
+ <label htmlFor={uniqueId}>
114
+ {nonCheckboxChildren as unknown as ReactNode}
115
+ </label>
116
+ </li>
117
+ );
118
+ };
119
+ }
120
+
121
+ type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
122
+ function createHeadingRenderer<T extends HeadingTag>(tag: T) {
123
+ return function HeadingRenderer(props: ComponentPropsWithoutRef<T>) {
124
+ const { children, ...rest } = props as { children: ReactNode };
125
+ const text = getNodeText(children);
126
+ const id = slugify(text);
127
+ return createElement(
128
+ tag,
129
+ { id, ...(rest as object) },
130
+ children,
131
+ text ? (
132
+ <a
133
+ className="heading-anchor"
134
+ href={`#${id}`}
135
+ aria-label="Link to heading"
136
+ >
137
+ #
138
+ </a>
139
+ ) : null,
140
+ );
141
+ };
142
+ }
143
+
144
+ function AnchorRenderer(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
145
+ const { Link } = usePluginOverrides<
146
+ BlogPluginOverrides,
147
+ Partial<BlogPluginOverrides>
148
+ >("blog", {
149
+ Link: DefaultLink,
150
+ });
151
+ const { href = "", children, className: anchorClassName, ...rest } = props;
152
+
153
+ return (
154
+ <Link href={href} className={anchorClassName as string} {...rest}>
155
+ {children}
156
+ </Link>
157
+ );
158
+ }
159
+
160
+ function ImgRenderer(props: React.ImgHTMLAttributes<HTMLImageElement>) {
161
+ const { Image } = usePluginOverrides<
162
+ BlogPluginOverrides,
163
+ Partial<BlogPluginOverrides>
164
+ >("blog", {
165
+ Image: DefaultImage,
166
+ });
167
+ const {
168
+ src = "",
169
+ alt = "",
170
+ className: imgClassName,
171
+ width,
172
+ height,
173
+ style,
174
+ ...rest
175
+ } = props;
176
+
177
+ // Only pass width/height if they're actually defined
178
+ const imageProps: React.ComponentProps<typeof Image> = {
179
+ src,
180
+ alt,
181
+ className: imgClassName as string,
182
+ style,
183
+ ...rest,
184
+ };
185
+
186
+ if (width != null && height != null) {
187
+ imageProps.width = width as number;
188
+ imageProps.height = height as number;
189
+ } else {
190
+ // When dimensions are missing, wrap in a container for fill mode
191
+ // The container will be styled via CSS to work with fill mode images
192
+ return (
193
+ <span className="markdown-image-wrapper">
194
+ <Image {...imageProps} />
195
+ </span>
196
+ );
197
+ }
198
+
199
+ return <Image {...imageProps} />;
200
+ }
201
+
202
+ type CodeProps = React.HTMLAttributes<HTMLElement> & {
203
+ inline?: boolean;
204
+ node?: unknown;
205
+ };
206
+ function CodeRenderer({ inline, className, children, ...rest }: CodeProps) {
207
+ const hasLanguage = /language-([a-z0-9-]+)/i.test(className ?? "");
208
+ const isInline = inline ?? !hasLanguage;
209
+ if (isInline) {
210
+ return (
211
+ <code className={className} {...rest}>
212
+ {children}
213
+ </code>
214
+ );
215
+ }
216
+ // Block code: keep markup simple here; the <pre> wrapper will handle toolbar/copier
217
+ return (
218
+ <code className={className} {...rest}>
219
+ {children}
220
+ </code>
221
+ );
222
+ }
223
+
224
+ function PreRenderer(props: React.HTMLAttributes<HTMLPreElement>) {
225
+ const { children, ...rest } = props;
226
+ const child = Array.isArray(children) ? children[0] : children;
227
+ let language: string | undefined;
228
+ if (isValidElement(child)) {
229
+ const className = (child.props as { className?: string }).className;
230
+ const match = /language-([a-z0-9-]+)/i.exec(className ?? "");
231
+ language = match?.[1];
232
+ }
233
+ const label = (language ?? "text").toUpperCase();
234
+
235
+ const preRef = useRef<HTMLPreElement | null>(null);
236
+ const [copied, setCopied] = useState(false);
237
+ const resetTimerRef = useRef<number | null>(null);
238
+
239
+ // Prepare line numbers based on code text
240
+ let codeText = "";
241
+ if (isValidElement(child)) {
242
+ const childProps = child.props as { children?: ReactNode };
243
+ codeText = getNodeText(childProps.children as ReactNode);
244
+ }
245
+ const normalized = codeText.endsWith("\n") ? codeText.slice(0, -1) : codeText;
246
+ const lineCount = Math.max(1, normalized.split("\n").length);
247
+ const digitCount = String(lineCount).length;
248
+ const onCopy: MouseEventHandler<HTMLButtonElement> = async (e) => {
249
+ e.preventDefault();
250
+ e.stopPropagation();
251
+ try {
252
+ const text = preRef.current?.textContent ?? "";
253
+ if (
254
+ text &&
255
+ typeof navigator !== "undefined" &&
256
+ navigator.clipboard?.writeText
257
+ ) {
258
+ await navigator.clipboard.writeText(text);
259
+ setCopied(true);
260
+ if (resetTimerRef.current) window.clearTimeout(resetTimerRef.current);
261
+ resetTimerRef.current = window.setTimeout(() => {
262
+ setCopied(false);
263
+ resetTimerRef.current = null;
264
+ }, 2000);
265
+ }
266
+ } catch {}
267
+ };
268
+ return (
269
+ <div
270
+ className="milkdown-code-block"
271
+ style={{
272
+ ["--code-gutter-width" as unknown as string]: `${digitCount + 1}ch`,
273
+ }}
274
+ >
275
+ <div className="code-toolbar">
276
+ <span className="language-label">{label}</span>
277
+ <button
278
+ type="button"
279
+ className="copy-button"
280
+ onClick={onCopy}
281
+ aria-label={copied ? "Copied" : "Copy code"}
282
+ >
283
+ {copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
284
+ </button>
285
+ </div>
286
+ <div className="code-content">
287
+ <ol className="line-numbers" aria-hidden>
288
+ {Array.from({ length: lineCount }).map((_, idx) => (
289
+ <li key={idx + 1}>{idx + 1}</li>
290
+ ))}
291
+ </ol>
292
+ <pre ref={preRef} {...rest}>
293
+ {children}
294
+ </pre>
295
+ </div>
296
+ </div>
297
+ );
298
+ }
299
+
300
+ export function MarkdownContent({ markdown, className }: MarkdownContentProps) {
301
+ const [mathPlugins, setMathPlugins] = useState<{
302
+ remarkMath: unknown;
303
+ rehypeKatex: unknown;
304
+ } | null>(null);
305
+ const [isLoadingMath, setIsLoadingMath] = useState(false);
306
+
307
+ const hasMath = useMemo(() => containsMath(markdown), [markdown]);
308
+
309
+ // Dynamically load math plugins and CSS only if needed
310
+ useEffect(() => {
311
+ if (!hasMath || mathPlugins || isLoadingMath) return;
312
+
313
+ setIsLoadingMath(true);
314
+
315
+ // Dynamically inject KaTeX CSS into the document
316
+ const katexCSSId = "katex-css";
317
+ if (!document.getElementById(katexCSSId)) {
318
+ const link = document.createElement("link");
319
+ link.id = katexCSSId;
320
+ link.rel = "stylesheet";
321
+ link.href =
322
+ "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css";
323
+ link.integrity =
324
+ "sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+";
325
+ link.crossOrigin = "anonymous";
326
+ document.head.appendChild(link);
327
+
328
+ // Add font-display override for KaTeX fonts to prevent FOIT
329
+ // This ensures text remains visible while fonts are loading
330
+ const fontDisplayStyleId = "katex-font-display-override";
331
+ if (!document.getElementById(fontDisplayStyleId)) {
332
+ const style = document.createElement("style");
333
+ style.id = fontDisplayStyleId;
334
+ style.textContent = `
335
+ /* Override KaTeX font-face declarations to add font-display: swap */
336
+ @font-face {
337
+ font-family: 'KaTeX_Main';
338
+ font-style: normal;
339
+ font-weight: normal;
340
+ font-display: swap;
341
+ src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2');
342
+ }
343
+ @font-face {
344
+ font-family: 'KaTeX_Math';
345
+ font-style: italic;
346
+ font-weight: normal;
347
+ font-display: swap;
348
+ src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2');
349
+ }
350
+ @font-face {
351
+ font-family: 'KaTeX_Size1';
352
+ font-style: normal;
353
+ font-weight: normal;
354
+ font-display: swap;
355
+ src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size1-Regular.woff2') format('woff2');
356
+ }
357
+ @font-face {
358
+ font-family: 'KaTeX_Size2';
359
+ font-style: normal;
360
+ font-weight: normal;
361
+ font-display: swap;
362
+ src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size2-Regular.woff2') format('woff2');
363
+ }
364
+ @font-face {
365
+ font-family: 'KaTeX_Size3';
366
+ font-style: normal;
367
+ font-weight: normal;
368
+ font-display: swap;
369
+ src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size3-Regular.woff2') format('woff2');
370
+ }
371
+ @font-face {
372
+ font-family: 'KaTeX_Size4';
373
+ font-style: normal;
374
+ font-weight: normal;
375
+ font-display: swap;
376
+ src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size4-Regular.woff2') format('woff2');
377
+ }
378
+ `;
379
+ document.head.appendChild(style);
380
+ }
381
+ }
382
+
383
+ // Load both the plugins
384
+ Promise.all([import("remark-math"), import("rehype-katex")])
385
+ .then(([remarkMathModule, rehypeKatexModule]) => {
386
+ setMathPlugins({
387
+ remarkMath: remarkMathModule.default,
388
+ rehypeKatex: rehypeKatexModule.default,
389
+ });
390
+ })
391
+ .catch((error) => {
392
+ console.error("Failed to load math plugins:", error);
393
+ })
394
+ .finally(() => {
395
+ setIsLoadingMath(false);
396
+ });
397
+ }, [hasMath, mathPlugins, isLoadingMath]);
398
+
399
+ const components = useMemo<Components>(() => {
400
+ return {
401
+ a: AnchorRenderer,
402
+ img: ImgRenderer,
403
+ code: CodeRenderer,
404
+ pre: PreRenderer,
405
+ h1: createHeadingRenderer("h1"),
406
+ h2: createHeadingRenderer("h2"),
407
+ h3: createHeadingRenderer("h3"),
408
+ h4: createHeadingRenderer("h4"),
409
+ h5: createHeadingRenderer("h5"),
410
+ h6: createHeadingRenderer("h6"),
411
+ li: createTaskListItemRenderer(),
412
+ };
413
+ }, []);
414
+
415
+ // Build plugin arrays based on whether math is needed and loaded
416
+ const remarkPlugins = useMemo(() => {
417
+ const plugins: unknown[] = [remarkGfm];
418
+ if (hasMath && mathPlugins?.remarkMath) {
419
+ plugins.push(mathPlugins.remarkMath);
420
+ }
421
+ return plugins as never;
422
+ }, [hasMath, mathPlugins]);
423
+
424
+ const rehypePlugins = useMemo(() => {
425
+ const plugins: unknown[] = [rehypeRaw, rehypeHighlight];
426
+ if (hasMath && mathPlugins?.rehypeKatex) {
427
+ plugins.push(mathPlugins.rehypeKatex);
428
+ }
429
+ return plugins as never;
430
+ }, [hasMath, mathPlugins]);
431
+
432
+ // Render content immediately; math will re-render once plugins load
433
+ return (
434
+ <div className={cn("milkdown-custom", className)}>
435
+ <div className="milkdown">
436
+ <div className="milkdown-content">
437
+ <ReactMarkdown
438
+ remarkPlugins={remarkPlugins}
439
+ rehypePlugins={rehypePlugins}
440
+ components={components as never}
441
+ >
442
+ {markdown}
443
+ </ReactMarkdown>
444
+ </div>
445
+ </div>
446
+ </div>
447
+ );
448
+ }
@@ -0,0 +1,234 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useMemo, useRef } from "react";
4
+ import { cn, slugify } from "../../../utils";
5
+ import { usePluginOverrides } from "@btst/stack/context";
6
+ import type { BlogPluginOverrides } from "../../overrides";
7
+ import { BLOG_LOCALIZATION } from "../../localization";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "@workspace/ui/components/select";
15
+
16
+ interface Heading {
17
+ id: string;
18
+ text: string;
19
+ level: number;
20
+ }
21
+
22
+ interface OnThisPageProps {
23
+ markdown: string;
24
+ className?: string;
25
+ }
26
+
27
+ export function OnThisPage({ markdown, className }: OnThisPageProps) {
28
+ const { localization } = usePluginOverrides<
29
+ BlogPluginOverrides,
30
+ Partial<BlogPluginOverrides>
31
+ >("blog", {
32
+ localization: BLOG_LOCALIZATION,
33
+ });
34
+ const headings = useMemo(() => extractHeadings(markdown), [markdown]);
35
+ const activeId = useActiveHeading(headings);
36
+
37
+ if (headings.length === 0) {
38
+ // placeholder if no headings are found
39
+ return (
40
+ <div
41
+ className={cn(
42
+ "sticky top-20 z-30 ml-auto hidden xl:flex w-44",
43
+ className,
44
+ )}
45
+ />
46
+ );
47
+ }
48
+
49
+ const handleClick = (id: string) => {
50
+ const element = document.getElementById(id);
51
+ if (element) {
52
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
53
+ }
54
+ };
55
+
56
+ return (
57
+ <nav
58
+ className={cn(
59
+ "sticky top-20 z-30 ml-auto hidden xl:flex w-44 flex-col gap-2 overflow-hidden pb-8",
60
+ className,
61
+ )}
62
+ aria-label="Table of contents"
63
+ >
64
+ <div className="overflow-y-auto px-2">
65
+ <div className="flex flex-col gap-2 p-4 pt-0 text-sm">
66
+ <p className="font-semibold text-muted-foreground sticky top-0 h-6 text-xs">
67
+ {localization.BLOG_POST_ON_THIS_PAGE}
68
+ </p>
69
+ {headings.map(({ id, text, level }) => {
70
+ const paddingLeft =
71
+ level === 1
72
+ ? "0"
73
+ : level === 2
74
+ ? "0.5rem"
75
+ : level === 3
76
+ ? "1rem"
77
+ : level === 4
78
+ ? "1.5rem"
79
+ : level === 5
80
+ ? "2rem"
81
+ : level === 6
82
+ ? "2.5rem"
83
+ : "0";
84
+ return (
85
+ <a
86
+ key={id}
87
+ href={`#${id}`}
88
+ onClick={(e) => {
89
+ e.preventDefault();
90
+ handleClick(id);
91
+ }}
92
+ style={{ paddingLeft }}
93
+ className={cn(
94
+ "text-muted-foreground hover:text-foreground text-[0.8rem] no-underline transition-colors",
95
+ activeId === id && "text-foreground",
96
+ )}
97
+ data-active={activeId === id}
98
+ data-depth={level}
99
+ >
100
+ {text}
101
+ </a>
102
+ );
103
+ })}
104
+ </div>
105
+ </div>
106
+ </nav>
107
+ );
108
+ }
109
+
110
+ export function OnThisPageSelect({ markdown, className }: OnThisPageProps) {
111
+ const { localization } = usePluginOverrides<
112
+ BlogPluginOverrides,
113
+ Partial<BlogPluginOverrides>
114
+ >("blog", {
115
+ localization: BLOG_LOCALIZATION,
116
+ });
117
+ const headings = useMemo(() => extractHeadings(markdown), [markdown]);
118
+ const initialValue = useMemo(() => headings[0]?.id ?? "", [headings]);
119
+ const activeId = useActiveHeading(headings);
120
+
121
+ // Use activeId as the value, fallback to initialValue if activeId is empty
122
+ const value = activeId || initialValue;
123
+
124
+ if (headings.length === 0) {
125
+ return null;
126
+ }
127
+
128
+ const handleValueChange = (id: string) => {
129
+ const element = document.getElementById(id);
130
+ if (element) {
131
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
132
+ }
133
+ };
134
+
135
+ return (
136
+ <div
137
+ className={cn(
138
+ "sticky z-30 w-full self-stretch xl:hidden bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60",
139
+ className,
140
+ )}
141
+ style={{
142
+ top: "var(--navbar-height, 16px)",
143
+ }}
144
+ >
145
+ <Select value={value} onValueChange={handleValueChange}>
146
+ <SelectTrigger className="w-full">
147
+ <SelectValue placeholder={localization.BLOG_POST_ON_THIS_PAGE} />
148
+ </SelectTrigger>
149
+ <SelectContent>
150
+ {headings.map(({ id, text, level }) => {
151
+ const indent = level > 1 ? `${(level - 1) * 0.5}rem` : "0";
152
+ return (
153
+ <SelectItem key={id} value={id}>
154
+ <span style={{ paddingLeft: indent }}>{text}</span>
155
+ </SelectItem>
156
+ );
157
+ })}
158
+ </SelectContent>
159
+ </Select>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function extractHeadings(markdown: string): Heading[] {
165
+ const headings: Heading[] = [];
166
+ const lines = markdown.split("\n");
167
+
168
+ for (const line of lines) {
169
+ // Match ATX-style headings (# Heading)
170
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
171
+ if (match) {
172
+ const level = match[1]?.length ?? 0;
173
+ const text = match[2]?.trim() ?? "";
174
+ // Remove any trailing #s or special chars
175
+ const cleanText = text.replace(/\s*#+\s*$/, "").trim();
176
+ // Generate ID using the same slugify utility as the markdown renderer
177
+ const id = slugify(cleanText);
178
+
179
+ if (id && cleanText) {
180
+ headings.push({ id, text: cleanText, level });
181
+ }
182
+ }
183
+ }
184
+
185
+ return headings;
186
+ }
187
+
188
+ function useActiveHeading(
189
+ headings: Heading[],
190
+ onActiveChange?: (id: string) => void,
191
+ ): string {
192
+ const [activeId, setActiveId] = useState<string>("");
193
+ const onActiveChangeRef = useRef(onActiveChange);
194
+
195
+ // Keep the ref in sync with the callback
196
+ useEffect(() => {
197
+ onActiveChangeRef.current = onActiveChange;
198
+ }, [onActiveChange]);
199
+
200
+ useEffect(() => {
201
+ if (headings.length === 0) return;
202
+
203
+ const observer = new IntersectionObserver(
204
+ (entries) => {
205
+ // Find the first heading that's in view
206
+ for (const entry of entries) {
207
+ if (entry.isIntersecting) {
208
+ setActiveId(entry.target.id);
209
+ onActiveChangeRef.current?.(entry.target.id);
210
+ break;
211
+ }
212
+ }
213
+ },
214
+ {
215
+ rootMargin: "-80px 0px -80% 0px",
216
+ threshold: 0,
217
+ },
218
+ );
219
+
220
+ // Observe all heading elements
221
+ headings.forEach(({ id }) => {
222
+ const element = document.getElementById(id);
223
+ if (element) {
224
+ observer.observe(element);
225
+ }
226
+ });
227
+
228
+ return () => {
229
+ observer.disconnect();
230
+ };
231
+ }, [headings]);
232
+
233
+ return activeId;
234
+ }
@@ -0,0 +1,35 @@
1
+ export function PageHeader({
2
+ title,
3
+ description,
4
+ childrenTop,
5
+ childrenBottom,
6
+ }: {
7
+ title: string;
8
+ description?: string;
9
+ childrenTop?: React.ReactNode;
10
+ childrenBottom?: React.ReactNode;
11
+ }) {
12
+ return (
13
+ <div
14
+ className="flex max-w-2xl flex-col items-center lg:gap-4 gap-2 text-center wrap-anywhere"
15
+ data-testid="page-header"
16
+ >
17
+ {childrenTop}
18
+ <h1
19
+ className="font-medium font-sans lg:text-6xl text-4xl tracking-tight"
20
+ data-testid="page-title"
21
+ >
22
+ {title}
23
+ </h1>
24
+ {description && (
25
+ <p
26
+ className="text-muted-foreground wrap-anywhere"
27
+ data-testid="page-description"
28
+ >
29
+ {description}
30
+ </p>
31
+ )}
32
+ {childrenBottom}
33
+ </div>
34
+ );
35
+ }