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

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 CHANGED
@@ -1,12 +1,20 @@
1
1
  # ReactPress API (SSR — Next.js server requests)
2
2
  REACTPRESS_API_URL=http://localhost:3002/api
3
3
 
4
- # Browser CSR requests (same as SSR, or use same-origin /api via rewrites)
5
- NEXT_PUBLIC_REACTPRESS_API_URL=http://localhost:3002/api
4
+ # Browser CSR requests — use same-origin /api proxy (recommended for remote API)
5
+ NEXT_PUBLIC_REACTPRESS_API_URL=/api
6
6
 
7
7
  # Public site URL (SEO / sitemap / Open Graph)
8
8
  CLIENT_SITE_URL=http://localhost:3001
9
9
 
10
+ # Embed ReactPress Admin at /admin/ (local dev: auto-starts static server on :3000)
11
+ # REACTPRESS_EMBED_ADMIN=1
12
+ # REACTPRESS_ADMIN_STATIC_ORIGIN=http://127.0.0.1:3000
13
+ # NEXT_PUBLIC_REACTPRESS_ADMIN_URL=http://localhost:3001/admin
14
+ #
15
+ # Vercel / serverless: bundle admin static assets into public/admin at build time
16
+ # REACTPRESS_BUNDLE_ADMIN=1
17
+
10
18
  # Subpath deployment (optional)
11
19
  # BASE_PATH=/blog
12
20
 
@@ -15,8 +23,15 @@ CLIENT_SITE_URL=http://localhost:3001
15
23
 
16
24
  # ── Remote API during dev (equivalent to --remote-origin) ───────────────────
17
25
  # REACTPRESS_DEV_REMOTE_ORIGIN=https://api.yoursite.com
26
+ # REACTPRESS_DEV_CLIENT_API_ORIGIN=https://api.yoursite.com
18
27
 
19
28
  # ── Production (Vercel / self-hosted) ───────────────────────────────────────
29
+ # Same nginx gateway (site + API same origin):
20
30
  # REACTPRESS_API_URL=https://api.yoursite.com/api
21
31
  # NEXT_PUBLIC_REACTPRESS_API_URL=/api
22
32
  # CLIENT_SITE_URL=https://www.yoursite.com
33
+ #
34
+ # Cross-subdomain (e.g. demo.yoursite.com + api.yoursite.com):
35
+ # REACTPRESS_API_URL=https://api.yoursite.com/api
36
+ # NEXT_PUBLIC_REACTPRESS_API_URL=https://api.yoursite.com/api
37
+ # CLIENT_SITE_URL=https://demo.yoursite.com
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
+ # ReactPress Theme Starter
2
+
1
3
  <div align="center">
2
4
 
3
- <a href="https://reactpress.surge.sh/">
5
+ <a href="https://reactpress.cc/">
4
6
  <img src="./public/logo.png" alt="ReactPress" width="480" />
5
7
  </a>
6
8
 
7
9
  <br />
8
10
 
