@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.
- package/README.md +156 -709
- package/dist/api/index.cjs +2 -1
- package/dist/api/index.d.cts +4 -3
- package/dist/api/index.d.mts +4 -3
- package/dist/api/index.d.ts +4 -3
- package/dist/api/index.mjs +1 -1
- package/dist/client/components/compose.cjs +68 -0
- package/dist/client/components/compose.mjs +65 -0
- package/dist/client/components/error-boundary.cjs +24 -0
- package/dist/client/components/error-boundary.mjs +22 -0
- package/dist/client/components/index.cjs +10 -0
- package/dist/client/components/index.d.cts +52 -0
- package/dist/client/components/index.d.mts +52 -0
- package/dist/client/components/index.d.ts +52 -0
- package/dist/client/components/index.mjs +2 -0
- package/dist/client/index.cjs +24 -5
- package/dist/client/index.d.cts +125 -8
- package/dist/client/index.d.mts +125 -8
- package/dist/client/index.d.ts +125 -8
- package/dist/client/index.mjs +21 -4
- package/dist/client/meta-utils.cjs +162 -0
- package/dist/client/meta-utils.mjs +160 -0
- package/dist/client/path-utils.cjs +15 -0
- package/dist/client/path-utils.mjs +13 -0
- package/dist/client/sitemap-utils.cjs +14 -0
- package/dist/client/sitemap-utils.mjs +12 -0
- package/dist/context/index.cjs +6 -63
- package/dist/context/index.d.cts +21 -24
- package/dist/context/index.d.mts +21 -24
- package/dist/context/index.d.ts +21 -24
- package/dist/context/index.mjs +1 -61
- package/dist/context/provider.cjs +51 -0
- package/dist/context/provider.mjs +46 -0
- package/dist/index.cjs +2 -3
- package/dist/index.d.cts +3 -2
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.mjs +1 -2
- package/dist/plugins/api/index.cjs +13 -0
- package/dist/plugins/api/index.d.cts +40 -0
- package/dist/plugins/api/index.d.mts +40 -0
- package/dist/plugins/api/index.d.ts +40 -0
- package/dist/plugins/api/index.mjs +8 -0
- package/dist/plugins/blog/api/index.cjs +11 -0
- package/dist/plugins/blog/api/index.d.cts +7 -0
- package/dist/plugins/blog/api/index.d.mts +7 -0
- package/dist/plugins/blog/api/index.d.ts +7 -0
- package/dist/plugins/blog/api/index.mjs +2 -0
- package/dist/plugins/blog/api/plugin.cjs +569 -0
- package/dist/plugins/blog/api/plugin.mjs +565 -0
- package/dist/plugins/blog/client/components/forms/image-field.cjs +133 -0
- package/dist/plugins/blog/client/components/forms/image-field.mjs +131 -0
- package/dist/plugins/blog/client/components/forms/markdown-editor-styles.css +30 -0
- package/dist/plugins/blog/client/components/forms/markdown-editor.cjs +106 -0
- package/dist/plugins/blog/client/components/forms/markdown-editor.mjs +104 -0
- package/dist/plugins/blog/client/components/forms/post-forms.cjs +401 -0
- package/dist/plugins/blog/client/components/forms/post-forms.mjs +398 -0
- package/dist/plugins/blog/client/components/forms/tags-multiselect.cjs +71 -0
- package/dist/plugins/blog/client/components/forms/tags-multiselect.mjs +65 -0
- package/dist/plugins/blog/client/components/index.cjs +17 -0
- package/dist/plugins/blog/client/components/index.d.cts +22 -0
- package/dist/plugins/blog/client/components/index.d.mts +22 -0
- package/dist/plugins/blog/client/components/index.d.ts +22 -0
- package/dist/plugins/blog/client/components/index.mjs +12 -0
- package/dist/plugins/blog/client/components/loading/form-page-skeleton.cjs +62 -0
- package/dist/plugins/blog/client/components/loading/form-page-skeleton.mjs +60 -0
- package/dist/plugins/blog/client/components/loading/index.cjs +20 -0
- package/dist/plugins/blog/client/components/loading/index.mjs +16 -0
- package/dist/plugins/blog/client/components/loading/list-page-skeleton.cjs +26 -0
- package/dist/plugins/blog/client/components/loading/list-page-skeleton.mjs +24 -0
- package/dist/plugins/blog/client/components/loading/page-header-skeleton.cjs +13 -0
- package/dist/plugins/blog/client/components/loading/page-header-skeleton.mjs +11 -0
- package/dist/plugins/blog/client/components/loading/post-card-skeleton.cjs +22 -0
- package/dist/plugins/blog/client/components/loading/post-card-skeleton.mjs +20 -0
- package/dist/plugins/blog/client/components/loading/post-page-skeleton.cjs +56 -0
- package/dist/plugins/blog/client/components/loading/post-page-skeleton.mjs +54 -0
- package/dist/plugins/blog/client/components/pages/404-page.cjs +19 -0
- package/dist/plugins/blog/client/components/pages/404-page.mjs +17 -0
- package/dist/plugins/blog/client/components/pages/edit-post-page.cjs +41 -0
- package/dist/plugins/blog/client/components/pages/edit-post-page.internal.cjs +57 -0
- package/dist/plugins/blog/client/components/pages/edit-post-page.internal.mjs +55 -0
- package/dist/plugins/blog/client/components/pages/edit-post-page.mjs +39 -0
- package/dist/plugins/blog/client/components/pages/home-page.cjs +41 -0
- package/dist/plugins/blog/client/components/pages/home-page.internal.cjs +61 -0
- package/dist/plugins/blog/client/components/pages/home-page.internal.mjs +59 -0
- package/dist/plugins/blog/client/components/pages/home-page.mjs +39 -0
- package/dist/plugins/blog/client/components/pages/new-post-page.cjs +37 -0
- package/dist/plugins/blog/client/components/pages/new-post-page.internal.cjs +53 -0
- package/dist/plugins/blog/client/components/pages/new-post-page.internal.mjs +51 -0
- package/dist/plugins/blog/client/components/pages/new-post-page.mjs +35 -0
- package/dist/plugins/blog/client/components/pages/post-page.cjs +39 -0
- package/dist/plugins/blog/client/components/pages/post-page.internal.cjs +101 -0
- package/dist/plugins/blog/client/components/pages/post-page.internal.mjs +99 -0
- package/dist/plugins/blog/client/components/pages/post-page.mjs +37 -0
- package/dist/plugins/blog/client/components/pages/tag-page.cjs +39 -0
- package/dist/plugins/blog/client/components/pages/tag-page.internal.cjs +61 -0
- package/dist/plugins/blog/client/components/pages/tag-page.internal.mjs +59 -0
- package/dist/plugins/blog/client/components/pages/tag-page.mjs +37 -0
- package/dist/plugins/blog/client/components/shared/better-blog-attribution.cjs +24 -0
- package/dist/plugins/blog/client/components/shared/better-blog-attribution.mjs +22 -0
- package/dist/plugins/blog/client/components/shared/default-error.cjs +18 -0
- package/dist/plugins/blog/client/components/shared/default-error.mjs +16 -0
- package/dist/plugins/blog/client/components/shared/defaults.cjs +13 -0
- package/dist/plugins/blog/client/components/shared/defaults.mjs +10 -0
- package/dist/plugins/blog/client/components/shared/empty-list.cjs +21 -0
- package/dist/plugins/blog/client/components/shared/empty-list.mjs +19 -0
- package/dist/plugins/blog/client/components/shared/error-placeholder.cjs +24 -0
- package/dist/plugins/blog/client/components/shared/error-placeholder.mjs +22 -0
- package/dist/plugins/blog/client/components/shared/highlight-text.cjs +53 -0
- package/dist/plugins/blog/client/components/shared/highlight-text.mjs +51 -0
- package/dist/plugins/blog/client/components/shared/markdown-content-styles.css +328 -0
- package/dist/plugins/blog/client/components/shared/markdown-content.cjs +324 -0
- package/dist/plugins/blog/client/components/shared/markdown-content.mjs +315 -0
- package/dist/plugins/blog/client/components/shared/on-this-page.cjs +161 -0
- package/dist/plugins/blog/client/components/shared/on-this-page.mjs +158 -0
- package/dist/plugins/blog/client/components/shared/page-header.cjs +40 -0
- package/dist/plugins/blog/client/components/shared/page-header.mjs +38 -0
- package/dist/plugins/blog/client/components/shared/page-layout.cjs +24 -0
- package/dist/plugins/blog/client/components/shared/page-layout.mjs +22 -0
- package/dist/plugins/blog/client/components/shared/page-wrapper.cjs +23 -0
- package/dist/plugins/blog/client/components/shared/page-wrapper.mjs +21 -0
- package/dist/plugins/blog/client/components/shared/post-card.cjs +279 -0
- package/dist/plugins/blog/client/components/shared/post-card.mjs +277 -0
- package/dist/plugins/blog/client/components/shared/post-navigation.cjs +74 -0
- package/dist/plugins/blog/client/components/shared/post-navigation.mjs +72 -0
- package/dist/plugins/blog/client/components/shared/posts-list.cjs +48 -0
- package/dist/plugins/blog/client/components/shared/posts-list.mjs +46 -0
- package/dist/plugins/blog/client/components/shared/recent-posts-carousel.cjs +59 -0
- package/dist/plugins/blog/client/components/shared/recent-posts-carousel.mjs +57 -0
- package/dist/plugins/blog/client/components/shared/search-input.cjs +136 -0
- package/dist/plugins/blog/client/components/shared/search-input.mjs +117 -0
- package/dist/plugins/blog/client/components/shared/search-modal.cjs +135 -0
- package/dist/plugins/blog/client/components/shared/search-modal.mjs +116 -0
- package/dist/plugins/blog/client/components/shared/tags-list.cjs +22 -0
- package/dist/plugins/blog/client/components/shared/tags-list.mjs +20 -0
- package/dist/plugins/blog/client/components/shared/use-route-lifecycle.cjs +50 -0
- package/dist/plugins/blog/client/components/shared/use-route-lifecycle.mjs +48 -0
- package/dist/plugins/blog/client/hooks/blog-hooks.cjs +380 -0
- package/dist/plugins/blog/client/hooks/blog-hooks.mjs +368 -0
- package/dist/plugins/blog/client/hooks/index.cjs +17 -0
- package/dist/plugins/blog/client/hooks/index.d.cts +150 -0
- package/dist/plugins/blog/client/hooks/index.d.mts +150 -0
- package/dist/plugins/blog/client/hooks/index.d.ts +150 -0
- package/dist/plugins/blog/client/hooks/index.mjs +1 -0
- package/dist/plugins/blog/client/hooks/use-debounce.cjs +16 -0
- package/dist/plugins/blog/client/hooks/use-debounce.mjs +14 -0
- package/dist/plugins/blog/client/index.cjs +7 -0
- package/dist/plugins/blog/client/index.d.cts +414 -0
- package/dist/plugins/blog/client/index.d.mts +414 -0
- package/dist/plugins/blog/client/index.d.ts +414 -0
- package/dist/plugins/blog/client/index.mjs +1 -0
- package/dist/plugins/blog/client/localization/blog-card.cjs +7 -0
- package/dist/plugins/blog/client/localization/blog-card.mjs +5 -0
- package/dist/plugins/blog/client/localization/blog-common.cjs +10 -0
- package/dist/plugins/blog/client/localization/blog-common.mjs +8 -0
- package/dist/plugins/blog/client/localization/blog-forms.cjs +40 -0
- package/dist/plugins/blog/client/localization/blog-forms.mjs +38 -0
- package/dist/plugins/blog/client/localization/blog-list.cjs +18 -0
- package/dist/plugins/blog/client/localization/blog-list.mjs +16 -0
- package/dist/plugins/blog/client/localization/blog-post.cjs +13 -0
- package/dist/plugins/blog/client/localization/blog-post.mjs +11 -0
- package/dist/plugins/blog/client/localization/index.cjs +17 -0
- package/dist/plugins/blog/client/localization/index.mjs +15 -0
- package/dist/plugins/blog/client/plugin.cjs +462 -0
- package/dist/plugins/blog/client/plugin.mjs +460 -0
- package/dist/plugins/blog/client.css +3 -0
- package/dist/plugins/blog/db.cjs +90 -0
- package/dist/plugins/blog/db.mjs +88 -0
- package/dist/plugins/blog/query-keys.cjs +181 -0
- package/dist/plugins/blog/query-keys.d.cts +530 -0
- package/dist/plugins/blog/query-keys.d.mts +530 -0
- package/dist/plugins/blog/query-keys.d.ts +530 -0
- package/dist/plugins/blog/query-keys.mjs +179 -0
- package/dist/plugins/blog/schemas.cjs +39 -0
- package/dist/plugins/blog/schemas.mjs +35 -0
- package/dist/plugins/blog/style.css +22 -0
- package/dist/plugins/blog/utils.cjs +97 -0
- package/dist/plugins/blog/utils.mjs +87 -0
- package/dist/plugins/client/index.cjs +15 -0
- package/dist/plugins/client/index.d.cts +57 -0
- package/dist/plugins/client/index.d.mts +57 -0
- package/dist/plugins/client/index.d.ts +57 -0
- package/dist/plugins/client/index.mjs +9 -0
- package/dist/{shared/stack.3OUyGp_E.mjs → plugins/utils.mjs} +1 -1
- package/dist/shared/{stack.DORw_1ps.d.cts → stack.ByOugz9d.d.cts} +17 -1
- package/dist/shared/{stack.DORw_1ps.d.mts → stack.ByOugz9d.d.mts} +17 -1
- package/dist/shared/{stack.DORw_1ps.d.ts → stack.ByOugz9d.d.ts} +17 -1
- package/dist/shared/stack.CoPoHVfV.d.cts +76 -0
- package/dist/shared/stack.CoPoHVfV.d.mts +76 -0
- package/dist/shared/stack.CoPoHVfV.d.ts +76 -0
- package/package.json +102 -14
- package/src/__tests__/plugins.test.tsx +539 -0
- package/src/__tests__/sitemap.test.ts +60 -0
- package/src/api/index.ts +75 -0
- package/src/client/components/compose.tsx +116 -0
- package/src/client/components/error-boundary.tsx +30 -0
- package/src/client/components/index.tsx +2 -0
- package/src/client/index.ts +109 -0
- package/src/client/meta-utils.ts +228 -0
- package/src/client/path-utils.ts +38 -0
- package/src/client/sitemap-utils.ts +46 -0
- package/src/context/index.ts +1 -0
- package/src/context/provider.tsx +157 -0
- package/src/index.ts +1 -0
- package/src/plugins/api/index.ts +50 -0
- package/src/plugins/blog/api/index.ts +2 -0
- package/src/plugins/blog/api/plugin.ts +759 -0
- package/src/plugins/blog/client/components/forms/image-field.tsx +165 -0
- package/src/plugins/blog/client/components/forms/markdown-editor-styles.css +30 -0
- package/src/plugins/blog/client/components/forms/markdown-editor.tsx +136 -0
- package/src/plugins/blog/client/components/forms/post-forms.tsx +531 -0
- package/src/plugins/blog/client/components/forms/tags-multiselect.tsx +79 -0
- package/src/plugins/blog/client/components/index.tsx +11 -0
- package/src/plugins/blog/client/components/loading/form-page-skeleton.tsx +75 -0
- package/src/plugins/blog/client/components/loading/index.tsx +27 -0
- package/src/plugins/blog/client/components/loading/list-page-skeleton.tsx +38 -0
- package/src/plugins/blog/client/components/loading/page-header-skeleton.tsx +10 -0
- package/src/plugins/blog/client/components/loading/post-card-skeleton.tsx +30 -0
- package/src/plugins/blog/client/components/loading/post-page-skeleton.tsx +75 -0
- package/src/plugins/blog/client/components/pages/404-page.tsx +23 -0
- package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +60 -0
- package/src/plugins/blog/client/components/pages/edit-post-page.tsx +40 -0
- package/src/plugins/blog/client/components/pages/home-page.internal.tsx +71 -0
- package/src/plugins/blog/client/components/pages/home-page.tsx +42 -0
- package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +59 -0
- package/src/plugins/blog/client/components/pages/new-post-page.tsx +36 -0
- package/src/plugins/blog/client/components/pages/post-page.internal.tsx +142 -0
- package/src/plugins/blog/client/components/pages/post-page.tsx +38 -0
- package/src/plugins/blog/client/components/pages/tag-page.internal.tsx +74 -0
- package/src/plugins/blog/client/components/pages/tag-page.tsx +38 -0
- package/src/plugins/blog/client/components/shared/better-blog-attribution.tsx +19 -0
- package/src/plugins/blog/client/components/shared/default-error.tsx +20 -0
- package/src/plugins/blog/client/components/shared/defaults.tsx +9 -0
- package/src/plugins/blog/client/components/shared/empty-list.tsx +25 -0
- package/src/plugins/blog/client/components/shared/error-placeholder.tsx +20 -0
- package/src/plugins/blog/client/components/shared/highlight-text.tsx +80 -0
- package/src/plugins/blog/client/components/shared/markdown-content-styles.css +328 -0
- package/src/plugins/blog/client/components/shared/markdown-content.tsx +448 -0
- package/src/plugins/blog/client/components/shared/on-this-page.tsx +234 -0
- package/src/plugins/blog/client/components/shared/page-header.tsx +35 -0
- package/src/plugins/blog/client/components/shared/page-layout.tsx +23 -0
- package/src/plugins/blog/client/components/shared/page-wrapper.tsx +32 -0
- package/src/plugins/blog/client/components/shared/post-card.tsx +308 -0
- package/src/plugins/blog/client/components/shared/post-navigation.tsx +98 -0
- package/src/plugins/blog/client/components/shared/posts-list.tsx +67 -0
- package/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +79 -0
- package/src/plugins/blog/client/components/shared/search-input.tsx +146 -0
- package/src/plugins/blog/client/components/shared/search-modal.tsx +162 -0
- package/src/plugins/blog/client/components/shared/tags-list.tsx +34 -0
- package/src/plugins/blog/client/components/shared/use-route-lifecycle.tsx +68 -0
- package/src/plugins/blog/client/hooks/blog-hooks.tsx +623 -0
- package/src/plugins/blog/client/hooks/index.tsx +1 -0
- package/src/plugins/blog/client/hooks/use-debounce.ts +43 -0
- package/src/plugins/blog/client/index.ts +9 -0
- package/src/plugins/blog/client/localization/blog-card.ts +3 -0
- package/src/plugins/blog/client/localization/blog-common.ts +7 -0
- package/src/plugins/blog/client/localization/blog-forms.ts +45 -0
- package/src/plugins/blog/client/localization/blog-list.ts +14 -0
- package/src/plugins/blog/client/localization/blog-post.ts +9 -0
- package/src/plugins/blog/client/localization/index.ts +15 -0
- package/src/plugins/blog/client/overrides.ts +123 -0
- package/src/plugins/blog/client/plugin.tsx +672 -0
- package/src/plugins/blog/client.css +3 -0
- package/src/plugins/blog/db.ts +90 -0
- package/src/plugins/blog/query-keys.ts +267 -0
- package/src/plugins/blog/schemas.ts +39 -0
- package/src/plugins/blog/style.css +22 -0
- package/src/plugins/blog/types.ts +37 -0
- package/src/plugins/blog/utils.ts +144 -0
- package/src/plugins/client/index.ts +53 -0
- package/src/plugins/index.ts +0 -0
- package/src/plugins/utils.ts +35 -0
- package/src/types.ts +209 -0
- package/dist/plugins/index.cjs +0 -15
- package/dist/plugins/index.d.cts +0 -64
- package/dist/plugins/index.d.mts +0 -64
- package/dist/plugins/index.d.ts +0 -64
- package/dist/plugins/index.mjs +0 -11
- package/dist/shared/stack.DrUAVfIH.d.cts +0 -17
- package/dist/shared/stack.DrUAVfIH.d.mts +0 -17
- package/dist/shared/stack.DrUAVfIH.d.ts +0 -17
- /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
|
+
}
|