@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,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
+ }