@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.
- package/.env.example +22 -0
- package/LICENSE +21 -0
- package/README.md +286 -0
- package/app/api/[[...path]]/route.ts +69 -0
- package/app/archives/page.tsx +28 -0
- package/app/article/[id]/page.tsx +92 -0
- package/app/category/[category]/page.tsx +60 -0
- package/app/globals.css +4969 -0
- package/app/knowledge/[pId]/[id]/page.tsx +54 -0
- package/app/knowledge/[pId]/page.tsx +47 -0
- package/app/knowledge/page.tsx +29 -0
- package/app/layout.tsx +76 -0
- package/app/llms.txt/route.ts +26 -0
- package/app/login/page.tsx +17 -0
- package/app/nav/[id]/page.tsx +44 -0
- package/app/nav/page.tsx +5 -0
- package/app/not-found.tsx +21 -0
- package/app/page/[id]/page.tsx +57 -0
- package/app/page.tsx +61 -0
- package/app/register/page.tsx +17 -0
- package/app/robots.ts +14 -0
- package/app/rss/route.ts +82 -0
- package/app/search/page.tsx +46 -0
- package/app/sitemap.ts +38 -0
- package/app/suggestions/page.tsx +47 -0
- package/app/tag/[tag]/page.tsx +58 -0
- package/app/tags/page.tsx +18 -0
- package/app/template.tsx +6 -0
- package/components/article/ArchiveBanner.tsx +40 -0
- package/components/article/ArticleActionsBar.tsx +53 -0
- package/components/article/ArticleCarousel.tsx +237 -0
- package/components/article/ArticleCopyrightBar.tsx +128 -0
- package/components/article/ArticleEndBar.tsx +19 -0
- package/components/article/ArticleFeedSection.tsx +59 -0
- package/components/article/ArticleList.tsx +170 -0
- package/components/article/ArticleReadingChrome.tsx +104 -0
- package/components/article/ArticleReadingProgress.tsx +75 -0
- package/components/article/ArticleRecommend.tsx +362 -0
- package/components/article/ArticleTocPanel.tsx +19 -0
- package/components/article/ArticleViewClient.tsx +221 -0
- package/components/article/CategoryMenu.tsx +67 -0
- package/components/article/CmsPageClient.tsx +92 -0
- package/components/article/LoadMore.tsx +59 -0
- package/components/article/ReadingContent.tsx +51 -0
- package/components/auth/AuthPageClient.tsx +314 -0
- package/components/comment/CommentEditor.tsx +290 -0
- package/components/comment/CommentEmoji.tsx +110 -0
- package/components/comment/CommentIconButton.tsx +37 -0
- package/components/comment/CommentItem.tsx +135 -0
- package/components/comment/CommentSection.tsx +123 -0
- package/components/comment/emojis.ts +152 -0
- package/components/knowledge/KnowledgeList.tsx +70 -0
- package/components/layout/BackToTop.tsx +27 -0
- package/components/layout/ConditionalSiteFooter.tsx +29 -0
- package/components/layout/ContentStylesLoader.tsx +9 -0
- package/components/layout/DoubleColumnLayout.tsx +171 -0
- package/components/layout/HeaderSearch.tsx +23 -0
- package/components/layout/LocaleSwitcher.tsx +24 -0
- package/components/layout/PageContainer.tsx +11 -0
- package/components/layout/PageLoadProgress.tsx +169 -0
- package/components/layout/SiteFooter.tsx +75 -0
- package/components/layout/SiteHeader.tsx +312 -0
- package/components/layout/SystemNotification.tsx +176 -0
- package/components/layout/ThemeSwitch.tsx +19 -0
- package/components/layout/UserAuth.tsx +130 -0
- package/components/search/SearchHero.tsx +323 -0
- package/components/search/SearchQuickLinks.tsx +285 -0
- package/components/shared/DevChunkRecovery.tsx +8 -0
- package/components/shared/Link.tsx +30 -0
- package/components/shared/SectionHeading.tsx +38 -0
- package/components/shared/ThemeImage.tsx +13 -0
- package/components/shared/logo.svg +7 -0
- package/components/views/ArchivesClient.tsx +137 -0
- package/components/views/CategoryClient.tsx +87 -0
- package/components/views/HomeClient.tsx +79 -0
- package/components/views/KnowledgeBookClient.tsx +127 -0
- package/components/views/KnowledgeChapterClient.tsx +175 -0
- package/components/views/KnowledgeClient.tsx +69 -0
- package/components/views/LoginClient.tsx +80 -0
- package/components/views/NavDetailClient.tsx +177 -0
- package/components/views/SearchClient.tsx +100 -0
- package/components/views/TagArchiveClient.tsx +105 -0
- package/components/views/TagsClient.tsx +46 -0
- package/components/widgets/AboutUs.tsx +180 -0
- package/components/widgets/ArticleActionButton.tsx +95 -0
- package/components/widgets/BookmarkWidget.tsx +61 -0
- package/components/widgets/CategoriesWidget.tsx +47 -0
- package/components/widgets/HomeSidebar.tsx +40 -0
- package/components/widgets/LikesWidget.tsx +77 -0
- package/components/widgets/ShareWidget.tsx +120 -0
- package/components/widgets/TagCloud.tsx +94 -0
- package/components/widgets/TagsWidget.tsx +160 -0
- package/eslint.config.mjs +80 -0
- package/lib/auth/githubOAuth.ts +26 -0
- package/lib/auth/sessionUser.ts +41 -0
- package/lib/mock-api/auth.ts +129 -0
- package/lib/mock-api/config.ts +11 -0
- package/lib/mock-api/cors.ts +53 -0
- package/lib/mock-api/data.ts +998 -0
- package/lib/mock-api/match-route.ts +202 -0
- package/lib/mock-api/proxy.ts +28 -0
- package/lib/providers/client.ts +58 -0
- package/lib/providers/materializeProvider.ts +9 -0
- package/lib/providers/server.ts +8 -0
- package/lib/reactpress/appearance.ts +69 -0
- package/lib/reactpress/bootstrap.ts +48 -0
- package/lib/reactpress/coercePublishedPage.ts +12 -0
- package/lib/reactpress/contentSeo.ts +123 -0
- package/lib/reactpress/env.ts +31 -0
- package/lib/reactpress/feedFooterPlacement.ts +16 -0
- package/lib/reactpress/fetchSetting.ts +19 -0
- package/lib/reactpress/homeFooter.tsx +27 -0
- package/lib/reactpress/layout-shell.tsx +19 -0
- package/lib/reactpress/materializeBootstrapCatalog.ts +31 -0
- package/lib/reactpress/normalizeApiResponse.ts +25 -0
- package/lib/reactpress/providers.tsx +199 -0
- package/lib/reactpress/resolveSiteNav.ts +56 -0
- package/lib/reactpress/seoMetadata.ts +82 -0
- package/lib/reactpress/serverLocale.ts +43 -0
- package/lib/reactpress/siteMetadata.ts +151 -0
- package/lib/reactpress/staticParams.ts +46 -0
- package/lib/reactpress/suggestionsContent.ts +48 -0
- package/lib/reactpress/useFeedFooterPlacement.ts +22 -0
- package/lib/reactpress/useSiteNavConfig.ts +39 -0
- package/lib/utils/archiveBanner.ts +8 -0
- package/lib/utils/colors.ts +119 -0
- package/lib/utils/comment.ts +46 -0
- package/lib/utils/headerPosition.ts +55 -0
- package/lib/utils/icons.tsx +238 -0
- package/lib/utils/reading.ts +16 -0
- package/lib/utils/scroll.ts +8 -0
- package/lib/utils/tagCloud.ts +222 -0
- package/next.config.js +103 -0
- package/package.json +98 -0
- package/postcss.config.js +5 -0
- package/prettier.config.js +10 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/cli.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/home-dark.png +0 -0
- package/public/home-light.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-400.png +0 -0
- package/public/logo.png +0 -0
- package/public/mobile.png +0 -0
- package/public/pad.png +0 -0
- package/public/site.webmanifest +19 -0
- package/scripts/build-mock.mjs +139 -0
- package/scripts/dev.mjs +221 -0
- package/scripts/ensure-env.mjs +100 -0
- package/scripts/remote-dev.mjs +143 -0
- package/scripts/resolve-api-env.mjs +114 -0
- package/scripts/set-publish-version.mjs +31 -0
- package/scripts/smoke-pages.mjs +113 -0
- package/scripts/start-mock.mjs +79 -0
- package/scripts/verify-toolkit.mjs +37 -0
- package/svg.d.ts +6 -0
- package/theme.json +123 -0
- package/theme.manifest.schema.json +265 -0
- package/tsconfig.json +41 -0
- 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
|
+
}
|