@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,31 @@
|
|
|
1
|
+
import type { AppBootstrapResult } from '@fecommunity/reactpress-toolkit/theme/server'
|
|
2
|
+
import type { SiteCatalogContextValue } from '@fecommunity/reactpress-toolkit/ui'
|
|
3
|
+
import type { ICategory, IPage, ISetting, ITag } from '@fecommunity/reactpress-toolkit/types'
|
|
4
|
+
|
|
5
|
+
/** Toolkit bootstrap uses loose records; catalog context expects typed entities. */
|
|
6
|
+
export function materializeBootstrapCatalog(bootstrap: AppBootstrapResult) {
|
|
7
|
+
const globalSetting = normalizeGlobalSetting(bootstrap.globalSetting)
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
setting: bootstrap.setting as unknown as ISetting,
|
|
11
|
+
globalSetting,
|
|
12
|
+
siteConfig: bootstrap.siteConfig as unknown as NonNullable<
|
|
13
|
+
SiteCatalogContextValue['siteConfig']
|
|
14
|
+
>,
|
|
15
|
+
tags: bootstrap.tags as ITag[],
|
|
16
|
+
categories: bootstrap.categories as ICategory[],
|
|
17
|
+
pages: bootstrap.pages as IPage[],
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeGlobalSetting(
|
|
22
|
+
raw: AppBootstrapResult['globalSetting']
|
|
23
|
+
): SiteCatalogContextValue['globalSetting'] {
|
|
24
|
+
if (!raw) return undefined
|
|
25
|
+
|
|
26
|
+
if (typeof raw.globalConfig === 'object' && raw.globalConfig !== null) {
|
|
27
|
+
return raw as { globalConfig: Record<string, unknown> }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { globalConfig: raw }
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Paginated ReactPress endpoints return `[items, total]` after envelope unwrap. */
|
|
2
|
+
export function normalizePaginated<T>(value: unknown): [T[], number] {
|
|
3
|
+
if (Array.isArray(value) && value.length >= 2 && Array.isArray(value[0])) {
|
|
4
|
+
const items = value[0] as T[]
|
|
5
|
+
const total = typeof value[1] === 'number' ? value[1] : items.length
|
|
6
|
+
return [items, total]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return [value as T[], value.length]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return [[], 0]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** List endpoints return a plain array after envelope unwrap. */
|
|
17
|
+
export function normalizeList<T>(value: unknown): T[] {
|
|
18
|
+
if (!Array.isArray(value)) return []
|
|
19
|
+
|
|
20
|
+
if (value.length === 2 && Array.isArray(value[0]) && typeof value[1] === 'number') {
|
|
21
|
+
return value[0] as T[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return value as T[]
|
|
25
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { normalizeSessionUser } from '@/lib/auth/sessionUser'
|
|
4
|
+
import { fetchVisitorSetting } from '@/lib/reactpress/fetchSetting'
|
|
5
|
+
import { materializeBootstrapCatalog } from '@/lib/reactpress/materializeBootstrapCatalog'
|
|
6
|
+
import {
|
|
7
|
+
ReactPressProvider,
|
|
8
|
+
SiteCatalogProvider,
|
|
9
|
+
readPersistedLocale,
|
|
10
|
+
useLocale,
|
|
11
|
+
type SiteCatalogContextValue,
|
|
12
|
+
} from '@fecommunity/reactpress-toolkit/ui'
|
|
13
|
+
import {
|
|
14
|
+
applyColorModeClass,
|
|
15
|
+
clearThemeSession,
|
|
16
|
+
mergeVisitorI18n,
|
|
17
|
+
persistColorMode,
|
|
18
|
+
persistThemeSession,
|
|
19
|
+
persistVisitorLocale,
|
|
20
|
+
resolveClientThemeMode,
|
|
21
|
+
resolveStoredUser,
|
|
22
|
+
safeJsonParse,
|
|
23
|
+
type ThemeColorMode,
|
|
24
|
+
} from '@fecommunity/reactpress-toolkit/theme'
|
|
25
|
+
import type { AppBootstrapResult } from '@fecommunity/reactpress-toolkit/theme/server'
|
|
26
|
+
import type { IUser } from '@fecommunity/reactpress-toolkit/types'
|
|
27
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
|
28
|
+
|
|
29
|
+
import { HomeFooterProvider } from './homeFooter'
|
|
30
|
+
import { LayoutShell } from './layout-shell'
|
|
31
|
+
|
|
32
|
+
type Props = {
|
|
33
|
+
bootstrap: AppBootstrapResult
|
|
34
|
+
children: React.ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function VisitorLocaleBootstrap({ locales, ssrLocale }: { locales: string[]; ssrLocale: string }) {
|
|
38
|
+
const { setLocale } = useLocale()
|
|
39
|
+
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const stored = readPersistedLocale(locales, ssrLocale)
|
|
42
|
+
if (stored && stored !== ssrLocale) {
|
|
43
|
+
setLocale(stored)
|
|
44
|
+
}
|
|
45
|
+
}, [locales, setLocale, ssrLocale])
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ReactPressAppProviders({ bootstrap, children }: Props) {
|
|
51
|
+
const catalogSeed = useMemo(() => materializeBootstrapCatalog(bootstrap), [bootstrap])
|
|
52
|
+
const { i18n: bootstrapI18n, locales, initialLocale, themeMods } = bootstrap
|
|
53
|
+
|
|
54
|
+
const [theme, setTheme] = useState<ThemeColorMode>('light')
|
|
55
|
+
const [locale, setLocale] = useState(initialLocale)
|
|
56
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
57
|
+
const [user, setUserState] = useState<SiteCatalogContextValue['user']>(null)
|
|
58
|
+
const [i18nCatalog, setI18nCatalog] = useState<Record<string, Record<string, string>>>(
|
|
59
|
+
() =>
|
|
60
|
+
mergeVisitorI18n(bootstrapI18n as Record<string, unknown>) as Record<
|
|
61
|
+
string,
|
|
62
|
+
Record<string, string>
|
|
63
|
+
>
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
const storedUser = resolveStoredUser()
|
|
68
|
+
if (storedUser) {
|
|
69
|
+
persistThemeSession(storedUser)
|
|
70
|
+
setUserState(storedUser)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const preferred = resolveClientThemeMode()
|
|
74
|
+
applyColorModeClass(preferred === 'dark')
|
|
75
|
+
setTheme(preferred)
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
applyColorModeClass(theme === 'dark')
|
|
80
|
+
}, [theme])
|
|
81
|
+
|
|
82
|
+
const prefetchRemainingI18nLocales = useCallback(() => {
|
|
83
|
+
if (!Array.isArray(locales) || locales.length <= 1) return
|
|
84
|
+
|
|
85
|
+
fetchVisitorSetting()
|
|
86
|
+
.then((res) => {
|
|
87
|
+
if (!res) return
|
|
88
|
+
const full = mergeVisitorI18n(safeJsonParse(res.i18n, {})) as Record<
|
|
89
|
+
string,
|
|
90
|
+
Record<string, string>
|
|
91
|
+
>
|
|
92
|
+
setI18nCatalog((prev) => ({ ...prev, ...full }))
|
|
93
|
+
})
|
|
94
|
+
.catch(() => {
|
|
95
|
+
// Active locale messages are already available from SSR / builtins.
|
|
96
|
+
})
|
|
97
|
+
}, [locales])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
prefetchRemainingI18nLocales()
|
|
101
|
+
}, [prefetchRemainingI18nLocales])
|
|
102
|
+
|
|
103
|
+
const changeTheme = useCallback((next: ThemeColorMode) => {
|
|
104
|
+
const isDark = next === 'dark'
|
|
105
|
+
applyColorModeClass(isDark)
|
|
106
|
+
persistColorMode(isDark)
|
|
107
|
+
setTheme(next)
|
|
108
|
+
}, [])
|
|
109
|
+
|
|
110
|
+
const changeLocale = useCallback(
|
|
111
|
+
(key: string) => {
|
|
112
|
+
if (!key || key === locale) return
|
|
113
|
+
persistVisitorLocale(key)
|
|
114
|
+
setLocale(key)
|
|
115
|
+
|
|
116
|
+
if (!i18nCatalog[key]) {
|
|
117
|
+
prefetchRemainingI18nLocales()
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[i18nCatalog, locale, prefetchRemainingI18nLocales]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const setUser = useCallback((next: IUser) => {
|
|
124
|
+
const session = normalizeSessionUser(next)
|
|
125
|
+
if (session) {
|
|
126
|
+
persistThemeSession(session)
|
|
127
|
+
}
|
|
128
|
+
setUserState(next)
|
|
129
|
+
}, [])
|
|
130
|
+
|
|
131
|
+
const removeUser = useCallback(() => {
|
|
132
|
+
clearThemeSession()
|
|
133
|
+
setUserState(null)
|
|
134
|
+
window.location.reload()
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
const toggleCollapse = useCallback(() => setCollapsed((value) => !value), [])
|
|
138
|
+
|
|
139
|
+
const catalogValue = useMemo<SiteCatalogContextValue>(
|
|
140
|
+
() => ({
|
|
141
|
+
setting: catalogSeed.setting,
|
|
142
|
+
i18n: i18nCatalog,
|
|
143
|
+
locale,
|
|
144
|
+
locales,
|
|
145
|
+
globalSetting: catalogSeed.globalSetting,
|
|
146
|
+
siteConfig: catalogSeed.siteConfig,
|
|
147
|
+
tags: catalogSeed.tags,
|
|
148
|
+
categories: catalogSeed.categories,
|
|
149
|
+
pages: catalogSeed.pages,
|
|
150
|
+
theme,
|
|
151
|
+
collapsed,
|
|
152
|
+
changeLocale,
|
|
153
|
+
user,
|
|
154
|
+
setUser,
|
|
155
|
+
removeUser,
|
|
156
|
+
changeTheme,
|
|
157
|
+
getSetting: () => catalogSeed.setting,
|
|
158
|
+
toggleCollapse,
|
|
159
|
+
}),
|
|
160
|
+
[
|
|
161
|
+
catalogSeed,
|
|
162
|
+
i18nCatalog,
|
|
163
|
+
locale,
|
|
164
|
+
locales,
|
|
165
|
+
theme,
|
|
166
|
+
collapsed,
|
|
167
|
+
changeLocale,
|
|
168
|
+
user,
|
|
169
|
+
setUser,
|
|
170
|
+
removeUser,
|
|
171
|
+
changeTheme,
|
|
172
|
+
toggleCollapse,
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const messages = (i18nCatalog[locale] ?? {}) as Record<string, string>
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<ReactPressProvider
|
|
180
|
+
locale={locale}
|
|
181
|
+
locales={locales}
|
|
182
|
+
messages={messages}
|
|
183
|
+
catalog={i18nCatalog}
|
|
184
|
+
themeId="reactpress-theme-starter"
|
|
185
|
+
activeThemeId="reactpress-theme-starter"
|
|
186
|
+
mods={themeMods}
|
|
187
|
+
onLocaleChange={changeLocale}
|
|
188
|
+
>
|
|
189
|
+
<SiteCatalogProvider value={catalogValue}>
|
|
190
|
+
<HomeFooterProvider>
|
|
191
|
+
<LayoutShell>
|
|
192
|
+
<VisitorLocaleBootstrap locales={locales} ssrLocale={initialLocale} />
|
|
193
|
+
{children}
|
|
194
|
+
</LayoutShell>
|
|
195
|
+
</HomeFooterProvider>
|
|
196
|
+
</SiteCatalogProvider>
|
|
197
|
+
</ReactPressProvider>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import themeManifest from '../../theme.json'
|
|
2
|
+
import { safeJsonParse } from '@fecommunity/reactpress-toolkit/theme'
|
|
3
|
+
|
|
4
|
+
export type SiteNavBlocks = {
|
|
5
|
+
urlConfig: unknown[]
|
|
6
|
+
searchCategories: {
|
|
7
|
+
categories?: Array<{ key: string; label: string }>
|
|
8
|
+
subCategories?: Record<string, Array<{ key: string; label: string; url?: string }>>
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type GlobalSettingRaw = {
|
|
13
|
+
theme?: { activeTheme?: string }
|
|
14
|
+
config?: Record<
|
|
15
|
+
string,
|
|
16
|
+
{
|
|
17
|
+
nav?: {
|
|
18
|
+
urlConfig?: unknown[]
|
|
19
|
+
searchCategories?: SiteNavBlocks['searchCategories']
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
>
|
|
23
|
+
en?: { globalConfig?: { urlConfig?: unknown[]; navConfig?: SiteNavBlocks['searchCategories'] } }
|
|
24
|
+
zh?: { globalConfig?: { urlConfig?: unknown[]; navConfig?: SiteNavBlocks['searchCategories'] } }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EMPTY_NAV: SiteNavBlocks = {
|
|
28
|
+
urlConfig: [],
|
|
29
|
+
searchCategories: { categories: [], subCategories: {} },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve nav blocks for the active theme and visitor locale (client-safe). */
|
|
33
|
+
export function resolveSiteNavConfig(
|
|
34
|
+
globalSettingRaw: unknown,
|
|
35
|
+
locale: string,
|
|
36
|
+
themeId = themeManifest.id
|
|
37
|
+
): SiteNavBlocks {
|
|
38
|
+
const gs = (globalSettingRaw ?? {}) as GlobalSettingRaw
|
|
39
|
+
const activeTheme = gs.theme?.activeTheme ?? themeId
|
|
40
|
+
const themeNav = gs.config?.[activeTheme]?.nav
|
|
41
|
+
const legacyBundle = locale === 'en' ? (gs.en ?? gs.zh) : (gs.zh ?? gs.en)
|
|
42
|
+
const legacy = legacyBundle?.globalConfig
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
urlConfig: themeNav?.urlConfig ?? legacy?.urlConfig ?? [],
|
|
46
|
+
searchCategories: themeNav?.searchCategories ?? legacy?.navConfig ?? EMPTY_NAV.searchCategories,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveSiteNavFromSetting(
|
|
51
|
+
setting: { globalSetting?: unknown } | null | undefined,
|
|
52
|
+
locale: string
|
|
53
|
+
): SiteNavBlocks {
|
|
54
|
+
const raw = safeJsonParse(setting?.globalSetting, {})
|
|
55
|
+
return resolveSiteNavConfig(raw, locale)
|
|
56
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DESCRIPTION = 'Blog powered by ReactPress'
|
|
4
|
+
const DEFAULT_OG_IMAGE = '/logo-400.png'
|
|
5
|
+
|
|
6
|
+
export function resolveFullPageTitle(pageTitle: string, siteName: string): string {
|
|
7
|
+
return `${pageTitle} - ${siteName}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildCanonicalUrl(siteUrl: string, path: string): string {
|
|
11
|
+
const base = siteUrl.replace(/\/$/, '')
|
|
12
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
13
|
+
return normalizedPath === '/' ? base : `${base}${normalizedPath}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PageMetadataInput = {
|
|
17
|
+
/** Page-specific title segment; root layout template appends site name. */
|
|
18
|
+
title: string
|
|
19
|
+
/** Full document title without template (home: site - slogan). */
|
|
20
|
+
titleAbsolute?: boolean
|
|
21
|
+
description?: string
|
|
22
|
+
keywords?: string | string[]
|
|
23
|
+
path?: string
|
|
24
|
+
siteUrl?: string
|
|
25
|
+
siteName?: string
|
|
26
|
+
ogType?: 'website' | 'article'
|
|
27
|
+
images?: string[]
|
|
28
|
+
publishedTime?: string
|
|
29
|
+
modifiedTime?: string
|
|
30
|
+
tags?: string[]
|
|
31
|
+
locale?: string
|
|
32
|
+
robots?: Metadata['robots']
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildPageMetadata(input: PageMetadataInput): Metadata {
|
|
36
|
+
const siteName = input.siteName ?? 'Blog'
|
|
37
|
+
const description = input.description?.trim() || DEFAULT_DESCRIPTION
|
|
38
|
+
const keywords = input.keywords
|
|
39
|
+
? Array.isArray(input.keywords)
|
|
40
|
+
? input.keywords
|
|
41
|
+
: input.keywords
|
|
42
|
+
: undefined
|
|
43
|
+
const canonical =
|
|
44
|
+
input.siteUrl && input.path ? buildCanonicalUrl(input.siteUrl, input.path) : undefined
|
|
45
|
+
const fullTitle = input.titleAbsolute ? input.title : resolveFullPageTitle(input.title, siteName)
|
|
46
|
+
const imageUrls = input.images?.filter(Boolean) ?? []
|
|
47
|
+
const ogImages = imageUrls.map((url) => ({ url }))
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
title: input.titleAbsolute ? { absolute: input.title } : input.title,
|
|
51
|
+
description,
|
|
52
|
+
...(keywords ? { keywords } : {}),
|
|
53
|
+
...(canonical ? { alternates: { canonical } } : {}),
|
|
54
|
+
openGraph: {
|
|
55
|
+
title: fullTitle,
|
|
56
|
+
description,
|
|
57
|
+
type: input.ogType ?? 'website',
|
|
58
|
+
siteName,
|
|
59
|
+
...(canonical ? { url: canonical } : {}),
|
|
60
|
+
...(ogImages.length ? { images: ogImages } : {}),
|
|
61
|
+
...(input.publishedTime ? { publishedTime: input.publishedTime } : {}),
|
|
62
|
+
...(input.modifiedTime ? { modifiedTime: input.modifiedTime } : {}),
|
|
63
|
+
...(input.tags?.length ? { tags: input.tags } : {}),
|
|
64
|
+
...(input.locale ? { locale: input.locale } : {}),
|
|
65
|
+
},
|
|
66
|
+
twitter: {
|
|
67
|
+
card: ogImages.length ? 'summary_large_image' : 'summary',
|
|
68
|
+
title: fullTitle,
|
|
69
|
+
description,
|
|
70
|
+
...(imageUrls.length ? { images: imageUrls } : {}),
|
|
71
|
+
},
|
|
72
|
+
...(input.robots ? { robots: input.robots } : {}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function defaultOgImageMetadata(
|
|
77
|
+
siteName?: string
|
|
78
|
+
): NonNullable<Metadata['openGraph']>['images'] {
|
|
79
|
+
return [{ url: DEFAULT_OG_IMAGE, width: 400, height: 400, alt: siteName ?? 'Blog' }]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createTranslator,
|
|
3
|
+
DEFAULT_VISITOR_LOCALES,
|
|
4
|
+
LEGACY_LOCALE_STORAGE_KEY,
|
|
5
|
+
mergeVisitorI18n,
|
|
6
|
+
resolveRequestLocale,
|
|
7
|
+
VISITOR_LOCALE_COOKIE,
|
|
8
|
+
} from '@fecommunity/reactpress-toolkit/theme/server'
|
|
9
|
+
import { cookies, headers } from 'next/headers'
|
|
10
|
+
|
|
11
|
+
export async function getServerTranslator() {
|
|
12
|
+
const catalog = mergeVisitorI18n({}) as Record<string, Record<string, string>>
|
|
13
|
+
const locales = Object.keys(catalog).length ? Object.keys(catalog) : [...DEFAULT_VISITOR_LOCALES]
|
|
14
|
+
const cookieStore = await cookies()
|
|
15
|
+
const preferred =
|
|
16
|
+
cookieStore.get(VISITOR_LOCALE_COOKIE)?.value ??
|
|
17
|
+
cookieStore.get(LEGACY_LOCALE_STORAGE_KEY)?.value
|
|
18
|
+
const acceptLanguage = (await headers()).get('accept-language') ?? undefined
|
|
19
|
+
const locale = resolveRequestLocale(locales, {
|
|
20
|
+
preferred,
|
|
21
|
+
acceptLanguage,
|
|
22
|
+
fallback: locales.includes('zh') ? 'zh' : (locales[0] ?? 'en'),
|
|
23
|
+
})
|
|
24
|
+
const messages = catalog[locale] ?? {}
|
|
25
|
+
const t = createTranslator(messages)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
locale,
|
|
29
|
+
t,
|
|
30
|
+
format(template: string, vars: Record<string, string | number>) {
|
|
31
|
+
return Object.entries(vars).reduce(
|
|
32
|
+
(text, [key, value]) => text.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)),
|
|
33
|
+
template
|
|
34
|
+
)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function tServer(key: string, vars?: Record<string, string | number>) {
|
|
40
|
+
const { t, format } = await getServerTranslator()
|
|
41
|
+
const text = t(key)
|
|
42
|
+
return vars ? format(text, vars) : text
|
|
43
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchSiteMeta,
|
|
3
|
+
getSiteTitle,
|
|
4
|
+
themeApi,
|
|
5
|
+
unwrapSetting,
|
|
6
|
+
withApiRetry,
|
|
7
|
+
} from '@fecommunity/reactpress-toolkit/theme/server'
|
|
8
|
+
import type { Metadata } from 'next'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
buildCanonicalUrl,
|
|
12
|
+
buildPageMetadata,
|
|
13
|
+
defaultOgImageMetadata,
|
|
14
|
+
DEFAULT_DESCRIPTION,
|
|
15
|
+
DEFAULT_OG_IMAGE,
|
|
16
|
+
} from './seoMetadata'
|
|
17
|
+
import { tServer } from './serverLocale'
|
|
18
|
+
|
|
19
|
+
async function fetchSiteSeoBase() {
|
|
20
|
+
const [siteMeta, settingRow] = await Promise.all([
|
|
21
|
+
withApiRetry(() => fetchSiteMeta(themeApi)),
|
|
22
|
+
withApiRetry(() => themeApi.setting.findAll()).then(unwrapSetting),
|
|
23
|
+
])
|
|
24
|
+
return { siteMeta, settingRow }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveSiteDescription(
|
|
28
|
+
settingRow: Record<string, unknown> | null,
|
|
29
|
+
siteDescription?: string
|
|
30
|
+
): string {
|
|
31
|
+
return (
|
|
32
|
+
(settingRow?.seoDesc != null ? String(settingRow.seoDesc).trim() : '') ||
|
|
33
|
+
siteDescription ||
|
|
34
|
+
DEFAULT_DESCRIPTION
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function buildRootMetadata(): Promise<Metadata> {
|
|
39
|
+
try {
|
|
40
|
+
const { siteMeta, settingRow } = await fetchSiteSeoBase()
|
|
41
|
+
const siteName = siteMeta.siteName ?? 'Blog'
|
|
42
|
+
const title = getSiteTitle({
|
|
43
|
+
systemTitle: siteMeta.siteName,
|
|
44
|
+
systemSubTitle: siteMeta.siteDescription,
|
|
45
|
+
})
|
|
46
|
+
const description = resolveSiteDescription(settingRow, siteMeta.siteDescription)
|
|
47
|
+
const keywords = settingRow?.seoKeyword != null ? String(settingRow.seoKeyword) : undefined
|
|
48
|
+
const canonical = siteMeta.siteUrl ? buildCanonicalUrl(siteMeta.siteUrl, '/') : undefined
|
|
49
|
+
const ogImages = defaultOgImageMetadata(siteName)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
title: {
|
|
53
|
+
default: title,
|
|
54
|
+
template: `%s - ${siteName}`,
|
|
55
|
+
},
|
|
56
|
+
description,
|
|
57
|
+
keywords,
|
|
58
|
+
metadataBase: siteMeta.siteUrl ? new URL(siteMeta.siteUrl) : undefined,
|
|
59
|
+
...(canonical ? { alternates: { canonical } } : {}),
|
|
60
|
+
openGraph: {
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
type: 'website',
|
|
64
|
+
siteName,
|
|
65
|
+
...(canonical ? { url: canonical } : {}),
|
|
66
|
+
images: ogImages,
|
|
67
|
+
},
|
|
68
|
+
twitter: {
|
|
69
|
+
card: 'summary_large_image',
|
|
70
|
+
title,
|
|
71
|
+
description,
|
|
72
|
+
images: [DEFAULT_OG_IMAGE],
|
|
73
|
+
},
|
|
74
|
+
robots: { index: true, follow: true },
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[reactpress-theme-starter] root metadata fetch failed', error)
|
|
78
|
+
return {
|
|
79
|
+
title: { default: 'Blog', template: '%s - Blog' },
|
|
80
|
+
description: DEFAULT_DESCRIPTION,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function buildHomePageMetadata(): Promise<Metadata> {
|
|
86
|
+
try {
|
|
87
|
+
const { siteMeta, settingRow } = await fetchSiteSeoBase()
|
|
88
|
+
const siteName = siteMeta.siteName ?? 'Blog'
|
|
89
|
+
const title = getSiteTitle({
|
|
90
|
+
systemTitle: siteMeta.siteName,
|
|
91
|
+
systemSubTitle: siteMeta.siteDescription,
|
|
92
|
+
})
|
|
93
|
+
const description = resolveSiteDescription(settingRow, siteMeta.siteDescription)
|
|
94
|
+
const keywords = settingRow?.seoKeyword != null ? String(settingRow.seoKeyword) : undefined
|
|
95
|
+
|
|
96
|
+
return buildPageMetadata({
|
|
97
|
+
title,
|
|
98
|
+
titleAbsolute: true,
|
|
99
|
+
description,
|
|
100
|
+
keywords,
|
|
101
|
+
path: '/',
|
|
102
|
+
siteUrl: siteMeta.siteUrl,
|
|
103
|
+
siteName,
|
|
104
|
+
images: [DEFAULT_OG_IMAGE],
|
|
105
|
+
})
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('[reactpress-theme-starter] home metadata fetch failed', error)
|
|
108
|
+
return { title: { absolute: 'Blog' } }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function buildLocalizedListPageMetadata(
|
|
113
|
+
titleKey: string,
|
|
114
|
+
vars?: Record<string, string | number>,
|
|
115
|
+
options?: { description?: string; path?: string }
|
|
116
|
+
): Promise<Metadata> {
|
|
117
|
+
const pageTitle = await tServer(titleKey, vars)
|
|
118
|
+
return buildListPageMetadata(pageTitle, options?.description, options?.path)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function buildListPageMetadata(
|
|
122
|
+
pageTitle: string,
|
|
123
|
+
description?: string,
|
|
124
|
+
path?: string
|
|
125
|
+
): Promise<Metadata> {
|
|
126
|
+
try {
|
|
127
|
+
const { siteMeta, settingRow } = await fetchSiteSeoBase()
|
|
128
|
+
const resolvedDescription = resolveSiteDescription(settingRow, siteMeta.siteDescription)
|
|
129
|
+
|
|
130
|
+
return buildPageMetadata({
|
|
131
|
+
title: pageTitle,
|
|
132
|
+
description: description?.trim() || resolvedDescription,
|
|
133
|
+
path,
|
|
134
|
+
siteUrl: siteMeta.siteUrl,
|
|
135
|
+
siteName: siteMeta.siteName ?? 'Blog',
|
|
136
|
+
images: [DEFAULT_OG_IMAGE],
|
|
137
|
+
})
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('[reactpress-theme-starter] list page metadata fetch failed', error)
|
|
140
|
+
return { title: pageTitle }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function buildNoIndexMetadata(pageTitle: string, path?: string): Promise<Metadata> {
|
|
145
|
+
const base = await buildListPageMetadata(pageTitle, undefined, path)
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
description: base.description ?? DEFAULT_DESCRIPTION,
|
|
149
|
+
robots: { index: false, follow: false },
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
themeApi,
|
|
3
|
+
unpackList,
|
|
4
|
+
unpackPaginatedPair,
|
|
5
|
+
withApiRetry,
|
|
6
|
+
} from '@fecommunity/reactpress-toolkit/theme/server'
|
|
7
|
+
import type { IArticle, ICategory, ITag } from '@fecommunity/reactpress-toolkit/types'
|
|
8
|
+
|
|
9
|
+
const STATIC_PARAM_LIMIT = 200
|
|
10
|
+
|
|
11
|
+
export async function generateArticleStaticParams() {
|
|
12
|
+
try {
|
|
13
|
+
const response = await withApiRetry(() =>
|
|
14
|
+
themeApi.article.findAll({
|
|
15
|
+
query: { page: 1, pageSize: STATIC_PARAM_LIMIT, status: 'publish' },
|
|
16
|
+
} as never)
|
|
17
|
+
)
|
|
18
|
+
const [articles] = unpackPaginatedPair<IArticle>(response)
|
|
19
|
+
return articles.map((article) => ({ id: article.id }))
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error('[reactpress-theme-starter] article static params failed', error)
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function generateTagStaticParams() {
|
|
27
|
+
try {
|
|
28
|
+
const response = await withApiRetry(() => themeApi.tag.findAll())
|
|
29
|
+
const tags = unpackList<ITag>(response).slice(0, STATIC_PARAM_LIMIT)
|
|
30
|
+
return tags.map((tag) => ({ tag: tag.value }))
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('[reactpress-theme-starter] tag static params failed', error)
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function generateCategoryStaticParams() {
|
|
38
|
+
try {
|
|
39
|
+
const response = await withApiRetry(() => themeApi.category.findAll())
|
|
40
|
+
const categories = unpackList<ICategory>(response).slice(0, STATIC_PARAM_LIMIT)
|
|
41
|
+
return categories.map((category) => ({ category: category.value }))
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('[reactpress-theme-starter] category static params failed', error)
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const REACTPRESS_GITHUB = 'https://github.com/fecommunity/reactpress'
|
|
2
|
+
const REACTPRESS_SITE = 'https://reactpress.surge.sh/'
|
|
3
|
+
|
|
4
|
+
const suggestionsHtmlEn = `
|
|
5
|
+
<p>Thank you for using ReactPress. We welcome your ideas, bug reports, and documentation feedback.</p>
|
|
6
|
+
<h2>Share your thoughts</h2>
|
|
7
|
+
<ul>
|
|
8
|
+
<li>Report bugs or UX issues on the <a href="${REACTPRESS_GITHUB}/issues" rel="noopener noreferrer">GitHub issue tracker</a>.</li>
|
|
9
|
+
<li>Suggest features for the CLI, admin console, or theme toolkit.</li>
|
|
10
|
+
<li>Share deployment guides for platforms beyond Vercel.</li>
|
|
11
|
+
<li>Tell us which documentation topics need clearer examples.</li>
|
|
12
|
+
</ul>
|
|
13
|
+
<h2>Contributing</h2>
|
|
14
|
+
<p>ReactPress is open source under the MIT license. Fork the repository, run <code>pnpm install</code> and <code>pnpm run dev</code>, and open a pull request. Meaningful contributions are credited in the README.</p>
|
|
15
|
+
<h2>Related reading</h2>
|
|
16
|
+
<ul>
|
|
17
|
+
<li><a href="/article/what-is-reactpress/">What is ReactPress?</a></li>
|
|
18
|
+
<li><a href="/article/quick-start/">Quick Start with ReactPress</a></li>
|
|
19
|
+
<li><a href="${REACTPRESS_SITE}" rel="noopener noreferrer">Official documentation</a></li>
|
|
20
|
+
<li><a href="${REACTPRESS_GITHUB}" rel="noopener noreferrer">GitHub repository</a></li>
|
|
21
|
+
</ul>
|
|
22
|
+
`.trim()
|
|
23
|
+
|
|
24
|
+
const suggestionsHtmlZh = `
|
|
25
|
+
<p>感谢使用 ReactPress。欢迎反馈想法、Bug 与文档改进建议。</p>
|
|
26
|
+
<h2>分享你的想法</h2>
|
|
27
|
+
<ul>
|
|
28
|
+
<li>在 <a href="${REACTPRESS_GITHUB}/issues" rel="noopener noreferrer">GitHub Issue</a> 反馈 Bug 或体验问题。</li>
|
|
29
|
+
<li>为 CLI、管理端或主题工具包提出功能建议。</li>
|
|
30
|
+
<li>分享 Vercel 之外平台的部署实践经验。</li>
|
|
31
|
+
<li>告诉我们哪些文档主题需要更清晰的示例。</li>
|
|
32
|
+
</ul>
|
|
33
|
+
<h2>参与贡献</h2>
|
|
34
|
+
<p>ReactPress 基于 MIT 协议开源。Fork 仓库后执行 <code>pnpm install</code> 与 <code>pnpm run dev</code>,提交 Pull Request 即可参与贡献。</p>
|
|
35
|
+
<h2>延伸阅读</h2>
|
|
36
|
+
<ul>
|
|
37
|
+
<li><a href="/article/what-is-reactpress/">什么是 ReactPress?</a></li>
|
|
38
|
+
<li><a href="/article/quick-start/">ReactPress 快速上手</a></li>
|
|
39
|
+
<li><a href="${REACTPRESS_SITE}" rel="noopener noreferrer">官方文档</a></li>
|
|
40
|
+
<li><a href="${REACTPRESS_GITHUB}" rel="noopener noreferrer">GitHub 仓库</a></li>
|
|
41
|
+
</ul>
|
|
42
|
+
`.trim()
|
|
43
|
+
|
|
44
|
+
/** Theme starter fallback — switches with visitor locale on the suggestions page. */
|
|
45
|
+
export function getSuggestionsPageHtml(locale: string, cmsHtml?: string) {
|
|
46
|
+
if (locale === 'zh') return suggestionsHtmlZh
|
|
47
|
+
return cmsHtml?.trim() || suggestionsHtmlEn
|
|
48
|
+
}
|