diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3cb45129 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +README.md +.dockerignore +docker-compose.prod.yml +node_modules +npm-debug.log +*.log +.env +.env.local +.docs +.husky +scripts/ +public/ +docs/ \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 00000000..f8bd3b74 --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +# ReactPress — copy to .env and run `pnpm init` to sync from .reactpress/config.json +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=reactpress +DB_PASSWD=reactpress +DB_DATABASE=reactpress + + +# Client Config +CLIENT_SITE_URL=http://localhost:3001 + +# Server Config +SERVER_SITE_URL=http://localhost:3002 \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..bdfeb85d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +node_modules +**/.next/** +**/_next/** +**/dist/** +.eslintrc.js +dist \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..8102f728 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,54 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'react-hooks', 'simple-import-sort', 'prettier'], + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + overrides: [ + { + files: ['**/*.{ts,tsx,js,jsx}'], + parserOptions: { + project: ['./client/tsconfig.json'], + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + }, + ], + settings: { + react: { + version: '17.0', + }, + }, + env: { + es6: true, + browser: true, + node: true, + }, + rules: { + 'func-names': 0, + 'no-shadow': 0, + '@typescript-eslint/no-shadow': 0, + '@typescript-eslint/explicit-function-return-type': 0, + '@typescript-eslint/no-unused-vars': [0, { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-use-before-define': 0, + '@typescript-eslint/ban-ts-ignore': 0, + '@typescript-eslint/no-empty-function': 0, + '@typescript-eslint/ban-ts-comment': 0, + '@typescript-eslint/no-var-requires': 0, + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-this-alias': 0, + '@typescript-eslint/explicit-module-boundary-types': 0, + '@typescript-eslint/ban-types': 0, + 'react-hooks/rules-of-hooks': 2, + 'react-hooks/exhaustive-deps': 2, + 'react/prop-types': 0, + 'react/react-in-jsx-scope': 0, + 'prettier/prettier': 'error', + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + }, + ignorePatterns: ['dist/', 'node_modules', 'scripts'], +}; diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..6d9893d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,56 @@ +--- +name: Bug report +about: Report something that is broken +title: '[Bug] ' +labels: bug +assignees: '' +--- + +**Describe the bug** + +A clear, concise description of what went wrong. + +**Component** + +- [ ] CLI (`reactpress`) +- [ ] Server (API) +- [ ] Admin (web) +- [ ] Toolkit +- [ ] Theme / templates +- [ ] Docs site +- [ ] Other + +**To reproduce** + +1. +2. +3. + +**Expected behavior** + + +**Actual behavior** + + +**Environment** + +| | | +|---|---| +| ReactPress version | | +| Node.js | | +| OS | | +| Setup | | + +**Logs / errors** + +``` +Paste relevant terminal output or stack traces +``` + +**Screenshots** + +If UI-related, attach screenshots or screen recordings. + +**Additional context** + +Database mode (Docker MySQL / external), custom `.env` flags, nginx — only if relevant. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d8a409e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,24 @@ +blank_issues_enabled: false + +contact_links: + - name: Questions & Help + url: https://github.com/fecommunity/reactpress/discussions/categories/q-a + about: Ask setup questions or share tips — prefer Discussions over Issues + - name: ReactPress Docs + url: https://reactpress-docs.vercel.app/ + about: Official ReactPress documentation + - name: Theme starter Q&A + url: https://github.com/fecommunity/reactpress-theme-starter/discussions/categories/q-a + about: Theme development and customization — starter repo Discussions + - name: Browse existing issues + url: https://github.com/fecommunity/reactpress/issues + about: Search open issues before filing a duplicate (CMS, API, CLI, admin, toolkit) + - name: Proposed roadmap issues + url: https://github.com/fecommunity/reactpress/blob/master/docs/proposed-reactpress-upstream-issues.md + about: Curated core and community issues you can pick up + - name: Contributing guide + url: https://github.com/fecommunity/reactpress/blob/master/CONTRIBUTING.md + about: Setup, PR process, and project structure + - name: Security report + url: https://github.com/fecommunity/reactpress/blob/master/SECURITY.md + about: Report vulnerabilities privately — do not use public issues diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..ac179a1a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest a new capability or improvement +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + + + +**Is your feature request related to a problem? Please describe.** + +What pain point does this solve? Who is affected (site owner, theme author, integrator, contributor)? + +**Describe the solution you'd like** + +What should exist when this is done? CLI commands, API, admin UI, docs — be specific if you can. + +**Describe alternatives you've considered** + +Other approaches you rejected and why. + +**Additional context** + +- **Area:** Plugin / Theme / Desktop / Docs / Integration / Other +- **Related epic:** +- Screenshots, sketches, or links — optional diff --git a/.github/ISSUE_TEMPLATE/feedback.md b/.github/ISSUE_TEMPLATE/feedback.md new file mode 100644 index 00000000..afc04516 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feedback.md @@ -0,0 +1,32 @@ +--- +name: Feedback & suggestions +about: Share product feedback, UX ideas, or site-operator suggestions (not a code bug) +title: '[Feedback] ' +labels: question +assignees: '' +--- + +**What is your feedback about?** + +- [ ] Admin UX +- [ ] CLI / developer experience +- [ ] Theme / visitor site +- [ ] Notifications & alerts +- [ ] Documentation +- [ ] Other + +**Current experience** + +What happens today that feels wrong, missing, or confusing? + +**Suggested improvement** + +What would better look like? Examples or references welcome. + +**Who is this for?** + + + +**Additional context** + +Version, screenshot, or workflow — optional. For **security** issues use [SECURITY.md](https://github.com/fecommunity/reactpress/blob/master/SECURITY.md) instead. diff --git a/.github/ISSUE_TEMPLATE/help_wanted.md b/.github/ISSUE_TEMPLATE/help_wanted.md new file mode 100644 index 00000000..eb93f0e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help_wanted.md @@ -0,0 +1,37 @@ +--- +name: Help wanted +about: Claim a community task or propose a small scoped contribution +title: '[Help wanted] ' +labels: help wanted +assignees: '' +--- + + + +**What do you want to work on?** + +Link an existing epic/checkbox or summarize the task in one sentence. + +**Problem (why this helps)** + +Who benefits and what is missing today? + +**Your plan** + +- [ ] Scope of this PR (one checkbox / one example / one event / one doc page) +- [ ] Files or packages you expect to touch +- [ ] Out of scope for this PR + +**Skills** + + + +**Describe alternatives you've considered** + +N/A if you are implementing an agreed task from the roadmap doc. + +**Additional context** + +- **Claiming a roadmap item?** Comment `I'll take: …` on the parent epic before opening the PR. +- **First contribution?** See [CONTRIBUTING.md](https://github.com/fecommunity/reactpress/blob/master/CONTRIBUTING.md) +- **Blocked on design?** Open as draft and ask early. diff --git a/.github/PULL_REQUEST_TEMPLATE/pr.md b/.github/PULL_REQUEST_TEMPLATE/pr.md new file mode 100644 index 00000000..9bcd1146 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pr.md @@ -0,0 +1,46 @@ +# Pull Request Template + +## Description + +📋 Please include a summary of the changes made in this pull request (PR). Provide any context necessary for the reviewer to understand the changes. + +## Related Issue + +🔗 Fixes # (issue number) + +If this PR resolves an issue, please include the issue number prefixed with `#`. + +## Type of Change + +🏷️ Please select the type of change this PR introduces: + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update +- [ ] Refactoring (no functional changes, no api changes) +- [ ] CI/CD related changes +- [ ] Other (please describe) + +## Checklist + +✅ Please tick all the boxes that are completed: + +- [ ] I have tested this code locally and it passes all tests. +- [ ] I have updated the documentation where necessary. +- [ ] All new and existing tests passed. +- [ ] My code follows the style guidelines of this project. +- [ ] I have added tests to cover my changes. +- [ ] All new and existing linting passes. + +## Screenshots (if applicable) + +📷 If this PR includes visual changes, please provide screenshots to help the reviewer understand the changes. + +## Additional Context + +💡 Add any other context or screenshots about the pull request here. This section is optional but can be useful for providing additional clarity. + +--- + +By submitting this pull request, I confirm that my contribution is made under the terms of the [project's license](LICENSE). \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..44047916 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + build-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build docs + run: pnpm run build:docs + env: + DOCS_SITE_URL: ${{ vars.DOCS_SITE_URL || 'https://reactpress-docs.vercel.app' }} + + build-and-smoke: + runs-on: ubuntu-latest + services: + mysql: + # Docker Hub is often slow/unreachable on GHA; use AWS ECR public mirror of official mysql image. + image: public.ecr.aws/docker/library/mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: reactpress + MYSQL_DATABASE: reactpress + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h localhost -uroot -preactpress" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build toolkit + run: pnpm run build:toolkit + + - name: Build server + run: pnpm run build:server + + - name: Create test .env + run: | + cat > .env <<'EOF' + DB_HOST=127.0.0.1 + DB_PORT=3306 + DB_USER=root + DB_PASSWD=reactpress + DB_DATABASE=reactpress + SERVER_PORT=3002 + SERVER_SITE_URL=http://127.0.0.1:3002 + SERVER_API_PREFIX=/api + CLIENT_SITE_URL=http://127.0.0.1:3001 + EOF + + - name: Start API and smoke health + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_USER: root + DB_PASSWD: reactpress + DB_DATABASE: reactpress + SERVER_PORT: 3002 + SERVER_SITE_URL: http://127.0.0.1:3002 + SERVER_API_PREFIX: /api + run: | + node server/dist/main.js & + echo $! > /tmp/reactpress-api.pid + for i in $(seq 1 45); do + if node scripts/smoke-api-health.mjs; then + kill "$(cat /tmp/reactpress-api.pid)" 2>/dev/null || true + exit 0 + fi + sleep 2 + done + kill "$(cat /tmp/reactpress-api.pid)" 2>/dev/null || true + echo "API health smoke failed" + exit 1 + + - name: CLI doctor (offline checks) + run: node cli/bin/reactpress.js doctor || true diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml new file mode 100644 index 00000000..4b8df181 --- /dev/null +++ b/.github/workflows/release-package.yml @@ -0,0 +1,33 @@ +name: Node.js Package + +on: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test + + publish-gpr: + needs: build + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://npm.pkg.github.com/ + - run: npm ci + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0b7e6763 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +node_modules +.DS_Store +.idea +.next +.cursor + +.env.prod +.reactpress/ +*.local +*.cache +*error.log +*debug.log +*.conf +sitemap.xml + +lib +!cli/lib +dist +dist-ssr +coverage +logs +test-results +.pnpm-store +tsconfig.tsbuildinfo +.pnpm-store + + + +# Production (root output only; do not use bare `build` — toolkit/src/theme/build is source) +/build + +# Build outputs (cli/desktop compiles, electron packaging, theme preview, etc.) +out +desktop/release/ +.next-preview/ +playwright-report/ + +# Generated files +.docusaurus +docs/build +.vercel +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.husky/_/husky.sh b/.husky/_/husky.sh new file mode 100644 index 00000000..6809ccca --- /dev/null +++ b/.husky/_/husky.sh @@ -0,0 +1,31 @@ +#!/bin/sh +if [ -z "$husky_skip_init" ]; then + debug () { + if [ "$HUSKY_DEBUG" = "1" ]; then + echo "husky (debug) - $1" + fi + } + + readonly hook_name="$(basename "$0")" + debug "starting $hook_name..." + + if [ "$HUSKY" = "0" ]; then + debug "HUSKY env variable is set to 0, skipping hook" + exit 0 + fi + + if [ -f ~/.huskyrc ]; then + debug "sourcing ~/.huskyrc" + . ~/.huskyrc + fi + + export readonly husky_skip_init=1 + sh -e "$0" "$@" + exitCode="$?" + + if [ $exitCode != 0 ]; then + echo "husky - $hook_name hook exited with code $exitCode (error)" + fi + + exit $exitCode +fi diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..17e9b8a9 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,8 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/scripts/strip-cursor-attribution.sh" + +COMMIT_MSG_FILE=$1 +[ -f "$COMMIT_MSG_FILE" ] || exit 0 +strip_cursor_attribution < "$COMMIT_MSG_FILE" > "${COMMIT_MSG_FILE}.tmp" && + mv "${COMMIT_MSG_FILE}.tmp" "$COMMIT_MSG_FILE" diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 00000000..019b65bf --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,12 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/scripts/strip-cursor-attribution.sh" + +# Cursor may append trailers after commit-msg; amend once if still present. +[ "$CURSOR_STRIP_AMENDING" = "1" ] && exit 0 + +if git log -1 --format=%B | grep -qE '^Co-authored-by:.*[Cc]ursor|^Made-with:.*[Cc]ursor'; then + CURSOR_STRIP_AMENDING=1 + export CURSOR_STRIP_AMENDING + git log -1 --format=%B | strip_cursor_attribution | git commit --amend -F - --no-verify +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..799c304d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +pnpm run precommit diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 00000000..17e9b8a9 --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,8 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/scripts/strip-cursor-attribution.sh" + +COMMIT_MSG_FILE=$1 +[ -f "$COMMIT_MSG_FILE" ] || exit 0 +strip_cursor_attribution < "$COMMIT_MSG_FILE" > "${COMMIT_MSG_FILE}.tmp" && + mv "${COMMIT_MSG_FILE}.tmp" "$COMMIT_MSG_FILE" diff --git a/.husky/scripts/strip-cursor-attribution.sh b/.husky/scripts/strip-cursor-attribution.sh new file mode 100755 index 00000000..2f916c23 --- /dev/null +++ b/.husky/scripts/strip-cursor-attribution.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Remove Cursor attribution trailers; GitHub counts Co-authored-by as contributors. +strip_cursor_attribution() { + sed -e '/^Co-authored-by: Cursor $/d' \ + -e '/^Co-authored-by: Cursor Agent/d' \ + -e '/^Co-authored-by: Cursor =18.20.4 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..bf3b6e6e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +lib +dist +.next +node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..0b0cebb6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "singleQuote": true, + "quoteProps": "consistent", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "always", + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "printWidth": 120, + "endOfLine": "lf" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2c21c325 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,230 @@ +# [3.6.0](https://github.com/fecommunity/reactpress/compare/v3.5.0...v3.6.0) (2026-06-14) + +### Docs CI/CD & Deployment + +* **Docs deployment**: GitHub Actions CI/CD pipeline for documentation site deployment +* **Vercel**: configuration migrated to repository root; documentation links updated to new deployment URL +* **CI**: removed deprecated deploy workflow; MySQL service image switched to AWS ECR public mirror for faster GHA builds +* **Config**: improved `DOCS_SITE_URL` handling in deployment configuration + +# [3.5.0](https://github.com/fecommunity/reactpress/compare/v3.4.0...v3.5.0) (2026-06-14) + +### Theme Catalog + +* **npm theme catalog**: added `theme.catalog.schema.json` and `themes/package.json` for theme discovery and management + +# [3.4.0](https://github.com/fecommunity/reactpress/compare/v3.3.0...v3.4.0) (2026-06-12) + +### Community + +* **Issue templates**: updated bug report, feature request, and config templates for improved clarity and usability + +# [3.3.0](https://github.com/fecommunity/reactpress/compare/v3.2.0...v3.3.0) (2026-06-07) + +### Community & Security + +* **Code of Conduct**: added `CODE_OF_CONDUCT.md` +* **Security Policy**: added `SECURITY.md` with vulnerability reporting guidelines + +# [3.2.0](https://github.com/fecommunity/reactpress/compare/v3.1.1...v3.2.0) (2026-06-07) + +### Theme Development + +* **Next.js config**: added Next.js configuration and node helpers for theme development workflow + +# [3.1.1](https://github.com/fecommunity/reactpress/compare/v3.1.0...v3.1.1) (2026-06-07) + +### Bug Fixes + +* **Theme schema**: corrected `theme.manifest.schema.json` `$id` URL for accuracy + +# [3.1.0](https://github.com/fecommunity/reactpress/compare/v3.0.0...v3.1.0) (2026-06-07) + +### Toolkit Theme Refactor + +* **Toolkit 3.1**: `@fecommunity/reactpress-toolkit` split into `theme` / `ui` / `app` / `plugin` submodules; theme manifest parsing, appearance config (Formily), SSR bootstrap, site settings & preview +* **Export paths**: `package.json` exports add `./theme`, `./ui`, `./app`, `./plugin/*` subpaths for on-demand theme and plugin imports + +### CLI & Operations + +* **CLI 3.0.3**: `reactpress nginx` reverse proxy management; `reactpress db backup` supports Docker `mysqldump`; `reactpress build` target selection & step logging; improved interactive theming and error messages +* **Fix**: enhanced monorepo root detection logic + +### Brand Assets + +* **Unified branding**: `public/brand/`, `public/favicon/`, and `public/icons/` centrally manage logo / favicon / PWA icons +* **Export script**: `pnpm export:brand` syncs assets to server, web, cli, themes, and other directories + +### Docs & Other + +* **README**: added official theme section, Lighthouse performance metrics, and "Why ReactPress?" overview +* **Theme schema**: `theme.manifest.schema.json` `$id` updated to `reactpress-docs.vercel.app` + +# [3.0.0](https://github.com/fecommunity/reactpress/compare/v2.0.2...v3.0.0) (2026-05-17) + +### Platform 3.0 — CLI-first Headless + +* **CLI**: main package renamed to `@fecommunity/reactpress`; `reactpress dev` prints frontend/admin/API/Swagger URLs on ready; `doctor`/`dev` failures include actionable suggestions; CLI i18n (`REACTPRESS_LANG` / `--lang`); `reactpress-cli` bin deprecated +* **Headless**: `GET /api/health`; API Key (`X-API-Key` + `/api/article/headless/list`); Webhooks (`article.published`, `comment.created`, HMAC signature + 3 retries) +* **Content**: scheduled article publishing (`scheduledPublishAt`); article revision history & rollback +* **Config**: root `.env.example`; removed deprecated `.reactpress` config path +* **Ops**: `cli/templates/docker-compose.prod.yml` production example; `scripts/benchmark-cold-start.mjs` cold-start benchmark +* **Breaking**: `@fecommunity/reactpress-server` deprecated; use `@fecommunity/reactpress@3`; see [docs/migration-2-to-3.md](./docs/migration-2-to-3.md) + +# [2.0.0-beta-4-beta.1](https://github.com/fecommunity/reactpress/compare/v2.0.1...v2.0.0-beta-4-beta.1) (2025-11-16) + + +### Features + +* optimize docker development ([e0dfa36](https://github.com/fecommunity/reactpress/commit/e0dfa360e5d754d9ab9c22939e7754907abfe6e5)) +* support docker deploy [to [#26](https://github.com/fecommunity/reactpress/issues/26)] ([9eafb4a](https://github.com/fecommunity/reactpress/commit/9eafb4a093278a717d98bbd52583a5624f7fa30d)) + + + +## [2.0.1](https://github.com/fecommunity/reactpress/compare/v2.0.0...v2.0.1) (2025-09-26) + + +### Bug Fixes + +* correct HttpClient filename case sensitivity ([7dd892a](https://github.com/fecommunity/reactpress/commit/7dd892a8d5b05a3ab24eaf73577848eb25b06450)) + + +### Features + +* add config for toolkit package ([0ed839d](https://github.com/fecommunity/reactpress/commit/0ed839d4667d671ea06b088c0bac5a2890680445)) + + + +# [2.0.0](https://github.com/fecommunity/reactpress/compare/v1.10.0...v2.0.0) (2025-09-21) + + +### Bug Fixes + +* server load issue ([a6f759b](https://github.com/fecommunity/reactpress/commit/a6f759b386e32727501b0eea3ea38f5a89dfe700)) +* type defs ([d6491d5](https://github.com/fecommunity/reactpress/commit/d6491d56f2ffdd19d5a47fda7273958cd4243fb3)) + + +### Features + +* add hello-world template ([7e2c948](https://github.com/fecommunity/reactpress/commit/7e2c9487ddc6023d7b382250b131fbe828013680)) +* add reactpress toolkit ([58f9312](https://github.com/fecommunity/reactpress/commit/58f9312644736aceb362e517fad8c3b3a83f275f)) +* add swagger v2 ui ([ef9fdc1](https://github.com/fecommunity/reactpress/commit/ef9fdc166955b4659c81fb559138ce38ef599cfe)) +* add twentytwentyfive theme ([715281f](https://github.com/fecommunity/reactpress/commit/715281fedcf8072348e4b8b6794891c7e67e1f99)) +* support npx install server ([e7f7b97](https://github.com/fecommunity/reactpress/commit/e7f7b970bb4dd8b845fcd8dde4048678a403557a)) +* support quick install ([96c1d0a](https://github.com/fecommunity/reactpress/commit/96c1d0a7cc1c72b7f6c489ba236ab6eb78472dee)) +* support quick install ([793bca7](https://github.com/fecommunity/reactpress/commit/793bca79f2bfa921f75bc1cc5f234207aa0ab40a)) + + + +# [1.10.0](https://github.com/fecommunity/reactpress/compare/v1.9.0...v1.10.0) (2025-08-03) + + +### Features + +* add type defs for config ([d8a6fed](https://github.com/fecommunity/reactpress/commit/d8a6fed7bc13f74be0916f80497590c7e737fb86)) + + + +# [1.9.0](https://github.com/fecommunity/reactpress/compare/v1.8.0...v1.9.0) (2025-05-21) + + + +# [1.8.0](https://github.com/fecommunity/reactpress/compare/v1.7.0...v1.8.0) (2025-03-22) + + +### Features + +* upgrade next version ([64cac4d](https://github.com/fecommunity/reactpress/commit/64cac4dcb9268a6bbb14fbbfe6995406638f7508)) + + + +# [1.7.0](https://github.com/fecommunity/reactpress/compare/v1.6.0...v1.7.0) (2025-03-07) + + +### Features + +* add reactpress docs ([002972b](https://github.com/fecommunity/reactpress/commit/002972b6194d13917d60de8dae019445739760cc)) +* release reactpress v1.6.0 ([9d75118](https://github.com/fecommunity/reactpress/commit/9d75118b7e19d85ab95b3e6f227f1b95cd95acb8)) + + + +# [1.6.0](https://github.com/fecommunity/reactpress/compare/v1.5.0...v1.6.0) (2024-12-21) + + +### Features + +* knowledge page perfect ([#16](https://github.com/fecommunity/reactpress/issues/16)) ([b9ae27d](https://github.com/fecommunity/reactpress/commit/b9ae27d087b3b451668b3c0acb40359b20a089e2)) + + + +# [1.5.0](https://github.com/fecommunity/reactpress/compare/v1.4.0...v1.5.0) (2024-12-21) + + +### Bug Fixes + +* adapt small screen content display issue ([84895c8](https://github.com/fecommunity/reactpress/commit/84895c8341d34285068806d5908b2af358719f80)) +* add key for tex loop item ([e8a2e36](https://github.com/fecommunity/reactpress/commit/e8a2e36f13d5031d845c3c3055374e5ff0446bc2)) +* nav config build error ([66acf46](https://github.com/fecommunity/reactpress/commit/66acf46485f902aa0647b4886beb0701372b95c2)) +* nav query id undefined ([2d0bbd7](https://github.com/fecommunity/reactpress/commit/2d0bbd78f4d73579039b3a9bf7c846fe2d976073)) +* route level default value ([4aae64e](https://github.com/fecommunity/reactpress/commit/4aae64e86346052cb51e63e7c149853a6d8d8ae6)) + + +### Features + +* add animation tags cloud ([68b1a5b](https://github.com/fecommunity/reactpress/commit/68b1a5b2fe73fafd7a9b282ed77f836aa7a76bf6)) +* add global config setting page ([c825425](https://github.com/fecommunity/reactpress/commit/c82542540b20f423b3b3f3ba04ea5e48e2523f5c)) +* add nav page ([c6703c6](https://github.com/fecommunity/reactpress/commit/c6703c6e3dea33a8cc226f1c02d9b5cac0ef827e)) +* optimized page interaction experience ([b0ac19a](https://github.com/fecommunity/reactpress/commit/b0ac19ab31b6bcf0d8916aeb373e6fa7288303aa)) + + + +# [1.4.0](https://github.com/fecommunity/reactpress/compare/v1.3.0...v1.4.0) (2024-12-08) + + + +# [1.3.0](https://github.com/fecommunity/reactpress/compare/v1.2.0...v1.3.0) (2024-12-01) + + +### Features + +* add system systemSubTitle setting config ([136f012](https://github.com/fecommunity/reactpress/commit/136f01288cb9714092a2aa0dc01421817bb26b7d)) +* optimized the css style loading experience for homepage ([0e7761f](https://github.com/fecommunity/reactpress/commit/0e7761fd28c1cac099ac15ddaa644a904aca8da4)) +* react helmet seo perfect ([848a5da](https://github.com/fecommunity/reactpress/commit/848a5da2a60cbd4c0684d6607ad9df6eeaa2dd7b)) + + + +# [1.2.0](https://github.com/fecommunity/reactpress/compare/v1.1.0...v1.2.0) (2024-11-23) + + +### Bug Fixes + +* system notice info empty error ([4e41daa](https://github.com/fecommunity/reactpress/commit/4e41daa14dc96499e6d65ebc2648f87147fc248d)) +* update admin view article link ([41656a7](https://github.com/fecommunity/reactpress/commit/41656a740d301cda7ec54bb6ceaaa8e8cea7c222)) + + +### Features + +* add category tag for article list ([f5068a1](https://github.com/fecommunity/reactpress/commit/f5068a17b4728a47330fbbbca0f236d12e299e40)) +* support system notification feature ([515e556](https://github.com/fecommunity/reactpress/commit/515e556d7b63192cbad4bda68d5ecf639cbaa96b)) + + + +# [1.1.0](https://github.com/fecommunity/reactpress/compare/v1.0.0...v1.1.0) (2024-11-02) + + + +# [1.0.0](https://github.com/fecommunity/reactpress/compare/a6b73a189090e0199cc6f803bfb498cdeb7868a5...v1.0.0) (2024-09-28) + + +### Bug Fixes + +* add ignoreValidator for system creating user ([83408d2](https://github.com/fecommunity/reactpress/commit/83408d20383a6a57546dacfcd7751210c6c66d4c)) +* next build type defs ([ec428b3](https://github.com/fecommunity/reactpress/commit/ec428b3cfe6950a368e3aeb7bf9945cf81a1f481)) + + +### Features + +* init easy-blog project ([a6b73a1](https://github.com/fecommunity/reactpress/commit/a6b73a189090e0199cc6f803bfb498cdeb7868a5)) + + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..5c05df42 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[https://github.com/fecommunity/reactpress/issues/new](https://github.com/fecommunity/reactpress/issues/new). +Please use a descriptive title (for example, "Code of Conduct report") and +include as much detail as possible. All complaints will be reviewed and +investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..852d21a0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,139 @@ +# Contributing to ReactPress + +Thank you for your interest in contributing to ReactPress! + +Please read this guide before opening a pull request. By participating, you +agree to abide by our [Code of Conduct](./CODE_OF_CONDUCT.md). + +## Ways to Contribute + +| Type | How | +| :--- | :-- | +| **Bug reports** | [Bug report](https://github.com/fecommunity/reactpress/issues/new?template=bug_report.md) — steps, component, versions, logs | +| **Feature ideas** | [Feature request](https://github.com/fecommunity/reactpress/issues/new?template=feature_request.md) — problem, solution, area | +| **Community tasks** | [Help wanted](https://github.com/fecommunity/reactpress/issues/new?template=help_wanted.md) — claim a scoped item from [proposed issues](./docs/proposed-reactpress-upstream-issues.md) | +| **Product feedback** | [Feedback & suggestions](https://github.com/fecommunity/reactpress/issues/new?template=feedback.md) — UX and operator ideas (not security) | +| **Code & docs** | Fork, branch, submit a PR (see below) | +| **Security issues** | Follow [SECURITY.md](./SECURITY.md) — do **not** use public issues | + +## Development Setup + +### Prerequisites + +- Node.js >= 18.0.0 +- pnpm >= 8.0.0 +- MySQL 5.7+ (or Docker via `pnpm run init` / `pnpm docker:dev`) + +### First run + +```bash +git clone https://github.com/fecommunity/reactpress.git +cd reactpress +pnpm install +pnpm run init # .reactpress/config.json + .env +pnpm run dev # toolkit → API (3002) → client (3001) +``` + +Run `pnpm test` and `pnpm test:smoke` before submitting changes that touch the CLI or API. + +## Project Structure + +``` +reactpress/ +├── cli/ # @fecommunity/reactpress — init, dev, build, doctor +├── server/ # NestJS API (primary backend) +├── client/ # Next.js admin & public frontend +├── toolkit/ # OpenAPI-generated API SDK + theme utilities +├── themes/ # Classic theme manifests & reference themes +├── templates/ # Starter project templates +├── docs/ # Docusaurus documentation site +├── scripts/ # Dev, deploy, and lifecycle scripts +└── .reactpress/ # Local CLI config (generated) +``` + +## Development Workflow + +| Task | Command | +|------|---------| +| Full stack dev | `pnpm dev` | +| API only (watch) | `pnpm dev:api` or `pnpm dev:server` | +| Client only | `pnpm dev:client` | +| Docker MySQL + proxy | `pnpm docker:dev` | +| Regenerate API types | `pnpm run build:toolkit` | +| Swagger spec | `pnpm run generate:swagger` | +| API lifecycle | `pnpm run start:api` / `stop` / `restart` / `status` | + +`pnpm dev` builds toolkit first, waits for API health, then starts the client. + +After API changes: `pnpm run generate:swagger` → `pnpm run build:toolkit`. + +## Building + +```bash +pnpm run build # toolkit + server + client +pnpm run build:server # Nest only +pnpm run build:client # Next.js only +pnpm run build:docs # Docusaurus site +``` + +## Pull Request Process + +1. **Fork** the repository and create a feature branch from `master`. +2. **Make focused changes** — one logical change per PR when possible. +3. **Follow conventions** (see below). +4. **Test locally** — at minimum `pnpm test` for CLI changes and manual smoke for UI/API. +5. **Update docs** if behavior, CLI flags, or configuration change. +6. **Open a PR** using the [pull request template](.github/PULL_REQUEST_TEMPLATE/pr.md). + +We review PRs as promptly as we can. Larger changes benefit from an issue discussion first. + +## Coding Conventions + +- **Language:** TypeScript for application code; match existing patterns in each package. +- **Formatting:** Prettier via `lint-staged` on commit (`pnpm precommit`). +- **Commit messages:** [Conventional Commits](https://www.conventionalcommits.org/) style: + - `feat:` new feature + - `fix:` bug fix + - `docs:` documentation only + - `refactor:` code change without behavior change + - `chore:` tooling, deps, CI +- **Scope:** Prefer package-scoped changes (`cli`, `server`, `client`, `toolkit`). + +## Production & Publishing + +```bash +pnpm run build +pnpm run pm2 # PM2 for API + client +# or +sh scripts/deploy.sh +``` + +Maintainers only: + +```bash +pnpm login +pnpm run publish:packages +``` + +Published packages: root meta, **server**, **client**, **toolkit**, **templates**. +`@fecommunity/reactpress` is the CLI entry (`init`, `dev`, Docker database helpers). + +## Architecture & Documentation + +| Topic | Reference | +| :---- | :-------- | +| Platform overview | [docs/tutorial/intro.md](./docs/tutorial/intro.md) | +| ReactPress 3.0 | [docs/tutorial/tutorial-extras/reactpress-3-0.md](./docs/tutorial/tutorial-extras/reactpress-3-0.md) | +| Upgrade from 2.x | [docs/migration-2-to-3.md](./docs/migration-2-to-3.md) | +| Configuration | [docs/tutorial/tutorial-extras/config-intro.md](./docs/tutorial/tutorial-extras/config-intro.md) | +| Theme manifest schema | [themes/theme.manifest.schema.json](./themes/theme.manifest.schema.json) | +| Changelog | [CHANGELOG.md](./CHANGELOG.md) | + +Live docs: [blog.gaoredu.com](https://blog.gaoredu.com) + +## Questions? + +- [GitHub Discussions](https://github.com/fecommunity/reactpress/discussions) (if enabled) or Issues for questions +- [中文文档](./README-zh_CN.md) for deployment and monorepo details in Chinese + +Thank you for helping make ReactPress better! diff --git a/LICENSE b/LICENSE index 261eeb9e..c11d4421 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2024–2026 fecommunity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 00000000..6a7e29da --- /dev/null +++ b/README-zh_CN.md @@ -0,0 +1,258 @@ +
+ + ReactPress 标志 + + +

+ 快速、流畅、轻松 — 约一分钟即可上线的全栈发布平台。
+ 一条 CLI · 全栈 CMS · Headless 主题 · 面向生产环境部署 +

+ + + ReactPress 官方主题 — 在线演示 + + +

+ 主题在线演示 + · + 全栈演示 + · + 官方主题 +

+ +[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/fecommunity/reactpress/blob/master/LICENSE) +[![NPM Version](https://img.shields.io/npm/v/@fecommunity/reactpress.svg?style=flat-square)](https://www.npmjs.com/package/@fecommunity/reactpress) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/fecommunity/reactpress/pulls) +[![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-95%20Performance-0cce6b?style=flat-square&logo=lighthouse&logoColor=white)](https://reactpress-theme-starter.vercel.app) +[![Lighthouse SEO](https://img.shields.io/badge/SEO-100-0cce6b?style=flat-square&logo=google&logoColor=white)](https://reactpress-theme-starter.vercel.app) + +

+ 报告错误 + · + 请求功能 + · + 文档 + · + English Documentation +

+ +

如果这个项目对你有帮助,欢迎点个 ⭐ Star,让更多人发现它。

+
+ +--- + +## 目录 + +- [ReactPress 是什么?](#reactpress-是什么) +- [为什么选 ReactPress?](#为什么选-reactpress) +- [怎么用?](#怎么用) +- [贡献](#贡献) + +--- + +## ReactPress 是什么? + +**ReactPress 是以 CMS 后端、管理后台和可选前台为核心的发布平台** — 安装 CLI 即可运行 API、在后台管理内容,并按需接入访客站主题。 + +| 组件 | 作用 | +| :--- | :--- | +| **CMS 后端(API)** | 存储并提供文章、页面、媒体、分类与站点设置 | +| **管理后台** | 写作与内容管理的 Web 界面(全栈部署中包含) | +| **[官方主题](https://github.com/fecommunity/reactpress-theme-starter)** | 推荐访客站 — 搜索、知识库、评论、深色模式 | +| **CLI(`reactpress`)** | 初始化、本地运行、构建与部署 | + +### 能做什么 + +- **发布内容** — 文章、页面、定时发布、分类与标签 +- **管理媒体** — 上传图片与文件,在内容中复用 +- **定制站点** — 标题、Logo、导航、外观,均在后台完成 +- **选择前台** — 使用官方主题,或通过 API 接入自定义前端 +- **即时预览主题** — 在主题仓库以示例数据预览,无需启动后端 + +> **一处创作,多处发布。** 在后台创建内容;在 Web 或任意已连接的前端展示。 + +
+ +ReactPress CLI 交互式菜单 + +
+ +--- + +## 为什么选 ReactPress? + +### 为什么要用? + +大多数发布工具都在逼你二选一:要么 **CMS 好用但前台慢或绑定紧**,要么 **静态站很快但没有像样的编辑器**。ReactPress 的设计目标,是减轻这种取舍。 + +| 你的需求 | ReactPress 提供 | +| :--- | :--- | +| **快速启动** | 全局安装一次;`init` + `dev` 约 1 分钟拉起 CMS 后端¹ | +| **熟悉的写作方式** | Web 后台管理文章、页面、媒体与分类 | +| **访客体验好的站点** | 官方主题:快速加载、搜索、评论、知识库、深色模式 | +| **可持续扩展** | Headless API — 可更换或定制前台,无需迁移内容 | +| **组件更少、路径更清晰** | 核心发布能力内置;官方主题演示 Lighthouse **95 / 100 / 100 / 100**² | + +**一句话:** WordPress 式内容工作流 + 现代化访客站 — 在性能与前台灵活性上路径更直接。 + +¹ 需已安装 Node.js 与 Docker;首次拉取 Docker 镜像可能更久。 +² 基于[官方主题在线演示](https://reactpress-theme-starter.vercel.app)实测;实际上线得分取决于托管与内容。 + +
+ + + Lighthouse 评分:性能 95、无障碍 100、最佳实践 100、SEO 100 + + +
+ +### 与竞品有何不同 + +| | **ReactPress** | WordPress | Ghost | 静态站点(Hugo、Hexo 等) | +| :--- | :--- | :--- | :--- | :--- | +| **首次跑通环境** | **`init` + `dev`,约 1 分钟**¹ | 服务器、PHP、主题与插件 | 托管或自建,步骤较多 | 每站独立仓库与构建流程 | +| **内容编辑** | **Web 后台** | Web 后台 | Web 后台 | Git 中的 Markdown / MDX | +| **前台速度与 SEO** | **Lighthouse 95/100/100/100**(官方主题演示)² | 因主题与插件差异大 | 通常较好 | 优秀,但无内置 CMS | +| **前端灵活性** | **Headless — 可对接或替换主题** | 主题/插件生态强,耦合度高 | 与 Ghost 主题体系绑定 | 构建时固定 | +| **发布相关内置能力** | **搜索、评论、知识库**(官方主题 + API) | 常靠插件扩展 | 侧重会员/通讯 | 需自行实现 | +| **更适合** | **博客、内容站、定制发布** | 通用网站 | 通讯与出版业务 | 文档站、开发者博客 | + +**对比 WordPress** — 同样是后台驱动发布,但默认现代化前台路径更短,减少对插件堆叠的依赖。 + +**对比 Ghost** — 都面向博客与内容发布。ReactPress 侧重 CLI 优先、Headless 架构与可替换的 Next.js 主题;Ghost 侧重一体化出版与会员能力。 + +**对比静态站点生成器** — 在保留性能与 SEO 潜力的同时,补上 CMS,编辑者无需通过 Git 发布。 + +**适合谁?** 博主、独立创作者、需要内容中心的团队,以及希望快速获得可生产部署能力、而非花一周做集成的人。 + +¹ 依赖环境就绪;首次 Docker 拉取可能更久。 +² 官方主题演示:[reactpress-theme-starter.vercel.app](https://reactpress-theme-starter.vercel.app)。 + +--- + +## 怎么用? + +### 1. 启动 CMS 后端 + +**环境要求:** Node.js 18+ · 推荐 Docker(用于内置 MySQL) + +```bash +npm i -g @fecommunity/reactpress@3 +mkdir my-blog && cd my-blog +reactpress init +reactpress dev +``` + +CLI 会启动 **CMS API**,并在就绪后打印访问地址: + +| 服务 | 典型地址 | +| :--- | :--- | +| API | `http://localhost:3002/api` | +| API 文档(Swagger) | `http://localhost:3002/api` | +| 健康检查 | `http://localhost:3002/api/health` | + +随时运行 `reactpress` 可打开交互菜单。启动失败时请使用 `reactpress doctor`。 + +> 在新项目目录中,`reactpress dev` 会先启动 API。访客站请接入[官方主题](#3-接入访客站);含管理后台的全栈部署见[文档](https://reactpress-docs.vercel.app/)。 + +### 2. 预览官方主题(无需后端) + +无需安装 ReactPress,即可用示例数据预览主题界面: + +```bash +npx create-next-app@latest my-blog --example "https://github.com/fecommunity/reactpress-theme-starter" --use-pnpm +cd my-blog +pnpm dev:mock +``` + +打开 **http://localhost:3001** — 与 [在线演示](https://reactpress-theme-starter.vercel.app) 相同。 + +[![使用 Vercel 部署主题](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress-theme-starter) + +### 3. 接入访客站 + +当主题需要展示 **CMS 中的真实内容** 时: + +1. 保持 ReactPress API 运行(`reactpress init` → `reactpress dev`,或 `reactpress dev --api-only`)。 +2. 克隆 [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) 并执行 `pnpm install`。 +3. 复制 `.env.example` 为 `.env`,然后运行 `pnpm dev`。 + +**http://localhost:3001** 为访客站。在全栈部署中,可在 ReactPress 管理后台调整颜色、Logo 与导航。 + +完整说明:[主题 README](https://github.com/fecommunity/reactpress-theme-starter/blob/master/README_zh.md)。 + +### 4. 演示 + +
+ +![ReactPress CLI 使用演示 — 从安装到上线](./public/usage.gif) + +
+ +| 管理后台 | 在线站点 | +| :------: | :------: | +| [![管理仪表板](./public/admin.png)](https://blog.gaoredu.com) | [![演示站点](./public/demo.png)](https://blog.gaoredu.com) | + +[全栈演示](https://blog.gaoredu.com) · [主题演示](https://reactpress-theme-starter.vercel.app) + +### 5. 常用命令 + +| 命令 | 作用 | +| :--- | :--- | +| `reactpress` | 打开交互式菜单 | +| `reactpress init` | 初始化新站点 | +| `reactpress dev` | 本地运行 CMS API(访客站需接入主题) | +| `reactpress build` | 生产环境构建 | +| `reactpress start` | 生产环境启动 | +| `reactpress doctor` | 诊断环境问题 | +| `reactpress status` | 查看运行状态 | + +更多选项见 [官方文档](https://reactpress-docs.vercel.app/)。 + +### 6. 部署上线 + +```bash +npm i -g @fecommunity/reactpress@3 +reactpress build +reactpress start +``` + +Docker、PM2、备份等进阶部署,见 [完整文档](https://reactpress-docs.vercel.app/)。 + +若只需部署访客站,可使用 [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) 并指向你的 ReactPress API。 + +--- + +## 贡献 + +**衷心感谢**每一位帮助 ReactPress 成长的朋友。 + +[贡献指南](./CONTRIBUTING.md) · [行为准则](./CODE_OF_CONDUCT.md) · [安全策略](./SECURITY.md) + + + + + + + + + + + + + + + + + + + + + +
fecommunity
FECommunity
want2sleeep
SleepSheep
fantasticit
fantasticit
chenbo29
chenbo29
redteav2
redteav2
trashken
trashken
franz007
franz007
funtime1
funtime1
scottdeift
scottdeift
TwoDollars666
TwoDollars666
Xiaonan2020
Xiaonan2020
gaoredu
redtea
m0_37981569
m0_37981569
+ +--- + +MIT License · © ReactPress / FECommunity + +[![Star History Chart](https://api.star-history.com/svg?repos=fecommunity/reactpress&type=Date)](https://star-history.com/#fecommunity/reactpress&Date) diff --git a/README.md b/README.md new file mode 100644 index 00000000..f1422d96 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +
+ + ReactPress Logo + + +

+ Fast, smooth, and effortless publishing — live in about a minute.
+ One CLI · Full-stack CMS · Headless themes · Built for production deployment. +

+ + + ReactPress official theme — live demo + + +

+ Theme live demo + · + Full stack demo + · + Theme Starter +

+ +[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/fecommunity/reactpress/blob/master/LICENSE) +[![NPM Version](https://img.shields.io/npm/v/@fecommunity/reactpress.svg?style=flat-square)](https://www.npmjs.com/package/@fecommunity/reactpress) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/fecommunity/reactpress/pulls) +[![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-95%20Performance-0cce6b?style=flat-square&logo=lighthouse&logoColor=white)](https://reactpress-theme-starter.vercel.app) +[![Lighthouse SEO](https://img.shields.io/badge/SEO-100-0cce6b?style=flat-square&logo=google&logoColor=white)](https://reactpress-theme-starter.vercel.app) + +

+ Report Bug + · + Request Feature + · + Documentation + · + 中文文档 +

+ +

If this project helps you, a ⭐ on GitHub helps others discover it.

+
+ +--- + +## Table of contents + +- [What is ReactPress?](#what-is-reactpress) +- [Why ReactPress?](#why-reactpress) +- [How to use](#how-to-use) +- [Contributing](#contributing) + +--- + +## What is ReactPress? + +**ReactPress is a publishing platform built around a CMS backend, an admin console, and optional frontends** — install one CLI package to run the API, manage content in the admin, and connect a public theme when you are ready. + +| Component | Role | +| :-------- | :--- | +| **CMS backend (API)** | Stores and serves posts, pages, media, categories, and settings | +| **Admin console** | Web UI for writing and managing content (included in full-stack setups) | +| **[Official theme](https://github.com/fecommunity/reactpress-theme-starter)** | Recommended public site — search, knowledge base, comments, dark mode | +| **CLI (`reactpress`)** | Initialize, run locally, build, and deploy | + +### What you can do + +- **Publish** — posts, pages, scheduled publishing, categories, and tags +- **Manage media** — upload images and files, reuse across content +- **Customize the site** — title, logo, navigation, and appearance from the admin +- **Choose your frontend** — use the official theme or connect a custom one via the API +- **Preview the theme instantly** — sample-data mode in the theme repo, no backend required + +> **Write once, publish everywhere.** Create content in the admin; render it on the web or through any connected frontend. + +
+ +ReactPress CLI interactive menu + +
+ +--- + +## Why ReactPress? + +### Why use it? + +Most publishing tools force a trade-off: either **an easy CMS with a slow or tightly coupled frontend**, or **a fast static site without a proper editor**. ReactPress is designed to reduce that trade-off. + +| What you need | What ReactPress gives you | +| :------------ | :------------------------ | +| **Start quickly** | One global install; `init` + `dev` brings up the CMS backend in about a minute¹ | +| **Write in a familiar way** | Web admin for posts, pages, media, and categories | +| **A site visitors enjoy** | Official theme: fast pages, search, comments, knowledge base, dark mode | +| **Room to grow** | Headless API — swap or customize the public frontend without migrating content | +| **Fewer moving parts** | Core publishing features without assembling plugins; official theme demo scores Lighthouse **95 / 100 / 100 / 100**² | + +**In one line:** WordPress-style content workflow + a modern public site — with a clearer path to performance and frontend flexibility. + +¹ After Node.js and Docker are available; the first Docker image pull may take longer. +² Measured on the [official theme live demo](https://reactpress-theme-starter.vercel.app); your scores depend on hosting and content. + +
+ + + Lighthouse scores: Performance 95, Accessibility 100, Best Practices 100, SEO 100 + + +
+ +### How it compares + +| | **ReactPress** | WordPress | Ghost | Static site (Hugo, Hexo, etc.) | +| :-- | :-- | :-- | :-- | :-- | +| **Time to first running stack** | **`init` + `dev` — about 1 minute**¹ | Server, PHP, themes, and plugins | Managed hosting or self-hosted install | New repo and build pipeline per site | +| **Content editing** | **Web admin** | Web admin | Web admin | Markdown or MDX in Git | +| **Public site speed & SEO** | **Lighthouse 95/100/100/100** (official theme demo)² | Varies widely by theme and plugins | Generally strong | Excellent, but no built-in CMS | +| **Frontend flexibility** | **Headless — connect or replace the theme** | Strong theme/plugin ecosystem, often coupled | Theme system tied to Ghost | Fixed at build time | +| **Built-in publishing extras** | **Search, comments, knowledge base** (official theme + API) | Often via plugins | Membership/newsletter focus | Build yourself | +| **Best for** | **Blogs, content sites, custom publishing** | General-purpose websites | Newsletters and publishing businesses | Docs and developer blogs | + +**vs WordPress** — Similar admin-driven publishing, with a faster default public theme path and less reliance on plugins for a modern frontend. + +**vs Ghost** — Both target blogging and content publishing. ReactPress emphasizes a CLI-first, headless setup and a swappable Next.js theme; Ghost emphasizes its integrated publishing and membership stack. + +**vs static generators** — Keep strong performance and SEO potential, while adding a CMS so editors can publish without working in Git. + +**Who is it for?** Bloggers, indie creators, teams that need a content hub, and anyone who wants a production-capable stack without a week of integration work. + +¹ After dependencies are installed; first Docker pull may take longer. +² Official theme demo at [reactpress-theme-starter.vercel.app](https://reactpress-theme-starter.vercel.app). + +--- + +## How to use + +### 1. Start the CMS backend + +**Requirements:** Node.js 18+ · Docker recommended (for the bundled MySQL) + +```bash +npm i -g @fecommunity/reactpress@3 +mkdir my-blog && cd my-blog +reactpress init +reactpress dev +``` + +The CLI starts the **CMS API** and prints the URLs when ready: + +| Service | Typical URL | +| :------ | :------------ | +| API | `http://localhost:3002/api` | +| API docs (Swagger) | `http://localhost:3002/api` | +| Health check | `http://localhost:3002/api/health` | + +Run `reactpress` anytime for the interactive menu. Use `reactpress doctor` if startup fails. + +> In a new project directory, `reactpress dev` starts the API first. Connect the [official theme](#3-connect-the-public-site) for the visitor-facing site, or see the [docs](https://reactpress-docs.vercel.app/) for a full-stack setup with the admin console. + +### 2. Preview the official theme (no backend) + +Preview the theme UI with sample content — no ReactPress install required: + +```bash +npx create-next-app@latest my-blog --example "https://github.com/fecommunity/reactpress-theme-starter" --use-pnpm +cd my-blog +pnpm dev:mock +``` + +Open **http://localhost:3001** — same as the [live demo](https://reactpress-theme-starter.vercel.app). + +[![Deploy theme with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress-theme-starter) + +### 3. Connect the public site + +When the theme should show **your** content from the CMS: + +1. Keep the ReactPress API running (`reactpress init` → `reactpress dev`, or `reactpress dev --api-only`). +2. Clone [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) and run `pnpm install`. +3. Copy `.env.example` to `.env`, then run `pnpm dev`. + +Open **http://localhost:3001** for the public site. Customize colors, logo, and navigation in the ReactPress admin when running a full-stack deployment. + +Full guide: [theme starter README](https://github.com/fecommunity/reactpress-theme-starter#readme). + +### 4. See it in action + +
+ +![ReactPress CLI demo — from install to live site](./public/usage.gif) + +
+ +| Admin console | Live site | +| :-----------: | :-------: | +| [![Admin dashboard](./public/admin.png)](https://blog.gaoredu.com) | [![Demo site](./public/demo.png)](https://blog.gaoredu.com) | + +[Full stack demo](https://blog.gaoredu.com) · [Theme demo](https://reactpress-theme-starter.vercel.app) + +### 5. Everyday commands + +| Command | What it does | +| :------ | :----------- | +| `reactpress` | Open the interactive menu | +| `reactpress init` | Set up a new site | +| `reactpress dev` | Run the CMS API locally (add a theme for the public site) | +| `reactpress build` | Prepare for production | +| `reactpress start` | Run in production | +| `reactpress doctor` | Diagnose setup issues | +| `reactpress status` | See what is running | + +More options: [documentation](https://reactpress-docs.vercel.app/) + +### 6. Deploy to production + +```bash +npm i -g @fecommunity/reactpress@3 +reactpress build +reactpress start +``` + +For Docker, PM2, backups, and advanced setup, see the [full documentation](https://reactpress-docs.vercel.app/). + +To deploy only the public theme, use [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) and point it at your ReactPress API. + +--- + +## Contributing + +**Thank you** to everyone who has helped shape ReactPress. + +[Contributing Guide](./CONTRIBUTING.md) · [Code of Conduct](./CODE_OF_CONDUCT.md) · [Security Policy](./SECURITY.md) + + + + + + + + + + + + + + + + + + + + + +
fecommunity
FECommunity
want2sleeep
SleepSheep
fantasticit
fantasticit
chenbo29
chenbo29
redteav2
redteav2
trashken
trashken
franz007
franz007
funtime1
funtime1
scottdeift
scottdeift
TwoDollars666
TwoDollars666
Xiaonan2020
Xiaonan2020
gaoredu
redtea
m0_37981569
m0_37981569
+ +--- + +MIT License · © ReactPress / FECommunity + +[![Star History Chart](https://api.star-history.com/svg?repos=fecommunity/reactpress&type=Date)](https://star-history.com/#fecommunity/reactpress&Date) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..411af86b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,66 @@ +# Security Policy + +## Supported Versions + +We release security fixes for the actively maintained ReactPress 3.x line. +Older major versions (2.x and below) no longer receive security updates unless +noted in a release announcement. + +| Version | Supported | +| :------ | :----------------- | +| 3.x | :white_check_mark: | +| 2.x | :x: | +| < 2.0 | :x: | + +Install the latest stable release: + +```bash +npm i -g @fecommunity/reactpress@latest +``` + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub Issues.** + +If you discover a security issue, report it privately so we can investigate and +ship a fix before details are public: + +1. **Preferred:** [Open a private security advisory](https://github.com/fecommunity/reactpress/security/advisories/new) + on GitHub (Repository → **Security** → **Report a vulnerability**). +2. **Alternative:** Email the maintainers via a GitHub issue titled + `Security report (private details)` and ask to move the conversation to a + private channel — do **not** include exploit steps or sensitive data in the + initial public comment. + +Include as much of the following as you can: + +- Affected component (CLI, API/server, client, toolkit, theme) +- ReactPress / npm package version +- Steps to reproduce (proof of concept if available) +- Impact assessment (data exposure, privilege escalation, RCE, etc.) +- Suggested remediation, if you have one + +## What to Expect + +| Timeline | Action | +| :------- | :----- | +| **Within 72 hours** | Acknowledgement of your report | +| **Within 7 days** | Initial assessment and severity classification | +| **Ongoing** | Status updates until resolved | +| **After fix** | Coordinated disclosure and credit in release notes (if desired) | + +We appreciate responsible disclosure. Valid reports may be credited in +[CHANGELOG.md](./CHANGELOG.md) unless you prefer to remain anonymous. + +## Security Best Practices for Deployments + +When self-hosting ReactPress: + +- Keep Node.js, dependencies, and `@fecommunity/reactpress` up to date +- Use strong database credentials; do not commit `.env` files +- Terminate TLS at your reverse proxy (see `reactpress nginx` in the CLI) +- Restrict admin/API access in production networks where possible +- Rotate API keys and webhook secrets periodically + +For general bugs and feature requests (non-security), use +[GitHub Issues](https://github.com/fecommunity/reactpress/issues). diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..269cb6b6 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FECommunity + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..fdaf2ace --- /dev/null +++ b/cli/README.md @@ -0,0 +1,47 @@ +# @fecommunity/reactpress-cli + +零配置一键初始化与管理 ReactPress CMS & 博客服务器。内置 NestJS 服务端,无需单独克隆 [fecommunity/reactpress](https://github.com/fecommunity/reactpress)。 + +完整文档与贡献指南见:[github.com/fecommunity/reactpress-cli](https://github.com/fecommunity/reactpress-cli) + +## 安装 + +```bash +npm install -g @fecommunity/reactpress-cli +``` + +全局命令为 `reactpress-cli`(与 npm 包名无关)。 + +> npm 上的无作用域包名 `reactpress-cli` 已被占用,本包发布为 `@fecommunity/reactpress-cli`。 + +## 快速开始 + +```bash +mkdir my-blog && cd my-blog +reactpress-cli init +reactpress-cli start +``` + +浏览器访问 `http://localhost:3002`(API 文档:`/api`)。 + +## 常用命令 + +| 命令 | 说明 | +|------|------| +| `reactpress-cli init [dir]` | 初始化项目 | +| `reactpress-cli start` | 启动服务(自动准备数据库) | +| `reactpress-cli stop` | 停止服务 | +| `reactpress-cli restart` | 重启服务 | +| `reactpress-cli status` | 查看状态 | +| `reactpress-cli config [key] [value]` | 查看/修改配置 | +| `reactpress-cli config server.port 3003 --apply` | 改端口并重启 | + +## 要求 + +- Node.js 18+ +- macOS / Linux / Windows +- 默认使用 Docker 运行嵌入式 MySQL;也可在 `.reactpress/config.json` 中配置外部数据库 + +## 许可证 + +MIT © FECommunity diff --git a/cli/bin/reactpress-cli-shim.js b/cli/bin/reactpress-cli-shim.js new file mode 100755 index 00000000..bf2dc2b0 --- /dev/null +++ b/cli/bin/reactpress-cli-shim.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +/** + * @deprecated 3.0 起请使用 `reactpress`(@fecommunity/reactpress)。3.1 将移除此 bin。 + */ +const chalk = require('chalk'); +const { t } = require('../lib/i18n'); + +function mapLegacyArgv(argv) { + const [cmd, ...rest] = argv; + if (cmd === 'start') return ['server', 'start', ...rest]; + if (cmd === 'stop') return ['server', 'stop', ...rest]; + if (cmd === 'restart') return ['server', 'restart', ...rest]; + if (cmd === 'status') return ['server', 'status', ...rest]; + return argv; +} + +if (!process.env.REACTPRESS_SUPPRESS_DEPRECATION) { + console.warn( + chalk.yellow( + t('shim.deprecated') + ) + ); +} + +const mapped = mapLegacyArgv(process.argv.slice(2)); +process.argv = [process.argv[0], process.argv[1], ...mapped]; +require('./reactpress.js'); diff --git a/cli/bin/reactpress.js b/cli/bin/reactpress.js new file mode 100755 index 00000000..67829e15 --- /dev/null +++ b/cli/bin/reactpress.js @@ -0,0 +1,381 @@ +#!/usr/bin/env node + +/** + * ReactPress unified CLI — init, dev, build, server, docker, publish. + * Run without arguments for an interactive menu (Claude Code–style). + */ + +const { Command } = require('commander'); +const path = require('path'); +const chalk = require('chalk'); +const { brand, divider } = require('../ui/theme'); +const { ensureOriginalCwd } = require('../lib/root'); +const { ensureProjectEnvironment, initMonorepoProject } = require('../lib/bootstrap'); +const { runDev } = require('../lib/dev'); +const { runApiDev } = require('../lib/api-dev'); +const { runLifecycleCommand } = require('../lib/lifecycle'); +const { runDockerCommand } = require('../lib/docker'); +const { runNginxCommand } = require('../lib/nginx'); +const { printUnifiedStatus } = require('../lib/status'); +const { runDoctor } = require('../lib/doctor'); +const { runDbBackup } = require('../lib/db-backup'); +const { runBuild } = require('../lib/build'); +const { startApiWithPm2 } = require('../lib/pm2'); +const { runNodeScript, runReactpressCli } = require('../lib/spawn'); +const { getClientBin } = require('../lib/paths'); +const { runInteractiveLoop } = require('../ui/interactive'); +const { t } = require('../lib/i18n'); + +const rootPkg = require(path.join(__dirname, '..', 'package.json')); + +const program = new Command(); + +program + .name('reactpress') + .description(t('cli.description')) + .version(rootPkg.version); + +program + .command('init') + .description(t('cli.init.description')) + .argument('[directory]', t('cli.init.directory'), '.') + .option('-f, --force', t('cli.init.force')) + .action(async (directory, options) => { + const projectRoot = path.resolve(directory); + process.env.REACTPRESS_ORIGINAL_CWD = projectRoot; + const { isMonorepoCheckout } = require('../lib/bootstrap'); + if (isMonorepoCheckout(projectRoot)) { + const result = await initMonorepoProject(projectRoot, { force: !!options.force }); + console.log(`[reactpress] ${result.message}`); + process.exit(result.ok ? 0 : 1); + return; + } + const args = ['init', directory]; + if (options.force) args.push('--force'); + runReactpressCli(args, { cwd: projectRoot }); + }); + +program + .command('dev') + .description(t('cli.dev.description')) + .option('--api-only', t('cli.dev.apiOnly')) + .option('--client-only', t('cli.dev.clientOnly')) + .action(async (options) => { + const projectRoot = ensureOriginalCwd(); + try { + if (options.clientOnly) { + await runNodeScript(getClientBin(), [], { cwd: projectRoot }); + return; + } + if (options.apiOnly) { + await runApiDev(projectRoot); + return; + } + await runDev(projectRoot); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(err.exitCode ?? 1); + } + }); + +const serverCmd = program.command('server').description(t('cli.server.description')); + +serverCmd + .command('start') + .description(t('cli.server.start.description')) + .option('--pm2', t('cli.server.start.pm2')) + .option('--bg', t('cli.server.start.bg')) + .action(async (options) => { + const projectRoot = ensureOriginalCwd(); + try { + if (options.pm2) { + await startApiWithPm2(projectRoot); + return; + } + const cmd = options.bg ? 'start:bg' : 'start'; + const code = await runLifecycleCommand(cmd, projectRoot); + process.exit(code ?? 0); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +serverCmd.command('stop').description(t('cli.server.stop')).action(async () => { + const code = await runLifecycleCommand('stop', ensureOriginalCwd()); + process.exit(code ?? 0); +}); + +serverCmd.command('restart').description(t('cli.server.restart')).action(async () => { + const code = await runLifecycleCommand('restart', ensureOriginalCwd()); + process.exit(code ?? 0); +}); + +serverCmd.command('status').description(t('cli.server.status')).action(async () => { + await runLifecycleCommand('status', ensureOriginalCwd()); +}); + +const clientCmd = program.command('client').description(t('cli.client.description')); + +clientCmd + .command('start') + .description(t('cli.client.start')) + .option('--pm2', t('cli.client.start.pm2')) + .action(async (options) => { + const args = options.pm2 ? ['--pm2'] : []; + await runNodeScript(getClientBin(), args, { cwd: ensureOriginalCwd() }); + }); + +program + .command('build') + .description(t('cli.build.description')) + .option('-t, --target ', t('cli.build.target'), 'all') + .action(async (options) => { + try { + await runBuild(options.target, ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +const dockerCmd = program.command('docker').description(t('cli.docker.description')); + +dockerCmd + .command('up') + .description(t('cli.docker.up')) + .action(async () => { + await runDockerCommand('up', ensureOriginalCwd()); + }); + +dockerCmd + .command('down') + .alias('stop') + .description(t('cli.docker.down')) + .action(async () => { + await runDockerCommand('down', ensureOriginalCwd()); + }); + +dockerCmd + .command('start') + .description(t('cli.docker.start')) + .action(async () => { + await runDockerCommand('start', ensureOriginalCwd()); + }); + +dockerCmd.command('restart').description(t('cli.docker.restart')).action(async () => { + await runDockerCommand('restart', ensureOriginalCwd()); +}); + +dockerCmd.command('status').description(t('cli.docker.status')).action(async () => { + await runDockerCommand('status', ensureOriginalCwd()); +}); + +dockerCmd + .command('logs [service]') + .description(t('cli.docker.logs')) + .action(async (service) => { + await runDockerCommand('logs', ensureOriginalCwd(), service ? [service] : []); + }); + +const nginxCmd = program.command('nginx').description(t('cli.nginx.description')); + +function nginxActionOptions(cmd) { + return cmd.option('--prod', t('cli.nginx.prod')).option('-f, --force', t('cli.nginx.force')); +} + +nginxActionOptions(nginxCmd.command('ensure').description(t('cli.nginx.ensure'))).action(async (options) => { + try { + await runNginxCommand('ensure', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxActionOptions(nginxCmd.command('up').description(t('cli.nginx.up'))).action(async (options) => { + try { + await runNginxCommand('up', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd + .command('down') + .alias('stop') + .description(t('cli.nginx.down')) + .option('--prod', t('cli.nginx.prod')) + .action(async (options) => { + try { + await runNginxCommand('down', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +nginxActionOptions(nginxCmd.command('restart').description(t('cli.nginx.restart'))).action(async (options) => { + try { + await runNginxCommand('restart', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd + .command('status') + .description(t('cli.nginx.status')) + .option('--prod', t('cli.nginx.prod')) + .action(async (options) => { + try { + await runNginxCommand('status', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +nginxCmd.command('logs').description(t('cli.nginx.logs')).action(async () => { + try { + await runNginxCommand('logs', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd.command('test').description(t('cli.nginx.test')).action(async () => { + try { + await runNginxCommand('test', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd.command('reload').description(t('cli.nginx.reload')).action(async () => { + try { + await runNginxCommand('reload', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd.command('open').description(t('cli.nginx.open')).action(async () => { + try { + await runNginxCommand('open', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +program + .command('status') + .description(t('cli.status.description')) + .action(async () => { + await printUnifiedStatus(ensureOriginalCwd()); + }); + +program + .command('doctor') + .description(t('cli.doctor.description')) + .action(async () => { + const code = await runDoctor(ensureOriginalCwd()); + process.exit(code); + }); + +const dbCmd = program.command('db').description(t('cli.db.description')); + +dbCmd + .command('backup') + .description(t('cli.db.backup')) + .option('-o, --output ', t('cli.db.backup.output')) + .action(async (options) => { + try { + await runDbBackup(ensureOriginalCwd(), options.output); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +program + .command('publish') + .description(t('cli.publish.description')) + .option('--build', t('cli.publish.build')) + .option('--publish', t('cli.publish.publish')) + .action(async (options) => { + try { + const publish = require('../lib/publish'); + if (options.build) { + await publish.buildPackages(); + return; + } + await publish.publishPackages(); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +program + .command('start') + .description(t('cli.start.description')) + .action(async () => { + const projectRoot = ensureOriginalCwd(); + const { hasClient } = require('../lib/project-type'); + const code = await runLifecycleCommand('start', projectRoot); + if (code !== 0) process.exit(code); + if (!hasClient(projectRoot)) { + console.log(t('dev.standaloneHint')); + return; + } + const { spawn } = require('child_process'); + const child = spawn('pnpm', ['run', '--dir', './client', 'start'], { + stdio: 'inherit', + shell: true, + cwd: projectRoot, + }); + child.on('close', (c) => process.exit(c ?? 0)); + }); + +program.on('--help', () => { + console.log(''); + console.log(brand.bold(t('cli.help.examples'))); + console.log(divider(40)); + const lines = [ + t('cli.help.interactive'), + t('cli.help.dev'), + t('cli.help.init'), + t('cli.help.server'), + t('cli.help.status'), + t('cli.help.doctor'), + t('cli.help.docker'), + t('cli.help.nginx'), + t('cli.help.build'), + t('cli.help.publish'), + ]; + for (const line of lines) { + console.log(brand.dim(line)); + } + console.log(''); +}); + +async function main() { + const argv = process.argv.slice(2); + if (argv.length === 0) { + await runInteractiveLoop(); + return; + } + program.parse(process.argv); +} + +main().catch((err) => { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); +}); diff --git a/cli/lib/api-dev-runner.js b/cli/lib/api-dev-runner.js new file mode 100644 index 00000000..4bbde612 --- /dev/null +++ b/cli/lib/api-dev-runner.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const { runApiDev } = require('./api-dev'); +const { ensureOriginalCwd } = require('./root'); + +ensureOriginalCwd(); +runApiDev(); diff --git a/cli/lib/api-dev.js b/cli/lib/api-dev.js new file mode 100644 index 00000000..cac3fc4c --- /dev/null +++ b/cli/lib/api-dev.js @@ -0,0 +1,89 @@ +const { spawn } = require('child_process'); +const path = require('path'); +const { ensureProjectEnvironment } = require('./bootstrap'); +const { + getServerBin, + getServerDir, + isUsingMonorepoServer, + canStartLocalApi, +} = require('./paths'); +const { stopApi } = require('./lifecycle'); +const { ensureOriginalCwd } = require('./root'); +const { t } = require('./i18n'); + +let apiChild; + +function stopApiDev(projectRoot) { + if (apiChild && !apiChild.killed) { + apiChild.kill('SIGTERM'); + } + if (!isUsingMonorepoServer(projectRoot)) { + stopApi(projectRoot); + } +} + +function startApiDev(projectRoot) { + if (isUsingMonorepoServer(projectRoot)) { + console.log(t('apiDev.modeServer')); + apiChild = spawn('pnpm', ['run', '--dir', './server', 'dev'], { + cwd: projectRoot, + stdio: 'inherit', + shell: true, + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + } else if (canStartLocalApi(projectRoot)) { + console.log(t('apiDev.modeBundled')); + apiChild = spawn(process.execPath, [getServerBin(projectRoot)], { + cwd: getServerDir(projectRoot), + stdio: 'inherit', + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + } else { + console.error(t('lifecycle.noServerAvailable')); + process.exit(1); + } + + if (apiChild) { + apiChild.on('close', (code) => { + process.exit(code ?? 0); + }); + console.log(t('apiDev.ctrlCHint')); + console.log(t('apiDev.stopHint')); + } +} + +async function runApiDev(projectRoot = ensureOriginalCwd()) { + try { + await ensureProjectEnvironment(projectRoot); + } catch (err) { + console.error(t('dev.envFailed'), err.message || err); + process.exit(1); + } + + process.on('SIGINT', () => { + stopApiDev(projectRoot); + process.exit(0); + }); + process.on('SIGTERM', () => { + stopApiDev(projectRoot); + process.exit(0); + }); + + startApiDev(projectRoot); +} + +function getApiDevScriptPath() { + return path.join(__dirname, 'api-dev-runner.js'); +} + +module.exports = { + runApiDev, + stopApiDev, + getApiDevScriptPath, +}; diff --git a/cli/lib/bootstrap.js b/cli/lib/bootstrap.js new file mode 100644 index 00000000..9d5dd7ba --- /dev/null +++ b/cli/lib/bootstrap.js @@ -0,0 +1,114 @@ +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { ensureOriginalCwd, isMonorepoCheckout } = require('./root'); +const { getCliPackageRoot } = require('./paths'); +const { t } = require('./i18n'); + +async function importCliModule(relativePath) { + const modulePath = path.join(getCliPackageRoot(), 'dist', relativePath); + return import(pathToFileURL(modulePath).href); +} + +async function copyTemplateFile(src, dest) { + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + await fs.promises.copyFile(src, dest); +} + +async function initMonorepoProject(projectRoot, { force = false } = {}) { + const { getProjectPaths, getTemplatesDir } = await importCliModule('utils/paths.js'); + const { saveConfig, syncEnvFromConfig } = await importCliModule('services/config.js'); + const { ensureDatabase, ensureDatabaseHostPort } = await importCliModule('services/database.js'); + + const paths = getProjectPaths(projectRoot); + const templatesDir = getTemplatesDir(); + + if (fs.existsSync(paths.configPath) && !force) { + const config = await (await importCliModule('services/config.js')).loadConfig(projectRoot); + await ensureDatabaseHostPort(projectRoot, undefined, config); + const dbResult = await ensureDatabase(projectRoot, config); + if (!dbResult.ok) { + return { ok: false, projectRoot, message: dbResult.message }; + } + return { ok: true, projectRoot, message: t('bootstrap.configReady') }; + } + + await fs.promises.mkdir(paths.reactpressDir, { recursive: true }); + await copyTemplateFile( + path.join(templatesDir, 'docker-compose.yml'), + paths.dockerComposePath + ); + + const config = JSON.parse( + await fs.promises.readFile(path.join(templatesDir, 'config.default.json'), 'utf8') + ); + await saveConfig(projectRoot, config); + await syncEnvFromConfig(projectRoot, config); + + if (!fs.existsSync(paths.envPath) || force) { + await copyTemplateFile(path.join(templatesDir, 'env.default'), paths.envPath); + await syncEnvFromConfig(projectRoot, config); + } + + await ensureDatabaseHostPort(projectRoot, undefined, config); + const dbResult = await ensureDatabase(projectRoot, config); + + if (!dbResult.ok) { + return { + ok: true, + projectRoot, + message: t('bootstrap.projectDbPending', { message: dbResult.message }), + }; + } + + return { + ok: true, + projectRoot, + message: t('bootstrap.ready'), + }; +} + +async function ensureProjectEnvironment(projectRoot = ensureOriginalCwd()) { + const root = path.resolve(projectRoot); + const { setProjectCwd } = await importCliModule('utils/cli-context.js'); + setProjectCwd(root); + + const { isReactPressProject, loadConfig } = await importCliModule('services/config.js'); + const { ensureDatabase, ensureDatabaseHostPort } = await importCliModule('services/database.js'); + + if (!(await isReactPressProject(root))) { + if (isMonorepoCheckout(root)) { + const result = await initMonorepoProject(root); + if (!result.ok) { + throw new Error(result.message || t('bootstrap.initFailed')); + } + return result; + } + + const { initProject } = await importCliModule('services/init.js'); + const result = await initProject({ directory: root, force: false }); + if (!result.ok) { + throw new Error(result.message || t('bootstrap.cliInitFailed')); + } + return result; + } + + const config = await loadConfig(root); + await ensureDatabaseHostPort(root, undefined, config); + const dbResult = await ensureDatabase(root, config); + if (!dbResult.ok) { + throw new Error( + t('bootstrap.dbNotReady', { + message: dbResult.message || t('bootstrap.dbPendingShort'), + }) + ); + } + + return { ok: true, projectRoot: root, message: t('bootstrap.dbReady') }; +} + +module.exports = { + ensureProjectEnvironment, + initMonorepoProject, + isMonorepoCheckout, +}; diff --git a/cli/lib/build.js b/cli/lib/build.js new file mode 100644 index 00000000..ee70bc19 --- /dev/null +++ b/cli/lib/build.js @@ -0,0 +1,162 @@ +const fs = require('fs'); +const path = require('path'); +const ora = require('ora'); +const { brand, icon, ok, warn, label, chip } = require('../ui/theme'); +const { runSync } = require('./spawn'); +const { ensureOriginalCwd } = require('./root'); +const { t } = require('./i18n'); + +const FORBIDDEN_SCRIPTS = new Set(['build']); + +/** @type {Record} */ +const BUILD_STEPS = { + toolkit: [{ script: 'build:toolkit', labelKey: 'build.label.toolkit' }], + server: [{ script: 'build:server', labelKey: 'build.label.server' }], + client: [{ script: 'build:client', labelKey: 'build.label.client' }], + docs: [{ script: 'build:docs', labelKey: 'build.label.docs' }], + all: [ + { script: 'build:toolkit', labelKey: 'build.label.toolkit' }, + { script: 'build:server', labelKey: 'build.label.server' }, + { script: 'build:client', labelKey: 'build.label.client' }, + ], +}; + +const TARGETS = Object.keys(BUILD_STEPS); + +const buildChildEnv = { REACTPRESS_BUILD_ACTIVE: '1' }; + +function readPackageScripts(packageJsonPath) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return pkg.scripts || {}; + } catch { + return {}; + } +} + +/** Prefer workspace package scripts over root package.json aliases. */ +function resolveBuildInvocation(script, projectRoot) { + const root = path.resolve(projectRoot); + + if (script === 'build:toolkit') { + const toolkitDir = path.join(root, 'toolkit'); + if (fs.existsSync(path.join(toolkitDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: toolkitDir }; + } + const rootScripts = readPackageScripts(path.join(root, 'package.json')); + if (rootScripts['build:toolkit']) { + return { command: 'pnpm', args: ['run', 'build:toolkit'], cwd: root }; + } + return null; + } + + if (script === 'build:server') { + const serverDir = path.join(root, 'server'); + if (fs.existsSync(path.join(serverDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: serverDir }; + } + } + + if (script === 'build:client') { + const clientDir = path.join(root, 'client'); + if (fs.existsSync(path.join(clientDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: clientDir }; + } + } + + if (script === 'build:docs') { + const docsDir = path.join(root, 'docs'); + if (fs.existsSync(path.join(docsDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: docsDir }; + } + } + + const rootScripts = readPackageScripts(path.join(root, 'package.json')); + if (rootScripts[script]) { + return { command: 'pnpm', args: ['run', script], cwd: root }; + } + + return null; +} + +function stepBadge(current, total) { + return chip(`${current}/${total}`, brand.primary); +} + +async function runBuild(target = 'all', projectRoot = ensureOriginalCwd()) { + if (process.env.REACTPRESS_BUILD_ACTIVE === '1') { + throw new Error(t('build.recursive')); + } + + const steps = BUILD_STEPS[target]; + if (!steps) { + throw new Error( + t('build.unknownTarget', { + target, + available: TARGETS.join(', '), + }) + ); + } + + const total = steps.length; + const buildStarted = Date.now(); + + console.log(''); + if (total > 1) { + console.log(label(t('build.plan', { total }))); + console.log(''); + } + + for (let i = 0; i < steps.length; i++) { + const { script, labelKey } = steps[i]; + if (FORBIDDEN_SCRIPTS.has(script)) { + throw new Error(t('build.forbiddenScript', { script })); + } + + const current = i + 1; + const stepLabel = t(labelKey); + const stepStarted = Date.now(); + const badge = stepBadge(current, total); + + const invocation = resolveBuildInvocation(script, projectRoot); + if (!invocation) { + console.log(` ${badge} ${warn(t('build.stepSkipped', { label: stepLabel }))}`); + continue; + } + + const spinner = ora({ + text: `${badge} ${t('build.step', { current, total, label: stepLabel })}`, + color: 'magenta', + spinner: 'dots', + }).start(); + + try { + runSync(invocation.command, invocation.args, { + cwd: invocation.cwd, + env: buildChildEnv, + }); + } catch (err) { + spinner.fail(`${badge} ${t('build.stepFailed', { current, total, label: stepLabel })}`); + throw err; + } + + const seconds = ((Date.now() - stepStarted) / 1000).toFixed(1); + spinner.succeed( + `${badge} ${ok(t('build.stepDone', { current, total, label: stepLabel, seconds }))}` + ); + } + + if (total > 1) { + const totalSeconds = ((Date.now() - buildStarted) / 1000).toFixed(1); + console.log(''); + console.log(` ${icon.spark} ${ok(t('build.done', { seconds: totalSeconds }))}`); + } + console.log(''); +} + +module.exports = { + runBuild, + TARGETS, + BUILD_STEPS, + resolveBuildInvocation, +}; diff --git a/cli/lib/db-backup.js b/cli/lib/db-backup.js new file mode 100644 index 00000000..729cb5f9 --- /dev/null +++ b/cli/lib/db-backup.js @@ -0,0 +1,67 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const chalk = require('chalk'); +const { t } = require('./i18n'); +const { mysqldumpFromDbContainer } = require('./docker'); + +function isLocalDbHost(host) { + const h = String(host || '').toLowerCase(); + return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === ''; +} + +function isMysqldumpNotFoundError(err) { + const msg = `${err && err.message ? err.message : ''}\n${err && err.stderr ? err.stderr : ''}`; + if (err && err.status === 127) return true; + return /command not found|not recognized as an internal or external command/i.test(msg); +} + +function parseEnv(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + const out = {}; + try { + const content = fs.readFileSync(envPath, 'utf8'); + for (const line of content.split('\n')) { + const m = line.match(/^([A-Z_]+)=(.*)$/); + if (m) out[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return out; +} + +async function runDbBackup(projectRoot, outputPath) { + const env = parseEnv(projectRoot); + const host = env.DB_HOST || '127.0.0.1'; + const port = env.DB_PORT || '3306'; + const user = env.DB_USER || 'root'; + const password = env.DB_PASSWD || env.DB_PASSWORD || 'root'; + const database = env.DB_DATABASE || 'reactpress'; + const out = + outputPath || + path.join(projectRoot, `reactpress-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.sql`); + + const cmd = `mysqldump -h ${host} -P ${port} -u ${user} -p${password} ${database}`; + console.log(chalk.cyan('[reactpress]'), t('db.backup.to', { path: out })); + try { + const dump = execSync(cmd, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 }); + fs.writeFileSync(out, dump, 'utf8'); + console.log(chalk.green('[reactpress]'), t('db.backup.done')); + return out; + } catch (err) { + if (isMysqldumpNotFoundError(err) && isLocalDbHost(host)) { + const via = mysqldumpFromDbContainer(projectRoot, { user, password, database }); + if (via.ok) { + console.log(chalk.cyan('[reactpress]'), t('db.backup.viaDocker')); + fs.writeFileSync(out, via.stdout, 'utf8'); + console.log(chalk.green('[reactpress]'), t('db.backup.done')); + return out; + } + } + console.error(chalk.red('[reactpress]'), t('db.backup.fail')); + throw err; + } +} + +module.exports = { runDbBackup }; diff --git a/cli/lib/dev-banner.js b/cli/lib/dev-banner.js new file mode 100644 index 00000000..876f7206 --- /dev/null +++ b/cli/lib/dev-banner.js @@ -0,0 +1,72 @@ +const { + brand, + icon, + ok, + divider, + padRight, + terminalWidth, + gradientText, + palette, + pulseBar, + statusLights, +} = require('../ui/theme'); +const { + loadClientSiteUrl, + loadServerSiteUrl, + getApiPrefix, + getHealthUrl, +} = require('./http'); +const { t } = require('./i18n'); + +function getDevUrls(projectRoot) { + const client = loadClientSiteUrl(projectRoot).replace(/\/$/, ''); + const server = loadServerSiteUrl(projectRoot).replace(/\/$/, ''); + const prefix = getApiPrefix(projectRoot).replace(/\/$/, '') || '/api'; + return { + site: client, + admin: `${client}/admin`, + api: `${server}${prefix}`, + swagger: `${server}${prefix}`, + health: getHealthUrl(projectRoot), + }; +} + +function urlLine(key, url, { underline = true } = {}) { + const keyCol = brand.muted(padRight(key, 10)); + const value = underline ? brand.accent.underline(url) : brand.dim(url); + return ` ${brand.accent('▸ ')}${keyCol} ${value}`; +} + +function printDevReadyBanner(projectRoot, { apiOnly = false } = {}) { + const urls = getDevUrls(projectRoot); + const w = Math.min(terminalWidth() - 4, 56); + + console.log(''); + console.log( + ` ${icon.ok} ${gradientText(t('devBanner.ready'), [palette.green, palette.accent], { bold: true })} ${statusLights('online')}` + ); + console.log(` ${brand.primary('╔' + '═'.repeat(w) + '╗')}`); + + if (!apiOnly) { + console.log(urlLine(t('devBanner.site'), urls.site)); + console.log(urlLine(t('devBanner.admin'), urls.admin)); + } + console.log(urlLine(t('devBanner.api'), urls.api)); + console.log(urlLine(t('devBanner.swagger'), urls.swagger)); + console.log(urlLine(t('devBanner.health'), urls.health, { underline: false })); + + const pulseWidth = Math.min(20, w - 4); + if (pulseWidth > 6) { + console.log( + ` ${brand.muted(' ')}${pulseBar(pulseWidth, pulseWidth)} ${brand.success(t('devBanner.allSystemsGo'))}` + ); + } + + console.log(` ${brand.primary('╚' + '═'.repeat(w) + '╝')}`); + console.log( + ` ${brand.dim(t('devBanner.hint'))} ${brand.muted('·')} ${brand.dim(t('devBanner.shortcuts'))}` + ); + console.log(''); +} + +module.exports = { getDevUrls, printDevReadyBanner }; diff --git a/cli/lib/dev.js b/cli/lib/dev.js new file mode 100644 index 00000000..838585e2 --- /dev/null +++ b/cli/lib/dev.js @@ -0,0 +1,141 @@ +const { spawn } = require('child_process'); +const path = require('path'); +const ora = require('ora'); +const { runBuild } = require('./build'); +const { ensureProjectEnvironment } = require('./bootstrap'); +const { loadServerSiteUrl, loadClientSiteUrl, waitForHttp } = require('./http'); +const { printDevReadyBanner } = require('./dev-banner'); +const { ensureOriginalCwd } = require('./root'); +const { detectProjectType, hasClient, hasToolkit } = require('./project-type'); +const { t } = require('./i18n'); + +const CLIENT_READY_TIMEOUT_MS = 120_000; +const API_READY_TIMEOUT_MS = 180_000; + +function formatDevFailureHint() { + return [ + t('dev.nextSteps'), + t('dev.nextDoctor'), + t('dev.nextDocker'), + t('dev.nextEnv'), + ].join('\n'); +} + +let apiChild; +let webChild; +let shuttingDown = false; + +function shutdown(signal = 'SIGINT') { + if (shuttingDown) return; + shuttingDown = true; + if (webChild && !webChild.killed) webChild.kill(signal); + if (apiChild && !apiChild.killed) apiChild.kill(signal); +} + +async function buildToolkit(projectRoot) { + if (!hasToolkit(projectRoot)) return; + await runBuild('toolkit', projectRoot); +} + +function spawnApi(projectRoot) { + const apiDevRunner = path.join(__dirname, 'api-dev-runner.js'); + console.log(t('dev.startingApi')); + apiChild = spawn(process.execPath, [apiDevRunner], { + stdio: 'inherit', + cwd: projectRoot, + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + + apiChild.on('close', (code) => { + if (shuttingDown) { + process.exit(code ?? 0); + return; + } + if (webChild && !webChild.killed) webChild.kill('SIGINT'); + process.exit(code ?? 1); + }); +} + +async function waitForApiReady(projectRoot) { + const serverUrl = loadServerSiteUrl(projectRoot); + const spinner = ora({ + text: t('dev.waitingApi', { url: serverUrl }), + color: 'magenta', + spinner: 'dots', + }).start(); + const ready = await waitForHttp(serverUrl, API_READY_TIMEOUT_MS); + if (!ready) { + spinner.fail(t('dev.apiTimeout', { seconds: API_READY_TIMEOUT_MS / 1000 })); + shutdown('SIGINT'); + process.exit(1); + } + spinner.succeed(t('dev.apiReady')); +} + +function spawnClient(projectRoot) { + webChild = spawn('pnpm', ['run', '--dir', './client', 'dev'], { + stdio: 'inherit', + shell: true, + cwd: projectRoot, + }); + + const clientUrl = loadClientSiteUrl(projectRoot); + waitForHttp(clientUrl, CLIENT_READY_TIMEOUT_MS).then((clientReady) => { + if (clientReady) { + printDevReadyBanner(projectRoot); + } else { + console.warn( + t('dev.clientSlow', { + seconds: CLIENT_READY_TIMEOUT_MS / 1000, + url: clientUrl, + }) + ); + } + }); + + webChild.on('close', (code) => { + if (!shuttingDown) shutdown('SIGINT'); + process.exit(code ?? 0); + }); +} + +async function startDevStack(projectRoot) { + const includeClient = hasClient(projectRoot); + + spawnApi(projectRoot); + await waitForApiReady(projectRoot); + printDevReadyBanner(projectRoot, { apiOnly: !includeClient }); + + if (!includeClient) { + console.log(t('dev.standaloneHint')); + return; + } + spawnClient(projectRoot); +} + +async function runDev(projectRoot = ensureOriginalCwd()) { + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + + try { + const result = await ensureProjectEnvironment(projectRoot); + if (result.message) console.log(`[reactpress] ${result.message}`); + } catch (err) { + console.error(t('dev.envFailed'), err.message || err); + console.error(formatDevFailureHint()); + process.exit(1); + } + + await buildToolkit(projectRoot); + await startDevStack(projectRoot); +} + +module.exports = { + runDev, + buildToolkit, + startDevStack, + detectProjectType, +}; diff --git a/cli/lib/docker.js b/cli/lib/docker.js new file mode 100644 index 00000000..cf4932b3 --- /dev/null +++ b/cli/lib/docker.js @@ -0,0 +1,275 @@ +const fs = require('fs'); +const path = require('path'); +const { spawn, execSync, spawnSync } = require('child_process'); +const ora = require('ora'); +const { ensureOriginalCwd } = require('./root'); +const { detectProjectType, hasClient } = require('./project-type'); +const { t } = require('./i18n'); + +function isDockerRunning() { + try { + execSync('docker info', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function pickDockerComposeCommand() { + const v2 = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' }); + if (v2.status === 0) return { command: 'docker', baseArgs: ['compose'] }; + + const v1 = spawnSync('docker-compose', ['version'], { stdio: 'ignore' }); + if (v1.status === 0) return { command: 'docker-compose', baseArgs: [] }; + + return { command: 'docker', baseArgs: ['compose'] }; +} + +/** + * Resolve which docker-compose file to use for the current project. + * + * - Monorepo checkouts use `docker-compose.dev.yml` at the repo root. + * - Standalone projects use `.reactpress/docker-compose.yml` (managed by init). + * + * @returns {{ composeFile: string, cwd: string, type: 'monorepo' | 'standalone' }} + */ +function resolveComposeContext(projectRoot) { + const type = detectProjectType(projectRoot); + if (type === 'monorepo') { + const composeFile = path.join(projectRoot, 'docker-compose.dev.yml'); + if (fs.existsSync(composeFile)) { + return { composeFile, cwd: projectRoot, type }; + } + } + const standaloneCompose = path.join(projectRoot, '.reactpress', 'docker-compose.yml'); + if (fs.existsSync(standaloneCompose)) { + return { composeFile: standaloneCompose, cwd: path.dirname(standaloneCompose), type: 'standalone' }; + } + const fallback = path.join(projectRoot, 'docker-compose.dev.yml'); + return { composeFile: fallback, cwd: projectRoot, type }; +} + +function runCompose(args, ctx, options = {}) { + const { command, baseArgs } = pickDockerComposeCommand(); + return spawnSync( + command, + [...baseArgs, '-f', ctx.composeFile, ...args], + { stdio: options.stdio ?? 'inherit', cwd: ctx.cwd, ...options } + ); +} + +function stopDockerServices(projectRoot) { + console.log(t('docker.stopping')); + const ctx = resolveComposeContext(projectRoot); + const result = runCompose(['down'], ctx); + if (result.status !== 0) { + console.error(t('docker.stopFailed')); + throw new Error(t('docker.stopFailed')); + } + console.log(t('docker.stopped')); +} + +function startDockerServices(projectRoot) { + console.log(t('docker.starting')); + if (!isDockerRunning()) { + throw new Error(t('docker.notRunning')); + } + try { + const { ensureNginxConfig } = require('./nginx'); + const { configPath, created } = ensureNginxConfig(projectRoot, { mode: 'dev' }); + if (created) { + console.log(t('nginx.configCreated', { path: configPath })); + } + } catch (err) { + console.warn(t('nginx.ensureWarn', { message: err.message || err })); + } + const ctx = resolveComposeContext(projectRoot); + const result = runCompose(['up', '-d'], ctx); + if (result.status !== 0) { + throw new Error(t('docker.notRunning')); + } + console.log(t('docker.started')); +} + +function resolveDbContainerName(ctx, projectRoot) { + if (ctx.type === 'standalone') return 'reactpress_cli_db'; + return 'reactpress_db'; +} + +function resolveDbCredentialsFromEnv(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + let user = 'reactpress'; + let password = 'reactpress'; + try { + const content = fs.readFileSync(envPath, 'utf8'); + const u = content.match(/^DB_USER=(.+)$/m); + const p = content.match(/^(DB_PASSWD|DB_PASSWORD)=(.+)$/m); + if (u) user = u[1].trim().replace(/^['"]|['"]$/g, ''); + if (p) password = p[2].trim().replace(/^['"]|['"]$/g, ''); + } catch { + // ignore + } + return { user, password }; +} + +async function waitForMysql(projectRoot, maxAttempts = 30) { + const ctx = resolveComposeContext(projectRoot); + const container = resolveDbContainerName(ctx, projectRoot); + const { user, password } = resolveDbCredentialsFromEnv(projectRoot); + + const spinner = ora({ + text: t('docker.waitingMysql'), + color: 'magenta', + spinner: 'dots', + }).start(); + + let attempts = 0; + while (attempts < maxAttempts) { + const probe = spawnSync( + 'docker', + ['exec', container, 'mysql', `-u${user}`, `-p${password}`, '-e', 'SELECT 1'], + { stdio: 'ignore' } + ); + if (probe.status === 0) { + spinner.succeed(t('docker.mysqlReady')); + return true; + } + attempts += 1; + spinner.text = t('docker.waitingMysqlProgress', { attempts, max: maxAttempts }); + await new Promise((r) => setTimeout(r, 1000)); + } + spinner.fail(t('docker.mysqlTimeout')); + return false; +} + +async function dockerStartWithDev(projectRoot) { + startDockerServices(projectRoot); + const ready = await waitForMysql(projectRoot); + if (!ready) { + throw new Error(t('docker.mysqlNotReady')); + } + + if (!hasClient(projectRoot)) { + console.log(t('dev.standaloneHint')); + return; + } + + const { buildToolkit } = require('./dev'); + await buildToolkit(projectRoot); + + const apiRunner = path.join(__dirname, 'api-dev-runner.js'); + console.log(t('docker.startDevStack')); + console.log(t('docker.visitUrls')); + + return new Promise((resolve, reject) => { + const child = spawn( + 'npx', + [ + 'concurrently', + '-n', + 'api,web', + '-c', + 'blue,green', + `node "${apiRunner}"`, + 'pnpm run --dir ./client dev', + ], + { + stdio: 'inherit', + shell: true, + cwd: projectRoot, + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + } + ); + + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(Object.assign(new Error(t('docker.devProcessExit', { code })), { exitCode: code })); + return; + } + resolve(); + }); + }); +} + +/** + * Run mysqldump inside the compose `db` container (MySQL image ships mysqldump). + * Used when the host has no `mysqldump` binary but Docker DB is running. + * + * @returns {{ ok: true, stdout: string } | { ok: false, stderr: string }} + */ +function mysqldumpFromDbContainer(projectRoot, { user, password, database }) { + const ctx = resolveComposeContext(projectRoot); + if (!fs.existsSync(ctx.composeFile)) { + return { ok: false, stderr: 'compose file missing' }; + } + if (!isDockerRunning()) { + return { ok: false, stderr: 'docker not running' }; + } + const container = resolveDbContainerName(ctx, projectRoot); + const res = spawnSync( + 'docker', + ['exec', container, 'mysqldump', `-u${user}`, `-p${password}`, database], + { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } + ); + if (res.error) { + return { ok: false, stderr: res.error.message }; + } + if (res.status !== 0) { + return { ok: false, stderr: res.stderr || res.stdout || `exit ${res.status}` }; + } + return { ok: true, stdout: res.stdout }; +} + +async function runDockerCommand(command, projectRoot = ensureOriginalCwd(), extraArgs = []) { + const ctx = resolveComposeContext(projectRoot); + switch (command) { + case 'up': + startDockerServices(projectRoot); + await waitForMysql(projectRoot); + return; + case 'down': + case 'stop': + stopDockerServices(projectRoot); + return; + case 'start': + await dockerStartWithDev(projectRoot); + return; + case 'restart': + stopDockerServices(projectRoot); + await new Promise((r) => setTimeout(r, 2000)); + startDockerServices(projectRoot); + await waitForMysql(projectRoot); + return; + case 'status': { + const res = runCompose(['ps'], ctx); + if (res.status !== 0) { + throw new Error(t('docker.unknownCommand', { command: 'ps' })); + } + return; + } + case 'logs': { + const service = extraArgs[0]; + const args = ['logs', '-f']; + if (service) args.push(service); + runCompose(args, ctx); + return; + } + default: + throw new Error(t('docker.unknownCommand', { command })); + } +} + +module.exports = { + runDockerCommand, + startDockerServices, + stopDockerServices, + waitForMysql, + isDockerRunning, + resolveComposeContext, + pickDockerComposeCommand, + mysqldumpFromDbContainer, +}; diff --git a/cli/lib/doctor.js b/cli/lib/doctor.js new file mode 100644 index 00000000..13d0e8ea --- /dev/null +++ b/cli/lib/doctor.js @@ -0,0 +1,266 @@ +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const { execSync } = require('child_process'); +const ora = require('ora'); +const { + brand, + icon, + ok, + warn, + divider, + sectionHeader, + terminalWidth, + gradientText, + palette, +} = require('../ui/theme'); +const { getHealthUrl, checkHealth } = require('./http'); +const { isDockerRunning } = require('./docker'); +const { checkNginx } = require('./nginx'); +const { envFileStatus } = require('./status'); +const { t } = require('./i18n'); + +function checkNodeVersion() { + const major = parseInt(process.versions.node.split('.')[0], 10); + if (major >= 18) { + return { ok: true, message: `Node.js ${process.version}` }; + } + return { + ok: false, + message: t('doctor.nodeBad', { version: process.version }), + fix: t('doctor.nodeFix'), + }; +} + +function checkDocker() { + if (isDockerRunning()) { + return { ok: true, message: t('doctor.dockerOk') }; + } + return { + ok: false, + message: t('doctor.dockerBad'), + fix: t('doctor.dockerFix'), + }; +} + +function parseEnv(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + const out = {}; + try { + const content = fs.readFileSync(envPath, 'utf8'); + for (const line of content.split('\n')) { + const m = line.match(/^([A-Z_]+)=(.*)$/); + if (m) out[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return out; +} + +function checkPort(port, host = '127.0.0.1') { + return new Promise((resolve) => { + const socket = net.createConnection({ port, host }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + socket.setTimeout(1000, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function checkPorts(projectRoot) { + const env = parseEnv(projectRoot); + const apiPort = parseInt(env.SERVER_PORT || '3002', 10); + const clientPort = parseInt(env.CLIENT_PORT || '3001', 10); + + const healthUrl = getHealthUrl(projectRoot); + const apiHealth = await checkHealth(healthUrl); + if (apiHealth.ok) { + return { + ok: true, + message: t('doctor.portOk', { apiPort, clientPort }), + }; + } + + const [apiBusy, clientBusy] = await Promise.all([checkPort(apiPort), checkPort(clientPort)]); + const issues = []; + if (apiBusy) issues.push(t('doctor.portApiBusy', { port: apiPort })); + if (clientBusy) issues.push(t('doctor.portClientBusy', { port: clientPort })); + if (issues.length) { + return { + ok: false, + message: issues.join('; '), + fix: t('doctor.portFix'), + }; + } + return { + ok: true, + message: t('doctor.portOk', { apiPort, clientPort }), + }; +} + +async function checkDatabase(projectRoot) { + const env = parseEnv(projectRoot); + const host = env.DB_HOST || '127.0.0.1'; + const port = parseInt(env.DB_PORT || '3306', 10); + const user = env.DB_USER || 'root'; + const password = env.DB_PASSWD || env.DB_PASSWORD || 'root'; + const database = env.DB_DATABASE || 'reactpress'; + + return new Promise((resolve) => { + let mysql; + try { + mysql = require('mysql2/promise'); + } catch { + try { + mysql = require(path.join(projectRoot, 'server/node_modules/mysql2/promise')); + } catch { + resolve({ + ok: false, + message: t('doctor.dbNoMysql2'), + fix: t('doctor.dbMysql2Fix'), + }); + return; + } + } + + mysql + .createConnection({ host, port, user, password, database, connectTimeout: 5000 }) + .then(async (conn) => { + await conn.ping(); + await conn.end(); + resolve({ + ok: true, + message: t('doctor.dbOk', { host, port, database }), + }); + }) + .catch((err) => { + resolve({ + ok: false, + message: t('doctor.dbBad', { error: err.message }), + fix: t('doctor.dbFix'), + }); + }); + }); +} + +async function checkApiHealth(projectRoot) { + const healthUrl = getHealthUrl(projectRoot); + const result = await checkHealth(healthUrl); + if (result.ok) { + return { ok: true, message: t('doctor.apiOk', { url: healthUrl }) }; + } + return { + ok: false, + message: t('doctor.apiBad', { url: healthUrl }), + fix: t('doctor.apiFix'), + }; +} + +function checkPnpm() { + try { + const v = execSync('pnpm -v', { encoding: 'utf8' }).trim(); + return { ok: true, message: `pnpm ${v}` }; + } catch { + return { + ok: false, + message: t('doctor.pnpmBad'), + fix: t('doctor.pnpmFix'), + }; + } +} + +async function runCheckWithSpinner(name, run) { + const spinner = ora({ + text: t('doctor.checking', { name }), + color: 'magenta', + spinner: 'dots', + }).start(); + const result = await run(); + if (result.ok) { + spinner.stop(); + } else { + spinner.stop(); + } + return result; +} + +async function runDoctor(projectRoot) { + const env = envFileStatus(projectRoot); + const checks = [ + { name: 'Node.js', run: () => checkNodeVersion() }, + { name: 'pnpm', run: () => checkPnpm() }, + { + name: t('doctor.check.config'), + run: () => ({ + ok: env.config, + message: env.config ? t('doctor.configOk') : t('doctor.configBad'), + fix: t('doctor.configFix'), + }), + }, + { + name: t('doctor.check.env'), + run: () => ({ + ok: env.env, + message: env.env ? t('doctor.envOk') : t('doctor.envBad'), + fix: t('doctor.envFix'), + }), + }, + { name: 'Docker', run: () => checkDocker() }, + { name: t('doctor.check.nginx'), run: () => checkNginx(projectRoot) }, + { name: t('doctor.check.ports'), run: () => checkPorts(projectRoot) }, + { name: t('doctor.check.database'), run: () => checkDatabase(projectRoot) }, + { name: t('doctor.check.api'), run: () => checkApiHealth(projectRoot) }, + ]; + + const w = Math.min(terminalWidth() - 4, 52); + const results = []; + const fixes = []; + + console.log(''); + console.log( + ` ${gradientText(t('doctor.title'), [palette.primary, palette.accent], { bold: true })} ${brand.dim(t('doctor.subtitle'))}` + ); + console.log(` ${brand.dim(t('doctor.project', { path: projectRoot }))}`); + console.log(` ${divider(w)}`); + + for (const { name, run } of checks) { + const result = await runCheckWithSpinner(name, run); + results.push({ name, ...result }); + const mark = result.ok ? icon.ok : icon.fail; + const msgColor = result.ok ? brand.dim : brand.warn; + console.log(` ${mark} ${brand.bold(name)} ${msgColor(result.message)}`); + if (!result.ok && result.fix) { + fixes.push({ name, fix: result.fix }); + } + } + + const passed = results.filter((r) => r.ok).length; + const failed = results.length - passed; + + console.log(` ${divider(w)}`); + console.log( + ` ${brand.dim(t('doctor.summary', { passed, failed, total: results.length }))}` + ); + + if (failed === 0) { + console.log(` ${ok(t('doctor.allPass'))}`); + } else { + console.log(` ${warn(t('doctor.failed', { count: failed }))}`); + if (fixes.length) { + console.log(''); + console.log(sectionHeader(t('doctor.fixesHeader'))); + for (const { name, fix } of fixes) { + console.log(` ${brand.primary('→')} ${brand.dim(name)} ${brand.warn(fix)}`); + } + } + } + console.log(''); + return failed === 0 ? 0 : 1; +} + +module.exports = { runDoctor }; diff --git a/cli/lib/http.js b/cli/lib/http.js new file mode 100644 index 00000000..403c486a --- /dev/null +++ b/cli/lib/http.js @@ -0,0 +1,182 @@ +const fs = require('fs'); +const http = require('http'); +const path = require('path'); + +function loadServerSiteUrl(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const match = content.match(/^SERVER_SITE_URL=(.+)$/m); + if (match) { + return match[1].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return 'http://localhost:3002'; +} + +function loadClientSiteUrl(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const match = content.match(/^CLIENT_SITE_URL=(.+)$/m); + if (match) { + return match[1].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return 'http://localhost:3001'; +} + +function getApiPrefix(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const match = content.match(/^SERVER_API_PREFIX=(.+)$/m); + if (match) { + return match[1].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return '/api'; +} + +function getHealthUrl(projectRoot) { + const base = loadServerSiteUrl(projectRoot).replace(/\/$/, ''); + const prefix = getApiPrefix(projectRoot).replace(/\/$/, ''); + return `${base}${prefix}/health`; +} + +function probeHttp(url, timeoutMs = 3000) { + return new Promise((resolve) => { + let parsed; + try { + parsed = new URL(url); + } catch { + resolve({ ok: false, statusCode: 0, data: null }); + return; + } + const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80); + const req = http.request( + { + hostname: parsed.hostname, + port, + path: parsed.pathname + (parsed.search || ''), + method: 'GET', + timeout: timeoutMs, + }, + (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + const ok = res.statusCode === 200; + let data = null; + try { + data = JSON.parse(body); + } catch { + // ignore + } + resolve({ ok, statusCode: res.statusCode, data }); + }); + } + ); + req.on('timeout', () => { + req.destroy(); + resolve({ ok: false, statusCode: 0, data: null }); + }); + req.on('error', () => resolve({ ok: false, statusCode: 0, data: null })); + req.end(); + }); +} + +/** + * Health probe: prefers `/api/health` JSON; falls back to API prefix (e.g. Swagger) + * for older bundled servers that omit the health route. + */ +async function checkHealth(url, timeoutMs = 3000) { + const primary = await probeHttp(url, timeoutMs); + if (primary.ok) return primary; + + if (primary.statusCode === 404 || primary.statusCode === 0) { + try { + const parsed = new URL(url); + const prefix = parsed.pathname.replace(/\/health\/?$/, '') || '/api'; + const candidates = [ + `${parsed.origin}${prefix}/`, + `${parsed.origin}${prefix}`, + parsed.origin, + ]; + for (const fallback of candidates) { + const alt = await probeHttp(fallback, timeoutMs); + if (alt.statusCode === 200) { + return { + ok: true, + statusCode: 200, + data: { status: 'ok', database: 'unknown' }, + }; + } + } + } catch { + // ignore + } + } + + return primary; +} + +function isHttpResponding(url, timeoutMs = 2000) { + return new Promise((resolve) => { + let parsed; + try { + parsed = new URL(url); + } catch { + resolve(false); + return; + } + + const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80); + const req = http.request( + { + hostname: parsed.hostname, + port, + path: parsed.pathname || '/', + method: 'GET', + timeout: timeoutMs, + }, + (res) => resolve(res.statusCode > 0) + ); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.on('error', () => resolve(false)); + req.end(); + }); +} + +async function waitForHttp(url, timeoutMs = 120_000, intervalMs = 500) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isHttpResponding(url)) { + return true; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +} + +module.exports = { + loadServerSiteUrl, + loadClientSiteUrl, + getApiPrefix, + getHealthUrl, + checkHealth, + isHttpResponding, + waitForHttp, +}; diff --git a/cli/lib/i18n/index.js b/cli/lib/i18n/index.js new file mode 100644 index 00000000..afeedd02 --- /dev/null +++ b/cli/lib/i18n/index.js @@ -0,0 +1,32 @@ +const { STRINGS } = require('./strings'); + +function resolveLocale() { + const raw = process.env.REACTPRESS_LANG || process.env.LANG || 'en'; + const code = String(raw).split(/[._-]/)[0].toLowerCase(); + return code === 'zh' ? 'zh' : 'en'; +} + +let locale = resolveLocale(); + +/** + * @param {string} key + * @param {Record} [vars] + */ +function t(key, vars = {}) { + const table = STRINGS[locale] || STRINGS.en; + let text = table[key] ?? STRINGS.en[key] ?? key; + for (const [name, value] of Object.entries(vars)) { + text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)); + } + return text; +} + +function getLocale() { + return locale; +} + +function setLocale(next) { + locale = next === 'zh' ? 'zh' : 'en'; +} + +module.exports = { t, getLocale, setLocale, resolveLocale }; diff --git a/cli/lib/i18n/strings.js b/cli/lib/i18n/strings.js new file mode 100644 index 00000000..4469cc24 --- /dev/null +++ b/cli/lib/i18n/strings.js @@ -0,0 +1,846 @@ +/** CLI user-facing strings — English first, Chinese via REACTPRESS_LANG=zh or zh LANG */ +const STRINGS = { + en: { + 'cli.description': 'ReactPress CLI — init, dev, build, deploy, and publish', + 'cli.init.description': 'Initialize project (.reactpress/config.json + .env + Docker MySQL)', + 'cli.init.directory': 'Project directory', + 'cli.init.force': 'Overwrite existing config', + 'cli.dev.description': 'Zero-config dev: env check + toolkit build + API + frontend', + 'cli.dev.apiOnly': 'API only (watch)', + 'cli.dev.clientOnly': 'Frontend only', + 'cli.server.description': 'Manage API service', + 'cli.server.start.description': 'Start API (wait until HTTP ready)', + 'cli.server.start.pm2': 'Start with PM2 (production)', + 'cli.server.start.bg': 'Start in background without waiting for HTTP', + 'cli.server.stop': 'Stop API', + 'cli.server.restart': 'Restart API', + 'cli.server.status': 'API status', + 'cli.client.description': 'Manage frontend', + 'cli.client.start': 'Start Next.js client', + 'cli.client.start.pm2': 'Start with PM2', + 'cli.build.description': 'Build production artifacts', + 'cli.docker.description': 'Docker dev environment (MySQL + nginx)', + 'cli.docker.up': 'Start Docker services and wait for MySQL', + 'cli.docker.down': 'Stop Docker services', + 'cli.docker.start': 'Start Docker + full-stack dev (API + frontend)', + 'cli.docker.restart': 'Restart Docker services', + 'cli.docker.status': 'Docker container status', + 'cli.docker.logs': 'Docker logs (db | nginx)', + 'cli.nginx.description': 'Nginx reverse proxy (unified entry on :80)', + 'cli.nginx.ensure': 'Write default nginx config if missing', + 'cli.nginx.up': 'Start nginx container', + 'cli.nginx.down': 'Stop nginx container', + 'cli.nginx.restart': 'Restart nginx container', + 'cli.nginx.status': 'Show nginx container and config status', + 'cli.nginx.logs': 'Follow nginx container logs', + 'cli.nginx.test': 'Validate nginx config inside container (nginx -t)', + 'cli.nginx.reload': 'Reload nginx after config change', + 'cli.nginx.open': 'Open nginx entry URL in browser', + 'cli.nginx.prod': 'Use production compose + nginx.conf (monorepo only)', + 'cli.nginx.force': 'Overwrite existing nginx config from template', + 'cli.help.nginx': ' reactpress nginx up Start reverse proxy (:80)', + 'cli.status.description': 'Project, API, frontend, and Docker status', + 'cli.doctor.description': 'Diagnose Node, Docker, ports, database, and API health', + 'cli.db.description': 'Database operations', + 'cli.db.backup': 'Backup current project database with mysqldump', + 'cli.db.backup.output': 'Output SQL file path', + 'cli.publish.description': 'Build and publish npm packages (interactive)', + 'cli.publish.build': 'Build all packages only', + 'cli.publish.publish': 'Interactive publish', + 'cli.start.description': 'Production: start API + frontend', + 'cli.help.examples': 'Examples:', + 'cli.help.interactive': ' reactpress Interactive menu', + 'cli.help.dev': ' reactpress dev Zero-config full-stack dev', + 'cli.help.init': ' reactpress init --force Re-initialize config', + 'cli.help.server': ' reactpress server start Start API', + 'cli.help.status': ' reactpress status Combined status', + 'cli.help.doctor': ' reactpress doctor Environment diagnostics', + 'cli.help.docker': ' reactpress docker start Docker + full stack', + 'cli.help.build': ' reactpress build -t client Build one target (toolkit|server|client|docs|all)', + 'cli.help.publish': ' reactpress publish Publish npm packages', + 'cli.build.target': 'Build target: toolkit | server | client | docs | all', + 'banner.subtitle': ' · Full-stack publishing CLI ', + /** Left label for the decorative pulse bar (not a URL — the repo link + * lives directly under the title bar at the top of the card). */ + 'banner.pulseLabel': 'Setup', + 'banner.pulseReady': 'READY', + 'banner.pulsePending': 'INIT', + 'banner.label.mode': 'MODE', + 'banner.label.path': 'PATH', + 'banner.mode.standalone': 'STANDALONE', + 'banner.mode.monorepo': 'MONOREPO', + 'banner.mode.uninitialized': 'UNINITIALIZED', + 'banner.systemLabel': 'SYSTEM', + 'banner.systemOnline': 'ONLINE', + 'banner.systemPending': 'PENDING', + 'menu.dev': 'Zero-config dev (env + DB + API + frontend)', + 'menu.init': 'Initialize project (.reactpress + .env + database)', + 'menu.status': 'View project status', + 'menu.doctor': 'Environment diagnostics (doctor)', + 'menu.devApi': 'API only (dev watch)', + 'menu.devClient': 'Frontend only', + 'menu.serverStart': 'Start API (production background)', + 'menu.serverStop': 'Stop API', + 'menu.serverRestart': 'Restart API', + 'menu.build': 'Build (toolkit → server → client)', + 'menu.buildTarget': 'What do you want to build?', + 'menu.buildAll': 'All (toolkit → server → client)', + 'menu.dockerStart': 'Docker dev (DB + nginx + full stack)', + 'menu.dockerUp': 'Docker: database only', + 'menu.dockerStop': 'Stop Docker services', + 'menu.openAdmin': 'Open admin in browser', + 'menu.publish': 'Publish npm packages (interactive)', + 'menu.exit': 'Exit', + 'menu.prompt': 'Choose an action', + 'menu.back': 'Return to main menu?', + 'menu.retry': 'Return to main menu and retry?', + 'menu.startingDev': 'Starting full-stack dev…', + 'menu.initProject': 'Initializing project…', + 'menu.done': 'Done', + 'menu.opening': 'Opening {url}', + 'menu.goodbye': ' Goodbye.', + 'menu.section.run': 'Run', + 'menu.section.lifecycle': 'Lifecycle', + 'menu.section.build': 'Build & Deploy', + 'menu.section.tools': 'Tools', + 'menu.tip': 'Tip: arrow keys to navigate, Enter to select, Ctrl+C to quit.', + 'menu.shortcuts': '↑/↓ navigate · enter select · esc back · ctrl+c quit', + 'menu.statusHeader': 'Status', + 'menu.contextStandalone': 'Project mode · standalone (using bundled API)', + 'menu.contextMonorepo': 'Project mode · monorepo (server/src + client/)', + 'menu.contextUnknown': 'Project mode · not initialized (run `init`)', + 'menu.statusApi': 'API {status}', + 'menu.statusDb': 'DB {status}', + 'menu.statusDocker': 'Docker {status}', + 'menu.statusLabelApi': 'API', + 'menu.statusLabelDb': 'DB', + 'menu.statusLabelDocker': 'Docker', + 'menu.statusChecking': 'checking…', + 'menu.startingApi': 'Starting API…', + 'menu.stoppingApi': 'Stopping API…', + 'menu.restartingApi': 'Restarting API…', + 'menu.statusOn': 'online', + 'menu.statusOff': 'offline', + 'menu.statusReady': 'ready', + 'menu.statusNotReady': 'not ready', + 'menu.statusYes': 'available', + 'menu.statusNo': 'unavailable', + 'menu.hint.dev': 'API + DB + frontend', + 'menu.hint.init': '.reactpress + .env', + 'menu.hint.status': 'all services overview', + 'menu.hint.doctor': 'environment diagnostics', + 'menu.hint.devApi': 'watch mode', + 'menu.hint.devClient': 'Next.js dev', + 'menu.hint.serverStart': 'production background', + 'menu.hint.serverStop': '', + 'menu.hint.serverRestart': '', + 'menu.hint.build': 'production output', + 'menu.hint.dockerStart': 'DB + nginx + dev stack', + 'menu.hint.dockerUp': 'database only', + 'menu.hint.dockerStop': '', + 'menu.nginxUp': 'Start nginx reverse proxy (:80)', + 'menu.nginxOpen': 'Open nginx entry in browser', + 'menu.nginxReload': 'Reload nginx after editing config', + 'menu.hint.nginxUp': 'unified entry :80', + 'menu.hint.nginxOpen': 'http://localhost', + 'menu.hint.nginxReload': 'nginx -t && reload', + 'menu.hint.openAdmin': 'opens in browser', + 'menu.hint.publish': 'maintainers only', + 'menu.hint.exit': '', + 'menu.actionPrefix': 'action', + 'dev.startingApi': '[reactpress] Starting API (first run may install deps)…', + 'dev.waitingApi': '[reactpress] Waiting for API: {url}', + 'dev.apiTimeout': '[reactpress] API not ready within {seconds}s.\n → Run reactpress doctor\n → Embedded MySQL: reactpress docker up\n → Check DB_* and SERVER_SITE_URL in .env', + 'dev.apiReady': '[reactpress] API ready, starting frontend…', + 'dev.clientSlow': '[reactpress] Frontend not responding within {seconds}s; it may still be compiling. Visit {url} later', + 'dev.envFailed': '[reactpress] Environment setup failed:', + 'dev.toolkitFailed': 'toolkit build failed with exit code: {code}', + 'dev.nextSteps': 'Suggested next steps:', + 'dev.nextDoctor': ' → reactpress doctor Diagnostics', + 'dev.nextDocker': ' → reactpress docker up Start embedded MySQL', + 'dev.nextEnv': ' → Check DB_* and SERVER_SITE_URL in .env', + 'dev.standaloneHint': '[reactpress] Standalone project: only API is started here; build your own frontend separately.', + 'devBanner.ready': 'ReactPress dev environment is ready', + 'devBanner.site': 'Site', + 'devBanner.admin': 'Admin', + 'devBanner.api': 'API', + 'devBanner.swagger': 'Swagger', + 'devBanner.health': 'Health', + 'devBanner.hint': 'Diagnostics: reactpress doctor · Status: reactpress status', + 'devBanner.shortcuts': 'Ctrl+C stop', + 'devBanner.allSystemsGo': 'ALL SYSTEMS GO', + 'doctor.nodeBad': 'Node.js {version} (requires ≥ 18)', + 'doctor.nodeFix': 'Install Node.js 18+: https://nodejs.org/', + 'doctor.dockerOk': 'Docker engine is available', + 'doctor.dockerBad': 'Docker is not running or unavailable', + 'doctor.dockerFix': 'Install and start Docker: https://docs.docker.com/get-docker/ , then run reactpress docker up; or use external MySQL in config.json', + 'doctor.portApiBusy': 'API port {port} is in use', + 'doctor.portClientBusy': 'Frontend port {port} is in use', + 'doctor.portFix': 'Change SERVER_PORT / CLIENT_PORT in .env, or stop the blocking process', + 'doctor.portOk': 'Ports {apiPort} (API) and {clientPort} (frontend) are available', + 'doctor.dbNoMysql2': 'mysql2 not installed; cannot check database', + 'doctor.dbMysql2Fix': 'Run pnpm install at monorepo root', + 'doctor.dbOk': 'MySQL {host}:{port}/{database} connected', + 'doctor.dbBad': 'Database connection failed: {error}', + 'doctor.dbFix': 'Run reactpress docker up or check DB_* in .env', + 'doctor.apiOk': 'API health check passed ({url})', + 'doctor.apiBad': 'API health check failed ({url})', + 'doctor.apiFix': 'Run reactpress server start or reactpress dev', + 'doctor.pnpmBad': 'pnpm not found', + 'doctor.pnpmFix': 'npm i -g pnpm, or enable corepack at monorepo root', + 'doctor.check.config': 'Config file', + 'doctor.check.env': 'Environment', + 'doctor.check.ports': 'Ports', + 'doctor.check.database': 'Database', + 'doctor.check.api': 'API health', + 'doctor.configOk': '.reactpress/config.json exists', + 'doctor.configBad': 'Missing .reactpress/config.json', + 'doctor.configFix': 'Run reactpress init', + 'doctor.envOk': '.env exists', + 'doctor.envBad': 'Missing .env', + 'doctor.envFix': 'Run reactpress init or reactpress config --apply', + 'doctor.project': 'Project {path}', + 'doctor.allPass': 'All checks passed. You can start developing.', + 'doctor.failed': '{count} item(s) need attention.', + 'doctor.title': 'ReactPress Doctor', + 'doctor.subtitle': 'environment diagnostics', + 'doctor.checking': 'Checking {name}…', + 'doctor.summary': '{passed} passed · {failed} failed · {total} total', + 'doctor.fixesHeader': 'Suggested fixes', + 'status.title': 'ReactPress project status', + 'status.dir': 'Project {path}', + 'status.apiSource': 'API source {source}', + 'status.apiSource.monorepo': 'monorepo server/', + 'status.apiSource.bundle': 'reactpress-cli', + 'status.configOk': '.reactpress/config.json', + 'status.configBad': 'not initialized', + 'status.envOk': '.env', + 'status.envBad': 'missing .env', + 'status.apiOnline': 'online', + 'status.apiOffline': 'offline', + 'status.apiUnreachable': '{url} (offline or not started)', + 'status.dbUp': 'connected', + 'status.dbDown': 'unavailable', + 'status.pidRunning': '(running)', + 'status.frontend': 'Frontend', + 'status.docker': 'Docker', + 'status.dockerUp': 'available', + 'status.dockerDown': 'not running', + 'status.section.project': 'Project', + 'status.section.api': 'API service', + 'status.section.frontend': 'Frontend', + 'status.section.docker': 'Docker', + 'status.field.url': 'URL', + 'status.field.http': 'HTTP', + 'status.field.health': 'Health', + 'status.field.database': 'Database', + 'status.field.pid': 'PID', + 'status.field.engine': 'Engine', + 'status.field.config': 'Config', + 'status.field.env': '.env', + 'status.field.source': 'Source', + 'status.field.dir': 'Directory', + 'bootstrap.configReady': 'Config exists and database is ready.', + 'bootstrap.projectDbPending': 'Project created, but database is not ready: {message}. Start Docker and run reactpress dev again.', + 'bootstrap.ready': 'ReactPress dev environment is ready (config + database).', + 'bootstrap.initFailed': 'Initialization failed', + 'bootstrap.cliInitFailed': 'reactpress-cli init failed', + 'bootstrap.dbPendingShort': 'Database not ready', + 'bootstrap.dbNotReady': '{message}. Tip: start Docker and run reactpress docker up, or run reactpress doctor', + 'bootstrap.dbReady': 'Database is ready', + 'db.backup.to': 'Backing up database to {path}', + 'db.backup.done': 'Backup complete', + 'db.backup.viaDocker': 'mysqldump not on PATH; using mysqldump inside the db container…', + 'db.backup.fail': + 'mysqldump failed; install a MySQL client (e.g. brew install mysql-client), or ensure Docker db is running for automatic container backup', + 'common.done': 'Done', + 'common.yes': 'yes', + 'common.no': 'no', + 'common.none': '(none)', + 'common.unknownError': 'Unknown error', + 'lifecycle.apiStopped': '[reactpress] API process stopped (pid {pid})', + 'lifecycle.stopPidFailed': '[reactpress] Failed to stop pid {pid}:', + 'lifecycle.apiAlreadyRunning': '[reactpress] API already running (pid {pid})', + 'lifecycle.noServerAvailable': '[reactpress] No API runtime found. Reinstall @fecommunity/reactpress or run from a project with server/src.', + 'lifecycle.startingLocalApi': '[reactpress] Starting local API (server/)…', + 'lifecycle.startingBundledApi': '[reactpress] Starting bundled API…', + 'lifecycle.apiStartedBg': '[reactpress] API started in background (pid {pid})', + 'lifecycle.apiTimeout120': '[reactpress] API not ready within 120s: {url}', + 'lifecycle.apiReady': '[reactpress] API ready: {url}', + 'lifecycle.apiStatusTitle': '[reactpress] API status', + 'lifecycle.source': ' Source: {source}', + 'lifecycle.source.monorepo': 'monorepo server/', + 'lifecycle.source.bundle': 'bundled API (@fecommunity/reactpress)', + 'lifecycle.pidFile': ' PID file: {path}', + 'lifecycle.recordedPid': ' Recorded PID: {pid}', + 'lifecycle.processAlive': ' Process alive: {alive}', + 'lifecycle.httpStatus': ' HTTP ({url}): {status}', + 'lifecycle.httpReachable': 'reachable', + 'lifecycle.httpUnreachable': 'unreachable', + 'lifecycle.unknownCommand': 'Unknown lifecycle command: {command}', + 'docker.stopping': '[reactpress] Stopping Docker services…', + 'docker.stopped': '[reactpress] Docker services stopped.', + 'docker.stopFailed': '[reactpress] Failed to stop Docker:', + 'docker.starting': '[reactpress] Starting Docker services…', + 'docker.notRunning': 'Docker is not running. Please start Docker Desktop first.', + 'docker.started': '[reactpress] Docker services started.', + 'docker.waitingMysql': '[reactpress] Waiting for MySQL…', + 'docker.mysqlReady': '[reactpress] MySQL is ready.', + 'docker.waitingMysqlProgress': '[reactpress] Waiting for MySQL… ({attempts}/{max})', + 'docker.mysqlTimeout': '[reactpress] MySQL not ready within timeout.', + 'docker.mysqlNotReady': 'MySQL is not ready', + 'docker.startDevStack': '[reactpress] Starting API + frontend (Docker MySQL)…', + 'docker.visitUrls': '[reactpress] Visit: http://localhost (nginx) / http://localhost:3001 (client)', + 'docker.devProcessExit': 'Dev process exited with code {code}', + 'docker.unknownCommand': 'Unknown docker command: {command}', + 'nginx.configCreated': '[reactpress] Created nginx config: {path}', + 'nginx.configExists': '[reactpress] Nginx config already exists: {path}', + 'nginx.ensureWarn': '[reactpress] Could not ensure nginx config: {message}', + 'nginx.started': '[reactpress] Nginx started — {url}', + 'nginx.configPath': '[reactpress] Config: {path}', + 'nginx.stopped': '[reactpress] Nginx stopped.', + 'nginx.startFailed': 'Failed to start nginx container', + 'nginx.prodMonorepoOnly': 'Production nginx (--prod) requires monorepo with docker-compose.prod.yml', + 'nginx.statusTitle': '[reactpress] Nginx status', + 'nginx.statusContainer': ' Container {name}: {running}', + 'nginx.statusConfig': ' Config {path}: {exists}', + 'nginx.statusUrl': ' Entry {url} (port {port})', + 'nginx.statusMode': ' Mode: {mode}', + 'nginx.notRunning': 'Nginx container is not running. Run: reactpress nginx up', + 'nginx.testOk': '[reactpress] Nginx config test passed.', + 'nginx.testFailed': 'Nginx config test failed', + 'nginx.reloadOk': '[reactpress] Nginx reloaded.', + 'nginx.reloadFailed': 'Nginx reload failed', + 'nginx.opening': '[reactpress] Opening {url}', + 'nginx.unknownCommand': 'Unknown nginx command: {command}', + 'nginx.templateMissing': 'Bundled nginx template missing: {path}', + 'nginx.doctorSkippedDocker': 'Skipped (Docker not running)', + 'nginx.doctorSkippedNotRunning': 'Not started (optional: reactpress nginx up)', + 'nginx.doctorNotRunningFix': 'reactpress nginx up (or reactpress docker up)', + 'nginx.doctorOk': 'Nginx healthy ({url}/health)', + 'nginx.doctorUnhealthy': 'Nginx running but /health failed ({url})', + 'nginx.doctorUnhealthyFix': 'Ensure client (:3001) and API (:3002) are running; reactpress nginx reload', + 'doctor.check.nginx': 'Nginx proxy', + 'apiDev.modeServer': '[reactpress] Dev mode: server/ (nest start --watch)', + 'apiDev.modeBundled': '[reactpress] Dev mode: bundled API (built-in server)', + 'apiDev.ctrlCHint': '[reactpress] Press Ctrl+C to stop API.', + 'apiDev.stopHint': '[reactpress] Stop separately: reactpress server stop', + 'build.unknownTarget': 'Unknown build target: {target}. Available: {available}', + 'build.recursive': 'Build recursion detected (pnpm run build must not call itself). Use build:toolkit, build:server, or build:client.', + 'build.forbiddenScript': 'Invalid build script "{script}"; use granular scripts like build:toolkit.', + 'build.stepFailed': '[{current}/{total}] {label} failed', + 'build.plan': 'Production build — {total} step(s): toolkit → server → client', + 'build.step': '[{current}/{total}] {label}', + 'build.stepDone': '[{current}/{total}] {label} ({seconds}s)', + 'build.stepSkipped': 'skipped {label} (not part of this project layout)', + 'build.done': 'Build finished in {seconds}s', + 'build.label.toolkit': 'Toolkit', + 'build.label.server': 'API (server)', + 'build.label.client': 'Frontend (client)', + 'build.label.docs': 'Documentation (docs)', + 'pm2.startFailed': '[reactpress] PM2 failed to start API:', + 'pm2.exitCode': 'PM2 exited with code {code}', + 'spawn.commandFailed': 'Command failed ({command}): exit code {code}', + 'spawn.exitCode': 'Exit code {code}', + 'shim.deprecated': '\n[deprecated] reactpress-cli will be removed in 3.1. Use instead:\n npm i -g @fecommunity/reactpress\n reactpress init · reactpress dev · reactpress doctor\n', + 'server.help.invokedBy': ' (usually invoked by reactpress server start)', + 'publish.pkg.main': 'ReactPress 3.0 main package — single entry (init / dev / doctor / publish)', + 'publish.pkg.server': 'NestJS backend API (deprecated — use bundled API in reactpress-cli)', + 'bundle.cli.description': 'Zero-config init and manage ReactPress CMS & blog server', + 'bundle.cli.cwd': 'ReactPress project directory (default: current working directory)', + 'bundle.cli.init.description': 'One-click init ReactPress CMS & blog (zero config)', + 'bundle.cli.init.directory': 'Project directory', + 'bundle.cli.init.force': 'Overwrite existing config', + 'bundle.cli.start.description': 'Start server (prepares database automatically)', + 'bundle.cli.stop.description': 'Stop server', + 'bundle.cli.stop.database': 'Also stop embedded database container', + 'bundle.cli.restart.description': 'Restart server', + 'bundle.cli.status.description': 'Server and database status', + 'bundle.cli.config.description': 'View or update config (--apply to restart)', + 'bundle.cli.config.key': 'Config key, e.g. server.port', + 'bundle.cli.config.value': 'New value', + 'bundle.cli.config.list': 'List all config keys', + 'bundle.cli.config.apply': 'Restart automatically after update', + 'bundle.cli.unknownCommand': 'Unknown command: {command}', + 'bundle.cmd.init.spinner': 'Initializing ReactPress project…', + 'bundle.cmd.init.succeed': 'Initialization complete', + 'bundle.cmd.init.fail': 'Initialization failed', + 'bundle.cmd.init.projectDir': 'Project directory: {path}', + 'bundle.cmd.init.nextStep': 'Next: reactpress-cli start', + 'bundle.cmd.notProject': 'This directory is not a ReactPress project.', + 'bundle.cmd.notProjectInit': 'This directory is not a ReactPress project. Run reactpress-cli init first.', + 'bundle.cmd.start.spinner': 'Preparing database and server…', + 'bundle.cmd.start.succeed': 'Server started', + 'bundle.cmd.start.fail': 'Start failed', + 'bundle.cmd.stop.spinner': 'Stopping server…', + 'bundle.cmd.stop.succeed': 'Stopped', + 'bundle.cmd.stop.fail': 'Stop failed', + 'bundle.cmd.restart.spinner': 'Restarting server…', + 'bundle.cmd.restart.succeed': 'Restart complete', + 'bundle.cmd.restart.fail': 'Restart failed', + 'bundle.cmd.status.title': 'Service status', + 'bundle.cmd.status.project': 'Project: {path}', + 'bundle.cmd.status.service': 'Service: {status}{pid}', + 'bundle.cmd.status.running': 'running', + 'bundle.cmd.status.stopped': 'stopped', + 'bundle.cmd.status.url': 'URL: {url}', + 'bundle.cmd.status.database': 'Database: {status} ({mode})', + 'bundle.cmd.status.dbReady': 'ready', + 'bundle.cmd.status.dbNotReady': 'not ready', + 'bundle.cmd.config.title': 'Configuration', + 'bundle.cmd.config.keyRequired': 'Specify a config key, e.g.: reactpress-cli config server.port 3003', + 'bundle.cmd.config.listHint': 'Use --list to show all keys', + 'bundle.cmd.config.updated': 'Updated {key} = {value}', + 'bundle.cmd.config.restartSpinner': 'Restarting to apply config…', + 'bundle.cmd.config.applied': 'Config applied', + 'bundle.cmd.config.restartFail': 'Restart failed', + 'bundle.cmd.config.restartManual': 'Run reactpress-cli restart manually', + 'bundle.cmd.config.applyHint': 'Run reactpress-cli restart or config --apply to apply changes', + 'bundle.service.init.alreadyProject': 'Directory is already a ReactPress project. Use --force to overwrite config.', + 'bundle.service.init.dbPending': 'Project created, but database is not ready: {message}. Run reactpress-cli start later.', + 'bundle.service.init.complete': 'ReactPress project initialized. Run reactpress-cli start to launch the server.', + 'bundle.service.init.templateMissing': 'Template file missing: {path}', + 'bundle.service.config.notFound': 'ReactPress project not found. Run reactpress-cli init first.', + 'bundle.service.config.keyMissing': 'Config key does not exist: {key}', + 'bundle.service.server.alreadyRunning': 'Server already running (PID {pid}), visit {url}', + 'bundle.service.server.portBusy': 'Port {port} is in use; ReactPress cannot bind. If you started via Docker Compose, run: docker stop reactpress_server. Or change server.port in .reactpress/config.json.', + 'bundle.service.server.cannotStart': 'Could not start ReactPress server process.', + 'bundle.service.server.noHttp': 'Server process started (PID {pid}) but no HTTP response at {url}. Check port conflicts or DB_* in .env.', + 'bundle.service.server.started': 'ReactPress server started at {url}', + 'bundle.service.server.stopped': 'ReactPress server stopped.', + 'bundle.service.server.cannotStopPid': 'Could not stop process PID {pid}', + 'bundle.service.database.dockerMissing': 'Docker not detected. Install and start Docker, or set database.mode to external with an existing MySQL.', + 'bundle.service.database.portSwitched': 'Host port {previous} was in use; switched to {port} (.env updated)', + 'bundle.service.database.portBindRetry': 'Port {port} bind failed, trying another port…', + 'bundle.service.database.containerStartFailed': 'Failed to start database container: {detail}', + 'bundle.service.database.credsMismatch': 'Database container is on port {port} but user "{user}" cannot connect (volume credentials differ from .env). Run: cd .reactpress && docker compose down -v && cd .. && reactpress-cli start', + 'bundle.service.database.connectionTimeout': 'Database container started but connection timed out. Run: docker logs {container}', + 'bundle.service.database.cannotConnect': 'Cannot connect to database {host}:{port}. Check DB_* in .env.', + 'bundle.serverBundle.missing': 'Bundled server is missing. Reinstall reactpress-cli.', + 'bundle.serverBundle.installFailed': 'Bundled server dependency install failed. In the reactpress-cli install dir run: cd server && npm install --omit=dev --no-bin-links', + 'bundle.serverBundle.notBuilt': 'Bundled server is not built or dependencies are incomplete. Run npm run build:server (dev) or reinstall reactpress-cli.', + 'bundle.port.notFound': 'No available port in range {start}-{end}', + }, + zh: { + 'cli.description': 'ReactPress 全栈 CLI — 初始化、开发、构建、部署、发布', + 'cli.init.description': '初始化项目 (.reactpress/config.json + .env + Docker MySQL)', + 'cli.init.directory': '项目目录', + 'cli.init.force': '覆盖已有配置', + 'cli.dev.description': '零配置开发: 环境检查 + toolkit 构建 + API + 前端', + 'cli.dev.apiOnly': '仅启动 API (watch)', + 'cli.dev.clientOnly': '仅启动前端', + 'cli.server.description': '管理 API 服务', + 'cli.server.start.description': '启动 API(等待 HTTP 就绪)', + 'cli.server.start.pm2': '使用 PM2 启动(生产)', + 'cli.server.start.bg': '后台启动,不等待 HTTP', + 'cli.server.stop': '停止 API', + 'cli.server.restart': '重启 API', + 'cli.server.status': '查看 API 状态', + 'cli.client.description': '管理前端', + 'cli.client.start': '启动 Next.js 客户端', + 'cli.client.start.pm2': '使用 PM2 启动', + 'cli.build.description': '构建生产产物', + 'cli.docker.description': 'Docker 开发环境 (MySQL + nginx)', + 'cli.docker.up': '仅启动 Docker 服务并等待 MySQL', + 'cli.docker.down': '停止 Docker 服务', + 'cli.docker.start': '启动 Docker + 全栈开发 (API + 前端)', + 'cli.docker.restart': '重启 Docker 服务', + 'cli.docker.status': '查看 Docker 容器状态', + 'cli.docker.logs': '查看 Docker 日志 (db | nginx)', + 'cli.nginx.description': 'Nginx 反向代理(统一入口 :80)', + 'cli.nginx.ensure': '若缺失则生成默认 nginx 配置', + 'cli.nginx.up': '启动 nginx 容器', + 'cli.nginx.down': '停止 nginx 容器', + 'cli.nginx.restart': '重启 nginx 容器', + 'cli.nginx.status': '查看 nginx 容器与配置状态', + 'cli.nginx.logs': '跟踪 nginx 容器日志', + 'cli.nginx.test': '在容器内校验配置 (nginx -t)', + 'cli.nginx.reload': '修改配置后热加载 nginx', + 'cli.nginx.open': '在浏览器打开 nginx 入口', + 'cli.nginx.prod': '使用生产 compose + nginx.conf(仅 monorepo)', + 'cli.nginx.force': '用模板覆盖已有 nginx 配置', + 'cli.help.nginx': ' reactpress nginx up 启动反向代理 (:80)', + 'cli.status.description': '查看项目、API、前端、Docker 综合状态', + 'cli.doctor.description': '诊断环境:Node、Docker、端口、数据库、API 健康', + 'cli.db.description': '数据库运维', + 'cli.db.backup': '使用 mysqldump 备份当前项目数据库', + 'cli.db.backup.output': '输出 SQL 文件路径', + 'cli.publish.description': '构建并发布 npm 包 (交互式)', + 'cli.publish.build': '仅构建所有包', + 'cli.publish.publish': '交互式发布', + 'cli.start.description': '生产模式: 启动 API + 前端', + 'cli.help.examples': '示例:', + 'cli.help.interactive': ' reactpress 交互式菜单', + 'cli.help.dev': ' reactpress dev 零配置全栈开发', + 'cli.help.init': ' reactpress init --force 重新初始化配置', + 'cli.help.server': ' reactpress server start 启动 API', + 'cli.help.status': ' reactpress status 综合状态', + 'cli.help.doctor': ' reactpress doctor 环境诊断', + 'cli.help.docker': ' reactpress docker start Docker + 全栈', + 'cli.help.build': ' reactpress build -t client 构建指定目标 (toolkit|server|client|docs|all)', + 'cli.help.publish': ' reactpress publish 发布 npm 包', + 'cli.build.target': '构建目标: toolkit | server | client | docs | all', + 'banner.subtitle': ' · 全栈发布平台 CLI ', + /** 左侧装饰性进度条标签(不是网址;仓库地址放在卡片顶部 Title 正下方)。 */ + 'banner.pulseLabel': '准备', + 'banner.pulseReady': '就绪', + 'banner.pulsePending': '初始化', + 'banner.label.mode': '模式', + 'banner.label.path': '路径', + 'banner.mode.standalone': '独立项目', + 'banner.mode.monorepo': 'MONOREPO', + 'banner.mode.uninitialized': '未初始化', + 'banner.systemLabel': '系统', + 'banner.systemOnline': '在线', + 'banner.systemPending': '准备中', + 'menu.dev': '零配置开发 (env + DB + API + 前端)', + 'menu.init': '初始化项目 (.reactpress + .env + 数据库)', + 'menu.status': '查看项目状态', + 'menu.doctor': '环境诊断 (doctor)', + 'menu.devApi': '仅启动 API (开发 watch)', + 'menu.devClient': '仅启动前端', + 'menu.serverStart': '启动 API (后台生产)', + 'menu.serverStop': '停止 API', + 'menu.serverRestart': '重启 API', + 'menu.build': '构建 (toolkit → server → client)', + 'menu.buildTarget': '选择要构建的目标', + 'menu.buildAll': '全部 (toolkit → server → client)', + 'menu.dockerStart': 'Docker 开发环境 (DB + nginx + 全栈)', + 'menu.dockerUp': 'Docker 仅启动数据库', + 'menu.dockerStop': '停止 Docker 服务', + 'menu.openAdmin': '在浏览器打开管理后台', + 'menu.publish': '发布 npm 包 (交互式)', + 'menu.exit': '退出', + 'menu.prompt': '选择操作', + 'menu.back': '返回主菜单?', + 'menu.retry': '返回主菜单重试?', + 'menu.startingDev': '启动全栈开发…', + 'menu.initProject': '初始化项目…', + 'menu.done': '完成', + 'menu.opening': '打开 {url}', + 'menu.goodbye': ' 再见。', + 'menu.section.run': '运行', + 'menu.section.lifecycle': '生命周期', + 'menu.section.build': '构建与部署', + 'menu.section.tools': '工具', + 'menu.tip': '提示:上下方向键选择,回车确认,Ctrl+C 退出。', + 'menu.shortcuts': '↑/↓ 选择 · 回车 确认 · esc 返回 · Ctrl+C 退出', + 'menu.statusHeader': '当前状态', + 'menu.contextStandalone': '项目类型 · 独立项目(使用内置 API)', + 'menu.contextMonorepo': '项目类型 · monorepo(server/src + client/)', + 'menu.contextUnknown': '项目类型 · 未初始化(请先运行 init)', + 'menu.statusApi': 'API {status}', + 'menu.statusDb': '数据库 {status}', + 'menu.statusDocker': 'Docker {status}', + 'menu.statusLabelApi': 'API', + 'menu.statusLabelDb': '数据库', + 'menu.statusLabelDocker': 'Docker', + 'menu.statusChecking': '检测中…', + 'menu.startingApi': '正在启动 API…', + 'menu.stoppingApi': '正在停止 API…', + 'menu.restartingApi': '正在重启 API…', + 'menu.statusOn': '在线', + 'menu.statusOff': '离线', + 'menu.statusReady': '就绪', + 'menu.statusNotReady': '未就绪', + 'menu.statusYes': '可用', + 'menu.statusNo': '不可用', + 'menu.hint.dev': 'API + 数据库 + 前端', + 'menu.hint.init': '生成 .reactpress + .env', + 'menu.hint.status': '所有服务概览', + 'menu.hint.doctor': '环境健康检查', + 'menu.hint.devApi': 'watch 模式', + 'menu.hint.devClient': 'Next.js 开发', + 'menu.hint.serverStart': '后台生产模式', + 'menu.hint.serverStop': '', + 'menu.hint.serverRestart': '', + 'menu.hint.build': '生产构建产物', + 'menu.hint.dockerStart': '数据库 + nginx + 全栈', + 'menu.hint.dockerUp': '仅数据库', + 'menu.hint.dockerStop': '', + 'menu.nginxUp': '启动 nginx 反向代理 (:80)', + 'menu.nginxOpen': '在浏览器打开 nginx 入口', + 'menu.nginxReload': '修改配置后重载 nginx', + 'menu.hint.nginxUp': '统一入口 :80', + 'menu.hint.nginxOpen': 'http://localhost', + 'menu.hint.nginxReload': 'nginx -t 后 reload', + 'menu.hint.openAdmin': '在浏览器打开', + 'menu.hint.publish': '仅维护者使用', + 'menu.hint.exit': '', + 'menu.actionPrefix': '操作', + 'dev.startingApi': '[reactpress] 正在启动 API(首次可能需安装依赖,请稍候)…', + 'dev.waitingApi': '[reactpress] 等待 API 就绪: {url}', + 'dev.apiTimeout': '[reactpress] API 在 {seconds}s 内未就绪。\n → 运行 reactpress doctor 查看详情\n → 嵌入式 MySQL:reactpress docker up\n → 检查 .env 中 DB_* 与 SERVER_SITE_URL', + 'dev.apiReady': '[reactpress] API 已就绪,正在启动前端…', + 'dev.clientSlow': '[reactpress] 前端在 {seconds}s 内未响应,可能仍在编译。稍后访问 {url}', + 'dev.envFailed': '[reactpress] 环境准备失败:', + 'dev.toolkitFailed': 'toolkit 构建失败,退出码: {code}', + 'dev.nextSteps': '下一步建议:', + 'dev.nextDoctor': ' → reactpress doctor 环境诊断', + 'dev.nextDocker': ' → reactpress docker up 启动嵌入式 MySQL', + 'dev.nextEnv': ' → 检查 .env 中 DB_* 与 SERVER_SITE_URL', + 'dev.standaloneHint': '[reactpress] 独立项目:当前目录仅启动 API,前端请单独构建。', + 'devBanner.ready': 'ReactPress 开发环境已就绪', + 'devBanner.site': '前台', + 'devBanner.admin': '管理端', + 'devBanner.api': 'API', + 'devBanner.swagger': 'Swagger', + 'devBanner.health': '健康检查', + 'devBanner.hint': '诊断: reactpress doctor · 状态: reactpress status', + 'devBanner.shortcuts': 'Ctrl+C 停止', + 'devBanner.allSystemsGo': '一切就绪', + 'doctor.nodeBad': 'Node.js {version}(需要 ≥ 18)', + 'doctor.nodeFix': '请安装 Node.js 18+:https://nodejs.org/', + 'doctor.dockerOk': 'Docker 引擎可用', + 'doctor.dockerBad': 'Docker 未运行或不可用', + 'doctor.dockerFix': '安装并启动 Docker:https://docs.docker.com/get-docker/ ,然后运行 reactpress docker up;或改 config.json 使用外部 MySQL', + 'doctor.portApiBusy': 'API 端口 {port} 已被占用', + 'doctor.portClientBusy': '前端端口 {port} 已被占用', + 'doctor.portFix': '修改 .env 中 SERVER_PORT / CLIENT_PORT,或停止占用进程', + 'doctor.portOk': '端口 {apiPort}(API)、{clientPort}(前端)可用', + 'doctor.dbNoMysql2': '未安装 mysql2,无法检测数据库', + 'doctor.dbMysql2Fix': '在 monorepo 根目录执行 pnpm install', + 'doctor.dbOk': 'MySQL {host}:{port}/{database} 连通', + 'doctor.dbBad': '数据库连接失败: {error}', + 'doctor.dbFix': '运行 reactpress docker up 或检查 .env 中 DB_* 配置', + 'doctor.apiOk': 'API 健康检查通过 ({url})', + 'doctor.apiBad': 'API 未响应健康检查 ({url})', + 'doctor.apiFix': '运行 reactpress server start 或 reactpress dev', + 'doctor.pnpmBad': '未检测到 pnpm', + 'doctor.pnpmFix': 'npm i -g pnpm,或在 monorepo 根目录使用 corepack enable', + 'doctor.check.config': '配置文件', + 'doctor.check.env': '环境变量', + 'doctor.check.ports': '端口', + 'doctor.check.database': '数据库', + 'doctor.check.api': 'API 健康', + 'doctor.configOk': '.reactpress/config.json 存在', + 'doctor.configBad': '缺少 .reactpress/config.json', + 'doctor.configFix': '运行 reactpress init', + 'doctor.envOk': '.env 存在', + 'doctor.envBad': '缺少 .env', + 'doctor.envFix': '运行 reactpress init 或 reactpress config --apply', + 'doctor.project': '项目目录 {path}', + 'doctor.allPass': '全部检查通过,可以开始开发。', + 'doctor.failed': '{count} 项需要处理。', + 'doctor.title': 'ReactPress Doctor', + 'doctor.subtitle': '环境健康检查', + 'doctor.checking': '正在检查 {name}…', + 'doctor.summary': '通过 {passed} · 失败 {failed} · 共 {total} 项', + 'doctor.fixesHeader': '修复建议', + 'status.title': 'ReactPress 项目状态', + 'status.dir': '项目目录 {path}', + 'status.apiSource': 'API 来源 {source}', + 'status.apiSource.monorepo': 'monorepo server/', + 'status.apiSource.bundle': 'reactpress-cli', + 'status.configOk': '.reactpress/config.json', + 'status.configBad': '未初始化', + 'status.envOk': '.env', + 'status.envBad': '缺少 .env', + 'status.apiOnline': '在线', + 'status.apiOffline': '离线', + 'status.apiUnreachable': '{url} (离线或未启动)', + 'status.dbUp': '连通', + 'status.dbDown': '不可用', + 'status.pidRunning': '(运行中)', + 'status.frontend': '前端', + 'status.docker': 'Docker', + 'status.dockerUp': '可用', + 'status.dockerDown': '未运行', + 'status.section.project': '项目信息', + 'status.section.api': 'API 服务', + 'status.section.frontend': '前端', + 'status.section.docker': 'Docker', + 'status.field.url': 'URL', + 'status.field.http': 'HTTP', + 'status.field.health': '健康', + 'status.field.database': '数据库', + 'status.field.pid': 'PID', + 'status.field.engine': '引擎', + 'status.field.config': '配置', + 'status.field.env': '环境', + 'status.field.source': '来源', + 'status.field.dir': '目录', + 'bootstrap.configReady': '配置已存在,数据库已就绪。', + 'bootstrap.projectDbPending': '项目已创建,但数据库未就绪: {message}。请确认 Docker 已启动后重试 reactpress dev。', + 'bootstrap.ready': 'ReactPress 开发环境已就绪(配置 + 数据库)。', + 'bootstrap.initFailed': '初始化失败', + 'bootstrap.cliInitFailed': 'reactpress-cli init 失败', + 'bootstrap.dbPendingShort': '数据库未就绪', + 'bootstrap.dbNotReady': '{message}。建议:启动 Docker 后运行 reactpress docker up,或执行 reactpress doctor', + 'bootstrap.dbReady': '数据库已就绪', + 'db.backup.to': '备份数据库到 {path}', + 'db.backup.done': '备份完成', + 'db.backup.viaDocker': '本机未找到 mysqldump,改用 Docker 内 db 容器的 mysqldump…', + 'db.backup.fail': + 'mysqldump 失败:请安装 MySQL 客户端(如 brew install mysql-client),或确保 Docker 数据库已运行以便自动在容器内备份', + 'common.done': '完成', + 'common.yes': '是', + 'common.no': '否', + 'common.none': '(无)', + 'common.unknownError': '未知错误', + 'lifecycle.apiStopped': '[reactpress] 已停止 API 进程 (pid {pid})', + 'lifecycle.stopPidFailed': '[reactpress] 停止 pid {pid} 失败:', + 'lifecycle.apiAlreadyRunning': '[reactpress] API 已在运行 (pid {pid})', + 'lifecycle.noServerAvailable': '[reactpress] 未找到可用的 API 运行时。请重新安装 @fecommunity/reactpress,或在含 server/src 的项目目录中运行。', + 'lifecycle.startingLocalApi': '[reactpress] 正在启动本地 API (server/)…', + 'lifecycle.startingBundledApi': '[reactpress] 正在启动内置 API…', + 'lifecycle.apiStartedBg': '[reactpress] API 已后台启动 (pid {pid})', + 'lifecycle.apiTimeout120': '[reactpress] API 在 120s 内未就绪: {url}', + 'lifecycle.apiReady': '[reactpress] API 已就绪: {url}', + 'lifecycle.apiStatusTitle': '[reactpress] API 状态', + 'lifecycle.source': ' 来源: {source}', + 'lifecycle.source.monorepo': 'monorepo server/', + 'lifecycle.source.bundle': '内置 API (@fecommunity/reactpress)', + 'lifecycle.pidFile': ' PID 文件: {path}', + 'lifecycle.recordedPid': ' 记录 PID: {pid}', + 'lifecycle.processAlive': ' 进程存活: {alive}', + 'lifecycle.httpStatus': ' HTTP ({url}): {status}', + 'lifecycle.httpReachable': '可访问', + 'lifecycle.httpUnreachable': '不可访问', + 'lifecycle.unknownCommand': '未知 lifecycle 命令: {command}', + 'docker.stopping': '[reactpress] 正在停止 Docker 服务…', + 'docker.stopped': '[reactpress] Docker 服务已停止。', + 'docker.stopFailed': '[reactpress] 停止 Docker 失败:', + 'docker.starting': '[reactpress] 正在启动 Docker 服务…', + 'docker.notRunning': 'Docker 未运行,请先启动 Docker Desktop。', + 'docker.started': '[reactpress] Docker 服务已启动。', + 'docker.waitingMysql': '[reactpress] 等待 MySQL 就绪…', + 'docker.mysqlReady': '[reactpress] MySQL 已就绪。', + 'docker.waitingMysqlProgress': '[reactpress] 等待 MySQL… ({attempts}/{max})', + 'docker.mysqlTimeout': '[reactpress] MySQL 在超时时间内未就绪。', + 'docker.mysqlNotReady': 'MySQL 未就绪', + 'docker.startDevStack': '[reactpress] 启动 API + 前端 (Docker MySQL)…', + 'docker.visitUrls': '[reactpress] 访问: http://localhost (nginx) / http://localhost:3001 (client)', + 'docker.devProcessExit': '开发进程退出: {code}', + 'docker.unknownCommand': '未知 docker 命令: {command}', + 'nginx.configCreated': '[reactpress] 已生成 nginx 配置: {path}', + 'nginx.configExists': '[reactpress] nginx 配置已存在: {path}', + 'nginx.ensureWarn': '[reactpress] 无法确保 nginx 配置: {message}', + 'nginx.started': '[reactpress] Nginx 已启动 — {url}', + 'nginx.configPath': '[reactpress] 配置: {path}', + 'nginx.stopped': '[reactpress] Nginx 已停止。', + 'nginx.startFailed': '启动 nginx 容器失败', + 'nginx.prodMonorepoOnly': '生产 nginx(--prod)需要 monorepo 且存在 docker-compose.prod.yml', + 'nginx.statusTitle': '[reactpress] Nginx 状态', + 'nginx.statusContainer': ' 容器 {name}: {running}', + 'nginx.statusConfig': ' 配置 {path}: {exists}', + 'nginx.statusUrl': ' 入口 {url} (端口 {port})', + 'nginx.statusMode': ' 模式: {mode}', + 'nginx.notRunning': 'Nginx 容器未运行。请执行: reactpress nginx up', + 'nginx.testOk': '[reactpress] Nginx 配置校验通过。', + 'nginx.testFailed': 'Nginx 配置校验失败', + 'nginx.reloadOk': '[reactpress] Nginx 已重载。', + 'nginx.reloadFailed': 'Nginx 重载失败', + 'nginx.opening': '[reactpress] 正在打开 {url}', + 'nginx.unknownCommand': '未知 nginx 命令: {command}', + 'nginx.templateMissing': '内置 nginx 模板缺失: {path}', + 'nginx.doctorSkippedDocker': '已跳过(Docker 未运行)', + 'nginx.doctorSkippedNotRunning': '未启动(可选: reactpress nginx up)', + 'nginx.doctorNotRunningFix': 'reactpress nginx up(或 reactpress docker up)', + 'nginx.doctorOk': 'Nginx 健康 ({url}/health)', + 'nginx.doctorUnhealthy': 'Nginx 在运行但 /health 失败 ({url})', + 'nginx.doctorUnhealthyFix': '确认前端 (:3001) 与 API (:3002) 已启动;可执行 reactpress nginx reload', + 'doctor.check.nginx': 'Nginx 代理', + 'apiDev.modeServer': '[reactpress] 开发模式: server/ (nest start --watch)', + 'apiDev.modeBundled': '[reactpress] 开发模式: 内置 API(随包附带)', + 'apiDev.ctrlCHint': '[reactpress] 按 Ctrl+C 停止 API。', + 'apiDev.stopHint': '[reactpress] 单独停止: reactpress server stop', + 'build.unknownTarget': '未知构建目标: {target},可选: {available}', + 'build.recursive': '检测到构建递归(pnpm run build 不能再次调用自身)。请使用 build:toolkit、build:server 或 build:client。', + 'build.forbiddenScript': '无效的构建脚本 "{script}",请使用 build:toolkit 等细分脚本。', + 'build.stepFailed': '[{current}/{total}] {label} 失败', + 'build.plan': '生产构建 — 共 {total} 步:toolkit → server → client', + 'build.step': '[{current}/{total}] {label}', + 'build.stepDone': '[{current}/{total}] {label} ({seconds}s)', + 'build.stepSkipped': '已跳过 {label}(当前项目无对应源码包)', + 'build.done': '构建完成,耗时 {seconds}s', + 'build.label.toolkit': 'Toolkit', + 'build.label.server': 'API (server)', + 'build.label.client': '前端 (client)', + 'build.label.docs': '文档 (docs)', + 'pm2.startFailed': '[reactpress] PM2 启动 API 失败:', + 'pm2.exitCode': 'PM2 退出码 {code}', + 'spawn.commandFailed': '命令失败 ({command}): 退出码 {code}', + 'spawn.exitCode': '退出码 {code}', + 'shim.deprecated': '\n[deprecated] reactpress-cli 将在 3.1 移除。请改用:\n npm i -g @fecommunity/reactpress\n reactpress init · reactpress dev · reactpress doctor\n', + 'server.help.invokedBy': ' (通常由 reactpress server start 调用)', + 'publish.pkg.main': 'ReactPress 3.0 主包 — 唯一入口 (init / dev / doctor / publish)', + 'publish.pkg.server': 'NestJS 后端 API (deprecated — 使用 reactpress-cli 内置 API)', + 'bundle.cli.description': '零配置初始化与管理 ReactPress CMS & 博客服务器', + 'bundle.cli.cwd': 'ReactPress 项目目录(默认:当前工作目录)', + 'bundle.cli.init.description': '一键初始化 ReactPress CMS & 博客服务器(零配置)', + 'bundle.cli.init.directory': '项目目录', + 'bundle.cli.init.force': '覆盖已有配置', + 'bundle.cli.start.description': '启动服务器(自动准备数据库)', + 'bundle.cli.stop.description': '停止服务器', + 'bundle.cli.stop.database': '同时停止嵌入式数据库容器', + 'bundle.cli.restart.description': '重启服务器', + 'bundle.cli.status.description': '查看服务与数据库状态', + 'bundle.cli.config.description': '查看或更新配置(更新后可用 --apply 重启生效)', + 'bundle.cli.config.key': '配置键,如 server.port', + 'bundle.cli.config.value': '新值', + 'bundle.cli.config.list': '列出所有配置', + 'bundle.cli.config.apply': '更新后自动重启服务', + 'bundle.cli.unknownCommand': '未知命令: {command}', + 'bundle.cmd.init.spinner': '正在初始化 ReactPress 项目…', + 'bundle.cmd.init.succeed': '初始化完成', + 'bundle.cmd.init.fail': '初始化失败', + 'bundle.cmd.init.projectDir': '项目目录: {path}', + 'bundle.cmd.init.nextStep': '下一步: reactpress-cli start', + 'bundle.cmd.notProject': '当前目录不是 ReactPress 项目。', + 'bundle.cmd.notProjectInit': '当前目录不是 ReactPress 项目。请先运行 reactpress-cli init。', + 'bundle.cmd.start.spinner': '正在准备数据库与服务…', + 'bundle.cmd.start.succeed': '服务已启动', + 'bundle.cmd.start.fail': '启动失败', + 'bundle.cmd.stop.spinner': '正在停止服务…', + 'bundle.cmd.stop.succeed': '已停止', + 'bundle.cmd.stop.fail': '停止失败', + 'bundle.cmd.restart.spinner': '正在重启服务…', + 'bundle.cmd.restart.succeed': '重启完成', + 'bundle.cmd.restart.fail': '重启失败', + 'bundle.cmd.status.title': '服务状态', + 'bundle.cmd.status.project': '项目: {path}', + 'bundle.cmd.status.service': '服务: {status}{pid}', + 'bundle.cmd.status.running': '运行中', + 'bundle.cmd.status.stopped': '已停止', + 'bundle.cmd.status.url': '地址: {url}', + 'bundle.cmd.status.database': '数据库: {status} ({mode})', + 'bundle.cmd.status.dbReady': '就绪', + 'bundle.cmd.status.dbNotReady': '未就绪', + 'bundle.cmd.config.title': '配置项', + 'bundle.cmd.config.keyRequired': '请指定配置键,例如: reactpress-cli config server.port 3003', + 'bundle.cmd.config.listHint': '使用 --list 查看所有配置项', + 'bundle.cmd.config.updated': '已更新 {key} = {value}', + 'bundle.cmd.config.restartSpinner': '正在重启以使配置生效…', + 'bundle.cmd.config.applied': '配置已应用', + 'bundle.cmd.config.restartFail': '重启失败', + 'bundle.cmd.config.restartManual': '请手动运行 reactpress-cli restart', + 'bundle.cmd.config.applyHint': '运行 reactpress-cli restart 或 config --apply 使配置生效', + 'bundle.service.init.alreadyProject': '目录已是 ReactPress 项目。使用 --force 覆盖配置。', + 'bundle.service.init.dbPending': '项目已创建,但数据库未就绪: {message}。可稍后运行 reactpress-cli start。', + 'bundle.service.init.complete': 'ReactPress 项目初始化完成。运行 reactpress-cli start 启动服务。', + 'bundle.service.init.templateMissing': '模板文件缺失: {path}', + 'bundle.service.config.notFound': '未找到 ReactPress 项目。请先运行 reactpress-cli init 初始化。', + 'bundle.service.config.keyMissing': '配置项不存在: {key}', + 'bundle.service.server.alreadyRunning': '服务已在运行 (PID {pid}),访问 {url}', + 'bundle.service.server.portBusy': '端口 {port} 已被占用,ReactPress 无法绑定。若曾用 Docker Compose 启动过 ReactPress,请执行: docker stop reactpress_server。也可在 .reactpress/config.json 中修改 server.port。', + 'bundle.service.server.cannotStart': '无法启动 ReactPress 服务进程。', + 'bundle.service.server.noHttp': '服务进程已启动 (PID {pid}),但 {url} 无 HTTP 响应。请检查端口占用或 .env 数据库配置。', + 'bundle.service.server.started': 'ReactPress 服务已启动,访问 {url}', + 'bundle.service.server.stopped': 'ReactPress 服务已停止。', + 'bundle.service.server.cannotStopPid': '无法停止进程 PID {pid}', + 'bundle.service.database.dockerMissing': '未检测到 Docker。请安装并启动 Docker,或将 database.mode 设为 external 并使用已有 MySQL。', + 'bundle.service.database.portSwitched': '宿主机端口 {previous} 已被占用,已改用 {port}(已更新 .env)', + 'bundle.service.database.portBindRetry': '端口 {port} 绑定失败,正在尝试其他端口…', + 'bundle.service.database.containerStartFailed': '启动数据库容器失败: {detail}', + 'bundle.service.database.credsMismatch': '数据库容器已在端口 {port} 运行,但账号「{user}」无法连接(数据卷中的凭证与 .env 不一致)。请在项目目录执行: cd .reactpress && docker compose down -v && cd .. && reactpress-cli start', + 'bundle.service.database.connectionTimeout': '数据库容器已启动,但连接超时。请执行 docker logs {container} 查看详情。', + 'bundle.service.database.cannotConnect': '无法连接数据库 {host}:{port},请检查 .env 中的 DB_* 配置。', + 'bundle.serverBundle.missing': '内置服务端缺失,请重新安装 reactpress-cli。', + 'bundle.serverBundle.installFailed': '内置服务端依赖安装失败。请在 reactpress-cli 安装目录下手动执行: cd server && npm install --omit=dev --no-bin-links', + 'bundle.serverBundle.notBuilt': '内置服务端未构建或依赖不完整。请运行 npm run build:server(开发)或重新安装 reactpress-cli。', + 'bundle.port.notFound': '在 {start}-{end} 范围内未找到可用端口', + }, +}; + +module.exports = { STRINGS }; diff --git a/cli/lib/lifecycle.js b/cli/lib/lifecycle.js new file mode 100644 index 00000000..e1c2ce7b --- /dev/null +++ b/cli/lib/lifecycle.js @@ -0,0 +1,190 @@ +const { spawn } = require('child_process'); +const ora = require('ora'); +const { ensureProjectEnvironment } = require('./bootstrap'); +const { loadServerSiteUrl, waitForHttp } = require('./http'); +const { + getServerBin, + getServerDir, + isUsingMonorepoServer, + canStartLocalApi, + getPidFile, +} = require('./paths'); +const net = require('net'); +const { readPid, isProcessRunning, clearPidFile, writePid } = require('./process'); +const { ensureOriginalCwd } = require('./root'); +const { t } = require('./i18n'); + +function parseServerPort(projectRoot) { + try { + const url = new URL(loadServerSiteUrl(projectRoot)); + return Number(url.port) || 3002; + } catch { + return 3002; + } +} + +function isPortBusy(port, host = '127.0.0.1') { + return new Promise((resolve) => { + const socket = net.createConnection({ port, host }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + socket.setTimeout(800, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function waitForPortFree(port, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await isPortBusy(port))) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return false; +} + +async function ensureConfig(projectRoot) { + try { + await ensureProjectEnvironment(projectRoot); + return true; + } catch (err) { + console.error(t('dev.envFailed'), err.message || err); + return false; + } +} + +function stopApi(projectRoot) { + const pid = readPid(projectRoot); + if (pid && isProcessRunning(pid)) { + try { + process.kill(pid, 'SIGTERM'); + console.log(t('lifecycle.apiStopped', { pid })); + } catch (err) { + console.warn(t('lifecycle.stopPidFailed', { pid }), err.message); + } + } + clearPidFile(projectRoot); +} + +async function startApi(projectRoot, { wait = true } = {}) { + if (!(await ensureConfig(projectRoot))) { + return 1; + } + + const existing = readPid(projectRoot); + if (existing && isProcessRunning(existing)) { + console.log(t('lifecycle.apiAlreadyRunning', { pid: existing })); + return 0; + } + clearPidFile(projectRoot); + + if (!canStartLocalApi(projectRoot)) { + console.error(t('lifecycle.noServerAvailable')); + return 1; + } + + if (isUsingMonorepoServer(projectRoot)) { + console.log(t('lifecycle.startingLocalApi')); + } else { + console.log(t('lifecycle.startingBundledApi')); + } + + const child = spawn(process.execPath, [getServerBin(projectRoot)], { + cwd: getServerDir(projectRoot), + detached: true, + stdio: 'ignore', + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + + child.unref(); + writePid(projectRoot, child.pid); + console.log(t('lifecycle.apiStartedBg', { pid: child.pid })); + + if (!wait) { + return 0; + } + + const serverUrl = loadServerSiteUrl(projectRoot); + const spinner = ora({ + text: t('dev.waitingApi', { url: serverUrl }), + color: 'magenta', + spinner: 'dots', + }).start(); + const ready = await waitForHttp(serverUrl); + if (!ready) { + spinner.fail(t('lifecycle.apiTimeout120', { url: serverUrl })); + return 1; + } + spinner.succeed(t('lifecycle.apiReady', { url: serverUrl })); + return 0; +} + +async function statusApi(projectRoot) { + const pid = readPid(projectRoot); + const serverUrl = loadServerSiteUrl(projectRoot); + const { isHttpResponding } = require('./http'); + const httpOk = await isHttpResponding(serverUrl); + + const source = isUsingMonorepoServer(projectRoot) + ? t('lifecycle.source.monorepo') + : t('lifecycle.source.bundle'); + + console.log(t('lifecycle.apiStatusTitle')); + console.log(t('lifecycle.source', { source })); + console.log(t('lifecycle.pidFile', { path: getPidFile(projectRoot) })); + console.log( + t('lifecycle.recordedPid', { + pid: pid ?? t('common.none'), + }) + ); + console.log( + t('lifecycle.processAlive', { + alive: pid + ? isProcessRunning(pid) + ? t('common.yes') + : t('common.no') + : '—', + }) + ); + console.log( + t('lifecycle.httpStatus', { + url: serverUrl, + status: httpOk ? t('lifecycle.httpReachable') : t('lifecycle.httpUnreachable'), + }) + ); +} + +async function runLifecycleCommand(command, projectRoot = ensureOriginalCwd()) { + switch (command) { + case 'start': + return startApi(projectRoot, { wait: true }); + case 'start:bg': + return startApi(projectRoot, { wait: false }); + case 'stop': + stopApi(projectRoot); + return 0; + case 'restart': + stopApi(projectRoot); + await waitForPortFree(parseServerPort(projectRoot)); + await new Promise((r) => setTimeout(r, 400)); + return startApi(projectRoot, { wait: true }); + case 'status': + await statusApi(projectRoot); + return 0; + default: + throw new Error(t('lifecycle.unknownCommand', { command })); + } +} + +module.exports = { + startApi, + stopApi, + statusApi, + runLifecycleCommand, +}; diff --git a/cli/lib/nginx.js b/cli/lib/nginx.js new file mode 100644 index 00000000..e056cab8 --- /dev/null +++ b/cli/lib/nginx.js @@ -0,0 +1,342 @@ +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { spawnSync } = require('child_process'); +const open = require('open'); +const { detectProjectType } = require('./project-type'); +const { isDockerRunning, pickDockerComposeCommand } = require('./docker'); +const { t } = require('./i18n'); + +const NGINX_CONTAINER = 'reactpress_nginx'; +const DEFAULT_NGINX_PORT = 80; + +function resolveNginxMode(options = {}) { + return options.prod ? 'prod' : 'dev'; +} + +function resolveNginxConfigBasename(mode) { + return mode === 'prod' ? 'nginx.conf' : 'nginx.dev.conf'; +} + +function resolveNginxConfigPath(projectRoot, mode = 'dev') { + const basename = resolveNginxConfigBasename(mode); + const type = detectProjectType(projectRoot); + if (type === 'monorepo') { + return path.join(projectRoot, basename); + } + return path.join(projectRoot, '.reactpress', basename); +} + +function bundledTemplatePath(mode) { + const file = mode === 'prod' ? 'nginx.prod.conf' : 'nginx.dev.conf'; + return path.join(__dirname, '..', 'templates', file); +} + +function resolveNginxPort(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const m = content.match(/^NGINX_PORT=(.+)$/m); + if (m) { + const port = parseInt(m[1].trim().replace(/^['"]|['"]$/g, ''), 10); + if (port > 0) return port; + } + } catch { + // ignore + } + return DEFAULT_NGINX_PORT; +} + +function nginxEntryUrl(projectRoot) { + const port = resolveNginxPort(projectRoot); + return port === 80 ? 'http://localhost' : `http://localhost:${port}`; +} + +/** + * Write default nginx config from CLI templates when missing (or when force). + * + * @returns {{ configPath: string, created: boolean, mode: 'dev' | 'prod' }} + */ +function ensureNginxConfig(projectRoot, options = {}) { + const mode = resolveNginxMode(options); + const configPath = resolveNginxConfigPath(projectRoot, mode); + const templatePath = bundledTemplatePath(mode); + if (!fs.existsSync(templatePath)) { + throw new Error(t('nginx.templateMissing', { path: templatePath })); + } + + const exists = fs.existsSync(configPath); + if (exists && !options.force) { + return { configPath, created: false, mode }; + } + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.copyFileSync(templatePath, configPath); + return { configPath, created: !exists || !!options.force, mode }; +} + +function resolveNginxComposeContext(projectRoot, mode = 'dev') { + const type = detectProjectType(projectRoot); + if (mode === 'prod' && type === 'monorepo') { + return { + composeFile: path.join(projectRoot, 'docker-compose.prod.yml'), + cwd: projectRoot, + service: 'nginx', + }; + } + if (type === 'monorepo') { + return { + composeFile: path.join(projectRoot, 'docker-compose.dev.yml'), + cwd: projectRoot, + service: 'nginx', + }; + } + return { + composeFile: path.join(projectRoot, '.reactpress', 'docker-compose.yml'), + cwd: path.join(projectRoot, '.reactpress'), + service: 'nginx', + }; +} + +function composeDefinesNginxService(composeFile) { + try { + const content = fs.readFileSync(composeFile, 'utf8'); + return /^\s*nginx:\s*$/m.test(content); + } catch { + return false; + } +} + +function runComposeOnContext(ctx, args, options = {}) { + const { command, baseArgs } = pickDockerComposeCommand(); + return spawnSync(command, [...baseArgs, '-f', ctx.composeFile, ...args], { + stdio: options.stdio ?? 'inherit', + cwd: ctx.cwd, + ...options, + }); +} + +function isNginxContainerRunning() { + const res = spawnSync( + 'docker', + ['inspect', '-f', '{{.State.Running}}', NGINX_CONTAINER], + { encoding: 'utf8' } + ); + return res.status === 0 && res.stdout.trim() === 'true'; +} + +function startNginxContainer(configPath, port) { + spawnSync('docker', ['rm', '-f', NGINX_CONTAINER], { stdio: 'ignore' }); + const absConfig = path.resolve(configPath); + const res = spawnSync( + 'docker', + [ + 'run', + '-d', + '--name', + NGINX_CONTAINER, + '-p', + `${port}:80`, + '-v', + `${absConfig}:/etc/nginx/conf.d/default.conf:ro`, + '--add-host', + 'host.docker.internal:host-gateway', + 'nginx:alpine', + ], + { encoding: 'utf8' } + ); + if (res.status !== 0) { + throw new Error(res.stderr?.trim() || t('nginx.startFailed')); + } +} + +function stopNginxContainer() { + spawnSync('docker', ['rm', '-sf', NGINX_CONTAINER], { stdio: 'ignore' }); +} + +function nginxUp(projectRoot, options = {}) { + if (!isDockerRunning()) { + throw new Error(t('docker.notRunning')); + } + + const mode = resolveNginxMode(options); + const type = detectProjectType(projectRoot); + + if (mode === 'prod' && type !== 'monorepo') { + throw new Error(t('nginx.prodMonorepoOnly')); + } + + const { configPath } = ensureNginxConfig(projectRoot, { mode, force: options.force }); + const port = resolveNginxPort(projectRoot); + const ctx = resolveNginxComposeContext(projectRoot, mode); + + if (fs.existsSync(ctx.composeFile) && composeDefinesNginxService(ctx.composeFile)) { + const result = runComposeOnContext(ctx, ['up', '-d', ctx.service]); + if (result.status !== 0) { + throw new Error(t('nginx.startFailed')); + } + } else { + startNginxContainer(configPath, port); + } + + console.log(t('nginx.started', { url: nginxEntryUrl(projectRoot) })); + console.log(t('nginx.configPath', { path: configPath })); +} + +function nginxDown(projectRoot, options = {}) { + const mode = resolveNginxMode(options); + const ctx = resolveNginxComposeContext(projectRoot, mode); + if (fs.existsSync(ctx.composeFile) && composeDefinesNginxService(ctx.composeFile)) { + runComposeOnContext(ctx, ['stop', ctx.service], { stdio: 'ignore' }); + } + stopNginxContainer(); + console.log(t('nginx.stopped')); +} + +function nginxRestart(projectRoot, options = {}) { + nginxDown(projectRoot, options); + nginxUp(projectRoot, options); +} + +function nginxStatus(projectRoot, options = {}) { + const mode = resolveNginxMode(options); + const configPath = resolveNginxConfigPath(projectRoot, mode); + const port = resolveNginxPort(projectRoot); + const running = isNginxContainerRunning(); + const configExists = fs.existsSync(configPath); + + console.log(t('nginx.statusTitle')); + console.log(t('nginx.statusContainer', { name: NGINX_CONTAINER, running: running ? t('common.yes') : t('common.no') })); + console.log(t('nginx.statusConfig', { path: configPath, exists: configExists ? t('common.yes') : t('common.no') })); + console.log(t('nginx.statusUrl', { url: nginxEntryUrl(projectRoot), port })); + console.log(t('nginx.statusMode', { mode })); +} + +function nginxLogs(extraArgs = []) { + const args = ['logs', '-f', NGINX_CONTAINER, ...extraArgs]; + spawnSync('docker', args, { stdio: 'inherit' }); +} + +function dockerExecNginx(args) { + return spawnSync('docker', ['exec', NGINX_CONTAINER, 'nginx', ...args], { + encoding: 'utf8', + }); +} + +function nginxTest() { + if (!isNginxContainerRunning()) { + throw new Error(t('nginx.notRunning')); + } + const res = dockerExecNginx(['-t']); + process.stdout.write(res.stdout || ''); + process.stderr.write(res.stderr || ''); + if (res.status !== 0) { + throw new Error(t('nginx.testFailed')); + } + console.log(t('nginx.testOk')); +} + +function nginxReload() { + nginxTest(); + const res = dockerExecNginx(['-s', 'reload']); + if (res.status !== 0) { + throw new Error(res.stderr?.trim() || t('nginx.reloadFailed')); + } + console.log(t('nginx.reloadOk')); +} + +async function nginxOpen(projectRoot) { + const url = nginxEntryUrl(projectRoot); + console.log(t('nginx.opening', { url })); + await open(url); +} + +function probeNginxHealth(projectRoot, timeoutMs = 2000) { + const url = new URL('/health', nginxEntryUrl(projectRoot)); + return new Promise((resolve) => { + const req = http.get(url, { timeout: timeoutMs }, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function checkNginx(projectRoot) { + if (!isDockerRunning()) { + return { ok: true, message: t('nginx.doctorSkippedDocker') }; + } + if (!isNginxContainerRunning()) { + return { ok: true, message: t('nginx.doctorSkippedNotRunning') }; + } + const healthy = await probeNginxHealth(projectRoot); + if (healthy) { + return { + ok: true, + message: t('nginx.doctorOk', { url: nginxEntryUrl(projectRoot) }), + }; + } + return { + ok: false, + message: t('nginx.doctorUnhealthy', { url: nginxEntryUrl(projectRoot) }), + fix: t('nginx.doctorUnhealthyFix'), + }; +} + +async function runNginxCommand(command, projectRoot, extraArgs = [], options = {}) { + switch (command) { + case 'ensure': { + const { configPath, created } = ensureNginxConfig(projectRoot, options); + console.log( + created ? t('nginx.configCreated', { path: configPath }) : t('nginx.configExists', { path: configPath }) + ); + return; + } + case 'up': + nginxUp(projectRoot, options); + return; + case 'down': + case 'stop': + nginxDown(projectRoot, options); + return; + case 'restart': + nginxRestart(projectRoot, options); + return; + case 'status': + nginxStatus(projectRoot, options); + return; + case 'logs': + nginxLogs(extraArgs); + return; + case 'test': + nginxTest(); + return; + case 'reload': + nginxReload(); + return; + case 'open': + await nginxOpen(projectRoot); + return; + default: + throw new Error(t('nginx.unknownCommand', { command })); + } +} + +module.exports = { + NGINX_CONTAINER, + DEFAULT_NGINX_PORT, + resolveNginxMode, + resolveNginxConfigPath, + resolveNginxComposeContext, + ensureNginxConfig, + nginxEntryUrl, + resolveNginxPort, + isNginxContainerRunning, + probeNginxHealth, + checkNginx, + runNginxCommand, +}; diff --git a/cli/lib/paths.js b/cli/lib/paths.js new file mode 100644 index 00000000..42351d46 --- /dev/null +++ b/cli/lib/paths.js @@ -0,0 +1,108 @@ +const fs = require('fs'); +const path = require('path'); +const { ensureOriginalCwd, getMonorepoRoot } = require('./root'); + +function resolveProjectRoot(projectRoot) { + return path.resolve(projectRoot || ensureOriginalCwd()); +} + +function getMonorepoServerDir(projectRoot) { + return path.join(resolveProjectRoot(projectRoot), 'server'); +} + +function hasMonorepoServerSource(projectRoot) { + return fs.existsSync( + path.join(getMonorepoServerDir(projectRoot), 'src', 'main.ts') + ); +} + +function getCliPackageRoot() { + const ownRoot = path.join(__dirname, '..'); + if (fs.existsSync(path.join(ownRoot, 'dist', 'index.js'))) { + return ownRoot; + } + try { + return path.dirname( + require.resolve('@fecommunity/reactpress-cli-core/package.json') + ); + } catch { + return path.dirname(require.resolve('@fecommunity/reactpress-cli/package.json')); + } +} + +function getBundledServerDir() { + return path.join(getCliPackageRoot(), 'server'); +} + +function hasBundledServerBuild() { + return fs.existsSync(path.join(getBundledServerDir(), 'dist', 'main.js')); +} + +function getServerDir(projectRoot) { + if (hasMonorepoServerSource(projectRoot)) { + return getMonorepoServerDir(projectRoot); + } + return getBundledServerDir(); +} + +function getServerBin(projectRoot) { + return path.join(getServerDir(projectRoot), 'bin', 'reactpress-server.js'); +} + +function getSwaggerPath(projectRoot) { + return path.join(getServerDir(projectRoot), 'public', 'swagger.json'); +} + +function getServerMain(projectRoot) { + return path.join(getServerDir(projectRoot), 'dist', 'main.js'); +} + +function isUsingMonorepoServer(projectRoot) { + return hasMonorepoServerSource(projectRoot); +} + +function canStartLocalApi(projectRoot) { + return ( + isUsingMonorepoServer(projectRoot) || + hasBundledServerBuild() + ); +} + +function getClientBin(projectRoot) { + const binPath = path.join( + resolveProjectRoot(projectRoot), + 'client', + 'bin', + 'reactpress-client.js' + ); + if (!fs.existsSync(binPath)) { + const err = new Error( + `Client entry not found: ${binPath}. Run from a ReactPress monorepo root or use reactpress dev --client-only with a remote API.` + ); + err.code = 'REACTPRESS_CLIENT_NOT_FOUND'; + throw err; + } + return binPath; +} + +function getPidFile(projectRoot) { + return path.join(resolveProjectRoot(projectRoot), '.reactpress', 'server.pid'); +} + +module.exports = { + getMonorepoRoot, + resolveProjectRoot, + getMonorepoServerDir, + hasMonorepoServerSource, + hasBundledServerBuild, + isUsingMonorepoServer, + canStartLocalApi, + getCliPackageRoot, + getBundledServerDir, + getServerDir, + getServerBin, + getSwaggerPath, + getServerMain, + getClientBin, + getPidFile, +}; diff --git a/cli/lib/pm2.js b/cli/lib/pm2.js new file mode 100644 index 00000000..1473b061 --- /dev/null +++ b/cli/lib/pm2.js @@ -0,0 +1,32 @@ +const { spawn } = require('child_process'); +const { getServerBin, getServerDir } = require('./paths'); +const { ensureOriginalCwd } = require('./root'); +const { t } = require('./i18n'); + +function startApiWithPm2(projectRoot = ensureOriginalCwd()) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [getServerBin(projectRoot), '--pm2'], { + stdio: 'inherit', + cwd: getServerDir(projectRoot), + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + + child.on('error', (error) => { + console.error(t('pm2.startFailed'), error); + reject(error); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(Object.assign(new Error(t('pm2.exitCode', { code })), { exitCode: code })); + return; + } + resolve(); + }); + }); +} + +module.exports = { startApiWithPm2 }; diff --git a/cli/lib/process.js b/cli/lib/process.js new file mode 100644 index 00000000..d0f3ced1 --- /dev/null +++ b/cli/lib/process.js @@ -0,0 +1,45 @@ +const fs = require('fs'); +const path = require('path'); +const { getPidFile } = require('./paths'); + +function readPid(projectRoot) { + const pidFile = getPidFile(projectRoot); + try { + const raw = fs.readFileSync(pidFile, 'utf8').trim(); + const pid = Number.parseInt(raw, 10); + return Number.isFinite(pid) ? pid : null; + } catch { + return null; + } +} + +function isProcessRunning(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function clearPidFile(projectRoot) { + const pidFile = getPidFile(projectRoot); + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } +} + +function writePid(projectRoot, pid) { + const pidFile = getPidFile(projectRoot); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + fs.writeFileSync(pidFile, String(pid)); +} + +module.exports = { + readPid, + isProcessRunning, + clearPidFile, + writePid, + getPidFile, +}; diff --git a/cli/lib/project-type.js b/cli/lib/project-type.js new file mode 100644 index 00000000..32e53012 --- /dev/null +++ b/cli/lib/project-type.js @@ -0,0 +1,72 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Decide whether a given directory is a ReactPress monorepo checkout (with + * editable `server/src`, `client/`, `toolkit/`) or a standalone project that + * was created with `reactpress init` and relies on the bundled runtime. + * + * @param {string} root absolute project root + * @returns {'monorepo' | 'standalone' | 'unknown'} + */ +function detectProjectType(root) { + if (!root) return 'unknown'; + const abs = path.resolve(root); + + const monorepoMarkers = [ + path.join(abs, 'pnpm-workspace.yaml'), + path.join(abs, 'server', 'src', 'main.ts'), + ]; + if (monorepoMarkers.some((p) => fs.existsSync(p))) { + return 'monorepo'; + } + + if (fs.existsSync(path.join(abs, '.reactpress', 'config.json'))) { + return 'standalone'; + } + + return 'unknown'; +} + +/** + * @param {string} root + */ +function hasClient(root) { + return fs.existsSync(path.join(root, 'client', 'package.json')); +} + +/** + * @param {string} root + */ +function hasServerSource(root) { + return fs.existsSync(path.join(root, 'server', 'src', 'main.ts')); +} + +/** + * @param {string} root + */ +function hasToolkit(root) { + return fs.existsSync(path.join(root, 'toolkit', 'package.json')); +} + +/** + * @param {string} root + */ +function describeProject(root) { + const type = detectProjectType(root); + return { + type, + root, + hasClient: hasClient(root), + hasServerSource: hasServerSource(root), + hasToolkit: hasToolkit(root), + }; +} + +module.exports = { + detectProjectType, + describeProject, + hasClient, + hasServerSource, + hasToolkit, +}; diff --git a/cli/lib/publish.js b/cli/lib/publish.js new file mode 100644 index 00000000..976fcb5b --- /dev/null +++ b/cli/lib/publish.js @@ -0,0 +1,968 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const inquirer = require('inquirer'); +const crypto = require('crypto'); +const { t } = require('./i18n'); +const { getMonorepoRoot } = require('./root'); + +function getWorkspaceRoot() { + const root = getMonorepoRoot(); + if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) { + return root; + } + return process.cwd(); +} + +function getPackages() { + return [ + { + name: '@fecommunity/reactpress', + path: 'cli', + description: t('publish.pkg.main') + }, + { + name: '@fecommunity/reactpress-toolkit', + path: 'toolkit', + description: 'API client and utilities toolkit' + }, + { + name: '@fecommunity/reactpress-client', + path: 'client', + description: 'Frontend application package' + }, + { + name: '@fecommunity/reactpress-server', + path: 'server', + description: t('publish.pkg.server'), + deprecated: true + }, + { + name: '@fecommunity/reactpress-template-hello-world', + path: 'templates/hello-world', + description: 'Hello World template for ReactPress' + }, + { + name: '@fecommunity/reactpress-template-twentytwentyfive', + path: 'templates/twentytwentyfive', + description: 'Twenty Twenty Five blog template for ReactPress' + } +]; +} + +const packages = getPackages(); + +// Generate a hash for a file or directory +function generateHash(filePath) { + try { + if (fs.statSync(filePath).isDirectory()) { + const files = fs.readdirSync(filePath); + const hashes = files + .filter(file => !file.startsWith('.') && file !== 'node_modules' && file !== 'dist' && file !== '.next') // Ignore hidden files and build directories + .map(file => generateHash(path.join(filePath, file))) + .sort(); + return crypto.createHash('md5').update(hashes.join('')).digest('hex'); + } else { + const content = fs.readFileSync(filePath); + return crypto.createHash('md5').update(content).digest('hex'); + } + } catch (error) { + return ''; + } +} + +// Get package content hash +function getPackageHash(packagePath) { + const fullPath = path.join(getWorkspaceRoot(), packagePath); + return generateHash(fullPath); +} + +// Check if package has meaningful changes (for build) +function hasMeaningfulChangesForBuild(packagePath, packageName) { + try { + // Create a hash file path to store previous hash in node_modules + const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.build-cache', `${packageName.replace('/', '_')}.hash`); + + // Generate current hash + const currentHash = getPackageHash(packagePath); + + // Check if we have a previous hash + if (fs.existsSync(hashFilePath)) { + const previousHash = fs.readFileSync(hashFilePath, 'utf8').trim(); + return currentHash !== previousHash; + } + + // If no previous hash, consider it as having changes + return true; + } catch (error) { + console.log(chalk.yellow(`⚠️ Could not check changes for ${packageName}, assuming changes exist`)); + return true; + } +} + +// Check if package has meaningful changes (for publish) +function hasMeaningfulChangesForPublish(packagePath, packageName) { + try { + // Create a hash file path to store previous hash in node_modules + const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.publish-cache', `${packageName.replace('/', '_')}.hash`); + + // Generate current hash + const currentHash = getPackageHash(packagePath); + + // Check if we have a previous hash + if (fs.existsSync(hashFilePath)) { + const previousHash = fs.readFileSync(hashFilePath, 'utf8').trim(); + return currentHash !== previousHash; + } + + // If no previous hash, consider it as having changes + return true; + } catch (error) { + console.log(chalk.yellow(`⚠️ Could not check changes for ${packageName}, assuming changes exist`)); + return true; + } +} + +// Save package hash (for build) +function savePackageHashForBuild(packagePath, packageName) { + try { + const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.build-cache', `${packageName.replace('/', '_')}.hash`); + + // Ensure cache directory exists + const cacheDir = path.dirname(hashFilePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + // Generate and save current hash + const currentHash = getPackageHash(packagePath); + fs.writeFileSync(hashFilePath, currentHash); + } catch (error) { + console.log(chalk.yellow(`⚠️ Could not save hash for ${packageName}`)); + } +} + +// Save package hash (for publish) +function savePackageHashForPublish(packagePath, packageName) { + try { + const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.publish-cache', `${packageName.replace('/', '_')}.hash`); + + // Ensure cache directory exists + const cacheDir = path.dirname(hashFilePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + // Generate and save current hash + const currentHash = getPackageHash(packagePath); + fs.writeFileSync(hashFilePath, currentHash); + } catch (error) { + console.log(chalk.yellow(`⚠️ Could not save hash for ${packageName}`)); + } +} + +// Get current versions +function getCurrentVersion(packagePath) { + try { + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return pkg.version; + } catch (error) { + return 'unknown'; + } +} + +// Increment version based on type +function incrementVersion(version, type) { + const base = String(version).split('-')[0]; + const parts = base.split('.').map((p) => parseInt(p, 10)); + while (parts.length < 3) parts.push(0); + const major = Number.isFinite(parts[0]) ? parts[0] : 0; + const minor = Number.isFinite(parts[1]) ? parts[1] : 0; + const patch = Number.isFinite(parts[2]) ? parts[2] : 0; + + switch (type) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + case 'beta': + // For beta, we increment the beta number or add beta.1 if not present + const match = version.match(/^(.*)-beta\.(\d+)$/); + if (match) { + const baseVersion = match[1]; + const betaNumber = parseInt(match[2]); + return `${baseVersion}-beta.${betaNumber + 1}`; + } else { + // If no beta version exists, add beta.1 + return `${version}-beta.1`; + } + case 'alpha': + // For alpha, we increment the alpha number or add alpha.1 if not present + const alphaMatch = version.match(/^(.*)-alpha\.(\d+)$/); + if (alphaMatch) { + const baseVersion = alphaMatch[1]; + const alphaNumber = parseInt(alphaMatch[2]); + return `${baseVersion}-alpha.${alphaNumber + 1}`; + } else { + // If no alpha version exists, add alpha.1 + return `${version}-alpha.1`; + } + default: + return version; + } +} + +// Get next available version from npm registry +function getNextAvailableVersion(packageName, currentVersion, versionType) { + try { + // First, increment the version locally + let nextVersion = incrementVersion(currentVersion, versionType); + + // Check if this version already exists on npm + let versionExists = true; + let attempts = 0; + const maxAttempts = 100; // Prevent infinite loop + + while (versionExists && attempts < maxAttempts) { + try { + execSync(`npm view ${packageName}@${nextVersion} version`, { stdio: 'ignore' }); + // If we get here, the version exists, so we need to increment again + nextVersion = incrementVersion(nextVersion, versionType); + attempts++; + } catch (error) { + // If we get an error, the version doesn't exist, which is what we want + versionExists = false; + } + } + + if (attempts >= maxAttempts) { + throw new Error('Too many attempts to find available version'); + } + + return nextVersion; + } catch (error) { + // Fallback to simple increment if npm view fails + console.log(chalk.yellow(`⚠️ Could not check npm registry, using local increment for ${packageName}`)); + return incrementVersion(currentVersion, versionType); + } +} + +// Update package version +function updateVersion(packagePath, newVersion) { + console.log(chalk.blue(`\n✏️ Updating version to ${newVersion}...`)); + + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + const oldVersion = pkg.version; + pkg.version = newVersion; + + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(chalk.green(`✅ Version updated from ${oldVersion} to ${newVersion}`)); +} + +// Fix workspace dependencies for build +function fixWorkspaceDependenciesForBuild(packagePath) { + console.log(chalk.blue(`🔧 Fixing workspace dependencies for build: ${packagePath}...`)); + + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + // Fix dependencies + const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; + + depTypes.forEach(depType => { + if (pkg[depType]) { + Object.keys(pkg[depType]).forEach(depName => { + // Check if it's a workspace dependency + if (pkg[depType][depName] === 'workspace:*' || pkg[depType][depName].startsWith('workspace:')) { + // For build purposes, we'll use the file: protocol to reference local packages + const depPackage = packages.find(p => p.name === depName); + if (depPackage) { + console.log(chalk.gray(` Replacing ${depName} workspace dependency with file reference`)); + pkg[depType][depName] = `file:../${depPackage.path}`; + } + } + }); + } + }); + + // Write the updated package.json + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(chalk.green(`✅ Workspace dependencies fixed for build: ${packagePath}`)); +} + +// Restore workspace dependencies after build +function restoreWorkspaceDependenciesAfterBuild(packagePath) { + console.log(chalk.blue(`🔄 Restoring workspace dependencies after build: ${packagePath}...`)); + + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + // Restore dependencies + const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; + + depTypes.forEach(depType => { + if (pkg[depType]) { + Object.keys(pkg[depType]).forEach(depName => { + // Check if this is one of our internal packages referenced with file: + const depPackage = packages.find(p => p.name === depName); + if (depPackage && pkg[depType][depName].startsWith('file:')) { + console.log(chalk.gray(` Restoring ${depName} to workspace dependency`)); + pkg[depType][depName] = 'workspace:*'; + } + }); + } + }); + + // Write the updated package.json + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(chalk.green(`✅ Workspace dependencies restored after build: ${packagePath}`)); +} + +// Fix workspace dependencies for publish +function fixWorkspaceDependenciesForPublish(packagePath, packageVersions) { + console.log(chalk.blue(`🔧 Fixing workspace dependencies for publish: ${packagePath}...`)); + + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + // Fix dependencies + const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; + + depTypes.forEach(depType => { + if (pkg[depType]) { + Object.keys(pkg[depType]).forEach(depName => { + // Check if it's a workspace dependency + if (pkg[depType][depName] === 'workspace:*' || pkg[depType][depName].startsWith('workspace:')) { + // Replace with actual version + const depPackage = packages.find(p => p.name === depName); + if (depPackage && packageVersions[depName]) { + console.log(chalk.gray(` Replacing ${depName} workspace dependency with version ${packageVersions[depName]}`)); + pkg[depType][depName] = packageVersions[depName]; + } + } + }); + } + }); + + // Write the updated package.json + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(chalk.green(`✅ Workspace dependencies fixed for publish: ${packagePath}`)); +} + +// Restore workspace dependencies after publish +function restoreWorkspaceDependenciesAfterPublish(packagePath) { + console.log(chalk.blue(`🔄 Restoring workspace dependencies for ${packagePath}...`)); + + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + // Restore dependencies + const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; + + depTypes.forEach(depType => { + if (pkg[depType]) { + Object.keys(pkg[depType]).forEach(depName => { + // Check if this is one of our internal packages + const depPackage = packages.find(p => p.name === depName); + if (depPackage) { + console.log(chalk.gray(` Restoring ${depName} to workspace dependency`)); + pkg[depType][depName] = 'workspace:*'; + } + }); + } + }); + + // Write the updated package.json + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(chalk.green(`✅ Workspace dependencies restored for ${packagePath}`)); +} + +// Build package +function buildPackage(pkg) { + console.log(chalk.blue(`\n🔨 Building ${pkg.name} (${pkg.description})...`)); + + try { + // Fix workspace dependencies for build + fixWorkspaceDependenciesForBuild(pkg.path); + + try { + const pkgDir = path.join(getWorkspaceRoot(), pkg.path); + if (pkg.path === 'cli') { + execSync('node scripts/sync-bundled-core.mjs', { cwd: pkgDir, stdio: 'inherit' }); + if (fs.existsSync(path.join(pkgDir, 'server', 'package.json'))) { + execSync('pnpm run build', { cwd: path.join(pkgDir, 'server'), stdio: 'inherit' }); + } + } else if (pkg.path === 'server') { + execSync('pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); + } else if (pkg.path === 'client') { + execSync('pnpm run prebuild && pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); + } else if (pkg.path === 'toolkit') { + execSync('pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); + } else if (pkg.path === 'templates/hello-world' || pkg.path === 'templates/twentytwentyfive') { + console.log(chalk.gray(' Templates do not require building, skipping...')); + } else if (fs.existsSync(path.join(pkgDir, 'package.json'))) { + execSync('pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); + } + console.log(chalk.green(`✅ ${pkg.name} built successfully`)); + } finally { + // Always restore workspace dependencies + restoreWorkspaceDependenciesAfterBuild(pkg.path); + } + } catch (error) { + console.log(chalk.red(`❌ Failed to build ${pkg.name}`)); + throw error; + } +} + +// Publish package +function publishPackage(packagePath, packageName, tag = 'latest') { + console.log(chalk.blue(`\n🚀 Publishing ${packageName} with tag ${tag}...`)); + + try { + const command = `pnpm publish --access public --tag ${tag} --registry https://registry.npmjs.org --no-git-checks`; + execSync(command, { cwd: path.join(getWorkspaceRoot(), packagePath), stdio: 'inherit' }); + console.log(chalk.green(`✅ ${packageName} published successfully!`)); + } catch (error) { + console.log(chalk.red(`❌ Failed to publish ${packageName}`)); + throw error; + } +} + +// Create GitHub release +function createGitHubRelease(tagName, releaseNotes) { + console.log(chalk.blue(`\n📝 Creating GitHub release ${tagName}...`)); + + try { + // Create release using GitHub CLI if available + const command = `gh release create ${tagName} --title "${tagName}" --notes "${releaseNotes}"`; + execSync(command, { stdio: 'inherit' }); + console.log(chalk.green(`✅ GitHub release ${tagName} created successfully!`)); + } catch (error) { + console.log(chalk.yellow(`⚠️ Failed to create GitHub release (GitHub CLI may not be installed or configured)`)); + console.log(chalk.gray('You can manually create the release at: https://github.com/fecommunity/reactpress/releases/new')); + } +} + +// Check environment +function checkEnvironment() { + // Check if pnpm is installed + try { + execSync('pnpm --version', { stdio: 'ignore' }); + } catch (error) { + console.log(chalk.red('❌ pnpm is not installed. Please install pnpm first.')); + return false; + } + + // Check if logged in to npm + try { + execSync('pnpm whoami --registry https://registry.npmjs.org', { stdio: 'ignore' }); + } catch (error) { + console.log(chalk.red('❌ Not logged in to npm. Please run "pnpm login --registry https://registry.npmjs.org" first.')); + return false; + } + + return true; +} + +// Build packages function +async function buildPackages() { + console.log(chalk.blue('🏗️ ReactPress Package Builder\n')); + + // Show current versions + console.log(chalk.cyan('📋 Current package versions:')); + packages.forEach(pkg => { + const version = getCurrentVersion(pkg.path); + console.log(chalk.gray(` ${pkg.name}: ${version}`)); + }); + console.log(); + + try { + // Track which packages actually need to be built + const packagesToBuild = []; + + // Check for meaningful changes in each package + for (const pkg of packages) { + if (fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { + if (hasMeaningfulChangesForBuild(pkg.path, pkg.name)) { + packagesToBuild.push(pkg); + console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be built`)); + } else { + console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); + } + } else { + console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); + } + } + + if (packagesToBuild.length === 0) { + console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to build!')); + return; + } + + // Build packages that have changes + for (const pkg of packagesToBuild) { + await buildPackage(pkg); + // Save the hash after successful build + savePackageHashForBuild(pkg.path, pkg.name); + } + + console.log(chalk.green(`\n🎉 ${packagesToBuild.length} package(s) built successfully!`)); + } catch (error) { + console.error(chalk.red('❌ Build failed:'), error); + process.exit(1); + } +} + +// Publish packages function +async function publishPackages() { + // Check if called with --no-build flag + const noBuild = process.argv.includes('--no-build'); + + console.log(chalk.blue('📦 ReactPress Package Publisher\n')); + + // Run environment checks + if (!checkEnvironment()) { + process.exit(1); + } + + // Show current versions + console.log(chalk.cyan('📋 Current package versions:')); + packages.forEach(pkg => { + const version = getCurrentVersion(pkg.path); + console.log(chalk.gray(` ${pkg.name}: ${version}`)); + }); + console.log(); + + // Ask for publishing options + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: 'What would you like to do?', + choices: [ + { name: '🚀 Publish all packages with version bump', value: 'publish-all' }, + { name: '📦 Publish specific package', value: 'publish-one' }, + { name: '🔨 Build all packages only', value: 'build-all' }, + { name: '🏷️ Publish as beta/alpha', value: 'publish-prerelease' }, + { name: '❌ Cancel', value: 'cancel' } + ] + } + ]); + + if (action === 'cancel') { + console.log(chalk.yellow('Operation cancelled.')); + return; + } + + if (action === 'build-all') { + console.log(chalk.blue('🔨 Building all packages...\n')); + + // Track which packages actually need to be built + const packagesToBuild = []; + + // Check for meaningful changes in each package + for (const pkg of packages) { + if (fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { + if (hasMeaningfulChangesForPublish(pkg.path, pkg.name)) { + packagesToBuild.push(pkg); + console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be built`)); + } else { + console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); + } + } else { + console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); + } + } + + if (packagesToBuild.length === 0) { + console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to build!')); + return; + } + + for (const pkg of packagesToBuild) { + await buildPackage(pkg); + savePackageHashForPublish(pkg.path, pkg.name); + } + + console.log(chalk.green(`\n🎉 ${packagesToBuild.length} package(s) built successfully!`)); + return; + } + + if (action === 'publish-one') { + const { selectedPackage } = await inquirer.prompt([ + { + type: 'list', + name: 'selectedPackage', + message: 'Which package would you like to publish?', + choices: packages.map(pkg => ({ + name: `${pkg.name} (${pkg.description})`, + value: pkg + })) + } + ]); + + // Check if the selected package has meaningful changes + if (!hasMeaningfulChangesForPublish(selectedPackage.path, selectedPackage.name)) { + console.log(chalk.gray(`\n⏭️ ${selectedPackage.name} has no meaningful changes, skipping...`)); + console.log(chalk.green('✅ Nothing to publish!')); + return; + } + + const { versionType } = await inquirer.prompt([ + { + type: 'list', + name: 'versionType', + message: 'Version bump type:', + choices: [ + { name: 'Beta (1.0.0-beta.1 -> 1.0.0-beta.2)', value: 'beta' }, + { name: 'Patch (1.0.0 -> 1.0.1)', value: 'patch' }, + { name: 'Minor (1.0.0 -> 1.1.0)', value: 'minor' }, + { name: 'Major (1.0.0 -> 2.0.0)', value: 'major' }, + { name: 'Custom version', value: 'custom' } + ] + } + ]); + + let newVersion; + const currentVersion = getCurrentVersion(selectedPackage.path); + + if (versionType === 'custom') { + const { customVersion } = await inquirer.prompt([ + { + type: 'input', + name: 'customVersion', + message: `Enter new version for ${selectedPackage.name} (current: ${currentVersion}):`, + validate: (input) => { + const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/; + return semverRegex.test(input) || 'Please enter a valid semver version (e.g., 1.0.0)'; + } + } + ]); + newVersion = customVersion; + } else { + newVersion = getNextAvailableVersion(selectedPackage.name, currentVersion, versionType); + } + + // Get all package versions for dependency resolution + const packageVersions = {}; + packages.forEach(pkg => { + packageVersions[pkg.name] = getCurrentVersion(pkg.path); + }); + // Update the selected package version + packageVersions[selectedPackage.name] = newVersion; + + // Fix workspace dependencies before publishing + fixWorkspaceDependenciesForPublish(selectedPackage.path, packageVersions); + + try { + updateVersion(selectedPackage.path, newVersion); + // Only build if not disabled + if (!noBuild) { + buildPackage(selectedPackage); + } + + // Determine tag based on version type + const tag = versionType === 'beta' ? 'beta' : 'latest'; + publishPackage(selectedPackage.path, selectedPackage.name, tag); + + // Save the hash after successful publish + savePackageHashForPublish(selectedPackage.path, selectedPackage.name); + + console.log(chalk.green(`\n🎉 ${selectedPackage.name} v${newVersion} published successfully!`)); + } finally { + // Always restore workspace dependencies + restoreWorkspaceDependenciesAfterPublish(selectedPackage.path); + } + + return; + } + + if (action === 'publish-prerelease') { + const { tag } = await inquirer.prompt([ + { + type: 'list', + name: 'tag', + message: 'Select prerelease tag:', + choices: ['beta', 'alpha', 'rc', 'next'] + } + ]); + + const { versionType } = await inquirer.prompt([ + { + type: 'list', + name: 'versionType', + message: 'Version bump type:', + choices: [ + { name: `Prerelease (${tag})`, value: tag }, + { name: 'Patch (1.0.0 -> 1.0.1)', value: 'patch' }, + { name: 'Minor (1.0.0 -> 1.1.0)', value: 'minor' }, + { name: 'Major (1.0.0 -> 2.0.0)', value: 'major' }, + { name: 'Custom version', value: 'custom' } + ] + } + ]); + + // Get all package versions for dependency resolution + const packageVersions = {}; + packages.forEach(pkg => { + const currentVersion = getCurrentVersion(pkg.path); + packageVersions[pkg.name] = currentVersion; + }); + + // Track which packages actually need to be published + const packagesToPublish = []; + + // Check for meaningful changes in each package + for (const pkg of packages) { + if (!fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { + console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); + continue; + } + + if (hasMeaningfulChangesForPublish(pkg.path, pkg.name)) { + packagesToPublish.push(pkg); + console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be published`)); + } else { + console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); + } + } + + if (packagesToPublish.length === 0) { + console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to publish!')); + return; + } + + // Process each package that has changes + for (const pkg of packagesToPublish) { + let newVersion; + const currentVersion = getCurrentVersion(pkg.path); + + if (versionType === 'custom') { + const { customVersion } = await inquirer.prompt([ + { + type: 'input', + name: 'customVersion', + message: `Enter version for ${pkg.name} (current: ${currentVersion}):`, + default: currentVersion + } + ]); + newVersion = customVersion; + } else { + newVersion = getNextAvailableVersion(pkg.name, currentVersion, versionType); + } + + // Update package version in our tracking + packageVersions[pkg.name] = newVersion; + + // Fix workspace dependencies before publishing + fixWorkspaceDependenciesForPublish(pkg.path, packageVersions); + + try { + updateVersion(pkg.path, newVersion); + // Only build if not disabled + if (!noBuild) { + buildPackage(pkg); + } + publishPackage(pkg.path, pkg.name, tag); + + // Save the hash after successful publish + savePackageHashForPublish(pkg.path, pkg.name); + } finally { + // Always restore workspace dependencies + restoreWorkspaceDependenciesAfterPublish(pkg.path); + } + } + + console.log(chalk.green(`\n🎉 ${packagesToPublish.length} package(s) published with ${tag} tag!`)); + return; + } + + if (action === 'publish-all') { + // Check if we're on master branch for final release + let isMasterBranch = false; + try { + const branch = execSync('git branch --show-current', { encoding: 'utf8' }).trim(); + isMasterBranch = branch === 'master' || branch === 'main'; + } catch (error) { + console.log(chalk.yellow('⚠️ Unable to determine current branch')); + } + + const { versionType } = await inquirer.prompt([ + { + type: 'list', + name: 'versionType', + message: 'Version bump type:', + choices: [ + { name: `Beta ${isMasterBranch ? '(will publish as final)' : '(will publish as beta)'}`, value: 'beta' }, + { name: 'Patch (1.0.0 -> 1.0.1)', value: 'patch' }, + { name: 'Minor (1.0.0 -> 1.1.0)', value: 'minor' }, + { name: 'Major (1.0.0 -> 2.0.0)', value: 'major' }, + { name: 'Custom version', value: 'custom' } + ] + } + ]); + + // Get all package versions for dependency resolution + const packageVersions = {}; + const originalVersions = {}; + + packages.forEach(pkg => { + const currentVersion = getCurrentVersion(pkg.path); + originalVersions[pkg.name] = currentVersion; + packageVersions[pkg.name] = currentVersion; + }); + + let baseVersion; + if (versionType === 'custom') { + const { customVersion } = await inquirer.prompt([ + { + type: 'input', + name: 'customVersion', + message: 'Enter new version for all packages:', + validate: (input) => { + const semverRegex = /^\d+\.\d+\.\d+$/; + return semverRegex.test(input) || 'Please enter a valid semver version (e.g., 1.0.0)'; + } + } + ]); + baseVersion = customVersion; + } else { + // Use the highest current version as base and increment + const nextVersion = getNextAvailableVersion(packages[0].name, originalVersions[packages[0].name], versionType); + baseVersion = nextVersion; + } + + console.log(chalk.cyan(`\n📋 Will publish all packages with version: ${baseVersion}\n`)); + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Are you sure you want to proceed?', + default: false + } + ]); + + if (!confirm) { + console.log(chalk.yellow('Operation cancelled.')); + return; + } + + // Track which packages actually need to be published + const packagesToPublish = []; + + // Check for meaningful changes in each package + for (const pkg of packages) { + if (!fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { + console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); + continue; + } + + if (hasMeaningfulChangesForPublish(pkg.path, pkg.name)) { + packagesToPublish.push(pkg); + console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be published`)); + } else { + console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); + } + } + + if (packagesToPublish.length === 0) { + console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to publish!')); + return; + } + + // Update versions, build and publish only packages with changes + for (const pkg of packagesToPublish) { + console.log(chalk.blue(`\n📦 Processing ${pkg.name}...`)); + + // For publish-all, we use the same version for all packages + const pkgVersion = baseVersion; + packageVersions[pkg.name] = pkgVersion; + + // Fix workspace dependencies before publishing + fixWorkspaceDependenciesForPublish(pkg.path, packageVersions); + + try { + updateVersion(pkg.path, pkgVersion); + // Only build if not disabled + if (!noBuild) { + buildPackage(pkg); + } + + // Determine tag based on version type and branch + const tag = (versionType === 'beta' && !isMasterBranch) ? 'beta' : 'latest'; + publishPackage(pkg.path, pkg.name, tag); + + // Save the hash after successful publish + savePackageHashForPublish(pkg.path, pkg.name); + } finally { + // Always restore workspace dependencies + restoreWorkspaceDependenciesAfterPublish(pkg.path); + } + } + + // Create GitHub release if on master and we actually published something + if (isMasterBranch && packagesToPublish.length > 0) { + const tagName = `v${baseVersion}`; + const releaseNotes = `Release ${baseVersion} + +Packages released: +${Object.entries(packageVersions).map(([name, version]) => `- ${name}@${version}`).join('\n')}`; + createGitHubRelease(tagName, releaseNotes); + } + + console.log(chalk.green(`\n🎉 ${packagesToPublish.length} package(s) published successfully with version ${baseVersion}!`)); + console.log(chalk.cyan('\n📋 Next steps:')); + console.log(chalk.gray('1. Create a git tag: git tag v' + baseVersion)); + console.log(chalk.gray('2. Push changes: git push && git push --tags')); + } +} + +// Main function +async function main() { + // Check command line arguments + const args = process.argv.slice(2); + + if (args.includes('--publish')) { + // When called with --publish, start the publish process + console.log(chalk.blue('🏗️ ReactPress Package Builder\n')); + + // Show current versions + console.log(chalk.cyan('📋 Current package versions:')); + packages.forEach(pkg => { + const version = getCurrentVersion(pkg.path); + console.log(chalk.gray(` ${pkg.name}: ${version}`)); + }); + console.log(); + + // Start the publish process + await publishPackages(); + } else if (args.includes('--build')) { + // When called with --build, just build packages + await buildPackages(); + } else { + // Default behavior - show help + console.log(chalk.blue('🏗️ ReactPress CLI\n')); + console.log('Usage:'); + console.log(' reactpress publish --build Build all packages'); + console.log(' reactpress publish --publish Publish packages (interactive)'); + console.log(''); + } +} + +module.exports = { main, buildPackages, publishPackages }; + +if (require.main === module) { + main().catch((error) => { + console.error(chalk.red('❌ Operation failed:'), error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/cli/lib/root.js b/cli/lib/root.js new file mode 100644 index 00000000..cfb53a79 --- /dev/null +++ b/cli/lib/root.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const path = require('path'); + +const CLI_PACKAGE_NAME = '@fecommunity/reactpress'; + +/** + * Install root: monorepo checkout (repo root) or published @fecommunity/reactpress package root. + * cli/lib -> ../.. when pnpm-workspace.yaml exists; published lib/ -> .. only. + */ +function getMonorepoRoot() { + const packageRoot = path.resolve(__dirname, '..'); + const parentOfPackage = path.resolve(__dirname, '../..'); + if (fs.existsSync(path.join(parentOfPackage, 'pnpm-workspace.yaml'))) { + return parentOfPackage; + } + return packageRoot; +} + +function isPublishedCliRoot(dir) { + const resolved = path.resolve(dir); + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(resolved, 'package.json'), 'utf8') + ); + if (pkg.name !== CLI_PACKAGE_NAME) return false; + } catch { + return false; + } + return !fs.existsSync(path.join(resolved, 'pnpm-workspace.yaml')); +} + +function isProjectRoot(dir) { + const resolved = path.resolve(dir); + if (isPublishedCliRoot(resolved)) return false; + return ( + fs.existsSync(path.join(resolved, '.reactpress', 'config.json')) || + fs.existsSync(path.join(resolved, 'pnpm-workspace.yaml')) || + fs.existsSync(path.join(resolved, 'server', 'src', 'main.ts')) || + fs.existsSync(path.join(resolved, 'toolkit', 'package.json')) + ); +} + +function findProjectRoot(startDir = process.cwd()) { + let dir = path.resolve(startDir); + while (true) { + if (isProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function getProjectRoot() { + const envRoot = process.env.REACTPRESS_ORIGINAL_CWD; + if (envRoot) { + const resolved = path.resolve(envRoot); + if (isProjectRoot(resolved)) return resolved; + } + const discovered = findProjectRoot(process.cwd()); + if (discovered) return discovered; + if (envRoot) return path.resolve(envRoot); + return path.resolve(process.cwd()); +} + +function ensureOriginalCwd() { + const root = getProjectRoot(); + process.env.REACTPRESS_ORIGINAL_CWD = root; + return root; +} + +function isMonorepoCheckout(cwd) { + const resolved = path.resolve(cwd || process.cwd()); + return ( + fs.existsSync(path.join(resolved, 'pnpm-workspace.yaml')) || + fs.existsSync(path.join(resolved, 'server', 'src', 'main.ts')) + ); +} + +module.exports = { + getMonorepoRoot, + getProjectRoot, + ensureOriginalCwd, + isMonorepoCheckout, + isProjectRoot, + findProjectRoot, + isPublishedCliRoot, +}; diff --git a/cli/lib/spawn.js b/cli/lib/spawn.js new file mode 100644 index 00000000..4753e978 --- /dev/null +++ b/cli/lib/spawn.js @@ -0,0 +1,92 @@ +const { spawn, spawnSync } = require('child_process'); +const path = require('path'); +const chalk = require('chalk'); +const { ensureOriginalCwd } = require('./root'); +const { getCliPackageRoot } = require('./paths'); +const { t, resolveLocale } = require('./i18n'); + +function runSync(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || ensureOriginalCwd(), + stdio: 'inherit', + env: { + ...process.env, + REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(), + REACTPRESS_ORIGINAL_CWD: + options.cwd || process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(), + ...options.env, + }, + shell: options.shell ?? false, + }); + if (result.status !== 0) { + const err = new Error( + t('spawn.commandFailed', { command, code: result.status ?? 1 }) + ); + err.exitCode = result.status ?? 1; + throw err; + } + return result; +} + +function runNodeScript(scriptPath, args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [scriptPath, ...args], { + stdio: 'inherit', + cwd: options.cwd || ensureOriginalCwd(), + env: { + ...process.env, + REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(), + REACTPRESS_ORIGINAL_CWD: + options.cwd || process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(), + ...options.env, + }, + }); + + child.on('error', (error) => { + console.error(chalk.red('[ReactPress]'), error.message || error); + reject(error); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(Object.assign(new Error(t('spawn.exitCode', { code })), { exitCode: code })); + return; + } + resolve(code); + }); + }); +} + +function spawnDetached(scriptPath, args = [], options = {}) { + const child = spawn(process.execPath, [scriptPath, ...args], { + stdio: options.stdio ?? 'ignore', + detached: true, + cwd: options.cwd, + env: { + ...process.env, + REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(), + REACTPRESS_ORIGINAL_CWD: + options.cwd || process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(), + ...options.env, + }, + }); + child.unref(); + return child; +} + +function runReactpressCli(args, options = {}) { + const cliBin = path.join(getCliPackageRoot(), 'dist', 'index.js'); + return runSync(process.execPath, [cliBin, ...args], options); +} + +function resolveCliScript(relativePath) { + return path.join(__dirname, '..', relativePath); +} + +module.exports = { + runSync, + runNodeScript, + spawnDetached, + runReactpressCli, + resolveCliScript, +}; diff --git a/cli/lib/status.js b/cli/lib/status.js new file mode 100644 index 00000000..a14ce81d --- /dev/null +++ b/cli/lib/status.js @@ -0,0 +1,132 @@ +const fs = require('fs'); +const path = require('path'); +const { + brand, + icon, + divider, + padRight, + statusPill, + sectionHeader, + terminalWidth, + gradientText, + palette, +} = require('../ui/theme'); +const { + loadServerSiteUrl, + loadClientSiteUrl, + getHealthUrl, + checkHealth, + isHttpResponding, +} = require('./http'); +const { isUsingMonorepoServer } = require('./paths'); +const { readPid, isProcessRunning } = require('./process'); +const { isDockerRunning } = require('./docker'); +const { ensureOriginalCwd } = require('./root'); +const { t } = require('./i18n'); + +function envFileStatus(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + const configPath = path.join(projectRoot, '.reactpress', 'config.json'); + return { + env: fs.existsSync(envPath), + config: fs.existsSync(configPath), + envPath, + configPath, + }; +} + +function fieldRow(label, value) { + return ` ${brand.muted(padRight(label, 10))} ${value}`; +} + +async function printUnifiedStatus(projectRoot = ensureOriginalCwd()) { + const env = envFileStatus(projectRoot); + const apiUrl = loadServerSiteUrl(projectRoot); + const clientUrl = loadClientSiteUrl(projectRoot); + const pid = readPid(projectRoot); + const healthUrl = getHealthUrl(projectRoot); + const [apiHttp, clientHttp, health] = await Promise.all([ + isHttpResponding(apiUrl), + isHttpResponding(clientUrl), + checkHealth(healthUrl), + ]); + + const apiSource = isUsingMonorepoServer(projectRoot) + ? t('status.apiSource.monorepo') + : t('status.apiSource.bundle'); + + const w = Math.min(terminalWidth() - 4, 52); + const httpOn = { on: t('status.apiOnline'), off: t('status.apiOffline') }; + + console.log(''); + console.log(` ${gradientText(t('status.title'), [palette.primary, palette.accent], { bold: true })}`); + console.log(` ${divider(w)}`); + + console.log(sectionHeader(t('status.section.project'))); + console.log(fieldRow(t('status.field.dir'), brand.dim(projectRoot))); + console.log(fieldRow(t('status.field.source'), brand.accent(apiSource))); + console.log( + fieldRow( + t('status.field.config'), + env.config ? brand.success(t('status.configOk')) : brand.warn(t('status.configBad')) + ) + ); + console.log( + fieldRow( + t('status.field.env'), + env.env ? brand.success(t('status.envOk')) : brand.warn(t('status.envBad')) + ) + ); + + console.log(''); + console.log(sectionHeader(t('status.section.api'))); + console.log(fieldRow(t('status.field.url'), brand.dim(apiUrl))); + console.log(fieldRow(t('status.field.http'), statusPill(apiHttp, httpOn))); + console.log( + fieldRow( + t('status.field.health'), + health.ok + ? `${icon.ok} ${brand.dim(healthUrl)}` + : brand.dim(t('status.apiUnreachable', { url: healthUrl })) + ) + ); + if (health.ok && health.data?.data) { + const db = health.data.data.database; + const dbOk = db === 'up'; + console.log( + fieldRow( + t('status.field.database'), + statusPill(dbOk, { on: t('status.dbUp'), off: t('status.dbDown') }) + ) + ); + } + const pidAlive = pid && isProcessRunning(pid); + console.log( + fieldRow( + t('status.field.pid'), + `${brand.dim(pid ?? '—')}${pidAlive ? ` ${brand.success(t('status.pidRunning'))}` : ''}` + ) + ); + + console.log(''); + console.log(sectionHeader(t('status.section.frontend'))); + console.log(fieldRow(t('status.field.url'), brand.dim(clientUrl))); + console.log(fieldRow(t('status.field.http'), statusPill(clientHttp, httpOn))); + + console.log(''); + console.log(sectionHeader(t('status.section.docker'))); + console.log( + fieldRow( + t('status.field.engine'), + statusPill(isDockerRunning(), { + on: t('status.dockerUp'), + off: t('status.dockerDown'), + }) + ) + ); + + console.log(` ${divider(w)}`); + console.log(''); +} + +module.exports = { printUnifiedStatus, envFileStatus }; diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..ad79a224 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,62 @@ +{ + "name": "@fecommunity/reactpress", + "version": "3.0.3", + "description": "ReactPress 3.0 — zero-config CMS: one package, one reactpress command", + "author": "fecommunity", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/fecommunity/reactpress.git", + "directory": "cli" + }, + "keywords": [ + "reactpress", + "cms", + "blog", + "cli" + ], + "engines": { + "node": ">=18" + }, + "bin": { + "reactpress": "./bin/reactpress.js", + "reactpress-cli": "./bin/reactpress-cli-shim.js" + }, + "main": "./bin/reactpress.js", + "files": [ + "bin", + "lib", + "ui", + "dist", + "server", + "templates", + "scripts/install-bundled-runtime.mjs", + "README.md", + "LICENSE" + ], + "scripts": { + "test": "node --test tests", + "prepare": "node scripts/sync-bundled-core.mjs", + "prepack": "node scripts/sync-bundled-core.mjs" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "optionalDependencies": { + "mysql2": "^3.12.0" + }, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^9.4.1", + "concurrently": "^7.0.0", + "cross-spawn": "^7.0.3", + "fs-extra": "^11.2.0", + "inquirer": "^8.2.4", + "open": "^8.2.1", + "ora": "^5.4.1" + }, + "devDependencies": { + "@fecommunity/reactpress-cli-core": "npm:@fecommunity/reactpress-cli@0.1.0" + } +} diff --git a/cli/scripts/install-bundled-runtime.mjs b/cli/scripts/install-bundled-runtime.mjs new file mode 100644 index 00000000..89e63b5f --- /dev/null +++ b/cli/scripts/install-bundled-runtime.mjs @@ -0,0 +1,46 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const serverDir = join(cliRoot, 'server'); + +function npmInstall() { + console.log('[reactpress-cli] installing bundled server runtime dependencies…'); + const result = spawnSync( + 'npm', + ['install', '--omit=dev', '--legacy-peer-deps', '--no-bin-links'], + { + cwd: serverDir, + stdio: 'inherit', + shell: process.platform === 'win32', + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +if (!existsSync(join(serverDir, 'package.json'))) { + console.warn('[reactpress-cli] bundled server missing, skip runtime install'); + process.exit(0); +} + +const serverRuntimeModules = [ + ['node_modules', '@nestjs', 'core'], + ['node_modules', '@nestjs', 'typeorm'], + ['node_modules', 'typeorm'], + ['node_modules', 'express'], + ['node_modules', '@fecommunity', 'reactpress-toolkit'], +]; + +const serverReady = + existsSync(join(serverDir, 'dist', 'main.js')) && + serverRuntimeModules.every((parts) => existsSync(join(serverDir, ...parts))); + +if (serverReady) { + process.exit(0); +} + +npmInstall(); diff --git a/cli/scripts/sync-bundled-core.mjs b/cli/scripts/sync-bundled-core.mjs new file mode 100644 index 00000000..68021882 --- /dev/null +++ b/cli/scripts/sync-bundled-core.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Copy core CLI runtime (dist, server, templates) from the legacy npm package + * so @fecommunity/reactpress-cli can be published as a self-contained package. + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliRoot = path.join(__dirname, '..'); +const require = createRequire(import.meta.url); + +const LEGACY_PKG = '@fecommunity/reactpress-cli-core'; + +const SKIP_DIRS = new Set(['node_modules', '.git', 'logs']); +const SKIP_FILES = new Set(['package-lock.json']); + +function copyDir(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue; + if (!entry.isDirectory() && SKIP_FILES.has(entry.name)) continue; + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(from, to); + } else { + fs.copyFileSync(from, to); + } + } +} + +function main() { + let legacyRoot; + try { + legacyRoot = path.dirname(require.resolve(`${LEGACY_PKG}/package.json`)); + } catch { + console.warn( + `[sync-bundled-core] Skip: install devDependency ${LEGACY_PKG} (npm:@fecommunity/reactpress-cli@0.1.0) first` + ); + process.exit(0); + } + + const entries = ['dist', 'server', 'templates', 'scripts']; + for (const name of entries) { + const src = path.join(legacyRoot, name); + if (!fs.existsSync(src)) continue; + const dest = path.join(cliRoot, name); + copyDir(src, dest); + console.log(`[sync-bundled-core] ${name}/ -> cli/${name}/`); + } + + for (const file of ['LICENSE', 'README.md']) { + const src = path.join(legacyRoot, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, path.join(cliRoot, file)); + } + } + + const distPkg = path.join(cliRoot, 'dist', 'package.json'); + fs.writeFileSync(distPkg, JSON.stringify({ type: 'module' }, null, 2) + '\n'); + + // Keep ESM bridge to CommonJS i18n (not shipped in legacy package) + const i18nBridge = path.join(cliRoot, 'dist', 'i18n.js'); + if (!fs.existsSync(i18nBridge)) { + fs.writeFileSync( + i18nBridge, + `import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { t, getLocale, setLocale } = require(join(dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'i18n', 'index.js')); + +export { t, getLocale, setLocale }; +` + ); + console.log('[sync-bundled-core] restored dist/i18n.js bridge'); + } +} + +main(); diff --git a/cli/server/bin/reactpress-server.js b/cli/server/bin/reactpress-server.js new file mode 100644 index 00000000..c6121a15 --- /dev/null +++ b/cli/server/bin/reactpress-server.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +/** + * ReactPress Server CLI Entry Point + * This script allows starting the ReactPress server via npx + * Supports both regular and PM2 startup modes + */ + +const path = require('path'); +const fs = require('fs'); +const { spawn, spawnSync } = require('child_process'); + +// Capture the original working directory where npx was executed +// BUT prioritize the REACTPRESS_ORIGINAL_CWD environment variable if it exists +// This ensures consistency when running via pnpm dev from root directory +const originalCwd = process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(); + +// Get command line arguments +const args = process.argv.slice(2); +const usePM2 = args.includes('--pm2'); +const showHelp = args.includes('--help') || args.includes('-h'); + +// Show help if requested +if (showHelp) { + console.log(` +ReactPress Server - NestJS-based backend API for ReactPress CMS + +Usage: + reactpress-server [options] + (通常由 reactpress-cli start 调用) + +Options: + --pm2 Start server with PM2 process manager + --help, -h Show this help message + +Examples: + node bin/reactpress-server.js # Start server normally + node bin/reactpress-server.js --pm2 # Start server with PM2 + `); + process.exit(0); +} + +// Get the directory where this script is located +const binDir = __dirname; +const serverDir = path.join(binDir, '..'); +const distPath = path.join(serverDir, 'dist', 'main.js'); +const ecosystemPath = path.join(serverDir, 'ecosystem.config.js'); + +// Function to check if PM2 is installed +function isPM2Installed() { + try { + require.resolve('pm2'); + return true; + } catch (e) { + // Check if PM2 is installed globally + try { + spawnSync('pm2', ['--version'], { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } + } +} + +// Function to install PM2 +function installPM2() { + console.log('[ReactPress Server] Installing PM2...'); + const installResult = spawnSync('npm', ['install', 'pm2', '--no-save'], { + stdio: 'inherit', + cwd: serverDir + }); + + if (installResult.status !== 0) { + console.error('[ReactPress Server] Failed to install PM2'); + return false; + } + + return true; +} + +// Function to start with PM2 +function startWithPM2() { + // Check if PM2 is installed + if (!isPM2Installed()) { + // Try to install PM2 + if (!installPM2()) { + console.error('[ReactPress Server] Cannot start with PM2'); + process.exit(1); + } + } + + // Check if the server is built + if (!fs.existsSync(distPath)) { + console.log('[ReactPress Server] Server not built yet. Building...'); + + // Try to build the server + const buildResult = spawnSync('npm', ['run', 'build'], { + stdio: 'inherit', + cwd: serverDir + }); + + if (buildResult.status !== 0) { + console.error('[ReactPress Server] Failed to build server'); + process.exit(1); + } + } + + console.log('[ReactPress Server] Starting with PM2...'); + + // Use ecosystem.config.js if it exists, otherwise use direct command + let pm2Command = 'pm2'; + try { + // Try to resolve PM2 path + pm2Command = path.join(serverDir, 'node_modules', '.bin', 'pm2'); + if (!fs.existsSync(pm2Command)) { + pm2Command = 'pm2'; + } + } catch (e) { + pm2Command = 'pm2'; + } + + // Check if ecosystem.config.js exists + if (fs.existsSync(ecosystemPath)) { + const pm2 = spawn(pm2Command, ['start', ecosystemPath], { + stdio: 'inherit', + cwd: serverDir + }); + + pm2.on('close', (code) => { + console.log(`[ReactPress Server] PM2 process exited with code ${code}`); + process.exit(code); + }); + + pm2.on('error', (error) => { + console.error('[ReactPress Server] Failed to start with PM2:', error); + process.exit(1); + }); + } else { + // Fallback to direct start + const pm2 = spawn(pm2Command, ['start', distPath, '--name', 'reactpress-server'], { + stdio: 'inherit', + cwd: serverDir + }); + + pm2.on('close', (code) => { + console.log(`[ReactPress Server] PM2 process exited with code ${code}`); + process.exit(code); + }); + + pm2.on('error', (error) => { + console.error('[ReactPress Server] Failed to start with PM2:', error); + process.exit(1); + }); + } +} + +// Function to start with regular Node.js +function startWithNode() { + // Check if the server is built + if (!fs.existsSync(distPath)) { + console.log('[ReactPress Server] Server not built yet. Building...'); + + // Try to build the server + const buildResult = spawnSync('npm', ['run', 'build'], { + stdio: 'inherit', + cwd: serverDir + }); + + if (buildResult.status !== 0) { + console.error('[ReactPress Server] Failed to build server'); + process.exit(1); + } + } + + // ONLY set the environment variable if it's not already set + // This preserves the value set by set-env.js when running pnpm dev from root + if (!process.env.REACTPRESS_ORIGINAL_CWD) { + process.env.REACTPRESS_ORIGINAL_CWD = originalCwd; + } else { + console.log(`[ReactPress Server] Using existing REACTPRESS_ORIGINAL_CWD: ${process.env.REACTPRESS_ORIGINAL_CWD}`); + } + + // Change to the server directory + process.chdir(serverDir); + + // Set environment variables + process.env.NODE_ENV = process.env.NODE_ENV || 'production'; + + // Import and run the server + try { + require(distPath); + } catch (error) { + console.error('[ReactPress Server] Failed to start server:', error); + process.exit(1); + } +} + +// Main execution +if (usePM2) { + startWithPM2(); +} else { + startWithNode(); +} diff --git a/cli/server/ecosystem.config.js b/cli/server/ecosystem.config.js new file mode 100644 index 00000000..6a90515a --- /dev/null +++ b/cli/server/ecosystem.config.js @@ -0,0 +1,18 @@ +module.exports = { + apps: [ + { + name: 'reactpress-server', + script: './dist/main.js', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + env: { + NODE_ENV: 'production', + }, + env_development: { + NODE_ENV: 'development', + }, + }, + ], +}; \ No newline at end of file diff --git a/cli/server/package.json b/cli/server/package.json new file mode 100644 index 00000000..4f84e927 --- /dev/null +++ b/cli/server/package.json @@ -0,0 +1,146 @@ +{ + "name": "reactpress-server", + "version": "1.0.0-beta.16", + "description": "ReactPress Server - NestJS-based backend API for ReactPress CMS", + "author": "fecommunity", + "license": "MIT", + "main": "dist/main.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*", + "public/**/*", + "bin/**/*", + "README.md", + "LICENSE" + ], + "keywords": [ + "reactpress", + "server", + "nestjs", + "cms", + "blog", + "api", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/fecommunity/reactpress.git", + "directory": "server" + }, + "engines": { + "node": ">=16.5.0" + }, + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build && npm run copy-assets", + "copy-assets": "cp -r public dist/ 2>/dev/null || true", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "dev": "nest start --watch", + "debug": "nest start --debug --watch", + "start": "cross-env NODE_ENV=production node dist/main", + "pm2": "pm2 start npm --name @fecommunity/reactpress-server -- start", + "lint": "tslint -p tsconfig.json -c tslint.json", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prepublishOnly": "npm run build", + "pm2:start": "pm2 start dist/main.js --name reactpress-server", + "pm2:stop": "pm2 stop reactpress-server", + "pm2:restart": "pm2 restart reactpress-server", + "pm2:delete": "pm2 delete reactpress-server", + "pm2:logs": "pm2 logs reactpress-server", + "pm2:status": "pm2 status reactpress-server", + "generate:swagger": "ts-node src/generate-swagger.ts" + }, + "dependencies": { + "@fecommunity/reactpress-toolkit": "file:../toolkit", + "@nestjs/cli": "^6.9.0", + "@nestjs/common": "^6.7.2", + "@nestjs/config": "^0.6.3", + "@nestjs/core": "^6.7.2", + "@nestjs/jwt": "^6.1.1", + "@nestjs/passport": "^6.1.1", + "@nestjs/platform-express": "^6.11.5", + "@nestjs/swagger": "^4.8.2", + "@nestjs/typeorm": "^6.3.4", + "@types/express-serve-static-core": "^4.19.5", + "ali-oss": "^6.5.1", + "axios": "^0.23.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.19.0", + "chalk": "^4.1.2", + "class-transformer": "^0.2.3", + "commander": "^9.4.1", + "compression": "^1.7.4", + "cross-env": "^7.0.3", + "date-fns": "^2.17.0", + "deepmerge": "^4.2.2", + "dotenv": "^8.2.0", + "express": "^4.18.2", + "express-rate-limit": "^5.0.0", + "fs-extra": "^10.0.0", + "helmet": "^3.21.2", + "highlight.js": "^9.18.0", + "inquirer": "^8.2.4", + "lodash": "^4.17.21", + "log4js": "^6.1.0", + "marked": "^0.8.0", + "mysql2": "^3.12.0", + "node-ip2region": "^1.0.2", + "nodemailer": "^6.4.2", + "nuid": "^1.1.0", + "open": "^8.2.1", + "passport": "^0.4.1", + "passport-jwt": "^4.0.0", + "pm2": "^5.2.0", + "reflect-metadata": "^0.1.13", + "rimraf": "^3.0.0", + "rxjs": "^6.5.3", + "segment": "^0.1.3", + "swagger-themes": "^1.4.3", + "swagger-ui-express": "^4.1.6", + "typeorm": "^0.2.45", + "ua-parser-js": "^0.7.28" + }, + "devDependencies": { + "@nestjs/schematics": "^6.7.0", + "@nestjs/testing": "^6.7.1", + "@types/express": "4.17.18", + "@types/jest": "^24.0.18", + "@types/node": "^12.7.5", + "@types/supertest": "^2.0.8", + "@typescript-eslint/eslint-plugin": "^5.21.0", + "@typescript-eslint/parser": "^5.21.0", + "eslint": "^8.15.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-simple-import-sort": "^7.0.0", + "jest": "^24.9.0", + "next-transpile-modules": "^6.3.0", + "prettier": "^1.18.2", + "supertest": "^4.0.2", + "ts-jest": "^24.1.0", + "ts-loader": "^6.1.1", + "ts-node": "^8.4.1", + "tsconfig-paths": "^3.9.0", + "tslint": "^5.20.0", + "typescript": "~4.1.6" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/cli/server/public/apple-touch-icon.png b/cli/server/public/apple-touch-icon.png new file mode 100644 index 00000000..29a9ac9c Binary files /dev/null and b/cli/server/public/apple-touch-icon.png differ diff --git a/cli/server/public/favicon-16.png b/cli/server/public/favicon-16.png new file mode 100644 index 00000000..9d8cd78b Binary files /dev/null and b/cli/server/public/favicon-16.png differ diff --git a/cli/server/public/favicon-32.png b/cli/server/public/favicon-32.png new file mode 100644 index 00000000..f455437f Binary files /dev/null and b/cli/server/public/favicon-32.png differ diff --git a/cli/server/public/favicon-48.png b/cli/server/public/favicon-48.png new file mode 100644 index 00000000..b09a2a0a Binary files /dev/null and b/cli/server/public/favicon-48.png differ diff --git a/cli/server/public/favicon.ico b/cli/server/public/favicon.ico new file mode 100644 index 00000000..5cca6cb9 Binary files /dev/null and b/cli/server/public/favicon.ico differ diff --git a/cli/server/public/favicon.png b/cli/server/public/favicon.png new file mode 100644 index 00000000..f455437f Binary files /dev/null and b/cli/server/public/favicon.png differ diff --git a/cli/server/public/icon-192.png b/cli/server/public/icon-192.png new file mode 100644 index 00000000..c158d9e7 Binary files /dev/null and b/cli/server/public/icon-192.png differ diff --git a/cli/server/public/icon-512.png b/cli/server/public/icon-512.png new file mode 100644 index 00000000..6b917752 Binary files /dev/null and b/cli/server/public/icon-512.png differ diff --git a/cli/server/public/index.html b/cli/server/public/index.html new file mode 100644 index 00000000..4c285e3a --- /dev/null +++ b/cli/server/public/index.html @@ -0,0 +1,439 @@ + + + + + + ReactPress › Installation + + + +
+ + +
+
+
+ +
+ +
+

Database Connection Information

+ +
+ You should have this information available from your web hosting provider. If you don't have it, contact them before continuing. +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +

Usually "localhost" or "127.0.0.1"

+
+ +

Default MySQL port is 3306

+
+ +

Your MySQL username

+
+ +

Your MySQL password

+
+ +

The name of the database you want to use with ReactPress

+
+ +
+ +

+ +

+
+ + +
+

Site Information

+ +
+ Please provide the following information. Don't worry, you can always change these settings later. +
+ + + + + + + + + + +
+ +

The URL where your ReactPress frontend will be accessible

+
+ +

The URL where your ReactPress API server will run

+
+ +
+ +

+ +

+
+ + +
+

🎉 Installation Complete!

+ +
+

ReactPress has been installed successfully!

+
+ +

Your ReactPress server configuration has been saved. You can:

+ +
    +
  • Close this browser window
  • +
  • Restart the server by running npx @fecommunity/reactpress-server in your terminal
  • +
  • Access your admin panel at http://localhost:3001/admin (when client is running)
  • +
+ + +
+

Waiting for server to start...

+
+ +
+ + +
+ +
+ Note: To start the server with your new configuration, run npx @fecommunity/reactpress-server in your terminal. +
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/cli/server/public/logo-200.png b/cli/server/public/logo-200.png new file mode 100644 index 00000000..d6aa9430 Binary files /dev/null and b/cli/server/public/logo-200.png differ diff --git a/cli/server/public/logo-400.png b/cli/server/public/logo-400.png new file mode 100644 index 00000000..76335233 Binary files /dev/null and b/cli/server/public/logo-400.png differ diff --git a/cli/server/public/logo.png b/cli/server/public/logo.png new file mode 100644 index 00000000..f5064b63 Binary files /dev/null and b/cli/server/public/logo.png differ diff --git a/cli/server/public/logo.svg b/cli/server/public/logo.svg new file mode 100644 index 00000000..7d83e681 --- /dev/null +++ b/cli/server/public/logo.svg @@ -0,0 +1,7 @@ + + ReactPress + + + + P + \ No newline at end of file diff --git a/cli/server/public/swagger/custom.css b/cli/server/public/swagger/custom.css new file mode 100644 index 00000000..fee918b2 --- /dev/null +++ b/cli/server/public/swagger/custom.css @@ -0,0 +1,11 @@ +.swagger-ui div.topbar { + display: none !important; + padding: 0 !important; +} + + +.swagger-ui .scheme-container { + display: none !important; + margin: 0 !important; + padding: 0 !important; +} \ No newline at end of file diff --git a/cli/templates/config.default.json b/cli/templates/config.default.json new file mode 100644 index 00000000..71441022 --- /dev/null +++ b/cli/templates/config.default.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "database": { + "mode": "embedded-docker", + "containerName": "reactpress_cli_db" + }, + "server": { + "port": 3002, + "clientUrl": "http://localhost:3001", + "serverUrl": "http://localhost:3002" + } +} diff --git a/cli/templates/docker-compose.prod.yml b/cli/templates/docker-compose.prod.yml new file mode 100644 index 00000000..5dc89def --- /dev/null +++ b/cli/templates/docker-compose.prod.yml @@ -0,0 +1,56 @@ +# ReactPress 3.0 生产部署示例 +# 用法: 复制到项目根目录,按需修改环境变量后 docker compose -f docker-compose.prod.yml up -d + +services: + db: + image: mysql:8.0 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWD:-reactpress} + MYSQL_DATABASE: ${DB_DATABASE:-reactpress} + volumes: + - reactpress_mysql_data:/var/lib/mysql + ports: + - '${DB_PORT:-3306}:3306' + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'] + interval: 10s + timeout: 5s + retries: 5 + + api: + image: node:20-alpine + restart: unless-stopped + working_dir: /app + depends_on: + db: + condition: service_healthy + environment: + NODE_ENV: production + DB_HOST: db + DB_PORT: 3306 + DB_USER: root + DB_PASSWD: ${DB_PASSWD:-reactpress} + DB_DATABASE: ${DB_DATABASE:-reactpress} + SERVER_PORT: 3002 + SERVER_SITE_URL: http://localhost:3002 + SERVER_API_PREFIX: /api + ports: + - '3002:3002' + command: sh -c "npm i -g @fecommunity/reactpress-cli@3 && reactpress-cli start --api-only" + # 生产建议挂载已 init 的项目目录,或使用自定义镜像内置 dist + + client: + image: node:20-alpine + restart: unless-stopped + depends_on: + - api + environment: + CLIENT_SITE_URL: http://localhost:3001 + SERVER_SITE_URL: http://api:3002 + ports: + - '3001:3001' + command: sh -c "npm i -g @fecommunity/reactpress-client@3 && reactpress-client start" + +volumes: + reactpress_mysql_data: diff --git a/cli/templates/docker-compose.yml b/cli/templates/docker-compose.yml new file mode 100644 index 00000000..47554989 --- /dev/null +++ b/cli/templates/docker-compose.yml @@ -0,0 +1,27 @@ +services: + db: + image: mysql:8.0 + container_name: reactpress_cli_db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-reactpress_root} + MYSQL_DATABASE: ${DB_DATABASE:-reactpress} + MYSQL_USER: ${DB_USER:-reactpress} + MYSQL_PASSWORD: ${DB_PASSWD:-reactpress} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-authentication-plugin=mysql_native_password + volumes: + - reactpress_cli_db_data:/var/lib/mysql + ports: + - "${DB_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-reactpress_root}"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 20s + +volumes: + reactpress_cli_db_data: diff --git a/cli/templates/env.default b/cli/templates/env.default new file mode 100644 index 00000000..c38aa038 --- /dev/null +++ b/cli/templates/env.default @@ -0,0 +1,11 @@ +# ReactPress — auto-generated by reactpress-cli (do not edit DB_* unless you know what you are doing) +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=reactpress +DB_PASSWD=reactpress +DB_DATABASE=reactpress + +CLIENT_SITE_URL=http://localhost:3001 +SERVER_SITE_URL=http://localhost:3002 +SERVER_PORT=3002 +SERVER_API_PREFIX=/api diff --git a/cli/templates/package.json b/cli/templates/package.json new file mode 100644 index 00000000..aaf09744 --- /dev/null +++ b/cli/templates/package.json @@ -0,0 +1,10 @@ +{ + "name": "reactpress-site", + "private": true, + "version": "1.0.0", + "description": "ReactPress CMS & Blog site", + "scripts": { + "start": "reactpress-cli start", + "stop": "reactpress-cli stop" + } +} diff --git a/cli/tests/build.test.js b/cli/tests/build.test.js new file mode 100644 index 00000000..5b35247e --- /dev/null +++ b/cli/tests/build.test.js @@ -0,0 +1,32 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveBuildInvocation, TARGETS } = require('../lib/build'); +const { createMonorepoFixture, createStandaloneProject, rmDir } = require('./helpers/tmp-project'); + +describe('lib/build', () => { + it('resolves toolkit build to toolkit/ directory in monorepo', () => { + const root = createMonorepoFixture(); + try { + const inv = resolveBuildInvocation('build:toolkit', root); + assert.ok(inv); + assert.match(inv.cwd, /toolkit$/); + } finally { + rmDir(root); + } + }); + + it('skips client build when client/ is missing', () => { + const root = createStandaloneProject(); + try { + const inv = resolveBuildInvocation('build:client', root); + assert.equal(inv, null); + } finally { + rmDir(root); + } + }); + + it('exposes known targets', () => { + assert.ok(TARGETS.includes('all')); + assert.ok(TARGETS.includes('toolkit')); + }); +}); diff --git a/cli/tests/docker.test.js b/cli/tests/docker.test.js new file mode 100644 index 00000000..06142777 --- /dev/null +++ b/cli/tests/docker.test.js @@ -0,0 +1,28 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const { resolveComposeContext } = require('../lib/docker'); +const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); + +describe('lib/docker', () => { + it('uses .reactpress/docker-compose.yml for standalone projects', () => { + const root = createStandaloneProject(); + try { + const ctx = resolveComposeContext(root); + assert.equal(ctx.type, 'standalone'); + assert.ok(ctx.composeFile.endsWith(path.join('.reactpress', 'docker-compose.yml'))); + } finally { + rmDir(root); + } + }); + + it('uses docker-compose.dev.yml for monorepo when present', () => { + const root = createMonorepoFixture(); + try { + const ctx = resolveComposeContext(root); + assert.equal(ctx.type, 'monorepo'); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/tests/helpers/tmp-project.js b/cli/tests/helpers/tmp-project.js new file mode 100644 index 00000000..5909bccf --- /dev/null +++ b/cli/tests/helpers/tmp-project.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function createStandaloneProject() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-test-')); + const reactpressDir = path.join(root, '.reactpress'); + fs.mkdirSync(reactpressDir, { recursive: true }); + + fs.writeFileSync( + path.join(reactpressDir, 'config.json'), + JSON.stringify( + { + version: 1, + database: { mode: 'embedded-docker', containerName: 'reactpress_cli_db' }, + server: { + port: 3002, + clientUrl: 'http://localhost:3001', + serverUrl: 'http://localhost:3002', + }, + }, + null, + 2 + ) + ); + + fs.writeFileSync( + path.join(root, '.env'), + [ + 'DB_HOST=127.0.0.1', + 'DB_PORT=3307', + 'DB_USER=reactpress', + 'DB_PASSWD=reactpress', + 'DB_DATABASE=reactpress', + 'CLIENT_SITE_URL=http://localhost:3001', + 'SERVER_SITE_URL=http://localhost:3002', + 'SERVER_PORT=3002', + 'SERVER_API_PREFIX=/api', + '', + ].join('\n') + ); + + fs.copyFileSync( + path.join(__dirname, '../../templates/docker-compose.yml'), + path.join(reactpressDir, 'docker-compose.yml') + ); + + return root; +} + +function createMonorepoFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-mono-')); + fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - server\n - client\n'); + fs.mkdirSync(path.join(root, 'server', 'src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'server', 'src', 'main.ts'), 'export {};\n'); + fs.writeFileSync( + path.join(root, 'server', 'package.json'), + JSON.stringify({ name: 'server', scripts: { build: 'echo build' } }) + ); + fs.mkdirSync(path.join(root, 'client')); + fs.writeFileSync( + path.join(root, 'client', 'package.json'), + JSON.stringify({ name: 'client', scripts: { dev: 'echo dev' } }) + ); + fs.mkdirSync(path.join(root, 'toolkit')); + fs.writeFileSync( + path.join(root, 'toolkit', 'package.json'), + JSON.stringify({ name: 'toolkit', scripts: { build: 'echo build' } }) + ); + return root; +} + +function rmDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +module.exports = { createStandaloneProject, createMonorepoFixture, rmDir }; diff --git a/cli/tests/http.test.js b/cli/tests/http.test.js new file mode 100644 index 00000000..8b8d7d04 --- /dev/null +++ b/cli/tests/http.test.js @@ -0,0 +1,23 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + loadServerSiteUrl, + loadClientSiteUrl, + getApiPrefix, + getHealthUrl, +} = require('../lib/http'); +const { createStandaloneProject, rmDir } = require('./helpers/tmp-project'); + +describe('lib/http', () => { + it('reads URLs and health path from .env', () => { + const root = createStandaloneProject(); + try { + assert.equal(loadServerSiteUrl(root), 'http://localhost:3002'); + assert.equal(loadClientSiteUrl(root), 'http://localhost:3001'); + assert.equal(getApiPrefix(root), '/api'); + assert.equal(getHealthUrl(root), 'http://localhost:3002/api/health'); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/tests/i18n.test.js b/cli/tests/i18n.test.js new file mode 100644 index 00000000..d6e511d9 --- /dev/null +++ b/cli/tests/i18n.test.js @@ -0,0 +1,20 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { t, setLocale, getLocale } = require('../lib/i18n'); + +describe('lib/i18n', () => { + it('translates known keys in en and zh', () => { + setLocale('en'); + assert.match(t('cli.description'), /ReactPress/); + setLocale('zh'); + assert.match(t('cli.description'), /ReactPress/); + assert.match(t('menu.dev'), /开发|dev/i); + setLocale('en'); + assert.equal(getLocale(), 'en'); + }); + + it('interpolates variables', () => { + setLocale('en'); + assert.match(t('menu.opening', { url: 'http://x' }), /http:\/\/x/); + }); +}); diff --git a/cli/tests/nginx.test.js b/cli/tests/nginx.test.js new file mode 100644 index 00000000..540b6bb1 --- /dev/null +++ b/cli/tests/nginx.test.js @@ -0,0 +1,59 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { + resolveNginxConfigPath, + resolveNginxComposeContext, + ensureNginxConfig, +} = require('../lib/nginx'); +const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); + +describe('lib/nginx', () => { + it('resolves dev config at repo root for monorepo', () => { + const root = createMonorepoFixture(); + try { + const configPath = resolveNginxConfigPath(root, 'dev'); + assert.equal(configPath, path.join(root, 'nginx.dev.conf')); + } finally { + rmDir(root); + } + }); + + it('resolves dev config under .reactpress for standalone', () => { + const root = createStandaloneProject(); + try { + const configPath = resolveNginxConfigPath(root, 'dev'); + assert.equal(configPath, path.join(root, '.reactpress', 'nginx.dev.conf')); + } finally { + rmDir(root); + } + }); + + it('ensureNginxConfig writes template when missing', () => { + const root = createStandaloneProject(); + try { + const target = path.join(root, '.reactpress', 'nginx.dev.conf'); + if (fs.existsSync(target)) fs.unlinkSync(target); + const { configPath, created } = ensureNginxConfig(root, { mode: 'dev' }); + assert.equal(created, true); + assert.equal(configPath, target); + assert.ok(fs.existsSync(target)); + assert.ok(fs.readFileSync(target, 'utf8').includes('host.docker.internal')); + } finally { + rmDir(root); + } + }); + + it('uses docker-compose.dev.yml for monorepo dev nginx', () => { + const root = createMonorepoFixture(); + try { + fs.writeFileSync(path.join(root, 'docker-compose.dev.yml'), 'services:\n nginx:\n image: nginx\n'); + const ctx = resolveNginxComposeContext(root, 'dev'); + assert.equal(ctx.composeFile, path.join(root, 'docker-compose.dev.yml')); + assert.equal(ctx.service, 'nginx'); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/tests/parity-pack.test.js b/cli/tests/parity-pack.test.js new file mode 100644 index 00000000..9754d0a8 --- /dev/null +++ b/cli/tests/parity-pack.test.js @@ -0,0 +1,43 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const CLI_ROOT = path.join(__dirname, '..'); + +/** Files that must ship in the npm tarball and exist locally (publish/local parity). */ +const REQUIRED_SHIPPED = [ + 'package.json', + 'bin/reactpress.js', + 'bin/reactpress-cli-shim.js', + 'lib/root.js', + 'lib/publish.js', + 'lib/project-type.js', + 'ui/interactive.js', + 'ui/banner.js', + 'ui/theme.js', + 'dist/index.js', + 'templates/env.default', + 'templates/config.default.json', +]; + +describe('publish/local file parity', () => { + it('critical runtime files exist on disk', () => { + for (const required of REQUIRED_SHIPPED) { + assert.ok(fs.existsSync(path.join(CLI_ROOT, required)), `missing locally: ${required}`); + } + }); + + it('package.json files[] lists top-level dirs for all shipped assets', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(CLI_ROOT, 'package.json'), 'utf8')); + const declared = new Set(pkg.files || []); + for (const required of REQUIRED_SHIPPED) { + if (required === 'package.json') continue; + const top = required.split('/')[0]; + assert.ok( + declared.has(top) || declared.has(required), + `package.json files[] missing "${top}" (needed for ${required})` + ); + } + }); +}); diff --git a/cli/tests/project-type.test.js b/cli/tests/project-type.test.js new file mode 100644 index 00000000..d4982ad9 --- /dev/null +++ b/cli/tests/project-type.test.js @@ -0,0 +1,32 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + detectProjectType, + describeProject, + hasClient, +} = require('../lib/project-type'); +const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); + +describe('lib/project-type', () => { + it('detects standalone projects', () => { + const root = createStandaloneProject(); + try { + assert.equal(detectProjectType(root), 'standalone'); + assert.equal(hasClient(root), false); + } finally { + rmDir(root); + } + }); + + it('detects monorepo checkouts', () => { + const root = createMonorepoFixture(); + try { + assert.equal(detectProjectType(root), 'monorepo'); + const info = describeProject(root); + assert.equal(info.hasClient, true); + assert.equal(info.hasServerSource, true); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/tests/publish-version.test.js b/cli/tests/publish-version.test.js new file mode 100644 index 00000000..e1051a9d --- /dev/null +++ b/cli/tests/publish-version.test.js @@ -0,0 +1,42 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// incrementVersion is not exported — mirror logic for regression tests +function incrementVersion(version, type) { + const base = String(version).split('-')[0]; + const parts = base.split('.').map((p) => parseInt(p, 10)); + while (parts.length < 3) parts.push(0); + const major = Number.isFinite(parts[0]) ? parts[0] : 0; + const minor = Number.isFinite(parts[1]) ? parts[1] : 0; + const patch = Number.isFinite(parts[2]) ? parts[2] : 0; + + switch (type) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + case 'beta': { + const match = version.match(/^(.*)-beta\.(\d+)$/); + if (match) return `${match[1]}-beta.${parseInt(match[2], 10) + 1}`; + return `${version}-beta.1`; + } + default: + return version; + } +} + +describe('publish version bump', () => { + it('bumps patch', () => { + assert.equal(incrementVersion('3.0.3', 'patch'), '3.0.4'); + }); + + it('handles two-segment versions', () => { + assert.equal(incrementVersion('3.0', 'patch'), '3.0.1'); + }); + + it('bumps beta', () => { + assert.equal(incrementVersion('3.0.0-beta.1', 'beta'), '3.0.0-beta.2'); + }); +}); diff --git a/cli/tests/root.test.js b/cli/tests/root.test.js new file mode 100644 index 00000000..7b9dc672 --- /dev/null +++ b/cli/tests/root.test.js @@ -0,0 +1,45 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const { + findProjectRoot, + isProjectRoot, + isPublishedCliRoot, + getMonorepoRoot, +} = require('../lib/root'); +const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); + +describe('lib/root', () => { + it('findProjectRoot discovers standalone .reactpress project', () => { + const root = createStandaloneProject(); + try { + assert.equal(findProjectRoot(root), root); + assert.equal(isProjectRoot(root), true); + } finally { + rmDir(root); + } + }); + + it('isPublishedCliRoot is false for user projects', () => { + const root = createStandaloneProject(); + try { + assert.equal(isPublishedCliRoot(root), false); + } finally { + rmDir(root); + } + }); + + it('getMonorepoRoot resolves to repo root in workspace', () => { + const mono = getMonorepoRoot(); + assert.ok(path.basename(mono) !== 'lib'); + }); + + it('findProjectRoot discovers monorepo via pnpm-workspace.yaml', () => { + const root = createMonorepoFixture(); + try { + assert.equal(findProjectRoot(root), root); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/ui/banner.js b/cli/ui/banner.js new file mode 100644 index 00000000..c4fa9f77 --- /dev/null +++ b/cli/ui/banner.js @@ -0,0 +1,441 @@ +const os = require('os'); +const path = require('path'); +const chalk = require('chalk'); +const { + brand, + icon, + palette, + visibleLength, + padRight, + terminalWidth, + gradientText, + pulseBar, + statusLights, +} = require('./theme'); +const { t } = require('../lib/i18n'); + +/** + * "REACTPRESS" rendered in the ANSI Shadow font. + * Each row is exactly 81 single-cell columns, so we can size the surrounding + * cyber-card deterministically without measuring per-glyph widths. + */ +const TECH_LOGO = [ + '██████╗ ███████╗ █████╗ ██████╗████████╗██████╗ ██████╗ ███████╗███████╗███████╗', + '██╔══██╗██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝', + '██████╔╝█████╗ ███████║██║ ██║ ██████╔╝██████╔╝█████╗ ███████╗███████╗', + '██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██╔═══╝ ██╔══██╗██╔══╝ ╚════██║╚════██║', + '██║ ██║███████╗██║ ██║╚██████╗ ██║ ██║ ██║ ██║███████╗███████║███████║', + '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝', +]; + +const LOGO_WIDTH = 81; +const LOGO_GRADIENTS = [ + [palette.pink, palette.primary], + [palette.pink, palette.primary], + [palette.primary, palette.accent], + [palette.primary, palette.accent], + [palette.accent, palette.primary], + [palette.accent, palette.primary], +]; + +const REPO_URL = 'https://github.com/fecommunity/reactpress'; +/** + * Shorter, human-friendly form of REPO_URL shown beneath the title bar. + * The clickable hyperlink still resolves to the full https:// URL via + * `hyperlink()`, so users can `cmd+click` from any modern terminal. + */ +const REPO_DISPLAY = 'github.com/fecommunity/reactpress'; + +/** + * Wrap text in an OSC-8 hyperlink escape so terminals that support it (iTerm2, + * Warp, WezTerm, modern macOS Terminal, VS Code, GNOME Terminal, Kitty, …) + * render the label as a clickable link. We only emit the escape sequence when + * stdout is a real TTY — otherwise (CI logs, file redirects, dumb terminals) + * we fall back to the plain styled label so users never see the raw `]8;;`. + */ +function hyperlink(url, label) { + if (!process.stdout.isTTY) return label; + if (process.env.TERM === 'dumb') return label; + return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`; +} + +function safeReadCliVersion() { + try { + return require(path.join(__dirname, '..', 'package.json')).version; + } catch { + return 'dev'; + } +} + +function homify(p) { + if (!p) return p; + const home = os.homedir(); + if (home && p.startsWith(home)) { + return '~' + p.slice(home.length); + } + return p; +} + +function renderLogoLines() { + return TECH_LOGO.map((line, i) => gradientText(line, LOGO_GRADIENTS[i])); +} + +function modeChip(type) { + if (type === 'monorepo') { + return chalk + .bgHex(palette.primary) + .hex('#0B1220') + .bold(` ${t('banner.mode.monorepo')} `); + } + if (type === 'standalone') { + return chalk + .bgHex(palette.accent) + .hex('#0B1220') + .bold(` ${t('banner.mode.standalone')} `); + } + return chalk + .bgHex(palette.gray) + .hex('#0B1220') + .bold(` ${t('banner.mode.uninitialized')} `); +} + +/** + * Decide how "ready" the welcome banner should look. When a fully + * initialized project is detected we render the pulse bar at 100% and + * report `ONLINE` status, instead of the static 70% placeholder that used + * to make `doctor` runs look incomplete even when everything passed. + */ +function bannerReadyState(options) { + const type = options && options.project && options.project.type; + if (type === 'monorepo' || type === 'standalone') { + return { ratio: 1, ready: true }; + } + return { ratio: 0.4, ready: false }; +} + +/** + * Build the top edge of the cyber-card with a centered title block: + * ╔══════════[ REACTPRESS · v3.0.3 ]══════════╗ + */ +function brandedTopBorder(version, width) { + const titleBlock = + brand.primary('[') + + ' ' + + gradientText('REACTPRESS', [palette.primary, palette.accent], { bold: true }) + + ' ' + + brand.muted('·') + + ' ' + + brand.accent(`v${version}`) + + ' ' + + brand.primary(']'); + const dashTotal = Math.max(0, width - 2 - visibleLength(titleBlock)); + const left = Math.floor(dashTotal / 2); + const right = dashTotal - left; + return ( + brand.primary('╔' + '═'.repeat(left)) + + titleBlock + + brand.primary('═'.repeat(right) + '╗') + ); +} + +function bottomBorder(width) { + return brand.primary('╚' + '═'.repeat(width - 2) + '╝'); +} + +function bodyLine(content, innerWidth) { + const padded = padRight(content, innerWidth); + return brand.primary('║ ') + padded + brand.primary(' ║'); +} + +function emptyBodyLine(innerWidth) { + return bodyLine('', innerWidth); +} + +/** + * A subtle "CRT scan-line" rendered just under the logo. + * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + */ +function scanline(width) { + return brand.muted('▔'.repeat(width)); +} + +/** + * Width of the left-side banner label column. + * + * Sized to fit our longest English label (`MODE` / `PATH` → 4 cells) + * plus a 2-cell trailing gap, which also accommodates the Chinese + * translations `模式` / `路径` (4 East-Asian cells each). + */ +const LABEL_WIDTH = 6; + +/** + * Centered, dim repo subtitle that sits directly under the top border. + * Replaces the previous in-body `◇ REPO ↗ …` row, which competed visually + * with the operational fields (MODE / PATH / pulse) further down. + */ +function repoSubline(innerWidth) { + const link = + brand.muted('↗ ') + hyperlink(REPO_URL, brand.accent.underline(REPO_DISPLAY)); + const pad = Math.max(0, Math.floor((innerWidth - visibleLength(link)) / 2)); + return ' '.repeat(pad) + link; +} + +/** + * Single-cell-wide chip label, e.g. `◇ MODE ▸ monorepo`. + */ +function infoRow(label, value) { + return ( + brand.accent('◇ ') + + brand.muted(padRight(label, LABEL_WIDTH)) + + ' ' + + brand.primary('▸ ') + + brand.dim(value) + ); +} + +/** + * Render the "command rail" navigation footer: + * ⟫ init ⟫ dev ⟫ build ⟫ deploy ⟫ publish + */ +function commandRail() { + const items = ['init', 'dev', 'build', 'deploy', 'publish']; + return items + .map( + (name) => + brand.primary('⟫ ') + gradientText(name, [palette.primary, palette.accent]) + ) + .join(brand.muted(' ')); +} + +/** + * Wide, full-fat cyber banner: ASCII logo + scan-line + bordered card. + */ +function printWideBanner(version, options) { + const cols = terminalWidth(); + const cardWidth = Math.min(Math.max(LOGO_WIDTH + 8, 88), cols - 2); + const innerWidth = cardWidth - 4; + + const lines = []; + lines.push(''); + lines.push(' ' + brandedTopBorder(version, cardWidth)); + lines.push(' ' + bodyLine(repoSubline(innerWidth), innerWidth)); + lines.push(' ' + emptyBodyLine(innerWidth)); + + const logoIndent = Math.max(0, Math.floor((innerWidth - LOGO_WIDTH) / 2)); + const indent = ' '.repeat(logoIndent); + for (const logoLine of renderLogoLines()) { + lines.push(' ' + bodyLine(indent + logoLine, innerWidth)); + } + + const scanWidth = Math.min(innerWidth - 2, LOGO_WIDTH); + const scanIndent = ' '.repeat(Math.max(0, Math.floor((innerWidth - scanWidth) / 2))); + lines.push(' ' + bodyLine(scanIndent + scanline(scanWidth), innerWidth)); + + lines.push(' ' + emptyBodyLine(innerWidth)); + + const ready = bannerReadyState(options); + const subtitle = + chalk.bold(brand.accent('◆ ')) + + gradientText(t('banner.subtitle').trim(), [palette.accent, palette.primary, palette.pink], { + bold: true, + }); + const stateLabel = ready.ready + ? brand.success(t('banner.systemOnline').trim()) + : brand.warn(t('banner.systemPending').trim()); + const right = + statusLights(ready.ready ? 'online' : 'pending') + + ' ' + + brand.dim(t('banner.systemLabel').trim() + ' ') + + stateLabel; + lines.push(' ' + bodyLine(subtitle + spacer(subtitle, right, innerWidth) + right, innerWidth)); + + lines.push(' ' + emptyBodyLine(innerWidth)); + + if (options.project) { + lines.push( + ' ' + + bodyLine( + brand.accent('◇ ') + + brand.muted(padRight(t('banner.label.mode').trim(), LABEL_WIDTH)) + + ' ' + + modeChip(options.project.type), + innerWidth + ) + ); + } + if (options.projectRoot) { + lines.push( + ' ' + + bodyLine( + infoRow(t('banner.label.path').trim(), homify(options.projectRoot)), + innerWidth + ) + ); + } + + const pulseWidth = Math.min(28, innerWidth - 18); + if (pulseWidth > 8) { + const filled = Math.max(1, Math.min(pulseWidth, Math.round(pulseWidth * ready.ratio))); + const pulse = pulseBar(pulseWidth, filled); + const pulseStatus = ready.ready + ? t('banner.pulseReady').trim() + : t('banner.pulsePending').trim(); + const pulseLine = + brand.accent('◇ ') + + brand.muted(padRight(t('banner.pulseLabel').trim(), LABEL_WIDTH)) + + ' ' + + pulse + + ' ' + + (ready.ready ? brand.success(pulseStatus) : brand.warn(pulseStatus)); + lines.push(' ' + bodyLine(pulseLine, innerWidth)); + } + + lines.push(' ' + emptyBodyLine(innerWidth)); + lines.push(' ' + bottomBorder(cardWidth)); + lines.push(' ' + commandRail()); + lines.push(''); + + for (const line of lines) console.log(line); +} + +/** + * Pad between a left-aligned and a right-aligned segment so they sit on the + * same line of the cyber card. + */ +function spacer(left, right, innerWidth) { + const used = visibleLength(left) + visibleLength(right); + const gap = Math.max(2, innerWidth - used); + return ' '.repeat(gap); +} + +/** + * Compact cyber banner for terminals that cannot host the full ASCII logo. + */ +function printCompactBanner(version, options) { + const cols = terminalWidth(); + const cardWidth = Math.min(cols - 2, 76); + const innerWidth = cardWidth - 4; + + const lines = []; + lines.push(''); + lines.push(' ' + brandedTopBorder(version, cardWidth)); + lines.push(' ' + bodyLine(repoSubline(innerWidth), innerWidth)); + lines.push(' ' + emptyBodyLine(innerWidth)); + + const ready = bannerReadyState(options); + const wordmark = + brand.primary('▌▍▎ ') + + gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], { + bold: true, + }) + + brand.primary(' ▎▍▌'); + const lights = statusLights(ready.ready ? 'online' : 'pending'); + lines.push( + ' ' + bodyLine(wordmark + spacer(wordmark, lights, innerWidth) + lights, innerWidth) + ); + + const subtitle = + chalk.bold(brand.accent('◆ ')) + brand.dim(t('banner.subtitle').trim()); + lines.push(' ' + bodyLine(subtitle, innerWidth)); + lines.push(' ' + emptyBodyLine(innerWidth)); + + if (options.project) { + lines.push( + ' ' + + bodyLine( + brand.accent('◇ ') + + brand.muted(padRight(t('banner.label.mode').trim(), LABEL_WIDTH)) + + ' ' + + modeChip(options.project.type), + innerWidth + ) + ); + } + if (options.projectRoot) { + lines.push( + ' ' + + bodyLine( + infoRow(t('banner.label.path').trim(), homify(options.projectRoot)), + innerWidth + ) + ); + } + + lines.push(' ' + emptyBodyLine(innerWidth)); + lines.push(' ' + bottomBorder(cardWidth)); + lines.push(' ' + commandRail()); + lines.push(''); + + for (const line of lines) console.log(line); +} + +/** + * Single-line banner for ultra-narrow terminals (CI logs, embedded shells). + */ +function printMinimalBanner(version, options) { + const ready = bannerReadyState(options); + const wordmark = gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], { + bold: true, + }); + console.log(''); + console.log(` ${brand.primary('▌▍▎')} ${wordmark} ${brand.muted('·')} ${brand.accent(`v${version}`)} ${statusLights(ready.ready ? 'online' : 'pending')}`); + console.log(` ${brand.dim(t('banner.subtitle').trim())}`); + if (options.project) { + console.log(` ${modeChip(options.project.type)}`); + } + if (options.projectRoot) { + console.log(` ${icon.bullet} ${brand.dim(homify(options.projectRoot))}`); + } + console.log( + ` ${brand.muted('↗')} ${hyperlink(REPO_URL, brand.accent.underline(REPO_URL))}` + ); + console.log(''); +} + +/** + * Print the top-of-screen banner. Adaptive to terminal width: collapses to a + * single-line greeting on very narrow terminals, otherwise renders a bordered + * cyber-card with the full ANSI Shadow logo when there is room. + * + * @param {{ + * projectRoot?: string, + * project?: { type: string, hasClient: boolean, hasServerSource: boolean } + * }} [options] + */ +function printBanner(options = {}) { + const version = safeReadCliVersion(); + const cols = terminalWidth(); + + if (cols < 64) { + printMinimalBanner(version, options); + return; + } + + if (cols < LOGO_WIDTH + 10) { + printCompactBanner(version, options); + return; + } + + printWideBanner(version, options); +} + +/** + * Box helper retained for backwards compatibility: a few callers still + * import `box` from this module to wrap arbitrary multi-line content. + */ +function box(lines, { width } = {}) { + const innerWidth = width + ? width - 4 + : lines.reduce((max, line) => Math.max(max, visibleLength(line)), 0); + + const horizontal = '═'.repeat(innerWidth + 2); + const top = brand.primary(` ╔${horizontal}╗`); + const bottom = brand.primary(` ╚${horizontal}╝`); + const body = lines.map((line) => { + const padded = padRight(line, innerWidth); + return brand.primary(' ║ ') + padded + brand.primary(' ║'); + }); + return [top, ...body, bottom]; +} + +module.exports = { printBanner, visibleLength, padRight, box }; diff --git a/cli/ui/interactive.js b/cli/ui/interactive.js new file mode 100644 index 00000000..282a2b08 --- /dev/null +++ b/cli/ui/interactive.js @@ -0,0 +1,380 @@ +const fs = require('fs'); +const path = require('path'); +const inquirer = require('inquirer'); +const ora = require('ora'); +const open = require('open'); +const { printBanner } = require('./banner'); +const { + brand, + icon, + label, + ok, + fail, + sectionHeader, + statusPill, + padRight, +} = require('./theme'); +const { ensureOriginalCwd } = require('../lib/root'); +const { describeProject, hasClient } = require('../lib/project-type'); +const { ensureProjectEnvironment } = require('../lib/bootstrap'); +const { runDev } = require('../lib/dev'); +const { runApiDev } = require('../lib/api-dev'); +const { runLifecycleCommand } = require('../lib/lifecycle'); +const { runDockerCommand } = require('../lib/docker'); +const { runNginxCommand } = require('../lib/nginx'); +const { printUnifiedStatus } = require('../lib/status'); +const { runDoctor } = require('../lib/doctor'); +const { runBuild, TARGETS } = require('../lib/build'); +const { runNodeScript } = require('../lib/spawn'); +const { getClientBin } = require('../lib/paths'); +const { loadClientSiteUrl, loadServerSiteUrl, isHttpResponding } = require('../lib/http'); +const { isDockerRunning } = require('../lib/docker'); +const { t } = require('../lib/i18n'); + +function menuSection(title) { + return new inquirer.Separator(sectionHeader(title)); +} + +function formatChoice(key, text, hint) { + const keyCol = key ? brand.primary(padRight(key, 2)) : ' '; + const hintPart = hint ? brand.dim(` ${hint}`) : ''; + return `${keyCol} ${text}${hintPart}`; +} + +function assignShortcuts(items) { + let n = 0; + return items.map((item) => { + if (item instanceof inquirer.Separator || item.type === 'separator') { + return item; + } + n += 1; + const key = n <= 9 ? String(n) : ''; + return { + ...item, + name: formatChoice(key, item._label || item.name, item._hint), + short: item.value, + }; + }); +} + +function choice(labelKey, value, hintKey) { + return { + _label: t(labelKey), + _hint: hintKey ? t(hintKey) : '', + value, + }; +} + +function getMenuActions(project) { + const standalone = project.type === 'standalone'; + const monorepo = project.type === 'monorepo'; + const showClient = hasClient(project.root); + + const items = [ + menuSection(t('menu.section.run')), + choice('menu.dev', 'dev', 'menu.hint.dev'), + choice('menu.init', 'init', 'menu.hint.init'), + choice('menu.status', 'status', 'menu.hint.status'), + choice('menu.doctor', 'doctor', 'menu.hint.doctor'), + menuSection(t('menu.section.lifecycle')), + choice('menu.devApi', 'dev:api', 'menu.hint.devApi'), + ]; + + if (showClient) { + items.push(choice('menu.devClient', 'dev:client', 'menu.hint.devClient')); + } + + items.push( + choice('menu.serverStart', 'server:start', 'menu.hint.serverStart'), + choice('menu.serverStop', 'server:stop', 'menu.hint.serverStop'), + choice('menu.serverRestart', 'server:restart', 'menu.hint.serverRestart'), + menuSection(t('menu.section.build')), + choice('menu.build', 'build', 'menu.hint.build') + ); + + if (monorepo) { + items.push( + choice('menu.dockerStart', 'docker:start', 'menu.hint.dockerStart'), + choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'), + choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop') + ); + } else if (standalone) { + items.push( + choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'), + choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop') + ); + } + + items.push( + menuSection(t('menu.section.tools')), + choice('menu.nginxUp', 'nginx:up', 'menu.hint.nginxUp'), + choice('menu.nginxOpen', 'nginx:open', 'menu.hint.nginxOpen'), + choice('menu.nginxReload', 'nginx:reload', 'menu.hint.nginxReload'), + choice('menu.openAdmin', 'open:admin', 'menu.hint.openAdmin') + ); + + if (monorepo) { + items.push(choice('menu.publish', 'publish', 'menu.hint.publish')); + } + + items.push( + new inquirer.Separator(), + choice('menu.exit', 'exit', 'menu.hint.exit') + ); + + return assignShortcuts(items); +} + +function parseEnvFile(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + const env = {}; + try { + if (!fs.existsSync(envPath)) return env; + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^([A-Z_]+)=(.*)$/); + if (m) env[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return env; +} + +async function probeDatabase(projectRoot) { + try { + const mysql = require('mysql2/promise'); + const env = parseEnvFile(projectRoot); + const conn = await mysql.createConnection({ + host: env.DB_HOST || '127.0.0.1', + port: Number(env.DB_PORT || 3306), + user: env.DB_USER || 'reactpress', + password: env.DB_PASSWD || env.DB_PASSWORD || 'reactpress', + database: env.DB_DATABASE || 'reactpress', + connectTimeout: 2000, + }); + await conn.ping(); + await conn.end(); + return true; + } catch { + return false; + } +} + +async function fetchContextStatus(projectRoot) { + const apiUrl = loadServerSiteUrl(projectRoot); + const [apiOk, dockerOk, dbOk] = await Promise.all([ + isHttpResponding(apiUrl, 1500), + Promise.resolve(isDockerRunning()), + probeDatabase(projectRoot), + ]); + return { apiOk, dbOk, dockerOk }; +} + +function printStatusPanel(status) { + const on = { on: t('menu.statusOn'), off: t('menu.statusOff') }; + const db = { + on: t('menu.statusReady'), + off: t('menu.statusNotReady'), + pending: t('menu.statusChecking'), + }; + const docker = { on: t('menu.statusYes'), off: t('menu.statusNo') }; + + console.log(sectionHeader(t('menu.statusHeader'))); + const rows = [ + [t('menu.statusLabelApi'), statusPill(status.apiOk, on)], + [t('menu.statusLabelDb'), statusPill(status.dbOk, db)], + [t('menu.statusLabelDocker'), statusPill(status.dockerOk, docker)], + ]; + for (const [name, pill] of rows) { + console.log(` ${brand.muted(padRight(name, 10))} ${pill}`); + } + console.log(''); +} + +async function printContextStatus(projectRoot) { + const spinner = ora({ + text: brand.dim(t('menu.statusChecking')), + color: 'magenta', + spinner: 'dots', + }).start(); + const status = await fetchContextStatus(projectRoot); + spinner.stop(); + printStatusPanel(status); + return status; +} + +async function withSpinner(text, fn) { + const spinner = ora({ text, color: 'magenta', spinner: 'dots' }).start(); + try { + const result = await fn(); + spinner.succeed(); + return result; + } catch (err) { + spinner.fail(); + throw err; + } +} + +async function runMenuAction(action, projectRoot, project) { + switch (action) { + case 'dev': + console.log(label(t('menu.startingDev'))); + await runDev(projectRoot); + return false; + case 'init': { + const result = await withSpinner(t('menu.initProject'), () => + ensureProjectEnvironment(projectRoot) + ); + console.log(ok(result.message || t('menu.done'))); + return true; + } + case 'status': + await printUnifiedStatus(projectRoot); + return true; + case 'doctor': { + const code = await runDoctor(projectRoot); + if (code !== 0) process.exit(code); + return true; + } + case 'dev:api': + await runApiDev(projectRoot); + return false; + case 'dev:client': + await runNodeScript(getClientBin(), [], { cwd: projectRoot }); + return false; + case 'server:start': { + const code = await withSpinner(t('menu.startingApi'), () => + runLifecycleCommand('start', projectRoot) + ); + if (code !== 0) process.exit(code); + return true; + } + case 'server:stop': + await withSpinner(t('menu.stoppingApi'), async () => { + await runLifecycleCommand('stop', projectRoot); + }); + return true; + case 'server:restart': { + const code = await withSpinner(t('menu.restartingApi'), () => + runLifecycleCommand('restart', projectRoot) + ); + if (code !== 0) process.exit(code); + return true; + } + case 'build': { + const buildChoices = TARGETS.map((target) => ({ + name: + target === 'all' + ? t('menu.buildAll') + : t(`build.label.${target}`), + value: target, + })); + const { target } = await inquirer.prompt([ + { + type: 'list', + name: 'target', + message: t('menu.buildTarget'), + pageSize: 12, + choices: buildChoices, + }, + ]); + await runBuild(target, projectRoot); + return true; + } + case 'docker:start': + await runDockerCommand('start', projectRoot); + return false; + case 'docker:up': + await withSpinner(t('docker.starting'), () => runDockerCommand('up', projectRoot)); + return true; + case 'docker:stop': + await withSpinner(t('docker.stopping'), async () => { + await runDockerCommand('down', projectRoot); + }); + return true; + case 'nginx:up': + await withSpinner(t('cli.nginx.up'), async () => { + await runNginxCommand('up', projectRoot); + }); + return true; + case 'nginx:open': + await runNginxCommand('open', projectRoot); + return true; + case 'nginx:reload': + await runNginxCommand('reload', projectRoot); + return true; + case 'open:admin': { + const url = loadClientSiteUrl(projectRoot); + console.log(label(t('menu.opening', { url }))); + await open(url); + return true; + } + case 'publish': { + const prev = process.argv.slice(); + process.argv = [process.argv[0], process.argv[1], '--publish']; + await require('../lib/publish').main(); + process.argv = prev; + return true; + } + case 'exit': + return false; + default: + return true; + } +} + +async function runInteractiveLoop() { + const projectRoot = ensureOriginalCwd(); + const project = describeProject(projectRoot); + + printBanner({ projectRoot, project }); + await printContextStatus(projectRoot); + console.log(` ${brand.dim(t('menu.shortcuts'))}`); + console.log(''); + + let loop = true; + while (loop) { + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: `${brand.primary(t('menu.actionPrefix'))} ${brand.dim('›')}`, + pageSize: 20, + loop: false, + choices: getMenuActions(project), + }, + ]); + + if (action === 'exit') { + console.log(brand.muted(t('menu.goodbye'))); + break; + } + + try { + const stay = await runMenuAction(action, projectRoot, project); + if (!stay) break; + + if (action !== 'status' && action !== 'doctor') { + console.log(''); + await printContextStatus(projectRoot); + } + } catch (err) { + console.error(fail(err.message || err)); + const { retry } = await inquirer.prompt([ + { + type: 'confirm', + name: 'retry', + message: t('menu.retry'), + default: true, + }, + ]); + loop = retry; + if (loop) { + console.log(''); + await printContextStatus(projectRoot); + } + } + } +} + +module.exports = { runInteractiveLoop, runMenuAction, getMenuActions }; diff --git a/cli/ui/theme.js b/cli/ui/theme.js new file mode 100644 index 00000000..7b06f7ab --- /dev/null +++ b/cli/ui/theme.js @@ -0,0 +1,265 @@ +const chalk = require('chalk'); + +/** + * ReactPress CLI visual identity — a single source of truth so banners, + * menus, status, doctor, build output all share the same colours and glyphs. + */ +const palette = { + primary: '#7C5CFF', + accent: '#22D3EE', + pink: '#F472B6', + green: '#22C55E', + amber: '#F59E0B', + red: '#EF4444', + gray: '#6B7280', + dim: '#9CA3AF', +}; + +const brand = { + primary: chalk.hex(palette.primary), + accent: chalk.hex(palette.accent), + pink: chalk.hex(palette.pink), + success: chalk.hex(palette.green), + warn: chalk.hex(palette.amber), + error: chalk.hex(palette.red), + muted: chalk.hex(palette.gray), + dim: chalk.hex(palette.dim), + bold: chalk.bold, +}; + +const icon = { + ok: brand.success('✓'), + fail: brand.error('✗'), + warn: brand.warn('⚠'), + info: brand.accent('ℹ'), + arrow: brand.primary('›'), + pointer: brand.primary('▸'), + bullet: brand.muted('·'), + dotOn: brand.success('●'), + dotOff: brand.muted('○'), + dotPending: brand.warn('◐'), + dotInfo: brand.accent('●'), + spark: brand.primary('✱'), + link: brand.muted('↗'), +}; + +/** + * Whether a Unicode code point should occupy two terminal cells. + * + * Covers the common "East Asian Wide / Full-width" ranges that show up in + * Chinese / Japanese / Korean text plus full-width punctuation. We + * deliberately do not pull in a heavy dependency like `string-width` to keep + * the CLI's startup cheap. + */ +function isWideCodePoint(cp) { + return ( + (cp >= 0x1100 && cp <= 0x115f) || + (cp >= 0x2e80 && cp <= 0x303e) || + (cp >= 0x3041 && cp <= 0x33ff) || + (cp >= 0x3400 && cp <= 0x4dbf) || + (cp >= 0x4e00 && cp <= 0x9fff) || + (cp >= 0xa000 && cp <= 0xa4cf) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe4f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1f300 && cp <= 0x1f64f) || + (cp >= 0x1f900 && cp <= 0x1f9ff) || + (cp >= 0x20000 && cp <= 0x2fffd) || + (cp >= 0x30000 && cp <= 0x3fffd) + ); +} + +/** + * Visible terminal-cell width of a string, after stripping ANSI colour codes + * and accounting for East Asian wide characters (which occupy 2 cells). + */ +function visibleLength(text) { + const stripped = String(text) + .replace(/\u001b\[[0-9;]*m/g, '') + .replace(/\u001b\]8;[^\u0007\u001b]*(?:\u0007|\u001b\\)/g, ''); + let width = 0; + for (const ch of stripped) { + const cp = ch.codePointAt(0); + if (cp === undefined) continue; + if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue; + width += isWideCodePoint(cp) ? 2 : 1; + } + return width; +} + +function padRight(text, width) { + const len = visibleLength(text); + if (len >= width) return text; + return text + ' '.repeat(width - len); +} + +function padLeft(text, width) { + const len = visibleLength(text); + if (len >= width) return text; + return ' '.repeat(width - len) + text; +} + +function terminalWidth(fallback = 80) { + const cols = Number(process.stdout.columns) || fallback; + return Math.max(48, Math.min(120, cols)); +} + +function divider(width = 44, char = '─', colorize = brand.muted) { + return colorize(char.repeat(width)); +} + +/** + * Cyberpunk-flavoured progress-bar style decoration. + * `filled` segments use the primary colour, the trailing track stays muted. + */ +function pulseBar(width = 24, filled = Math.ceil(width * 0.7)) { + const f = Math.max(0, Math.min(width, filled)); + const head = brand.primary('▰'.repeat(f)); + const tail = brand.muted('▱'.repeat(Math.max(0, width - f))); + return `${head}${tail}`; +} + +/** + * Three-light status indicator used in the top-right of the banner. + * Mimics the running-light cluster you'd see on a server rack. + */ +function statusLights(state = 'online') { + if (state === 'offline') { + return `${brand.muted('●')} ${brand.muted('●')} ${brand.muted('●')}`; + } + if (state === 'pending') { + return `${brand.warn('●')} ${brand.warn('●')} ${brand.muted('○')}`; + } + return `${brand.success('●')} ${brand.warn('●')} ${brand.muted('○')}`; +} + +function hex2rgb(h) { + const s = h.replace('#', ''); + return { + r: parseInt(s.substring(0, 2), 16), + g: parseInt(s.substring(2, 4), 16), + b: parseInt(s.substring(4, 6), 16), + }; +} + +function rgb2hex(r, g, b) { + const pad = (n) => n.toString(16).padStart(2, '0'); + return `#${pad(r)}${pad(g)}${pad(b)}`; +} + +function mixHex(a, b, t) { + const pa = hex2rgb(a); + const pb = hex2rgb(b); + const r = Math.round(pa.r + (pb.r - pa.r) * t); + const g = Math.round(pa.g + (pb.g - pa.g) * t); + const bl = Math.round(pa.b + (pb.b - pa.b) * t); + return rgb2hex(r, g, bl); +} + +/** + * Paint a string with a left→right linear gradient across `colors` (hex). + * Falls back to plain text when stdout does not support truecolor. + */ +function gradientText(text, colors = [palette.primary, palette.accent], { bold = false } = {}) { + if (!text) return ''; + const supports = chalk.supportsColor && chalk.supportsColor.has16m; + if (!supports || colors.length < 2) { + const c = chalk.hex(colors[0] || palette.primary); + return bold ? c.bold(text) : c(text); + } + const chars = [...String(text)]; + const n = Math.max(chars.length - 1, 1); + return chars + .map((ch, i) => { + const ratio = i / n; + const idx = ratio * (colors.length - 1); + const lo = Math.floor(idx); + const hi = Math.min(colors.length - 1, lo + 1); + const local = idx - lo; + const c = chalk.hex(mixHex(colors[lo], colors[hi], local)); + return bold ? c.bold(ch) : c(ch); + }) + .join(''); +} + +function label(text) { + return `${icon.arrow} ${brand.primary(text)}`; +} + +function ok(text) { + return `${icon.ok} ${brand.success(text)}`; +} + +function fail(text) { + return `${icon.fail} ${brand.error(text)}`; +} + +function warn(text) { + return `${icon.warn} ${brand.warn(text)}`; +} + +function info(text) { + return `${icon.info} ${brand.accent(text)}`; +} + +function chip(text, color = brand.primary) { + return color(`[ ${text} ]`); +} + +function kv(key, value, { keyWidth = 10, valueColor = (s) => s } = {}) { + return `${brand.muted(padRight(key, keyWidth))} ${valueColor(value)}`; +} + +/** + * Render a 3-state status pill, e.g. `● online` / `○ offline` / `◐ pending`. + * + * @param {boolean | 'pending'} state + * @param {{ on?: string, off?: string, pending?: string }} labels + */ +function statusPill(state, labels = {}) { + if (state === 'pending') { + return `${icon.dotPending} ${brand.warn(labels.pending || 'pending')}`; + } + if (state === true) { + return `${icon.dotOn} ${brand.success(labels.on || 'online')}`; + } + return `${icon.dotOff} ${brand.dim(labels.off || 'offline')}`; +} + +/** + * Render a single-line section header: ` ── Title ────────────`. + */ +function sectionHeader(title, { width } = {}) { + const w = width ?? terminalWidth(); + const prefix = brand.muted('── '); + const t = brand.bold(brand.primary(title)); + const usedLen = visibleLength(prefix) + visibleLength(t) + 2; + const fillLen = Math.max(3, w - usedLen - 2); + const fill = brand.muted('─'.repeat(fillLen)); + return ` ${prefix}${t} ${fill}`; +} + +module.exports = { + palette, + brand, + icon, + label, + ok, + fail, + warn, + info, + chip, + kv, + statusPill, + sectionHeader, + visibleLength, + padRight, + padLeft, + terminalWidth, + divider, + gradientText, + pulseBar, + statusLights, +}; diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 00000000..2e70ebfc --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,38 @@ +# Use Node.js 18 as the base image +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Copy ALL files from the project root +COPY . . + +# Debug: Show what files were copied +RUN echo "=== Files in /app ===" && ls -la +RUN echo "=== pnpm-lock.yaml exists? ===" && test -f pnpm-lock.yaml && echo "YES" || echo "NO" +RUN echo "=== pnpm-workspace.yaml exists? ===" && test -f pnpm-workspace.yaml && echo "YES" || echo "NO" +RUN echo "=== client/package.json exists? ===" && test -f client/package.json && echo "YES" || echo "NO" + +# Install dependencies - ALWAYS use --no-frozen-lockfile to avoid issues +RUN pnpm install --no-frozen-lockfile + +# Build the client application +WORKDIR /app/client +RUN pnpm run build + +# Expose port +EXPOSE 3001 + +# Create a non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 + +# Change ownership of the app directory +RUN chown -R nextjs:nodejs /app +USER nextjs + +# Start the application +CMD ["pnpm", "run", "start"] \ No newline at end of file diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..154717ef --- /dev/null +++ b/client/README.md @@ -0,0 +1,341 @@ +# @fecommunity/reactpress-client + +ReactPress Client - Next.js 14 frontend for ReactPress CMS with modern UI and responsive design. + +[![NPM Version](https://img.shields.io/npm/v/@fecommunity/reactpress-client.svg)](https://www.npmjs.com/package/@fecommunity/reactpress-client) +[![License](https://img.shields.io/npm/l/@fecommunity/reactpress-client.svg)](https://github.com/fecommunity/reactpress/blob/master/client/LICENSE) +[![Node Version](https://img.shields.io/node/v/@fecommunity/reactpress-client.svg)](https://nodejs.org) +[![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) +[![Next.js](https://img.shields.io/badge/Next.js-14-black)](https://nextjs.org/) + +## Overview + +ReactPress Client is a responsive frontend application built with Next.js 14 that serves as the user interface for the ReactPress CMS platform. It provides a clean design, intuitive navigation, and content management capabilities. + +The client is designed with a component-based architecture that promotes reusability and maintainability. It integrates with the ReactPress backend through the [ReactPress Toolkit](../toolkit), providing type-safe API interactions. + +## Quick Start + +### Installation & Setup + +```bash +# Regular startup +npx @fecommunity/reactpress-client + +# PM2 startup for production +npx @fecommunity/reactpress-client --pm2 +``` + +## Features + +- ⚡ **App Router Architecture** with Server Components for optimal SSR +- 🎨 **Theme System** with light/dark mode switching +- 🌍 **Internationalization** - Supports Chinese and English languages +- 🌙 **Theme Switching** with system preference detection +- ✍️ **Markdown Editor** with live preview +- 📊 **Analytics Dashboard** with metrics and visualizations +- 🔍 **Search** with filtering +- 🖼️ **Media Management** with drag-and-drop upload +- 📱 **PWA Support** with offline capabilities +- ♿ **Accessibility Compliance** - WCAG 2.1 AA standards +- 🚀 **Performance Optimized** - Code splitting, image optimization, and caching + +## Requirements + +- Node.js >= 18.20.4 +- npm or pnpm package manager +- ReactPress Server running (for API connectivity) + +## Usage Scenarios + +### Standalone Client +Perfect for: +- Connecting to remote ReactPress API +- Headless CMS implementation +- Custom deployment scenarios +- Microfrontend architecture + +### Full ReactPress Stack +Use with ReactPress API for complete CMS solution: +```bash +# Start API first +pnpm exec reactpress-cli start + +# In another terminal, start client +npx @fecommunity/reactpress-client +``` + +## Core Components + +ReactPress Client includes a comprehensive set of UI components: + +- **Admin Dashboard** - Content management interface with role-based access +- **Article Editor** - Advanced markdown editor with media embedding +- **Comment System** - Moderation tools with spam detection +- **Media Library** - File management +- **User Management** - Account and profile settings with 2FA +- **Analytics Views** - Data visualization components with export capabilities +- **Theme Switcher** - Light/dark mode toggle with system preference detection +- **Language Selector** - Internationalization controls with RTL support + +## PM2 Support + +ReactPress client supports PM2 process management for production deployments: + +```bash +# Start with PM2 +npx @fecommunity/reactpress-client --pm2 +``` + +PM2 features: +- Automatic process restart on crash +- Memory monitoring +- Log management with rotation +- Process management +- Health checks + +## Configuration + +The client connects to the ReactPress server via environment variables: + +```env +# Server API URL +SERVER_API_URL=https://api.yourdomain.com + +# Client URL +CLIENT_URL=https://yourdomain.com +CLIENT_PORT=3001 + +# Analytics +GOOGLE_ANALYTICS_ID=your_ga_id + +# Security +NEXT_PUBLIC_CRYPTO_KEY=your_encryption_key +``` + +## Development + +```bash +# Clone repository +git clone https://github.com/fecommunity/reactpress.git +cd reactpress/client + +# Install dependencies +pnpm install + +# Start development server with hot reload +pnpm run dev + +# Start with PM2 (development) +pnpm run pm2 + +# Build for production +pnpm run build + +# Start production server +pnpm run start +``` + +## Project Structure + +``` +client/ +├── app/ # Next.js 14 App Router +│ ├── (admin)/ # Admin dashboard routes +│ ├── (public)/ # Public facing routes +│ └── api/ # API routes +├── components/ # Reusable UI components +├── lib/ # Business logic and utilities +├── providers/ # React context providers +├── hooks/ # Custom React hooks +├── styles/ # Global styles and design tokens +├── public/ # Static assets +└── bin/ # CLI entry points +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `SERVER_API_URL` | ReactPress server API URL | `http://localhost:3002` | +| `CLIENT_URL` | Client site URL | `http://localhost:3001` | +| `CLIENT_PORT` | Client port | `3001` | +| `NEXT_PUBLIC_GA_ID` | Google Analytics ID | - | +| `NEXT_PUBLIC_SITE_TITLE` | Site title | `ReactPress` | +| `NEXT_PUBLIC_CRYPTO_KEY` | Encryption key for sensitive data | - | + +## CLI Commands + +```bash +# Show help +npx @fecommunity/reactpress-client --help + +# Start client +npx @fecommunity/reactpress-client + +# Start with PM2 +npx @fecommunity/reactpress-client --pm2 + +# Specify port +npx @fecommunity/reactpress-client --port 3001 + +# Enable verbose logging +npx @fecommunity/reactpress-client --verbose +``` + +## Integration with ReactPress Toolkit + +The client seamlessly integrates with the ReactPress Toolkit for API interactions: + +```typescript +import { api, types } from '@fecommunity/reactpress-toolkit'; + +// Fetch articles with proper typing +const articles: types.IArticle[] = await api.article.findAll(); + +// Create new article +const newArticle = await api.article.create({ + title: 'My New Article', + content: 'Article content here...', + // ... other properties +}); +``` + +The toolkit provides: +- Strongly-typed API clients for all modules +- TypeScript definitions for all data models +- Utility functions for common operations +- Built-in authentication and error handling +- Automatic retry mechanisms for failed requests + +## Theme Customization + +ReactPress Client supports advanced theme customization: + +### Design Token System +```typescript +// Custom theme tokens +const customTokens = { + colors: { + primary: '#0070f3', + secondary: '#7928ca', + background: '#ffffff', + text: '#000000' + }, + typography: { + fontFamily: 'Inter, sans-serif', + fontSize: { + small: '12px', + medium: '16px', + large: '20px' + } + } +}; +``` + +### Component-Level Customization +```typescript +// Extend existing components +import { Button } from '@fecommunity/reactpress-components'; + +const CustomButton = styled(Button)` + background-color: ${props => props.theme.colors.primary}; + border-radius: 8px; + padding: 12px 24px; +`; +``` + +## Performance Optimization + +- **App Router Architecture** - Server Components for optimal SSR +- **Automatic Code Splitting** - Route-based code splitting +- **Image Optimization** - Next.js built-in image optimization with automatic format selection +- **Lazy Loading** - Component and route lazy loading +- **Caching Strategies** - HTTP caching and in-memory caching +- **Bundle Analysis** - Built-in bundle analysis tools + +## PWA Support + +ReactPress Client is a Progressive Web App with: +- Offline support with service workers +- Installable on devices with native app experience +- Push notifications (coming soon) +- App-like experience with splash screens + +## Testing + +```bash +# Run unit tests with Vitest +pnpm run test + +# Run integration tests with Playwright +pnpm run test:e2e + +# Run linting +pnpm run lint + +# Run formatting +pnpm run format + +# Run type checking +pnpm run type-check + +# Run bundle analysis +pnpm run analyze +``` + +## Templates + +ReactPress Client can be used with various professional templates: + +### Hello World Template +```bash +npx @fecommunity/reactpress-template-hello-world my-blog +``` + +### Twenty Twenty Five Template +```bash +npx @fecommunity/reactpress-template-twentytwentyfive my-blog +``` + +### Custom Templates +Create your own templates by extending the client with custom components and pages. + +## Deployment + +### Vercel Deployment (Recommended) + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress) + +### Custom Deployment + +```bash +# Build for production +pnpm run build + +# Start production server +pnpm run start +``` + +## Support + +- 📖 [Documentation](https://github.com/fecommunity/reactpress) +- 🐛 [Issues](https://github.com/fecommunity/reactpress/issues) +- 💬 [Discussions](https://github.com/fecommunity/reactpress/discussions) +- 📧 [Support](mailto:support@reactpress.dev) + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a pull request + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +--- + +Built with ❤️ by [FECommunity](https://github.com/fecommunity) \ No newline at end of file diff --git a/client/bin/reactpress-client.js b/client/bin/reactpress-client.js new file mode 100755 index 00000000..c045124c --- /dev/null +++ b/client/bin/reactpress-client.js @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +/** + * ReactPress Client CLI Entry Point + * This script allows starting the ReactPress client via npx + * Supports both regular and PM2 startup modes + */ + +const path = require('path'); +const fs = require('fs'); +const { spawn, spawnSync } = require('child_process'); + +// Capture the original working directory where npx was executed +// BUT prioritize the REACTPRESS_ORIGINAL_CWD environment variable if it exists +// This ensures consistency when running via pnpm dev from root directory +const originalCwd = process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(); + +// Get command line arguments +const args = process.argv.slice(2); +const usePM2 = args.includes('--pm2'); +const showHelp = args.includes('--help') || args.includes('-h'); + +// Show help if requested +if (showHelp) { + console.log(` +ReactPress Client - Next.js-based frontend for ReactPress CMS + +Usage: + npx @fecommunity/reactpress-client [options] + +Options: + --pm2 Start client with PM2 process manager + --help, -h Show this help message + +Examples: + npx @fecommunity/reactpress-client # Start client normally + npx @fecommunity/reactpress-client --pm2 # Start client with PM2 + npx @fecommunity/reactpress-client --help # Show this help message + `); + process.exit(0); +} + +// Get the directory where this script is located +const binDir = __dirname; +const clientDir = path.join(binDir, '..'); +const nextDir = path.join(clientDir, '.next'); + +// Function to check if PM2 is installed +function isPM2Installed() { + try { + require.resolve('pm2'); + return true; + } catch (e) { + // Check if PM2 is installed globally + try { + spawnSync('pm2', ['--version'], { stdio: 'ignore' }); + return true; + } catch (e) { + return false; + } + } +} + +// Function to install PM2 +function installPM2() { + console.log('[ReactPress Client] Installing PM2...'); + const installResult = spawnSync('npm', ['install', 'pm2', '--no-save'], { + stdio: 'inherit', + cwd: clientDir + }); + + if (installResult.status !== 0) { + console.error('[ReactPress Client] Failed to install PM2'); + return false; + } + + return true; +} + +// Function to start with PM2 +function startWithPM2() { + // Check if PM2 is installed + if (!isPM2Installed()) { + // Try to install PM2 + if (!installPM2()) { + console.error('[ReactPress Client] Cannot start with PM2'); + process.exit(1); + } + } + + // Check if the client is built + if (!fs.existsSync(nextDir)) { + console.log('[ReactPress Client] Client not built yet. Building...'); + + // Try to build the client + const buildResult = spawnSync('npm', ['run', 'build'], { + stdio: 'inherit', + cwd: clientDir + }); + + if (buildResult.status !== 0) { + console.error('[ReactPress Client] Failed to build client'); + process.exit(1); + } + } + + console.log('[ReactPress Client] Starting with PM2...'); + + // Use PM2 to start the Next.js production server + let pm2Command = 'pm2'; + try { + // Try to resolve PM2 path + pm2Command = path.join(clientDir, 'node_modules', '.bin', 'pm2'); + if (!fs.existsSync(pm2Command)) { + pm2Command = 'pm2'; + } + } catch (e) { + pm2Command = 'pm2'; + } + + // Start with PM2 using direct command + const pm2 = spawn(pm2Command, ['start', 'npm', '--name', 'reactpress-client', '--', 'run', 'start'], { + stdio: 'inherit', + cwd: clientDir + }); + + pm2.on('close', (code) => { + console.log(`[ReactPress Client] PM2 process exited with code ${code}`); + process.exit(code); + }); + + pm2.on('error', (error) => { + console.error('[ReactPress Client] Failed to start with PM2:', error); + process.exit(1); + }); +} + +// Function to start with regular Node.js (npm start) +function startWithNode() { + // Check if the app is built + if (!fs.existsSync(nextDir)) { + console.log('[ReactPress Client] Client not built yet. Building...'); + + // Try to build the client + const buildResult = spawnSync('npm', ['run', 'build'], { + stdio: 'inherit', + cwd: clientDir + }); + + if (buildResult.status !== 0) { + console.error('[ReactPress Client] Failed to build client'); + process.exit(1); + } + } + + // ONLY set the environment variable if it's not already set + // This preserves the value set by set-env.js when running pnpm dev from root + if (!process.env.REACTPRESS_ORIGINAL_CWD) { + process.env.REACTPRESS_ORIGINAL_CWD = originalCwd; + } else { + console.log(`[ReactPress Client] Using existing REACTPRESS_ORIGINAL_CWD: ${process.env.REACTPRESS_ORIGINAL_CWD}`); + } + + // Change to the client directory + process.chdir(clientDir); + + // Start with npm start + console.log('[ReactPress Client] Starting with npm start...'); + const npmStart = spawn('npm', ['start'], { + stdio: 'inherit', + cwd: clientDir + }); + + npmStart.on('close', (code) => { + console.log(`[ReactPress Client] npm start process exited with code ${code}`); + process.exit(code); + }); + + npmStart.on('error', (error) => { + console.error('[ReactPress Client] Failed to start with npm start:', error); + process.exit(1); + }); +} + +// Main execution +if (usePM2) { + startWithPM2(); +} else { + startWithNode(); +} \ No newline at end of file diff --git a/client/next-env.d.ts b/client/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/client/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/client/next-sitemap.js b/client/next-sitemap.js new file mode 100644 index 00000000..63aa6ab1 --- /dev/null +++ b/client/next-sitemap.js @@ -0,0 +1,10 @@ +const { config } = require('@fecommunity/reactpress-toolkit'); + +module.exports = { + siteUrl: config.CLIENT_SITE_URL, + generateRobotsTxt: true, + robotsTxtOptions: { + policies: [{ userAgent: '*', allow: '/', disallow: '/admin/' }], + }, + exclude: ['/admin', '/admin/**'], +}; diff --git a/client/next.config.js b/client/next.config.js new file mode 100644 index 00000000..4ff813f5 --- /dev/null +++ b/client/next.config.js @@ -0,0 +1,67 @@ +const path = require('path'); +const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); +const withPlugins = require('next-compose-plugins'); +const withLess = require('next-with-less'); +const withPWA = require('next-pwa'); +const { config } = require('@fecommunity/reactpress-toolkit'); +const antdVariablesFilePath = path.resolve(__dirname, './antd-custom.less'); + +const getServerApiUrl = () => { + if (config.SERVER_URL) { + return `${config.SERVER_SITE_URL}/api`; + } else { + return config.SERVER_API_URL || `${process.env.SERVER_SITE_URL}/api` || 'http://localhost:3002/api'; + } +}; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + assetPrefix: config.CLIENT_ASSET_PREFIX || '/', + i18n: { + locales: config.locales && config.locales.length > 0 ? config.locales : ['zh', 'en'], + defaultLocale: config.defaultLocale || 'zh', + }, + env: { + SERVER_API_URL: getServerApiUrl(), + GITHUB_CLIENT_ID: config.GITHUB_CLIENT_ID, + }, + webpack: (config, { dev, isServer }) => { + config.resolve.plugins.push(new TsconfigPathsPlugin()); + return config; + }, + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + compiler: { + removeConsole: { + exclude: ['error'], + }, + }, +}; + +module.exports = withPlugins( + [ + [ + withPWA, + { + pwa: { + disable: process.env.NODE_ENV !== 'production', + dest: '.next', + sw: 'service-worker.js', + }, + }, + ], + [ + withLess, + { + lessLoaderOptions: { + additionalData: (content) => `${content}\n\n@import '${antdVariablesFilePath}';`, + }, + }, + ], + ], + nextConfig +); \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 00000000..1898dc80 --- /dev/null +++ b/client/package.json @@ -0,0 +1,92 @@ +{ + "name": "@fecommunity/reactpress-client", + "version": "3.0.4", + "bin": { + "reactpress-client": "./bin/reactpress-client.js" + }, + "files": [ + ".next/**/*", + "bin/**/*", + "public/**/*", + "next.config.js", + "server.js" + ], + "scripts": { + "prebuild": "rimraf .next", + "build": "next build", + "postbuild": "next-sitemap", + "dev": "node server.js", + "start": "cross-env NODE_ENV=production node server.js", + "pm2": "pm2 start npm --name @fecommunity/reactpress-client -- start" + }, + "dependencies": { + "@ant-design/compatible": "^1.1.0", + "@ant-design/cssinjs": "^1.22.0", + "@ant-design/icons": "^4.7.0", + "@ant-design/pro-layout": "7.19.11", + "@fecommunity/reactpress-toolkit": "workspace:*", + "@monaco-editor/react": "^4.6.0", + "@vercel/analytics": "^2.0.1", + "antd": "^5.24.4", + "array-move": "^3.0.1", + "axios": "^0.23.0", + "classnames": "^2.3.1", + "copy-to-clipboard": "^3.3.1", + "date-fns": "^2.17.0", + "deep-equal": "^2.0.5", + "dotenv": "^17.2.3", + "echarts": "^5.6.0", + "echarts-for-react": "^3.0.2", + "fs-extra": "^10.0.0", + "highlight.js": "^9.18.5", + "less": "^4.1.2", + "less-vars-to-js": "^1.3.0", + "lodash-es": "^4.17.21", + "mime-types": "^2.1.26", + "next": "^12.3.4", + "next-compose-plugins": "^2.2.1", + "next-fonts": "^1.5.1", + "next-images": "^1.3.1", + "next-intl": "^1.5.1", + "next-page-transitions": "^1.0.0-beta.2", + "next-pwa": "^5.5.2", + "next-sitemap": "^1.6.102", + "next-with-less": "^2.0.5", + "nprogress": "^0.2.0", + "open": "^8.4.2", + "preact": "^10.5.14", + "qrcode-svg": "^1.1.0", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-infinite-scroller": "^1.2.4", + "react-lazyload": "^2.6.5", + "react-sortable-hoc": "^2.0.0", + "react-spring": "^9.1.2", + "react-text-loop": "2.3.0", + "react-visibility-sensor": "^5.1.1", + "showdown": "^1.9.1", + "viewerjs": "^1.5.0", + "xml": "^1.0.1" + }, + "devDependencies": { + "@types/node": "17.0.22", + "@types/react": "17.0.42", + "@types/react-infinite-scroller": "^1.2.3", + "@typescript-eslint/eslint-plugin": "^5.21.0", + "@typescript-eslint/parser": "^5.21.0", + "cross-env": "^7.0.3", + "eslint": "8.11.0", + "eslint-config-next": "12.1.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-react": "^7.29.4", + "eslint-plugin-react-hooks": "^4.5.0", + "eslint-plugin-simple-import-sort": "^7.0.0", + "less-loader": "^10.2.0", + "rimraf": "^3.0.2", + "sass": "^1.49.9", + "tsconfig-paths-webpack-plugin": "^3.5.2", + "typescript": "4.6.2" + } +} diff --git a/client/pages/404.tsx b/client/pages/404.tsx new file mode 100644 index 00000000..5e5e5df3 --- /dev/null +++ b/client/pages/404.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { Error404 } from './_error'; + +function Error() { + return ; +} + +export default Error; diff --git a/client/pages/_app.tsx b/client/pages/_app.tsx new file mode 100644 index 00000000..601b685d --- /dev/null +++ b/client/pages/_app.tsx @@ -0,0 +1,180 @@ +import '@/theme/index.scss'; +import 'highlight.js/styles/atom-one-dark.css'; +import 'viewerjs/dist/viewer.css'; + +import { NProgress } from '@components/NProgress'; +import { Analytics as VercelAnalytics } from '@vercel/analytics/react'; +import { ConfigProvider, theme } from 'antd'; +import App from 'next/app'; +import { default as Router } from 'next/router'; +import { IntlMessages, NextIntlProvider } from 'next-intl'; + +import { Analytics } from '@/components/Analytics'; +import { FixAntdStyleTransition } from '@/components/FixAntdStyleTransition'; +import { ViewStatistics } from '@/components/ViewStatistics'; +import { GlobalContext, IGlobalContext } from '@/context/global'; +import { AppLayout } from '@/layout/AppLayout'; +import { CategoryProvider } from '@/providers/category'; +import { PageProvider } from '@/providers/page'; +import { SettingProvider } from '@/providers/setting'; +import { TagProvider } from '@/providers/tag'; +import { UserProvider } from '@/providers/user'; +import { safeJsonParse } from '@/utils/json'; +import { toLogin } from '@/utils/login'; + +Router.events.on('routeChangeComplete', () => { + setTimeout(() => { + if (document.documentElement.scrollTop > 0) { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + } + }, 0); +}); + +class MyApp extends App { + state = { + locale: '', + user: null, + theme: null, + collapsed: false, + }; + + static getInitialProps = async ({ Component, ctx }) => { + const getPagePropsPromise = Component.getInitialProps ? Component.getInitialProps(ctx) : Promise.resolve({}); + const [pageProps, setting, tags, categories, pages] = await Promise.all([ + getPagePropsPromise, + SettingProvider.getSetting(), + TagProvider.getTags({ articleStatus: 'publish' }), + CategoryProvider.getCategory({ articleStatus: 'publish' }), + PageProvider.getAllPublisedPages(), + ]); + const i18n = safeJsonParse(setting.i18n); + const globalSetting = safeJsonParse(setting.globalSetting)?.[ctx?.locale]; + return { + pageProps, + setting, + tags, + categories, + pages: pages[0] || [], + i18n, + globalSetting, + locales: Object.keys(i18n), + }; + }; + + changeLocale = (key) => { + window.localStorage.setItem('locale', key); + this.setState({ locale: key }); + }; + + setUser = (user) => { + window.localStorage.setItem('user', JSON.stringify(user)); + this.setState({ user }); + }; + + removeUser = () => { + window.localStorage.setItem('user', ''); + this.setState({ user: null }); + window.location.reload(); + }; + + changeTheme = (theme: string) => { + this.setState({ theme }); + }; + + getSetting = () => { + SettingProvider.getSetting().then((res) => { + this.setState({ setting: res }); + }); + }; + + isAdminPage = () => { + const isAdminPage = this.props?.router?.route?.startsWith('/admin'); + return isAdminPage; + }; + + getUserFromStorage = () => { + const str = localStorage.getItem('user'); + const isAdminPage = this.isAdminPage(); + if (!isAdminPage) { + return; + } + if (str) { + const user = JSON.parse(str); + this.setUser(user); + UserProvider.checkAdmin(user); + } else { + toLogin(); + } + }; + + toggleCollapse = () => { + this.setState({ collapsed: !this.state.collapsed }); + }; + + componentDidMount() { + const userStr = window.localStorage.getItem('user'); + if (userStr) { + this.setState({ user: safeJsonParse(userStr) }); + } + this.getUserFromStorage(); + } + + render() { + const { Component, pageProps, i18n, globalSetting, locales, router, ...contextValue } = this.props; + const locale = this.state.locale || router.locale; + const { needLayoutFooter = true, hasBg = false } = pageProps; + const message = i18n[locale] || {}; + const algorithm = this.state.theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm; + const isAdminPage = this.isAdminPage(); + const hasFooter = !isAdminPage && needLayoutFooter; + + return ( + + + + + + + + + {!isAdminPage && } + + + + + + ); + } +} + +export default MyApp; diff --git a/client/pages/_document.tsx b/client/pages/_document.tsx new file mode 100644 index 00000000..4e515f7b --- /dev/null +++ b/client/pages/_document.tsx @@ -0,0 +1,40 @@ +import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs'; +import type { DocumentContext } from 'next/document'; +import Document, { Head, Html, Main, NextScript } from 'next/document'; + +const MyDocument = () => ( + + + +
+ + + +); + +MyDocument.getInitialProps = async (ctx: DocumentContext) => { + const cache = createCache(); + const originalRenderPage = ctx.renderPage; + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => (props) => ( + + + + ), + }); + + const initialProps = await Document.getInitialProps(ctx); + const style = extractStyle(cache, true); + return { + ...initialProps, + styles: ( + <> + {initialProps.styles} + + + + + )} + /> + + + + ); +}; + +export const GitHub = () => { + return ( + +
  • + + + +
  • +
    + ); +}; + +export const Comment = () => { + return ( + + } + > +
  • + +
  • +
    + ); +}; + +export const WeChat = () => { + return ( + } + > +
  • + +
  • +
    + ); +}; + +export const ContactInfo = () => { + return ( +
    +
      + + + + + + + + + + + + + +
    +
    + ); +}; + +const AboutUs = ({ setting, className = '', hasBg = false }: IProps) => { + const t = useTranslations(); + return ( + + + {t('aboutUs')} + + } + className={style.card} + > +
    + {setting?.systemFooterInfo && ( +
    + )} +
    +
    + + +
    +
    +
    +
    + ); +}; + +export default AboutUs; diff --git a/client/src/components/AdvanceSearch/index.module.scss b/client/src/components/AdvanceSearch/index.module.scss new file mode 100644 index 00000000..f49fff68 --- /dev/null +++ b/client/src/components/AdvanceSearch/index.module.scss @@ -0,0 +1,129 @@ +.searchItem { + display: flex; + flex-direction: column; + cursor: pointer; + &:hover { + color: var(--primary-color); + } + .description { + color: var(--second-text-color); + } +} +.wrapper { + padding: 16px 24px !important; +} +.pop { + background-color: var(--bg-box) !important; +} +.searchWrapper { + background-color: var(--bg-box) !important; + .autoComplete { + width: 100%; + height: 48px !important; + * { + background-color: var(--bg-box) !important; + } + } + + .searchCategory, + .searchSubCategory { + background-color: var(--bg-box) !important; + > div { + margin: 0 !important; + div { + justify-content: center !important; + } + } + } + .searchSubCategory { + > div { + > div { + > div { + > div:last-child { + top: 0 !important; + content: ''; + border-width: 8px 8px 0px 8px; + border-style: solid; + border-color: #f44336 transparent transparent; + position: absolute; + left: 50%; + top: 0; + margin-left: -8px; + background: none; + width: 0px !important; + } + } + } + } + } + + .searchInput { + border-radius: 24px; + } + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: var(--primary-color); + } + } + + .wrapper { + height: fit-content; + max-height: 70vh; + overflow: scroll; + } +} +.inner { + box-shadow: var(--box-shadow) !important; + + .ant-modal-header { + font-size: 1.5rem; + font-weight: 600; + } + + .ant-modal-close-x { + font-size: 18px; + } + + .result { + flex: 1; + overflow: auto; + } + + ul { + list-style: circle; + + li { + display: flex; + align-items: center; + + &:hover { + background: var(--bg-body); + } + + a { + display: inline-block; + width: 100%; + padding: 12px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } +} + +@media (min-width: 992px) { + .inner { + width: 768px !important; + } +} + +@media (max-width: 576px) { + .inner { + width: 92% !important; + } +} diff --git a/client/src/components/AdvanceSearch/index.tsx b/client/src/components/AdvanceSearch/index.tsx new file mode 100644 index 00000000..32f0b68d --- /dev/null +++ b/client/src/components/AdvanceSearch/index.tsx @@ -0,0 +1,151 @@ +import { AutoComplete, Button, Input, Spin, Tabs } from 'antd'; +import React, { useContext, useEffect, useState } from 'react'; + +import { useAsyncLoading } from '@/hooks/useAsyncLoading'; +import { SearchProvider } from '@/providers/search'; +import { SearchOutlined } from '@ant-design/icons'; + +import { GlobalContext } from '@/context/global'; +import { ArticleProvider } from '@/providers/article'; +import { jsonp } from '@/utils/jsonp'; +import styles from './index.module.scss'; + +interface IProps { + globalSetting?: any; +} + +export const AdvanceSearch: React.FC = (props) => { + const { globalSetting } = useContext(GlobalContext); + const { subCategories = {}, categories } = globalSetting?.globalConfig?.navConfig || props.globalSetting || {}; + const [category, setCategory] = useState(categories?.[0]?.key); + const [subCategory, setSubCategory] = useState(subCategories?.[category]?.[0]?.key); + const [options, setOptions] = useState([]); + const [searchVal, setSearchVal] = useState(); + + useEffect(() => { + setSubCategory(subCategories?.[category]?.[0]?.key); + fetchSuggestions(searchVal); + }, [category]); + + const fetchLocalData = (keyword: string) => { + if (keyword?.length) { + return SearchProvider.searchArticles(keyword); + } else { + return ArticleProvider.getRecommend(); + } + }; + + const [searchArticles, loading] = useAsyncLoading(fetchLocalData); + + const fetchSuggestions = (keyword: string) => { + switch (category) { + case 'local': + return searchArticles(keyword).then((res) => { + const options = res + .filter((t) => t.status === 'publish') + .map((item) => ({ + label: item?.title, + value: item?.title, + description: item?.summary, + link: `/article/${item?.id}`, + data: item, + })); + setOptions(options); + }); + default: + return jsonp( + `https://suggestion.baidu.com/su`, + { + wd: keyword || '高热度网', + }, + (res) => { + const data = subCategories[category]?.find((item) => item.key === subCategory); + const options = (res?.s || []).map((item) => ({ + link: data?.url ? `${data.url}${item}` : null, + label: item, + value: item, + })); + setOptions(options); + } + ); + } + }; + + const onValueChange = (val) => { + setSearchVal(val); + fetchSuggestions(val); + }; + + const handleSearch = () => { + const data = subCategories[category]?.find((item) => item.key === subCategory); + const link = data?.url ? `${data.url}${searchVal || '高热度网'}` : null; + if (category === 'local' || !!searchVal) { + fetchSuggestions(searchVal); + } else { + window.open(link, '_blank'); + } + }; + + const optionRender = (record, info) => { + const { label, value, data: { link, description, id } = {} as any } = record; + + return ( +
    { + !!link && window.open(link, '_blank'); + e.stopPropagation(); + }} + key={info?.index} + > +
    {label}
    +

    +

    + ); + }; + + return ( +
    +
    +
    + { + setCategory(val); + }} + /> + fetchSuggestions(searchVal)} + notFoundContent={loading ? : null} + popupClassName={styles.pop} + > + } type="text" />} + /> + + { + setSubCategory(value); + fetchSuggestions(searchVal); + }} + /> +
    +
    + ); +}; diff --git a/client/src/components/Analytics/index.tsx b/client/src/components/Analytics/index.tsx new file mode 100644 index 00000000..739d9689 --- /dev/null +++ b/client/src/components/Analytics/index.tsx @@ -0,0 +1,49 @@ +import { useContext, useEffect } from 'react'; + +import { GlobalContext } from '@/context/global'; + +export const Analytics = (props) => { + const { setting } = useContext(GlobalContext); + + useEffect(() => { + const googleAnalyticsId = setting.googleAnalyticsId; + + if (!googleAnalyticsId) { + return; + } + + // @ts-ignore + window.dataLayer = window.dataLayer || []; + function gtag() { + // @ts-ignore + window.dataLayer.push(arguments); // eslint-disable-line prefer-rest-params + } + // @ts-ignore + gtag('js', new Date()); + // @ts-ignore + gtag('config', googleAnalyticsId); + + const script = document.createElement('script'); + script.src = `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsId}`; + script.async = true; + + if (document.body) { + document.body.appendChild(script); + } + }, [setting.googleAnalyticsId]); + + useEffect(() => { + const baiduAnalyticsId = setting.baiduAnalyticsId; + + if (!baiduAnalyticsId) { + return; + } + + const hm = document.createElement('script'); + hm.src = `https://hm.baidu.com/hm.js?${baiduAnalyticsId}`; + const s = document.getElementsByTagName('script')[0]; + s.parentNode.insertBefore(hm, s); + }, [setting.baiduAnalyticsId]); + + return props.children || null; +}; diff --git a/client/src/components/Animation/Opacity.tsx b/client/src/components/Animation/Opacity.tsx new file mode 100644 index 00000000..0537d667 --- /dev/null +++ b/client/src/components/Animation/Opacity.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Spring, SpringProps } from './Spring'; + +export const Opacity: React.FC = (props) => { + const { from = {}, to = {}, ...rest } = props; + from.opacity = 0; + to.opacity = 1; + + return ; +}; diff --git a/client/src/components/Animation/Spring.tsx b/client/src/components/Animation/Spring.tsx new file mode 100644 index 00000000..6db5262c --- /dev/null +++ b/client/src/components/Animation/Spring.tsx @@ -0,0 +1,41 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { animated, useSpring } from 'react-spring'; +import VisibilitySensor from 'react-visibility-sensor'; + +import { elementInViewport } from '@/utils'; + +export interface SpringProps { + containerProps?: Record; + from?: Record; + to?: Record; +} + +export const Spring: React.FC = ({ containerProps = {}, from = {}, to = {}, children }) => { + const ref = useRef(); + const [styles, animation] = useSpring(() => ({ + ...from, + config: { mass: 10, tension: 400, friction: 40, precision: 0.00001, clamp: true }, + })); + const onViewportChange = useCallback( + (visible) => { + if (visible) { + animation.start(to); + } + }, + [animation, to] + ); + + useEffect(() => { + if (elementInViewport(ref.current)) { + animation.start(to); + } + }, [animation, to]); + + return ( + + + {children} + + + ); +}; diff --git a/client/src/components/Animation/Trail.tsx b/client/src/components/Animation/Trail.tsx new file mode 100644 index 00000000..49177aa9 --- /dev/null +++ b/client/src/components/Animation/Trail.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { animated, useTrail } from 'react-spring'; + +interface ListTrailProps { + length: number; + options: Record; + element?: string; + setItemContainerProps?: (index: number) => Record; + renderItem: (index: number) => React.ReactNode; +} + +export const ListTrail: React.FC = ({ + length, + options, + element = 'li', + setItemContainerProps = () => ({}), + renderItem, +}) => { + const C = animated[element]; + const trail = useTrail(length, { + config: { mass: 2, tension: 280, friction: 24, clamp: true }, + ...options, + }); + + return ( + <> + {trail.map((style, index) => { + return ( + + {renderItem(index)} + + ); + })} + + ); +}; diff --git a/client/src/components/Animation/Transition.tsx b/client/src/components/Animation/Transition.tsx new file mode 100644 index 00000000..da7b353c --- /dev/null +++ b/client/src/components/Animation/Transition.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { animated, useTransition } from 'react-spring'; + +type ConditionTransitionProps = { + visible: boolean; + options: Record; +}; + +export const ConditionTransition: React.FC = ({ visible, options, children }) => { + const transitions = useTransition(visible, { + config: { mass: 2, tension: 280, friction: 24, clamp: true }, + ...options, + }); + + return ( + <> + {transitions( + (style, item) => item && {children} + )} + + ); +}; diff --git a/client/src/components/ArticleCarousel/index.module.scss b/client/src/components/ArticleCarousel/index.module.scss new file mode 100644 index 00000000..133b2094 --- /dev/null +++ b/client/src/components/ArticleCarousel/index.module.scss @@ -0,0 +1,85 @@ +.wrapper { + background: var(--bg-second); + box-shadow: var(--box-shadow); + border-radius: var(--border-radius); + + > div { + font-size: 0 !important; + } + + .articleItem { + position: relative; + display: flex; + width: 100%; + height: 260px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + overflow: hidden; + border-radius: var(--border-radius); + + img { + width: 100%; + } + + .info { + position: absolute; + top: 0; + left: 0; + z-index: 1; + display: flex; + width: 100%; + height: 100%; + font-size: 1rem; + color: var(--font-color-base); + background-color: rgb(0 0 0 / 35%); + flex-direction: column; + justify-content: center; + align-items: center; + + h2 { + color: inherit; + text-align: center; + margin: 0 16px; + } + + .seperator { + margin: 0 4px; + } + + .meta { + text-align: right; + } + } + } + + @media (max-width: 768px) { + .articleItem { + height: 300px; + } + } + + @media (min-width: 768px) { + .container { + width: 768px; + } + } + + @media (min-width: 992px) { + .articleItem { + height: 340px; + } + } + + @media (min-width: 1200px) { + .articleItem { + height: 380px; + } + } + + @media (min-width: 1360px) { + .articleItem { + height: 460px; + } + } +} diff --git a/client/src/components/ArticleCarousel/index.tsx b/client/src/components/ArticleCarousel/index.tsx new file mode 100644 index 00000000..8f00e92a --- /dev/null +++ b/client/src/components/ArticleCarousel/index.tsx @@ -0,0 +1,49 @@ +import { Carousel } from 'antd'; +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import React from 'react'; + +import { LocaleTime } from '@/components/LocaleTime'; + +import style from './index.module.scss'; + +interface IProps { + articles?: IArticle[]; +} + +export const ArticleCarousel: React.FC = ({ articles = [] }) => { + const t = useTranslations(); + return articles && articles.length ? ( +
    + + {(articles || []) + .filter((article) => article.cover) + .slice(0, 6) + .map((article) => { + return ( + + ); + })} + +
    + ) : null; +}; diff --git a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss b/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss new file mode 100644 index 00000000..b38c9083 --- /dev/null +++ b/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss @@ -0,0 +1,38 @@ +.formItem { + display: flex; + align-items: center; + + + .formItem { + margin-top: 16px; + } + + > span { + padding-right: 16px; + } + + > div { + flex: 1; + } +} + +.cover { + .preview { + display: flex; + justify-content: center; + align-items: center; + height: 180px; + margin-bottom: 16px; + color: #888; + background-color: #f5f5f5; + + img { + display: block; + max-width: 100%; + max-height: 180px; + } + } + + button { + margin-top: 16px; + } +} diff --git a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx b/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx new file mode 100644 index 00000000..fd0d2daf --- /dev/null +++ b/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx @@ -0,0 +1,212 @@ +import { Button, Drawer, Input, Select, Switch } from 'antd'; +import React, { useEffect, useReducer, useState } from 'react'; + +import { FileSelectDrawer } from '@/components/FileSelectDrawer'; +import { CategoryProvider } from '@/providers/category'; +import { TagProvider } from '@/providers/tag'; + +import style from './index.module.scss'; + +interface IProps { + visible: boolean; + article?: Partial; + onClose: () => void; + onChange?: (arg) => void; +} + +const FormItem = ({ label, content }) => { + return ( +
    + {label} +
    {content}
    +
    + ); +}; + +const initialArticleAttrs = { + summary: null, // 摘要 + password: null, // 密码 + isCommentable: true, // 评论 + isRecommended: true, // 推荐到首页 + category: null, // 分类 + tags: [], // 标签 + cover: null, // 封面 +}; +function reducer(state: typeof initialArticleAttrs = initialArticleAttrs, action) { + const payload = action.payload; + switch (action.type) { + case 'summary': + return { ...state, summary: payload }; + case 'password': + return { ...state, password: payload }; + case 'isCommentable': + return { ...state, isCommentable: payload }; + case 'isRecommended': + return { ...state, isRecommended: payload }; + case 'category': + return { ...state, category: payload }; + case 'tags': + return { ...state, tags: payload }; + case 'cover': + return { ...state, cover: payload }; + default: + return state; + } +} +export const ArticleSettingDrawer: React.FC = ({ article, visible, onClose, onChange }) => { + const [fileVisible, setFileVisible] = useState(false); + const [attrs, dispatch] = useReducer(reducer, article as typeof initialArticleAttrs); + const [categorys, setCategorys] = useState>([]); + const [tags, setTags] = useState>([]); + + useEffect(() => { + CategoryProvider.getCategory().then((res) => setCategorys(res)); + TagProvider.getTags().then((tags) => setTags(tags)); + }, []); + + const ok = () => { + onChange({ + ...attrs, + tags: (attrs.tags || []).join(','), + }); + }; + + return ( + + { + dispatch({ type: 'summary', payload: e.target.value }); + }} + /> + } + /> + { + dispatch({ type: 'password', payload: e.target.value }); + }} + /> + } + /> + { + dispatch({ type: 'isCommentable', payload: val }); + }} + /> + } + /> + { + dispatch({ type: 'isRecommended', payload: val }); + }} + /> + } + /> + { + dispatch({ type: 'category', payload: id }); + }} + style={{ width: '100%' }} + > + {categorys.map((t) => ( + + {t.label} + + ))} + + } + /> + t.id || t)} + onChange={(tags) => { + dispatch({ type: 'tags', payload: tags }); + }} + > + {tags.map((tag) => ( + + {tag.label} + + ))} + + } + /> + +
    setFileVisible(true)} className={style.preview}> + 预览图 +
    + { + dispatch({ type: 'cover', payload: e.target.value }); + }} + /> + + + } + /> + setFileVisible(false)} + onChange={(url) => { + dispatch({ type: 'cover', payload: url }); + }} + /> +
    + +
    +
    + ); +}; diff --git a/client/src/components/ArticleEditor/index.module.scss b/client/src/components/ArticleEditor/index.module.scss new file mode 100644 index 00000000..8a69d715 --- /dev/null +++ b/client/src/components/ArticleEditor/index.module.scss @@ -0,0 +1,31 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100vh; + background-color: var(--bg-box); + + .header { + z-index: 1000; + height: 64px; + background-color: var(--bg-secord); + + > div { + height: 100%; + } + + input { + padding-right: 0; + padding-left: 0; + border-top: 0; + border-left: 0; + border-radius: 0 !important; + box-shadow: none !important; + border-right: 0; + } + } + + .main { + flex: 1; + overflow: hidden; + } +} diff --git a/client/src/components/ArticleEditor/index.tsx b/client/src/components/ArticleEditor/index.tsx new file mode 100644 index 00000000..ca25ec07 --- /dev/null +++ b/client/src/components/ArticleEditor/index.tsx @@ -0,0 +1,247 @@ +import { CloseOutlined, EllipsisOutlined } from '@ant-design/icons'; +import { PageHeader } from '@ant-design/pro-layout'; +import { Editor as MDEditor } from '@components/Editor'; +import { Button, Dropdown, Input, Layout, Menu, message, Modal } from 'antd'; +import cls from 'classnames'; +import { default as Router } from 'next/router'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Head from 'next/head'; + +import { useSetting } from '@/hooks/useSetting'; +import { useToggle } from '@/hooks/useToggle'; +import { useWarningOnExit } from '@/hooks/useWarningOnExit'; +import { ArticleProvider } from '@/providers/article'; +import { resolveUrl } from '@/utils'; + +import { ArticleSettingDrawer } from './ArticleSettingDrawer'; +import style from './index.module.scss'; + +interface IProps { + id?: string | number; + article?: IArticle; +} + +const REQUIRED_ARTICLE_ATTRS = [ + ['title', '请输入文章标题'], + ['content', '请输入文章内容'], +]; + +// 副作用:传给服务端的 category 需要是 id +const transformCategory = (article) => { + if (article.category && article.category.id) { + article.category = article.category.id; + } +}; +const transformTags = (article) => { + if (Array.isArray(article.tags)) { + try { + article.tags = (article.tags as ITag[]).map((t) => t.id).join(','); + } catch (e) { + console.log(e); + } + } +}; + +export const ArticleEditor: React.FC = ({ id: defaultId, article: defaultArticle = { title: '' } }) => { + const isCreate = !defaultId; // 一开始是否是新建 + const setting = useSetting(); + const [id, setId] = useState(defaultId); + const [article, setArticle] = useState>(defaultArticle); + const [settingDrawerVisible, toggleSettingDrawerVisible] = useToggle(false); + const [hasSaved, toggleHasSaved] = useToggle(false); + + const patchArticle = useMemo( + () => (key) => (value) => { + if (value.target) { + value = value.target.value; + } + setArticle((article) => { + article[key] = value; + return article; + }); + }, + [] + ); + + // 校验文章必要属性 + const check = useCallback(() => { + let canPublish = true; + let errorMsg = null; + REQUIRED_ARTICLE_ATTRS.forEach(([key, msg]) => { + if (!article[key]) { + errorMsg = msg; + canPublish = false; + } + }); + if (!canPublish) { + return Promise.reject(new Error(errorMsg)); + } + return Promise.resolve(); + }, [article]); + + // 打开发布抽屉 + const openSetting = useCallback(() => { + check() + .then(() => { + toggleSettingDrawerVisible(); + }) + .catch((err) => { + message.warning(err.message); + }); + }, [check, toggleSettingDrawerVisible]); + + const saveSetting = useCallback( + (setting) => { + toggleSettingDrawerVisible(); + Object.assign(article, setting); + }, + [article, toggleSettingDrawerVisible] + ); + + // 保存草稿或者发布线上 + const saveOrPublish = useCallback( + (patch = {}) => { + const data = { ...article, ...patch }; + return check() + .then(() => { + transformCategory(data); + transformTags(data); + const promise = !isCreate ? ArticleProvider.updateArticle(id, data) : ArticleProvider.addArticle(data); + return promise.then((res) => { + setId(res.id); + toggleHasSaved(true); + message.success(res.status === 'draft' ? '文章已保存为草稿' : '文章已发布'); + }); + }) + .catch((err) => { + message.warning(err.message); + return Promise.reject(err); + }); + }, + [article, isCreate, check, id, toggleHasSaved] + ); + + const saveDraft = useCallback(() => { + return saveOrPublish({ status: 'draft' }); + }, [saveOrPublish]); + + const publish = useCallback(() => { + return saveOrPublish({ status: 'publish' }); + }, [saveOrPublish]); + + // 预览文章 + const preview = useCallback(() => { + if (id) { + if (!setting.systemUrl) { + message.error('尚未配置前台地址,无法正确构建预览地址'); + return; + } + window.open(resolveUrl(setting.systemUrl, '/article/' + id)); + } else { + message.warning('请先保存'); + } + }, [id, setting.systemUrl]); + + const deleteArticle = useCallback(() => { + if (!id) { + return; + } + const handle = () => { + ArticleProvider.deleteArticle(id).then(() => { + toggleHasSaved(true); + message.success('文章删除成功'); + Router.push('/article'); + }); + }; + Modal.confirm({ + title: '确认删除?', + content: '删除内容后,无法恢复。', + onOk: handle, + okText: '确认', + cancelText: '取消', + transitionName: '', + maskTransitionName: '', + }); + }, [id, toggleHasSaved]); + + const goback = useCallback(() => { + Router.push('/admin/article'); + }, []); + + useEffect(() => { + if (isCreate && id) { + Router.replace('/admin/article/editor/' + id); + } + }, [id, isCreate]); + + useWarningOnExit(!hasSaved, () => window.confirm('确认关闭?如果有内容变更,请先保存!')); + + return ( +
    + + {id ? `编辑文章 ${article.title ? '-' + article.title : ''}` : '新建文章'} + +
    + } />} + style={{ + borderBottom: '1px solid rgb(235, 237, 240)', + }} + onBack={goback} + title={ + + } + extra={[ + , + + + 查看 + + + 设置 + + + + 保存草稿 + + + + 删除 + + + } + > + + , + ]} + /> +
    +
    + { + patchArticle('content')(value); + patchArticle('html')(html); + patchArticle('toc')(toc); + }} + /> +
    + +
    + ); +}; diff --git a/client/src/components/ArticleList/index.module.scss b/client/src/components/ArticleList/index.module.scss new file mode 100644 index 00000000..5d1c64fa --- /dev/null +++ b/client/src/components/ArticleList/index.module.scss @@ -0,0 +1,265 @@ +.wrapper { + overflow: hidden; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + margin-top: 1rem; +} + +.articleItem { + position: relative; + display: flex; + justify-content: space-between; + overflow: hidden; + padding: 1rem; + background-color: var(--bg-box); + border-radius: var(--border-radius); + + .info { + display: flex; + align-items: center; + } + + &:hover { + img { + transform: scale(1.1); + transition: all 0.2s ease-in; + } + } + + .antBadge { + margin-left: 4px; + &:hover { + opacity: 0.7; + } + .category { + color: var(--second-text-color); + } + } + + .coverWrapper { + position: relative; + height: 114px; + width: 200px; + margin: 0 10px 0 0; + flex-shrink: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + border-radius: 5px; + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + @media (max-width: 992px) { + .coverWrapper { + width: 180px; + } + } + + .badge { + position: absolute; + top: 20px; + left: -1px; + width: 5px; + height: 25px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + background-color: var(--primary-color); + } + + .link { + display: inline-block; + height: 100%; + width: 100%; + } + + .articleWrapper { + flex: 1; + } + + & + .articleItem { + margin-top: 1rem; + } + + &::after { + position: absolute; + bottom: 0rem; + width: calc(100% - 32px); + height: 1px; + // background: var(--border-color); + content: ''; + } + + &:last-of-type { + &::after { + height: 0; + } + } + + &:hover { + header .title { + color: var(--primary-color); + } + } + + header { + display: flex; + align-items: flex-start; + + .title { + overflow: hidden; + font-size: 16px; + font-weight: 600; + line-height: 22px; + color: var(--main-text-color); + text-overflow: ellipsis; + font-synthesis: style; + + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2em; + max-height: 2.4em; + } + + .time, + .category { + color: #fff; + } + } + + main { + display: flex; + flex-wrap: nowrap; + height: calc(100% - 28px); + + .coverWrapper { + position: relative; + width: 120px; + max-height: 100px; + min-height: 80px; + margin-left: 1.5rem; + overflow: hidden; + border-radius: var(--border-radius); + flex: 0 0 auto; + + &:hover { + transform: scale(1.2); + } + + img { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + transform: translate3d(-50%, -50%, 0); + object-fit: cover; + } + } + + .contentWrapper { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + + .desc { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + font-size: 14px; + color: var(--second-text-color); + text-overflow: ellipsis; + line-height: 1.2em; + max-height: 2.4em; + max-width: 100%; + width: calc(100% - 24px); + } + + .meta { + width: 100%; + margin-top: 0.8rem; + font-size: 14px; + line-height: 20px; + color: #8590a6; + display: flex; + justify-content: space-between; + white-space: nowrap; + + .separator { + margin: 0 8px; + } + + .number { + margin-left: 6px; + color: var(--second-text-color); + } + .time { + > * { + margin-left: 6px; + } + } + } + } + } +} + +@media (max-width: 658px) { + .articleItem { + .coverWrapper { + width: 140px; + height: 80px; + } + + > a { + flex-direction: column; + } + + .info { + display: none; + } + + header { + flex-direction: column; + align-items: flex-start; + + .info { + font-size: 0.8em; + + > div:first-of-type { + display: none; + } + } + .category { + display: none; + } + } + + main { + .contentWrapper { + .desc { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2em; + max-height: 2.4em; + } + + .time { + display: none; + } + } + } + } +} diff --git a/client/src/components/ArticleList/index.tsx b/client/src/components/ArticleList/index.tsx new file mode 100644 index 00000000..a305bc19 --- /dev/null +++ b/client/src/components/ArticleList/index.tsx @@ -0,0 +1,160 @@ +/** + * ArticleList Component + * + * This component displays a list of articles in a card format. + * Each article card includes: + * - Cover image (with lazy loading) + * - Title + * - Category tag + * - Summary + * - Meta information (likes, views, publish date) + * + * Features: + * - Lazy loading for images + * - Responsive design + * - Category navigation + * - Article statistics display + */ + +import { EyeOutlined, FolderOutlined, HeartOutlined, HistoryOutlined } from '@ant-design/icons'; +import { Spin, Tag } from 'antd'; +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; +import React, { useContext, useMemo } from 'react'; +import LazyLoad from 'react-lazyload'; +import LogoSvg from '../../assets/LogoSvg'; + +import { LocaleTime } from '@/components/LocaleTime'; +import { GlobalContext } from '@/context/global'; +import { getColorFromNumber } from '@/utils'; +import style from './index.module.scss'; + +interface Article { + id: string; + title: string; + cover?: string; + summary: string; + category?: { + value: string; + label: string; + }; + likes: number; + views: number; + publishAt: string; +} + +interface ArticleListProps { + articles: Article[]; + coverHeight?: number; + asRecommend?: boolean; +} + +/** + * ArticleCard Component + * Renders a single article card with all its details + */ +const ArticleCard: React.FC<{ article: Article; categoryIndex: number }> = ({ article, categoryIndex }) => { + return ( + + ); +}; + +/** + * Main ArticleList Component + * Renders a list of article cards with proper handling of empty state + */ +export const ArticleList: React.FC = ({ articles = [] }) => { + const t = useTranslations(); + const { categories } = useContext(GlobalContext); + + // Memoize the category indices to avoid recalculating on every render + const categoryIndices = useMemo(() => { + return articles.map(article => + categories?.findIndex((category) => category?.value === article?.category?.value) + ); + }, [articles, categories]); + + return ( +
    + {articles && articles.length ? ( + articles.map((article, index) => ( + + )) + ) : ( +
    {t('empty')}
    + )} +
    + ); +}; diff --git a/client/src/components/ArticleRecommend/index.module.scss b/client/src/components/ArticleRecommend/index.module.scss new file mode 100644 index 00000000..c4a27426 --- /dev/null +++ b/client/src/components/ArticleRecommend/index.module.scss @@ -0,0 +1,152 @@ +.wrapper { + margin-bottom: 1.3rem; + overflow: hidden; + line-height: 1.29; + + &.inline { + background-color: var(--bg-box); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + } + + .recommendIcon { + margin-right: 8px; + } + + .title { + padding: 1rem; + font-weight: bold; + color: var(--main-text-color); + border-bottom: 1px solid var(--border-color); + } + + ul.inlineWrapper { + padding: 0 1rem 1rem; + + .article { + display: flex; + justify-content: space-between; + width: 100%; + .seqId { + display: inline-block; + width: 40px; + border-radius: 4px; + background-color: var(--primary-color); + } + .articleTitle { + flex: 1; + width: 100%; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + &::before { + background-color: var(--primary-color); + color: #fff; + content: attr(data-num); + display: inline-block; + font-size: 14px; + line-height: 18px; + margin-right: 5px; + text-align: center; + width: 18px; + } + } + .views { + display: inline-block; + width: 54px; + color: var(--second-text-color); + } + } + + li { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + padding-top: 1rem; + color: var(--second-text-color); + + > div:last-of-type { + display: flex; + align-items: center; + } + + a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; + + span:first-of-type { + color: var(--main-text-color); + } + + &:hover { + color: var(--primary-color); + + span:first-of-type { + color: inherit; + } + } + } + + p { + margin: 0; + } + + img { + display: inline-block; + width: 6.8rem; + height: 3.8rem; + margin-right: 0.8rem; + } + } + } +} + +.articleItem { + position: relative; + width: 100%; + padding: 0; + margin-right: 14px; + overflow: hidden; + background: var(--bg-second); + border-radius: 5px; + box-shadow: var(--box-shadow); + transition: transform 0.3s; + + &:hover { + transform: scale(1.04); + } + + img { + width: 100%; + height: 123px; + border-top-right-radius: 5px; + border-top-left-radius: 5px; + object-fit: cover; + object-fit: cover; + } + + .title { + min-width: 225px; + padding: 12px; + margin-bottom: 0; + overflow: hidden; + font-size: 16px; + font-weight: 600; + line-height: 22px; + color: var(--main-text-color); + text-overflow: ellipsis; + white-space: nowrap; + border: 0; + font-synthesis: style; + } + + .meta { + width: 100%; + padding: 0 12px 12px; + font-size: 14px; + line-height: 20px; + color: #8590a6; + } +} diff --git a/client/src/components/ArticleRecommend/index.tsx b/client/src/components/ArticleRecommend/index.tsx new file mode 100644 index 00000000..25f72083 --- /dev/null +++ b/client/src/components/ArticleRecommend/index.tsx @@ -0,0 +1,82 @@ +import { ArticleList } from '@components/ArticleList'; +import { Spin } from 'antd'; +import cls from 'classnames'; +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; +import React, { useEffect, useState } from 'react'; + +import { useAsyncLoading } from '@/hooks/useAsyncLoading'; +import { ArticleProvider } from '@/providers/article'; +import { LikeOutlined, EyeOutlined } from '@ant-design/icons'; + +import style from './index.module.scss'; + +interface IProps { + articleId?: string; + mode?: 'inline' | 'vertical'; + needTitle?: boolean; +} + +export const ArticleRecommend: React.FC = ({ mode = 'vertical', articleId = null, needTitle = true }) => { + const t = useTranslations(); + const [getRecommend, loading] = useAsyncLoading(ArticleProvider.getRecommend, 150, true); + const [fetched, setFetched] = useState(''); + const [articles, setArticles] = useState([]); + + useEffect(() => { + if (fetched === articleId) return; + getRecommend(articleId).then((res) => { + const articles = res.slice(0, 6); + articles.sort((a, b) => b.views - a.views); + setArticles(articles); + setFetched(articleId); + }); + }, [articleId, getRecommend, fetched]); + + return ( +
    + {needTitle && ( +
    + + {t('recommendToReading')} +
    + )} + + + {loading ? ( +
    + ) : mode === 'inline' ? ( + articles.length <= 0 ? ( + loading ? ( +
    + ) : ( +
    {t('empty')}
    + ) + ) : ( + + ) + ) : ( + + )} +
    +
    + ); +}; diff --git a/client/src/components/Categories/index.module.scss b/client/src/components/Categories/index.module.scss new file mode 100644 index 00000000..fa76f47b --- /dev/null +++ b/client/src/components/Categories/index.module.scss @@ -0,0 +1,58 @@ +.wrapper { + margin-bottom: 1.3rem; + overflow: hidden; + line-height: 1.29; + background-color: var(--bg-box); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + + .title { + padding: 1rem 1.3rem; + font-weight: bold; + color: var(--main-text-color); + border-bottom: 1px solid var(--border-color); + } + + .categoryIcon { + margin-right: 8px; + } + + ul { + padding: 1rem; + } + + li { + padding: 8px 7px; + line-height: 1.5em; + color: var(--second-text-color); + border-radius: var(--border-radius); + transition: all ease-in-out 0.2s; + + a { + display: inline-flex; + justify-content: space-between; + width: 100%; + + > span:first-of-type { + color: var(var(--main-text-color)); + } + } + + &:hover { + color: var(--primary-color); + + a > span:first-of-type { + color: inherit; + } + } + + &.active { + color: var(--bg); + background-color: var(--primary-color); + + a > span:first-of-type { + color: inherit; + } + } + } +} diff --git a/client/src/components/Categories/index.tsx b/client/src/components/Categories/index.tsx new file mode 100644 index 00000000..93c9baf2 --- /dev/null +++ b/client/src/components/Categories/index.tsx @@ -0,0 +1,36 @@ +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +import { FolderOutlined } from '@ant-design/icons'; + +import style from './index.module.scss'; + +export const Categories = ({ categories = [] }) => { + const t = useTranslations(); + + return ( +
    +
    + + {t('categoryTitle')} +
    + +
    + ); +}; diff --git a/client/src/components/Comment/CommentAction/CommentAction.tsx b/client/src/components/Comment/CommentAction/CommentAction.tsx new file mode 100644 index 00000000..c148b106 --- /dev/null +++ b/client/src/components/Comment/CommentAction/CommentAction.tsx @@ -0,0 +1,145 @@ +import { Divider, Input, message, Modal, notification, Popconfirm } from 'antd'; +import React, { useCallback, useState } from 'react'; + +import { useSetting } from '@/hooks/useSetting'; +import { CommentProvider } from '@/providers/comment'; +import { SettingProvider } from '@/providers/setting'; + +import style from './index.module.scss'; + +export const CommentAction = ({ comment, refresh }) => { + const setting = useSetting(); + const [replyContent, setReplyContent] = useState(null); + const [replyVisible, setReplyVisible] = useState(false); + + // 修改评论 + const updateComment = useCallback( + (comment, pass = false) => { + CommentProvider.updateComment(comment.id, { pass }).then(() => { + message.success(pass ? '评论已通过' : '评论已拒绝'); + refresh(); + }); + }, + [refresh] + ); + + const reply = useCallback(() => { + if (!replyContent) { + return; + } + const userInfo = JSON.parse(window.localStorage.getItem('user')); + const email = (userInfo && userInfo.mail) || (setting && setting.smtpFromUser); + const notify = () => { + notification.error({ + message: '回复评论失败', + description: '请前往系统设置完善 SMTP 设置,前往个人中心更新个人邮箱。', + }); + }; + + const handle = (email) => { + const data = { + name: userInfo.name, + email, + content: replyContent, + parentCommentId: comment.parentCommentId || comment.id, + hostId: comment.hostId, + isHostInPage: comment.isHostInPage, + replyUserName: comment.name, + replyUserEmail: comment.email, + url: comment.url, + createByAdmin: true, + }; + + CommentProvider.addComment(data) + .then(() => { + message.success('回复成功'); + setReplyContent(''); + refresh(); + }) + .catch(() => notify()); + }; + + if (!email) { + SettingProvider.getSetting() + .then((res) => { + if (res && res.smtpFromUser) { + handle(res.smtpFromUser); + } else { + notify(); + } + setReplyVisible(false); + }) + .catch(() => { + notify(); + setReplyVisible(false); + }); + } else { + handle(email); + setReplyVisible(false); + } + }, [ + replyContent, + comment.email, + comment.hostId, + comment.id, + comment.isHostInPage, + comment.name, + comment.parentCommentId, + comment.url, + refresh, + setting, + ]); + + // 删除评论 + const deleteComment = useCallback( + (id) => { + CommentProvider.deleteComment(id).then(() => { + message.success('评论删除成功'); + refresh(); + }); + }, + [refresh] + ); + + return ( +
    + + updateComment(comment, true)}>通过 + + updateComment(comment, false)}>拒绝 + + setReplyVisible(true)}>回复 + + deleteComment(comment.id)} + okText="确认" + cancelText="取消" + > + 删除 + + + setReplyVisible(false)} + transitionName={''} + maskTransitionName={''} + > + { + const val = e.target.value; + setReplyContent(val); + }} + > + +
    + ); +}; diff --git a/client/src/components/Comment/CommentAction/CommentArticle.tsx b/client/src/components/Comment/CommentAction/CommentArticle.tsx new file mode 100644 index 00000000..c7d34545 --- /dev/null +++ b/client/src/components/Comment/CommentAction/CommentArticle.tsx @@ -0,0 +1,21 @@ +import { Popover } from 'antd'; +import React from 'react'; + +import { useSetting } from '@/hooks/useSetting'; +import { resolveUrl } from '@/utils'; + +import style from './index.module.scss'; + +export const CommentArticle = ({ comment }) => { + const setting = useSetting(); + const { url: link } = comment; + const href = resolveUrl(setting?.systemUrl, link); + + return ( + } placement={'right'} mouseEnterDelay={0.5}> + + 文章 + + + ); +}; diff --git a/client/src/components/Comment/CommentAction/CommentContent.tsx b/client/src/components/Comment/CommentAction/CommentContent.tsx new file mode 100644 index 00000000..11cf73a9 --- /dev/null +++ b/client/src/components/Comment/CommentAction/CommentContent.tsx @@ -0,0 +1,25 @@ +import { Button, Popover } from 'antd'; +import React from 'react'; + +export const CommentContent = ({ comment }) => { + return ( + + + } + > + + + + ); +}; diff --git a/client/src/components/Comment/CommentAction/CommentHTML.tsx b/client/src/components/Comment/CommentAction/CommentHTML.tsx new file mode 100644 index 00000000..fd358130 --- /dev/null +++ b/client/src/components/Comment/CommentAction/CommentHTML.tsx @@ -0,0 +1,25 @@ +import { Button, Popover } from 'antd'; +import React from 'react'; + +export const CommentHTML = ({ comment }) => { + return ( + + + } + > + + + + ); +}; diff --git a/client/src/components/Comment/CommentAction/CommentStatus.tsx b/client/src/components/Comment/CommentAction/CommentStatus.tsx new file mode 100644 index 00000000..9588928d --- /dev/null +++ b/client/src/components/Comment/CommentAction/CommentStatus.tsx @@ -0,0 +1,6 @@ +import { Badge } from 'antd'; +import React from 'react'; + +export const CommentStatus = ({ comment }) => { + return ; +}; diff --git a/client/src/components/Comment/CommentAction/index.module.scss b/client/src/components/Comment/CommentAction/index.module.scss new file mode 100644 index 00000000..7bf73382 --- /dev/null +++ b/client/src/components/Comment/CommentAction/index.module.scss @@ -0,0 +1,7 @@ +.action a { + color: #1890ff; +} + +.link { + color: #1890ff; +} diff --git a/client/src/components/Comment/CommentEditor/Emoji/emojis.ts b/client/src/components/Comment/CommentEditor/Emoji/emojis.ts new file mode 100644 index 00000000..75a2633c --- /dev/null +++ b/client/src/components/Comment/CommentEditor/Emoji/emojis.ts @@ -0,0 +1,152 @@ +export const emojis = { + grinning: '😀', + smiley: '😃', + smile: '😄', + grin: '😁', + laughing: '😆', + satisfied: '😆', + sweat_smile: '😅', + joy: '😂', + wink: '😉', + blush: '😊', + innocent: '😇', + heart_eyes: '😍', + kissing_heart: '😘', + kissing: '😗', + kissing_closed_eyes: '😚', + kissing_smiling_eyes: '😙', + yum: '😋', + stuck_out_tongue: '😛', + stuck_out_tongue_winking_eye: '😜', + stuck_out_tongue_closed_eyes: '😝', + neutral_face: '😐', + expressionless: '😑', + no_mouth: '😶', + smirk: '😏', + unamused: '😒', + relieved: '😌', + pensive: '😔', + sleepy: '😪', + sleeping: '😴', + mask: '😷', + dizzy_face: '😵', + sunglasses: '😎', + confused: '😕', + worried: '😟', + open_mouth: '😮', + hushed: '😯', + astonished: '😲', + flushed: '😳', + frowning: '😦', + anguished: '😧', + fearful: '😨', + cold_sweat: '😰', + disappointed_relieved: '😥', + cry: '😢', + sob: '😭', + scream: '😱', + confounded: '😖', + persevere: '😣', + disappointed: '😞', + sweat: '😓', + weary: '😩', + tired_face: '😫', + rage: '😡', + pout: '😡', + angry: '😠', + smiling_imp: '😈', + smiley_cat: '😺', + smile_cat: '😸', + joy_cat: '😹', + heart_eyes_cat: '😻', + smirk_cat: '😼', + kissing_cat: '😽', + scream_cat: '🙀', + crying_cat_face: '😿', + pouting_cat: '😾', + heart: '❤️', + hand: '✋', + raised_hand: '✋', + v: '✌️', + point_up: '☝️', + fist_raised: '✊', + fist: '✊', + monkey_face: '🐵', + cat: '🐱', + cow: '🐮', + mouse: '🐭', + coffee: '☕', + hotsprings: '♨️', + anchor: '⚓', + airplane: '✈️', + hourglass: '⌛', + watch: '⌚', + sunny: '☀️', + star: '⭐', + cloud: '☁️', + umbrella: '☔', + zap: '⚡', + snowflake: '❄️', + sparkles: '✨', + black_joker: '🃏', + mahjong: '🀄', + phone: '☎️', + telephone: '☎️', + envelope: '✉️', + pencil2: '✏️', + black_nib: '✒️', + scissors: '✂️', + wheelchair: '♿', + warning: '⚠️', + aries: '♈', + taurus: '♉', + gemini: '♊', + cancer: '♋', + leo: '♌', + virgo: '♍', + libra: '♎', + scorpius: '♏', + sagittarius: '♐', + capricorn: '♑', + aquarius: '♒', + pisces: '♓', + heavy_multiplication_x: '✖️', + heavy_plus_sign: '➕', + heavy_minus_sign: '➖', + heavy_division_sign: '➗', + bangbang: '‼️', + interrobang: '⁉️', + question: '❓', + grey_question: '❔', + grey_exclamation: '❕', + exclamation: '❗', + heavy_exclamation_mark: '❗', + wavy_dash: '〰️', + recycle: '♻️', + white_check_mark: '✅', + ballot_box_with_check: '☑️', + heavy_check_mark: '✔️', + x: '❌', + negative_squared_cross_mark: '❎', + curly_loop: '➰', + loop: '➿', + part_alternation_mark: '〽️', + eight_spoked_asterisk: '✳️', + eight_pointed_black_star: '✴️', + sparkle: '❇️', + copyright: '©️', + registered: '®️', + tm: '™️', + information_source: 'ℹ️', + m: 'Ⓜ️', + black_circle: '⚫', + white_circle: '⚪', + black_large_square: '⬛', + white_large_square: '⬜', + black_medium_square: '◼️', + white_medium_square: '◻️', + black_medium_small_square: '◾', + white_medium_small_square: '◽', + black_small_square: '▪️', + white_small_square: '▫️', +}; diff --git a/client/src/components/Comment/CommentEditor/Emoji/index.module.scss b/client/src/components/Comment/CommentEditor/Emoji/index.module.scss new file mode 100644 index 00000000..04887ca9 --- /dev/null +++ b/client/src/components/Comment/CommentEditor/Emoji/index.module.scss @@ -0,0 +1,36 @@ +.wrapper { + display: flex; + display: flex; + width: 390px; + height: 240px; + overflow: auto; + flex-wrap: wrap; + flex-wrap: wrap; + + li { + position: relative; + display: flex; + width: 32px; + height: 32px; + font-size: 18px; + cursor: pointer; + align-items: center; + justify-content: center; + } +} + +.text { + display: flex; + align-items: center; + color: var(--disable-text-color); + cursor: pointer; + + &:hover { + color: var(--primary-color); + } + + > span { + margin-left: 4px; + transform: translateY(1px); + } +} diff --git a/client/src/components/Comment/CommentEditor/Emoji/index.tsx b/client/src/components/Comment/CommentEditor/Emoji/index.tsx new file mode 100644 index 00000000..5843f2a4 --- /dev/null +++ b/client/src/components/Comment/CommentEditor/Emoji/index.tsx @@ -0,0 +1,27 @@ +import { Popover } from 'antd'; +import React from 'react'; + +import { emojis } from './emojis'; +import styles from './index.module.scss'; + +export const Emoji: React.FC<{ onClickEmoji: (arg: string) => void }> = ({ onClickEmoji, children }) => { + return ( + + {Object.keys(emojis).map((key) => { + return ( +
  • onClickEmoji(emojis[key])}> + {emojis[key]} +
  • + ); + })} + + } + placement="bottomRight" + trigger="click" + > + {children} +
    + ); +}; diff --git a/client/src/components/Comment/CommentEditor/index.module.scss b/client/src/components/Comment/CommentEditor/index.module.scss new file mode 100644 index 00000000..b408edc3 --- /dev/null +++ b/client/src/components/Comment/CommentEditor/index.module.scss @@ -0,0 +1,60 @@ +.wrapper { + main { + display: flex; + flex-wrap: nowrap; + } + + .textareaWrapper { + position: relative; + flex: 1; + margin-left: 16px; + + .mask { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + cursor: pointer; + } + + textarea { + border-color: var(--comment-editor-border-color); + color: var(--main-text-color); + background-color: var(--bg-second); + box-shadow: none !important; + } + } + + > footer { + display: flex; + justify-content: space-between; + padding-top: 8px; + padding-left: 44px; + + :global { + .ant-btn-primary[disabled] { + border-color: var(--comment-editor-border-color); + color: var(--main-text-color); + background-color: var(--comment-editor-disable-bg); + } + } + + .emojiTrigger { + display: flex; + align-items: center; + color: var(--disable-text-color); + cursor: pointer; + + &:hover { + color: var(--primary-color); + } + + > span { + margin-left: 4px; + transform: translateY(1px); + } + } + } +} diff --git a/client/src/components/Comment/CommentEditor/index.tsx b/client/src/components/Comment/CommentEditor/index.tsx new file mode 100644 index 00000000..7b9a0304 --- /dev/null +++ b/client/src/components/Comment/CommentEditor/index.tsx @@ -0,0 +1,152 @@ +import { Button, Input, message } from 'antd'; +import cls from 'classnames'; +import { useTranslations } from 'next-intl'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; + +import { isValidUser, UserInfo } from '@/components/UserInfo'; +import { GlobalContext } from '@/context/global'; +import { useAsyncLoading } from '@/hooks/useAsyncLoading'; +import { useToggle } from '@/hooks/useToggle'; +import { CommentProvider } from '@/providers/comment'; + +import { Emoji } from './Emoji'; +import styles from './index.module.scss'; +import { default as Router } from 'next/router'; + +const { TextArea } = Input; + +interface Props { + hostId: string; + parentComment?: IComment; + replyComment?: IComment; + onOk?: () => void; + onClose?: () => void; + small?: boolean; +} + +export const CommentEditor: React.FC = ({ hostId, parentComment, replyComment, onOk, onClose, small }) => { + const t = useTranslations('commentNamespace'); + const { user } = useContext(GlobalContext); + const [addComment, loading] = useAsyncLoading(CommentProvider.addComment); + const [needSetInfo, toggleNeedSetInfo] = useToggle(false); + const [content, setContent] = useState(''); + // @ts-ignore + const hasValidUser = useMemo(() => isValidUser(user), [user]); + const textareaPlaceholder = useMemo( + () => (replyComment ? `${t('reply')} ${replyComment.name}` : t('replyPlaceholder')), + [t, replyComment] + ); + const textareaSize = useMemo(() => (small ? { minRows: 3, maxRows: 6 } : { minRows: 4, maxRows: 8 }), [small]); + const btnSize = useMemo(() => (small ? 'small' : 'middle'), [small]); + const emojiTrigger = ( + + + + + {t('emoji')} + + ); + + const onInput = useCallback( + (e) => { + if (!hasValidUser) { + return; + } + setContent(e.target.value); + }, + [hasValidUser] + ); + + const addEmoji = useCallback( + (emoji) => { + if (!hasValidUser) { + return; + } + setContent(`${content}${emoji}`); + }, + [content, hasValidUser] + ); + + const submit = useCallback(() => { + const data = { + hostId, + name: user.name, + email: user.email, + avatar: user.avatar || '', + content, + url: window.location.pathname, + }; + + if (parentComment && parentComment.id) { + Object.assign(data, { parentCommentId: parentComment.id }); + } + + if (replyComment) { + Object.assign(data, { + replyUserName: replyComment.name, + replyUserEmail: replyComment.email, + }); + } + + addComment(data).then(() => { + message.success(t('commentSuccess')); + setContent(''); + onOk && onOk(); + }); + }, [t, hostId, parentComment, replyComment, onOk, user, content, addComment]); + + return ( +
    +
    + +
    + {!hasValidUser && ( +
    { + if (user) { + message.warning(t('toggleNeedSetInfo')); + Router.push('/admin/ownspace'); + } else { + toggleNeedSetInfo(true); + } + }} + >
    + )} +