@fecommunity/reactpress-theme-starter 1.0.0-beta.0

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 (163) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +286 -0
  4. package/app/api/[[...path]]/route.ts +69 -0
  5. package/app/archives/page.tsx +28 -0
  6. package/app/article/[id]/page.tsx +92 -0
  7. package/app/category/[category]/page.tsx +60 -0
  8. package/app/globals.css +4969 -0
  9. package/app/knowledge/[pId]/[id]/page.tsx +54 -0
  10. package/app/knowledge/[pId]/page.tsx +47 -0
  11. package/app/knowledge/page.tsx +29 -0
  12. package/app/layout.tsx +76 -0
  13. package/app/llms.txt/route.ts +26 -0
  14. package/app/login/page.tsx +17 -0
  15. package/app/nav/[id]/page.tsx +44 -0
  16. package/app/nav/page.tsx +5 -0
  17. package/app/not-found.tsx +21 -0
  18. package/app/page/[id]/page.tsx +57 -0
  19. package/app/page.tsx +61 -0
  20. package/app/register/page.tsx +17 -0
  21. package/app/robots.ts +14 -0
  22. package/app/rss/route.ts +82 -0
  23. package/app/search/page.tsx +46 -0
  24. package/app/sitemap.ts +38 -0
  25. package/app/suggestions/page.tsx +47 -0
  26. package/app/tag/[tag]/page.tsx +58 -0
  27. package/app/tags/page.tsx +18 -0
  28. package/app/template.tsx +6 -0
  29. package/components/article/ArchiveBanner.tsx +40 -0
  30. package/components/article/ArticleActionsBar.tsx +53 -0
  31. package/components/article/ArticleCarousel.tsx +237 -0
  32. package/components/article/ArticleCopyrightBar.tsx +128 -0
  33. package/components/article/ArticleEndBar.tsx +19 -0
  34. package/components/article/ArticleFeedSection.tsx +59 -0
  35. package/components/article/ArticleList.tsx +170 -0
  36. package/components/article/ArticleReadingChrome.tsx +104 -0
  37. package/components/article/ArticleReadingProgress.tsx +75 -0
  38. package/components/article/ArticleRecommend.tsx +362 -0
  39. package/components/article/ArticleTocPanel.tsx +19 -0
  40. package/components/article/ArticleViewClient.tsx +221 -0
  41. package/components/article/CategoryMenu.tsx +67 -0
  42. package/components/article/CmsPageClient.tsx +92 -0
  43. package/components/article/LoadMore.tsx +59 -0
  44. package/components/article/ReadingContent.tsx +51 -0
  45. package/components/auth/AuthPageClient.tsx +314 -0
  46. package/components/comment/CommentEditor.tsx +290 -0
  47. package/components/comment/CommentEmoji.tsx +110 -0
  48. package/components/comment/CommentIconButton.tsx +37 -0
  49. package/components/comment/CommentItem.tsx +135 -0
  50. package/components/comment/CommentSection.tsx +123 -0
  51. package/components/comment/emojis.ts +152 -0
  52. package/components/knowledge/KnowledgeList.tsx +70 -0
  53. package/components/layout/BackToTop.tsx +27 -0
  54. package/components/layout/ConditionalSiteFooter.tsx +29 -0
  55. package/components/layout/ContentStylesLoader.tsx +9 -0
  56. package/components/layout/DoubleColumnLayout.tsx +171 -0
  57. package/components/layout/HeaderSearch.tsx +23 -0
  58. package/components/layout/LocaleSwitcher.tsx +24 -0
  59. package/components/layout/PageContainer.tsx +11 -0
  60. package/components/layout/PageLoadProgress.tsx +169 -0
  61. package/components/layout/SiteFooter.tsx +75 -0
  62. package/components/layout/SiteHeader.tsx +312 -0
  63. package/components/layout/SystemNotification.tsx +176 -0
  64. package/components/layout/ThemeSwitch.tsx +19 -0
  65. package/components/layout/UserAuth.tsx +130 -0
  66. package/components/search/SearchHero.tsx +323 -0
  67. package/components/search/SearchQuickLinks.tsx +285 -0
  68. package/components/shared/DevChunkRecovery.tsx +8 -0
  69. package/components/shared/Link.tsx +30 -0
  70. package/components/shared/SectionHeading.tsx +38 -0
  71. package/components/shared/ThemeImage.tsx +13 -0
  72. package/components/shared/logo.svg +7 -0
  73. package/components/views/ArchivesClient.tsx +137 -0
  74. package/components/views/CategoryClient.tsx +87 -0
  75. package/components/views/HomeClient.tsx +79 -0
  76. package/components/views/KnowledgeBookClient.tsx +127 -0
  77. package/components/views/KnowledgeChapterClient.tsx +175 -0
  78. package/components/views/KnowledgeClient.tsx +69 -0
  79. package/components/views/LoginClient.tsx +80 -0
  80. package/components/views/NavDetailClient.tsx +177 -0
  81. package/components/views/SearchClient.tsx +100 -0
  82. package/components/views/TagArchiveClient.tsx +105 -0
  83. package/components/views/TagsClient.tsx +46 -0
  84. package/components/widgets/AboutUs.tsx +180 -0
  85. package/components/widgets/ArticleActionButton.tsx +95 -0
  86. package/components/widgets/BookmarkWidget.tsx +61 -0
  87. package/components/widgets/CategoriesWidget.tsx +47 -0
  88. package/components/widgets/HomeSidebar.tsx +40 -0
  89. package/components/widgets/LikesWidget.tsx +77 -0
  90. package/components/widgets/ShareWidget.tsx +120 -0
  91. package/components/widgets/TagCloud.tsx +94 -0
  92. package/components/widgets/TagsWidget.tsx +160 -0
  93. package/eslint.config.mjs +80 -0
  94. package/lib/auth/githubOAuth.ts +26 -0
  95. package/lib/auth/sessionUser.ts +41 -0
  96. package/lib/mock-api/auth.ts +129 -0
  97. package/lib/mock-api/config.ts +11 -0
  98. package/lib/mock-api/cors.ts +53 -0
  99. package/lib/mock-api/data.ts +998 -0
  100. package/lib/mock-api/match-route.ts +202 -0
  101. package/lib/mock-api/proxy.ts +28 -0
  102. package/lib/providers/client.ts +58 -0
  103. package/lib/providers/materializeProvider.ts +9 -0
  104. package/lib/providers/server.ts +8 -0
  105. package/lib/reactpress/appearance.ts +69 -0
  106. package/lib/reactpress/bootstrap.ts +48 -0
  107. package/lib/reactpress/coercePublishedPage.ts +12 -0
  108. package/lib/reactpress/contentSeo.ts +123 -0
  109. package/lib/reactpress/env.ts +31 -0
  110. package/lib/reactpress/feedFooterPlacement.ts +16 -0
  111. package/lib/reactpress/fetchSetting.ts +19 -0
  112. package/lib/reactpress/homeFooter.tsx +27 -0
  113. package/lib/reactpress/layout-shell.tsx +19 -0
  114. package/lib/reactpress/materializeBootstrapCatalog.ts +31 -0
  115. package/lib/reactpress/normalizeApiResponse.ts +25 -0
  116. package/lib/reactpress/providers.tsx +199 -0
  117. package/lib/reactpress/resolveSiteNav.ts +56 -0
  118. package/lib/reactpress/seoMetadata.ts +82 -0
  119. package/lib/reactpress/serverLocale.ts +43 -0
  120. package/lib/reactpress/siteMetadata.ts +151 -0
  121. package/lib/reactpress/staticParams.ts +46 -0
  122. package/lib/reactpress/suggestionsContent.ts +48 -0
  123. package/lib/reactpress/useFeedFooterPlacement.ts +22 -0
  124. package/lib/reactpress/useSiteNavConfig.ts +39 -0
  125. package/lib/utils/archiveBanner.ts +8 -0
  126. package/lib/utils/colors.ts +119 -0
  127. package/lib/utils/comment.ts +46 -0
  128. package/lib/utils/headerPosition.ts +55 -0
  129. package/lib/utils/icons.tsx +238 -0
  130. package/lib/utils/reading.ts +16 -0
  131. package/lib/utils/scroll.ts +8 -0
  132. package/lib/utils/tagCloud.ts +222 -0
  133. package/next.config.js +103 -0
  134. package/package.json +98 -0
  135. package/postcss.config.js +5 -0
  136. package/prettier.config.js +10 -0
  137. package/public/apple-touch-icon.png +0 -0
  138. package/public/cli.png +0 -0
  139. package/public/favicon-32.png +0 -0
  140. package/public/favicon.ico +0 -0
  141. package/public/home-dark.png +0 -0
  142. package/public/home-light.png +0 -0
  143. package/public/icon-192.png +0 -0
  144. package/public/icon-512.png +0 -0
  145. package/public/logo-400.png +0 -0
  146. package/public/logo.png +0 -0
  147. package/public/mobile.png +0 -0
  148. package/public/pad.png +0 -0
  149. package/public/site.webmanifest +19 -0
  150. package/scripts/build-mock.mjs +139 -0
  151. package/scripts/dev.mjs +221 -0
  152. package/scripts/ensure-env.mjs +100 -0
  153. package/scripts/remote-dev.mjs +143 -0
  154. package/scripts/resolve-api-env.mjs +114 -0
  155. package/scripts/set-publish-version.mjs +31 -0
  156. package/scripts/smoke-pages.mjs +113 -0
  157. package/scripts/start-mock.mjs +79 -0
  158. package/scripts/verify-toolkit.mjs +37 -0
  159. package/svg.d.ts +6 -0
  160. package/theme.json +123 -0
  161. package/theme.manifest.schema.json +265 -0
  162. package/tsconfig.json +41 -0
  163. package/vercel.json +12 -0
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+
3
+ import { useLayoutEffect } from 'react'
4
+
5
+ import {
6
+ shouldPlaceFooterInSidebar,
7
+ type FeedFooterPlacementOptions,
8
+ } from '@/lib/reactpress/feedFooterPlacement'
9
+ import { useHomeFooter } from '@/lib/reactpress/homeFooter'
10
+
11
+ export function useFeedFooterPlacement(options: FeedFooterPlacementOptions) {
12
+ const { setShowLayoutFooter } = useHomeFooter()
13
+ const footerInSidebar = shouldPlaceFooterInSidebar(options)
14
+ const footerAtBottom = !footerInSidebar
15
+
16
+ useLayoutEffect(() => {
17
+ setShowLayoutFooter(footerAtBottom)
18
+ return () => setShowLayoutFooter(false)
19
+ }, [footerAtBottom, setShowLayoutFooter])
20
+
21
+ return { footerInSidebar, footerAtBottom }
22
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { fetchVisitorSetting } from '@/lib/reactpress/fetchSetting'
4
+ import { resolveSiteNavConfig, type SiteNavBlocks } from '@/lib/reactpress/resolveSiteNav'
5
+ import themeManifest from '../../theme.json'
6
+ import { safeJsonParse } from '@fecommunity/reactpress-toolkit/theme'
7
+ import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
8
+ import { useEffect, useRef, useState } from 'react'
9
+
10
+ export function useSiteNavConfig(initialNav?: Partial<SiteNavBlocks>) {
11
+ const { locale } = useLocale()
12
+ const initialLocaleRef = useRef(locale)
13
+ const skipInitialFetchRef = useRef(Boolean(initialNav?.urlConfig?.length))
14
+ const [nav, setNav] = useState<SiteNavBlocks>(() => ({
15
+ urlConfig: initialNav?.urlConfig ?? [],
16
+ searchCategories: initialNav?.searchCategories ?? { categories: [], subCategories: {} },
17
+ }))
18
+
19
+ useEffect(() => {
20
+ if (skipInitialFetchRef.current && locale === initialLocaleRef.current) {
21
+ skipInitialFetchRef.current = false
22
+ return
23
+ }
24
+
25
+ let cancelled = false
26
+
27
+ fetchVisitorSetting().then((setting) => {
28
+ if (cancelled || !setting) return
29
+ const raw = safeJsonParse(setting.globalSetting, {})
30
+ setNav(resolveSiteNavConfig(raw, locale, themeManifest.id))
31
+ })
32
+
33
+ return () => {
34
+ cancelled = true
35
+ }
36
+ }, [locale])
37
+
38
+ return nav
39
+ }
@@ -0,0 +1,8 @@
1
+ export function getArchiveBannerImage(articles: { cover?: string }[] = []) {
2
+ const cover = articles.find((article) => article.cover)?.cover
3
+ const fallback = '/logo.png'
4
+ return {
5
+ url: cover || fallback,
6
+ isBrandFallback: !cover,
7
+ }
8
+ }
@@ -0,0 +1,119 @@
1
+ const TAG_COLORS = [
2
+ '#389e0d',
3
+ '#cf1322',
4
+ '#096dd9',
5
+ '#d48806',
6
+ '#c41d7f',
7
+ '#531dab',
8
+ '#a8071a',
9
+ '#006d75',
10
+ '#237804',
11
+ '#d4380d',
12
+ '#434343',
13
+ '#ad8b00',
14
+ '#595959',
15
+ '#08979c',
16
+ '#3f8600',
17
+ '#0050b3',
18
+ ]
19
+
20
+ function parseHexColor(hex: string): [number, number, number] | null {
21
+ const normalized = hex.replace('#', '').trim()
22
+ if (normalized.length === 3) {
23
+ return [
24
+ parseInt(normalized[0] + normalized[0], 16),
25
+ parseInt(normalized[1] + normalized[1], 16),
26
+ parseInt(normalized[2] + normalized[2], 16),
27
+ ]
28
+ }
29
+ if (normalized.length === 6) {
30
+ return [
31
+ parseInt(normalized.slice(0, 2), 16),
32
+ parseInt(normalized.slice(2, 4), 16),
33
+ parseInt(normalized.slice(4, 6), 16),
34
+ ]
35
+ }
36
+ return null
37
+ }
38
+
39
+ function toHex(r: number, g: number, b: number): string {
40
+ const clamp = (value: number) => Math.max(0, Math.min(255, Math.round(value)))
41
+ return `#${[clamp(r), clamp(g), clamp(b)]
42
+ .map((value) => value.toString(16).padStart(2, '0'))
43
+ .join('')}`
44
+ }
45
+
46
+ function relativeLuminance(r: number, g: number, b: number): number {
47
+ const [rs, gs, bs] = [r, g, b].map((channel) => {
48
+ const normalized = channel / 255
49
+ return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4)
50
+ })
51
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
52
+ }
53
+
54
+ function contrastBetween(bg: string, text: string): number {
55
+ const bgRgb = parseHexColor(bg)
56
+ const textRgb = parseHexColor(text)
57
+ if (!bgRgb || !textRgb) return 1
58
+ const bgLum = relativeLuminance(...bgRgb)
59
+ const textLum = relativeLuminance(...textRgb)
60
+ const lighter = Math.max(bgLum, textLum)
61
+ const darker = Math.min(bgLum, textLum)
62
+ return (lighter + 0.05) / (darker + 0.05)
63
+ }
64
+
65
+ export function getColorFromNumber(num: number): string {
66
+ return TAG_COLORS[num % TAG_COLORS.length]
67
+ }
68
+
69
+ export function getReadableTextColor(backgroundColor: string): '#ffffff' | '#1a1a1a' {
70
+ const whiteContrast = contrastBetween(backgroundColor, '#ffffff')
71
+ const blackContrast = contrastBetween(backgroundColor, '#1a1a1a')
72
+ return whiteContrast >= blackContrast ? '#ffffff' : '#1a1a1a'
73
+ }
74
+
75
+ function adjustTagColors(
76
+ backgroundColor: string,
77
+ textColor: '#ffffff' | '#1a1a1a',
78
+ factor: number
79
+ ): { backgroundColor: string; color: string } | null {
80
+ let bg = backgroundColor
81
+
82
+ for (let attempt = 0; attempt < 16; attempt += 1) {
83
+ if (contrastBetween(bg, textColor) >= 4.5) {
84
+ return { backgroundColor: bg, color: textColor }
85
+ }
86
+ const rgb = parseHexColor(bg)
87
+ if (!rgb) break
88
+ bg = toHex(rgb[0] * factor, rgb[1] * factor, rgb[2] * factor)
89
+ }
90
+
91
+ return null
92
+ }
93
+
94
+ export function getTagStyle(backgroundColor: string): { backgroundColor: string; color: string } {
95
+ const whitePath = adjustTagColors(backgroundColor, '#ffffff', 0.85)
96
+ const blackPath = adjustTagColors(backgroundColor, '#1a1a1a', 1.12)
97
+
98
+ if (whitePath && blackPath) {
99
+ const originalContrast = Math.max(
100
+ contrastBetween(backgroundColor, '#ffffff'),
101
+ contrastBetween(backgroundColor, '#1a1a1a')
102
+ )
103
+ const whiteDelta = Math.abs(
104
+ contrastBetween(whitePath.backgroundColor, '#ffffff') - originalContrast
105
+ )
106
+ const blackDelta = Math.abs(
107
+ contrastBetween(blackPath.backgroundColor, '#1a1a1a') - originalContrast
108
+ )
109
+ return whiteDelta <= blackDelta ? whitePath : blackPath
110
+ }
111
+
112
+ return (
113
+ whitePath ??
114
+ blackPath ?? {
115
+ backgroundColor,
116
+ color: getReadableTextColor(backgroundColor),
117
+ }
118
+ )
119
+ }
@@ -0,0 +1,46 @@
1
+ import type { IComment } from '@fecommunity/reactpress-toolkit/types'
2
+
3
+ export type CommentNode = IComment & { children?: CommentNode[] }
4
+
5
+ const AVATAR_COLORS = [
6
+ '#52c41a',
7
+ '#f5222d',
8
+ '#1890ff',
9
+ '#faad14',
10
+ '#ff0064',
11
+ '#722ed1',
12
+ '#dc3545',
13
+ '#17a2b8',
14
+ '#00b74a',
15
+ '#fc651f',
16
+ '#6c757d',
17
+ '#f5c800',
18
+ '#808695',
19
+ '#2db7f5',
20
+ '#87d068',
21
+ '#108ee9',
22
+ ]
23
+
24
+ const colorCache: Record<string, string> = {}
25
+
26
+ export function getRandomColor(key: string): string {
27
+ if (!colorCache[key]) {
28
+ colorCache[key] = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]
29
+ }
30
+ return colorCache[key]
31
+ }
32
+
33
+ type SessionUser = {
34
+ name?: string
35
+ email?: string
36
+ token?: string
37
+ avatar?: string
38
+ }
39
+
40
+ export function isLoggedInUser(
41
+ user: SessionUser | null | undefined
42
+ ): user is SessionUser & { name: string; token: string } {
43
+ return Boolean(user?.name?.trim() && user?.token?.trim())
44
+ }
45
+
46
+ export const COMMENT_DOM_ID = 'js-comment-id'
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ export const HEADER_BAR_SELECTOR = '.rp-header-bar'
6
+
7
+ export function measureHeaderTop(): number {
8
+ const header = document.querySelector(HEADER_BAR_SELECTOR)
9
+ if (!header) return 0
10
+ const { top } = header.getBoundingClientRect()
11
+ // Header visible: align with its top edge. Header hidden: stay at viewport top.
12
+ return Math.max(0, top)
13
+ }
14
+
15
+ export function useHeaderTop() {
16
+ const [top, setTop] = useState(0)
17
+
18
+ useEffect(() => {
19
+ let frame = 0
20
+
21
+ const update = () => {
22
+ frame = 0
23
+ setTop(measureHeaderTop())
24
+ }
25
+
26
+ const onScroll = () => {
27
+ if (frame) return
28
+ frame = window.requestAnimationFrame(update)
29
+ }
30
+
31
+ update()
32
+ window.addEventListener('scroll', onScroll, { passive: true })
33
+ window.addEventListener('resize', onScroll, { passive: true })
34
+
35
+ const header = document.querySelector(HEADER_BAR_SELECTOR)
36
+ header?.addEventListener('transitionend', onScroll)
37
+
38
+ const onHeaderState = (event: MessageEvent) => {
39
+ if (event.origin !== window.location.origin) return
40
+ if (event.data?.id !== 'header-state') return
41
+ onScroll()
42
+ }
43
+ window.addEventListener('message', onHeaderState)
44
+
45
+ return () => {
46
+ if (frame) cancelAnimationFrame(frame)
47
+ window.removeEventListener('scroll', onScroll)
48
+ window.removeEventListener('resize', onScroll)
49
+ header?.removeEventListener('transitionend', onScroll)
50
+ window.removeEventListener('message', onHeaderState)
51
+ }
52
+ }, [])
53
+
54
+ return top
55
+ }
@@ -0,0 +1,238 @@
1
+ import type { ReactNode, SVGProps } from 'react'
2
+
3
+ type IconProps = SVGProps<SVGSVGElement> & { size?: number }
4
+
5
+ function SvgIcon({ size = 20, children, ...rest }: IconProps & { children: ReactNode }) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width={size}
10
+ height={size}
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth={1.8}
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ aria-hidden
18
+ {...rest}
19
+ >
20
+ {children}
21
+ </svg>
22
+ )
23
+ }
24
+
25
+ export function HomeIcon(props: IconProps) {
26
+ return (
27
+ <SvgIcon {...props}>
28
+ <path d="M3 10.5 12 3l9 7.5" />
29
+ <path d="M5 9.5V20h14V9.5" />
30
+ </SvgIcon>
31
+ )
32
+ }
33
+
34
+ export function NavIcon(props: IconProps) {
35
+ return (
36
+ <SvgIcon {...props}>
37
+ <circle cx="12" cy="12" r="9" />
38
+ <path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
39
+ </SvgIcon>
40
+ )
41
+ }
42
+
43
+ export function ArchivesIcon(props: IconProps) {
44
+ return (
45
+ <SvgIcon {...props}>
46
+ <circle cx="12" cy="12" r="9" />
47
+ <path d="M12 7v5l3 2" />
48
+ </SvgIcon>
49
+ )
50
+ }
51
+
52
+ export function FolderIcon(props: IconProps) {
53
+ return (
54
+ <SvgIcon {...props}>
55
+ <path d="M3 7h6l2 2h10v10H3z" />
56
+ </SvgIcon>
57
+ )
58
+ }
59
+
60
+ export function HeartIcon(props: IconProps) {
61
+ return (
62
+ <SvgIcon {...props}>
63
+ <path d="M12 20s-7-4.5-7-10a4 4 0 0 1 7-2 4 4 0 0 1 7 2c0 5.5-7 10-7 10z" />
64
+ </SvgIcon>
65
+ )
66
+ }
67
+
68
+ export function EyeIcon(props: IconProps) {
69
+ return (
70
+ <SvgIcon {...props}>
71
+ <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
72
+ <circle cx="12" cy="12" r="3" />
73
+ </SvgIcon>
74
+ )
75
+ }
76
+
77
+ export function ClockIcon(props: IconProps) {
78
+ return (
79
+ <SvgIcon {...props}>
80
+ <circle cx="12" cy="12" r="9" />
81
+ <path d="M12 7v5l3 2" />
82
+ </SvgIcon>
83
+ )
84
+ }
85
+
86
+ export function TagIcon(props: IconProps) {
87
+ return (
88
+ <SvgIcon {...props}>
89
+ <path d="M3 11v-2l8-6 8 6v2l-8 10z" />
90
+ <circle cx="7.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
91
+ </SvgIcon>
92
+ )
93
+ }
94
+
95
+ export function UserIcon(props: IconProps) {
96
+ return (
97
+ <SvgIcon {...props}>
98
+ <circle cx="12" cy="8" r="4" />
99
+ <path d="M4 20c1.5-4 6-6 8-6s6.5 2 8 6" />
100
+ </SvgIcon>
101
+ )
102
+ }
103
+
104
+ export function BellIcon(props: IconProps) {
105
+ return (
106
+ <SvgIcon {...props}>
107
+ <path d="M18 16H6l2-2V10a4 4 0 0 1 8 0v4z" />
108
+ <path d="M10 18a2 2 0 0 0 4 0" />
109
+ </SvgIcon>
110
+ )
111
+ }
112
+
113
+ export function FlameIcon(props: IconProps) {
114
+ return (
115
+ <SvgIcon {...props}>
116
+ <path d="M12 3c2 4 5 5 5 9a5 5 0 0 1-10 0c0-3 2-4 3-7 1 2 1 3 2 5z" />
117
+ </SvgIcon>
118
+ )
119
+ }
120
+
121
+ export function SearchIcon(props: IconProps) {
122
+ return (
123
+ <SvgIcon {...props}>
124
+ <circle cx="11" cy="11" r="7" />
125
+ <path d="m20 20-4-4" />
126
+ </SvgIcon>
127
+ )
128
+ }
129
+
130
+ export function ChevronRightIcon(props: IconProps) {
131
+ return (
132
+ <SvgIcon {...props}>
133
+ <path d="m9 6 6 6-6 6" />
134
+ </SvgIcon>
135
+ )
136
+ }
137
+
138
+ export function ChevronLeftIcon(props: IconProps) {
139
+ return (
140
+ <SvgIcon {...props}>
141
+ <path d="m15 18-6-6 6-6" />
142
+ </SvgIcon>
143
+ )
144
+ }
145
+
146
+ export function MessageIcon(props: IconProps) {
147
+ return (
148
+ <SvgIcon {...props}>
149
+ <path d="M4 5h16v11H7l-3 3z" />
150
+ </SvgIcon>
151
+ )
152
+ }
153
+
154
+ export function MenuFoldIcon(props: IconProps) {
155
+ return (
156
+ <SvgIcon {...props}>
157
+ <path d="M4 6h16M4 12h10M4 18h16" />
158
+ </SvgIcon>
159
+ )
160
+ }
161
+
162
+ export function MenuUnfoldIcon(props: IconProps) {
163
+ return (
164
+ <SvgIcon {...props}>
165
+ <path d="M4 6h16M10 12h10M4 18h16" />
166
+ </SvgIcon>
167
+ )
168
+ }
169
+
170
+ export function ToolIcon(props: IconProps) {
171
+ return (
172
+ <SvgIcon {...props}>
173
+ <path d="M14.7 6.3a4.5 4.5 0 0 0-6.1 6.1L3 18l3 3 5.6-5.6a4.5 4.5 0 0 0 6.1-6.1z" />
174
+ <path d="M15 5l4 4" />
175
+ </SvgIcon>
176
+ )
177
+ }
178
+
179
+ export function CodeIcon(props: IconProps) {
180
+ return (
181
+ <SvgIcon {...props}>
182
+ <path d="M9 8 4 12l5 4" />
183
+ <path d="M15 8l5 4-5 4" />
184
+ <path d="M11 20 13 4" />
185
+ </SvgIcon>
186
+ )
187
+ }
188
+
189
+ import type { JSX } from 'react'
190
+
191
+ const iconMap: Record<string, (props: IconProps) => JSX.Element> = {
192
+ HomeOutlined: HomeIcon,
193
+ home: HomeIcon,
194
+ GlobalOutlined: NavIcon,
195
+ nav: NavIcon,
196
+ ToolOutlined: ToolIcon,
197
+ tool: ToolIcon,
198
+ CodeOutlined: CodeIcon,
199
+ code: CodeIcon,
200
+ HistoryOutlined: ArchivesIcon,
201
+ archives: ArchivesIcon,
202
+ FolderOutlined: FolderIcon,
203
+ folder: FolderIcon,
204
+ TagOutlined: TagIcon,
205
+ tag: TagIcon,
206
+ UserOutlined: UserIcon,
207
+ user: UserIcon,
208
+ SearchOutlined: SearchIcon,
209
+ search: SearchIcon,
210
+ MessageOutlined: MessageIcon,
211
+ message: MessageIcon,
212
+ suggestions: MessageIcon,
213
+ ProfileOutlined: UserIcon,
214
+ profile: UserIcon,
215
+ }
216
+
217
+ export function getIconByName(name?: string | ReactNode) {
218
+ if (!name || typeof name !== 'string') return null
219
+ return iconMap[name] ?? null
220
+ }
221
+
222
+ /** Nav category icons — fallback to folder when name is unknown. */
223
+ export function getNavIconByName(name?: string) {
224
+ if (!name) return FolderIcon
225
+ return iconMap[name] ?? FolderIcon
226
+ }
227
+
228
+ export function getFirstLevelRoute(path: string, locales: string[] = ['zh', 'en']) {
229
+ if (!path.startsWith('/')) path = `/${path}`
230
+ if (path.endsWith('/') && path.length > 1) path = path.slice(0, -1)
231
+ const segments = path.split('/').filter(Boolean)
232
+ if (segments.length > 0 && locales.includes(segments[0])) {
233
+ segments.shift()
234
+ path = segments.length ? `/${segments.join('/')}` : '/'
235
+ }
236
+ const secondSlashIndex = path.indexOf('/', 1)
237
+ return secondSlashIndex === -1 ? path : path.slice(0, secondSlashIndex)
238
+ }
@@ -0,0 +1,16 @@
1
+ /** Estimate reading time from HTML content (CJK-aware). */
2
+ export function estimateReadMinutes(html: string): number {
3
+ const text = html
4
+ .replace(/<[^>]*>/g, ' ')
5
+ .replace(/\s+/g, ' ')
6
+ .trim()
7
+ if (!text) return 1
8
+
9
+ const cjkChars = (text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g) || []).length
10
+ if (cjkChars > text.length * 0.25) {
11
+ return Math.max(1, Math.ceil(text.length / 400))
12
+ }
13
+
14
+ const words = text.split(/\s+/).filter(Boolean).length
15
+ return Math.max(1, Math.ceil(words / 220))
16
+ }
@@ -0,0 +1,8 @@
1
+ export function getDocumentScrollTop(): number {
2
+ return (
3
+ document.documentElement.scrollTop ||
4
+ window.pageYOffset ||
5
+ window.scrollY ||
6
+ document.body.scrollTop
7
+ )
8
+ }