9
- **ReactPress Theme Starter**
10
-
11
11
  A lightweight, simple, and easy-to-use Next.js theme, powered by [ReactPress](https://github.com/fecommunity/reactpress).
12
12
 
13
13
  <a href="https://reactpress-theme-starter.vercel.app">
@@ -16,7 +16,7 @@ A lightweight, simple, and easy-to-use Next.js theme, powered by [ReactPress](ht
16
16
 
17
17
  <br />
18
18
 
19
- [Live Demo](https://reactpress-theme-starter.vercel.app) · [Documentation](https://reactpress.surge.sh/) · [简体中文](./README_zh.md)
19
+ [Live Demo](https://reactpress-theme-starter.vercel.app) · [Documentation](https://reactpress.cc/) · [简体中文](./README_zh.md)
20
20
 
21
21
  <br />
22
22
 
@@ -90,6 +90,41 @@ ReactPress API ──REST──▶ Theme Starter (Next.js) ──▶ Public
90
90
  | Try without backend | ✅ `pnpm dev:mock` | ❌ Usually needs content/API |
91
91
  | Knowledge base & comments | ✅ Built-in | ❌ Roll your own |
92
92
  | Customizable appearance | ✅ Admin console + `theme.json` | ❌ Hard-coded or manual |
93
+ | Lighthouse performance | ✅ **95** out of the box (see below) | ❌ Varies widely |
94
+
95
+ ### Performance
96
+
97
+ Built on Next.js 15 App Router with server rendering, static generation, and minimal client JavaScript — the theme delivers strong Core Web Vitals without extra tuning.
98
+
99
+ <div align="center">
100
+
101
+ <img src="./public/lighthouse.png" alt="Lighthouse audit: Performance 95, Accessibility 100, Best Practices 100, SEO 100" width="720" />
102
+
103
+ </div>
104
+
105
+ | Category | Score |
106
+ | :------------- | :------ |
107
+ | Performance | **95** |
108
+ | Accessibility | **100** |
109
+ | Best Practices | **100** |
110
+ | SEO | **100** |
111
+
112
+ **Core Web Vitals** (homepage audit on the [live demo](https://reactpress-theme-starter.vercel.app)):
113
+
114
+ | Metric | Result |
115
+ | :----------------------------- | :--------- |
116
+ | First Contentful Paint (FCP) | **0.4 s** |
117
+ | Largest Contentful Paint (LCP) | **1.0 s** |
118
+ | Total Blocking Time (TBT) | **150 ms** |
119
+ | Cumulative Layout Shift (CLS) | **0** |
120
+ | Speed Index | **1.1 s** |
121
+
122
+ **Why it feels fast:**
123
+
124
+ - **SSR + SSG** — pages render on the server; static routes are pre-built at deploy time
125
+ - **Zero layout shift** — stable typography and layout from first paint
126
+ - **Light client bundle** — content-first UI without heavy runtime overhead
127
+ - **SEO-ready** — metadata, Open Graph, RSS, and sitemap included
93
128
 
94
129
  ---
95
130
 
@@ -99,7 +134,7 @@ ReactPress API ──REST──▶ Theme Starter (Next.js) ──▶ Public
99
134
 
100
135
  ### Recommended — ReactPress CLI
101
136
 
102
- The fastest way to run a full stack (visitor site, admin console, and API) is the [ReactPress CLI](https://reactpress.surge.sh/). No manual `.env` wiring for a standard setup.
137
+ The fastest way to run a full stack (visitor site, admin console, and API) is the [ReactPress CLI](https://reactpress.cc/). No manual `.env` wiring for a standard setup.
103
138
 
104
139
  ```bash
105
140
  npm i -g @fecommunity/reactpress@3
@@ -219,14 +254,14 @@ Route templates are declared in [`theme.json`](./theme.json). Appearance (colors
219
254
  <details>
220
255
  <summary><strong>Do I need ReactPress to preview the theme?</strong></summary>
221
256
 
222
- No. Run `pnpm dev:mock` for a full UI preview with sample data. Install [ReactPress CLI](https://reactpress.surge.sh/) when you want a real CMS, admin panel, and API.
257
+ No. Run `pnpm dev:mock` for a full UI preview with sample data. Install [ReactPress CLI](https://reactpress.cc/) when you want a real CMS, admin panel, and API.
223
258
 
224
259
  </details>
225
260
 
226
261
  <details>
227
262
  <summary><strong>How is this different from WordPress or Ghost themes?</strong></summary>
228
263
 
229
- This is a **headless frontend** — content lives in ReactPress (REST API + admin). You deploy the Next.js theme separately and point it at your API. See [ReactPress docs](https://reactpress.surge.sh/) for the full stack.
264
+ This is a **headless frontend** — content lives in ReactPress (REST API + admin). You deploy the Next.js theme separately and point it at your API. See [ReactPress docs](https://reactpress.cc/) for the full stack.
230
265
 
231
266
  </details>
232
267
 
@@ -253,7 +288,7 @@ Theme issues → [this repo](https://github.com/fecommunity/reactpress-theme-sta
253
288
  | Q&A & showcase | [GitHub Discussions](https://github.com/fecommunity/reactpress-theme-starter/discussions) |
254
289
  | Bug reports | [Issues](https://github.com/fecommunity/reactpress-theme-starter/issues) |
255
290
  | ReactPress core | [github.com/fecommunity/reactpress](https://github.com/fecommunity/reactpress) |
256
- | Documentation | [reactpress.surge.sh](https://reactpress.surge.sh/) |
291
+ | Documentation | [reactpress.cc](https://reactpress.cc/) |
257
292
 
258
293
  ---
259
294
 
@@ -272,7 +307,7 @@ Theme issues → [this repo](https://github.com/fecommunity/reactpress-theme-sta
272
307
 
273
308
  | | |
274
309
  | :---------------- | :----------------------------------------------------------------------------------------------- |
275
- | ReactPress docs | [reactpress.surge.sh](https://reactpress.surge.sh/) |
310
+ | ReactPress docs | [reactpress.cc](https://reactpress.cc/) |
276
311
  | ReactPress source | [github.com/fecommunity/reactpress](https://github.com/fecommunity/reactpress) |
277
312
  | Toolkit | [@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit) |
278
313
  | Theme schema | [theme.manifest.schema.json](./theme.manifest.schema.json) |
@@ -27,10 +27,10 @@ async function handleRequest(request: Request, context: RouteContext): Promise<R
27
27
 
28
28
  const { path = [] } = await context.params
29
29
  const keyword = new URL(request.url).searchParams.get('keyword')?.trim() ?? ''
30
- const body = await readJsonBody(request)
31
30
 
32
31
  let response: Response
33
32
  if (isMockApiEnabled()) {
33
+ const body = await readJsonBody(request)
34
34
  const mockResponse = matchMockRoute(request.method, path, { keyword, body })
35
35
  response = mockResponse ?? Response.json({ success: true, data: null })
36
36
  } else {
package/app/layout.tsx CHANGED
@@ -7,7 +7,11 @@ import ConditionalSiteFooter from '@/components/layout/ConditionalSiteFooter'
7
7
  import PageContainer from '@/components/layout/PageContainer'
8
8
  import { loadAppBootstrap } from '@/lib/reactpress/bootstrap'
9
9
  import { buildThemeStarterAppearanceCss } from '@/lib/reactpress/appearance'
10
- import { getApiPreconnectOrigin } from '@/lib/reactpress/env'
10
+ import {
11
+ buildClientApiRuntimeScript,
12
+ getApiPreconnectOrigin,
13
+ getServerResolvedPublicApiUrl,
14
+ } from '@/lib/reactpress/env'
11
15
  import { ReactPressAppProviders } from '@/lib/reactpress/providers'
12
16
  import { buildRootMetadata } from '@/lib/reactpress/siteMetadata'
13
17
  import { DEFAULT_DESCRIPTION } from '@/lib/reactpress/seoMetadata'
@@ -31,6 +35,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
31
35
  const bootstrap = await loadAppBootstrap('/')
32
36
  const appearanceCss = buildThemeStarterAppearanceCss(bootstrap.themeMods)
33
37
  const basePath = process.env.BASE_PATH || ''
38
+ const clientApiBase = getServerResolvedPublicApiUrl()
34
39
  const apiPreconnect = getApiPreconnectOrigin()
35
40
  const rootMetadata = await buildRootMetadata()
36
41
  const siteDescription =
@@ -42,6 +47,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
42
47
  <html lang={bootstrap.initialLocale} suppressHydrationWarning>
43
48
  <head>
44
49
  <meta name="description" content={siteDescription} />
50
+ <script
51
+ dangerouslySetInnerHTML={{
52
+ __html: buildClientApiRuntimeScript(clientApiBase),
53
+ }}
54
+ />
45
55
  <script
46
56
  dangerouslySetInnerHTML={{
47
57
  __html: `document.documentElement.setAttribute('data-rp-loading','');`,
@@ -2,7 +2,6 @@
2
2
 
3
3
  import Link from '@/components/shared/Link'
4
4
  import ThemeImage from '@/components/shared/ThemeImage'
5
- import { getAdminBaseUrl } from '@/lib/reactpress/env'
6
5
  import { useLocale } from '@fecommunity/reactpress-toolkit/ui'
7
6
  import { resolveImageUrl, useSiteUser } from '@fecommunity/reactpress-toolkit/theme'
8
7
  import { usePathname } from 'next/navigation'
@@ -10,9 +9,24 @@ import { useMemo, useState } from 'react'
10
9
 
11
10
  function resolveAdminHref(): string {
12
11
  const configured = process.env.NEXT_PUBLIC_REACTPRESS_ADMIN_URL?.trim()
13
- if (configured) return configured.endsWith('/') ? configured : `${configured}/`
14
- if (typeof window !== 'undefined') return `${window.location.origin}/admin/`
15
- return `${getAdminBaseUrl()}/`
12
+ if (!configured) return '/admin/'
13
+ if (configured.startsWith('/')) {
14
+ return configured.endsWith('/') ? configured : `${configured}/`
15
+ }
16
+ if (typeof window !== 'undefined') {
17
+ try {
18
+ const parsed = new URL(configured)
19
+ if (
20
+ parsed.pathname.replace(/\/$/, '') === '/admin' &&
21
+ parsed.origin === window.location.origin
22
+ ) {
23
+ return '/admin/'
24
+ }
25
+ } catch {
26
+ // fall through to configured absolute URL
27
+ }
28
+ }
29
+ return configured.endsWith('/') ? configured : `${configured}/`
16
30
  }
17
31
 
18
32
  function resolveThemeAuthHref(path: 'login' | 'register', from: string): string {
@@ -1,5 +1,14 @@
1
1
  import { resolveUpstreamApiBase } from './config'
2
2
 
3
+ /** Strip hop-by-hop / encoding headers — fetch may decompress the body. */
4
+ function sanitizeProxyResponseHeaders(headers: Headers): Headers {
5
+ const next = new Headers(headers)
6
+ for (const name of ['content-encoding', 'content-length', 'transfer-encoding', 'connection']) {
7
+ next.delete(name)
8
+ }
9
+ return next
10
+ }
11
+
3
12
  export async function proxyToUpstreamApi(request: Request, segments: string[]): Promise<Response> {
4
13
  const base = resolveUpstreamApiBase()
5
14
  const pathname = segments.map((segment) => decodeURIComponent(segment)).join('/')
@@ -21,8 +30,12 @@ export async function proxyToUpstreamApi(request: Request, segments: string[]):
21
30
  redirect: 'manual',
22
31
  })
23
32
 
24
- return new Response(upstream.body, {
33
+ // Buffer body — piping upstream.body through a new Response is unreliable on Vercel.
34
+ const upstreamBody = request.method === 'HEAD' ? null : Buffer.from(await upstream.arrayBuffer())
35
+
36
+ return new Response(upstreamBody, {
25
37
  status: upstream.status,
26
- headers: upstream.headers,
38
+ statusText: upstream.statusText,
39
+ headers: sanitizeProxyResponseHeaders(upstream.headers),
27
40
  })
28
41
  }
@@ -1,6 +1,7 @@
1
- import { createThemeHttpStack } from '@fecommunity/reactpress-toolkit/theme'
1
+ import { resolveClientApiBaseUrl } from '@/lib/reactpress/env'
2
2
  import { normalizeList, normalizePaginated } from '@/lib/reactpress/normalizeApiResponse'
3
3
  import { materializeProvider } from '@/lib/providers/materializeProvider'
4
+ import { createThemeHttpStack } from '@fecommunity/reactpress-toolkit/theme'
4
5
 
5
6
  /** API messages already shown in UI (login form, etc.) — skip dev console noise. */
6
7
  function isExpectedClientError(message: string, status?: number): boolean {
@@ -11,6 +12,7 @@ function isExpectedClientError(message: string, status?: number): boolean {
11
12
  }
12
13
 
13
14
  const stack = createThemeHttpStack({
15
+ baseURL: resolveClientApiBaseUrl(),
14
16
  onError: (msg: string, status?: number) => {
15
17
  if (process.env.NODE_ENV !== 'development') return
16
18
  if (isExpectedClientError(msg, status)) return
@@ -18,6 +20,12 @@ const stack = createThemeHttpStack({
18
20
  },
19
21
  })
20
22
 
23
+ // Module init may run before layout runtime script; refresh baseURL on every request.
24
+ stack.httpProvider.interceptors.request.use((config) => {
25
+ config.baseURL = resolveClientApiBaseUrl()
26
+ return config
27
+ })
28
+
21
29
  export const { CommentProvider, UserProvider, SettingProvider } = stack
22
30
 
23
31
  export const ArticleProvider = {
@@ -1,5 +1,10 @@
1
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'
2
+ import {
3
+ DEFAULT_LOCAL_ADMIN,
4
+ DEFAULT_LOCAL_API,
5
+ DEFAULT_LOCAL_SITE,
6
+ resolveThemeApiEnv,
7
+ } from '../../scripts/resolve-api-env.mjs'
3
8
 
4
9
  export {
5
10
  DEFAULT_LOCAL_ADMIN,
@@ -7,6 +12,15 @@ export {
7
12
  DEFAULT_LOCAL_SITE,
8
13
  } from '../../scripts/resolve-api-env.mjs'
9
14
 
15
+ declare global {
16
+ interface Window {
17
+ /** Injected in root layout at SSR time — avoids stale build-time NEXT_PUBLIC_* in the browser. */
18
+ __REACTPRESS_CLIENT_API__?: string
19
+ }
20
+ }
21
+
22
+ export const RUNTIME_CLIENT_API_KEY = '__REACTPRESS_CLIENT_API__'
23
+
10
24
  export function getClientSiteUrl(): string {
11
25
  return (process.env.CLIENT_SITE_URL || DEFAULT_LOCAL_SITE).replace(/\/$/, '')
12
26
  }
@@ -17,11 +31,44 @@ export function getAdminBaseUrl(): string {
17
31
  return DEFAULT_LOCAL_ADMIN
18
32
  }
19
33
 
34
+ /** Server-side resolved browser API base (honours runtime env on each request). */
35
+ export function getServerResolvedPublicApiUrl(): string {
36
+ return resolveThemeApiEnv().NEXT_PUBLIC_REACTPRESS_API_URL.replace(/\/$/, '')
37
+ }
38
+
39
+ /** Inline script for `<head>` — must run before client bundles evaluate. */
40
+ export function buildClientApiRuntimeScript(apiBase: string): string {
41
+ return `window.${RUNTIME_CLIENT_API_KEY}=${JSON.stringify(apiBase.replace(/\/$/, ''))};`
42
+ }
43
+
44
+ function readRuntimeClientApiBaseUrl(): string | undefined {
45
+ if (typeof window === 'undefined') return undefined
46
+ const value = window[RUNTIME_CLIENT_API_KEY]?.trim()
47
+ return value ? value.replace(/\/$/, '') : undefined
48
+ }
49
+
50
+ /** Axios/fetch base URL for browser CSR — never fall back to localhost:3002 in the browser. */
51
+ export function resolveClientApiBaseUrl(): string {
52
+ if (typeof window === 'undefined') {
53
+ return (
54
+ process.env.REACTPRESS_API_URL?.trim() ||
55
+ process.env.NEXT_PUBLIC_REACTPRESS_API_URL?.trim() ||
56
+ DEFAULT_LOCAL_API
57
+ ).replace(/\/$/, '')
58
+ }
59
+
60
+ const runtime = readRuntimeClientApiBaseUrl()
61
+ if (runtime) return runtime
62
+
63
+ const publicUrl = process.env.NEXT_PUBLIC_REACTPRESS_API_URL?.trim()
64
+ if (publicUrl) return publicUrl.replace(/\/$/, '')
65
+
66
+ return '/api'
67
+ }
68
+
20
69
  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()
70
+ const apiEnv = resolveThemeApiEnv()
71
+ const api = apiEnv.NEXT_PUBLIC_REACTPRESS_API_URL.trim()
25
72
 
26
73
  if (!api || api.startsWith('/')) return null
27
74
 
@@ -1,7 +1,9 @@
1
1
  /** Same-origin visitor setting fetch — avoids toolkit axios onError console noise. */
2
+ import { resolveClientApiBaseUrl } from '@/lib/reactpress/env'
3
+
2
4
  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
+ const apiBase = resolveClientApiBaseUrl()
6
+ const url = `${apiBase}/setting/get`
5
7
 
6
8
  try {
7
9
  const response = await fetch(url, {
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useReportPageView } from '@fecommunity/reactpress-toolkit/ui'
3
+ import { useReportPageView } from '@/lib/reactpress/useReportPageView'
4
4
  import { type ReactNode } from 'react'
5
5
 
6
6
  import BackToTop from '@/components/layout/BackToTop'
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { resolveClientApiBaseUrl } from '@/lib/reactpress/env'
4
+ import { useEffect, useRef } from 'react'
5
+
6
+ interface UseReportPageViewOptions {
7
+ url?: string
8
+ skipLocalhost?: boolean
9
+ enabled?: boolean
10
+ }
11
+
12
+ /** Report page view via resolved public API base (runtime-injected in production). */
13
+ export function useReportPageView(options: UseReportPageViewOptions = {}) {
14
+ const { url, skipLocalhost = true, enabled = true } = options
15
+ const lastUrl = useRef<string | undefined>(undefined)
16
+
17
+ useEffect(() => {
18
+ if (!enabled || typeof window === 'undefined') return undefined
19
+
20
+ const target = url ?? window.location.href
21
+ if (skipLocalhost && /localhost|127\.0\.0\.1/.test(target)) return undefined
22
+ if (lastUrl.current === target) return undefined
23
+ lastUrl.current = target
24
+
25
+ const apiBase = resolveClientApiBaseUrl()
26
+ fetch(`${apiBase}/view`, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ url: target }),
30
+ }).catch((error) => {
31
+ console.error('[reactpress] report page view failed', error)
32
+ })
33
+
34
+ return undefined
35
+ }, [enabled, skipLocalhost, url])
36
+ }
package/next.config.js CHANGED
@@ -6,6 +6,12 @@ const {
6
6
  resolveClientSiteUrl,
7
7
  isLocalhostUrl,
8
8
  } = require('./scripts/resolve-api-env.mjs')
9
+ const {
10
+ isEmbedAdminEnabled,
11
+ isBundledAdminEnabled,
12
+ createLocalAdminRewrites,
13
+ createBundledAdminFallbackRewrites,
14
+ } = require('./scripts/embed-admin.cjs')
9
15
 
10
16
  // App Router (Next 15): do not use createReactPressNextConfig's react webpack alias —
11
17
  // it conflicts with Next's compiled React and causes SSR "useContext" null errors.
@@ -73,7 +79,16 @@ module.exports = {
73
79
  return webpackConfig
74
80
  },
75
81
  async rewrites() {
76
- return [{ source: '/uploads/:path*', destination: `${uploadRewriteOrigin}/uploads/:path*` }]
82
+ const beforeFiles = [
83
+ { source: '/uploads/:path*', destination: `${uploadRewriteOrigin}/uploads/:path*` },
84
+ ]
85
+ if (isEmbedAdminEnabled()) {
86
+ beforeFiles.push(...createLocalAdminRewrites())
87
+ }
88
+
89
+ const fallback = isBundledAdminEnabled() ? createBundledAdminFallbackRewrites() : []
90
+
91
+ return { beforeFiles, fallback }
77
92
  },
78
93
  async redirects() {
79
94
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fecommunity/reactpress-theme-starter",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "ReactPress 博客主题 Starter:Tailwind CSS + Next.js App Router,对接 toolkit 动态内容",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -47,7 +47,9 @@
47
47
  "scripts": {
48
48
  "dev": "node scripts/dev.mjs",
49
49
  "dev:mock": "cross-env REACTPRESS_MOCK_API=1 node scripts/dev.mjs",
50
+ "dev:admin": "node node_modules/@fecommunity/reactpress-web/bin/reactpress-web.js start --port 3000",
50
51
  "clean": "rm -rf .next .next-dev",
52
+ "prebuild": "node scripts/copy-admin-dist.mjs",
51
53
  "build": "cross-env INIT_CWD=$PWD next build",
52
54
  "build:mock": "node scripts/build-mock.mjs",
53
55
  "build:ci": "pnpm clean && cross-env INIT_CWD=$PWD next build",
@@ -63,6 +65,7 @@
63
65
  },
64
66
  "dependencies": {
65
67
  "@fecommunity/reactpress-toolkit": "^3.1.0",
68
+ "@fecommunity/reactpress-web": "^3.7.0",
66
69
  "@tailwindcss/forms": "^0.5.11",
67
70
  "@tailwindcss/postcss": "^4.1.18",
68
71
  "@tailwindcss/typography": "^0.5.19",
Binary file
Binary file
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy @fecommunity/reactpress-web/dist → public/admin for Vercel / serverless deploys.
4
+ * Admin SPA expects base /admin/ and same-origin /api (proxied by app/api/[[...path]]).
5
+ */
6
+ import fs from 'node:fs'
7
+ import fsp from 'node:fs/promises'
8
+ import path from 'node:path'
9
+ import { createRequire } from 'node:module'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const require = createRequire(import.meta.url)
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
+ const projectRoot = path.resolve(__dirname, '..')
15
+ const { isBundledAdminEnabled } = require('./embed-admin.cjs')
16
+
17
+ function resolveAdminDistDir() {
18
+ const dist = path.join(projectRoot, 'node_modules', '@fecommunity/reactpress-web', 'dist')
19
+ return fs.realpathSync(dist)
20
+ }
21
+
22
+ async function copyEntry(src, dest) {
23
+ const stat = await fsp.stat(src)
24
+ if (stat.isDirectory()) {
25
+ await fsp.mkdir(dest, { recursive: true })
26
+ for (const name of await fsp.readdir(src)) {
27
+ await copyEntry(path.join(src, name), path.join(dest, name))
28
+ }
29
+ return
30
+ }
31
+ await fsp.copyFile(src, dest)
32
+ }
33
+
34
+ async function main() {
35
+ if (!isBundledAdminEnabled()) {
36
+ console.log('[copy-admin] skipped (REACTPRESS_BUNDLE_ADMIN=0 or not a Vercel production build)')
37
+ return
38
+ }
39
+
40
+ const src = resolveAdminDistDir()
41
+ const indexHtml = path.join(src, 'index.html')
42
+ if (!fs.existsSync(indexHtml)) {
43
+ throw new Error(
44
+ `Admin dist not found at ${src}. Ensure @fecommunity/reactpress-web is installed.`
45
+ )
46
+ }
47
+
48
+ const dest = path.join(projectRoot, 'public/admin')
49
+ await fsp.rm(dest, { recursive: true, force: true })
50
+ await copyEntry(src, dest)
51
+
52
+ console.log(`[copy-admin] copied ${src} → public/admin`)
53
+ }
54
+
55
+ main().catch((error) => {
56
+ console.error('[copy-admin]', error.message || error)
57
+ process.exit(1)
58
+ })
package/scripts/dev.mjs CHANGED
@@ -17,11 +17,16 @@ import {
17
17
  resolveDevApiOrigins,
18
18
  resolveRemoteThemeApiBase,
19
19
  } from './remote-dev.mjs'
20
+ import { createRequire } from 'node:module'
20
21
  import {
21
22
  DEFAULT_LOCAL_API,
22
23
  DEFAULT_THEME_DEV_PORT,
23
24
  resolveThemeApiEnv as resolveProjectApiEnv,
24
25
  } from './resolve-api-env.mjs'
26
+ import { spawnEmbeddedAdmin } from './spawn-admin.mjs'
27
+
28
+ const require = createRequire(import.meta.url)
29
+ const { isEmbedAdminEnabled, resolveAdminStaticOrigin } = require('./embed-admin.cjs')
25
30
 
26
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
27
32
  const projectRoot = path.resolve(__dirname, '..')
@@ -176,8 +181,9 @@ function buildChildEnv(apiEnv, port) {
176
181
  }
177
182
  }
178
183
 
179
- function printBanner(apiEnv, port) {
184
+ function printBanner(apiEnv, port, childEnv) {
180
185
  const mockEnabled = process.env.REACTPRESS_MOCK_API === '1'
186
+ const embedAdmin = isEmbedAdminEnabled(childEnv)
181
187
  console.log('')
182
188
  console.log(' ReactPress Theme Dev')
183
189
  console.log(` Local: http://localhost:${port}`)
@@ -188,18 +194,42 @@ function printBanner(apiEnv, port) {
188
194
  } else {
189
195
  console.log(` API: ${apiEnv.serverApiUrl} (local)`)
190
196
  }
197
+ if (embedAdmin) {
198
+ console.log(
199
+ ` Admin: http://localhost:${port}/admin/ (static → ${resolveAdminStaticOrigin(childEnv)})`
200
+ )
201
+ }
191
202
  console.log('')
192
203
  }
193
204
 
205
+ function shutdownChildren(children, signal = 'SIGTERM') {
206
+ for (const child of children) {
207
+ if (child && !child.killed) {
208
+ child.kill(signal)
209
+ }
210
+ }
211
+ }
212
+
194
213
  function main() {
195
214
  const cli = parseArgs(process.argv.slice(2))
196
215
  const apiEnv = resolveThemeApiEnv(cli)
197
216
  const childEnv = buildChildEnv(apiEnv, cli.port)
217
+ const children = []
198
218
 
199
- printBanner(apiEnv, cli.port)
219
+ if (isEmbedAdminEnabled(childEnv)) {
220
+ childEnv.REACTPRESS_EMBED_ADMIN = '1'
221
+ try {
222
+ children.push(spawnEmbeddedAdmin(childEnv))
223
+ } catch (error) {
224
+ console.error(`[dev] Failed to start embedded admin: ${error.message}`)
225
+ process.exit(1)
226
+ }
227
+ }
228
+
229
+ printBanner(apiEnv, cli.port, childEnv)
200
230
 
201
231
  const nextBin = path.join(projectRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
202
- const child = spawn(
232
+ const nextChild = spawn(
203
233
  process.execPath,
204
234
  [nextBin, 'dev', '-p', String(cli.port), ...cli.passthrough],
205
235
  {
@@ -208,8 +238,17 @@ function main() {
208
238
  stdio: 'inherit',
209
239
  }
210
240
  )
241
+ children.push(nextChild)
242
+
243
+ const onSignal = (signal) => {
244
+ shutdownChildren(children, signal)
245
+ process.exit(0)
246
+ }
247
+ process.on('SIGINT', onSignal)
248
+ process.on('SIGTERM', onSignal)
211
249
 
212
- child.on('exit', (code, signal) => {
250
+ nextChild.on('exit', (code, signal) => {
251
+ shutdownChildren(children.filter((c) => c !== nextChild))
213
252
  if (signal) {
214
253
  process.kill(process.pid, signal)
215
254
  return
@@ -0,0 +1,67 @@
1
+ /** Shared helpers for embedding @fecommunity/reactpress-web (CJS — next.config.js). */
2
+
3
+ const DEFAULT_ADMIN_STATIC_PORT = 3000
4
+
5
+ function trimUrl(value) {
6
+ if (typeof value !== 'string') return ''
7
+ return value.trim().replace(/\/$/, '')
8
+ }
9
+
10
+ function isEmbedAdminEnabled(processEnv = process.env) {
11
+ const flag = processEnv.REACTPRESS_EMBED_ADMIN
12
+ if (flag === '0' || flag === 'false') return false
13
+ if (flag === '1' || flag === 'true') return true
14
+ // Default on for local theme dev when not on Vercel.
15
+ return processEnv.VERCEL !== '1' && processEnv.REACTPRESS_MOCK_API !== '1'
16
+ }
17
+
18
+ function resolveAdminStaticOrigin(processEnv = process.env) {
19
+ const explicit = trimUrl(processEnv.REACTPRESS_ADMIN_STATIC_ORIGIN)
20
+ if (explicit) return explicit
21
+ const port = Number(processEnv.WEB_ADMIN_PORT || DEFAULT_ADMIN_STATIC_PORT)
22
+ return `http://127.0.0.1:${port}`
23
+ }
24
+
25
+ /** Next.js rewrites — browser hits /admin on the theme origin, upstream serves static SPA. */
26
+ function createLocalAdminRewrites(processEnv = process.env) {
27
+ const adminOrigin = resolveAdminStaticOrigin(processEnv)
28
+ return [
29
+ { source: '/admin', destination: `${adminOrigin}/admin/` },
30
+ { source: '/admin/:path*', destination: `${adminOrigin}/admin/:path*` },
31
+ ]
32
+ }
33
+
34
+ function resolveEmbeddedAdminPublicUrl(processEnv = process.env, clientSiteUrl) {
35
+ const site = trimUrl(clientSiteUrl)
36
+ if (!site) return null
37
+ return `${site}/admin`
38
+ }
39
+
40
+ /**
41
+ * Vercel / serverless: copy @fecommunity/reactpress-web/dist into public/admin at build time.
42
+ * REACTPRESS_EMBED_ADMIN=0 only disables the Node custom-server embed, not bundled static admin.
43
+ */
44
+ function isBundledAdminEnabled(processEnv = process.env) {
45
+ const flag = processEnv.REACTPRESS_BUNDLE_ADMIN
46
+ if (flag === '0' || flag === 'false') return false
47
+ if (flag === '1' || flag === 'true') return true
48
+ return processEnv.VERCEL === '1' && processEnv.REACTPRESS_MOCK_API !== '1'
49
+ }
50
+
51
+ /** SPA fallback — use in Next.js rewrites().fallback after public/admin static files. */
52
+ function createBundledAdminFallbackRewrites() {
53
+ return [
54
+ { source: '/admin', destination: '/admin/index.html' },
55
+ { source: '/admin/:path*', destination: '/admin/index.html' },
56
+ ]
57
+ }
58
+
59
+ module.exports = {
60
+ DEFAULT_ADMIN_STATIC_PORT,
61
+ isEmbedAdminEnabled,
62
+ isBundledAdminEnabled,
63
+ resolveAdminStaticOrigin,
64
+ createLocalAdminRewrites,
65
+ createBundledAdminFallbackRewrites,
66
+ resolveEmbeddedAdminPublicUrl,
67
+ }
@@ -18,12 +18,14 @@ const MERGE_KEYS = [
18
18
  'BASE_PATH',
19
19
  'REACTPRESS_MOCK_API',
20
20
  'NEXT_PUBLIC_REACTPRESS_ADMIN_URL',
21
+ 'REACTPRESS_EMBED_ADMIN',
22
+ 'REACTPRESS_ADMIN_STATIC_ORIGIN',
21
23
  'REACTPRESS_DEV_REMOTE_ORIGIN',
22
24
  ]
23
25
 
24
26
  const FALLBACK_ENV = `# Auto-generated for CI / Vercel mock build
25
27
  REACTPRESS_API_URL=http://localhost:3002/api
26
- NEXT_PUBLIC_REACTPRESS_API_URL=http://localhost:3002/api
28
+ NEXT_PUBLIC_REACTPRESS_API_URL=/api
27
29
  CLIENT_SITE_URL=http://localhost:3001
28
30
  `
29
31
 
@@ -1,9 +1,23 @@
1
+ function isEmbedAdminEnabled(processEnv = process.env) {
2
+ const flag = processEnv.REACTPRESS_EMBED_ADMIN
3
+ if (flag === '0' || flag === 'false') return false
4
+ if (flag === '1' || flag === 'true') return true
5
+ return processEnv.VERCEL !== '1' && processEnv.REACTPRESS_MOCK_API !== '1'
6
+ }
7
+
8
+ function isBundledAdminEnabled(processEnv = process.env) {
9
+ const flag = processEnv.REACTPRESS_BUNDLE_ADMIN
10
+ if (flag === '0' || flag === 'false') return false
11
+ if (flag === '1' || flag === 'true') return true
12
+ return processEnv.VERCEL === '1' && processEnv.REACTPRESS_MOCK_API !== '1'
13
+ }
14
+
1
15
  /**
2
16
  * Single source of truth for ReactPress theme URL resolution (build, dev, Vercel).
3
17
  */
4
18
  export const DEFAULT_LOCAL_API = 'http://localhost:3002/api'
5
19
  export const DEFAULT_LOCAL_SITE = 'http://localhost:3001'
6
- export const DEFAULT_LOCAL_ADMIN = 'http://localhost:3000'
20
+ export const DEFAULT_LOCAL_ADMIN = 'http://localhost:3000/admin'
7
21
  export const DEFAULT_THEME_DEV_PORT = 3001
8
22
  export const DEFAULT_VERCEL_DEMO_SITE = 'https://reactpress-theme-starter.vercel.app'
9
23
 
@@ -30,6 +44,35 @@ function apiOriginFromBase(serverApi) {
30
44
  return serverApi.replace(/\/api\/?$/, '')
31
45
  }
32
46
 
47
+ function parseHttpOrigin(value) {
48
+ const normalized = value.startsWith('http') ? value : `https://${value}`
49
+ return new URL(normalized).origin
50
+ }
51
+
52
+ /** True when public site and API share the same origin (nginx /api gateway). */
53
+ export function isSameSiteAndApiOrigin(serverApi, processEnv) {
54
+ if (isLocalhostUrl(serverApi)) return true
55
+
56
+ const apiOriginRaw = apiOriginFromBase(serverApi)
57
+ const siteUrl = resolveClientSiteUrl(processEnv)
58
+
59
+ try {
60
+ return parseHttpOrigin(siteUrl) === parseHttpOrigin(apiOriginRaw)
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Default browser API URL when NEXT_PUBLIC_REACTPRESS_API_URL is unset.
68
+ * Same-origin (nginx /api gateway) → `/api`; cross-subdomain → direct API URL.
69
+ */
70
+ function resolvePublicApiDefault(serverApi, processEnv) {
71
+ if (isLocalhostUrl(serverApi)) return serverApi
72
+ if (isSameSiteAndApiOrigin(serverApi, processEnv)) return '/api'
73
+ return serverApi
74
+ }
75
+
33
76
  /**
34
77
  * @param {NodeJS.ProcessEnv} [processEnv]
35
78
  */
@@ -71,11 +114,14 @@ export function resolveThemeApiEnv(processEnv = process.env) {
71
114
  // Browser must hit same-origin mock routes; ignore .env localhost:3002.
72
115
  publicApi = '/api'
73
116
  } else if (!publicApi) {
74
- if (processEnv.NODE_ENV === 'production' && !isLocalhostUrl(serverApi)) {
75
- publicApi = '/api'
76
- } else {
77
- publicApi = serverApi
78
- }
117
+ publicApi = resolvePublicApiDefault(serverApi, processEnv)
118
+ } else if (
119
+ publicApi === '/api' &&
120
+ !isLocalhostUrl(serverApi) &&
121
+ !isSameSiteAndApiOrigin(serverApi, processEnv)
122
+ ) {
123
+ // `.env.example` defaults to `/api`; cross-subdomain deploys must hit the API host directly.
124
+ publicApi = serverApi
79
125
  }
80
126
 
81
127
  const nginxEntry = trimUrl(processEnv.REACTPRESS_NGINX_ENTRY_URL || processEnv.NGINX_ENTRY_URL)
@@ -83,7 +129,10 @@ export function resolveThemeApiEnv(processEnv = process.env) {
83
129
  trimUrl(processEnv.NEXT_PUBLIC_REACTPRESS_ADMIN_URL) ||
84
130
  (nginxEntry ? `${nginxEntry}/admin` : DEFAULT_LOCAL_ADMIN)
85
131
 
86
- if (processEnv.VERCEL === '1' && isLocalhostUrl(adminUrl) && !isLocalhostUrl(serverApi)) {
132
+ if (isEmbedAdminEnabled(processEnv) || isBundledAdminEnabled(processEnv)) {
133
+ // Same-origin rewrite/bundle — relative path keeps port and host in sync with the theme.
134
+ adminUrl = '/admin'
135
+ } else if (processEnv.VERCEL === '1' && isLocalhostUrl(adminUrl) && !isLocalhostUrl(serverApi)) {
87
136
  adminUrl = `${apiOriginFromBase(serverApi)}/admin`
88
137
  }
89
138
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createRequire } from 'node:module'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const { DEFAULT_ADMIN_STATIC_PORT, resolveAdminStaticOrigin } = require('./embed-admin.cjs')
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+ const projectRoot = path.resolve(__dirname, '..')
13
+
14
+ function resolveAdminBin() {
15
+ const local = path.join(
16
+ projectRoot,
17
+ 'node_modules',
18
+ '@fecommunity/reactpress-web',
19
+ 'bin',
20
+ 'reactpress-web.js'
21
+ )
22
+ if (fs.existsSync(local)) return local
23
+ throw new Error(
24
+ 'Missing @fecommunity/reactpress-web. Run pnpm install (see package.json dependency).'
25
+ )
26
+ }
27
+
28
+ function adminPortFromOrigin(origin) {
29
+ try {
30
+ const url = new URL(origin)
31
+ if (url.port) return Number(url.port)
32
+ return url.protocol === 'https:' ? 443 : 80
33
+ } catch {
34
+ return DEFAULT_ADMIN_STATIC_PORT
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Start embedded Admin static server. Returns child process (stdio inherited).
40
+ * @param {NodeJS.ProcessEnv} [processEnv]
41
+ */
42
+ export function spawnEmbeddedAdmin(processEnv = process.env) {
43
+ const origin = resolveAdminStaticOrigin(processEnv)
44
+ const port = adminPortFromOrigin(origin)
45
+ const bin = resolveAdminBin()
46
+
47
+ return spawn(process.execPath, [bin, 'start', '--port', String(port), '--host', '127.0.0.1'], {
48
+ cwd: projectRoot,
49
+ env: processEnv,
50
+ stdio: 'inherit',
51
+ })
52
+ }