@fecommunity/reactpress-theme-starter 1.0.0-beta.0 → 1.0.0-beta.2
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 +17 -2
- package/README.md +51 -9
- package/app/api/[[...path]]/route.ts +1 -1
- package/app/layout.tsx +11 -1
- package/components/layout/UserAuth.tsx +18 -4
- package/lib/mock-api/proxy.ts +15 -2
- package/lib/providers/client.ts +9 -1
- package/lib/reactpress/env.ts +52 -5
- package/lib/reactpress/fetchSetting.ts +4 -2
- package/lib/reactpress/layout-shell.tsx +1 -1
- package/lib/reactpress/useReportPageView.ts +36 -0
- package/next.config.js +16 -1
- package/package.json +7 -4
- package/public/home-dark.png +0 -0
- package/public/lighthouse.png +0 -0
- package/scripts/copy-admin-dist.mjs +58 -0
- package/scripts/dev.mjs +43 -4
- package/scripts/embed-admin.cjs +67 -0
- package/scripts/ensure-env.mjs +3 -1
- package/scripts/resolve-api-env.mjs +56 -7
- package/scripts/spawn-admin.mjs +52 -0
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
|
|
5
|
-
NEXT_PUBLIC_REACTPRESS_API_URL
|
|
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,20 @@
|
|
|
1
|
+
> **Official hubs** (package name unchanged)
|
|
2
|
+
>
|
|
3
|
+
> - Site / docs: https://www.reactpress.cc/
|
|
4
|
+
> - Source: https://github.com/reactpressjs/reactpress
|
|
5
|
+
>
|
|
6
|
+
> Install: `npm i -g @fecommunity/reactpress`
|
|
7
|
+
|
|
8
|
+
# ReactPress Theme Starter
|
|
9
|
+
|
|
1
10
|
<div align="center">
|
|
2
11
|
|
|
3
|
-
<a href="https://reactpress.
|
|
12
|
+
<a href="https://reactpress.cc/">
|
|
4
13
|
<img src="./public/logo.png" alt="ReactPress" width="480" />
|
|
5
14
|
</a>
|
|
6
15
|
|
|
7
16
|
<br />
|
|
8
17
|
|
|
9
|
-
**ReactPress Theme Starter**
|
|
10
|
-
|
|
11
18
|
A lightweight, simple, and easy-to-use Next.js theme, powered by [ReactPress](https://github.com/fecommunity/reactpress).
|
|
12
19
|
|
|
13
20
|
<a href="https://reactpress-theme-starter.vercel.app">
|
|
@@ -16,7 +23,7 @@ A lightweight, simple, and easy-to-use Next.js theme, powered by [ReactPress](ht
|
|
|
16
23
|
|
|
17
24
|
<br />
|
|
18
25
|
|
|
19
|
-
[Live Demo](https://reactpress-theme-starter.vercel.app) · [Documentation](https://reactpress.
|
|
26
|
+
[Live Demo](https://reactpress-theme-starter.vercel.app) · [Documentation](https://reactpress.cc/) · [简体中文](./README_zh.md)
|
|
20
27
|
|
|
21
28
|
<br />
|
|
22
29
|
|
|
@@ -90,6 +97,41 @@ ReactPress API ──REST──▶ Theme Starter (Next.js) ──▶ Public
|
|
|
90
97
|
| Try without backend | ✅ `pnpm dev:mock` | ❌ Usually needs content/API |
|
|
91
98
|
| Knowledge base & comments | ✅ Built-in | ❌ Roll your own |
|
|
92
99
|
| Customizable appearance | ✅ Admin console + `theme.json` | ❌ Hard-coded or manual |
|
|
100
|
+
| Lighthouse performance | ✅ **95** out of the box (see below) | ❌ Varies widely |
|
|
101
|
+
|
|
102
|
+
### Performance
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
<div align="center">
|
|
107
|
+
|
|
108
|
+
<img src="./public/lighthouse.png" alt="Lighthouse audit: Performance 95, Accessibility 100, Best Practices 100, SEO 100" width="720" />
|
|
109
|
+
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
| Category | Score |
|
|
113
|
+
| :------------- | :------ |
|
|
114
|
+
| Performance | **95** |
|
|
115
|
+
| Accessibility | **100** |
|
|
116
|
+
| Best Practices | **100** |
|
|
117
|
+
| SEO | **100** |
|
|
118
|
+
|
|
119
|
+
**Core Web Vitals** (homepage audit on the [live demo](https://reactpress-theme-starter.vercel.app)):
|
|
120
|
+
|
|
121
|
+
| Metric | Result |
|
|
122
|
+
| :----------------------------- | :--------- |
|
|
123
|
+
| First Contentful Paint (FCP) | **0.4 s** |
|
|
124
|
+
| Largest Contentful Paint (LCP) | **1.0 s** |
|
|
125
|
+
| Total Blocking Time (TBT) | **150 ms** |
|
|
126
|
+
| Cumulative Layout Shift (CLS) | **0** |
|
|
127
|
+
| Speed Index | **1.1 s** |
|
|
128
|
+
|
|
129
|
+
**Why it feels fast:**
|
|
130
|
+
|
|
131
|
+
- **SSR + SSG** — pages render on the server; static routes are pre-built at deploy time
|
|
132
|
+
- **Zero layout shift** — stable typography and layout from first paint
|
|
133
|
+
- **Light client bundle** — content-first UI without heavy runtime overhead
|
|
134
|
+
- **SEO-ready** — metadata, Open Graph, RSS, and sitemap included
|
|
93
135
|
|
|
94
136
|
---
|
|
95
137
|
|
|
@@ -99,7 +141,7 @@ ReactPress API ──REST──▶ Theme Starter (Next.js) ──▶ Public
|
|
|
99
141
|
|
|
100
142
|
### Recommended — ReactPress CLI
|
|
101
143
|
|
|
102
|
-
The fastest way to run a full stack (visitor site, admin console, and API) is the [ReactPress CLI](https://reactpress.
|
|
144
|
+
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
145
|
|
|
104
146
|
```bash
|
|
105
147
|
npm i -g @fecommunity/reactpress@3
|
|
@@ -219,14 +261,14 @@ Route templates are declared in [`theme.json`](./theme.json). Appearance (colors
|
|
|
219
261
|
<details>
|
|
220
262
|
<summary><strong>Do I need ReactPress to preview the theme?</strong></summary>
|
|
221
263
|
|
|
222
|
-
No. Run `pnpm dev:mock` for a full UI preview with sample data. Install [ReactPress CLI](https://reactpress.
|
|
264
|
+
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
265
|
|
|
224
266
|
</details>
|
|
225
267
|
|
|
226
268
|
<details>
|
|
227
269
|
<summary><strong>How is this different from WordPress or Ghost themes?</strong></summary>
|
|
228
270
|
|
|
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.
|
|
271
|
+
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
272
|
|
|
231
273
|
</details>
|
|
232
274
|
|
|
@@ -253,7 +295,7 @@ Theme issues → [this repo](https://github.com/fecommunity/reactpress-theme-sta
|
|
|
253
295
|
| Q&A & showcase | [GitHub Discussions](https://github.com/fecommunity/reactpress-theme-starter/discussions) |
|
|
254
296
|
| Bug reports | [Issues](https://github.com/fecommunity/reactpress-theme-starter/issues) |
|
|
255
297
|
| ReactPress core | [github.com/fecommunity/reactpress](https://github.com/fecommunity/reactpress) |
|
|
256
|
-
| Documentation | [reactpress.
|
|
298
|
+
| Documentation | [reactpress.cc](https://reactpress.cc/) |
|
|
257
299
|
|
|
258
300
|
---
|
|
259
301
|
|
|
@@ -272,7 +314,7 @@ Theme issues → [this repo](https://github.com/fecommunity/reactpress-theme-sta
|
|
|
272
314
|
|
|
273
315
|
| | |
|
|
274
316
|
| :---------------- | :----------------------------------------------------------------------------------------------- |
|
|
275
|
-
| ReactPress docs | [reactpress.
|
|
317
|
+
| ReactPress docs | [reactpress.cc](https://reactpress.cc/) |
|
|
276
318
|
| ReactPress source | [github.com/fecommunity/reactpress](https://github.com/fecommunity/reactpress) |
|
|
277
319
|
| Toolkit | [@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit) |
|
|
278
320
|
| 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 {
|
|
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
|
|
14
|
-
if (
|
|
15
|
-
|
|
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 {
|
package/lib/mock-api/proxy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
+
statusText: upstream.statusText,
|
|
39
|
+
headers: sanitizeProxyResponseHeaders(upstream.headers),
|
|
27
40
|
})
|
|
28
41
|
}
|
package/lib/providers/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 = {
|
package/lib/reactpress/env.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/** App/runtime helpers — values are injected via next.config `env` when possible. */
|
|
2
|
-
import {
|
|
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
|
|
22
|
-
|
|
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 = (
|
|
4
|
-
const url =
|
|
5
|
+
const apiBase = resolveClientApiBaseUrl()
|
|
6
|
+
const url = `${apiBase}/setting/get`
|
|
5
7
|
|
|
6
8
|
try {
|
|
7
9
|
const response = await fetch(url, {
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
4
|
"description": "ReactPress 博客主题 Starter:Tailwind CSS + Next.js App Router,对接 toolkit 动态内容",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
],
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
|
-
"url": "git+https://github.com/
|
|
16
|
+
"url": "git+https://github.com/reactpressjs/reactpress.git"
|
|
17
17
|
},
|
|
18
18
|
"bugs": {
|
|
19
|
-
"url": "https://github.com/
|
|
19
|
+
"url": "https://github.com/reactpressjs/reactpress/issues"
|
|
20
20
|
},
|
|
21
|
-
"homepage": "https://
|
|
21
|
+
"homepage": "https://www.reactpress.cc/",
|
|
22
22
|
"publishConfig": {
|
|
23
23
|
"access": "public",
|
|
24
24
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -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",
|
package/public/home-dark.png
CHANGED
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/scripts/ensure-env.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
+
}
|