@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,202 @@
1
+ import { mockAuthGithub, mockAuthLogin, mockAuthRegister } from './auth'
2
+ import {
3
+ filterMockArticlesByCategory,
4
+ filterMockArticlesByTag,
5
+ getMockArticleById,
6
+ getMockCategoryByValue,
7
+ getMockKnowledgeById,
8
+ getMockPageById,
9
+ getMockTagByValue,
10
+ mockArchives,
11
+ mockArticles,
12
+ mockCategories,
13
+ mockKnowledgeBooks,
14
+ mockPages,
15
+ mockRecommendedArticles,
16
+ mockSetting,
17
+ mockTags,
18
+ searchMockArticles,
19
+ } from './data'
20
+
21
+ function incrementViews<T extends { views?: number }>(item: T): T {
22
+ return { ...item, views: (item.views ?? 0) + 1 }
23
+ }
24
+
25
+ function findMockKnowledgeById(id: string) {
26
+ return getMockKnowledgeById(id)
27
+ }
28
+
29
+ type MatchOptions = {
30
+ keyword?: string
31
+ body?: unknown
32
+ }
33
+
34
+ function jsonEnvelope(data: unknown, status = 200): Response {
35
+ return Response.json(
36
+ { success: true, data },
37
+ {
38
+ status,
39
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
40
+ }
41
+ )
42
+ }
43
+
44
+ function normalizePath(segments: string[]): string {
45
+ const path = `/${segments.map((segment) => decodeURIComponent(segment)).join('/')}`
46
+ return (path.replace(/\/$/, '') || '/').toLowerCase()
47
+ }
48
+
49
+ function paginated<T>(items: T[]) {
50
+ return [items, items.length] as const
51
+ }
52
+
53
+ export function matchMockRoute(
54
+ method: string,
55
+ segments: string[],
56
+ options: MatchOptions = {}
57
+ ): Response | null {
58
+ const path = normalizePath(segments)
59
+
60
+ if (method === 'POST' && path === '/auth/login') {
61
+ return mockAuthLogin(options.body)
62
+ }
63
+ if (method === 'POST' && path === '/auth/github') {
64
+ return mockAuthGithub(options.body)
65
+ }
66
+ if (method === 'POST' && path === '/user/register') {
67
+ return mockAuthRegister(options.body)
68
+ }
69
+
70
+ if (method === 'POST' && path === '/setting/get') {
71
+ return jsonEnvelope(mockSetting)
72
+ }
73
+
74
+ if (method === 'POST' && path === '/view') {
75
+ return jsonEnvelope(null)
76
+ }
77
+
78
+ if (method === 'GET' && path.startsWith('/comment/host/')) {
79
+ return jsonEnvelope(paginated([]))
80
+ }
81
+ if (method === 'POST' && path === '/comment') {
82
+ return jsonEnvelope({
83
+ id: 'mock-comment',
84
+ hostId: '',
85
+ content: '',
86
+ status: 'approved',
87
+ createAt: new Date().toISOString(),
88
+ updateAt: new Date().toISOString(),
89
+ })
90
+ }
91
+
92
+ if (method === 'POST' && path.startsWith('/article/')) {
93
+ const rest = decodeURIComponent(path.slice('/article/'.length))
94
+ const slash = rest.indexOf('/')
95
+ if (slash === -1) {
96
+ return null
97
+ }
98
+ const id = rest.slice(0, slash)
99
+ const action = rest.slice(slash + 1)
100
+ const article = getMockArticleById(id)
101
+
102
+ if (action === 'views') {
103
+ return jsonEnvelope(incrementViews(article))
104
+ }
105
+ if (action === 'likes') {
106
+ return jsonEnvelope({ ...article, likes: (article.likes ?? 0) + 1 })
107
+ }
108
+ if (action === 'checkPassword') {
109
+ return jsonEnvelope({ pass: true, ...article, needPassword: false })
110
+ }
111
+ }
112
+
113
+ if (method === 'POST' && path.startsWith('/knowledge/')) {
114
+ const rest = decodeURIComponent(path.slice('/knowledge/'.length))
115
+ const slash = rest.indexOf('/')
116
+ if (slash === -1) {
117
+ return null
118
+ }
119
+ const id = rest.slice(0, slash)
120
+ const action = rest.slice(slash + 1)
121
+ const book = findMockKnowledgeById(id)
122
+ if (!book) {
123
+ return jsonEnvelope(null)
124
+ }
125
+ if (action === 'views') {
126
+ return jsonEnvelope(incrementViews(book))
127
+ }
128
+ if (action === 'likes') {
129
+ return jsonEnvelope({ ...book, likes: (book.likes ?? 0) + 1 })
130
+ }
131
+ }
132
+
133
+ if (method === 'GET' && path === '/article') {
134
+ return jsonEnvelope(paginated(mockArticles))
135
+ }
136
+ if (method === 'GET' && (path === '/article/recommend' || path === '/article/all/recommend')) {
137
+ return jsonEnvelope(mockRecommendedArticles)
138
+ }
139
+ if (method === 'GET' && path === '/article/archives') {
140
+ return jsonEnvelope(mockArchives)
141
+ }
142
+ if (method === 'GET' && path.startsWith('/article/category/')) {
143
+ const value = decodeURIComponent(path.slice('/article/category/'.length))
144
+ return jsonEnvelope(paginated(filterMockArticlesByCategory(value)))
145
+ }
146
+ if (method === 'GET' && path.startsWith('/article/tag/')) {
147
+ const value = decodeURIComponent(path.slice('/article/tag/'.length))
148
+ return jsonEnvelope(paginated(filterMockArticlesByTag(value)))
149
+ }
150
+ if (method === 'GET' && path.startsWith('/article/')) {
151
+ const id = decodeURIComponent(path.slice('/article/'.length))
152
+ if (!id.includes('/')) {
153
+ return jsonEnvelope(getMockArticleById(id))
154
+ }
155
+ }
156
+
157
+ if (method === 'GET' && path === '/category') {
158
+ return jsonEnvelope(mockCategories)
159
+ }
160
+ if (method === 'GET' && path.startsWith('/category/')) {
161
+ const value = decodeURIComponent(path.slice('/category/'.length))
162
+ return jsonEnvelope(getMockCategoryByValue(value))
163
+ }
164
+
165
+ if (method === 'GET' && path === '/tag') {
166
+ return jsonEnvelope(mockTags)
167
+ }
168
+ if (method === 'GET' && path.startsWith('/tag/')) {
169
+ const value = decodeURIComponent(path.slice('/tag/'.length))
170
+ return jsonEnvelope(getMockTagByValue(value))
171
+ }
172
+
173
+ if (method === 'GET' && path === '/page') {
174
+ return jsonEnvelope(paginated(mockPages))
175
+ }
176
+ if (method === 'GET' && path.startsWith('/page/')) {
177
+ const id = decodeURIComponent(path.slice('/page/'.length))
178
+ if (id.endsWith('/views')) {
179
+ return jsonEnvelope(null)
180
+ }
181
+ return jsonEnvelope(getMockPageById(id))
182
+ }
183
+ if (method === 'POST' && path.startsWith('/page/') && path.endsWith('/views')) {
184
+ const id = decodeURIComponent(path.slice('/page/'.length, -'/views'.length))
185
+ const page = getMockPageById(id)
186
+ return jsonEnvelope(incrementViews(page))
187
+ }
188
+
189
+ if (method === 'GET' && path === '/knowledge') {
190
+ return jsonEnvelope(paginated(mockKnowledgeBooks))
191
+ }
192
+ if (method === 'GET' && path.startsWith('/knowledge/')) {
193
+ const id = decodeURIComponent(path.slice('/knowledge/'.length))
194
+ return jsonEnvelope(getMockKnowledgeById(id))
195
+ }
196
+
197
+ if (method === 'GET' && path === '/search/article') {
198
+ return jsonEnvelope(searchMockArticles(options.keyword ?? ''))
199
+ }
200
+
201
+ return null
202
+ }
@@ -0,0 +1,28 @@
1
+ import { resolveUpstreamApiBase } from './config'
2
+
3
+ export async function proxyToUpstreamApi(request: Request, segments: string[]): Promise<Response> {
4
+ const base = resolveUpstreamApiBase()
5
+ const pathname = segments.map((segment) => decodeURIComponent(segment)).join('/')
6
+ const incoming = new URL(request.url)
7
+ const target = new URL(pathname ? `${base}/${pathname}` : base)
8
+ target.search = incoming.search
9
+
10
+ const headers = new Headers(request.headers)
11
+ headers.delete('host')
12
+ headers.delete('connection')
13
+
14
+ const body =
15
+ request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer()
16
+
17
+ const upstream = await fetch(target, {
18
+ method: request.method,
19
+ headers,
20
+ body,
21
+ redirect: 'manual',
22
+ })
23
+
24
+ return new Response(upstream.body, {
25
+ status: upstream.status,
26
+ headers: upstream.headers,
27
+ })
28
+ }
@@ -0,0 +1,58 @@
1
+ import { createThemeHttpStack } from '@fecommunity/reactpress-toolkit/theme'
2
+ import { normalizeList, normalizePaginated } from '@/lib/reactpress/normalizeApiResponse'
3
+ import { materializeProvider } from '@/lib/providers/materializeProvider'
4
+
5
+ /** API messages already shown in UI (login form, etc.) — skip dev console noise. */
6
+ function isExpectedClientError(message: string, status?: number): boolean {
7
+ if (status === 400 || status === 401 || status === 403) return true
8
+ return /用户名|密码|登录|注册|邮箱|授权|已存在|锁定|未认证|无权|Unauthorized|Request failed/i.test(
9
+ message
10
+ )
11
+ }
12
+
13
+ const stack = createThemeHttpStack({
14
+ onError: (msg: string, status?: number) => {
15
+ if (process.env.NODE_ENV !== 'development') return
16
+ if (isExpectedClientError(msg, status)) return
17
+ console.error('[reactpress-theme-starter]', msg)
18
+ },
19
+ })
20
+
21
+ export const { CommentProvider, UserProvider, SettingProvider } = stack
22
+
23
+ export const ArticleProvider = {
24
+ ...materializeProvider(stack.ArticleProvider),
25
+ getArticles: (params: Parameters<typeof stack.ArticleProvider.getArticles>[0]) =>
26
+ stack.ArticleProvider.getArticles(params).then(normalizePaginated),
27
+ getArticlesByCategory: (
28
+ category: Parameters<typeof stack.ArticleProvider.getArticlesByCategory>[0],
29
+ params: Parameters<typeof stack.ArticleProvider.getArticlesByCategory>[1]
30
+ ) => stack.ArticleProvider.getArticlesByCategory(category, params).then(normalizePaginated),
31
+ getArticlesByTag: (
32
+ tag: Parameters<typeof stack.ArticleProvider.getArticlesByTag>[0],
33
+ params: Parameters<typeof stack.ArticleProvider.getArticlesByTag>[1]
34
+ ) => stack.ArticleProvider.getArticlesByTag(tag, params).then(normalizePaginated),
35
+ getRecommend: (
36
+ articleId?: Parameters<typeof stack.ArticleProvider.getRecommend>[0],
37
+ pageSize?: Parameters<typeof stack.ArticleProvider.getRecommend>[1]
38
+ ) => stack.ArticleProvider.getRecommend(articleId, pageSize).then(normalizeList),
39
+ getAllRecommendArticles: () =>
40
+ stack.ArticleProvider.getAllRecommendArticles().then(normalizeList),
41
+ } as typeof stack.ArticleProvider
42
+
43
+ export const SearchProvider = {
44
+ ...materializeProvider(stack.SearchProvider),
45
+ searchArticles: (keyword: string) =>
46
+ stack.SearchProvider.searchArticles(keyword).then(normalizeList),
47
+ } as typeof stack.SearchProvider
48
+
49
+ export const PageProvider = {
50
+ ...materializeProvider(stack.PageProvider),
51
+ getAllPublisedPages: () => stack.PageProvider.getAllPublisedPages().then(normalizePaginated),
52
+ } as typeof stack.PageProvider
53
+
54
+ export const KnowledgeProvider = {
55
+ ...materializeProvider(stack.KnowledgeProvider),
56
+ getKnowledges: (params: Parameters<typeof stack.KnowledgeProvider.getKnowledges>[0]) =>
57
+ stack.KnowledgeProvider.getKnowledges(params).then(normalizePaginated),
58
+ } as typeof stack.KnowledgeProvider
@@ -0,0 +1,9 @@
1
+ /** Class static methods are not copied by object spread — materialize as a plain object. */
2
+ export function materializeProvider<T extends object>(Provider: T): T {
3
+ const source = Provider as Record<string, unknown>
4
+ return Object.fromEntries(
5
+ Object.getOwnPropertyNames(Provider)
6
+ .filter((name) => typeof source[name] === 'function')
7
+ .map((name) => [name, source[name]])
8
+ ) as T
9
+ }
@@ -0,0 +1,8 @@
1
+ import {
2
+ createThemeAxiosClient,
3
+ createThemeProviders,
4
+ } from '@fecommunity/reactpress-toolkit/theme/server'
5
+
6
+ const http = createThemeAxiosClient({ unwrapEnvelope: true })
7
+
8
+ export const { SettingProvider, CategoryProvider, ArticleProvider } = createThemeProviders(http)
@@ -0,0 +1,69 @@
1
+ import {
2
+ buildBrandingAppearanceCss,
3
+ type ThemeMods,
4
+ } from '@fecommunity/reactpress-toolkit/theme/server'
5
+
6
+ function modValue(mods: ThemeMods, key: string): string | undefined {
7
+ const raw = mods?.[key]
8
+ if (typeof raw !== 'string') return undefined
9
+ const trimmed = raw.trim()
10
+ return trimmed || undefined
11
+ }
12
+
13
+ function normalizeHex(value: string | undefined): string | undefined {
14
+ if (!value) return undefined
15
+ const trimmed = value.trim()
16
+ if (!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed)) return undefined
17
+ if (trimmed.length === 4) {
18
+ const [, r, g, b] = trimmed
19
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
20
+ }
21
+ return trimmed.toLowerCase()
22
+ }
23
+
24
+ function darkenHex(hex: string, amount = 0.22): string {
25
+ const normalized = normalizeHex(hex)
26
+ if (!normalized) return hex
27
+ const value = normalized.slice(1)
28
+ const channels = [0, 2, 4].map((start) => parseInt(value.slice(start, start + 2), 16))
29
+ const factor = 1 - amount
30
+ return `#${channels
31
+ .map((channel) =>
32
+ Math.max(0, Math.min(255, Math.round(channel * factor)))
33
+ .toString(16)
34
+ .padStart(2, '0')
35
+ )
36
+ .join('')}`
37
+ }
38
+
39
+ /** Maps Customizer color mods to Tailwind v4 primary scale overrides. */
40
+ export function buildThemeStarterAppearanceCss(mods: ThemeMods): string {
41
+ const base = buildBrandingAppearanceCss(mods)
42
+ const lightPrimary = normalizeHex(modValue(mods, 'primaryColor'))
43
+ const darkPrimary = normalizeHex(modValue(mods, 'darkPrimaryColor'))
44
+ const lightBg = normalizeHex(modValue(mods, 'backgroundColor'))
45
+ const darkBg = normalizeHex(modValue(mods, 'darkBackgroundColor'))
46
+
47
+ const blocks: string[] = [base]
48
+
49
+ if (lightPrimary) {
50
+ const buttonBg = darkenHex(lightPrimary)
51
+ blocks.push(
52
+ `body:not(.dark) { --primary-color: ${lightPrimary}; --primary-button-bg: ${buttonBg}; --color-primary-500: ${lightPrimary}; --color-primary-600: ${buttonBg}; }`
53
+ )
54
+ }
55
+ if (darkPrimary) {
56
+ const buttonBg = darkenHex(darkPrimary)
57
+ blocks.push(
58
+ `body.dark { --primary-color: ${darkPrimary}; --primary-button-bg: ${buttonBg}; --color-primary-500: ${darkPrimary}; --color-primary-400: ${darkPrimary}; }`
59
+ )
60
+ }
61
+ if (lightBg) {
62
+ blocks.push(`body:not(.dark) { background-color: ${lightBg}; }`)
63
+ }
64
+ if (darkBg) {
65
+ blocks.push(`body.dark { background-color: ${darkBg}; }`)
66
+ }
67
+
68
+ return blocks.filter(Boolean).join('\n')
69
+ }
@@ -0,0 +1,48 @@
1
+ import themeManifest from '../../theme.json'
2
+ import {
3
+ createDefaultAppBootstrap,
4
+ fetchAppBootstrap,
5
+ slimAppBootstrapForRoute,
6
+ type AppBootstrapResult,
7
+ } from '@fecommunity/reactpress-toolkit/theme/server'
8
+ import type { IncomingMessage } from 'http'
9
+ import { cookies, headers } from 'next/headers'
10
+
11
+ type BootstrapCategory = {
12
+ value: string
13
+ label: string
14
+ articleCount?: number
15
+ }
16
+
17
+ async function buildVisitorRequest(): Promise<IncomingMessage> {
18
+ const cookieStore = await cookies()
19
+ const headerStore = await headers()
20
+ const cookieHeader = cookieStore
21
+ .getAll()
22
+ .map((item) => `${item.name}=${encodeURIComponent(item.value)}`)
23
+ .join('; ')
24
+
25
+ return {
26
+ headers: {
27
+ cookie: cookieHeader,
28
+ 'accept-language': headerStore.get('accept-language') ?? '',
29
+ },
30
+ } as IncomingMessage
31
+ }
32
+
33
+ export async function loadAppBootstrap(pathname = '/'): Promise<AppBootstrapResult> {
34
+ try {
35
+ const req = await buildVisitorRequest()
36
+ const bootstrap = await fetchAppBootstrap({ manifest: themeManifest, req })
37
+ const slimmed = slimAppBootstrapForRoute(bootstrap, pathname)
38
+ const categories = (bootstrap.categories as BootstrapCategory[]).map(
39
+ ({ value, label, articleCount }) => ({ value, label, articleCount })
40
+ )
41
+ return { ...slimmed, categories }
42
+ } catch (error) {
43
+ console.error('[reactpress-theme-starter] fetchAppBootstrap failed, using defaults', error)
44
+ return createDefaultAppBootstrap()
45
+ }
46
+ }
47
+
48
+ export { themeManifest }
@@ -0,0 +1,12 @@
1
+ import type { IPage } from '@fecommunity/reactpress-toolkit/types'
2
+
3
+ /** CMS page props from toolkit SSR helpers are typed as `unknown`. */
4
+ export function coercePublishedPage(page: unknown): IPage | null {
5
+ if (!page || typeof page !== 'object') return null
6
+
7
+ const candidate = page as Partial<IPage>
8
+ if (typeof candidate.id !== 'string' || !candidate.id.trim()) return null
9
+ if (candidate.status !== 'publish') return null
10
+
11
+ return candidate as IPage
12
+ }
@@ -0,0 +1,123 @@
1
+ import type { IArticle, IKnowledge, IPage } from '@fecommunity/reactpress-toolkit/types'
2
+ import type { Metadata } from 'next'
3
+
4
+ import { buildPageMetadata, DEFAULT_OG_IMAGE } from './seoMetadata'
5
+
6
+ export type SiteSeoContext = {
7
+ siteName?: string
8
+ siteDescription?: string
9
+ siteUrl?: string
10
+ seoKeyword?: string
11
+ seoDesc?: string
12
+ }
13
+
14
+ export function parseSiteSeoContext(
15
+ row: Record<string, unknown> | null,
16
+ siteMeta?: { siteName?: string; siteDescription?: string; siteUrl?: string }
17
+ ): SiteSeoContext {
18
+ return {
19
+ siteName: siteMeta?.siteName,
20
+ siteDescription: siteMeta?.siteDescription,
21
+ siteUrl: siteMeta?.siteUrl,
22
+ seoKeyword: row?.seoKeyword != null ? String(row.seoKeyword) : undefined,
23
+ seoDesc: row?.seoDesc != null ? String(row.seoDesc) : undefined,
24
+ }
25
+ }
26
+
27
+ function tagLabel(tag: string | { label?: string }) {
28
+ return typeof tag === 'string' ? tag : (tag?.label ?? '')
29
+ }
30
+
31
+ function buildKeywords(
32
+ title: string,
33
+ tags: Array<string | { label?: string }> | undefined,
34
+ siteKeywords?: string
35
+ ) {
36
+ return [title]
37
+ .concat((tags ?? []).map(tagLabel))
38
+ .concat(siteKeywords?.split(',') ?? [])
39
+ .filter(Boolean)
40
+ .join(',')
41
+ }
42
+
43
+ function resolveCmsPagePath(page: IPage): string {
44
+ const customPath = page.path?.trim()
45
+ if (customPath) {
46
+ return customPath.startsWith('/') ? customPath : `/${customPath}`
47
+ }
48
+ return `/page/${page.id}`
49
+ }
50
+
51
+ export function buildArticleMetadata(
52
+ article: IArticle,
53
+ site: SiteSeoContext,
54
+ imageUrl?: string
55
+ ): Metadata {
56
+ const siteName = site.siteName ?? 'Blog'
57
+ const description = article.summary || site.seoDesc || site.siteDescription
58
+ const keywords = buildKeywords(article.title, article.tags, site.seoKeyword)
59
+ const images = imageUrl ? [imageUrl] : [DEFAULT_OG_IMAGE]
60
+
61
+ return buildPageMetadata({
62
+ title: article.title,
63
+ description,
64
+ keywords,
65
+ path: `/article/${article.id}`,
66
+ siteUrl: site.siteUrl,
67
+ siteName,
68
+ ogType: 'article',
69
+ images,
70
+ publishedTime: article.publishAt,
71
+ modifiedTime: article.updateAt,
72
+ tags: (article.tags ?? []).map(tagLabel).filter(Boolean),
73
+ })
74
+ }
75
+
76
+ export function buildKnowledgeChapterMetadata(
77
+ chapter: IKnowledge,
78
+ book: IKnowledge,
79
+ site: SiteSeoContext
80
+ ): Metadata {
81
+ const description = chapter.summary || book.summary || site.seoDesc || site.siteDescription
82
+
83
+ return buildPageMetadata({
84
+ title: `${chapter.title} - ${book.title}`,
85
+ description,
86
+ path: `/knowledge/${book.id}/${chapter.id}`,
87
+ siteUrl: site.siteUrl,
88
+ siteName: site.siteName ?? 'Blog',
89
+ ogType: 'article',
90
+ images: [DEFAULT_OG_IMAGE],
91
+ publishedTime: chapter.publishAt,
92
+ modifiedTime: chapter.updateAt,
93
+ })
94
+ }
95
+
96
+ export function buildCmsPageMetadata(page: IPage, site: SiteSeoContext): Metadata {
97
+ const description = site.seoDesc || site.siteDescription
98
+
99
+ return buildPageMetadata({
100
+ title: page.name,
101
+ description,
102
+ path: resolveCmsPagePath(page),
103
+ siteUrl: site.siteUrl,
104
+ siteName: site.siteName ?? 'Blog',
105
+ images: [DEFAULT_OG_IMAGE],
106
+ })
107
+ }
108
+
109
+ export function buildArticleJsonLd(article: IArticle, siteUrl?: string, coverUrl?: string) {
110
+ const url = siteUrl ? new URL(`/article/${article.id}`, siteUrl).href : undefined
111
+
112
+ return {
113
+ '@context': 'https://schema.org',
114
+ '@type': 'Article',
115
+ headline: article.title,
116
+ description: article.summary,
117
+ datePublished: article.publishAt,
118
+ dateModified: article.updateAt ?? article.publishAt,
119
+ ...(url ? { url, mainEntityOfPage: url } : {}),
120
+ ...(coverUrl ? { image: [coverUrl] } : {}),
121
+ keywords: (article.tags ?? []).map(tagLabel).filter(Boolean).join(', '),
122
+ }
123
+ }
@@ -0,0 +1,31 @@
1
+ /** App/runtime helpers — values are injected via next.config `env` when possible. */
2
+ import { DEFAULT_LOCAL_ADMIN, DEFAULT_LOCAL_SITE } from '../../scripts/resolve-api-env.mjs'
3
+
4
+ export {
5
+ DEFAULT_LOCAL_ADMIN,
6
+ DEFAULT_LOCAL_API,
7
+ DEFAULT_LOCAL_SITE,
8
+ } from '../../scripts/resolve-api-env.mjs'
9
+
10
+ export function getClientSiteUrl(): string {
11
+ return (process.env.CLIENT_SITE_URL || DEFAULT_LOCAL_SITE).replace(/\/$/, '')
12
+ }
13
+
14
+ export function getAdminBaseUrl(): string {
15
+ const configured = process.env.NEXT_PUBLIC_REACTPRESS_ADMIN_URL?.trim()
16
+ if (configured) return configured.replace(/\/$/, '')
17
+ return DEFAULT_LOCAL_ADMIN
18
+ }
19
+
20
+ export function getApiPreconnectOrigin(): string | null {
21
+ const api =
22
+ process.env.REACTPRESS_API_URL?.trim() ||
23
+ process.env.SERVER_API_URL?.trim() ||
24
+ process.env.NEXT_PUBLIC_REACTPRESS_API_URL?.trim()
25
+
26
+ if (!api || api.startsWith('/')) return null
27
+
28
+ const origin = api.replace(/\/api\/?$/, '')
29
+ if (/localhost|127\.0\.0\.1/i.test(origin)) return null
30
+ return origin
31
+ }
@@ -0,0 +1,16 @@
1
+ export const FEED_PAGE_SIZE = 12
2
+
3
+ export type FeedFooterPlacementOptions = {
4
+ hasMore?: boolean
5
+ itemCount?: number
6
+ pageSize?: number
7
+ }
8
+
9
+ /** 内容足够多、需要滚动加载时 footer 放右侧栏;否则放页面底部。 */
10
+ export function shouldPlaceFooterInSidebar({
11
+ hasMore = false,
12
+ itemCount = 0,
13
+ pageSize = FEED_PAGE_SIZE,
14
+ }: FeedFooterPlacementOptions = {}): boolean {
15
+ return hasMore || itemCount >= pageSize
16
+ }
@@ -0,0 +1,19 @@
1
+ /** Same-origin visitor setting fetch — avoids toolkit axios onError console noise. */
2
+ export async function fetchVisitorSetting(): Promise<Record<string, unknown> | null> {
3
+ const apiBase = (process.env.NEXT_PUBLIC_REACTPRESS_API_URL || '/api').replace(/\/$/, '')
4
+ const url = apiBase.startsWith('http') ? `${apiBase}/setting/get` : `${apiBase}/setting/get`
5
+
6
+ try {
7
+ const response = await fetch(url, {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json' },
10
+ body: '{}',
11
+ })
12
+ if (!response.ok) return null
13
+
14
+ const payload = (await response.json()) as { success?: boolean; data?: Record<string, unknown> }
15
+ return payload?.success ? (payload.data ?? null) : null
16
+ } catch {
17
+ return null
18
+ }
19
+ }
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
4
+
5
+ type HomeFooterContextValue = {
6
+ /** 为 true 时在页面底部展示 SiteFooter(列表页内容较少时)。 */
7
+ showLayoutFooter: boolean
8
+ setShowLayoutFooter: (show: boolean) => void
9
+ }
10
+
11
+ const HomeFooterContext = createContext<HomeFooterContextValue | null>(null)
12
+
13
+ export function HomeFooterProvider({ children }: { children: ReactNode }) {
14
+ // 默认展示底部 footer;列表页内容较多时由 useFeedFooterPlacement 在 layout 阶段切换为侧栏。
15
+ const [showLayoutFooter, setShowLayoutFooter] = useState(true)
16
+ const value = useMemo(() => ({ showLayoutFooter, setShowLayoutFooter }), [showLayoutFooter])
17
+
18
+ return <HomeFooterContext.Provider value={value}>{children}</HomeFooterContext.Provider>
19
+ }
20
+
21
+ export function useHomeFooter() {
22
+ const context = useContext(HomeFooterContext)
23
+ if (!context) {
24
+ throw new Error('useHomeFooter must be used within HomeFooterProvider')
25
+ }
26
+ return context
27
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+
3
+ import { useReportPageView } from '@fecommunity/reactpress-toolkit/ui'
4
+ import { type ReactNode } from 'react'
5
+
6
+ import BackToTop from '@/components/layout/BackToTop'
7
+ import PageLoadProgress from '@/components/layout/PageLoadProgress'
8
+
9
+ export function LayoutShell({ children }: { children: ReactNode }) {
10
+ useReportPageView()
11
+
12
+ return (
13
+ <>
14
+ <PageLoadProgress />
15
+ {children}
16
+ <BackToTop />
17
+ </>
18
+ )
19
+ }