From 5e520fdb433315952b3d13a768a822dcf6bb5faf Mon Sep 17 00:00:00 2001 From: m0_37981569 Date: Sat, 11 Jul 2026 09:36:41 +0800 Subject: [PATCH 01/33] refactor: merge master --- .env | 17 +- .eslintrc.js | 73 +- .github/workflows/ci.yml | 15 +- .github/workflows/deploy-ecs.yml | 110 + .github/workflows/release-package.yml | 41 +- .gitignore | 32 +- .npmrc | 2 +- .nvmrc | 1 + .prettierrc | 2 +- .vscode/settings.json | 28 + ARCHITECTURE.md | 1107 + CHANGELOG.md | 25 + CONTRIBUTING.md | 28 +- README-zh_CN.md | 543 +- README.md | 531 +- cli/README.md | 74 +- cli/bin/reactpress-cli-shim.js | 8 +- cli/bin/reactpress-theme-client.js | 2 + cli/bin/reactpress.js | 381 +- cli/lib/dev-banner.js | 72 - cli/lib/dev.js | 141 - cli/lib/docker.js | 275 - cli/lib/nginx.js | 342 - cli/lib/publish.js | 968 - cli/package.json | 24 +- cli/scripts/sync-bundled-core.mjs | 2 +- cli/server/package.json | 2 +- cli/server/public/favicon.png | Bin 1489 -> 33057 bytes .../2026-05-30/QR3X4UZQ93ZTUPGXCFA9II..png | Bin .../2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp | Bin 0 -> 28804 bytes .../BXQV3NX9ZVWHGNOSX9AXD8_medium.webp | Bin 0 -> 23644 bytes .../BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp | Bin 0 -> 6532 bytes .../2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg | Bin 0 -> 22388 bytes cli/src/bin/reactpress-cli-shim.ts | 29 + cli/src/bin/reactpress-theme-client.ts | 122 + cli/src/bin/reactpress.ts | 599 + cli/src/core/services/config.ts | 144 + cli/src/core/services/database/index.ts | 313 + cli/src/core/services/database/mysql.ts | 92 + cli/src/core/services/database/profile.ts | 58 + cli/src/core/services/database/sqlite.ts | 107 + cli/src/core/services/exec.ts | 70 + cli/src/core/services/init.ts | 76 + cli/src/core/services/local-site.ts | 180 + cli/src/core/utils/cli-context.ts | 9 + cli/src/core/utils/paths.ts | 44 + cli/src/core/utils/platform.ts | 25 + cli/src/core/utils/port.ts | 30 + .../lib/api-dev-runner.ts} | 1 + cli/{lib/api-dev.js => src/lib/api-dev.ts} | 35 +- .../bootstrap.js => src/lib/bootstrap.ts} | 70 +- cli/{lib/build.js => src/lib/build.ts} | 89 +- cli/src/lib/context-status.ts | 157 + cli/src/lib/database-mode.ts | 41 + .../db-backup.js => src/lib/db-backup.ts} | 1 + cli/src/lib/dev-banner.ts | 159 + cli/src/lib/dev-child-io.ts | 83 + cli/src/lib/dev-log.ts | 68 + cli/src/lib/dev-session.ts | 120 + cli/src/lib/dev.ts | 1112 + cli/src/lib/docker.ts | 434 + cli/{lib/doctor.js => src/lib/doctor.ts} | 21 +- cli/src/lib/health-parse.test.ts | 37 + cli/src/lib/health-parse.ts | 36 + cli/{lib/http.js => src/lib/http.ts} | 81 +- .../i18n/index.js => src/lib/i18n/index.ts} | 6 +- .../strings.js => src/lib/i18n/strings.ts} | 512 +- .../lifecycle.js => src/lib/lifecycle.ts} | 1 + cli/src/lib/nginx.ts | 661 + cli/{lib/paths.js => src/lib/paths.ts} | 55 +- cli/src/lib/plugin-build.ts | 97 + cli/src/lib/plugin-cli.ts | 144 + cli/{lib/pm2.js => src/lib/pm2.ts} | 1 + cli/src/lib/ports.ts | 561 + cli/{lib/process.js => src/lib/process.ts} | 1 + cli/src/lib/prod-memory.ts | 55 + .../lib/project-type.ts} | 33 +- cli/src/lib/publish.ts | 424 + cli/src/lib/remote-dev.ts | 177 + cli/{lib/root.js => src/lib/root.ts} | 1 + cli/{lib/spawn.js => src/lib/spawn.ts} | 22 +- cli/{lib/status.js => src/lib/status.ts} | 1 + cli/src/lib/theme-catalog.ts | 3 + cli/src/lib/theme-cli.ts | 54 + cli/src/lib/theme-dev.ts | 844 + cli/src/lib/theme-env.ts | 112 + cli/src/lib/theme-install.ts | 379 + cli/src/lib/theme-lock.ts | 72 + cli/src/lib/theme-paths.ts | 82 + cli/src/lib/theme-placeholder-cover.ts | 102 + cli/src/lib/theme-preview-frame.ts | 113 + cli/src/lib/theme-preview-pool.ts | 570 + cli/src/lib/theme-preview-proxy.ts | 117 + cli/src/lib/theme-prod.ts | 439 + cli/src/lib/theme-registry.ts | 151 + cli/src/lib/theme-runtime.ts | 377 + cli/src/lib/theme-sources.ts | 377 + cli/src/lib/theme-warmup.ts | 172 + cli/src/lib/toolkit-build.ts | 54 + cli/src/types/config.ts | 64 + cli/src/ui/banner-startup.ts | 139 + cli/src/ui/banner.ts | 558 + cli/src/ui/interactive.ts | 461 + cli/{ui/theme.js => src/ui/theme.ts} | 83 +- cli/templates/config.default.json | 7 +- cli/templates/config.local.json | 13 + cli/templates/env.local.default | 10 + cli/templates/theme-catalog.json | 27 + cli/tests/banner.test.js | 174 + cli/tests/build.test.js | 25 +- cli/tests/cli-version.test.js | 27 + cli/tests/context-status.test.js | 63 + cli/tests/database-fallback.test.js | 80 + cli/tests/dev-auto-local.test.js | 46 + cli/tests/docker.test.js | 2 +- cli/tests/http.test.js | 14 +- cli/tests/i18n.test.js | 8 +- cli/tests/nginx.test.js | 42 +- cli/tests/parity-pack.test.js | 14 +- cli/tests/ports.test.js | 67 + cli/tests/project-type.test.js | 2 +- cli/tests/publish-version.test.js | 8 +- cli/tests/remote-dev.test.js | 98 + cli/tests/root.test.js | 2 +- cli/tests/sqlite-path.test.js | 36 + cli/tests/theme-catalog.test.js | 13 + cli/tests/theme-dev-watch.test.js | 75 + cli/tests/theme-install.test.js | 63 + cli/tests/theme-paths.test.js | 27 + cli/tests/theme-preview-frame.test.js | 93 + cli/tests/theme-preview-pool.test.js | 90 + cli/tests/theme-registry.test.js | 89 + cli/tests/theme-warmup.test.js | 55 + cli/tests/toolkit-build.test.js | 50 + cli/tsconfig.json | 21 + cli/ui/banner.js | 441 - cli/ui/interactive.js | 380 - client/Dockerfile | 38 - client/README.md | 341 - client/bin/reactpress-client.js | 190 - client/next-env.d.ts | 5 - client/next-sitemap.js | 10 - client/next.config.js | 67 - client/package.json | 91 - client/pages/404.tsx | 9 - client/pages/_app.tsx | 179 - client/pages/_document.tsx | 40 - client/pages/_error.tsx | 69 - .../admin/article/category/index.module.scss | 46 - client/pages/admin/article/category/index.tsx | 169 - client/pages/admin/article/editor/[id].tsx | 27 - client/pages/admin/article/editor/index.tsx | 13 - client/pages/admin/article/index.module.scss | 14 - client/pages/admin/article/index.tsx | 350 - .../admin/article/tags/index.module.scss | 46 - client/pages/admin/article/tags/index.tsx | 170 - client/pages/admin/comment/index.tsx | 223 - client/pages/admin/file/index.module.scss | 86 - client/pages/admin/file/index.tsx | 244 - client/pages/admin/index.module.scss | 122 - client/pages/admin/index.tsx | 209 - client/pages/admin/knowledge/editor/[id].tsx | 270 - .../admin/knowledge/editor/index.module.scss | 52 - .../pages/admin/knowledge/index.module.scss | 25 - client/pages/admin/knowledge/index.tsx | 159 - client/pages/admin/mail/index.module.scss | 5 - client/pages/admin/mail/index.tsx | 197 - client/pages/admin/ownspace/index.module.scss | 7 - client/pages/admin/ownspace/index.tsx | 221 - client/pages/admin/page/editor/[id].tsx | 22 - client/pages/admin/page/editor/index.tsx | 9 - client/pages/admin/page/index.module.scss | 9 - client/pages/admin/page/index.tsx | 287 - client/pages/admin/search/index.module.scss | 25 - client/pages/admin/search/index.tsx | 122 - client/pages/admin/setting/index.module.scss | 11 - client/pages/admin/setting/index.tsx | 85 - client/pages/admin/user/index.module.scss | 25 - client/pages/admin/user/index.tsx | 211 - client/pages/admin/view/index.module.scss | 29 - client/pages/admin/view/index.tsx | 191 - client/pages/archives/index.module.scss | 122 - client/pages/archives/index.tsx | 116 - client/pages/article/[id].tsx | 220 - client/pages/article/index.module.scss | 139 - client/pages/category/[category].tsx | 121 - client/pages/index.module.scss | 63 - client/pages/index.tsx | 138 - .../knowledge/[pId]/[id]/index.module.scss | 176 - client/pages/knowledge/[pId]/[id]/index.tsx | 214 - .../pages/knowledge/[pId]/index.module.scss | 185 - client/pages/knowledge/[pId]/index.tsx | 157 - client/pages/knowledge/index.tsx | 86 - client/pages/login/index.module.scss | 4 - client/pages/login/index.tsx | 45 - client/pages/nav/[id].tsx | 167 - client/pages/nav/index.module.scss | 167 - client/pages/nav/index.tsx | 59 - client/pages/page/[id].tsx | 79 - client/pages/page/index.module.scss | 93 - client/pages/rss/index.tsx | 55 - client/pages/tag/[tag].tsx | 119 - client/public/favicon.ico | Bin 129948 -> 0 bytes client/public/logo.png | Bin 24108 -> 0 bytes client/public/robots.txt | 10 - client/public/vercel.svg | 4 - client/server.js | 29 - client/src/assets/LogoSvg.tsx | 12 - .../src/components/AboutUs/index.module.scss | 79 - client/src/components/AboutUs/index.tsx | 143 - .../AdvanceSearch/index.module.scss | 129 - client/src/components/AdvanceSearch/index.tsx | 151 - client/src/components/Analytics/index.tsx | 49 - client/src/components/Animation/Opacity.tsx | 11 - client/src/components/Animation/Spring.tsx | 41 - client/src/components/Animation/Trail.tsx | 36 - .../src/components/Animation/Transition.tsx | 22 - .../ArticleCarousel/index.module.scss | 85 - .../src/components/ArticleCarousel/index.tsx | 49 - .../ArticleSettingDrawer/index.module.scss | 38 - .../ArticleSettingDrawer/index.tsx | 212 - .../ArticleEditor/index.module.scss | 31 - client/src/components/ArticleEditor/index.tsx | 247 - .../components/ArticleList/index.module.scss | 265 - client/src/components/ArticleList/index.tsx | 160 - .../ArticleRecommend/index.module.scss | 152 - .../src/components/ArticleRecommend/index.tsx | 82 - .../components/Categories/index.module.scss | 58 - client/src/components/Categories/index.tsx | 36 - .../Comment/CommentAction/CommentAction.tsx | 145 - .../Comment/CommentAction/CommentArticle.tsx | 21 - .../Comment/CommentAction/CommentContent.tsx | 25 - .../Comment/CommentAction/CommentHTML.tsx | 25 - .../Comment/CommentAction/CommentStatus.tsx | 6 - .../Comment/CommentAction/index.module.scss | 7 - .../Comment/CommentEditor/Emoji/emojis.ts | 152 - .../CommentEditor/Emoji/index.module.scss | 36 - .../Comment/CommentEditor/Emoji/index.tsx | 27 - .../Comment/CommentEditor/index.module.scss | 60 - .../Comment/CommentEditor/index.tsx | 152 - .../Comment/CommentIcon.module.scss | 21 - client/src/components/Comment/CommentIcon.tsx | 23 - .../Comment/CommentItem/index.module.scss | 81 - .../components/Comment/CommentItem/index.tsx | 144 - .../src/components/Comment/index.module.scss | 28 - client/src/components/Comment/index.tsx | 67 - client/src/components/Copy/index.module.scss | 14 - client/src/components/Copy/index.tsx | 34 - .../src/components/Editor/DefaultMarkdown.ts | 79 - client/src/components/Editor/MonacoEditor.tsx | 205 - client/src/components/Editor/Preview.tsx | 43 - .../src/components/Editor/index.module.scss | 99 - client/src/components/Editor/index.tsx | 190 - .../src/components/Editor/toolbar/AddCode.tsx | 43 - .../src/components/Editor/toolbar/Emoji.tsx | 51 - client/src/components/Editor/toolbar/File.tsx | 49 - .../src/components/Editor/toolbar/Iframe.tsx | 45 - .../src/components/Editor/toolbar/Image.tsx | 49 - .../src/components/Editor/toolbar/Magimg.tsx | 66 - .../src/components/Editor/toolbar/Video.tsx | 49 - .../src/components/Editor/toolbar/emojis.ts | 152 - .../src/components/Editor/toolbar/index.tsx | 61 - client/src/components/Editor/utils/modal.tsx | 20 - .../FileSelectDrawer/index.module.scss | 15 - .../src/components/FileSelectDrawer/index.tsx | 121 - .../FixAntdStyleTransition/index.tsx | 20 - .../src/components/Footer/index.module.scss | 74 - client/src/components/Footer/index.tsx | 72 - .../src/components/Header/index.module.scss | 262 - client/src/components/Header/index.tsx | 250 - client/src/components/ImageViewer/index.tsx | 26 - client/src/components/JsonEditor/index.tsx | 85 - .../KnowledgeList/index.module.scss | 156 - client/src/components/KnowledgeList/index.tsx | 66 - .../KnowledgeSettingDrawer/index.tsx | 119 - client/src/components/Likes/index.module.scss | 21 - client/src/components/Likes/index.tsx | 49 - client/src/components/LocaleTime/index.tsx | 56 - client/src/components/Locales/index.tsx | 45 - .../MarkdownReader/index.module.scss | 25 - .../src/components/MarkdownReader/index.tsx | 86 - client/src/components/NProgress/index.tsx | 33 - client/src/components/NavCard/Category.tsx | 58 - client/src/components/NavCard/NavCard.tsx | 65 - .../src/components/NavCard/index.module.scss | 56 - client/src/components/NavCard/index.tsx | 32 - .../components/PageEditor/index.module.scss | 65 - client/src/components/PageEditor/index.tsx | 250 - .../components/Pagination/index.module.scss | 4 - client/src/components/Pagination/index.tsx | 37 - .../PaginationTable/index.module.scss | 11 - .../src/components/PaginationTable/index.tsx | 130 - .../src/components/Search/index.module.scss | 73 - client/src/components/Search/index.tsx | 105 - .../components/SearchHeader/index.module.scss | 37 - client/src/components/SearchHeader/index.tsx | 87 - client/src/components/Seo/index.tsx | 24 - .../Setting/AnalyticsSetting/index.tsx | 51 - .../Setting/GlobalSetting/index.tsx | 118 - .../Setting/LocaleSetting/index.tsx | 113 - .../components/Setting/OSSSetting/index.tsx | 84 - .../components/Setting/SEOSetting/index.tsx | 52 - .../components/Setting/SMTPSetting/index.tsx | 101 - .../SystemNotification/index.module.scss | 33 - .../Setting/SystemNotification/index.tsx | 30 - .../Setting/SystemSetting/index.tsx | 182 - .../src/components/TagCloud/index.module.scss | 29 - client/src/components/TagCloud/index.tsx | 25 - client/src/components/TagCloud/tag.ts | 208 - client/src/components/Tags/index.module.scss | 67 - client/src/components/Tags/index.tsx | 73 - client/src/components/Theme/index.module.scss | 9 - client/src/components/Theme/index.tsx | 70 - client/src/components/Toc/index.module.scss | 89 - client/src/components/Toc/index.tsx | 103 - client/src/components/Upload/index.tsx | 48 - .../src/components/UserInfo/index.module.scss | 35 - client/src/components/UserInfo/index.tsx | 274 - client/src/components/ViewChart/index.tsx | 94 - .../src/components/ViewStatistics/index.tsx | 24 - client/src/constants/index.tsx | 22 - client/src/context/global.tsx | 41 - client/src/hooks/useAsyncLoading.ts | 36 - client/src/hooks/useForceUpdate.ts | 11 - client/src/hooks/usePagination.ts | 90 - client/src/hooks/useSetting.ts | 8 - client/src/hooks/useToggle.ts | 15 - client/src/hooks/useUser.ts | 8 - client/src/hooks/useWarningOnExit.ts | 57 - .../src/layout/AdminLayout/ResourceCreate.tsx | 54 - .../src/layout/AdminLayout/index.module.scss | 147 - client/src/layout/AdminLayout/index.tsx | 148 - client/src/layout/AdminLayout/menus.tsx | 188 - client/src/layout/AppLayout/index.module.scss | 29 - client/src/layout/AppLayout/index.tsx | 53 - .../DoubleColumnLayout/index.module.scss | 109 - .../src/layout/DoubleColumnLayout/index.tsx | 116 - client/src/providers/article.ts | 112 - client/src/providers/category.ts | 43 - client/src/providers/comment.ts | 51 - client/src/providers/file.ts | 33 - client/src/providers/http.ts | 89 - client/src/providers/knowledge.ts | 69 - client/src/providers/mail.ts | 14 - client/src/providers/page.ts | 61 - client/src/providers/poster.ts | 7 - client/src/providers/search.ts | 17 - client/src/providers/setting.ts | 17 - client/src/providers/smtp.ts | 15 - client/src/providers/tag.ts | 58 - client/src/providers/user.ts | 51 - client/src/providers/view.ts | 26 - client/src/rss/index.js | 200 - client/src/theme/index.scss | 3 - client/src/theme/markdown.scss | 348 - client/src/theme/reset.scss | 136 - client/src/theme/var.scss | 71 - client/src/utils/copy.ts | 11 - client/src/utils/index.tsx | 257 - client/src/utils/json.ts | 15 - client/src/utils/jsonp.ts | 48 - client/src/utils/login.ts | 8 - client/tsconfig.json | 29 - client/types/index.d.ts | 376 - desktop/README.md | 160 + desktop/docs/size-optimization.md | 90 + desktop/electron-builder.yml | 34 + desktop/electron.vite.config.ts | 36 + desktop/package.json | 84 + desktop/resources/icon.png | Bin 0 -> 25009 bytes desktop/scripts/bootstrap-local-api.cjs | 89 + desktop/scripts/build-desktop.mjs | 163 + desktop/scripts/build-freshness.mjs | 554 + desktop/scripts/cache-dir.mjs | 22 + desktop/scripts/dev-full.mjs | 36 + desktop/scripts/ensure-electron.mjs | 56 + desktop/scripts/launch-dev.mjs | 48 + desktop/scripts/prepare-app-resources.mjs | 451 + desktop/scripts/prepare-macos-dev-app.mjs | 64 + desktop/scripts/prune-bundle.mjs | 164 + desktop/scripts/run-release-dir.mjs | 54 + desktop/src/main/app-icon.ts | 38 + desktop/src/main/config.ts | 140 + desktop/src/main/index.ts | 111 + desktop/src/main/ipc.ts | 115 + desktop/src/main/local-server.ts | 219 + desktop/src/main/local-site.ts | 205 + desktop/src/main/local-theme.ts | 649 + desktop/src/main/packaged-runtime.ts | 75 + desktop/src/main/system-log.ts | 169 + desktop/src/main/window.ts | 83 + desktop/src/preload/index.ts | 19 + desktop/src/shared/constants.ts | 21 + desktop/src/shared/resolve-port.ts | 51 + desktop/src/shared/types.ts | 33 + desktop/tsconfig.json | 14 + desktop/tsconfig.node.json | 13 + docker-compose.dev.yml | 7 +- docker-compose.prod.low-mem.yml | 24 + docker-compose.prod.yml | 39 +- docs/blog/changelog.md | 21 + docs/blog/tags.yml | 4 +- docs/docusaurus.config.ts | 51 +- docs/i18n/en/code.json | 71 +- .../options.json | 2 +- .../docusaurus-plugin-content-blog/tags.yml | 4 +- .../current.json | 6 +- .../current/intro.md | 135 +- .../current/tutorial-basics/_category_.json | 2 +- .../current/tutorial-basics/create-a-post.md | 5 +- .../tutorial-basics/deploy-your-site.md | 103 +- .../current/tutorial-basics/start.md | 50 +- .../current/tutorial-extras/_category_.json | 2 +- .../tutorial-extras/docker-deployment.md | 2 +- .../tutorial-extras/migration-3-to-4.md | 60 + .../current/tutorial-extras/reactpress-3-0.md | 4 +- .../current/tutorial-extras/reactpress-4-0.md | 110 + docs/i18n/zh/code.json | 36 +- .../changelog.md | 25 + .../options.json | 2 +- docs/migration-3-to-4.md | 96 + docs/package.json | 16 +- docs/src/components/Card/index.tsx | 2 +- docs/src/components/CliCommandBlock/index.tsx | 63 +- .../CliCommandBlock/useCliTypewriter.ts | 4 +- docs/src/components/Features/index.tsx | 7 +- .../components/Home/CallToAction/index.tsx | 7 +- docs/src/components/Home/Hero/Devices.tsx | 486 +- .../components/Home/Hero/FloorBackground.tsx | 10 +- .../components/Home/Hero/GridBackground.tsx | 94 +- docs/src/components/Home/Hero/index.tsx | 33 +- docs/src/components/Home/Highlights/index.tsx | 36 +- docs/src/components/Home/index.tsx | 5 +- docs/src/components/Section/index.tsx | 2 +- docs/src/components/SectionTitle/index.tsx | 12 +- docs/src/components/ThemeImage/index.tsx | 16 +- docs/src/constants/quickStartCommands.ts | 22 +- docs/src/pages/index.tsx | 8 +- docs/theme-system-review.md | 382 + docs/tsconfig.eslint.json | 19 + docs/tutorial/intro.md | 54 +- docs/tutorial/tutorial-basics/_category_.json | 2 +- .../tutorial/tutorial-basics/create-a-post.md | 6 +- .../tutorial-basics/deploy-your-site.md | 22 +- docs/tutorial/tutorial-basics/start.md | 68 +- docs/tutorial/tutorial-extras/_category_.json | 3 +- docs/tutorial/tutorial-extras/config-intro.md | 6 +- .../tutorial-extras/docker-deployment.md | 12 +- .../tutorial-extras/migration-3-to-4.md | 60 + .../tutorial-extras/reactpress-3-0.md | 7 +- .../tutorial-extras/reactpress-4-0.md | 110 + package.json | 70 +- plugins/README.md | 402 + plugins/hello-world/README.md | 104 + plugins/hello-world/locales/en.json | 24 + plugins/hello-world/locales/zh.json | 24 + plugins/hello-world/package.json | 20 + plugins/hello-world/plugin.json | 61 + plugins/hello-world/src/config.ts | 18 + plugins/hello-world/src/index.ts | 23 + plugins/hello-world/src/summary.ts | 36 + plugins/hello-world/src/types.ts | 17 + plugins/hello-world/tsconfig.json | 16 + plugins/image-optimizer/README.md | 43 + .../admin/OptimizeDashboard.tsx | 249 + plugins/image-optimizer/admin/api.ts | 133 + plugins/image-optimizer/admin/index.ts | 16 + plugins/image-optimizer/admin/locale.ts | 68 + .../admin/optimize-dashboard.module.css | 80 + plugins/image-optimizer/locales/en.json | 51 + plugins/image-optimizer/locales/zh.json | 53 + plugins/image-optimizer/package.json | 20 + plugins/image-optimizer/plugin.json | 58 + plugins/image-optimizer/src/config.ts | 19 + plugins/image-optimizer/src/index.ts | 16 + plugins/image-optimizer/tsconfig.json | 16 + plugins/package.json | 14 + plugins/plugin.manifest.schema.json | 110 + plugins/seo/README.md | 60 + plugins/seo/admin/ArticleEditorSeoPanel.tsx | 67 + .../admin/article-editor-seo-panel.module.css | 74 + plugins/seo/admin/index.ts | 18 + plugins/seo/locales/en.json | 32 + plugins/seo/locales/zh.json | 32 + plugins/seo/package.json | 20 + plugins/seo/plugin.json | 76 + plugins/seo/src/config.ts | 20 + plugins/seo/src/index.ts | 31 + plugins/seo/src/seo.ts | 84 + plugins/seo/src/slug.ts | 10 + plugins/seo/src/types.ts | 21 + plugins/seo/tsconfig.json | 16 + pnpm-lock.yaml | 30271 ++++++++-------- pnpm-workspace.yaml | 14 +- public/README.md | 26 +- public/desktop.gif | Bin 0 -> 1087767 bytes public/desktop.png | Bin 0 -> 590623 bytes scripts/apply-release.sh | 36 + scripts/apply-theme-build.sh | 24 + scripts/brand-assets.mjs | 32 +- scripts/build.sh | 78 + scripts/bundled-server-path.js | 2 - scripts/clean.mjs | 100 + scripts/deploy.sh | 104 +- scripts/dev-smoke-nginx.mjs | 132 + scripts/dev-themes.mjs | 38 + scripts/docker-dev.js | 6 - scripts/export-brand-assets.mjs | 12 + scripts/install.sh | 543 - scripts/package-theme-build.sh | 37 + scripts/publish-3.7.0.sh | 78 - scripts/reactpress-api-dev.js | 4 - scripts/reactpress-api-lifecycle.js | 7 - scripts/reactpress-api-pm2.js | 4 - scripts/reactpress-bootstrap.js | 5 - scripts/reactpress-cli.js | 3 - scripts/reactpress-dev.js | 4 - scripts/reactpress-publish.js | 6 - scripts/seed-legacy-media.mjs | 190 + scripts/smoke-api-health.mjs | 28 +- scripts/smoke-dev-web-local.mjs | 227 + scripts/sync-theme-catalog.mjs | 40 + scripts/test-theme-flow.mjs | 188 + scripts/verify-build-artifacts.mjs | 63 + server/.eslintrc.js | 4 +- server/Dockerfile | 33 +- server/README.md | 362 +- server/ecosystem.config.js | 9 +- server/nest-cli.json | 3 +- server/package.json | 19 +- .../2026-05-30/QR3X4UZQ93ZTUPGXCFA9II..png | Bin .../2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp | Bin 0 -> 28804 bytes .../BXQV3NX9ZVWHGNOSX9AXD8_medium.webp | Bin 0 -> 23644 bytes .../BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp | Bin 0 -> 6532 bytes .../2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg | Bin 0 -> 22388 bytes server/scripts/ensure-dev-dist.js | 27 + server/src/app.module.ts | 55 +- server/src/common/api-messages.ts | 10 + server/src/database/typeorm-options.ts | 62 + server/src/generate-swagger.ts | 37 +- .../src/interceptors/transform.interceptor.ts | 23 +- server/src/logger/index.ts | 9 +- server/src/main.ts | 156 +- server/src/modules/api-key/api-key.guard.ts | 5 +- server/src/modules/api-key/api-key.service.ts | 2 +- .../article/article-revision.entity.ts | 6 +- .../src/modules/article/article-slug.util.ts | 15 + .../src/modules/article/article.controller.ts | 7 +- server/src/modules/article/article.entity.ts | 40 +- server/src/modules/article/article.module.ts | 9 +- server/src/modules/article/article.service.ts | 126 +- server/src/modules/auth/auth.service.ts | 2 +- server/src/modules/auth/jwt-auth.guard.ts | 3 +- server/src/modules/auth/jwt.strategy.ts | 2 +- .../modules/bootstrap/bootstrap.constants.ts | 187 + .../modules/bootstrap/bootstrap.service.ts | 186 + .../src/modules/bootstrap/install-locale.ts | 9 + .../src/modules/category/category.entity.ts | 2 +- .../src/modules/category/category.service.ts | 2 +- server/src/modules/comment/comment.entity.ts | 6 +- server/src/modules/comment/comment.module.ts | 2 +- server/src/modules/comment/comment.service.ts | 31 +- server/src/modules/comment/html.ts | 8 +- .../src/modules/extension/extension.module.ts | 35 + .../extension/plugin-loader.service.ts | 92 + .../extension/plugin-registry.bridge.ts | 44 + .../modules/extension/plugin.controller.ts | 93 + .../src/modules/extension/plugin.service.ts | 443 + .../modules/extension/preview-draft.store.ts | 47 + .../extension/theme-registry.bridge.ts | 85 + .../src/modules/extension/theme.controller.ts | 203 + server/src/modules/extension/theme.service.ts | 1226 + .../file/file-optimization.controller.ts | 45 + .../modules/file/file-optimization.service.ts | 444 + .../modules/file/file-optimization.types.ts | 72 + server/src/modules/file/file.controller.ts | 6 +- server/src/modules/file/file.entity.ts | 13 + server/src/modules/file/file.module.ts | 20 +- server/src/modules/file/file.service.ts | 109 +- .../file/image-optimizer-plugin.guard.ts | 17 + .../src/modules/health/health.controller.ts | 6 +- server/src/modules/hook/hook.module.ts | 10 + server/src/modules/hook/hook.service.ts | 141 + .../src/modules/install/install.controller.ts | 20 - server/src/modules/install/install.module.ts | 14 - server/src/modules/install/install.service.ts | 53 - .../src/modules/knowledge/knowledge.entity.ts | 10 +- .../modules/knowledge/knowledge.service.ts | 2 +- server/src/modules/page/page.entity.ts | 10 +- server/src/modules/page/page.service.ts | 8 +- .../src/modules/setting/setting.constant.ts | 26 +- server/src/modules/setting/setting.entity.ts | 12 + server/src/modules/setting/setting.service.ts | 253 +- server/src/modules/smtp/mail.util.ts | 25 +- server/src/modules/smtp/smtp.controller.ts | 11 + server/src/modules/smtp/smtp.service.ts | 74 +- server/src/modules/tag/tag.entity.ts | 2 +- server/src/modules/tag/tag.service.ts | 2 +- server/src/modules/user/user.controller.ts | 10 +- server/src/modules/user/user.entity.ts | 4 +- server/src/modules/user/user.service.ts | 24 +- server/src/modules/webhook/webhook.service.ts | 7 +- server/src/node-polyfills.ts | 46 + server/src/starter.ts | 223 +- server/src/utils/image-processor.util.ts | 118 + server/src/utils/local-api-quiet.util.ts | 4 + server/src/utils/markdown.util.ts | 4 +- server/src/utils/oss.util.ts | 9 +- server/src/utils/oss/aliyun-oss-client.ts | 8 +- server/src/utils/oss/oss-client.ts | 3 +- server/src/utils/project-root.util.ts | 42 + server/src/utils/upload.util.ts | 43 +- server/src/utils/user.util.ts | 6 +- templates/hello-world/README.md | 297 - templates/hello-world/components/Footer.tsx | 35 - templates/hello-world/components/Header.tsx | 113 - templates/hello-world/next-env.d.ts | 5 - templates/hello-world/next.config.js | 10 - templates/hello-world/package.json | 27 - templates/hello-world/pages/404.tsx | 152 - templates/hello-world/pages/about.tsx | 295 - templates/hello-world/pages/index.tsx | 356 - templates/hello-world/tsconfig.json | 30 - templates/twentytwentyfive/LICENSE | 21 - templates/twentytwentyfive/README.md | 347 - .../__tests__/template.test.tsx | 46 - .../bin/create-twentytwentyfive.js | 64 - .../twentytwentyfive/components/Footer.tsx | 166 - .../twentytwentyfive/components/Header.tsx | 154 - .../twentytwentyfive/components/TagsCloud.tsx | 125 - templates/twentytwentyfive/jest.config.js | 16 - templates/twentytwentyfive/jest.setup.js | 1 - templates/twentytwentyfive/next-env.d.ts | 5 - templates/twentytwentyfive/next.config.js | 10 - templates/twentytwentyfive/package-lock.json | 11461 ------ templates/twentytwentyfive/package.json | 34 - templates/twentytwentyfive/pages/404.tsx | 335 - .../twentytwentyfive/pages/article/[id].tsx | 750 - .../pages/category/[category].tsx | 563 - templates/twentytwentyfive/pages/index.tsx | 538 - templates/twentytwentyfive/pages/search.tsx | 733 - .../twentytwentyfive/pages/tag/[tag].tsx | 507 - templates/twentytwentyfive/styles/globals.css | 0 templates/twentytwentyfive/tsconfig.json | 30 - themes/README.md | 173 + themes/hello-world/.gitignore | 3 + .../.next-preview/.reactpress-theme-id | 1 + .../.reactpress-preview-frame-patched | 1 + themes/hello-world/README.md | 107 + .../hello-world/bin/create-hello-world.js | 0 themes/hello-world/components/Footer.tsx | 12 + themes/hello-world/components/Header.tsx | 56 + themes/hello-world/components/PageHead.tsx | 50 + themes/hello-world/components/PostEntry.tsx | 59 + themes/hello-world/components/Sidebar.tsx | 57 + themes/hello-world/components/TagsCloud.tsx | 25 + themes/hello-world/components/ThemeShell.tsx | 8 + themes/hello-world/cover.svg | 55 + themes/hello-world/locales/en.json | 45 + themes/hello-world/next-env.d.ts | 6 + themes/hello-world/next.config.js | 42 + themes/hello-world/package.json | 38 + themes/hello-world/pages/404.tsx | 28 + themes/hello-world/pages/[path].tsx | 97 + themes/hello-world/pages/_app.tsx | 7 + themes/hello-world/pages/_document.tsx | 25 + themes/hello-world/pages/about.tsx | 60 + themes/hello-world/pages/article/[id].tsx | 150 + .../hello-world/pages/category/[category].tsx | 104 + themes/hello-world/pages/index.tsx | 64 + themes/hello-world/pages/search.tsx | 112 + themes/hello-world/pages/tag/[tag].tsx | 100 + .../hello-world/pages/toolkit-demo.tsx | 70 +- themes/hello-world/server.js | 38 + themes/hello-world/styles/globals.css | 487 + themes/hello-world/theme.json | 76 + themes/hello-world/tsconfig.eslint.json | 36 + themes/hello-world/tsconfig.json | 37 + themes/my-blog/public/favicon-16.png | Bin 828 -> 0 bytes themes/my-blog/public/favicon-32.png | Bin 1489 -> 0 bytes themes/my-blog/public/favicon-48.png | Bin 2442 -> 0 bytes themes/my-blog/public/favicon.ico | Bin 13294 -> 0 bytes themes/my-blog/public/favicon.png | Bin 1489 -> 0 bytes themes/my-blog/public/icon-192.png | Bin 8844 -> 0 bytes themes/my-blog/public/icon-512.png | Bin 24807 -> 0 bytes themes/my-blog/public/logo-200.png | Bin 4966 -> 0 bytes themes/my-blog/public/logo-400.png | Bin 10204 -> 0 bytes themes/my-blog/public/logo.png | Bin 21414 -> 0 bytes themes/my-blog/public/logo.svg | 7 - themes/package.json | 2 +- themes/theme-starter/README.md | 54 + themes/theme-starter/package.json | 24 + themes/theme.manifest.schema.json | 4 +- themes/twentytwentyfive/public/favicon-16.png | Bin 828 -> 0 bytes themes/twentytwentyfive/public/favicon-32.png | Bin 1489 -> 0 bytes themes/twentytwentyfive/public/favicon-48.png | Bin 2442 -> 0 bytes themes/twentytwentyfive/public/favicon.ico | Bin 13294 -> 0 bytes themes/twentytwentyfive/public/favicon.png | Bin 1489 -> 0 bytes themes/twentytwentyfive/public/icon-192.png | Bin 8844 -> 0 bytes themes/twentytwentyfive/public/icon-512.png | Bin 24807 -> 0 bytes themes/twentytwentyfive/public/logo-200.png | Bin 4966 -> 0 bytes themes/twentytwentyfive/public/logo-400.png | Bin 10204 -> 0 bytes themes/twentytwentyfive/public/logo.png | Bin 21414 -> 0 bytes themes/twentytwentyfive/public/logo.svg | 7 - toolkit/README.md | 216 +- toolkit/package.json | 48 +- toolkit/scripts/resolve-swagger-input.js | 2 +- toolkit/src/plugin/admin/index.ts | 2 + toolkit/src/plugin/admin/plugin-admin.ts | 97 + toolkit/src/plugin/admin/slots.ts | 47 + toolkit/src/plugin/client/runtime.ts | 7 +- toolkit/src/plugin/extension/index.ts | 4 + .../extension/plugin-admin-locale.io.ts | 21 + .../plugin/extension/plugin-admin-locale.ts | 94 + toolkit/src/plugin/extension/plugin.ts | 257 + toolkit/src/plugin/extension/security.ts | 43 + toolkit/src/plugin/extension/validate.ts | 65 + toolkit/src/plugin/index.ts | 4 + toolkit/src/plugin/node.ts | 2 + toolkit/src/plugin/react/index.ts | 1 + toolkit/src/plugin/react/plugin-admin-ui.tsx | 80 + toolkit/src/plugin/server/hook-reject.ts | 41 + toolkit/src/plugin/server/index.ts | 2 + toolkit/src/plugin/server/types.ts | 49 + toolkit/src/theme/content/assets.ts | 39 +- toolkit/src/theme/extension/theme.ts | 2 +- toolkit/src/theme/ssr/fetch.ts | 3 + toolkit/src/theme/ssr/pageProps.ts | 28 +- toolkit/src/theme/ssr/setting.ts | 5 + toolkit/src/theme/ssr/templates.ts | 2 +- toolkit/src/types/index.ts | 3 + tsconfig.base.json | 15 + web/.env.development | 13 + web/.env.electron | 5 + web/.env.example | 29 + web/.env.production | 6 + web/.oxfmt.toml | 25 + web/.vite-hooks/pre-commit | 2 + web/AGENTS.md | 84 + web/README.md | 268 + web/bin/reactpress-web.js | 113 + web/e2e/helpers.ts | 16 + web/e2e/login.spec.ts | 27 + web/e2e/theme-preview-starter.spec.ts | 32 + web/e2e/themes.spec.ts | 52 + web/e2e/users.spec.ts | 43 + web/index.html | 21 + web/node/admin-route-helpers.cjs | 68 + web/node/index.d.ts | 37 + web/node/index.mjs | 10 + web/node/next.cjs | 7 + web/node/next.d.ts | 29 + web/node/next.mjs | 36 + web/node/static.mjs | 197 + web/package.json | 110 + web/playwright.config.ts | 31 + web/public/mockServiceWorker.js | 336 + .../showcase/undraw_around_the_world.svg | 1 + .../showcase/undraw_docusaurus_mountain.svg | 54 + .../showcase/undraw_docusaurus_react.svg | 51 + .../showcase/undraw_docusaurus_tree.svg | 40 + web/public/showcase/undraw_react.svg | 1 + web/public/showcase/undraw_typewriter.svg | 1 + .../showcase/undraw_version_control.svg | 1 + web/scripts/check-i18n-keys.mjs | 40 + web/scripts/prepack.mjs | 10 + web/src/api/auth.ts | 12 + web/src/api/schemas.ts | 131 + web/src/api/user.ts | 11 + web/src/components/Aurora/index.css | 88 + web/src/components/Aurora/index.tsx | 14 + .../components/DataTable/DataTableEmpty.tsx | 64 + .../DataTable/DataTableSkeleton.tsx | 298 + web/src/components/DataTable/index.css | 35 + web/src/components/DataTable/index.tsx | 127 + web/src/components/FilterToolbar/index.tsx | 174 + web/src/components/FormModal/index.tsx | 51 + web/src/components/Icon/GitHub.tsx | 11 + web/src/components/Icon/Theme.tsx | 21 + web/src/components/Icon/index.tsx | 2 + web/src/components/LanguageSwitcher/index.tsx | 55 + web/src/components/Layout/AppFooter/index.tsx | 42 + web/src/components/Layout/Header/index.tsx | 158 + .../components/Layout/MainLayout/index.tsx | 39 + web/src/components/Layout/Sidebar/index.css | 62 + web/src/components/Layout/Sidebar/index.tsx | 293 + web/src/components/Layout/UserMenu/index.css | 7 + web/src/components/Layout/UserMenu/index.tsx | 105 + web/src/components/Layout/admin-layout.css | 440 + web/src/components/NotFound/index.tsx | 70 + web/src/hooks/tokenBuilders.ts | 145 + web/src/hooks/useAppLocale.ts | 23 + web/src/hooks/useAppTheme.ts | 16 + web/src/hooks/useDocumentTitle.ts | 86 + web/src/hooks/useMinWidth.ts | 20 + web/src/hooks/usePendingCommentCount.ts | 15 + web/src/hooks/usePermission.ts | 10 + web/src/hooks/usePluginAdminLocale.ts | 23 + web/src/hooks/usePluginListItemMeta.ts | 26 + web/src/hooks/usePlugins.ts | 114 + web/src/hooks/useResourceCRUD.ts | 82 + web/src/hooks/useSiteSettings.ts | 41 + web/src/hooks/useSiteThemeState.ts | 43 + web/src/hooks/useThemeActivation.ts | 124 + web/src/hooks/useThemeAdminLocale.ts | 23 + web/src/hooks/useThemeConfiguration.ts | 39 + web/src/hooks/useThemeListItemMeta.ts | 23 + web/src/hooks/useThemePreviewHtml.ts | 50 + web/src/hooks/useThemePreviewSession.ts | 154 + web/src/hooks/useThemes.ts | 104 + web/src/hooks/wpAdminTokens.ts | 12 + web/src/i18n/format.ts | 39 + web/src/i18n/index.ts | 45 + web/src/i18n/locales/en.json | 962 + web/src/i18n/locales/zh.json | 962 + web/src/index.css | 55 + web/src/main.tsx | 117 + web/src/mocks/browser.ts | 5 + web/src/mocks/createHandler.ts | 58 + web/src/mocks/data.ts | 254 + web/src/mocks/handlers/article.ts | 204 + web/src/mocks/handlers/auth.ts | 64 + web/src/mocks/handlers/category.ts | 17 + web/src/mocks/handlers/comment.ts | 78 + web/src/mocks/handlers/index.ts | 33 + web/src/mocks/handlers/page.ts | 294 + web/src/mocks/handlers/plugins.ts | 232 + web/src/mocks/handlers/tag.ts | 17 + web/src/mocks/handlers/themes.ts | 448 + web/src/mocks/handlers/user.ts | 118 + web/src/mocks/mockCredentials.ts | 10 + web/src/mocks/mockSession.ts | 9 + web/src/mocks/utils.ts | 52 + .../components/ActiveThemePanel.tsx | 78 + .../components/AppearanceSectionFields.tsx | 72 + .../components/AppearanceSettingField.tsx | 164 + .../components/PreviewDeviceToolbar.tsx | 54 + .../appearance/components/SearchHighlight.tsx | 37 + .../components/ThemeAppearancePanel.tsx | 306 + .../appearance/components/ThemeCard.tsx | 83 + .../components/ThemeCatalogToolbar.tsx | 49 + .../components/ThemeConfigurationForm.tsx | 182 + .../appearance/components/ThemeCoverImage.tsx | 20 + .../components/ThemeCoverPlaceholder.tsx | 46 + .../components/ThemeOfficialBadge.tsx | 22 + .../components/ThemePreviewFrame.tsx | 155 + .../components/ThemePreviewPaneLoading.tsx | 22 + .../components/ThemeSettingsEditor.tsx | 84 + .../components/ThemeSettingsSearchBar.tsx | 26 + .../components/ThemeSettingsSidebar.tsx | 132 + .../components/formily/JsonConfigEditor.tsx | 89 + .../components/formily/NavLinkListField.tsx | 209 + .../components/formily/VsCodeSection.tsx | 50 + .../components/formily/VsCodeSettingRow.tsx | 73 + .../formily/nav-link-list-field.module.css | 75 + .../components/formily/schemaField.tsx | 56 + .../formily/vscode-section.module.css | 30 + .../formily/vscode-setting-row.module.css | 38 + .../components/search-highlight.module.css | 8 + .../theme-configuration-form.module.css | 93 + .../theme-cover-placeholder.module.css | 180 + .../theme-official-badge.module.css | 52 + .../theme-settings-editor.module.css | 216 + .../components/theme-settings-page.module.css | 83 + .../components/themes-page.module.css | 1199 + .../context/ThemeAdminLocaleContext.tsx | 53 + .../context/ThemeSettingsSearchContext.tsx | 85 + .../hooks/useThemeSettingsActiveId.ts | 110 + web/src/modules/appearance/index.ts | 38 + .../appearance/pages/CustomizePage.tsx | 304 + .../appearance/pages/ThemePreviewPage.tsx | 238 + .../appearance/pages/ThemeSettingsPage.tsx | 103 + .../modules/appearance/pages/ThemesPage.tsx | 168 + .../modules/appearance/utils/appearanceNav.ts | 61 + .../appearance/utils/isOfficialTheme.ts | 27 + .../modules/appearance/utils/parseSiteI18n.ts | 37 + .../utils/patchVsCodeFormilySchema.ts | 30 + .../utils/resolveAppearanceManifestText.ts | 24 + .../appearance/utils/themeSettingsAnchors.ts | 87 + .../appearance/utils/themeSettingsIndex.ts | 166 + .../utils/themeSettingsScrollLock.ts | 25 + web/src/modules/article/articleEditorApi.ts | 156 + web/src/modules/article/articleListApi.ts | 196 + .../components/ArticleCategoryTagsFields.tsx | 283 + .../components/ArticleEditorSidebar.tsx | 209 + .../components/ArticleListSubHeader.tsx | 84 + .../components/ArticleListTablenav.tsx | 99 + .../article/components/EditorMetaPanel.tsx | 26 + .../article-editor-sidebar.module.css | 265 + .../components/article-editor.module.css | 134 + .../components/article-list.module.css | 311 + .../components/articleListThemeVars.ts | 32 + .../components/editor-meta-panel.module.css | 58 + .../components/taxonomy-admin.module.css | 121 + web/src/modules/article/constants.ts | 26 + web/src/modules/article/index.ts | 67 + .../article/pages/ArticleEditorPage.tsx | 455 + .../modules/article/pages/ArticleListPage.tsx | 438 + .../article/pages/TaxonomyManagePage.tsx | 321 + web/src/modules/article/taxonomyApi.ts | 71 + web/src/modules/comment/commentListApi.ts | 85 + .../components/CommentListSubHeader.tsx | 76 + .../components/CommentListTablenav.tsx | 87 + .../components/comment-list.module.css | 412 + web/src/modules/comment/index.ts | 17 + .../modules/comment/pages/CommentListPage.tsx | 368 + .../modules/comment/pendingCommentCountApi.ts | 12 + .../components/RecentCommentsCard.tsx | 174 + .../components/recent-comments.module.css | 121 + .../modules/dashboard/dashboardCommentApi.ts | 20 + .../modules/dashboard/dashboardThemeVars.ts | 16 + web/src/modules/dashboard/index.ts | 16 + .../modules/dashboard/pages/DashboardPage.tsx | 206 + web/src/modules/data/index.ts | 41 + web/src/modules/data/pages/AnalyticsPage.tsx | 68 + web/src/modules/data/pages/ExportPage.tsx | 57 + web/src/modules/data/pages/ImportPage.tsx | 47 + .../media/components/MediaListTablenav.tsx | 43 + .../media/components/MediaListToolbar.tsx | 97 + .../media/components/media-list.module.css | 365 + .../media/components/mediaListThemeVars.ts | 23 + web/src/modules/media/index.ts | 17 + web/src/modules/media/mediaListApi.ts | 78 + web/src/modules/media/pages/MediaListPage.tsx | 350 + .../page/components/PageListSubHeader.tsx | 84 + .../page/components/PageListTablenav.tsx | 69 + .../page/components/page-editor.module.css | 18 + web/src/modules/page/index.ts | 34 + web/src/modules/page/pageListApi.ts | 105 + web/src/modules/page/pages/PageEditorPage.tsx | 262 + web/src/modules/page/pages/PageListPage.tsx | 233 + .../components/PluginListSubHeader.tsx | 90 + .../plugins/components/PluginListTablenav.tsx | 67 + .../plugin-settings-page.module.css | 125 + .../components/plugins-page.module.css | 67 + .../context/PluginAdminLocaleContext.tsx | 56 + web/src/modules/plugins/index.ts | 18 + .../plugins/pages/PluginSettingsPage.tsx | 271 + web/src/modules/plugins/pages/PluginsPage.tsx | 357 + .../plugins/utils/pluginSettingsSchema.ts | 41 + .../utils/resolvePluginManifestText.ts | 12 + .../settings/components/SettingsTabForm.tsx | 375 + .../components/settings-form.module.css | 177 + web/src/modules/settings/index.ts | 60 + .../settings/pages/SettingsLayoutPage.tsx | 52 + .../modules/settings/siteSettingDefaults.ts | 52 + web/src/modules/settings/smtpTestApi.ts | 15 + .../user/components/UserListSubHeader.tsx | 78 + .../user/components/UserListTablenav.tsx | 107 + .../user/components/profile.module.css | 79 + .../user/components/user-list.module.css | 65 + web/src/modules/user/index.ts | 33 + web/src/modules/user/pages/ProfilePage.tsx | 353 + web/src/modules/user/pages/UserListPage.tsx | 472 + web/src/modules/user/profileApi.ts | 64 + web/src/modules/user/userListApi.ts | 165 + web/src/routeTree.gen.ts | 676 + web/src/routes/404/index.tsx | 7 + web/src/routes/__root.tsx | 47 + web/src/routes/_auth.tsx | 28 + web/src/routes/_auth/403/index.tsx | 41 + .../_auth/appearance/customize/index.tsx | 14 + .../themes/$themeId/settings/index.tsx | 10 + .../routes/_auth/appearance/themes/index.tsx | 7 + .../_auth/appearance/themes/preview/index.tsx | 13 + .../routes/_auth/article/category/index.tsx | 9 + .../routes/_auth/article/comment/index.tsx | 19 + web/src/routes/_auth/article/editor/$id.tsx | 10 + web/src/routes/_auth/article/editor/index.tsx | 9 + web/src/routes/_auth/article/index.tsx | 23 + web/src/routes/_auth/article/tags/index.tsx | 9 + web/src/routes/_auth/dashboard/index.css | 55 + web/src/routes/_auth/dashboard/index.tsx | 7 + web/src/routes/_auth/data/analytics/index.tsx | 7 + web/src/routes/_auth/data/export/index.tsx | 7 + web/src/routes/_auth/data/import/index.tsx | 7 + web/src/routes/_auth/media/index.tsx | 21 + web/src/routes/_auth/page/editor/$id.tsx | 10 + web/src/routes/_auth/page/editor/index.tsx | 7 + web/src/routes/_auth/page/index.tsx | 22 + .../_auth/plugins/$id/settings/index.tsx | 10 + web/src/routes/_auth/plugins/index.tsx | 7 + web/src/routes/_auth/profile/index.tsx | 7 + web/src/routes/_auth/settings/$tab/index.tsx | 17 + web/src/routes/_auth/settings/index.tsx | 7 + web/src/routes/_auth/users/-FormModal.tsx | 81 + web/src/routes/_auth/users/index.tsx | 21 + web/src/routes/index.tsx | 10 + web/src/routes/login/-LoginBrandMark.tsx | 26 + web/src/routes/login/-LoginCliSnippet.tsx | 234 + web/src/routes/login/-LoginHeroLinks.tsx | 33 + web/src/routes/login/-LoginSnapNav.tsx | 80 + web/src/routes/login/-loginCyberLogo.ts | 22 + web/src/routes/login/-loginHeroSlides.ts | 14 + .../login/docs-home/-LoginDocsFeatures.tsx | 68 + .../routes/login/docs-home/-LoginDocsHero.tsx | 112 + .../login/docs-home/-LoginDocsHighlights.tsx | 64 + .../routes/login/docs-home/hero/Devices.tsx | 1023 + .../login/docs-home/hero/FloorBackground.tsx | 40 + .../login/docs-home/hero/GridBackground.tsx | 119 + web/src/routes/login/docs-home/hero/Logo.tsx | 11 + .../login/docs-home/hero/styles.module.css | 556 + web/src/routes/login/docs-home/index.tsx | 33 + .../login/docs-home/login-docs-cta.module.css | 114 + .../docs-home/login-docs-features.module.css | 94 + .../login-docs-highlights.module.css | 336 + .../docs-home/login-docs-home.module.css | 153 + web/src/routes/login/index.tsx | 205 + .../routes/login/login-brand-mark.module.css | 25 + .../routes/login/login-cli-snippet.module.css | 599 + web/src/routes/login/login-page.module.css | 408 + .../routes/login/login-snap-nav.module.css | 73 + web/src/routes/register/index.tsx | 215 + web/src/routes/searchDefaults.ts | 46 + web/src/shared/api/fileOptimize.ts | 117 + web/src/shared/api/getApiErrorMessage.ts | 106 + web/src/shared/api/pagination.ts | 15 + web/src/shared/api/plugins.ts | 114 + web/src/shared/api/themes.ts | 267 + web/src/shared/api/uploadFile.ts | 44 + web/src/shared/auth/adminAccess.ts | 18 + web/src/shared/auth/loginErrorMessage.ts | 69 + web/src/shared/auth/registerAccount.ts | 52 + web/src/shared/auth/session.ts | 126 + web/src/shared/client.ts | 25 + web/src/shared/coerceApiString.ts | 9 + web/src/shared/components/Dashicon.tsx | 43 + .../components/Editor/DefaultMarkdown.ts | 9 + .../shared/components/Editor/MonacoEditor.tsx | 259 + web/src/shared/components/Editor/Preview.tsx | 46 + .../components/Editor/editor.module.css | 198 + web/src/shared/components/Editor/index.tsx | 319 + .../components/Editor/toolbar/AddCode.tsx | 24 + .../components/Editor/toolbar/Emoji.tsx | 40 + .../Editor/toolbar/FormatToolbar.tsx | 195 + .../components/Editor/toolbar/Iframe.tsx | 42 + .../components/Editor/toolbar/Image.tsx | 37 + .../components/Editor/toolbar/Magimg.tsx | 43 + .../components/Editor/toolbar/Video.tsx | 37 + .../components/Editor/toolbar/emojis.ts | 152 + .../components/Editor/toolbar/index.tsx | 24 + .../Editor/toolbar/markdownActions.ts | 122 + .../shared/components/Editor/toolbar/types.ts | 30 + .../components/Editor/utils/markdown.ts | 15 +- .../shared/components/Editor/utils/modal.tsx | 15 + .../components/Editor/utils/syncScroll.tsx | 22 +- .../components/Editor/utils/uploadInsert.ts | 16 + .../shared/components/ListPaginationNav.tsx | 107 + .../components/MarkdownReader/index.tsx | 109 + .../MarkdownReader/markdown-reader.module.css | 27 + .../components/MediaSelectDrawer/index.tsx | 153 + .../media-select-drawer.module.css | 75 + .../shared/components/ModulePlaceholder.tsx | 28 + .../components/SiteNoticeListField/index.tsx | 153 + .../site-notice-list-field.module.css | 80 + web/src/shared/components/Toc/index.tsx | 129 + web/src/shared/components/Toc/toc.module.css | 112 + .../desktop/DesktopLocalQueryPolicy.tsx | 39 + web/src/shared/desktop/DesktopModeBadge.tsx | 43 + web/src/shared/desktop/DesktopModeSwitch.tsx | 40 + .../shared/desktop/DesktopWorkspacePanel.tsx | 265 + web/src/shared/desktop/apiConfig.ts | 118 + .../desktop/desktop-api-setup.module.css | 174 + .../desktop/desktop-mode-badge.module.css | 16 + web/src/shared/desktop/electronRouting.ts | 4 + web/src/shared/desktop/refreshApiContext.ts | 50 + web/src/shared/desktop/syncToRemote.ts | 152 + web/src/shared/hooks/useToggle.ts | 15 + web/src/shared/menu.ts | 53 + web/src/shared/pluginAdmin/loaders.ts | 35 + .../pluginAdmin/reactpress-plugins.d.ts | 9 + web/src/shared/queryClient.ts | 7 + .../shared/styles/admin-form-table.module.css | 109 + web/src/shared/styles/editor-theme.css | 34 + web/src/shared/styles/markdown.css | 124 + web/src/shared/theme/clearThemeMods.ts | 25 + web/src/shared/theme/normalizeMods.ts | 60 + web/src/shared/theme/previewUrl.ts | 70 + web/src/shared/theme/waitForVisitorSite.ts | 121 + web/src/shared/types/showdown.d.ts | 6 + web/src/shell/PluginAdminProvider.tsx | 110 + web/src/shell/bootstrap.ts | 67 + web/src/shell/permissions.ts | 30 + web/src/stores/auth.ts | 61 + web/src/stores/createPersistentStore.ts | 29 + web/src/stores/desktop.ts | 33 + web/src/stores/settings.ts | 48 + web/src/utils/appMenu.ts | 1 + web/src/utils/constants.ts | 56 + web/src/utils/http.ts | 158 + web/src/utils/session.ts | 16 + web/src/vite-env.d.ts | 35 + web/tsconfig.eslint.json | 31 + web/tsconfig.json | 23 + web/vercel.json | 8 + web/vite.config.ts | 127 + 1096 files changed, 85674 insertions(+), 57903 deletions(-) create mode 100644 .github/workflows/deploy-ecs.yml create mode 100644 .nvmrc create mode 100644 .vscode/settings.json create mode 100644 ARCHITECTURE.md create mode 100644 cli/bin/reactpress-theme-client.js delete mode 100644 cli/lib/dev-banner.js delete mode 100644 cli/lib/dev.js delete mode 100644 cli/lib/docker.js delete mode 100644 cli/lib/nginx.js delete mode 100644 cli/lib/publish.js rename themes/my-blog/public/apple-touch-icon.png => cli/server/public/uploads/2026-05-30/QR3X4UZQ93ZTUPGXCFA9II..png (100%) create mode 100644 cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp create mode 100644 cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp create mode 100644 cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp create mode 100644 cli/server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg create mode 100644 cli/src/bin/reactpress-cli-shim.ts create mode 100644 cli/src/bin/reactpress-theme-client.ts create mode 100755 cli/src/bin/reactpress.ts create mode 100644 cli/src/core/services/config.ts create mode 100644 cli/src/core/services/database/index.ts create mode 100644 cli/src/core/services/database/mysql.ts create mode 100644 cli/src/core/services/database/profile.ts create mode 100644 cli/src/core/services/database/sqlite.ts create mode 100644 cli/src/core/services/exec.ts create mode 100644 cli/src/core/services/init.ts create mode 100644 cli/src/core/services/local-site.ts create mode 100644 cli/src/core/utils/cli-context.ts create mode 100644 cli/src/core/utils/paths.ts create mode 100644 cli/src/core/utils/platform.ts create mode 100644 cli/src/core/utils/port.ts rename cli/{lib/api-dev-runner.js => src/lib/api-dev-runner.ts} (90%) rename cli/{lib/api-dev.js => src/lib/api-dev.ts} (67%) rename cli/{lib/bootstrap.js => src/lib/bootstrap.ts} (59%) rename cli/{lib/build.js => src/lib/build.ts} (59%) create mode 100644 cli/src/lib/context-status.ts create mode 100644 cli/src/lib/database-mode.ts rename cli/{lib/db-backup.js => src/lib/db-backup.ts} (99%) create mode 100644 cli/src/lib/dev-banner.ts create mode 100644 cli/src/lib/dev-child-io.ts create mode 100644 cli/src/lib/dev-log.ts create mode 100644 cli/src/lib/dev-session.ts create mode 100644 cli/src/lib/dev.ts create mode 100644 cli/src/lib/docker.ts rename cli/{lib/doctor.js => src/lib/doctor.ts} (92%) create mode 100644 cli/src/lib/health-parse.test.ts create mode 100644 cli/src/lib/health-parse.ts rename cli/{lib/http.js => src/lib/http.ts} (64%) rename cli/{lib/i18n/index.js => src/lib/i18n/index.ts} (88%) rename cli/{lib/i18n/strings.js => src/lib/i18n/strings.ts} (61%) rename cli/{lib/lifecycle.js => src/lib/lifecycle.ts} (99%) create mode 100644 cli/src/lib/nginx.ts rename cli/{lib/paths.js => src/lib/paths.ts} (61%) create mode 100644 cli/src/lib/plugin-build.ts create mode 100644 cli/src/lib/plugin-cli.ts rename cli/{lib/pm2.js => src/lib/pm2.ts} (98%) create mode 100644 cli/src/lib/ports.ts rename cli/{lib/process.js => src/lib/process.ts} (98%) create mode 100644 cli/src/lib/prod-memory.ts rename cli/{lib/project-type.js => src/lib/project-type.ts} (64%) create mode 100644 cli/src/lib/publish.ts create mode 100644 cli/src/lib/remote-dev.ts rename cli/{lib/root.js => src/lib/root.ts} (99%) rename cli/{lib/spawn.js => src/lib/spawn.ts} (79%) rename cli/{lib/status.js => src/lib/status.ts} (99%) create mode 100644 cli/src/lib/theme-catalog.ts create mode 100644 cli/src/lib/theme-cli.ts create mode 100644 cli/src/lib/theme-dev.ts create mode 100644 cli/src/lib/theme-env.ts create mode 100644 cli/src/lib/theme-install.ts create mode 100644 cli/src/lib/theme-lock.ts create mode 100644 cli/src/lib/theme-paths.ts create mode 100644 cli/src/lib/theme-placeholder-cover.ts create mode 100644 cli/src/lib/theme-preview-frame.ts create mode 100644 cli/src/lib/theme-preview-pool.ts create mode 100644 cli/src/lib/theme-preview-proxy.ts create mode 100644 cli/src/lib/theme-prod.ts create mode 100644 cli/src/lib/theme-registry.ts create mode 100644 cli/src/lib/theme-runtime.ts create mode 100644 cli/src/lib/theme-sources.ts create mode 100644 cli/src/lib/theme-warmup.ts create mode 100644 cli/src/lib/toolkit-build.ts create mode 100644 cli/src/types/config.ts create mode 100644 cli/src/ui/banner-startup.ts create mode 100644 cli/src/ui/banner.ts create mode 100644 cli/src/ui/interactive.ts rename cli/{ui/theme.js => src/ui/theme.ts} (74%) create mode 100644 cli/templates/config.local.json create mode 100644 cli/templates/env.local.default create mode 100644 cli/templates/theme-catalog.json create mode 100644 cli/tests/banner.test.js create mode 100644 cli/tests/cli-version.test.js create mode 100644 cli/tests/context-status.test.js create mode 100644 cli/tests/database-fallback.test.js create mode 100644 cli/tests/dev-auto-local.test.js create mode 100644 cli/tests/ports.test.js create mode 100644 cli/tests/remote-dev.test.js create mode 100644 cli/tests/sqlite-path.test.js create mode 100644 cli/tests/theme-catalog.test.js create mode 100644 cli/tests/theme-dev-watch.test.js create mode 100644 cli/tests/theme-install.test.js create mode 100644 cli/tests/theme-paths.test.js create mode 100644 cli/tests/theme-preview-frame.test.js create mode 100644 cli/tests/theme-preview-pool.test.js create mode 100644 cli/tests/theme-registry.test.js create mode 100644 cli/tests/theme-warmup.test.js create mode 100644 cli/tests/toolkit-build.test.js create mode 100644 cli/tsconfig.json delete mode 100644 cli/ui/banner.js delete mode 100644 cli/ui/interactive.js delete mode 100644 client/Dockerfile delete mode 100644 client/README.md delete mode 100755 client/bin/reactpress-client.js delete mode 100644 client/next-env.d.ts delete mode 100644 client/next-sitemap.js delete mode 100644 client/next.config.js delete mode 100644 client/package.json delete mode 100644 client/pages/404.tsx delete mode 100644 client/pages/_app.tsx delete mode 100644 client/pages/_document.tsx delete mode 100644 client/pages/_error.tsx delete mode 100644 client/pages/admin/article/category/index.module.scss delete mode 100644 client/pages/admin/article/category/index.tsx delete mode 100644 client/pages/admin/article/editor/[id].tsx delete mode 100644 client/pages/admin/article/editor/index.tsx delete mode 100644 client/pages/admin/article/index.module.scss delete mode 100644 client/pages/admin/article/index.tsx delete mode 100644 client/pages/admin/article/tags/index.module.scss delete mode 100644 client/pages/admin/article/tags/index.tsx delete mode 100644 client/pages/admin/comment/index.tsx delete mode 100644 client/pages/admin/file/index.module.scss delete mode 100644 client/pages/admin/file/index.tsx delete mode 100644 client/pages/admin/index.module.scss delete mode 100644 client/pages/admin/index.tsx delete mode 100644 client/pages/admin/knowledge/editor/[id].tsx delete mode 100644 client/pages/admin/knowledge/editor/index.module.scss delete mode 100644 client/pages/admin/knowledge/index.module.scss delete mode 100644 client/pages/admin/knowledge/index.tsx delete mode 100644 client/pages/admin/mail/index.module.scss delete mode 100644 client/pages/admin/mail/index.tsx delete mode 100644 client/pages/admin/ownspace/index.module.scss delete mode 100644 client/pages/admin/ownspace/index.tsx delete mode 100644 client/pages/admin/page/editor/[id].tsx delete mode 100644 client/pages/admin/page/editor/index.tsx delete mode 100644 client/pages/admin/page/index.module.scss delete mode 100644 client/pages/admin/page/index.tsx delete mode 100644 client/pages/admin/search/index.module.scss delete mode 100644 client/pages/admin/search/index.tsx delete mode 100644 client/pages/admin/setting/index.module.scss delete mode 100644 client/pages/admin/setting/index.tsx delete mode 100644 client/pages/admin/user/index.module.scss delete mode 100644 client/pages/admin/user/index.tsx delete mode 100644 client/pages/admin/view/index.module.scss delete mode 100644 client/pages/admin/view/index.tsx delete mode 100644 client/pages/archives/index.module.scss delete mode 100644 client/pages/archives/index.tsx delete mode 100644 client/pages/article/[id].tsx delete mode 100644 client/pages/article/index.module.scss delete mode 100644 client/pages/category/[category].tsx delete mode 100644 client/pages/index.module.scss delete mode 100644 client/pages/index.tsx delete mode 100644 client/pages/knowledge/[pId]/[id]/index.module.scss delete mode 100644 client/pages/knowledge/[pId]/[id]/index.tsx delete mode 100644 client/pages/knowledge/[pId]/index.module.scss delete mode 100644 client/pages/knowledge/[pId]/index.tsx delete mode 100644 client/pages/knowledge/index.tsx delete mode 100644 client/pages/login/index.module.scss delete mode 100644 client/pages/login/index.tsx delete mode 100644 client/pages/nav/[id].tsx delete mode 100644 client/pages/nav/index.module.scss delete mode 100644 client/pages/nav/index.tsx delete mode 100644 client/pages/page/[id].tsx delete mode 100644 client/pages/page/index.module.scss delete mode 100644 client/pages/rss/index.tsx delete mode 100644 client/pages/tag/[tag].tsx delete mode 100644 client/public/favicon.ico delete mode 100644 client/public/logo.png delete mode 100644 client/public/robots.txt delete mode 100644 client/public/vercel.svg delete mode 100755 client/server.js delete mode 100644 client/src/assets/LogoSvg.tsx delete mode 100644 client/src/components/AboutUs/index.module.scss delete mode 100644 client/src/components/AboutUs/index.tsx delete mode 100644 client/src/components/AdvanceSearch/index.module.scss delete mode 100644 client/src/components/AdvanceSearch/index.tsx delete mode 100644 client/src/components/Analytics/index.tsx delete mode 100644 client/src/components/Animation/Opacity.tsx delete mode 100644 client/src/components/Animation/Spring.tsx delete mode 100644 client/src/components/Animation/Trail.tsx delete mode 100644 client/src/components/Animation/Transition.tsx delete mode 100644 client/src/components/ArticleCarousel/index.module.scss delete mode 100644 client/src/components/ArticleCarousel/index.tsx delete mode 100644 client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss delete mode 100644 client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx delete mode 100644 client/src/components/ArticleEditor/index.module.scss delete mode 100644 client/src/components/ArticleEditor/index.tsx delete mode 100644 client/src/components/ArticleList/index.module.scss delete mode 100644 client/src/components/ArticleList/index.tsx delete mode 100644 client/src/components/ArticleRecommend/index.module.scss delete mode 100644 client/src/components/ArticleRecommend/index.tsx delete mode 100644 client/src/components/Categories/index.module.scss delete mode 100644 client/src/components/Categories/index.tsx delete mode 100644 client/src/components/Comment/CommentAction/CommentAction.tsx delete mode 100644 client/src/components/Comment/CommentAction/CommentArticle.tsx delete mode 100644 client/src/components/Comment/CommentAction/CommentContent.tsx delete mode 100644 client/src/components/Comment/CommentAction/CommentHTML.tsx delete mode 100644 client/src/components/Comment/CommentAction/CommentStatus.tsx delete mode 100644 client/src/components/Comment/CommentAction/index.module.scss delete mode 100644 client/src/components/Comment/CommentEditor/Emoji/emojis.ts delete mode 100644 client/src/components/Comment/CommentEditor/Emoji/index.module.scss delete mode 100644 client/src/components/Comment/CommentEditor/Emoji/index.tsx delete mode 100644 client/src/components/Comment/CommentEditor/index.module.scss delete mode 100644 client/src/components/Comment/CommentEditor/index.tsx delete mode 100644 client/src/components/Comment/CommentIcon.module.scss delete mode 100644 client/src/components/Comment/CommentIcon.tsx delete mode 100644 client/src/components/Comment/CommentItem/index.module.scss delete mode 100644 client/src/components/Comment/CommentItem/index.tsx delete mode 100644 client/src/components/Comment/index.module.scss delete mode 100644 client/src/components/Comment/index.tsx delete mode 100644 client/src/components/Copy/index.module.scss delete mode 100644 client/src/components/Copy/index.tsx delete mode 100644 client/src/components/Editor/DefaultMarkdown.ts delete mode 100644 client/src/components/Editor/MonacoEditor.tsx delete mode 100644 client/src/components/Editor/Preview.tsx delete mode 100644 client/src/components/Editor/index.module.scss delete mode 100644 client/src/components/Editor/index.tsx delete mode 100644 client/src/components/Editor/toolbar/AddCode.tsx delete mode 100644 client/src/components/Editor/toolbar/Emoji.tsx delete mode 100644 client/src/components/Editor/toolbar/File.tsx delete mode 100644 client/src/components/Editor/toolbar/Iframe.tsx delete mode 100644 client/src/components/Editor/toolbar/Image.tsx delete mode 100644 client/src/components/Editor/toolbar/Magimg.tsx delete mode 100644 client/src/components/Editor/toolbar/Video.tsx delete mode 100644 client/src/components/Editor/toolbar/emojis.ts delete mode 100644 client/src/components/Editor/toolbar/index.tsx delete mode 100644 client/src/components/Editor/utils/modal.tsx delete mode 100644 client/src/components/FileSelectDrawer/index.module.scss delete mode 100644 client/src/components/FileSelectDrawer/index.tsx delete mode 100644 client/src/components/FixAntdStyleTransition/index.tsx delete mode 100644 client/src/components/Footer/index.module.scss delete mode 100644 client/src/components/Footer/index.tsx delete mode 100644 client/src/components/Header/index.module.scss delete mode 100644 client/src/components/Header/index.tsx delete mode 100644 client/src/components/ImageViewer/index.tsx delete mode 100644 client/src/components/JsonEditor/index.tsx delete mode 100644 client/src/components/KnowledgeList/index.module.scss delete mode 100644 client/src/components/KnowledgeList/index.tsx delete mode 100644 client/src/components/KnowledgeSettingDrawer/index.tsx delete mode 100644 client/src/components/Likes/index.module.scss delete mode 100644 client/src/components/Likes/index.tsx delete mode 100644 client/src/components/LocaleTime/index.tsx delete mode 100644 client/src/components/Locales/index.tsx delete mode 100644 client/src/components/MarkdownReader/index.module.scss delete mode 100644 client/src/components/MarkdownReader/index.tsx delete mode 100644 client/src/components/NProgress/index.tsx delete mode 100644 client/src/components/NavCard/Category.tsx delete mode 100644 client/src/components/NavCard/NavCard.tsx delete mode 100644 client/src/components/NavCard/index.module.scss delete mode 100644 client/src/components/NavCard/index.tsx delete mode 100644 client/src/components/PageEditor/index.module.scss delete mode 100644 client/src/components/PageEditor/index.tsx delete mode 100644 client/src/components/Pagination/index.module.scss delete mode 100644 client/src/components/Pagination/index.tsx delete mode 100644 client/src/components/PaginationTable/index.module.scss delete mode 100644 client/src/components/PaginationTable/index.tsx delete mode 100644 client/src/components/Search/index.module.scss delete mode 100644 client/src/components/Search/index.tsx delete mode 100644 client/src/components/SearchHeader/index.module.scss delete mode 100644 client/src/components/SearchHeader/index.tsx delete mode 100644 client/src/components/Seo/index.tsx delete mode 100644 client/src/components/Setting/AnalyticsSetting/index.tsx delete mode 100644 client/src/components/Setting/GlobalSetting/index.tsx delete mode 100644 client/src/components/Setting/LocaleSetting/index.tsx delete mode 100644 client/src/components/Setting/OSSSetting/index.tsx delete mode 100644 client/src/components/Setting/SEOSetting/index.tsx delete mode 100644 client/src/components/Setting/SMTPSetting/index.tsx delete mode 100644 client/src/components/Setting/SystemNotification/index.module.scss delete mode 100644 client/src/components/Setting/SystemNotification/index.tsx delete mode 100644 client/src/components/Setting/SystemSetting/index.tsx delete mode 100644 client/src/components/TagCloud/index.module.scss delete mode 100644 client/src/components/TagCloud/index.tsx delete mode 100644 client/src/components/TagCloud/tag.ts delete mode 100644 client/src/components/Tags/index.module.scss delete mode 100644 client/src/components/Tags/index.tsx delete mode 100644 client/src/components/Theme/index.module.scss delete mode 100644 client/src/components/Theme/index.tsx delete mode 100644 client/src/components/Toc/index.module.scss delete mode 100644 client/src/components/Toc/index.tsx delete mode 100644 client/src/components/Upload/index.tsx delete mode 100644 client/src/components/UserInfo/index.module.scss delete mode 100644 client/src/components/UserInfo/index.tsx delete mode 100644 client/src/components/ViewChart/index.tsx delete mode 100644 client/src/components/ViewStatistics/index.tsx delete mode 100644 client/src/constants/index.tsx delete mode 100644 client/src/context/global.tsx delete mode 100644 client/src/hooks/useAsyncLoading.ts delete mode 100644 client/src/hooks/useForceUpdate.ts delete mode 100644 client/src/hooks/usePagination.ts delete mode 100644 client/src/hooks/useSetting.ts delete mode 100644 client/src/hooks/useToggle.ts delete mode 100644 client/src/hooks/useUser.ts delete mode 100644 client/src/hooks/useWarningOnExit.ts delete mode 100644 client/src/layout/AdminLayout/ResourceCreate.tsx delete mode 100644 client/src/layout/AdminLayout/index.module.scss delete mode 100644 client/src/layout/AdminLayout/index.tsx delete mode 100644 client/src/layout/AdminLayout/menus.tsx delete mode 100644 client/src/layout/AppLayout/index.module.scss delete mode 100644 client/src/layout/AppLayout/index.tsx delete mode 100644 client/src/layout/DoubleColumnLayout/index.module.scss delete mode 100644 client/src/layout/DoubleColumnLayout/index.tsx delete mode 100644 client/src/providers/article.ts delete mode 100644 client/src/providers/category.ts delete mode 100644 client/src/providers/comment.ts delete mode 100644 client/src/providers/file.ts delete mode 100644 client/src/providers/http.ts delete mode 100644 client/src/providers/knowledge.ts delete mode 100644 client/src/providers/mail.ts delete mode 100644 client/src/providers/page.ts delete mode 100644 client/src/providers/poster.ts delete mode 100644 client/src/providers/search.ts delete mode 100644 client/src/providers/setting.ts delete mode 100644 client/src/providers/smtp.ts delete mode 100644 client/src/providers/tag.ts delete mode 100644 client/src/providers/user.ts delete mode 100644 client/src/providers/view.ts delete mode 100644 client/src/rss/index.js delete mode 100644 client/src/theme/index.scss delete mode 100644 client/src/theme/markdown.scss delete mode 100644 client/src/theme/reset.scss delete mode 100644 client/src/theme/var.scss delete mode 100644 client/src/utils/copy.ts delete mode 100644 client/src/utils/index.tsx delete mode 100644 client/src/utils/json.ts delete mode 100644 client/src/utils/jsonp.ts delete mode 100644 client/src/utils/login.ts delete mode 100644 client/tsconfig.json delete mode 100644 client/types/index.d.ts create mode 100644 desktop/README.md create mode 100644 desktop/docs/size-optimization.md create mode 100644 desktop/electron-builder.yml create mode 100644 desktop/electron.vite.config.ts create mode 100644 desktop/package.json create mode 100644 desktop/resources/icon.png create mode 100644 desktop/scripts/bootstrap-local-api.cjs create mode 100644 desktop/scripts/build-desktop.mjs create mode 100644 desktop/scripts/build-freshness.mjs create mode 100644 desktop/scripts/cache-dir.mjs create mode 100644 desktop/scripts/dev-full.mjs create mode 100644 desktop/scripts/ensure-electron.mjs create mode 100644 desktop/scripts/launch-dev.mjs create mode 100644 desktop/scripts/prepare-app-resources.mjs create mode 100644 desktop/scripts/prepare-macos-dev-app.mjs create mode 100644 desktop/scripts/prune-bundle.mjs create mode 100644 desktop/scripts/run-release-dir.mjs create mode 100644 desktop/src/main/app-icon.ts create mode 100644 desktop/src/main/config.ts create mode 100644 desktop/src/main/index.ts create mode 100644 desktop/src/main/ipc.ts create mode 100644 desktop/src/main/local-server.ts create mode 100644 desktop/src/main/local-site.ts create mode 100644 desktop/src/main/local-theme.ts create mode 100644 desktop/src/main/packaged-runtime.ts create mode 100644 desktop/src/main/system-log.ts create mode 100644 desktop/src/main/window.ts create mode 100644 desktop/src/preload/index.ts create mode 100644 desktop/src/shared/constants.ts create mode 100644 desktop/src/shared/resolve-port.ts create mode 100644 desktop/src/shared/types.ts create mode 100644 desktop/tsconfig.json create mode 100644 desktop/tsconfig.node.json create mode 100644 docker-compose.prod.low-mem.yml create mode 100644 docs/i18n/en/docusaurus-plugin-content-docs/current/tutorial-extras/migration-3-to-4.md create mode 100644 docs/i18n/en/docusaurus-plugin-content-docs/current/tutorial-extras/reactpress-4-0.md create mode 100644 docs/migration-3-to-4.md create mode 100644 docs/theme-system-review.md create mode 100644 docs/tsconfig.eslint.json create mode 100644 docs/tutorial/tutorial-extras/migration-3-to-4.md create mode 100644 docs/tutorial/tutorial-extras/reactpress-4-0.md create mode 100644 plugins/README.md create mode 100644 plugins/hello-world/README.md create mode 100644 plugins/hello-world/locales/en.json create mode 100644 plugins/hello-world/locales/zh.json create mode 100644 plugins/hello-world/package.json create mode 100644 plugins/hello-world/plugin.json create mode 100644 plugins/hello-world/src/config.ts create mode 100644 plugins/hello-world/src/index.ts create mode 100644 plugins/hello-world/src/summary.ts create mode 100644 plugins/hello-world/src/types.ts create mode 100644 plugins/hello-world/tsconfig.json create mode 100644 plugins/image-optimizer/README.md create mode 100644 plugins/image-optimizer/admin/OptimizeDashboard.tsx create mode 100644 plugins/image-optimizer/admin/api.ts create mode 100644 plugins/image-optimizer/admin/index.ts create mode 100644 plugins/image-optimizer/admin/locale.ts create mode 100644 plugins/image-optimizer/admin/optimize-dashboard.module.css create mode 100644 plugins/image-optimizer/locales/en.json create mode 100644 plugins/image-optimizer/locales/zh.json create mode 100644 plugins/image-optimizer/package.json create mode 100644 plugins/image-optimizer/plugin.json create mode 100644 plugins/image-optimizer/src/config.ts create mode 100644 plugins/image-optimizer/src/index.ts create mode 100644 plugins/image-optimizer/tsconfig.json create mode 100644 plugins/package.json create mode 100644 plugins/plugin.manifest.schema.json create mode 100644 plugins/seo/README.md create mode 100644 plugins/seo/admin/ArticleEditorSeoPanel.tsx create mode 100644 plugins/seo/admin/article-editor-seo-panel.module.css create mode 100644 plugins/seo/admin/index.ts create mode 100644 plugins/seo/locales/en.json create mode 100644 plugins/seo/locales/zh.json create mode 100644 plugins/seo/package.json create mode 100644 plugins/seo/plugin.json create mode 100644 plugins/seo/src/config.ts create mode 100644 plugins/seo/src/index.ts create mode 100644 plugins/seo/src/seo.ts create mode 100644 plugins/seo/src/slug.ts create mode 100644 plugins/seo/src/types.ts create mode 100644 plugins/seo/tsconfig.json create mode 100644 public/desktop.gif create mode 100644 public/desktop.png create mode 100755 scripts/apply-release.sh create mode 100755 scripts/apply-theme-build.sh create mode 100755 scripts/build.sh delete mode 100644 scripts/bundled-server-path.js create mode 100644 scripts/clean.mjs create mode 100644 scripts/dev-smoke-nginx.mjs create mode 100644 scripts/dev-themes.mjs delete mode 100755 scripts/docker-dev.js delete mode 100755 scripts/install.sh create mode 100755 scripts/package-theme-build.sh delete mode 100755 scripts/publish-3.7.0.sh delete mode 100644 scripts/reactpress-api-dev.js delete mode 100644 scripts/reactpress-api-lifecycle.js delete mode 100644 scripts/reactpress-api-pm2.js delete mode 100644 scripts/reactpress-bootstrap.js delete mode 100755 scripts/reactpress-cli.js delete mode 100644 scripts/reactpress-dev.js delete mode 100644 scripts/reactpress-publish.js create mode 100644 scripts/seed-legacy-media.mjs create mode 100755 scripts/smoke-dev-web-local.mjs create mode 100644 scripts/sync-theme-catalog.mjs create mode 100644 scripts/test-theme-flow.mjs create mode 100755 scripts/verify-build-artifacts.mjs rename themes/twentytwentyfive/public/apple-touch-icon.png => server/public/uploads/2026-05-30/QR3X4UZQ93ZTUPGXCFA9II..png (100%) create mode 100644 server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp create mode 100644 server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp create mode 100644 server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp create mode 100644 server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg create mode 100644 server/scripts/ensure-dev-dist.js create mode 100644 server/src/database/typeorm-options.ts create mode 100644 server/src/modules/article/article-slug.util.ts create mode 100644 server/src/modules/bootstrap/bootstrap.constants.ts create mode 100644 server/src/modules/bootstrap/bootstrap.service.ts create mode 100644 server/src/modules/bootstrap/install-locale.ts create mode 100644 server/src/modules/extension/extension.module.ts create mode 100644 server/src/modules/extension/plugin-loader.service.ts create mode 100644 server/src/modules/extension/plugin-registry.bridge.ts create mode 100644 server/src/modules/extension/plugin.controller.ts create mode 100644 server/src/modules/extension/plugin.service.ts create mode 100644 server/src/modules/extension/preview-draft.store.ts create mode 100644 server/src/modules/extension/theme-registry.bridge.ts create mode 100644 server/src/modules/extension/theme.controller.ts create mode 100644 server/src/modules/extension/theme.service.ts create mode 100644 server/src/modules/file/file-optimization.controller.ts create mode 100644 server/src/modules/file/file-optimization.service.ts create mode 100644 server/src/modules/file/file-optimization.types.ts create mode 100644 server/src/modules/file/image-optimizer-plugin.guard.ts create mode 100644 server/src/modules/hook/hook.module.ts create mode 100644 server/src/modules/hook/hook.service.ts delete mode 100644 server/src/modules/install/install.controller.ts delete mode 100644 server/src/modules/install/install.module.ts delete mode 100644 server/src/modules/install/install.service.ts create mode 100644 server/src/node-polyfills.ts create mode 100644 server/src/utils/image-processor.util.ts create mode 100644 server/src/utils/local-api-quiet.util.ts create mode 100644 server/src/utils/project-root.util.ts delete mode 100644 templates/hello-world/README.md delete mode 100644 templates/hello-world/components/Footer.tsx delete mode 100644 templates/hello-world/components/Header.tsx delete mode 100644 templates/hello-world/next-env.d.ts delete mode 100644 templates/hello-world/next.config.js delete mode 100644 templates/hello-world/package.json delete mode 100644 templates/hello-world/pages/404.tsx delete mode 100644 templates/hello-world/pages/about.tsx delete mode 100644 templates/hello-world/pages/index.tsx delete mode 100644 templates/hello-world/tsconfig.json delete mode 100644 templates/twentytwentyfive/LICENSE delete mode 100644 templates/twentytwentyfive/README.md delete mode 100644 templates/twentytwentyfive/__tests__/template.test.tsx delete mode 100755 templates/twentytwentyfive/bin/create-twentytwentyfive.js delete mode 100644 templates/twentytwentyfive/components/Footer.tsx delete mode 100644 templates/twentytwentyfive/components/Header.tsx delete mode 100644 templates/twentytwentyfive/components/TagsCloud.tsx delete mode 100644 templates/twentytwentyfive/jest.config.js delete mode 100644 templates/twentytwentyfive/jest.setup.js delete mode 100644 templates/twentytwentyfive/next-env.d.ts delete mode 100644 templates/twentytwentyfive/next.config.js delete mode 100644 templates/twentytwentyfive/package-lock.json delete mode 100644 templates/twentytwentyfive/package.json delete mode 100644 templates/twentytwentyfive/pages/404.tsx delete mode 100644 templates/twentytwentyfive/pages/article/[id].tsx delete mode 100644 templates/twentytwentyfive/pages/category/[category].tsx delete mode 100644 templates/twentytwentyfive/pages/index.tsx delete mode 100644 templates/twentytwentyfive/pages/search.tsx delete mode 100644 templates/twentytwentyfive/pages/tag/[tag].tsx delete mode 100644 templates/twentytwentyfive/styles/globals.css delete mode 100644 templates/twentytwentyfive/tsconfig.json create mode 100644 themes/README.md create mode 100644 themes/hello-world/.gitignore create mode 100644 themes/hello-world/.next-preview/.reactpress-theme-id create mode 100644 themes/hello-world/.reactpress-preview-frame-patched create mode 100644 themes/hello-world/README.md rename {templates => themes}/hello-world/bin/create-hello-world.js (100%) create mode 100644 themes/hello-world/components/Footer.tsx create mode 100644 themes/hello-world/components/Header.tsx create mode 100644 themes/hello-world/components/PageHead.tsx create mode 100644 themes/hello-world/components/PostEntry.tsx create mode 100644 themes/hello-world/components/Sidebar.tsx create mode 100644 themes/hello-world/components/TagsCloud.tsx create mode 100644 themes/hello-world/components/ThemeShell.tsx create mode 100644 themes/hello-world/cover.svg create mode 100644 themes/hello-world/locales/en.json create mode 100644 themes/hello-world/next-env.d.ts create mode 100644 themes/hello-world/next.config.js create mode 100644 themes/hello-world/package.json create mode 100644 themes/hello-world/pages/404.tsx create mode 100644 themes/hello-world/pages/[path].tsx create mode 100644 themes/hello-world/pages/_app.tsx create mode 100644 themes/hello-world/pages/_document.tsx create mode 100644 themes/hello-world/pages/about.tsx create mode 100644 themes/hello-world/pages/article/[id].tsx create mode 100644 themes/hello-world/pages/category/[category].tsx create mode 100644 themes/hello-world/pages/index.tsx create mode 100644 themes/hello-world/pages/search.tsx create mode 100644 themes/hello-world/pages/tag/[tag].tsx rename {templates => themes}/hello-world/pages/toolkit-demo.tsx (89%) create mode 100644 themes/hello-world/server.js create mode 100644 themes/hello-world/styles/globals.css create mode 100644 themes/hello-world/theme.json create mode 100644 themes/hello-world/tsconfig.eslint.json create mode 100644 themes/hello-world/tsconfig.json delete mode 100644 themes/my-blog/public/favicon-16.png delete mode 100644 themes/my-blog/public/favicon-32.png delete mode 100644 themes/my-blog/public/favicon-48.png delete mode 100644 themes/my-blog/public/favicon.ico delete mode 100644 themes/my-blog/public/favicon.png delete mode 100644 themes/my-blog/public/icon-192.png delete mode 100644 themes/my-blog/public/icon-512.png delete mode 100644 themes/my-blog/public/logo-200.png delete mode 100644 themes/my-blog/public/logo-400.png delete mode 100644 themes/my-blog/public/logo.png delete mode 100644 themes/my-blog/public/logo.svg create mode 100644 themes/theme-starter/README.md create mode 100644 themes/theme-starter/package.json delete mode 100644 themes/twentytwentyfive/public/favicon-16.png delete mode 100644 themes/twentytwentyfive/public/favicon-32.png delete mode 100644 themes/twentytwentyfive/public/favicon-48.png delete mode 100644 themes/twentytwentyfive/public/favicon.ico delete mode 100644 themes/twentytwentyfive/public/favicon.png delete mode 100644 themes/twentytwentyfive/public/icon-192.png delete mode 100644 themes/twentytwentyfive/public/icon-512.png delete mode 100644 themes/twentytwentyfive/public/logo-200.png delete mode 100644 themes/twentytwentyfive/public/logo-400.png delete mode 100644 themes/twentytwentyfive/public/logo.png delete mode 100644 themes/twentytwentyfive/public/logo.svg create mode 100644 toolkit/src/plugin/admin/plugin-admin.ts create mode 100644 toolkit/src/plugin/admin/slots.ts create mode 100644 toolkit/src/plugin/extension/index.ts create mode 100644 toolkit/src/plugin/extension/plugin-admin-locale.io.ts create mode 100644 toolkit/src/plugin/extension/plugin-admin-locale.ts create mode 100644 toolkit/src/plugin/extension/plugin.ts create mode 100644 toolkit/src/plugin/extension/security.ts create mode 100644 toolkit/src/plugin/extension/validate.ts create mode 100644 toolkit/src/plugin/node.ts create mode 100644 toolkit/src/plugin/react/plugin-admin-ui.tsx create mode 100644 toolkit/src/plugin/server/hook-reject.ts create mode 100644 toolkit/src/plugin/server/index.ts create mode 100644 toolkit/src/plugin/server/types.ts create mode 100644 tsconfig.base.json create mode 100644 web/.env.development create mode 100644 web/.env.electron create mode 100644 web/.env.example create mode 100644 web/.env.production create mode 100644 web/.oxfmt.toml create mode 100755 web/.vite-hooks/pre-commit create mode 100644 web/AGENTS.md create mode 100644 web/README.md create mode 100755 web/bin/reactpress-web.js create mode 100644 web/e2e/helpers.ts create mode 100644 web/e2e/login.spec.ts create mode 100644 web/e2e/theme-preview-starter.spec.ts create mode 100644 web/e2e/themes.spec.ts create mode 100644 web/e2e/users.spec.ts create mode 100644 web/index.html create mode 100644 web/node/admin-route-helpers.cjs create mode 100644 web/node/index.d.ts create mode 100644 web/node/index.mjs create mode 100644 web/node/next.cjs create mode 100644 web/node/next.d.ts create mode 100644 web/node/next.mjs create mode 100644 web/node/static.mjs create mode 100644 web/package.json create mode 100644 web/playwright.config.ts create mode 100644 web/public/mockServiceWorker.js create mode 100644 web/public/showcase/undraw_around_the_world.svg create mode 100644 web/public/showcase/undraw_docusaurus_mountain.svg create mode 100644 web/public/showcase/undraw_docusaurus_react.svg create mode 100644 web/public/showcase/undraw_docusaurus_tree.svg create mode 100644 web/public/showcase/undraw_react.svg create mode 100644 web/public/showcase/undraw_typewriter.svg create mode 100644 web/public/showcase/undraw_version_control.svg create mode 100644 web/scripts/check-i18n-keys.mjs create mode 100644 web/scripts/prepack.mjs create mode 100644 web/src/api/auth.ts create mode 100644 web/src/api/schemas.ts create mode 100644 web/src/api/user.ts create mode 100644 web/src/components/Aurora/index.css create mode 100644 web/src/components/Aurora/index.tsx create mode 100644 web/src/components/DataTable/DataTableEmpty.tsx create mode 100644 web/src/components/DataTable/DataTableSkeleton.tsx create mode 100644 web/src/components/DataTable/index.css create mode 100644 web/src/components/DataTable/index.tsx create mode 100644 web/src/components/FilterToolbar/index.tsx create mode 100644 web/src/components/FormModal/index.tsx create mode 100644 web/src/components/Icon/GitHub.tsx create mode 100644 web/src/components/Icon/Theme.tsx create mode 100644 web/src/components/Icon/index.tsx create mode 100644 web/src/components/LanguageSwitcher/index.tsx create mode 100644 web/src/components/Layout/AppFooter/index.tsx create mode 100644 web/src/components/Layout/Header/index.tsx create mode 100644 web/src/components/Layout/MainLayout/index.tsx create mode 100644 web/src/components/Layout/Sidebar/index.css create mode 100644 web/src/components/Layout/Sidebar/index.tsx create mode 100644 web/src/components/Layout/UserMenu/index.css create mode 100644 web/src/components/Layout/UserMenu/index.tsx create mode 100644 web/src/components/Layout/admin-layout.css create mode 100644 web/src/components/NotFound/index.tsx create mode 100644 web/src/hooks/tokenBuilders.ts create mode 100644 web/src/hooks/useAppLocale.ts create mode 100644 web/src/hooks/useAppTheme.ts create mode 100644 web/src/hooks/useDocumentTitle.ts create mode 100644 web/src/hooks/useMinWidth.ts create mode 100644 web/src/hooks/usePendingCommentCount.ts create mode 100644 web/src/hooks/usePermission.ts create mode 100644 web/src/hooks/usePluginAdminLocale.ts create mode 100644 web/src/hooks/usePluginListItemMeta.ts create mode 100644 web/src/hooks/usePlugins.ts create mode 100644 web/src/hooks/useResourceCRUD.ts create mode 100644 web/src/hooks/useSiteSettings.ts create mode 100644 web/src/hooks/useSiteThemeState.ts create mode 100644 web/src/hooks/useThemeActivation.ts create mode 100644 web/src/hooks/useThemeAdminLocale.ts create mode 100644 web/src/hooks/useThemeConfiguration.ts create mode 100644 web/src/hooks/useThemeListItemMeta.ts create mode 100644 web/src/hooks/useThemePreviewHtml.ts create mode 100644 web/src/hooks/useThemePreviewSession.ts create mode 100644 web/src/hooks/useThemes.ts create mode 100644 web/src/hooks/wpAdminTokens.ts create mode 100644 web/src/i18n/format.ts create mode 100644 web/src/i18n/index.ts create mode 100644 web/src/i18n/locales/en.json create mode 100644 web/src/i18n/locales/zh.json create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/mocks/browser.ts create mode 100644 web/src/mocks/createHandler.ts create mode 100644 web/src/mocks/data.ts create mode 100644 web/src/mocks/handlers/article.ts create mode 100644 web/src/mocks/handlers/auth.ts create mode 100644 web/src/mocks/handlers/category.ts create mode 100644 web/src/mocks/handlers/comment.ts create mode 100644 web/src/mocks/handlers/index.ts create mode 100644 web/src/mocks/handlers/page.ts create mode 100644 web/src/mocks/handlers/plugins.ts create mode 100644 web/src/mocks/handlers/tag.ts create mode 100644 web/src/mocks/handlers/themes.ts create mode 100644 web/src/mocks/handlers/user.ts create mode 100644 web/src/mocks/mockCredentials.ts create mode 100644 web/src/mocks/mockSession.ts create mode 100644 web/src/mocks/utils.ts create mode 100644 web/src/modules/appearance/components/ActiveThemePanel.tsx create mode 100644 web/src/modules/appearance/components/AppearanceSectionFields.tsx create mode 100644 web/src/modules/appearance/components/AppearanceSettingField.tsx create mode 100644 web/src/modules/appearance/components/PreviewDeviceToolbar.tsx create mode 100644 web/src/modules/appearance/components/SearchHighlight.tsx create mode 100644 web/src/modules/appearance/components/ThemeAppearancePanel.tsx create mode 100644 web/src/modules/appearance/components/ThemeCard.tsx create mode 100644 web/src/modules/appearance/components/ThemeCatalogToolbar.tsx create mode 100644 web/src/modules/appearance/components/ThemeConfigurationForm.tsx create mode 100644 web/src/modules/appearance/components/ThemeCoverImage.tsx create mode 100644 web/src/modules/appearance/components/ThemeCoverPlaceholder.tsx create mode 100644 web/src/modules/appearance/components/ThemeOfficialBadge.tsx create mode 100644 web/src/modules/appearance/components/ThemePreviewFrame.tsx create mode 100644 web/src/modules/appearance/components/ThemePreviewPaneLoading.tsx create mode 100644 web/src/modules/appearance/components/ThemeSettingsEditor.tsx create mode 100644 web/src/modules/appearance/components/ThemeSettingsSearchBar.tsx create mode 100644 web/src/modules/appearance/components/ThemeSettingsSidebar.tsx create mode 100644 web/src/modules/appearance/components/formily/JsonConfigEditor.tsx create mode 100644 web/src/modules/appearance/components/formily/NavLinkListField.tsx create mode 100644 web/src/modules/appearance/components/formily/VsCodeSection.tsx create mode 100644 web/src/modules/appearance/components/formily/VsCodeSettingRow.tsx create mode 100644 web/src/modules/appearance/components/formily/nav-link-list-field.module.css create mode 100644 web/src/modules/appearance/components/formily/schemaField.tsx create mode 100644 web/src/modules/appearance/components/formily/vscode-section.module.css create mode 100644 web/src/modules/appearance/components/formily/vscode-setting-row.module.css create mode 100644 web/src/modules/appearance/components/search-highlight.module.css create mode 100644 web/src/modules/appearance/components/theme-configuration-form.module.css create mode 100644 web/src/modules/appearance/components/theme-cover-placeholder.module.css create mode 100644 web/src/modules/appearance/components/theme-official-badge.module.css create mode 100644 web/src/modules/appearance/components/theme-settings-editor.module.css create mode 100644 web/src/modules/appearance/components/theme-settings-page.module.css create mode 100644 web/src/modules/appearance/components/themes-page.module.css create mode 100644 web/src/modules/appearance/context/ThemeAdminLocaleContext.tsx create mode 100644 web/src/modules/appearance/context/ThemeSettingsSearchContext.tsx create mode 100644 web/src/modules/appearance/hooks/useThemeSettingsActiveId.ts create mode 100644 web/src/modules/appearance/index.ts create mode 100644 web/src/modules/appearance/pages/CustomizePage.tsx create mode 100644 web/src/modules/appearance/pages/ThemePreviewPage.tsx create mode 100644 web/src/modules/appearance/pages/ThemeSettingsPage.tsx create mode 100644 web/src/modules/appearance/pages/ThemesPage.tsx create mode 100644 web/src/modules/appearance/utils/appearanceNav.ts create mode 100644 web/src/modules/appearance/utils/isOfficialTheme.ts create mode 100644 web/src/modules/appearance/utils/parseSiteI18n.ts create mode 100644 web/src/modules/appearance/utils/patchVsCodeFormilySchema.ts create mode 100644 web/src/modules/appearance/utils/resolveAppearanceManifestText.ts create mode 100644 web/src/modules/appearance/utils/themeSettingsAnchors.ts create mode 100644 web/src/modules/appearance/utils/themeSettingsIndex.ts create mode 100644 web/src/modules/appearance/utils/themeSettingsScrollLock.ts create mode 100644 web/src/modules/article/articleEditorApi.ts create mode 100644 web/src/modules/article/articleListApi.ts create mode 100644 web/src/modules/article/components/ArticleCategoryTagsFields.tsx create mode 100644 web/src/modules/article/components/ArticleEditorSidebar.tsx create mode 100644 web/src/modules/article/components/ArticleListSubHeader.tsx create mode 100644 web/src/modules/article/components/ArticleListTablenav.tsx create mode 100644 web/src/modules/article/components/EditorMetaPanel.tsx create mode 100644 web/src/modules/article/components/article-editor-sidebar.module.css create mode 100644 web/src/modules/article/components/article-editor.module.css create mode 100644 web/src/modules/article/components/article-list.module.css create mode 100644 web/src/modules/article/components/articleListThemeVars.ts create mode 100644 web/src/modules/article/components/editor-meta-panel.module.css create mode 100644 web/src/modules/article/components/taxonomy-admin.module.css create mode 100644 web/src/modules/article/constants.ts create mode 100644 web/src/modules/article/index.ts create mode 100644 web/src/modules/article/pages/ArticleEditorPage.tsx create mode 100644 web/src/modules/article/pages/ArticleListPage.tsx create mode 100644 web/src/modules/article/pages/TaxonomyManagePage.tsx create mode 100644 web/src/modules/article/taxonomyApi.ts create mode 100644 web/src/modules/comment/commentListApi.ts create mode 100644 web/src/modules/comment/components/CommentListSubHeader.tsx create mode 100644 web/src/modules/comment/components/CommentListTablenav.tsx create mode 100644 web/src/modules/comment/components/comment-list.module.css create mode 100644 web/src/modules/comment/index.ts create mode 100644 web/src/modules/comment/pages/CommentListPage.tsx create mode 100644 web/src/modules/comment/pendingCommentCountApi.ts create mode 100644 web/src/modules/dashboard/components/RecentCommentsCard.tsx create mode 100644 web/src/modules/dashboard/components/recent-comments.module.css create mode 100644 web/src/modules/dashboard/dashboardCommentApi.ts create mode 100644 web/src/modules/dashboard/dashboardThemeVars.ts create mode 100644 web/src/modules/dashboard/index.ts create mode 100644 web/src/modules/dashboard/pages/DashboardPage.tsx create mode 100644 web/src/modules/data/index.ts create mode 100644 web/src/modules/data/pages/AnalyticsPage.tsx create mode 100644 web/src/modules/data/pages/ExportPage.tsx create mode 100644 web/src/modules/data/pages/ImportPage.tsx create mode 100644 web/src/modules/media/components/MediaListTablenav.tsx create mode 100644 web/src/modules/media/components/MediaListToolbar.tsx create mode 100644 web/src/modules/media/components/media-list.module.css create mode 100644 web/src/modules/media/components/mediaListThemeVars.ts create mode 100644 web/src/modules/media/index.ts create mode 100644 web/src/modules/media/mediaListApi.ts create mode 100644 web/src/modules/media/pages/MediaListPage.tsx create mode 100644 web/src/modules/page/components/PageListSubHeader.tsx create mode 100644 web/src/modules/page/components/PageListTablenav.tsx create mode 100644 web/src/modules/page/components/page-editor.module.css create mode 100644 web/src/modules/page/index.ts create mode 100644 web/src/modules/page/pageListApi.ts create mode 100644 web/src/modules/page/pages/PageEditorPage.tsx create mode 100644 web/src/modules/page/pages/PageListPage.tsx create mode 100644 web/src/modules/plugins/components/PluginListSubHeader.tsx create mode 100644 web/src/modules/plugins/components/PluginListTablenav.tsx create mode 100644 web/src/modules/plugins/components/plugin-settings-page.module.css create mode 100644 web/src/modules/plugins/components/plugins-page.module.css create mode 100644 web/src/modules/plugins/context/PluginAdminLocaleContext.tsx create mode 100644 web/src/modules/plugins/index.ts create mode 100644 web/src/modules/plugins/pages/PluginSettingsPage.tsx create mode 100644 web/src/modules/plugins/pages/PluginsPage.tsx create mode 100644 web/src/modules/plugins/utils/pluginSettingsSchema.ts create mode 100644 web/src/modules/plugins/utils/resolvePluginManifestText.ts create mode 100644 web/src/modules/settings/components/SettingsTabForm.tsx create mode 100644 web/src/modules/settings/components/settings-form.module.css create mode 100644 web/src/modules/settings/index.ts create mode 100644 web/src/modules/settings/pages/SettingsLayoutPage.tsx create mode 100644 web/src/modules/settings/siteSettingDefaults.ts create mode 100644 web/src/modules/settings/smtpTestApi.ts create mode 100644 web/src/modules/user/components/UserListSubHeader.tsx create mode 100644 web/src/modules/user/components/UserListTablenav.tsx create mode 100644 web/src/modules/user/components/profile.module.css create mode 100644 web/src/modules/user/components/user-list.module.css create mode 100644 web/src/modules/user/index.ts create mode 100644 web/src/modules/user/pages/ProfilePage.tsx create mode 100644 web/src/modules/user/pages/UserListPage.tsx create mode 100644 web/src/modules/user/profileApi.ts create mode 100644 web/src/modules/user/userListApi.ts create mode 100644 web/src/routeTree.gen.ts create mode 100644 web/src/routes/404/index.tsx create mode 100644 web/src/routes/__root.tsx create mode 100644 web/src/routes/_auth.tsx create mode 100644 web/src/routes/_auth/403/index.tsx create mode 100644 web/src/routes/_auth/appearance/customize/index.tsx create mode 100644 web/src/routes/_auth/appearance/themes/$themeId/settings/index.tsx create mode 100644 web/src/routes/_auth/appearance/themes/index.tsx create mode 100644 web/src/routes/_auth/appearance/themes/preview/index.tsx create mode 100644 web/src/routes/_auth/article/category/index.tsx create mode 100644 web/src/routes/_auth/article/comment/index.tsx create mode 100644 web/src/routes/_auth/article/editor/$id.tsx create mode 100644 web/src/routes/_auth/article/editor/index.tsx create mode 100644 web/src/routes/_auth/article/index.tsx create mode 100644 web/src/routes/_auth/article/tags/index.tsx create mode 100644 web/src/routes/_auth/dashboard/index.css create mode 100644 web/src/routes/_auth/dashboard/index.tsx create mode 100644 web/src/routes/_auth/data/analytics/index.tsx create mode 100644 web/src/routes/_auth/data/export/index.tsx create mode 100644 web/src/routes/_auth/data/import/index.tsx create mode 100644 web/src/routes/_auth/media/index.tsx create mode 100644 web/src/routes/_auth/page/editor/$id.tsx create mode 100644 web/src/routes/_auth/page/editor/index.tsx create mode 100644 web/src/routes/_auth/page/index.tsx create mode 100644 web/src/routes/_auth/plugins/$id/settings/index.tsx create mode 100644 web/src/routes/_auth/plugins/index.tsx create mode 100644 web/src/routes/_auth/profile/index.tsx create mode 100644 web/src/routes/_auth/settings/$tab/index.tsx create mode 100644 web/src/routes/_auth/settings/index.tsx create mode 100644 web/src/routes/_auth/users/-FormModal.tsx create mode 100644 web/src/routes/_auth/users/index.tsx create mode 100644 web/src/routes/index.tsx create mode 100644 web/src/routes/login/-LoginBrandMark.tsx create mode 100644 web/src/routes/login/-LoginCliSnippet.tsx create mode 100644 web/src/routes/login/-LoginHeroLinks.tsx create mode 100644 web/src/routes/login/-LoginSnapNav.tsx create mode 100644 web/src/routes/login/-loginCyberLogo.ts create mode 100644 web/src/routes/login/-loginHeroSlides.ts create mode 100644 web/src/routes/login/docs-home/-LoginDocsFeatures.tsx create mode 100644 web/src/routes/login/docs-home/-LoginDocsHero.tsx create mode 100644 web/src/routes/login/docs-home/-LoginDocsHighlights.tsx create mode 100644 web/src/routes/login/docs-home/hero/Devices.tsx create mode 100644 web/src/routes/login/docs-home/hero/FloorBackground.tsx create mode 100644 web/src/routes/login/docs-home/hero/GridBackground.tsx create mode 100644 web/src/routes/login/docs-home/hero/Logo.tsx create mode 100644 web/src/routes/login/docs-home/hero/styles.module.css create mode 100644 web/src/routes/login/docs-home/index.tsx create mode 100644 web/src/routes/login/docs-home/login-docs-cta.module.css create mode 100644 web/src/routes/login/docs-home/login-docs-features.module.css create mode 100644 web/src/routes/login/docs-home/login-docs-highlights.module.css create mode 100644 web/src/routes/login/docs-home/login-docs-home.module.css create mode 100644 web/src/routes/login/index.tsx create mode 100644 web/src/routes/login/login-brand-mark.module.css create mode 100644 web/src/routes/login/login-cli-snippet.module.css create mode 100644 web/src/routes/login/login-page.module.css create mode 100644 web/src/routes/login/login-snap-nav.module.css create mode 100644 web/src/routes/register/index.tsx create mode 100644 web/src/routes/searchDefaults.ts create mode 100644 web/src/shared/api/fileOptimize.ts create mode 100644 web/src/shared/api/getApiErrorMessage.ts create mode 100644 web/src/shared/api/pagination.ts create mode 100644 web/src/shared/api/plugins.ts create mode 100644 web/src/shared/api/themes.ts create mode 100644 web/src/shared/api/uploadFile.ts create mode 100644 web/src/shared/auth/adminAccess.ts create mode 100644 web/src/shared/auth/loginErrorMessage.ts create mode 100644 web/src/shared/auth/registerAccount.ts create mode 100644 web/src/shared/auth/session.ts create mode 100644 web/src/shared/client.ts create mode 100644 web/src/shared/coerceApiString.ts create mode 100644 web/src/shared/components/Dashicon.tsx create mode 100644 web/src/shared/components/Editor/DefaultMarkdown.ts create mode 100644 web/src/shared/components/Editor/MonacoEditor.tsx create mode 100644 web/src/shared/components/Editor/Preview.tsx create mode 100644 web/src/shared/components/Editor/editor.module.css create mode 100644 web/src/shared/components/Editor/index.tsx create mode 100644 web/src/shared/components/Editor/toolbar/AddCode.tsx create mode 100644 web/src/shared/components/Editor/toolbar/Emoji.tsx create mode 100644 web/src/shared/components/Editor/toolbar/FormatToolbar.tsx create mode 100644 web/src/shared/components/Editor/toolbar/Iframe.tsx create mode 100644 web/src/shared/components/Editor/toolbar/Image.tsx create mode 100644 web/src/shared/components/Editor/toolbar/Magimg.tsx create mode 100644 web/src/shared/components/Editor/toolbar/Video.tsx create mode 100644 web/src/shared/components/Editor/toolbar/emojis.ts create mode 100644 web/src/shared/components/Editor/toolbar/index.tsx create mode 100644 web/src/shared/components/Editor/toolbar/markdownActions.ts create mode 100644 web/src/shared/components/Editor/toolbar/types.ts rename {client/src => web/src/shared}/components/Editor/utils/markdown.ts (55%) create mode 100644 web/src/shared/components/Editor/utils/modal.tsx rename {client/src => web/src/shared}/components/Editor/utils/syncScroll.tsx (56%) create mode 100644 web/src/shared/components/Editor/utils/uploadInsert.ts create mode 100644 web/src/shared/components/ListPaginationNav.tsx create mode 100644 web/src/shared/components/MarkdownReader/index.tsx create mode 100644 web/src/shared/components/MarkdownReader/markdown-reader.module.css create mode 100644 web/src/shared/components/MediaSelectDrawer/index.tsx create mode 100644 web/src/shared/components/MediaSelectDrawer/media-select-drawer.module.css create mode 100644 web/src/shared/components/ModulePlaceholder.tsx create mode 100644 web/src/shared/components/SiteNoticeListField/index.tsx create mode 100644 web/src/shared/components/SiteNoticeListField/site-notice-list-field.module.css create mode 100644 web/src/shared/components/Toc/index.tsx create mode 100644 web/src/shared/components/Toc/toc.module.css create mode 100644 web/src/shared/desktop/DesktopLocalQueryPolicy.tsx create mode 100644 web/src/shared/desktop/DesktopModeBadge.tsx create mode 100644 web/src/shared/desktop/DesktopModeSwitch.tsx create mode 100644 web/src/shared/desktop/DesktopWorkspacePanel.tsx create mode 100644 web/src/shared/desktop/apiConfig.ts create mode 100644 web/src/shared/desktop/desktop-api-setup.module.css create mode 100644 web/src/shared/desktop/desktop-mode-badge.module.css create mode 100644 web/src/shared/desktop/electronRouting.ts create mode 100644 web/src/shared/desktop/refreshApiContext.ts create mode 100644 web/src/shared/desktop/syncToRemote.ts create mode 100644 web/src/shared/hooks/useToggle.ts create mode 100644 web/src/shared/menu.ts create mode 100644 web/src/shared/pluginAdmin/loaders.ts create mode 100644 web/src/shared/pluginAdmin/reactpress-plugins.d.ts create mode 100644 web/src/shared/queryClient.ts create mode 100644 web/src/shared/styles/admin-form-table.module.css create mode 100644 web/src/shared/styles/editor-theme.css create mode 100644 web/src/shared/styles/markdown.css create mode 100644 web/src/shared/theme/clearThemeMods.ts create mode 100644 web/src/shared/theme/normalizeMods.ts create mode 100644 web/src/shared/theme/previewUrl.ts create mode 100644 web/src/shared/theme/waitForVisitorSite.ts create mode 100644 web/src/shared/types/showdown.d.ts create mode 100644 web/src/shell/PluginAdminProvider.tsx create mode 100644 web/src/shell/bootstrap.ts create mode 100644 web/src/shell/permissions.ts create mode 100644 web/src/stores/auth.ts create mode 100644 web/src/stores/createPersistentStore.ts create mode 100644 web/src/stores/desktop.ts create mode 100644 web/src/stores/settings.ts create mode 100644 web/src/utils/appMenu.ts create mode 100644 web/src/utils/constants.ts create mode 100644 web/src/utils/http.ts create mode 100644 web/src/utils/session.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.eslint.json create mode 100644 web/tsconfig.json create mode 100644 web/vercel.json create mode 100644 web/vite.config.ts diff --git a/.env b/.env index f8bd3b74..a7860bc1 100644 --- a/.env +++ b/.env @@ -1,13 +1,12 @@ -# ReactPress — copy to .env and run `pnpm init` to sync from .reactpress/config.json +# ReactPress — managed by reactpress-cli DB_HOST=127.0.0.1 -DB_PORT=3306 +DB_PORT=3307 DB_USER=reactpress DB_PASSWD=reactpress -DB_DATABASE=reactpress - - -# Client Config +DB_DATABASE=.reactpress/reactpress.db CLIENT_SITE_URL=http://localhost:3001 - -# Server Config -SERVER_SITE_URL=http://localhost:3002 \ No newline at end of file +SERVER_SITE_URL=http://localhost:3002 +SERVER_PORT=3002 +DB_TYPE=sqlite +SERVER_API_PREFIX=/api +REACTPRESS_UPLOAD_DIR=/Users/xiu/Documents/my-code/gaoredu-blog/easy-blog-publish/uploads diff --git a/.eslintrc.js b/.eslintrc.js index 8102f728..1923bb0c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,19 +7,63 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, overrides: [ { - files: ['**/*.{ts,tsx,js,jsx}'], + files: ['web/**/*.{ts,tsx}'], + parserOptions: { + project: ['./web/tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + { + files: ['toolkit/**/*.ts'], + parserOptions: { + project: ['./toolkit/tsconfig.json'], + tsconfigRootDir: __dirname, + }, + }, + { + files: ['server/**/*.ts'], parserOptions: { - project: ['./client/tsconfig.json'], + project: ['./server/tsconfig.json'], tsconfigRootDir: __dirname, - sourceType: 'module', + }, + }, + { + files: ['docs/**/*.{ts,tsx}'], + parserOptions: { + project: ['./docs/tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + { + files: ['themes/hello-world/**/*.{ts,tsx}'], + parserOptions: { + project: ['./themes/hello-world/tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + { + // Next.js regenerates next-env.d.ts with /// for route types. + files: ['**/next-env.d.ts'], + rules: { + '@typescript-eslint/triple-slash-reference': 'off', }, }, ], settings: { react: { - version: '17.0', + version: 'detect', + }, + 'import/resolver': { + node: { + paths: ['./'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, }, }, env: { @@ -43,12 +87,23 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/ban-types': 0, 'react-hooks/rules-of-hooks': 2, - 'react-hooks/exhaustive-deps': 2, + 'react-hooks/exhaustive-deps': 1, 'react/prop-types': 0, 'react/react-in-jsx-scope': 0, - 'prettier/prettier': 'error', - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', + // Prettier 2.x cannot parse modern TS (`import type`, inline `type` imports). + // web uses `vp fmt` (Oxfmt); keep formatting out of ESLint to avoid false IDE errors. + 'prettier/prettier': 0, + 'simple-import-sort/imports': 'off', + 'simple-import-sort/exports': 'off', }, - ignorePatterns: ['dist/', 'node_modules', 'scripts'], + ignorePatterns: [ + 'dist/', + 'node_modules', + 'scripts', + 'examples', + '**/.next', + 'toolkit/dist', + 'server/dist', + 'web/src/routeTree.gen.ts', + ], }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f95a744..31b1732c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,10 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies @@ -50,22 +48,17 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 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: Production build + run: pnpm run build - name: Create test .env run: | diff --git a/.github/workflows/deploy-ecs.yml b/.github/workflows/deploy-ecs.yml new file mode 100644 index 00000000..f87a0d11 --- /dev/null +++ b/.github/workflows/deploy-ecs.yml @@ -0,0 +1,110 @@ +# Build on GitHub → upload release tarball → SSH deploy on ECS. +# +# Required GitHub Secrets (Settings → Secrets and variables → Actions): +# ECS_HOST ECS public IP or domain +# ECS_USER SSH user, e.g. root or deploy +# ECS_SSH_KEY Private key (PEM), full content including BEGIN/END lines +# ECS_APP_DIR App root on server, e.g. /opt/reactpress +# +# Optional Secrets: +# ECS_SSH_PORT Default 22 +# NGINX_ENTRY_URL Public site URL written into deploy env, e.g. https://blog.example.com +# +# One-time ECS setup: +# 1. Clone repo to ECS_APP_DIR, create .env (DB, ports, domain — never commit) +# 2. Install Node 24, pnpm, PM2, Docker; run `pnpm run deploy` once manually +# 3. Add GitHub Actions deploy key / user SSH public key to ECS ~/.ssh/authorized_keys +# +name: Deploy ECS + +on: + workflow_dispatch: + push: + branches: [main, master] + +concurrency: + group: deploy-ecs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Production build + pack + run: pnpm run build + + - name: Locate release tarball + id: pack + run: | + TAR="$(ls -1 dist/reactpress-release-*.tar.gz | tail -1)" + echo "path=$TAR" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$TAR")" >> "$GITHUB_OUTPUT" + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: reactpress-release + path: ${{ steps.pack.outputs.path }} + retention-days: 7 + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + environment: production + steps: + - name: Download release artifact + uses: actions/download-artifact@v4 + with: + name: reactpress-release + path: dist + + - name: Resolve tarball name + id: tar + run: | + TAR="$(ls -1 dist/reactpress-release-*.tar.gz | tail -1)" + echo "path=$TAR" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$TAR")" >> "$GITHUB_OUTPUT" + + - name: Prepare upload directory + run: | + mkdir -p upload + cp "${{ steps.tar.outputs.path }}" "upload/${{ steps.tar.outputs.name }}" + + - name: Copy release to ECS + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.ECS_HOST }} + username: ${{ secrets.ECS_USER }} + key: ${{ secrets.ECS_SSH_KEY }} + port: ${{ secrets.ECS_SSH_PORT || 22 }} + source: upload/${{ steps.tar.outputs.name }} + target: ${{ secrets.ECS_APP_DIR }}/dist/ + + - name: Deploy on ECS + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.ECS_HOST }} + username: ${{ secrets.ECS_USER }} + key: ${{ secrets.ECS_SSH_KEY }} + port: ${{ secrets.ECS_SSH_PORT || 22 }} + command_timeout: 30m + script: | + set -e + cd "${{ secrets.ECS_APP_DIR }}" + git fetch origin "${{ github.ref_name }}" --depth=1 + git checkout "${{ github.sha }}" + export NGINX_ENTRY_URL="${{ secrets.NGINX_ENTRY_URL }}" + pnpm run deploy -- "dist/${{ steps.tar.outputs.name }}" diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 4b8df181..c3600446 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -1,33 +1,30 @@ -name: Node.js Package +name: Release on: release: types: [created] jobs: - build: + validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - - run: npm test + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 - 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}} + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Production build + run: pnpm run build + + - name: Test + run: pnpm test + + - name: Build publishable packages + run: pnpm run publish:build diff --git a/.gitignore b/.gitignore index 0b7e6763..0c23f6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,17 @@ +# 官方主题模板(运行时副本在 .reactpress/runtime/,已由 .reactpress/ 忽略) +themes/* +!themes/hello-world/ +!themes/theme-starter/ +!themes/.gitkeep +!themes/README.md + node_modules .DS_Store .idea .next +.next-preview .cursor +.brand-export/ .env.prod .reactpress/ @@ -14,16 +23,33 @@ node_modules sitemap.xml lib -!cli/lib +!cli/src/lib/ +!themes/hello-world/**/lib/ +!themes/theme-starter/**/lib/ +cli/out/ dist dist-ssr coverage logs test-results +web/dist +web/playwright-report +web/.tanstack + +# Electron desktop (desktop/) +desktop/out/ +desktop/.cache/ +desktop/release/ +desktop/*.dmg +desktop/*.zip +desktop/*.exe +desktop/*.AppImage +desktop/*.blockmap +desktop/builder-debug.yml +desktop/builder-effective-config.yaml + .pnpm-store tsconfig.tsbuildinfo -.pnpm-store - # Production (root output only; do not use bare `build` — toolkit/src/theme/build is source) diff --git a/.npmrc b/.npmrc index 953b26a8..580aec53 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ strict-peer-dependencies=false -engine-strict = true +engine-strict = false engine=node >=18.20.4 \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..8fdd954d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 0b0cebb6..370c31cc 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,7 +2,7 @@ "singleQuote": true, "quoteProps": "consistent", "bracketSpacing": true, - "jsxBracketSameLine": false, + "bracketSameLine": false, "arrowParens": "always", "trailingComma": "es5", "tabWidth": 2, diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..50c388e1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.preferences.includePackageJsonAutoImports": "auto", + "eslint.useFlatConfig": false, + "eslint.workingDirectories": [{ "mode": "auto" }], + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "eslint.rules.customizations": [ + { "rule": "react-hooks/exhaustive-deps", "severity": "warn" }, + { "rule": "@typescript-eslint/no-inferrable-types", "severity": "warn" } + ], + "editor.formatOnSave": false, + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "files.associations": { + "*.css": "css" + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..9aa2da3e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1107 @@ +# ReactPress System Architecture + +> ReactPress 4.0 — modern full-stack CMS / blog publishing platform on React +> Core principle: **Admin manages content · Themes manage presentation · Plugins manage logic · API manages data · Toolkit manages contracts** + +--- + +## Table of contents + +- [1. Overview](#1-overview) +- [2. Architecture](#2-architecture) +- [3. Design principles & decisions](#3-design-principles--decisions) +- [4. Monorepo package structure](#4-monorepo-package-structure) +- [5. Runtime & ports](#5-runtime--ports) +- [6. Data flow & dependency rules](#6-data-flow--dependency-rules) +- [7. Maintainability](#7-maintainability) +- [8. Extensibility](#8-extensibility) +- [9. Technical choices](#9-technical-choices) +- [10. Cost & multi-platform](#10-cost--multi-platform) +- [11. Server (backend API)](#11-server-backend-api) +- [12. Web (admin SPA)](#12-web-admin-spa) +- [13. Themes (visitor frontend)](#13-themes-visitor-frontend) +- [14. Toolkit (shared contract layer)](#14-toolkit-shared-contract-layer) +- [15. CLI (orchestration)](#15-cli-orchestration) +- [16. Auth & security](#16-auth--security) +- [17. Configuration](#17-configuration) +- [18. Deployment](#18-deployment) +- [19. Local development](#19-local-development) +- [20. Plugins](#20-plugins) +- [21. Desktop client](#21-desktop-client) +- [22. Evolution & roadmap](#22-evolution--roadmap) +- [23. Acceptance criteria](#23-acceptance-criteria) +- [24. References](#24-references) + +--- + +## 1. Overview + +ReactPress is a WordPress-like content platform with a clear separation of concerns: + +| Domain | Capabilities | +|--------|--------------| +| Content | Articles, categories, tags, comments, static pages | +| Media | Upload, media library, storage (local / OSS) | +| Appearance | Theme install / activate / preview, site customization | +| Extensions | Plugin install / enable / configure | +| System | Users & permissions, site settings, import/export, analytics | + +### Non-functional goals + +| Goal | Target | +|------|--------| +| Admin responsiveness | Shell stays mounted; route switches feel < 100ms; list views cache on revisit | +| Visitor SEO | Core pages SSR/ISR; Lighthouse SEO ≥ 90 | +| Multi-device | One responsive web app for desktop / tablet / mobile | +| Data consistency | All frontends access API only through toolkit | + +--- + +## 2. Architecture + +ReactPress uses a **Monorepo + multi-process** model: content management, visitor presentation, and API services are decoupled; toolkit keeps types and contracts aligned. + +```mermaid +flowchart TB + subgraph Presentation["Presentation (replaceable, extensible)"] + Web["web — Admin SPA"] + Desktop["desktop — Electron shell (loads web/dist)"] + Theme["themes/* — Visitor SSR"] + PluginUI["plugins/*/admin — Plugin Admin slots"] + end + + subgraph Contract["Contract (stable, shared)"] + Toolkit["toolkit
api · types · react · admin · theme · plugin"] + end + + subgraph Platform["Platform (cannot be bypassed)"] + Server["server — REST · auth · Hook · extension registry"] + CLI["cli — process orchestration · scaffolding"] + DB[(MySQL / SQLite)] + end + + Web --> Toolkit + Desktop --> Web + Theme --> Toolkit + PluginUI --> Toolkit + Toolkit -->|HTTP /api| Server + Server -->|Hook| PluginServer["plugins/*/dist — Server modules"] + Server --> DB + CLI --> Web + CLI --> Theme + CLI --> Server + CLI --> Desktop +``` + +### Responsibility matrix + +| Package | Single responsibility | Rendering | SEO | +|---------|----------------------|-----------|-----| +| **server** | Business rules, persistence, auth, extension lifecycle | — | — | +| **web** | Admin UI | Vite CSR SPA | No | +| **themes/** | Visitor site | Next.js SSR/SSG/ISR | Yes | +| **toolkit** | API client, types, React integration, extension schemas | — | — | +| **plugins/** | Incremental logic (Hook + optional Admin UI) | Server + Admin slots | Plugin-dependent | +| **desktop/** | Electron shell, local API orchestration, IPC | Loads `web/dist` | No | +| **cli** | Local dev / deploy orchestration | — | — | +| **docs** | Project docs (Docusaurus) | SSG | — | + +### Architecture red lines + +- **No visitor pages in Admin**; **no admin routes in themes** (new themes must follow this) +- All frontends (web / themes / plugins) **depend on toolkit only** for API access +- **server must not depend on any frontend package** + +--- + +## 3. Design principles & decisions + +All trade-offs follow this priority: + +```mermaid +flowchart LR + M[Maintainability] --> E[Extensibility] + E --> T[Technical fit] + T --> C[Low cost] +``` + +| Principle | Meaning | How it lands | +|-------------|---------|--------------| +| **Maintainability** | Change one place, test one place, clear boundaries | Layering + Feature Modules + single API client + OpenAPI codegen | +| **Extensibility** | Core changes rarely; third parties can attach | Registry + Hook + manifest contracts | +| **Technical fit** | Match tech to scenario; avoid stack bloat | Admin = SPA, public pages = SSR, business logic in Server | +| **Low cost** | Few processes, few repos, little duplication | Monorepo + shared toolkit; responsive web instead of native apps | + +### Key decision summary + +| Decision | Choice | Maintainability | Extensibility | Fit | Cost | +|----------|--------|-----------------|---------------|-----|------| +| API access | toolkit as sole entry | ★★★ | ★★ | ★★★ | Low | +| Admin | Vite SPA | ★★ | ★★ | ★★★ | Low | +| Visitor | Next.js SSR/ISR | ★★ | ★★★ | ★★★ | Medium | +| Module layout | Feature Module + Registry | ★★★ | ★★★ | ★★ | Low | +| Plugins | Hook + manifest | ★★ | ★★★ | ★★★ | Medium | +| Themes | Separate process + `theme.json` | ★★ | ★★★ | ★★★ | Medium | +| List state | URL searchParams | ★★★ | ★★ | ★★★ | Low | +| Multi-device | Responsive web + Electron shell | ★★★ | ★★ | ★★★ | Medium | +| Types | OpenAPI codegen | ★★★ | ★★ | ★★★ | Low | + +--- + +## 4. Monorepo package structure + +Managed with **pnpm workspace** (`pnpm-workspace.yaml`): + +```yaml +packages: + - 'cli' # Global CLI (@fecommunity/reactpress) + - 'server' # NestJS API + - 'web' # Admin SPA + - 'desktop' # Electron shell + - 'docs' # Docusaurus docs site + - 'toolkit' # Shared API contract layer + - 'themes' # Theme registry + - 'themes/*' # Official theme templates + - 'plugins' # Plugin registry + - 'plugins/*' # Official plugins +``` + +### Repository tree (core) + +``` +easy-blog-publish/ +├── cli/ # CLI with bundled server +├── server/ # NestJS API source +├── web/ # Vite Admin SPA +├── desktop/ # Electron (local SQLite + Admin SPA) +├── toolkit/ # OpenAPI SDK + React integration +├── themes/ +│ ├── hello-world/ # Starter theme (local) +│ └── theme-starter/ # npm official theme catalog anchor +├── plugins/ +│ ├── hello-world/ # Auto summary plugin +│ ├── seo/ # SEO enhancement +│ └── image-optimizer/ # Image batch optimization +├── docs/ # Docusaurus +├── public/ # Marketing / brand assets +├── scripts/ # Build, deploy, smoke tests +├── docker-compose.*.yml +├── nginx*.conf +├── .reactpress/ # Runtime: active-theme.json, runtime/, plugins/ +└── package.json +``` + +### npm package mapping + +| Directory | npm package | Notes | +|-----------|-------------|-------| +| `cli/` | `@fecommunity/reactpress` | 4.0 main package; global `reactpress` command | +| `web/` | `@fecommunity/reactpress-web` | Admin SPA | +| `server/` | `@fecommunity/reactpress-server` | Monorepo source; standalone npm deprecated — use CLI bundled API | +| `toolkit/` | `@fecommunity/reactpress-toolkit` | Shared SDK | +| `themes/hello-world` | `@fecommunity/reactpress-template-hello-world` | Starter theme | + +--- + +## 5. Runtime & ports + +CLI orchestrates independent processes in local development: + +| Process | Default port | Stack | Notes | +|---------|--------------|-------|-------| +| **web** | 3000 | Vite + React | Admin entry | +| **active theme** | 3001 | Next.js | Current visitor theme | +| **server** | 3002 | NestJS | REST API (prefix `/api`) | +| **preview theme** | 3003 | Next.js | Admin iframe preview of non-active theme | +| **MySQL** | 3306 | MySQL 5.7 | Full-stack dev / default production persistence | +| **desktop local API** | 13102 | NestJS + SQLite | Embedded API in `pnpm dev:desktop` | +| **nginx** (optional) | 80 / 8080 | nginx | Unified reverse proxy | + +Three core processes (Admin, theme, API) deploy and scale independently — traffic patterns differ, so separation beats a monolithic Next app. + +```mermaid +flowchart LR + Browser["Browser"] + Nginx["nginx :80"] + Web["web :3000"] + Theme["theme :3001"] + Preview["preview :3003"] + API["server :3002"] + DB[(MySQL :3306)] + + Browser -->|"/admin"| Nginx + Browser -->|"/"| Nginx + Nginx -->|"/admin/"| Web + Nginx -->|"/"| Theme + Nginx -->|"/api"| API + Web -->|"/api proxy"| API + Theme -->|toolkit HTTP| API + Preview -->|toolkit HTTP| API + API --> DB +``` + +--- + +## 6. Data flow & dependency rules + +### Typical request paths + +**Admin write:** + +``` +web page → toolkit createClient() → POST /api/article → server ArticleService → DB +``` + +**Visitor read:** + +``` +theme getServerSideProps → toolkit fetchSingleArticle() → GET /api/article/:id → server → DB +``` + +**Theme management:** + +``` +web Appearance → GET /api/extension/themes → server ThemeService + → Setting.globalSetting (activeTheme / mods) + → .reactpress/active-theme.json + → CLI restarts Next.js theme process +``` + +### Sequence: publish article + +```mermaid +sequenceDiagram + participant U as Admin + participant W as web SPA + participant T as toolkit + participant S as server + participant H as Hook / plugin + + U->>W: Edit and publish + W->>T: useMutation → api.article.update + T->>S: PUT /article/:id + S->>H: applyFilters('article.beforePublish') + H-->>S: mutated payload + S->>S: persist + S->>H: doAction('article.afterPublish') + S-->>T: 200 + data + T-->>W: invalidateQueries + W-->>U: success notification +``` + +### Sequence: visitor article page + +```mermaid +sequenceDiagram + participant V as Visitor + participant Th as theme Next.js + participant T as toolkit/theme + participant S as server + + V->>Th: GET /article/hello-world + Th->>T: fetchArticle (SSR) + T->>S: GET /article/by-slug/hello-world + S-->>T: article + meta + T-->>Th: typed data + Th-->>V: full HTML + JSON-LD +``` + +### Dependency rules (hard constraints) + +``` +web / themes / plugins → toolkit only +toolkit → HTTP + stdlib only (no Ant Design / Next deps) +server → no frontend packages +plugins/server → server Hook + DI interfaces only +cli → orchestrates server/web/themes; not imported by business code +``` + +--- + +## 7. Maintainability + +### Single data entry: toolkit + +**Problem:** Multiple hand-rolled HTTP layers → type drift, inconsistent errors, N places to change on API updates. + +**Solution:** One client factory for the whole platform: + +```typescript +export function createClient(options: ClientOptions) { + const http = createHttpClient(options); + return { + article: new Article(http), + file: new File(http), + extension: new Extension(http), + // … mirrors server controllers + }; +} +``` + +**Benefits:** + +- Server API change → run codegen → TypeScript errors pinpoint callers +- Error codes, auth, retry logic written once +- New modules (web / theme / plugin) with zero HTTP boilerplate + +### Feature Modules (vertical slices) + +Each domain is self-contained: + +``` +web/src/modules/article/ +├── index.ts # public export: register(admin) +├── routes.tsx # TanStack Router routes +├── pages/ # thin pages composing hooks + components +├── components/ # module-private UI +├── hooks/ # data + URL state +├── schemas/ # Zod form + API boundary validation +└── permissions.ts # module permission declarations +``` + +**Forbidden between modules:** direct import of another module's internal components. +**Allowed:** Registry for menus/settings/permissions; toolkit hooks for shared server data. + +### URL as state + +List filters, pagination, and sort live in URL searchParams: + +``` +/article?page=2&status=published&sort=-createdAt&keyword=react +``` + +| Benefit | Why | +|---------|-----| +| Shareable | Admins copy links to restore views | +| Testable | E2E does not depend on component state | +| Cacheable | React Query uses URL params as queryKey | +| Device-agnostic | Desktop / mobile share the same data logic | + +### Codegen boundaries + +| Generated (no hand edits) | Hand-written | +|---------------------------|--------------| +| `toolkit/api/*` | `toolkit/react/hooks/*` | +| `toolkit/types/*` | `toolkit/admin/components/*` | +| OpenAPI spec | Feature Module business UI | + +--- + +## 8. Extensibility + +Modeled after WordPress `add_action` / `add_filter`, constrained by TypeScript manifests. + +### Extension model + +| Type | Extends | Carrier | +|------|---------|---------| +| **Theme** | Visitor UI | Independent Next.js package + `theme.json` | +| **Plugin** | Business logic + optional Admin UI | Server module + optional `admin/index.ts` | + +**Hooks** (in-process, can mutate) vs **Webhooks** (outbound HTTP) are separate. + +### Manifest contracts + +**theme.json** (flat structure — `templates` at root): + +```json +{ + "id": "hello-world", + "name": "Hello World", + "version": "1.0.0", + "requires": ">=3.5.0", + "templates": { + "home": "pages/index.tsx", + "single": "pages/article/[id].tsx", + "archive": "pages/category/[category].tsx" + }, + "supports": { "menus": ["primary", "footer"], "darkMode": true } +} +``` + +**plugin.json:** + +```json +{ + "id": "seo", + "name": "SEO Enhancement", + "version": "1.0.0", + "server": { "module": "./dist/index.js" }, + "admin": { + "slots": { "subscribe": ["article.editor.meta.afterSummary"] } + }, + "settings": { "schema": { "type": "object" } } +} +``` + +Schemas live in `toolkit/extension`; CLI validates on install — invalid packages fail at startup, not at runtime. + +### Server Hook + +```typescript +interface HookService { + applyFilters(name: string, value: T, ctx?: unknown): Promise; + doAction(name: string, payload?: unknown): Promise; +} +``` + +| Hook | When | +|------|------| +| `article.beforePublish` | Mutate fields before publish | +| `article.afterPublish` | Notify, index after publish | +| `comment.beforeCreate` | Spam filter | +| `setting.beforeSave` | Validate extension config | + +### Admin Registry + +```typescript +interface AdminModule { + id: string; + register(ctx: AdminContext): void; +} + +interface AdminContext { + menu: MenuRegistry; + settings: SettingsRegistry; + permissions: PermissionRegistry; + routes: RouteRegistry; +} +``` + +Core modules and plugins use the same API — new official features = new module + `register()`, no Shell edits. + +### Theme switching strategy + +| Phase | Strategy | Rationale | +|-------|----------|-----------| +| MVP (current) | Update `activeTheme` + restart theme process | Simple, stable SSR, no runtime federation | +| Later | Hot swap / multi-theme preview | Only when product requires it | + +### Permission model + +```typescript +type Permission = + | 'article:read' | 'article:write' | 'article:publish' + | 'media:manage' | 'page:manage' + | 'user:manage' | 'setting:manage' + | 'extension:manage'; +``` + +- **Server:** Guard checks JWT + Permission +- **Web:** `usePermission()` + route-level `` +- **Plugins:** manifest declares `permissions`; merged into roles on activate + +String capabilities beat hard-coded `role === 'admin'`. + +### Plugin three-layer model + +| Layer | Path | Role | +|-------|------|------| +| Registry | `plugins/` + `plugins/package.json` | What can be installed | +| Materialized | `.reactpress/plugins/{id}/` | Installed copy with `dist/` | +| Active | `Setting.globalSetting.plugins` | Enabled list + per-plugin config | + +| Action | Effect | +|--------|--------| +| Install | Materialize to `.reactpress/plugins/` | +| Enable | Hot-load `server.module` → `register(hooks, ctx)` | +| Disable | Remove hooks; optional `deactivate()` | +| Config | JSON Schema validation then reload | + +Built-in plugins: `hello-world`, `seo`, `image-optimizer`. See [plugins/README.md](./plugins/README.md). + +--- + +## 9. Technical choices + +### Rendering by scenario + +| Scenario | Tech | Why | +|----------|------|-----| +| Admin | **Vite + React SPA** | No SEO; small CSR bundle, fast HMR, static deploy | +| Visitor theme | **Next.js SSR/SSG/ISR** | Crawlers and social previews need full HTML | +| API | **NestJS REST** | Mature modules; OpenAPI codegen chain | + +**Not chosen:** + +| Approach | Why not | +|----------|---------| +| Admin on Next.js | No SSR/RSC benefit; extra routing + server complexity | +| Admin + theme in one app | Coupled responsibilities, bundle bloat, cannot deploy separately | +| GraphQL instead of REST | Existing Swagger pipeline; GraphQL adds schema maintenance | +| Micro-frontends (qiankun, etc.) | Team/scale mismatch; Registry + dynamic import is enough | + +### Admin frontend stack + +| Layer | Choice | Role | +|-------|--------|------| +| Build | Vite+ (`vp dev/build`) | Fast dev, native ESM | +| Routing | TanStack Router | Type-safe, file routes, searchParams first-class | +| Server state | TanStack Query | Cache, retry, optimistic mutations | +| Client state | Zustand (auth/settings only) | Light persistence; avoid global store abuse | +| UI | Ant Design 6 | Complete admin components, responsive grid | +| Validation | Zod | Unified form + API boundary | + +State split: **URL for list state · React Query for server data · Zustand for session/UI prefs**. + +### Admin performance + +| Technique | Mechanism | +|-----------|-----------| +| Persistent shell | Layout route stays mounted; only `` swaps | +| Route-level code split | Each module is its own chunk | +| Lazy heavy deps | Rich text, charts via `React.lazy()` | +| List cache | `staleTime: 30s` for instant back-navigation | +| Prefetch | Sidebar hover preloads next route chunk | + +### Visitor SEO + +| Page type | Mode | +|-----------|------| +| Home, article, archives | ISR `revalidate: 60` | +| About, privacy | SSG | +| Search | SSR | +| Comment submit | CSR island | + +`toolkit/theme` provides `fetchArticle`, `buildPageMeta`, `buildJsonLd` — theme authors call helpers, not SEO boilerplate. + +--- + +## 10. Cost & multi-platform + +### Cost model + +| Cost type | Control strategy | +|-----------|-------------------| +| Development | Monorepo + toolkit reuse; Feature Module templates for CRUD | +| Operations | Admin static hosting; theme = standard Next deploy; API single process | +| Multi-device | Responsive web — no native iOS/Android Admin | +| Extensions | manifest + Registry — no core PR for third-party features | +| Learning curve | Stack converges on React + Nest; theme authors need Next + toolkit only | + +### Responsive web (one codebase, three viewports) + +Breakpoints align with Ant Design (single standard across repo): + +| Breakpoint | Width | Admin | Theme | +|------------|-------|-------|-------| +| `< md` | < 768px | Drawer nav; table → cards | Single column | +| `md–lg` | 768–992px | Collapsed sidebar | Two columns | +| `≥ lg` | ≥ 992px | Fixed sidebar + wide table | Sidebar + main | + +Shared components in `toolkit/admin`: `ResponsiveTable`, `ResponsiveFilterToolbar`, `ResponsiveFormModal`. + +**Principle:** API has no device fields; differences are UI-only. + +**Progressive path:** + +1. Default: responsive web (zero extra engineering) +2. Optional: **Electron desktop** (same `web/dist`) +3. Optional: PWA caches shell static assets only +4. Future: Capacitor wraps `web/dist` without rewriting UI + +### What we deliberately skip (cost control) + +| Skip | Reason | +|------|--------| +| Native mobile Admin app | Responsive web covers most ops | +| Electron-embedded duplicate Admin UI | Shell only loads `web/dist` | +| Plugin marketplace sandbox (v1) | Local dir + admin trust model is enough | +| Theme runtime federation | Separate process + restart is simpler | +| Multi-DB / multi-tenant (v1) | Single-site CMS first | +| Custom ORM / UI library | TypeORM + Ant Design | + +--- + +## 11. Server (backend API) + +### Stack + +| Layer | Technology | +|-------|------------| +| Framework | NestJS 6 | +| ORM | TypeORM 0.2 | +| Database | MySQL (default); **SQLite** for desktop local mode (`DB_TYPE=sqlite`) | +| Auth | Passport + JWT, API Key | +| Docs | Swagger at `/api` | +| Other | helmet, compression, rate-limit, log4js, nodemailer, ali-oss | + +### Module layout + +``` +server/src/modules/ +├── article/ category/ tag/ comment/ page/ file/ # content +├── user/ auth/ # identity +├── setting/ smtp/ # config +├── view/ search/ knowledge/ # data +├── extension/ # theme + plugin lifecycle +├── hook/ # Action/Filter registry +├── api-key/ webhook/ health/ +└── … +``` + +Each module: thin controller → service (business + Hook calls) → entity. **extension** manages install/activate state, not domain business logic. + +### Domain entities (15) + +User, Article, ArticleRevision, Category, Tag, Comment, Page, Knowledge, File, Setting, SMTP, Search, View, ApiKey, Webhook. + +### API conventions + +- Global prefix: `/api` (`SERVER_API_PREFIX` configurable) +- Unified response: `{ statusCode, success, data }` (except `/health`) + +### Startup paths + +1. **First install:** Express wizard in `main.ts` (`/test-db`, `/install` writes `.env`) → NestJS bootstrap +2. **Normal / production:** Direct `starter.ts` bootstrap + +--- + +## 12. Web (admin SPA) + +### Stack + +| Category | Technology | +|----------|------------| +| Build | Vite+ | +| UI | React 18 + Ant Design 6 | +| Routing | TanStack Router (file routes) | +| Data | TanStack Query + Zustand (auth persist) | +| Editor | Monaco + Showdown (Markdown) | +| i18n | i18next | +| Testing | MSW + Playwright E2E | + +### Directory structure + +``` +web/src/ +├── routes/ # TanStack file routes +│ ├── login/ +│ └── _auth/ # authenticated routes +│ ├── dashboard/ article/ media/ page/ +│ ├── appearance/ settings/ plugins/ data/ +├── modules/ # feature domains (mirror routes) +├── shell/ # bootstrap, permissions, Admin Registry +├── shared/ components/ mocks/ stores/ hooks/ i18n/ +``` + +### Admin route map + +| Module | Route | APIs | +|--------|-------|------| +| Dashboard | `/` | view, article stats | +| Articles | `/article`, `/article/editor/:id?` | article, category, tag | +| Comments | `/article/comment` | comment | +| Media | `/media` | file | +| Pages | `/page`, `/page/editor/:id?` | page | +| Appearance | `/appearance/themes`, `/appearance/customize` | extension, setting | +| Plugins | `/plugins`, `/plugins/:id/settings` | extension | +| Users | `/users`, `/profile` | user | +| Settings | `/settings/:tab` | setting, smtp, api-key, webhook | +| Data | `/data/analytics`, `/data/export`, `/data/import` | view, search, export | + +Settings use routes (not tab query params). Plugins insert tabs via `settings.registerTab({ id, title, path, permission })`. + +### Module registration example + +```typescript +export const articleModule: AdminModule = { + id: 'article', + register({ menu, permissions }) { + menu.register({ + id: 'content', + title: 'Content', + children: [ + { id: 'article.list', title: 'Articles', path: '/article' }, + { id: 'article.new', title: 'New article', path: '/article/editor' }, + { id: 'article.comment', title: 'Comments', path: '/article/comment' }, + ], + }); + permissions.register(['article:read', 'article:write', 'article:publish']); + }, +}; +``` + +Shell `bootstrap()` registers core modules, then loads active plugins. Menu order uses `sort`, not import order. + +### API connection + +- Dev: `VITE_API_BASE_URL=/api`, Vite proxy to `:3002` +- Client: `getToolkitClient()` → `@fecommunity/reactpress-toolkit/react` +- Mock: `VITE_AUTH_MODE=mock` + MSW +- Live: `VITE_AUTH_MODE=server` + +--- + +## 13. Themes (visitor frontend) + +Since 3.0, visitor sites are independent Next.js packages under `themes/` (replacing legacy `client/`). + +### Package structure (hello-world) + +``` +themes/hello-world/ +├── theme.json # manifest (id, templates, customizer) +├── pages/_app.tsx # createThemeApp(manifest) +├── pages/index.tsx, article/[id].tsx, … +├── components/ styles/ next.config.js +``` + +### WordPress mapping + +| WordPress | ReactPress | +|-----------|------------| +| `style.css` header | `theme.json` | +| `functions.php` | `pages/_app.tsx` → `createThemeApp()` | +| Template hierarchy | `theme.json` → `templates` + `pages/*` | +| Customizer | `appearance.sections` + Formily + `useThemeMod` | + +### Official themes + +| Theme | Source | Role | +|-------|--------|------| +| **hello-world** | local | Minimal Pages Router starter | +| **reactpress-theme-starter** | npm (`theme-starter` anchor) | Full theme: search, knowledge base, comments, dark mode | + +### Theme lifecycle + +```mermaid +sequenceDiagram + participant Admin as web Admin + participant API as server ThemeService + participant FS as filesystem + participant CLI as cli theme-dev + participant Next as Next.js theme + + Admin->>API: POST /extension/themes/install + API->>FS: copy themes/{id} → .reactpress/runtime/{id} + Admin->>API: POST /extension/themes/activate + API->>FS: write active-theme.json + CLI->>Next: start theme :3001 + Admin->>API: beginPreviewSession + API->>FS: write preview-theme.json + CLI->>Next: start preview :3003 +``` + +See [themes/README.md](./themes/README.md). + +--- + +## 14. Toolkit (shared contract layer) + +Single API contract layer for the platform; generated from server OpenAPI. + +### Structure + +``` +toolkit/src/ +├── api/ types/ # generated from Swagger +├── react/ # createClient(), resolveApiBaseUrl(), runtime detection +├── theme/ ui/ # SSR helpers, headless components +├── admin/ plugin/ # Registry, plugin SDK +├── extension/ # theme.json / plugin.json JSON Schema +├── config/ utils/ +``` + +### Export paths + +| Path | Use | +|------|-----| +| `@fecommunity/reactpress-toolkit` | Main entry | +| `@fecommunity/reactpress-toolkit/react` | React client factory | +| `@fecommunity/reactpress-toolkit/theme` | Theme SSR | +| `@fecommunity/reactpress-toolkit/plugin/server` | Plugin Hook SDK | +| `@fecommunity/reactpress-toolkit/plugin/admin` | Plugin Admin registration | + +### Regenerate + +```bash +pnpm run generate:swagger # server → swagger.json +pnpm run build:toolkit # regenerate api/types +``` + +See [toolkit/README.md](./toolkit/README.md). + +--- + +## 15. CLI (orchestration) + +Published as `@fecommunity/reactpress` — zero-config project lifecycle. + +### Core commands + +| Command | Description | +|---------|-------------| +| `reactpress init` | Init project (`.env` + `.reactpress/config.json`; `--local` = SQLite) | +| `reactpress dev` | Full-stack dev (API + web + theme + Docker MySQL) | +| `reactpress dev --api-only` | API only (headless) | +| `reactpress dev --web-only` | Admin + API | +| `reactpress build` / `start` | Production build / start | +| `reactpress doctor` / `status` | Diagnostics / status | +| `reactpress plugin list/install` | Plugin registry | +| `reactpress theme list/add` | Theme catalog | +| `reactpress desktop dev` | Desktop dev (SQLite + Admin + Electron) | + +### CLI layout (4.0 TypeScript) + +``` +cli/ +├── bin/ # thin entry → out/bin/ +├── src/ # TypeScript source +├── out/ # compiled (gitignored) +│ ├── bin/ core/ ui/ +│ └── lib/ # dev/build/docker/theme/plugin orchestration +├── server/ # bundled NestJS runtime for npm +└── templates/ # init scaffolds +``` + +Server resolution: monorepo `server/` if present, else `cli/server/` bundled copy. + +See [cli/README.md](./cli/README.md). + +--- + +## 16. Auth & security + +### JWT (admin / user sessions) + +- Login: `POST /api/auth/login` → token (4h default) +- Protected routes: `@UseGuards(JwtAuthGuard)` + Bearer +- Roles: `@Roles('admin')` + `RolesGuard` (admin / visitor) + +### API Key (headless / integrations) + +- Header: `X-API-Key` or `Authorization: Bearer ` +- Scopes: `read` / `write` + +### Other + +- GitHub OAuth: `POST /api/auth/github` +- Passwords: bcrypt via `User.comparePassword()` + +--- + +## 17. Configuration + +**`.reactpress/config.json`** is the source of truth; `.env` is synced on `init`. **`--local`** uses SQLite with `config.local.json` / `env.local.default`. + +| File | Purpose | +|------|---------| +| `.reactpress/config.json` | Project config | +| `.reactpress/active-theme.json` | Active theme id | +| `.reactpress/preview-theme.json` | Preview theme id | +| `.reactpress/runtime/{id}/` | Materialized theme copy | +| `.reactpress/plugins/{id}/` | Materialized plugin copy | +| `.env` | DB, ports, secrets | + +| Variable | Default | +|----------|---------| +| `SERVER_PORT` | `3002` | +| `REACTPRESS_API_URL` | `http://localhost:3002/api` | + +--- + +## 18. Deployment + +### Development (recommended) + +- App processes on host (`pnpm dev`) +- Docker for **MySQL + nginx** only +- nginx forwards to host via `host.docker.internal` + +### Production options + +| Mode | Notes | +|------|-------| +| **PM2** | `pnpm build` → `pnpm start` | +| **Docker** | MySQL container + nginx; API can run on host | +| **Vercel** | Theme / Admin static deploy | + +### nginx routes (dev) + +| Path | Target | +|------|--------| +| `/` | Theme `:3001` | +| `/admin/` | Admin `:3000` | +| `/api` | API `:3002` | + +--- + +## 19. Local development + +```mermaid +flowchart LR + subgraph init [First time] + A[pnpm install] --> B[reactpress init] + B --> C[.env + .reactpress/config.json] + end + subgraph dev [Daily] + D[pnpm dev] --> E[toolkit build] + E --> F[server :3002] + F --> G[web :3000] + G --> H[theme :3001] + end + init --> dev +``` + +```bash +pnpm install +pnpm dev # API + Admin + theme + MySQL +pnpm dev:web:local # Admin + SQLite API (no Docker) +pnpm dev:desktop # Electron + SQLite +pnpm build:plugins # compile official plugins +pnpm build # toolkit → server → web → themes +``` + +After API changes: + +```bash +pnpm run generate:swagger && pnpm run build:toolkit +``` + +--- + +## 20. Plugins + +**Themes handle presentation; plugins handle logic.** Server-side Hooks extend business rules; optional Admin UI via slots. + +Covered in [§8 Extensibility](#8-extensibility) and [plugins/README.md](./plugins/README.md). + +--- + +## 21. Desktop client + +Electron shell loads the same Admin SPA as the browser — **no duplicate business UI**. + +```mermaid +flowchart TB + subgraph DesktopApp["desktop/ — Electron"] + Main[Main: window · tray · IPC · local API spawn] + Preload[Preload: contextBridge] + Renderer[Renderer: web/dist] + end + Renderer --> Toolkit[toolkit] + Toolkit --> API[server :3002 or :13102 local] +``` + +| Layer | Responsibility | +|-------|----------------| +| **web** | All Admin UI (same as browser) | +| **toolkit** | API client, auth, `getRuntime()` / `getDesktopApi()` | +| **desktop** | Main/Preload only: window, IPC, SQLite API spawn, config | +| **server** | REST API; local mode spawned by Main, remote mode connects externally | + +### Modes + +| Mode | Description | +|------|-------------| +| **Local (default)** | Main spawns embedded API (SQLite, default `:13102`); default `admin` / `admin` | +| **Remote** | Connect to existing ReactPress API; sync local content to remote site | + +### Load modes + +| Mode | Use case | +|------|----------| +| **A. Bundled (production)** | `file://` or custom protocol → `web/dist/index.html` | +| **B. Remote URL** | Load `https://admin.example.com` (enterprise intranet) | +| **C. Dev** | `http://localhost:3000` (Vite dev server) | + +### Security (Electron) + +| Rule | Required | +|------|----------| +| `contextIsolation: true` | Yes | +| `nodeIntegration: false` in renderer | Yes | +| Preload whitelist IPC channels | Yes | +| `webSecurity: true` | Yes | +| Remote URL allowlist | When using mode B | + +### Desktop roadmap + +| Phase | Content | Status | +|-------|---------|--------| +| D0 | Scaffold; dev loads Vite; prod loads `web/dist` | ✅ 4.0 | +| D1 | Local SQLite, remote API switch, login, macOS/Windows packages | ✅ 4.0 MVP | +| D1+ | Local → remote content sync | ✅ 4.0 | +| D2 | Tray, shortcuts, native notifications | Planned | +| D3 | `electron-updater` | Planned | + +**Why Electron over Tauri:** mature updater/tray/builder ecosystem; Chromium matches Admin stack; shell is swappable — **web + toolkit stay unchanged**. + +See [desktop/README.md](./desktop/README.md). + +--- + +## 22. Evolution & roadmap + +### 4.0 vs 3.x + +| 3.x | 4.0 | +|-----|-----| +| No official plugin runtime | Hook + manifest + Admin slots; built-in hello-world, seo, image-optimizer | +| Web Admin only | Optional **Electron desktop** (SQLite local mode) | +| hello-world–centric themes | npm catalog (`theme-starter` anchor) | +| CLI pure JS `lib/` | TypeScript `src/` → `out/` | + +### 3.0 vs 2.x + +| 2.x | 3.0 | +|-----|-----| +| Monolithic `client/` Next (incl. /admin) | `web/` Admin SPA + `themes/` visitor | +| Multiple HTTP layers | Unified toolkit client | +| Manual setup | CLI `init` + `dev` | + +### Implementation phases (historical) + +| Step | Deliverable | Status | +|------|-------------|--------| +| 1–2 | toolkit client + web Shell + auth | ✅ 3.x | +| 3–4 | article module template + core CRUD | ✅ 3.x | +| 5–6 | server extension/hook + appearance/plugins UI | ✅ 3.x–4.0 | +| 7 | theme.json + CLI theme commands | ✅ 3.x | +| 8–9 | responsive components + import/export | ✅ 3.x | +| 10 | Electron desktop shell | ✅ 4.0 MVP | +| 11 | Plugin Hook + Admin slots | ✅ 4.0 | + +### Known legacy / future work + +- Standalone `server` npm package deprecated — use CLI bundled API +- `dev:client` script name retained; starts active theme +- Plugin npm catalog, marketplace, Desktop auto-update → future 4.x iterations + +### Feature coverage + +| Domain | Status | +|--------|--------| +| Content, media, appearance, system settings | ✅ | +| Plugins (Hook + Registry + Admin slots) | ✅ 4.0 | +| Desktop (Electron + SQLite) | ✅ 4.0 MVP | +| Knowledge base | ✅ server module | + +--- + +## 23. Acceptance criteria + +| Dimension | Standard | +|-----------|----------| +| Maintainability | New CRUD module ≤ one directory + one `register()`; API changes = server + codegen only | +| Extensibility | Official SEO plugin mounts menu + Hook without core edits | +| Performance | Admin route switch < 100ms; theme Lighthouse SEO ≥ 90 | +| Multi-device | No horizontal scroll at 390px; core flows pass E2E on three viewports | +| Consistency | web / themes / plugins have no custom HTTP clients | +| Desktop | Packaged app shows same Admin as browser; no forked Admin source | + +--- + +## 24. References + +- [README.md](./README.md) — quick start (English) +- [README-zh_CN.md](./README-zh_CN.md) — quick start (Chinese) +- [docs/migration-3-to-4.md](./docs/migration-3-to-4.md) — 3.x → 4.0 migration +- [docs/](./docs/) — Docusaurus tutorials +- [themes/README.md](./themes/README.md) — theme development +- [plugins/README.md](./plugins/README.md) — plugin development +- [desktop/README.md](./desktop/README.md) — desktop client +- [toolkit/README.md](./toolkit/README.md) — SDK usage +- [cli/README.md](./cli/README.md) — CLI reference diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c1080f..2a990976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# [4.0.0-beta.0](https://github.com/fecommunity/reactpress/compare/v3.7.0...v4.0.0-beta.0) (2026-06-27) + +> **Pre-release** — install with `npm i -g @fecommunity/reactpress@beta` for testing. Final 4.0.0 follows after validation. + +### Plugin System + +* **Hook + manifest**: `plugin.json` lifecycle (install / activate / config / hot reload); `HookService` with filters and actions +* **Admin slots**: `AdminSlot` + `PluginAdminProvider`; SEO plugin integrates article editor +* **Built-in plugins**: `hello-world` (auto summary), `seo` (slug, keywords, meta description), `image-optimizer` (legacy media WebP batch optimization) +* **CLI**: `reactpress plugin list` / `install`; `pnpm build:plugins` in full build pipeline +* **Security**: manifest JSON Schema validation, module path constraints, Ajv config validation + +### Desktop Client + +* **Electron shell**: loads same Admin SPA (`web/dist`); `pnpm dev:desktop` / `pnpm build:desktop` +* **Local mode**: embedded SQLite API (default port `13102`), no Docker/MySQL required +* **Remote mode**: connect to existing ReactPress API +* **Sync**: push articles, pages, and settings from local to remote site + +### Themes & Docs + +* **Theme catalog**: npm anchor `theme-starter`; enhanced theme management; removed deprecated bundled themes +* **hello-world**: updated README aligned with Pages Router + `createThemeApp` +* **Docs**: ARCHITECTURE / design sync; migration 3→4; ReactPress 4.0 guide + # [3.7.0](https://github.com/fecommunity/reactpress/compare/v3.6.0...v3.7.0) (2026-06-23) ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 039cf75a..6e1cc784 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Security Advisories when applicable. ### Prerequisites - Node.js >= 18.0.0 -- pnpm >= 8.0.0 +- pnpm 9.x(与根目录 `packageManager` 一致,推荐 `corepack enable` 后使用) - MySQL 5.7+ (or Docker via `pnpm run init` / `pnpm docker:dev`) ### First run @@ -52,9 +52,10 @@ Run `pnpm test` and `pnpm test:smoke` before submitting changes that touch the C reactpress/ ├── cli/ # @fecommunity/reactpress — init, dev, build, doctor ├── server/ # NestJS API (primary backend) -├── client/ # Next.js admin & public frontend +├── web/ # Admin SPA (Vite) +├── client/ # Next.js legacy admin & public frontend +├── themes/ # Visitor theme templates (Next.js) ├── 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 @@ -71,7 +72,7 @@ reactpress/ | 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` | +| API lifecycle | `pnpm run start` / `stop` / `restart` / `status` | `pnpm dev` builds toolkit first, waits for API health, then starts the client. @@ -80,8 +81,9 @@ After API changes: `pnpm run generate:swagger` → `pnpm run build:toolkit`. ## Building ```bash -pnpm run build # toolkit + server + client +pnpm run build # toolkit + server + web + active theme pnpm run build:server # Nest only +pnpm run build:web # Admin SPA only pnpm run build:client # Next.js only pnpm run build:docs # Docusaurus site ``` @@ -121,12 +123,22 @@ sh scripts/deploy.sh Maintainers only: ```bash -pnpm login +pnpm login --registry https://registry.npmjs.org + +# Interactive (choose beta/stable + version) pnpm run publish:packages + +# Beta prerelease (uses package.json versions, npm tag: beta) +NPM_OTP=123456 pnpm run publish:packages -- --tag beta --yes + +# Explicit version +NPM_OTP=123456 pnpm run publish:packages -- --tag beta --version 4.0.0-beta.0 --yes + +# Build artifacts only (no npm publish) +pnpm run publish:build ``` -Published packages: root meta, **server**, **client**, **toolkit**, **templates**. -`@fecommunity/reactpress` is the CLI entry (`init`, `dev`, Docker database helpers). +Published packages: **toolkit**, **web**, **server** (deprecated), **cli** (`@fecommunity/reactpress`). ## Architecture & Documentation diff --git a/README-zh_CN.md b/README-zh_CN.md index 2cd7feef..df22bf39 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -1,319 +1,422 @@
- - ReactPress 标志 - -

ReactPress

+ + ReactPress 标志 + + +

ReactPress

+ +

不是 CMS,是现代开发者的发布平台。

+ +

+ WordPress 式编辑 · Next.js 交付 · 一条 CLI 上线。
+ CMS + 后台 + API + 主题 + 插件 + 桌面端 — 无需自行拼装。 +

+ +

+ 快速开始 ↓ +  ·  + 全栈演示 +  ·  + 主题演示 +  ·  + 文档 +  ·  + English +

+ +[![GitHub stars](https://img.shields.io/github/stars/fecommunity/reactpress?style=social)](https://github.com/fecommunity/reactpress/stargazers) +[![npm downloads](https://img.shields.io/npm/dm/@fecommunity/reactpress?style=flat-square)](https://www.npmjs.com/package/@fecommunity/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) +[![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) +[![Lighthouse 95](https://img.shields.io/badge/Lighthouse-95%20%2F%20100%20SEO-0cce6b?style=flat-square&logo=lighthouse&logoColor=white)](https://reactpress-theme-starter.vercel.app) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/fecommunity/reactpress/pulls) + +

+ React + Next.js + NestJS + Electron + MySQL / SQLite +

+ +
+ + + 在 GitHub 上 Star ReactPress + -

- 开源发布平台 — WordPress 式编辑体验,Next.js 级前台性能。
- 一个全局包 · 零配置 CMS · Headless API · 可生产部署的主题 -

+

如果 ReactPress 帮你省下了 CMS + API + 前台拼装的功夫 — 点个 ⭐ 能让下一位开发者更容易发现它。

-

- GitHub Stars - NPM 版本 - NPM 下载量 - CI - 许可证 -

+
-
+--- - - ReactPress 官方主题 — 在线演示 - +## 效果预览 -

+
+ +![ReactPress CLI — 从安装到上线约 60 秒](./public/usage.gif) + + + + + + +
+ + 桌面客户端 — SQLite 离线写作 + + 桌面端 — 离线写作,同步到线上 + + + 官方主题 — 搜索、评论、知识库 + + 访客站 — 搜索 · 评论 · 知识库 · 深色模式 +
+ + + Lighthouse:性能 95、无障碍 100、最佳实践 100、SEO 100 + + +评分来自官方主题演示;实际上线结果取决于托管与内容。 - - - - - - -
- ⚡ 约 60 秒
- 零配置冷启动 -
- 📊 95 / 100 / 100 / 100
- 官方主题 Lighthouse 实测 -
- 🔌 Headless
- REST · Swagger · TypeScript SDK -
- -
- -

- 主题演示 -  ·  - 全栈演示 -  ·  - 官方文档 -  ·  - 官方主题 -  ·  - English -

- -

如果 ReactPress 帮到了你,⭐ 点个 Star,让更多人发现它。

--- -## 快速开始 - -**环境要求:** Node.js 18+ · 推荐 Docker(内置 MySQL) +## 30 秒快速开始 ```bash -npm i -g @fecommunity/reactpress@3 -mkdir my-blog && cd my-blog -reactpress init -reactpress dev +npm i -g @fecommunity/reactpress@4 +mkdir my-site && cd my-site +reactpress init && reactpress dev ``` -就这么简单 — CLI 自动生成配置、启动 MySQL、拉起 CMS API,无需手写 `.env`。 +**环境要求:** [Node.js 20+](https://nodejs.org/) · 推荐 [Docker](https://www.docker.com/)(内置 MySQL) -| 服务 | 地址 | -| :--- | :--- | -| CMS API | `http://localhost:3002/api` | -| Swagger 文档 | `http://localhost:3002/api` | -| 健康检查 | `http://localhost:3002/api/health` | +| 入口 | 地址 | +| :------ | :-- | +| **访客站** | http://localhost:3001 | +| **管理后台** | http://localhost:3000 | +| **API** | http://localhost:3002/api/health | -运行 `reactpress` 打开交互菜单 · 启动失败请执行 `reactpress doctor`。 +`reactpress doctor` 诊断环境问题 · `reactpress` 打开交互式菜单 -> **下一步:** 接入[官方主题](#接入访客站),或参考[全栈指南](https://docs.gaoredu.com/)。 + + + + + + + +
约 60 秒
init → 全栈就绪
95 / 100
Lighthouse 性能
MIT
可自托管
1 条 CLI
完整平台
-
-目录 +
-- [快速开始](#快速开始) -- [效果演示](#效果演示) -- [为什么选 ReactPress?](#为什么选-reactpress) -- [核心特性](#核心特性) -- [适用场景](#适用场景) -- [架构概览](#架构概览) -- [使用路径](#使用路径) -- [技术栈与生态](#技术栈与生态) -- [常见问题](#常见问题) -- [社区与贡献](#社区与贡献) +**跑通了?** [Star 本仓库](https://github.com/fecommunity/reactpress/stargazers) · [提交 Issue](https://github.com/fecommunity/reactpress/issues) · [阅读文档](https://reactpress-docs.vercel.app/) -
+ --- -## 效果演示 - -
+## 目录 + +- [效果预览](#效果预览) +- [30 秒快速开始](#30-秒快速开始) +- [目录](#目录) +- [痛点](#痛点) +- [ReactPress 是什么?](#reactpress-是什么) +- [能做什么](#能做什么) +- [架构](#架构) +- [主题](#主题) +- [插件](#插件) +- [桌面优先写作](#桌面优先写作) +- [为什么选 ReactPress?](#为什么选-reactpress) +- [4.0 新特性](#40-新特性) +- [开发者](#开发者) +- [部署](#部署) +- [路线图(4.x)](#路线图4x) +- [FAQ](#faq) +- [贡献](#贡献) -![ReactPress CLI 演示 — 从安装到运行](./public/usage.gif) +--- - - - - - - - - - - - - - -
CLI访客站(深色模式)
- - ReactPress CLI 交互菜单 - - - - ReactPress 官方主题 — 深色模式 - -
+## 痛点 - - Lighthouse 评分:性能 95、无障碍 100、最佳实践 100、SEO 100 - +现代内容系统往往逼你在几难全之间做取舍: -
+| 路径 | 代价 | +| :--- | :--- | +| **WordPress 式 CMS** | 编辑体验好 — 主题慢、PHP 栈耦合 | +| **静态站点生成器** | 极快 — 非开发者没有像样的 CMS | +| **Headless CMS**(Strapi、Payload) | API 灵活 — 后台、前台、部署仍要自行拼装 | ---- +> **前端团队值得拥有一个发布平台 — 而不是五个仓库硬接在一起。** -## 为什么选 ReactPress? +``` +以前 用 ReactPress +──── ───────────── +选 CMS 后端 → reactpress init +配 API → reactpress dev +做或买管理后台 → 在 Admin 写作 (:3000) +搭前台 → 访客看 Theme (:3001) +接部署 → reactpress build && start +``` -大多数发布工具都在逼你二选一:**CMS 好用但前台慢或绑定紧**,或**静态站极快但没有像样的编辑器**。ReactPress 减轻这种取舍 — WordPress 式编辑 + 现代化、可解耦的访客站。 +--- -| | **ReactPress** | WordPress | Ghost | 静态站点(Hugo、Hexo) | -| :--- | :--- | :--- | :--- | :--- | -| **首次跑通** | **`init` + `dev`,约 60 秒**¹ | 服务器、PHP、主题与插件 | 托管或自建,步骤较多 | 每站独立仓库与构建 | -| **内容编辑** | **Web 后台** | Web 后台 | Web 后台 | Git 中的 Markdown | -| **前台速度与 SEO** | **Lighthouse 95/100/100/100**² | 因主题与插件差异大 | 通常较好 | 优秀,无内置 CMS | -| **前端灵活性** | **Headless — 可替换主题** | 主题/插件生态强,耦合度高 | 与 Ghost 主题绑定 | 构建时固定 | -| **内置能力** | **搜索、评论、知识库** | 常靠插件 | 会员/通讯为主 | 需自行实现 | -| **更适合** | **博客、内容站、定制发布** | 通用网站 | 通讯与出版业务 | 文档站、开发者博客 | +## ReactPress 是什么? -¹ 需 Node.js 与 Docker 就绪;首次 Docker 拉取可能更久。 -² 基于[官方主题在线演示](https://reactpress-theme-starter.vercel.app)实测。 +ReactPress 是 **为 React 时代打造的全栈发布平台** — 不是又一个需要你自己接线的 Headless 后端。 ---- +一条 CLI,全部包含: -## 核心特性 +| 层级 | 你得到什么 | +| :---- | :----------- | +| **CMS** | WordPress 式编辑 — 文章、页面、媒体、分类 | +| **API** | Headless REST — React 优先、Swagger 文档 | +| **Admin** | Web 写作界面 — 无需另建后台 | +| **Themes** | 可 npm 安装的 Next.js 前台 — 可替换 | +| **Plugins** | 基于 Hook 的扩展 — SEO、摘要、图片优化 | +| **Desktop** | 本地优先写作 — SQLite、离线、可同步线上 | -| | 特性 | 你能得到什么 | -| :---: | :--- | :--- | -| ⚡ | **约 60 秒冷启动** | `init` + `dev`,零配置,内置 Docker MySQL | -| ✍️ | **熟悉的 CMS** | 文章、页面、媒体、分类、标签、定时发布 | -| 🎨 | **现代化前台** | 官方 Next.js 主题 — 搜索、评论、知识库、深色模式 | -| 🔌 | **Headless 就绪** | REST API、Swagger、API Key、Webhook — 可替换或自建前台 | -| 📊 | **生产级指标** | 官方主题演示 Lighthouse **95 / 100 / 100 / 100** | -| 🛠️ | **开发者体验** | 交互式 CLI、`doctor`、`status`、`db backup` | -| 🌐 | **国际化** | 中英文后台与文档 | -| 📦 | **一个包搞定** | `@fecommunity/reactpress@3` — CLI + API + 模板,无需拼装 | +> 内容归系统管,前台归开发者管。**它不是 CMS — 它是发布平台。** --- -## 适用场景 +## 能做什么 -| | 场景 | 为什么选 ReactPress | -| :---: | :--- | :--- | -| 📝 | **个人博客** | 富文本编辑器 — 不必在 Git 里写 Markdown | -| 🏢 | **内容站与文档** | 知识库、搜索、评论开箱即用 | -| 🧑‍💻 | **开发团队** | Headless API + SDK,任意前端栈 | -| 🚀 | **独立开发者** | `npm i -g` → 约 60 秒跑通 CMS | -| 🔌 | **Headless CMS** | REST + Swagger + Webhook 作为内容中心 | +| 场景 | 为什么适合 | +| :------- | :------------------ | +| 个人博客 | 后台写作 + Lighthouse 级 Next.js 主题 | +| 开发者文档与知识库 | 官方主题 + API 内置 | +| SaaS 营销站 | Headless API + 自定义 Next.js 前台 | +| 多编辑团队 | Web 后台给作者,主题仓库给工程师 | +| 离线优先工作流 | 桌面端 SQLite,就绪后同步 | --- -## 架构概览 - -ReactPress 将**内容管理**与**前台展示**解耦 — 在后台创作,任意前端渲染。 +## 架构 ```mermaid flowchart LR - subgraph Author["创作端 · :3001/admin"] - A["管理后台
React · Ant Design"] + subgraph Authoring + Admin["Admin"] + Desktop["Desktop"] end - - subgraph Core["内容平台 · :3002"] - B["NestJS REST API
Swagger · Headless · Webhook"] - DB[(MySQL)] - B --- DB + subgraph Core + API["CMS API"] + Plugins["Plugins"] end - - subgraph Delivery["访客交付"] - C["官方主题
Next.js SSR"] - D["自定义前台
Toolkit · REST · API Key"] + subgraph Delivery + Theme["Theme"] end - - A -->|创作与发布| B - B -->|SSR 拉数| C - B -->|Headless API| D + Admin --> API + Desktop --> API + Plugins --> API + API --> Theme ``` -| 组件 | 作用 | -| :--- | :--- | -| **CLI(`reactpress`)** | 初始化、开发、构建、部署、Docker、诊断 | -| **CMS API** | 内容、媒体、设置、Headless 接口、Webhook | -| **管理后台** | 编辑者的 Web 界面(全栈部署中包含) | -| **[官方主题](https://github.com/fecommunity/reactpress-theme-starter)** | 推荐访客站 — 快速、SEO 友好、功能完整 | -| **[@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit)** | 自建前台的 TypeScript SDK | +``` +CMS Core → 内容、媒体、设置 (NestJS) +Admin UI → 写作体验 (React + Vite) +API Layer → Headless 访问 (REST + Swagger) +Theme System → 访客前台 (Next.js, npm) +Plugin System→ 扩展能力 (hooks + Admin 插槽) +Desktop App → 离线写作 (Electron + SQLite) +``` --- -## 使用路径 +## 主题 -### 预览主题(无需后端) +主题是完全可替换的 Next.js 前台 — 不绑定核心。 + +```bash +reactpress theme add @fecommunity/reactpress-theme-starter +reactpress dev +``` + +无需后端即可预览: ```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)相同。 +**在线演示:** [reactpress-theme-starter.vercel.app](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) -[![使用 Vercel 部署主题](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress-theme-starter) +--- -### 接入访客站 +## 插件 -1. 保持 ReactPress API 运行(`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`。 +扩展核心,无需改源码。 -在 ReactPress 管理后台调整颜色、Logo 与导航。完整说明:[主题 README](https://github.com/fecommunity/reactpress-theme-starter/blob/master/README_zh.md)。 +```bash +reactpress plugin install seo +reactpress plugin install hello-world # 发布时自动生成摘要 +reactpress plugin install image-optimizer +``` + +| 插件 | 能力 | +| :----- | :--------- | +| `seo` | Slug、关键词、meta 描述 + Admin 编辑器插槽 | +| `hello-world` | 自动生成文章摘要 | +| `image-optimizer` | 媒体库批量 WebP 优化 | + +开发指南:[plugins/README.md](./plugins/README.md) -### 部署上线 +--- + +## 桌面优先写作 + +离线写作,就绪后同步。无需 Docker。 ```bash -reactpress build && reactpress start +pnpm dev:desktop # monorepo 根目录 +pnpm build:desktop # 打包安装程序 +``` + +SQLite 本地存储 · 离线编辑 · 可选同步远程 CMS · [desktop/README.md](./desktop/README.md) + +--- + +## 为什么选 ReactPress? + +| | ReactPress | WordPress | 静态站 | Headless CMS | +| :-: | :--- | :--- | :--- | :--- | +| **编辑体验** | 有 | 有 | 无 | 部分 | +| **前台自由度** | 有 | 无 | 仅构建时 | 有 | +| **开箱完整系统** | 有 | 靠插件 | 无 | 无 | +| **上手时间** | 约 1 分钟 | 数小时 | 单站快 | 搭建 + 拼装 | +| **本地 / 离线写作** | 桌面端 | 无 | 无 | 无 | +| **Lighthouse 性能** | 95² | 看主题 | 优秀 | 看前台 | + +**对比 WordPress** — 同样的后台工作流,现代化 Next.js 交付,无 PHP 主题臃肿。 + +**对比静态生成器** — 保留速度,补上真正的 CMS。 + +**对比 Strapi / Payload** — 它们只 ship 后端;ReactPress ship **完整发布平台**。 + +² [官方主题演示](https://reactpress-theme-starter.vercel.app) + +--- + +## 4.0 新特性 + +代号 **Extend** — 插件、桌面端、npm 主题。仍是 **一条 CLI、一套 Admin**。 + +```bash +npm i -g @fecommunity/reactpress@4 ``` -Docker、PM2、备份等:[完整文档](https://docs.gaoredu.com/)。 +[4.0 指南](./docs/tutorial/tutorial-extras/reactpress-4-0.md) · [从 3.x 迁移](./docs/tutorial/tutorial-extras/migration-3-to-4.md) + +--- -### 常用命令 +## 开发者 + +默认 Headless,任意前台通过 REST 接入。 + +```bash +curl -H "X-API-Key: YOUR_KEY" \ + "http://localhost:3002/api/article/headless/list?status=publish&page=1&pageSize=10" +``` + +| 资源 | 链接 | +| :------- | :--- | +| Swagger | http://localhost:3002/api | +| 主题开发 | [themes/README.md](./themes/README.md) | +| 插件开发 | [plugins/README.md](./plugins/README.md) | +| 官方 Starter | [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) | + +
+命令与端口 | 命令 | 作用 | -| :--- | :--- | -| `reactpress` | 交互式菜单 | -| `reactpress init` | 初始化新站点 | -| `reactpress dev` | 本地运行(API;访客站需接入主题) | -| `reactpress dev --api-only` | 仅 API(Headless 模式) | -| `reactpress build` / `reactpress start` | 生产构建与启动 | -| `reactpress doctor` / `reactpress status` | 诊断与查看状态 | -| `reactpress db backup` | 备份数据库 | +| :------ | :----- | +| `reactpress init` | 新建站点 | +| `reactpress dev` | 启动 API、后台与主题 | +| `reactpress build` / `start` | 生产环境 | +| `reactpress theme add ` | 安装主题 | +| `reactpress plugin install ` | 安装插件 | + +| 服务 | 端口 | +| :------ | :---: | +| Admin | 3000 | +| 访客站 | 3001 | +| API | 3002 | +| 主题预览 | 3003 | + +
+ +--- + +## 部署 + +```bash +reactpress build && reactpress start +``` + +Docker、PM2、备份:[完整文档](https://reactpress-docs.vercel.app/)。仅部署访客站:部署 [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) 并指向你的 API。 --- -## 技术栈与生态 +## 路线图(4.x) -| | | -| :-- | :-- | -| **技术栈** | Node.js CLI · NestJS API · MySQL · React 后台 · Next.js 主题 · TypeScript SDK | -| **主仓库** | [fecommunity/reactpress](https://github.com/fecommunity/reactpress) | -| **官方主题** | [fecommunity/reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) | -| **官方文档** | [docs.gaoredu.com](https://docs.gaoredu.com/) | -| **NPM** | [@fecommunity/reactpress](https://www.npmjs.com/package/@fecommunity/reactpress) · [@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit) | -| **在线演示** | [全栈](https://blog.gaoredu.com) · [仅主题](https://reactpress-theme-starter.vercel.app) | +- 插件 npm 目录 · `reactpress plugin create` +- 桌面端自动更新、托盘、快捷键 +- `reactpress theme create` 脚手架 +- 主题与插件市场 --- -## 常见问题 +## FAQ
-必须用 Docker 吗? +需要 Docker 吗? + +内置 MySQL 推荐使用。桌面端用 SQLite,无需 Docker。 -推荐使用。ReactPress 默认通过嵌入式 Docker MySQL 运行。也可在 `.reactpress/config.json` 中配置外部 MySQL。
-可以用自己的前台吗? +能用自己的前台吗? + +可以 — Headless REST API + API Key。Fork [官方 starter](https://github.com/fecommunity/reactpress-theme-starter) 或对接 `/api/article`、`/api/page` 等接口。 + +
+ +
+和 WordPress 有什么不同? + +同样是后台驱动的工作流,但默认主题更快、Headless 路径更干净,现代 React/Next.js 前台无需插件堆叠。 -可以。ReactPress 以 Headless 为优先 — REST API、API Key 和 [@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit) 可接入任意技术栈。
-和 WordPress 有什么区别? +4.0 能用于生产吗? + +4.0 为 beta(`4.0.0-beta.3`)。3.x 核心已久经考验 — 生产升级前请阅读[迁移指南](./docs/tutorial/tutorial-extras/migration-3-to-4.md)。 -同样是后台驱动的发布工作流,但通往快速现代化前台的路径更短 — 无需 PHP 栈,也无需靠插件堆叠来优化性能。API 与主题在设计上就是解耦的。
-「约 60 秒跑通」是什么意思? +WordPress 替代?Headless CMS?Next.js 博客? + +都可以 — ReactPress 同时覆盖:自托管 WordPress 式编辑、供自定义前台的 Headless REST,以及 Lighthouse 95 的官方 Next.js 主题。 -在 Node.js 与 Docker 已安装的前提下,二次冷启动(`reactpress init` + `reactpress dev`)通常在 60 秒内完成。首次拉取 Docker 镜像会更久。
--- -## 社区与贡献 +## 贡献 -| | | -| :-- | :-- | -| **Bug 与功能建议** | [GitHub Issues](https://github.com/fecommunity/reactpress/issues) | -| **问答与想法** | [GitHub Discussions](https://github.com/fecommunity/reactpress/discussions) | -| **参与贡献** | [贡献指南](./CONTRIBUTING.md) · [行为准则](./CODE_OF_CONDUCT.md) · [安全策略](./SECURITY.md) | +[贡献指南](./CONTRIBUTING.md) · [行为准则](./CODE_OF_CONDUCT.md) · [安全策略](./SECURITY.md) -**衷心感谢**每一位帮助 ReactPress 成长的朋友。 +[Issues](https://github.com/fecommunity/reactpress/issues) · [Pull requests](https://github.com/fecommunity/reactpress/pulls) @@ -342,7 +445,17 @@ Docker、PM2、备份等:[完整文档](https://docs.gaoredu.com/)。
-MIT License · © ReactPress / FECommunity +**MIT License** · © ReactPress / FECommunity + +
+ + + 在 GitHub 上 Star ReactPress + + +

不是 CMS,是现代开发者的发布平台。
帮助更多开发者发现它 — 欢迎在 GitHub 上 ⭐。

+ +
diff --git a/README.md b/README.md index c49d7dc7..1d9f4d99 100644 --- a/README.md +++ b/README.md @@ -1,279 +1,375 @@
- - ReactPress Logo - -

ReactPress

+ + ReactPress Logo + -

- The open-source publishing platform — WordPress familiarity, Next.js performance.
- One global package · Zero-config CMS · Headless API · Production-ready themes -

+

ReactPress

+ +

Not a CMS. A publishing platform for modern developers.

+ +

+ WordPress-style editing · Next.js delivery · One CLI to ship.
+ CMS + Admin + API + Themes + Plugins + Desktop — no assembly required. +

+ +

+ Quick start ↓ +  ·  + Live demo +  ·  + Theme demo +  ·  + Docs +  ·  + 中文 +

+ +[![GitHub stars](https://img.shields.io/github/stars/fecommunity/reactpress?style=social)](https://github.com/fecommunity/reactpress/stargazers) +[![npm downloads](https://img.shields.io/npm/dm/@fecommunity/reactpress?style=flat-square)](https://www.npmjs.com/package/@fecommunity/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) +[![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) +[![Lighthouse 95](https://img.shields.io/badge/Lighthouse-95%20%2F%20100%20SEO-0cce6b?style=flat-square&logo=lighthouse&logoColor=white)](https://reactpress-theme-starter.vercel.app) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/fecommunity/reactpress/pulls) + +

+ React + Next.js + NestJS + Electron + MySQL / SQLite +

+ +
+ + + Star ReactPress on GitHub + -

- GitHub stars - NPM version - NPM downloads - CI - License -

+

If ReactPress saves you from stitching CMS + API + frontend — a ⭐ helps the next developer find it.

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

+## See it in action + +
+ +![ReactPress CLI — install to live site in ~60 seconds](./public/usage.gif) + +
+ + + + +
+ + Desktop — offline writing with SQLite + + Desktop — offline writing, sync to production + + + Official theme — search, comments, knowledge base + + Visitor site — search · comments · knowledge base · dark mode +
+ + + Lighthouse: Performance 95, Accessibility 100, Best Practices 100, SEO 100 + + +Scores on the official theme demo. Production results depend on hosting and content. - - - - - - -
- ⚡ ~60s
- Zero-config cold start -
- 📊 95 / 100 / 100 / 100
- Lighthouse on official theme -
- 🔌 Headless
- REST · Swagger · TypeScript SDK -
- -
- -

- Theme demo -  ·  - Full-stack demo -  ·  - Documentation -  ·  - Official theme -  ·  - 中文文档 -

- -

If ReactPress saves you time, ⭐ Star on GitHub — it helps others find it.

--- -## Quick start - -**Requirements:** Node.js 18+ · Docker recommended (bundled MySQL) +## 30-second start ```bash -npm i -g @fecommunity/reactpress@3 -mkdir my-blog && cd my-blog -reactpress init -reactpress dev +npm i -g @fecommunity/reactpress@4 +mkdir my-site && cd my-site +reactpress init && reactpress dev ``` -That's it — the CLI scaffolds config, starts MySQL, and launches the CMS API. No manual `.env` editing. +**Requirements:** [Node.js 20+](https://nodejs.org/) · [Docker](https://www.docker.com/) recommended for bundled MySQL -| Service | URL | +| Surface | URL | | :------ | :-- | -| CMS API | `http://localhost:3002/api` | -| Swagger docs | `http://localhost:3002/api` | -| Health check | `http://localhost:3002/api/health` | +| **Public site** | http://localhost:3001 | +| **Admin** | http://localhost:3000 | +| **API** | http://localhost:3002/api/health | + +`reactpress doctor` fixes setup issues · `reactpress` opens the interactive menu + + + + + + + + +
~60 sec
init → live stack
95 / 100
Lighthouse performance
MIT
self-hosted
1 CLI
full platform
-Run `reactpress` for the interactive menu · `reactpress doctor` if something fails. +
-> **Next:** connect the [official theme](#connect-the-public-site), or follow the [full-stack guide](https://docs.gaoredu.com/). +**Working?** [Star the repo](https://github.com/fecommunity/reactpress/stargazers) · [Open an issue](https://github.com/fecommunity/reactpress/issues) · [Read the docs](https://reactpress-docs.vercel.app/) -
-Table of contents +
+ +--- + +## Contents -- [Quick start](#quick-start) - [See it in action](#see-it-in-action) -- [Why ReactPress?](#why-reactpress) -- [Features](#features) -- [Perfect for](#perfect-for) +- [30-second start](#30-second-start) +- [Contents](#contents) +- [The problem](#the-problem) +- [What is ReactPress?](#what-is-reactpress) +- [What you can build](#what-you-can-build) - [Architecture](#architecture) -- [Usage paths](#usage-paths) -- [Tech stack & ecosystem](#tech-stack--ecosystem) +- [Themes](#themes) +- [Plugins](#plugins) +- [Desktop-first writing](#desktop-first-writing) +- [Why ReactPress?](#why-reactpress) +- [What's new in 4.0](#whats-new-in-40) +- [For developers](#for-developers) +- [Deploy](#deploy) +- [Roadmap (4.x)](#roadmap-4x) - [FAQ](#faq) -- [Community & contributing](#community--contributing) - - +- [Contributing](#contributing) --- -## See it in action +## The problem -
+Modern content systems force a bad trade-off: -![ReactPress CLI — from install to running stack](./public/usage.gif) +| Path | Trade-off | +| :--- | :-------- | +| **WordPress-style CMS** | Great editing — slow themes, coupled PHP stack | +| **Static site generators** | Blazing fast — no real CMS for non-developers | +| **Headless CMS** (Strapi, Payload) | Flexible API — you still assemble admin, frontend, deploy | - - - - - - - - - - - - - -
CLIPublic site (dark)
- - ReactPress CLI interactive menu - - - - ReactPress official theme — dark mode - -
+> **Frontend teams deserve one publishing platform — not five repos to wire together.** - - Lighthouse scores: Performance 95, Accessibility 100, Best Practices 100, SEO 100 - - -
+``` +Before With ReactPress +────── ─────────────── +Pick a CMS backend → reactpress init +Configure the API → reactpress dev +Build or buy an admin → write in Admin (:3000) +Scaffold a frontend → visitors see Theme (:3001) +Wire up deploy → reactpress build && start +``` --- -## Why ReactPress? - -Most publishing tools force a trade-off: **easy CMS with a slow or tightly coupled frontend**, or **blazing static sites without a proper editor**. ReactPress reduces that trade-off — WordPress-style editing with a modern, decoupled public site. - -| | **ReactPress** | WordPress | Ghost | Static (Hugo, Hexo) | -| :-- | :-- | :-- | :-- | :-- | -| **Time to first stack** | **`init` + `dev` — ~60s**¹ | Server, PHP, themes, plugins | Managed or self-hosted install | New repo + pipeline per site | -| **Content editing** | **Web admin** | Web admin | Web admin | Markdown in Git | -| **Public site speed & SEO** | **Lighthouse 95/100/100/100**² | Varies by theme/plugins | Generally strong | Excellent, no built-in CMS | -| **Frontend flexibility** | **Headless — swap the theme** | Theme/plugin ecosystem, often coupled | Tied to Ghost themes | Fixed at build time | -| **Built-in extras** | **Search, comments, knowledge base** | Often via plugins | Membership focus | Build yourself | -| **Best for** | **Blogs, content sites, custom publishing** | General websites | Newsletters & publishing | Docs & dev blogs | +## What is ReactPress? -¹ After Node.js and Docker are ready; first Docker image pull may take longer. -² Measured on the [official theme live demo](https://reactpress-theme-starter.vercel.app). +ReactPress is a **full publishing platform built for the React era** — not another headless backend to wire up. ---- +One CLI install. Everything included: -## Features +| Layer | What you get | +| :---- | :----------- | +| **CMS** | WordPress-style editing — posts, pages, media, categories | +| **API** | Headless REST — React-first, Swagger-documented | +| **Admin** | Web writing UI — no separate admin to build | +| **Themes** | npm-installable Next.js frontends — swappable | +| **Plugins** | Hook-based extensibility — SEO, summaries, image optimization | +| **Desktop** | Local-first writing — SQLite, offline, sync upstream | -| | Feature | What you get | -| :---: | :------ | :----------- | -| ⚡ | **~60s cold start** | `init` + `dev` — zero config, embedded Docker MySQL | -| ✍️ | **Familiar CMS** | Posts, pages, media, categories, tags, scheduled publishing | -| 🎨 | **Modern frontend** | Official Next.js theme — search, comments, knowledge base, dark mode | -| 🔌 | **Headless-ready** | REST API, Swagger, API keys, webhooks — swap or build your own frontend | -| 📊 | **Production scores** | Official theme demo: Lighthouse **95 / 100 / 100 / 100** | -| 🛠️ | **Developer UX** | Interactive CLI menu, `doctor`, `status`, `db backup` | -| 🌐 | **i18n** | Chinese & English admin and docs | -| 📦 | **One package** | `@fecommunity/reactpress@3` — CLI + API + templates, no assembly required | +> Content owned by the system. Frontend owned by developers. **It is not a CMS — it is a publishing platform.** --- -## Perfect for +## What you can build -| | Use case | Why ReactPress | -| :---: | :------- | :------------- | -| 📝 | **Personal blogs** | Rich editor — no Markdown-in-Git workflow | -| 🏢 | **Content sites & docs** | Knowledge base, search, comments out of the box | -| 🧑‍💻 | **Developer teams** | Headless API + SDK, any frontend stack | -| 🚀 | **Indie makers** | `npm i -g` → running CMS in ~60 seconds | -| 🔌 | **Headless CMS** | REST + Swagger + webhooks as your content hub | +| Use case | Why ReactPress fits | +| :------- | :------------------ | +| Personal blogs | Admin writing + Lighthouse-fast Next.js theme | +| Developer docs & knowledge bases | Built into official theme + API | +| SaaS marketing sites | Headless API + custom Next.js frontend | +| Multi-editor teams | Web admin for writers, theme repo for engineers | +| Offline-first workflows | Desktop app with SQLite, sync when ready | --- ## Architecture -ReactPress separates **content management** from **presentation** — write in the admin, render anywhere. - ```mermaid flowchart LR - subgraph Author["Authoring · :3001/admin"] - A["Admin console
React · Ant Design"] + subgraph Authoring + Admin["Admin"] + Desktop["Desktop"] end - - subgraph Core["Content platform · :3002"] - B["NestJS REST API
Swagger · Headless · Webhooks"] - DB[(MySQL)] - B --- DB + subgraph Core + API["CMS API"] + Plugins["Plugins"] end - - subgraph Delivery["Public delivery"] - C["Official theme
Next.js SSR"] - D["Custom frontend
Toolkit · REST · API Key"] + subgraph Delivery + Theme["Theme"] end - - A -->|write & publish| B - B -->|SSR fetch| C - B -->|headless API| D + Admin --> API + Desktop --> API + Plugins --> API + API --> Theme ``` -| Component | Role | -| :-------- | :--- | -| **CLI (`reactpress`)** | Init, dev, build, deploy, Docker, diagnostics | -| **CMS API** | Content, media, settings, headless endpoints, webhooks | -| **Admin console** | Web UI for editors (included in full-stack setups) | -| **[Official theme](https://github.com/fecommunity/reactpress-theme-starter)** | Recommended public site — fast, SEO-friendly, feature-rich | -| **[@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit)** | TypeScript SDK for custom frontends | +``` +CMS Core → content, media, settings (NestJS) +Admin UI → writing experience (React + Vite) +API Layer → headless access (REST + Swagger) +Theme System → visitor-facing frontend (Next.js, npm) +Plugin System→ extensibility (hooks + Admin slots) +Desktop App → offline writing (Electron + SQLite) +``` --- -## Usage paths +## Themes -### Preview the theme (no backend) +Themes are fully replaceable Next.js frontends — not locked to core. + +```bash +reactpress theme add @fecommunity/reactpress-theme-starter +reactpress dev +``` + +Preview without a backend: ```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). +**Live:** [reactpress-theme-starter.vercel.app](https://reactpress-theme-starter.vercel.app) · [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress-theme-starter) + +--- + +## Plugins -[![Deploy theme with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress-theme-starter) +Extend without touching core. -### Connect the public site +```bash +reactpress plugin install seo +reactpress plugin install hello-world # auto-summary on publish +reactpress plugin install image-optimizer +``` -1. Keep the ReactPress API running (`reactpress dev`, or `reactpress dev --api-only`). -2. Clone [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) → `pnpm install`. -3. Copy `.env.example` to `.env` → `pnpm dev`. +| Plugin | Capability | +| :----- | :--------- | +| `seo` | Slug, keywords, meta description + Admin editor slot | +| `hello-world` | Auto-generate article summaries | +| `image-optimizer` | Batch WebP optimization for media | -Customize colors, logo, and navigation in the ReactPress admin. Full guide: [theme starter README](https://github.com/fecommunity/reactpress-theme-starter#readme). +Dev guide: [plugins/README.md](./plugins/README.md) + +--- -### Deploy to production +## Desktop-first writing + +Write offline. Sync when ready. No Docker required. ```bash -reactpress build && reactpress start +pnpm dev:desktop # monorepo root +pnpm build:desktop # build installer ``` -Docker, PM2, backups: [full documentation](https://docs.gaoredu.com/). +SQLite local storage · offline editing · optional sync to remote CMS · [desktop/README.md](./desktop/README.md) + +--- + +## Why ReactPress? + +| | ReactPress | WordPress | Static sites | Headless CMS | +| :-: | :--- | :--- | :--- | :--- | +| **Editing experience** | Yes | Yes | No | Partial | +| **Frontend freedom** | Yes | No | Build-time only | Yes | +| **Full system out of box** | Yes | Via plugins | No | No | +| **Time to start** | ~1 min | Hours | Fast per site | Setup + assembly | +| **Local / offline writing** | Desktop app | No | No | No | +| **Lighthouse performance** | 95² | Theme-dependent | Excellent | Depends on frontend | -### Everyday commands +**vs WordPress** — same editing workflow, modern Next.js delivery, no PHP theme bloat. -| Command | What it does | -| :------ | :----------- | -| `reactpress` | Interactive menu | -| `reactpress init` | Set up a new site | -| `reactpress dev` | Run locally (API; add theme for public site) | -| `reactpress dev --api-only` | API only (headless) | -| `reactpress build` / `reactpress start` | Production build & run | -| `reactpress doctor` / `reactpress status` | Diagnose & check status | -| `reactpress db backup` | Back up the database | +**vs Static generators** — keep the speed, add a real CMS. + +**vs Strapi / Payload** — they ship a backend; ReactPress ships the **full publishing platform**. + +² [Official theme demo](https://reactpress-theme-starter.vercel.app) --- -## Tech stack & ecosystem +## What's new in 4.0 + +Codename **Extend** — plugins, desktop, npm themes. Still **one CLI, one Admin.** + +```bash +npm i -g @fecommunity/reactpress@4 +``` -| | | -| :-- | :-- | -| **Stack** | Node.js CLI · NestJS API · MySQL · React admin · Next.js theme · TypeScript SDK | -| **Main repo** | [fecommunity/reactpress](https://github.com/fecommunity/reactpress) | -| **Official theme** | [fecommunity/reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) | -| **Documentation** | [docs.gaoredu.com](https://docs.gaoredu.com/) | -| **NPM** | [@fecommunity/reactpress](https://www.npmjs.com/package/@fecommunity/reactpress) · [@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit) | -| **Live demos** | [Full stack](https://blog.gaoredu.com) · [Theme only](https://reactpress-theme-starter.vercel.app) | +[4.0 guide](./docs/tutorial/tutorial-extras/reactpress-4-0.md) · [Migrate from 3.x](./docs/tutorial/tutorial-extras/migration-3-to-4.md) + +--- + +## For developers + +Headless by default. Connect any frontend via REST. + +```bash +curl -H "X-API-Key: YOUR_KEY" \ + "http://localhost:3002/api/article/headless/list?status=publish&page=1&pageSize=10" +``` + +| Resource | Link | +| :------- | :--- | +| Swagger | http://localhost:3002/api | +| Theme dev | [themes/README.md](./themes/README.md) | +| Plugin dev | [plugins/README.md](./plugins/README.md) | +| Official starter | [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) | + +
+Commands & ports + +| Command | Action | +| :------ | :----- | +| `reactpress init` | New site | +| `reactpress dev` | API + admin + theme | +| `reactpress build` / `start` | Production | +| `reactpress theme add ` | Install theme | +| `reactpress plugin install ` | Install plugin | + +| Service | Port | +| :------ | :---: | +| Admin | 3000 | +| Public site | 3001 | +| API | 3002 | +| Theme preview | 3003 | + +
+ +--- + +## Deploy + +```bash +reactpress build && reactpress start +``` + +Docker, PM2, backups: [full docs](https://reactpress-docs.vercel.app/). Theme-only: deploy [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) and point it at your API. + +--- + +## Roadmap (4.x) + +- Plugin npm catalog · `reactpress plugin create` +- Desktop auto-update, tray, shortcuts +- `reactpress theme create` scaffold +- Theme & plugin marketplace --- @@ -282,38 +378,45 @@ Docker, PM2, backups: [full documentation](https://docs.gaoredu.com/).
Do I need Docker? -Recommended. ReactPress uses embedded Docker MySQL by default. You can also point to an external MySQL instance via `.reactpress/config.json`. +Recommended for bundled MySQL. Desktop runs on SQLite without Docker. +
Can I use my own frontend? -Yes. ReactPress is headless-first — REST API, API keys, and [@fecommunity/reactpress-toolkit](https://www.npmjs.com/package/@fecommunity/reactpress-toolkit) for any stack. +Yes — headless REST API with API keys. Fork the [official starter](https://github.com/fecommunity/reactpress-theme-starter) or build against `/api/article`, `/api/page`, etc. +
How is this different from WordPress? -Similar admin-driven workflow, but a shorter path to a fast modern frontend — no PHP stack, no plugin maze for performance. API and theme are decoupled by design. +Same admin-driven workflow, but a faster default theme, a cleaner headless path, and no plugin bloat for a modern React/Next.js frontend. + +
+ +
+Is 4.0 production-ready? + +4.0 is beta (`4.0.0-beta.3`). Core 3.x is battle-tested — see [migration guide](./docs/tutorial/tutorial-extras/migration-3-to-4.md) before upgrading production. +
-What does "~60 seconds" mean? +WordPress alternative? Headless CMS? Next.js blog? + +Yes — ReactPress targets all three: self-hosted WordPress-style editing, headless REST for custom frontends, and an official Next.js theme with Lighthouse 95 performance out of the box. -After Node.js and Docker are installed, a second cold start (`reactpress init` + `reactpress dev`) typically completes within 60 seconds. First Docker image pull takes longer.
--- -## Community & contributing +## Contributing -| | | -| :-- | :-- | -| **Issues & features** | [GitHub Issues](https://github.com/fecommunity/reactpress/issues) | -| **Questions & ideas** | [GitHub Discussions](https://github.com/fecommunity/reactpress/discussions) | -| **Contributing** | [Guide](./CONTRIBUTING.md) · [Code of Conduct](./CODE_OF_CONDUCT.md) · [Security](./SECURITY.md) | +[Contributing](./CONTRIBUTING.md) · [Code of Conduct](./CODE_OF_CONDUCT.md) · [Security](./SECURITY.md) -**Thank you** to everyone who has helped shape ReactPress. +[Issues](https://github.com/fecommunity/reactpress/issues) · [Pull requests](https://github.com/fecommunity/reactpress/pulls) @@ -342,7 +445,17 @@ After Node.js and Docker are installed, a second cold start (`reactpress init` +
-MIT License · © ReactPress / FECommunity +**MIT License** · © ReactPress / FECommunity + +
+ + + Star ReactPress on GitHub + + +

Not a CMS. A publishing platform for modern developers.
Help us reach more builders — ⭐ on GitHub.

+ +
diff --git a/cli/README.md b/cli/README.md index fdaf2ace..7676874e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,47 +1,69 @@ -# @fecommunity/reactpress-cli +# @fecommunity/reactpress -零配置一键初始化与管理 ReactPress CMS & 博客服务器。内置 NestJS 服务端,无需单独克隆 [fecommunity/reactpress](https://github.com/fecommunity/reactpress)。 +ReactPress 4.0 CLI — bootstrap, dev, build, and manage the full publishing platform (CMS, Admin, API, themes, plugins). One global install ships NestJS API, Admin workflow, and tooling. -完整文档与贡献指南见:[github.com/fecommunity/reactpress-cli](https://github.com/fecommunity/reactpress-cli) +> **Not a CMS.** WordPress-style editing · Next.js delivery · one CLI to ship. -## 安装 +- **npm package**: [`@fecommunity/reactpress`](https://www.npmjs.com/package/@fecommunity/reactpress) +- **Docs**: [reactpress-docs.vercel.app](https://reactpress-docs.vercel.app/) +- **Monorepo**: [github.com/fecommunity/reactpress](https://github.com/fecommunity/reactpress) + +## Install ```bash -npm install -g @fecommunity/reactpress-cli +npm install -g @fecommunity/reactpress@4 ``` -全局命令为 `reactpress-cli`(与 npm 包名无关)。 - -> npm 上的无作用域包名 `reactpress-cli` 已被占用,本包发布为 `@fecommunity/reactpress-cli`。 +Global command: **`reactpress`** (`reactpress-cli` is a compatibility alias). -## 快速开始 +## Quick start ```bash mkdir my-blog && cd my-blog -reactpress-cli init -reactpress-cli start +reactpress init +reactpress dev ``` -浏览器访问 `http://localhost:3002`(API 文档:`/api`)。 +| Service | Port | URL | +| :--- | :---: | :--- | +| Admin (Vite) | 3000 | `http://localhost:3000` | +| Public theme | 3001 | `http://localhost:3001` | +| API | 3002 | `http://localhost:3002/api/health` | +| Theme preview | 3003 | `http://localhost:3003` | + +Run `reactpress` anytime for the interactive menu. Use `reactpress doctor` if startup fails. -## 常用命令 +## Common commands -| 命令 | 说明 | -|------|------| -| `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` | 改端口并重启 | +| Command | Description | +| :--- | :--- | +| `reactpress init [dir]` | Initialize a new site | +| `reactpress dev` | Start API, admin, and active theme locally | +| `reactpress build` | Build for production | +| `reactpress start` | Run in production | +| `reactpress status` | Show running services | +| `reactpress doctor` | Diagnose setup issues | +| `reactpress theme add ` | Install a theme from npm | +| `reactpress plugin install ` | Install a plugin | -## 要求 +## Requirements -- Node.js 18+ +- Node.js 20+ - macOS / Linux / Windows -- 默认使用 Docker 运行嵌入式 MySQL;也可在 `.reactpress/config.json` 中配置外部数据库 +- Docker recommended for bundled MySQL; SQLite available via desktop client or `--local` modes + +## Development (monorepo) + +From repository root: + +```bash +pnpm install +pnpm run build:cli +node ./cli/bin/reactpress.js dev +``` + +See root [README.md](../README.md) and [ARCHITECTURE.md](../ARCHITECTURE.md). -## 许可证 +## License MIT © FECommunity diff --git a/cli/bin/reactpress-cli-shim.js b/cli/bin/reactpress-cli-shim.js index bf2dc2b0..35931a94 100755 --- a/cli/bin/reactpress-cli-shim.js +++ b/cli/bin/reactpress-cli-shim.js @@ -4,7 +4,7 @@ * @deprecated 3.0 起请使用 `reactpress`(@fecommunity/reactpress)。3.1 将移除此 bin。 */ const chalk = require('chalk'); -const { t } = require('../lib/i18n'); +const { t } = require('../out/lib/i18n'); function mapLegacyArgv(argv) { const [cmd, ...rest] = argv; @@ -16,11 +16,7 @@ function mapLegacyArgv(argv) { } if (!process.env.REACTPRESS_SUPPRESS_DEPRECATION) { - console.warn( - chalk.yellow( - t('shim.deprecated') - ) - ); + console.warn(chalk.yellow(t('shim.deprecated'))); } const mapped = mapLegacyArgv(process.argv.slice(2)); diff --git a/cli/bin/reactpress-theme-client.js b/cli/bin/reactpress-theme-client.js new file mode 100644 index 00000000..3118c0a1 --- /dev/null +++ b/cli/bin/reactpress-theme-client.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../out/bin/reactpress-theme-client.js'); diff --git a/cli/bin/reactpress.js b/cli/bin/reactpress.js index 67829e15..a89dc68c 100755 --- a/cli/bin/reactpress.js +++ b/cli/bin/reactpress.js @@ -1,381 +1,2 @@ #!/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); -}); +require('../out/bin/reactpress.js'); diff --git a/cli/lib/dev-banner.js b/cli/lib/dev-banner.js deleted file mode 100644 index 876f7206..00000000 --- a/cli/lib/dev-banner.js +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 838585e2..00000000 --- a/cli/lib/dev.js +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index cf4932b3..00000000 --- a/cli/lib/docker.js +++ /dev/null @@ -1,275 +0,0 @@ -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/nginx.js b/cli/lib/nginx.js deleted file mode 100644 index e056cab8..00000000 --- a/cli/lib/nginx.js +++ /dev/null @@ -1,342 +0,0 @@ -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/publish.js b/cli/lib/publish.js deleted file mode 100644 index 976fcb5b..00000000 --- a/cli/lib/publish.js +++ /dev/null @@ -1,968 +0,0 @@ -#!/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/package.json b/cli/package.json index 106be0e5..e64bf191 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@fecommunity/reactpress", - "version": "3.7.0", - "description": "ReactPress 3.0 — zero-config CMS: one package, one reactpress command", + "version": "4.0.0-beta.3", + "description": "ReactPress 4.0 CLI — full publishing platform bootstrap: CMS, Admin, API, themes, plugins, desktop", "author": "fecommunity", "license": "MIT", "repository": { @@ -22,11 +22,10 @@ "reactpress": "./bin/reactpress.js", "reactpress-cli": "./bin/reactpress-cli-shim.js" }, - "main": "./bin/reactpress.js", + "main": "./out/bin/reactpress.js", "files": [ "bin", - "lib", - "ui", + "out", "dist", "server", "templates", @@ -35,9 +34,11 @@ "LICENSE" ], "scripts": { - "test": "node --test tests", - "prepare": "node scripts/sync-bundled-core.mjs", - "prepack": "node scripts/sync-bundled-core.mjs && node scripts/sync-monorepo-server.mjs" + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "pnpm run build && node --test tests", + "prepare": "node scripts/sync-bundled-core.mjs && pnpm run build", + "prepack": "node scripts/sync-bundled-core.mjs && node scripts/sync-monorepo-server.mjs && pnpm run build" }, "publishConfig": { "access": "public", @@ -57,6 +58,11 @@ "ora": "^5.4.1" }, "devDependencies": { - "@fecommunity/reactpress-cli-core": "npm:@fecommunity/reactpress-cli@0.1.0" + "@fecommunity/reactpress-cli-core": "npm:@fecommunity/reactpress-cli@0.1.0", + "@types/cross-spawn": "^6.0.6", + "@types/fs-extra": "^11.0.4", + "@types/inquirer": "^8.2.10", + "@types/node": "^24.5.2", + "typescript": "~5.9.3" } } diff --git a/cli/scripts/sync-bundled-core.mjs b/cli/scripts/sync-bundled-core.mjs index 68021882..d5831a59 100644 --- a/cli/scripts/sync-bundled-core.mjs +++ b/cli/scripts/sync-bundled-core.mjs @@ -72,7 +72,7 @@ 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')); +const { t, getLocale, setLocale } = require(join(dirname(fileURLToPath(import.meta.url)), '..', 'out', 'lib', 'i18n', 'index.js')); export { t, getLocale, setLocale }; ` diff --git a/cli/server/package.json b/cli/server/package.json index 4f84e927..92cb6701 100644 --- a/cli/server/package.json +++ b/cli/server/package.json @@ -1,7 +1,7 @@ { "name": "reactpress-server", "version": "1.0.0-beta.16", - "description": "ReactPress Server - NestJS-based backend API for ReactPress CMS", + "description": "ReactPress NestJS CMS API — headless REST backend for the publishing platform", "author": "fecommunity", "license": "MIT", "main": "dist/main.js", diff --git a/cli/server/public/favicon.png b/cli/server/public/favicon.png index f455437f62445b4d341505d01014d615683a4aed..521edf80e7dfb357e0bb99f8659aad49b04430b8 100644 GIT binary patch literal 33057 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

oLluaFg!lf8R;7G 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 FV1m^A{DS}h literal 1489 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 z`he2K+{JH6jMr130000000000 O00000000000002*-)o=% literal 0 HcmV?d00001 diff --git a/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp new file mode 100644 index 0000000000000000000000000000000000000000..a8eb1d19b6ccf96a017a4789ce3adaab61f7c5c6 GIT binary patch literal 23644 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 L0000000000Z#Li& literal 0 HcmV?d00001 diff --git a/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp new file mode 100644 index 0000000000000000000000000000000000000000..8336663422221bc5ae9f366f5ec0b3bd6597bf3c GIT binary patch literal 6532 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 literal 0 HcmV?d00001 diff --git a/cli/server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg b/cli/server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg new file mode 100644 index 0000000000000000000000000000000000000000..75bdbac720683e0866f6d59e3405384e3d05dd27 GIT binary patch literal 22388 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 literal 0 HcmV?d00001 diff --git a/cli/src/bin/reactpress-cli-shim.ts b/cli/src/bin/reactpress-cli-shim.ts new file mode 100644 index 00000000..f6c924be --- /dev/null +++ b/cli/src/bin/reactpress-cli-shim.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// @ts-nocheck + +/** + * @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/src/bin/reactpress-theme-client.ts b/cli/src/bin/reactpress-theme-client.ts new file mode 100644 index 00000000..87fb35d7 --- /dev/null +++ b/cli/src/bin/reactpress-theme-client.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// @ts-nocheck + +/** + * Generic ReactPress theme client launcher (for themes without bin/reactpress-client.js). + * Set REACTPRESS_THEME_DIR to the active theme package root. + */ + +const path = require('path'); +const fs = require('fs'); +const { spawn, spawnSync } = require('child_process'); +const { hasUsableProductionBuild } = require('../lib/theme-prod'); +const { getPm2ClientMemoryRestart, resolveBuildNodeEnv } = require('../lib/prod-memory'); +const { readActiveThemeManifest } = require('../lib/theme-runtime'); + +const originalCwd = process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(); +const args = process.argv.slice(2); +const usePM2 = args.includes('--pm2'); + +const clientDir = process.env.REACTPRESS_THEME_DIR + ? path.resolve(process.env.REACTPRESS_THEME_DIR) + : null; + +if (!clientDir || !fs.existsSync(path.join(clientDir, 'package.json'))) { + console.error('[ReactPress Client] REACTPRESS_THEME_DIR must point to a theme package'); + process.exit(1); +} + +const nextDir = path.join(clientDir, '.next'); +const startScript = fs.existsSync(path.join(clientDir, 'server.js')) ? 'server.js' : null; + +function runStartCommand() { + if (startScript) { + return ['node', [startScript]]; + } + return ['npm', ['run', 'start']]; +} + +function ensureBuilt() { + const { activeTheme } = readActiveThemeManifest(originalCwd); + const themeId = process.env.REACTPRESS_THEME_ID || activeTheme || path.basename(clientDir); + if (hasUsableProductionBuild(clientDir, themeId)) return; + console.log('[ReactPress Client] Client not built yet. Building…'); + const build = spawnSync('pnpm', ['run', 'build'], { + stdio: 'inherit', + cwd: clientDir, + env: resolveBuildNodeEnv({ ...process.env, REACTPRESS_BUILD_ACTIVE: '1' }), + }); + if (build.status !== 0) process.exit(build.status || 1); +} + +function startWithPm2() { + ensureBuilt(); + const [cmd, cmdArgs] = runStartCommand(); + const visitorPort = process.env.CLIENT_PORT || process.env.PORT || '3001'; + const apiPort = process.env.SERVER_PORT || '3002'; + const nginxEntry = (process.env.REACTPRESS_NGINX_ENTRY_URL || process.env.NGINX_ENTRY_URL || '') + .replace(/\/$/, ''); + const pm2Env = { + ...process.env, + NODE_ENV: 'production', + PORT: String(visitorPort), + CLIENT_PORT: String(visitorPort), + REACTPRESS_ORIGINAL_CWD: originalCwd, + REACTPRESS_THEME_DIR: clientDir, + REACTPRESS_API_URL: process.env.REACTPRESS_API_URL || `http://127.0.0.1:${apiPort}/api`, + SERVER_API_URL: process.env.SERVER_API_URL || `http://127.0.0.1:${apiPort}/api`, + NEXT_PUBLIC_REACTPRESS_API_URL: + process.env.NEXT_PUBLIC_REACTPRESS_API_URL || + (nginxEntry ? `${nginxEntry}/api` : `http://127.0.0.1:${apiPort}/api`), + ...(nginxEntry + ? { REACTPRESS_NGINX_ENTRY_URL: nginxEntry, NGINX_ENTRY_URL: nginxEntry } + : { REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1' }), + }; + + const pm2Mem = getPm2ClientMemoryRestart(); + const pm2Args = + cmd === 'node' + ? [ + 'start', + cmd, + '--name', + 'reactpress-client', + '--update-env', + '--max-memory-restart', + pm2Mem, + '--', + ...cmdArgs, + ] + : [ + 'start', + cmd, + '--name', + 'reactpress-client', + '--update-env', + '--max-memory-restart', + pm2Mem, + '--', + ...cmdArgs, + ]; + + const child = spawn('pm2', pm2Args, { stdio: 'inherit', cwd: clientDir, env: pm2Env }); + child.on('close', (code) => process.exit(code ?? 0)); + child.on('error', (err) => { + console.error('[ReactPress Client] PM2 failed:', err); + process.exit(1); + }); +} + +function startWithNode() { + ensureBuilt(); + process.chdir(clientDir); + const [cmd, cmdArgs] = runStartCommand(); + const child = spawn(cmd, cmdArgs, { stdio: 'inherit', cwd: clientDir, env: process.env }); + child.on('close', (code) => process.exit(code ?? 0)); +} + +if (usePM2) { + startWithPm2(); +} else { + startWithNode(); +} diff --git a/cli/src/bin/reactpress.ts b/cli/src/bin/reactpress.ts new file mode 100755 index 00000000..3aee978a --- /dev/null +++ b/cli/src/bin/reactpress.ts @@ -0,0 +1,599 @@ +#!/usr/bin/env node +// @ts-nocheck + +/** + * 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, runWebDev, runLocalWebDev, runLocalMonorepoDev, runThemeDev } = require('../lib/dev'); +const { resolveDevApiOrigins, applyDevApiOriginsToEnv } = require('../lib/remote-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 { getThemeBin } = require('../lib/paths'); +const { runInteractiveLoop } = require('../ui/interactive'); +const { t } = require('../lib/i18n'); + +const { getCliVersion } = require('../lib/paths'); + +const program = new Command(); + +program + .name('reactpress') + .description(t('cli.description')) + .version(getCliVersion()); + +program + .command('init') + .description(t('cli.init.description')) + .argument('[directory]', t('cli.init.directory'), '.') + .option('-f, --force', t('cli.init.force')) + .option('--local', t('cli.init.local')) + .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, + local: !!options.local, + }); + console.log(`[reactpress] ${result.message}`); + process.exit(result.ok ? 0 : 1); + return; + } + const args = ['init', directory]; + if (options.force) args.push('--force'); + if (options.local) args.push('--local'); + await 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')) + .option('--web-only', t('cli.dev.webOnly')) + .option('--remote-origin ', t('cli.dev.remoteOrigin')) + .option('--admin-origin ', t('cli.dev.adminOrigin')) + .option('--client-origin ', t('cli.dev.clientOrigin')) + .option('--local', t('cli.dev.local')) + .action(async (options) => { + const projectRoot = ensureOriginalCwd(); + if (options.local) { + process.env.REACTPRESS_LOCAL_MODE = '1'; + process.env.REACTPRESS_SKIP_NGINX = '1'; + } + try { + const hasOriginFlag = + options.remoteOrigin !== undefined || + options.adminOrigin !== undefined || + options.clientOrigin !== undefined; + + let apiOrigins = { admin: null, client: null, needsLocalApi: true }; + if (hasOriginFlag) { + const resolved = resolveDevApiOrigins(projectRoot, { + remoteOrigin: options.remoteOrigin, + adminOrigin: options.adminOrigin, + clientOrigin: options.clientOrigin, + }); + if (resolved.error === 'REMOTE_DEFAULT_REQUIRED') { + console.error(chalk.red('[reactpress]'), t('cli.dev.remoteDefaultRequired')); + process.exit(1); + } + if (resolved.error === 'INVALID_ORIGIN') { + console.error(chalk.red('[reactpress]'), t('cli.dev.invalidOrigin')); + process.exit(1); + } + if ( + options.remoteOrigin !== undefined && + !resolved.remoteDefault && + options.adminOrigin === undefined && + options.clientOrigin === undefined + ) { + console.error(chalk.red('[reactpress]'), t('cli.dev.remoteOriginRequired')); + process.exit(1); + } + apiOrigins = resolved; + applyDevApiOriginsToEnv(apiOrigins); + } + + if (options.clientOnly) { + await runThemeDev(projectRoot, { apiOrigins }); + return; + } + if (options.webOnly) { + if (options.local) { + await runLocalWebDev(projectRoot, { apiOrigins }); + return; + } + await runWebDev(projectRoot, { apiOrigins }); + return; + } + if (options.apiOnly) { + if (!apiOrigins.needsLocalApi) { + console.error(chalk.red('[reactpress]'), t('cli.dev.remoteOriginIncompatibleApiOnly')); + process.exit(1); + } + await runApiDev(projectRoot); + return; + } + if (options.local) { + const { isMonorepoCheckout } = require('../lib/root'); + const { hasWeb } = require('../lib/project-type'); + if (isMonorepoCheckout(projectRoot) && hasWeb(projectRoot)) { + await runLocalMonorepoDev(projectRoot); + return; + } + } + await runDev(projectRoot, { apiOrigins }); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(err.exitCode ?? 1); + } + }); + +const desktopCmd = program.command('desktop').description(t('cli.desktopDev.description')); + +desktopCmd + .command('dev') + .description(t('cli.desktopDev.description')) + .action(async () => { + const projectRoot = ensureOriginalCwd(); + const script = path.join(projectRoot, 'desktop/scripts/dev-full.mjs'); + try { + const { spawn } = require('child_process'); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script], { + cwd: projectRoot, + stdio: 'inherit', + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + child.on('close', (code) => { + if (code && code !== 0) { + reject(Object.assign(new Error(`desktop dev exited with code ${code}`), { exitCode: code })); + return; + } + resolve(); + }); + child.on('error', reject); + }); + } 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 projectRoot = ensureOriginalCwd(); + const { resolveThemeDirectory, readActiveThemeManifest } = require('../lib/theme-runtime'); + const { resolveProductionThemeEnv } = require('../lib/theme-prod'); + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + const args = options.pm2 ? ['--pm2'] : []; + const env = + themeDir && options.pm2 + ? resolveProductionThemeEnv(projectRoot, themeDir) + : themeDir + ? { REACTPRESS_THEME_DIR: themeDir } + : undefined; + await runNodeScript(getThemeBin(projectRoot), args, { + cwd: projectRoot, + env, + }); + }); + +clientCmd + .command('restart') + .description(t('cli.client.restart')) + .option('--pm2', t('cli.client.start.pm2')) + .action(async (options) => { + try { + const projectRoot = ensureOriginalCwd(); + const { restartProductionVisitorClient } = require('../lib/theme-prod'); + if (options.pm2) { + await restartProductionVisitorClient(projectRoot); + } else { + const { buildActiveTheme } = require('../lib/theme-prod'); + const { activeTheme, themeDir } = buildActiveTheme(projectRoot); + const args = []; + await runNodeScript(getThemeBin(projectRoot), args, { + cwd: projectRoot, + env: { REACTPRESS_THEME_DIR: themeDir }, + }); + console.log(`[reactpress] ${require('../lib/i18n').t('themeProd.restarted', { id: activeTheme })}`); + } + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +program + .command('build') + .description(t('cli.build.description')) + .option('-t, --target ', t('cli.build.target'), 'all') + .option('--low-mem', t('cli.build.lowMem')) + .action(async (options) => { + try { + if (options.lowMem) { + process.env.REACTPRESS_LOW_MEM = '1'; + } + 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')) + .option('--tag ', 'npm dist-tag: beta or latest') + .option('--version ', 'semver for all core packages') + .option('--yes', 'skip confirmation prompt') + .option('--no-build', 'skip build before publish') + .option('--otp ', 'npm 2FA one-time password') + .action(async (options) => { + try { + const publish = require('../lib/publish'); + if (options.build) { + await publish.buildPackages(); + return; + } + if (options.publish) { + await publish.publishPackages({ + tag: options.tag, + version: options.version, + yes: Boolean(options.yes), + noBuild: Boolean(options.noBuild), + otp: options.otp || process.env.NPM_OTP, + }); + return; + } + await publish.main(); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +const themeCmd = program.command('theme').description(t('cli.theme.description')); + +themeCmd + .command('add') + .description(t('cli.theme.add.description')) + .argument('[spec]', t('cli.theme.add.spec')) + .option('--catalog ', t('cli.theme.add.catalog')) + .option('--skip-deps', t('cli.theme.add.skipDeps')) + .action(async (spec, options) => { + try { + const projectRoot = ensureOriginalCwd(); + const targetSpec = options.catalog || spec; + if (!targetSpec) { + throw new Error(t('themeInstall.specRequired')); + } + await require('../lib/theme-cli').runThemeAdd(projectRoot, targetSpec, { + skipDependencies: !!options.skipDeps, + }); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +themeCmd.command('list').description(t('cli.theme.list.description')).action(() => { + require('../lib/theme-cli').runThemeList(ensureOriginalCwd()); +}); + +const pluginCmd = program.command('plugin').description(t('cli.plugin.description')); + +pluginCmd + .command('install') + .description(t('cli.plugin.install.description')) + .argument('', t('cli.plugin.install.id')) + .action((id) => { + try { + require('../lib/plugin-cli').runPluginInstall(ensureOriginalCwd(), id); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +pluginCmd.command('list').description(t('cli.plugin.list.description')).action(() => { + require('../lib/plugin-cli').runPluginList(ensureOriginalCwd()); +}); + +program + .command('start') + .description(t('cli.start.description')) + .action(async () => { + const projectRoot = ensureOriginalCwd(); + const code = await runLifecycleCommand('start', projectRoot); + if (code !== 0) process.exit(code); + + const { resolveThemeDirectory, readActiveThemeManifest } = require('../lib/theme-runtime'); + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + if (!themeDir) { + console.log(t('dev.standaloneHint')); + return; + } + const { spawn } = require('child_process'); + const child = spawn('pnpm', ['run', 'start'], { + stdio: 'inherit', + shell: true, + cwd: themeDir, + }); + 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.devLocal'), + t('cli.help.initLocal'), + t('cli.help.desktop'), + 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.theme'), + t('cli.help.themeList'), + t('cli.help.plugin'), + t('cli.help.dbBackup'), + 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/src/core/services/config.ts b/cli/src/core/services/config.ts new file mode 100644 index 00000000..a140fd77 --- /dev/null +++ b/cli/src/core/services/config.ts @@ -0,0 +1,144 @@ +import fs from 'fs-extra'; + +import type { EnvMap, ReactPressConfig } from '../../types/config'; +import { getProjectPaths, SQLITE_REL_PATH } from '../utils/paths'; + +export async function loadConfig(projectRoot: string): Promise { + const { configPath } = getProjectPaths(projectRoot); + if (!(await fs.pathExists(configPath))) { + throw new Error('未找到 ReactPress 项目。请先运行 reactpress init 初始化。'); + } + return fs.readJson(configPath) as Promise; +} + +export async function saveConfig(projectRoot: string, config: ReactPressConfig): Promise { + const { configPath, reactpressDir } = getProjectPaths(projectRoot); + await fs.ensureDir(reactpressDir); + await fs.writeJson(configPath, config, { spaces: 2 }); +} + +export async function loadEnvFile(envPath: string): Promise { + const content = await fs.readFile(envPath, 'utf8'); + const map: EnvMap = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + map[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + return map; +} + +export async function writeEnvFile(envPath: string, map: EnvMap): Promise { + const lines = [ + '# ReactPress — managed by reactpress-cli', + ...Object.entries(map).map(([k, v]) => `${k}=${v}`), + '', + ]; + await fs.writeFile(envPath, lines.join('\n'), 'utf8'); +} + +export function isSqliteMode(config: ReactPressConfig): boolean { + return config.database.mode === 'embedded-sqlite'; +} + +export async function syncEnvFromConfig( + projectRoot: string, + config: ReactPressConfig, +): Promise { + const { envPath, sqlitePath, uploadsDir } = getProjectPaths(projectRoot); + const existing = (await fs.pathExists(envPath)) ? await loadEnvFile(envPath) : {}; + + if (isSqliteMode(config)) { + const rawDbFile = config.database.sqlitePath ?? sqlitePath; + const dbFile = + rawDbFile === 'data/reactpress.db' || rawDbFile.endsWith('/data/reactpress.db') + ? SQLITE_REL_PATH + : rawDbFile; + const port = config.server.port; + const siteUrl = + config.server.serverUrl ?? config.server.siteUrl ?? `http://127.0.0.1:${port}`; + const clientUrl = config.server.clientUrl ?? 'http://localhost:3001'; + const merged: EnvMap = { + ...existing, + DB_TYPE: 'sqlite', + DB_DATABASE: dbFile, + SERVER_PORT: String(port), + CLIENT_SITE_URL: clientUrl, + SERVER_SITE_URL: siteUrl, + SERVER_API_PREFIX: config.server.apiPrefix ?? existing.SERVER_API_PREFIX ?? '/api', + REACTPRESS_UPLOAD_DIR: existing.REACTPRESS_UPLOAD_DIR ?? uploadsDir, + }; + await writeEnvFile(envPath, merged); + return; + } + + const merged: EnvMap = { + ...existing, + DB_TYPE: 'mysql', + DB_HOST: config.database.host ?? existing.DB_HOST ?? '127.0.0.1', + DB_PORT: String(config.database.port ?? existing.DB_PORT ?? 3306), + DB_USER: config.database.user ?? existing.DB_USER ?? 'reactpress', + DB_PASSWD: config.database.password ?? existing.DB_PASSWD ?? 'reactpress', + DB_DATABASE: config.database.database ?? existing.DB_DATABASE ?? 'reactpress', + SERVER_PORT: String(config.server.port), + CLIENT_SITE_URL: config.server.clientUrl ?? existing.CLIENT_SITE_URL ?? 'http://localhost:3001', + SERVER_SITE_URL: config.server.serverUrl ?? existing.SERVER_SITE_URL ?? 'http://localhost:3002', + }; + await writeEnvFile(envPath, merged); +} + +export function setConfigValue( + config: ReactPressConfig, + keyPath: string, + value: string, +): ReactPressConfig { + const parts = keyPath.split('.'); + const clone = structuredClone(config) as unknown as Record; + let cursor: Record = clone; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (typeof cursor[part] !== 'object' || cursor[part] === null) { + cursor[part] = {}; + } + cursor = cursor[part] as Record; + } + const last = parts[parts.length - 1]; + const numericKeys = new Set(['port', 'version']); + cursor[last] = numericKeys.has(last) ? Number(value) : value; + return clone as unknown as ReactPressConfig; +} + +export function getConfigValue(config: ReactPressConfig, keyPath: string): string { + const parts = keyPath.split('.'); + let cursor: unknown = config; + for (const part of parts) { + if (typeof cursor !== 'object' || cursor === null) { + throw new Error(`配置项不存在: ${keyPath}`); + } + cursor = (cursor as Record)[part]; + } + if (cursor === undefined) { + throw new Error(`配置项不存在: ${keyPath}`); + } + return String(cursor); +} + +export function listConfigKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + keys.push(...listConfigKeys(value as Record, fullPath)); + } else { + keys.push(fullPath); + } + } + return keys; +} + +export async function isReactPressProject(projectRoot: string): Promise { + const { configPath } = getProjectPaths(projectRoot); + return fs.pathExists(configPath); +} diff --git a/cli/src/core/services/database/index.ts b/cli/src/core/services/database/index.ts new file mode 100644 index 00000000..e5a63d7a --- /dev/null +++ b/cli/src/core/services/database/index.ts @@ -0,0 +1,313 @@ +import fs from 'fs-extra'; + +import type { + DatabaseEnsureResult, + MysqlCredentials, + ReactPressConfig, +} from '../../../types/config'; +import { getDockerComposeCommand } from '../../utils/platform'; +import { getProjectPaths } from '../../utils/paths'; +import { findAvailablePort, isDockerPortBindError, isPortAvailable } from '../../utils/port'; +import { isDockerAvailable, runSync, sleep } from '../exec'; +import { + isSqliteMode, + loadConfig, + loadEnvFile, + saveConfig, + syncEnvFromConfig, +} from '../config'; +import { + getDatabaseCredentials, + testMysqlConnection, + waitForMysql, +} from './mysql'; +import { ensureSqliteDatabase, isSqliteReady } from './sqlite'; + +export { getDatabaseCredentials, testMysqlConnection as testDatabaseConnection } from './mysql'; +export { ensureSqliteDatabase, probeSqliteDatabase, isSqliteReady } from './sqlite'; +export { resolveDatabaseProfile, requiresDocker, isLocalDatabaseMode } from './profile'; + +const DEFAULT_DB_HOST_PORT = 3306; +const EMBEDDED_DB_ROOT_PASSWORD = 'reactpress_root'; + +export async function waitForDatabase( + creds: MysqlCredentials, + maxAttempts = 40, + intervalMs = 1000, +): Promise { + return waitForMysql(creds, maxAttempts, intervalMs); +} + +export function parseDockerPublishedPort(output: string): number | null { + for (const line of output.split('\n')) { + const match = line.trim().match(/:(\d+)\s*$/); + if (match) return Number(match[1]); + } + return null; +} + +export async function getContainerPublishedHostPort( + containerName: string, + containerPort = 3306, +): Promise { + if (!isDockerAvailable()) return null; + const result = runSync('docker', ['port', containerName, `${containerPort}/tcp`], { + silent: true, + }); + if (!result.ok || !result.stdout.trim()) return null; + return parseDockerPublishedPort(result.stdout); +} + +async function persistDatabaseHostPort( + projectRoot: string, + config: ReactPressConfig, + port: number, +): Promise { + const { configPath, envPath } = getProjectPaths(projectRoot); + config.database.port = port; + if (await fs.pathExists(configPath)) { + await saveConfig(projectRoot, config); + } + if (await fs.pathExists(envPath)) { + await syncEnvFromConfig(projectRoot, config); + } +} + +async function isContainerHealthy(containerName: string): Promise { + const result = runSync( + 'docker', + [ + 'inspect', + '-f', + '{{if .State.Health}}{{.State.Health.Status}}{{else}}healthy{{end}}', + containerName, + ], + { silent: true }, + ); + if (!result.ok) return false; + return result.stdout.trim() === 'healthy'; +} + +async function buildConnectionFailureMessage( + projectRoot: string, + config: ReactPressConfig, + creds: MysqlCredentials, +): Promise { + const containerName = config.database.containerName ?? 'reactpress_cli_db'; + const published = await getContainerPublishedHostPort(containerName); + const port = published ?? creds.port; + const rootReachable = await testMysqlConnection({ + host: creds.host, + port, + user: 'root', + password: EMBEDDED_DB_ROOT_PASSWORD, + database: creds.database, + }); + const healthy = await isContainerHealthy(containerName); + if (rootReachable && healthy) { + return ( + `数据库容器已在端口 ${port} 运行,但账号「${creds.user}」无法连接(数据卷中的凭证与 .env 不一致)。` + + ` 请在项目目录执行: cd .reactpress && docker compose down -v && cd .. && reactpress start` + ); + } + return `数据库容器已启动,但连接超时。请执行 docker logs ${containerName} 查看详情。`; +} + +export async function ensureDatabaseHostPort( + projectRoot: string, + forcePort?: number, + configOverride?: ReactPressConfig, +): Promise<{ port: number; changed: boolean; previousPort: number }> { + const config = configOverride ?? (await loadConfig(projectRoot)); + if (isSqliteMode(config)) { + const port = config.server.port ?? DEFAULT_DB_HOST_PORT; + return { port, changed: false, previousPort: port }; + } + if (config.database.mode !== 'embedded-docker') { + const port = config.database.port ?? DEFAULT_DB_HOST_PORT; + return { port, changed: false, previousPort: port }; + } + + const { envPath } = getProjectPaths(projectRoot); + const existing = (await fs.pathExists(envPath)) ? await loadEnvFile(envPath) : {}; + const currentPort = Number(existing.DB_PORT ?? config.database.port ?? DEFAULT_DB_HOST_PORT); + const containerName = config.database.containerName ?? 'reactpress_cli_db'; + const containerPort = await getContainerPublishedHostPort(containerName); + + if (containerPort !== null && !forcePort) { + if (containerPort !== currentPort) { + await persistDatabaseHostPort(projectRoot, config, containerPort); + return { port: containerPort, changed: true, previousPort: currentPort }; + } + return { port: containerPort, changed: false, previousPort: currentPort }; + } + + if (!forcePort && (await isPortAvailable(currentPort))) { + return { port: currentPort, changed: false, previousPort: currentPort }; + } + + if (!forcePort && !(await isPortAvailable(currentPort))) { + const creds = await getDatabaseCredentials(projectRoot); + if (await testMysqlConnection({ ...creds, port: currentPort })) { + return { port: currentPort, changed: false, previousPort: currentPort }; + } + } + + let port: number; + if (forcePort && (await isPortAvailable(forcePort))) { + port = forcePort; + } else { + const start = + forcePort ?? + (currentPort === DEFAULT_DB_HOST_PORT ? DEFAULT_DB_HOST_PORT + 1 : currentPort + 1); + port = await findAvailablePort(start); + } + + if (currentPort === port && config.database.port === port) { + return { port, changed: false, previousPort: currentPort }; + } + + await persistDatabaseHostPort(projectRoot, config, port); + return { port, changed: true, previousPort: currentPort }; +} + +async function getDockerComposeEnv(projectRoot: string): Promise> { + const creds = await getDatabaseCredentials(projectRoot); + return { + DB_PORT: String(creds.port), + DB_USER: creds.user, + DB_PASSWD: creds.password, + DB_DATABASE: creds.database, + MYSQL_ROOT_PASSWORD: EMBEDDED_DB_ROOT_PASSWORD, + }; +} + +function runDockerCompose( + composeFile: string, + cwd: string, + subcommand: string[], + env: Record, +) { + const composeV2 = runSync('docker', ['compose', 'version'], { silent: true }); + if (composeV2.ok) { + return runSync('docker', ['compose', '-f', composeFile, ...subcommand], { + cwd, + silent: true, + env, + }); + } + return runSync(getDockerComposeCommand(), ['-f', composeFile, ...subcommand], { + cwd, + silent: true, + env, + }); +} + +export async function promoteToSqliteMode( + projectRoot: string, + config: ReactPressConfig, +): Promise { + const { sqlitePath } = getProjectPaths(projectRoot); + const next: ReactPressConfig = structuredClone(config); + next.database.mode = 'embedded-sqlite'; + next.database.sqlitePath = config.database.sqlitePath ?? sqlitePath; + await saveConfig(projectRoot, next); + await syncEnvFromConfig(projectRoot, next); + return ensureSqliteDatabase(projectRoot); +} + +export async function startEmbeddedDatabase( + projectRoot: string, + config: ReactPressConfig, +): Promise { + if (isSqliteMode(config)) { + await syncEnvFromConfig(projectRoot, config); + return ensureSqliteDatabase(projectRoot); + } + if (config.database.mode !== 'embedded-docker') { + return { ok: true }; + } + if (!isDockerAvailable()) { + return promoteToSqliteMode(projectRoot, config); + } + + const { dockerComposePath, reactpressDir } = getProjectPaths(projectRoot); + const maxPortRetries = 5; + + for (let attempt = 0; attempt < maxPortRetries; attempt++) { + const { port, changed, previousPort } = await ensureDatabaseHostPort( + projectRoot, + undefined, + config, + ); + if (changed) { + console.warn(`[reactpress] 宿主机端口 ${previousPort} 已被占用,已改用 ${port}(已更新 .env)`); + } + const composeEnv = await getDockerComposeEnv(projectRoot); + const result = runDockerCompose(dockerComposePath, reactpressDir, ['up', '-d'], composeEnv); + if (result.ok) break; + + const output = `${result.stderr}\n${result.stdout}`; + if (isDockerPortBindError(output) && attempt < maxPortRetries - 1) { + await ensureDatabaseHostPort(projectRoot, port + 1, config); + console.warn(`[reactpress] 端口 ${port} 绑定失败,正在尝试其他端口…`); + continue; + } + return { ok: false, message: `启动数据库容器失败: ${result.stderr || result.stdout}` }; + } + + const creds = await getDatabaseCredentials(projectRoot); + const ready = await waitForDatabase(creds); + if (!ready) { + return { + ok: false, + message: await buildConnectionFailureMessage(projectRoot, config, creds), + }; + } + return { ok: true }; +} + +export async function stopEmbeddedDatabase( + projectRoot: string, + config: ReactPressConfig, +): Promise { + if (config.database.mode !== 'embedded-docker') return; + if (!isDockerAvailable()) return; + const { dockerComposePath, reactpressDir } = getProjectPaths(projectRoot); + const composeEnv = await getDockerComposeEnv(projectRoot); + runDockerCompose(dockerComposePath, reactpressDir, ['down'], composeEnv); +} + +export async function ensureDatabase( + projectRoot: string, + config: ReactPressConfig, +): Promise { + if (isSqliteMode(config)) { + await syncEnvFromConfig(projectRoot, config); + return ensureSqliteDatabase(projectRoot); + } + + const creds = await getDatabaseCredentials(projectRoot); + if (await testMysqlConnection(creds)) { + return { ok: true }; + } + if (config.database.mode === 'embedded-docker') { + if (!isDockerAvailable()) { + return promoteToSqliteMode(projectRoot, config); + } + return startEmbeddedDatabase(projectRoot, config); + } + return { + ok: false, + message: `无法连接数据库 ${creds.host}:${creds.port},请检查 .env 中的 DB_* 配置。`, + }; +} + +export async function isDatabaseReady(projectRoot: string): Promise { + const config = await loadConfig(projectRoot); + if (isSqliteMode(config)) { + return isSqliteReady(projectRoot); + } + const creds = await getDatabaseCredentials(projectRoot); + return testMysqlConnection(creds); +} diff --git a/cli/src/core/services/database/mysql.ts b/cli/src/core/services/database/mysql.ts new file mode 100644 index 00000000..179b7cd7 --- /dev/null +++ b/cli/src/core/services/database/mysql.ts @@ -0,0 +1,92 @@ +import fs from 'fs-extra'; +import path from 'node:path'; + +import type { DatabaseEnsureResult, MysqlCredentials } from '../../../types/config'; + +export type ConnectionTester = (creds: MysqlCredentials) => Promise; + +async function defaultConnectionTester(creds: MysqlCredentials): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mysql = require('mysql2/promise') as typeof import('mysql2/promise'); + const connection = await mysql.createConnection({ + host: creds.host, + port: creds.port, + user: creds.user, + password: creds.password, + database: creds.database, + connectTimeout: 5000, + }); + await connection.query('SELECT 1'); + await connection.end(); + return true; + } catch { + return false; + } +} + +let connectionTester: ConnectionTester = defaultConnectionTester; + +/** @internal test hook */ +export function setConnectionTesterForTests(tester: ConnectionTester | null): void { + connectionTester = tester ?? defaultConnectionTester; +} + +export async function testMysqlConnection(creds: MysqlCredentials): Promise { + return connectionTester(creds); +} + +export async function waitForMysql( + creds: MysqlCredentials, + maxAttempts = 40, + intervalMs = 1000, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (await testMysqlConnection(creds)) return true; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +} + +import { loadEnvFile } from '../config'; +import { getProjectPaths } from '../../utils/paths'; + +export async function getDatabaseCredentials(projectRoot: string): Promise { + const { envPath } = getProjectPaths(projectRoot); + if (!(await fs.pathExists(envPath))) { + return { ...DEFAULT_CREDS }; + } + const env = await loadEnvFile(envPath); + return { + host: env.DB_HOST ?? DEFAULT_CREDS.host, + port: Number(env.DB_PORT ?? DEFAULT_CREDS.port), + user: env.DB_USER ?? DEFAULT_CREDS.user, + password: env.DB_PASSWD ?? DEFAULT_CREDS.password, + database: env.DB_DATABASE ?? DEFAULT_CREDS.database, + }; +} + +const DEFAULT_CREDS: MysqlCredentials = { + host: '127.0.0.1', + port: 3306, + user: 'reactpress', + password: 'reactpress', + database: 'reactpress', +}; + +export async function probeMysqlHost( + host: string, + port: number, + user: string, + password: string, + database: string, +): Promise<{ ok: boolean; error?: string }> { + try { + const ok = await testMysqlConnection({ host, port, user, password, database }); + return ok ? { ok: true } : { ok: false, error: 'connection refused' }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export { DEFAULT_CREDS }; diff --git a/cli/src/core/services/database/profile.ts b/cli/src/core/services/database/profile.ts new file mode 100644 index 00000000..6e3bcc5b --- /dev/null +++ b/cli/src/core/services/database/profile.ts @@ -0,0 +1,58 @@ +import fs from 'fs-extra'; +import path from 'node:path'; + +import type { DatabaseProfile, EnvMap, ReactPressConfig } from '../../../types/config'; +import { getProjectPaths } from '../../utils/paths'; +import { isSqliteMode, loadConfig, loadEnvFile } from '../config'; + +const DEFAULT_MYSQL = { + host: '127.0.0.1', + port: 3306, + user: 'reactpress', + password: 'reactpress', + database: 'reactpress', +} as const; + +export function resolveDatabaseType(config: ReactPressConfig, env: EnvMap): 'mysql' | 'sqlite' { + const envType = String(env.DB_TYPE || '').toLowerCase(); + if (envType === 'sqlite') return 'sqlite'; + if (isSqliteMode(config)) return 'sqlite'; + return 'mysql'; +} + +export async function resolveDatabaseProfile(projectRoot: string): Promise { + const config = await loadConfig(projectRoot); + const { envPath, sqlitePath } = getProjectPaths(projectRoot); + const env = (await fs.pathExists(envPath)) ? await loadEnvFile(envPath) : {}; + const type = resolveDatabaseType(config, env); + + if (type === 'sqlite') { + const database = + env.DB_DATABASE ?? config.database.sqlitePath ?? sqlitePath; + return { + type: 'sqlite', + mode: config.database.mode, + sqlite: { database: path.resolve(projectRoot, database) }, + }; + } + + return { + type: 'mysql', + mode: config.database.mode, + mysql: { + host: env.DB_HOST ?? config.database.host ?? DEFAULT_MYSQL.host, + port: Number(env.DB_PORT ?? config.database.port ?? DEFAULT_MYSQL.port), + user: env.DB_USER ?? config.database.user ?? DEFAULT_MYSQL.user, + password: env.DB_PASSWD ?? config.database.password ?? DEFAULT_MYSQL.password, + database: env.DB_DATABASE ?? config.database.database ?? DEFAULT_MYSQL.database, + }, + }; +} + +export function isLocalDatabaseMode(config: ReactPressConfig): boolean { + return config.database.mode === 'embedded-sqlite'; +} + +export function requiresDocker(config: ReactPressConfig): boolean { + return config.database.mode === 'embedded-docker'; +} diff --git a/cli/src/core/services/database/sqlite.ts b/cli/src/core/services/database/sqlite.ts new file mode 100644 index 00000000..581d9409 --- /dev/null +++ b/cli/src/core/services/database/sqlite.ts @@ -0,0 +1,107 @@ +import fs from 'fs-extra'; +import path from 'node:path'; + +import type { DatabaseEnsureResult, SqliteCredentials } from '../../../types/config'; +import { getProjectPaths, SQLITE_REL_PATH } from '../../utils/paths'; +import { loadEnvFile } from '../config'; + +const LEGACY_SQLITE_REL_PATH = path.join('data', 'reactpress.db'); + +async function maybeMigrateLegacySqlite(projectRoot: string, targetPath: string): Promise { + const legacyPath = path.join(projectRoot, LEGACY_SQLITE_REL_PATH); + const normalizedTarget = path.resolve(projectRoot, targetPath); + const normalizedLegacy = path.resolve(legacyPath); + + if (normalizedLegacy === normalizedTarget) return; + if (!(await fs.pathExists(legacyPath))) return; + if (await fs.pathExists(normalizedTarget)) return; + + await fs.ensureDir(path.dirname(normalizedTarget)); + await fs.copyFile(legacyPath, normalizedTarget); + console.log( + `[reactpress] Migrated SQLite database: ${LEGACY_SQLITE_REL_PATH} → ${path.relative(projectRoot, normalizedTarget) || SQLITE_REL_PATH}`, + ); +} + +export async function resolveSqlitePath( + projectRoot: string, + override?: string, +): Promise { + const paths = getProjectPaths(projectRoot); + if (override) { + const resolved = path.resolve(projectRoot, override); + await maybeMigrateLegacySqlite(projectRoot, resolved); + return resolved; + } + + if (await fs.pathExists(paths.envPath)) { + const env = await loadEnvFile(paths.envPath); + if (env.DB_DATABASE) { + const resolved = path.resolve(projectRoot, env.DB_DATABASE); + await maybeMigrateLegacySqlite(projectRoot, resolved); + return resolved; + } + } + + const target = paths.sqlitePath; + await maybeMigrateLegacySqlite(projectRoot, target); + return target; +} + +export async function getSqliteCredentials(projectRoot: string): Promise { + const database = await resolveSqlitePath(projectRoot); + return { database }; +} + +export async function ensureSqliteDatabase(projectRoot: string): Promise { + const database = await resolveSqlitePath(projectRoot); + const dir = path.dirname(database); + await fs.ensureDir(dir); + + try { + await fs.access(dir, fs.constants.W_OK); + } catch { + return { ok: false, message: `SQLite 数据目录不可写: ${dir}` }; + } + + if (!(await fs.pathExists(database))) { + await fs.writeFile(database, Buffer.alloc(0)); + } + + return { ok: true }; +} + +export async function probeSqliteDatabase( + projectRoot: string, +): Promise<{ ok: boolean; message?: string }> { + const database = await resolveSqlitePath(projectRoot); + const dir = path.dirname(database); + + if (!(await fs.pathExists(dir))) { + return { ok: false, message: `SQLite 目录不存在: ${dir}` }; + } + + try { + await fs.access(dir, fs.constants.W_OK); + } catch { + return { ok: false, message: `SQLite 目录不可写: ${dir}` }; + } + + if (await fs.pathExists(database)) { + const stat = await fs.stat(database); + return { + ok: true, + message: `SQLite ${database} (${stat.size} bytes)`, + }; + } + + return { + ok: true, + message: `SQLite 将在启动时创建: ${database}`, + }; +} + +export async function isSqliteReady(projectRoot: string): Promise { + const result = await ensureSqliteDatabase(projectRoot); + return result.ok; +} diff --git a/cli/src/core/services/exec.ts b/cli/src/core/services/exec.ts new file mode 100644 index 00000000..deb69a54 --- /dev/null +++ b/cli/src/core/services/exec.ts @@ -0,0 +1,70 @@ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import crossSpawn from 'cross-spawn'; + +import { isWindows } from '../utils/platform'; + +export interface RunSyncResult { + ok: boolean; + stdout: string; + stderr: string; + code: number | null; +} + +export function runSync( + command: string, + args: string[], + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + silent?: boolean; + } = {}, +): RunSyncResult { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + encoding: 'utf8', + shell: isWindows(), + stdio: options.silent ? 'pipe' : 'inherit', + }); + return { + ok: result.status === 0, + stdout: (result.stdout ?? '').toString(), + stderr: (result.stderr ?? '').toString(), + code: result.status, + }; +} + +export function spawnDetached( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): ChildProcess { + return crossSpawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + detached: !isWindows(), + stdio: 'ignore', + shell: isWindows(), + }); +} + +export function isCommandAvailable(command: string): boolean { + const checkCmd = isWindows() ? 'where' : 'which'; + const result = spawnSync(checkCmd, [command], { + encoding: 'utf8', + shell: isWindows(), + stdio: 'pipe', + }); + return result.status === 0; +} + +export function isDockerAvailable(): boolean { + const result = runSync('docker', ['info'], { silent: true }); + return result.ok; +} + +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export let spawnFn = spawn; diff --git a/cli/src/core/services/init.ts b/cli/src/core/services/init.ts new file mode 100644 index 00000000..79a373d0 --- /dev/null +++ b/cli/src/core/services/init.ts @@ -0,0 +1,76 @@ +import fs from 'fs-extra'; +import { join } from 'node:path'; + +import type { ReactPressConfig } from '../../types/config'; +import { getProjectPaths, getTemplatesDir } from '../utils/paths'; +import { saveConfig, syncEnvFromConfig } from './config'; +import { ensureDatabase, ensureDatabaseHostPort } from './database'; +import { initLocalProject } from './local-site'; + +export interface InitProjectOptions { + directory: string; + force?: boolean; + local?: boolean; +} + +export async function initProject( + options: InitProjectOptions, +): Promise<{ ok: boolean; projectRoot: string; message: string }> { + if (options.local) { + return initLocalProject(options.directory, { force: options.force }); + } + + const projectRoot = options.directory; + const paths = getProjectPaths(projectRoot); + + if ((await fs.pathExists(paths.configPath)) && !options.force) { + return { + ok: false, + projectRoot, + message: '目录已是 ReactPress 项目。使用 --force 覆盖配置。', + }; + } + + await fs.ensureDir(projectRoot); + await fs.ensureDir(paths.reactpressDir); + const templatesDir = getTemplatesDir(); + + await copyTemplate(join(templatesDir, 'docker-compose.yml'), paths.dockerComposePath); + await copyTemplate(join(templatesDir, 'package.json'), join(projectRoot, 'package.json')); + + const config = (await fs.readJson( + join(templatesDir, 'config.default.json'), + )) as ReactPressConfig; + await saveConfig(projectRoot, config); + await syncEnvFromConfig(projectRoot, config); + + if (!(await fs.pathExists(paths.envPath)) || options.force) { + const envTemplate = await fs.readFile(join(templatesDir, 'env.default'), 'utf8'); + await fs.writeFile(paths.envPath, envTemplate, 'utf8'); + await syncEnvFromConfig(projectRoot, config); + } + + await ensureDatabaseHostPort(projectRoot, undefined, config); + const dbResult = await ensureDatabase(projectRoot, config); + + if (!dbResult.ok) { + return { + ok: true, + projectRoot, + message: `项目已创建,但数据库未就绪: ${dbResult.message}。可稍后运行 reactpress dev。`, + }; + } + + return { + ok: true, + projectRoot, + message: 'ReactPress 项目初始化完成。运行 reactpress dev 启动服务。', + }; +} + +async function copyTemplate(src: string, dest: string): Promise { + if (!(await fs.pathExists(src))) { + throw new Error(`模板文件缺失: ${src}`); + } + await fs.copy(src, dest, { overwrite: true }); +} diff --git a/cli/src/core/services/local-site.ts b/cli/src/core/services/local-site.ts new file mode 100644 index 00000000..8ca5643e --- /dev/null +++ b/cli/src/core/services/local-site.ts @@ -0,0 +1,180 @@ +import fs from 'fs-extra'; +import path from 'node:path'; + +import type { ReactPressConfig } from '../../types/config'; +import { CONFIG_DIR, getProjectPaths, SQLITE_REL_PATH } from '../utils/paths'; +import { saveConfig, syncEnvFromConfig } from '../services/config'; + +export interface LocalSitePaths { + siteRoot: string; + dataDir: string; + uploadsDir: string; + dbPath: string; + envPath: string; + reactpressDir: string; +} + +export function getLocalSitePaths(siteRoot: string): LocalSitePaths { + const reactpressDir = path.join(siteRoot, CONFIG_DIR); + return { + siteRoot, + dataDir: reactpressDir, + uploadsDir: path.join(siteRoot, 'uploads'), + dbPath: path.join(reactpressDir, 'reactpress.db'), + envPath: path.join(siteRoot, '.env'), + reactpressDir, + }; +} + +export interface EnsureLocalSiteOptions { + monorepoRoot?: string; + adminUser?: string; + adminPassword?: string; +} + +export function ensureLocalSite( + siteRoot: string, + port: number, + options: EnsureLocalSiteOptions = {}, +): LocalSitePaths { + const paths = getLocalSitePaths(siteRoot); + fs.mkdirSync(paths.reactpressDir, { recursive: true }); + fs.mkdirSync(paths.uploadsDir, { recursive: true }); + + const siteUrl = `http://127.0.0.1:${port}`; + const clientSiteUrl = 'http://localhost:3001'; + const envLines = [ + 'DB_TYPE=sqlite', + `DB_DATABASE=${SQLITE_REL_PATH}`, + `SERVER_PORT=${port}`, + `SERVER_SITE_URL=${siteUrl}`, + `CLIENT_SITE_URL=${clientSiteUrl}`, + 'SERVER_API_PREFIX=/api', + `REACTPRESS_UPLOAD_DIR=${paths.uploadsDir}`, + `ADMIN_USER=${options.adminUser ?? 'admin'}`, + `ADMIN_PASSWD=${options.adminPassword ?? 'admin'}`, + `REACTPRESS_LANG=${process.env.REACTPRESS_LANG ?? 'zh'}`, + '', + ]; + fs.writeFileSync(paths.envPath, envLines.join('\n'), 'utf8'); + + if (options.monorepoRoot) { + seedBundledAssets(siteRoot, options.monorepoRoot); + } + + const configPath = path.join(paths.reactpressDir, 'config.json'); + if (!fs.existsSync(configPath)) { + const config: ReactPressConfig = { + version: 1, + database: { mode: 'embedded-sqlite', sqlitePath: SQLITE_REL_PATH }, + server: { + port, + apiPrefix: '/api', + siteUrl, + clientUrl: clientSiteUrl, + serverUrl: siteUrl, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); + } + + return paths; +} + +function seedBundledAssets(siteRoot: string, monorepoRoot: string): void { + seedSymlinkRegistry( + path.join(monorepoRoot, 'plugins'), + path.join(siteRoot, 'plugins'), + 'local', + ); + seedSymlinkRegistry( + path.join(monorepoRoot, 'themes'), + path.join(siteRoot, 'themes'), + 'local', + 'npm', + ); + seedRuntimeThemes(siteRoot, monorepoRoot); +} + +function seedSymlinkRegistry( + sourceDir: string, + targetDir: string, + ...registryKeys: string[] +): void { + const sourcePackageJson = path.join(sourceDir, 'package.json'); + const targetPackageJson = path.join(targetDir, 'package.json'); + if (!fs.existsSync(sourcePackageJson)) return; + + fs.mkdirSync(targetDir, { recursive: true }); + if (!fs.existsSync(targetPackageJson)) { + fs.copyFileSync(sourcePackageJson, targetPackageJson); + } + + let meta: { reactpress?: Record } = {}; + try { + meta = JSON.parse(fs.readFileSync(targetPackageJson, 'utf8')) as typeof meta; + } catch { + return; + } + + for (const key of registryKeys) { + const ids = Array.isArray(meta.reactpress?.[key]) ? meta.reactpress[key] : []; + for (const id of ids) { + if (typeof id !== 'string' || !id.trim()) continue; + const sourcePath = path.join(sourceDir, id.trim()); + const targetPath = path.join(targetDir, id.trim()); + if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) continue; + fs.symlinkSync(sourcePath, targetPath, 'dir'); + } + } +} + +function seedRuntimeThemes(siteRoot: string, monorepoRoot: string): void { + const sourceRuntime = path.join(monorepoRoot, '.reactpress', 'runtime'); + const targetRuntime = path.join(siteRoot, '.reactpress', 'runtime'); + if (!fs.existsSync(sourceRuntime)) return; + + fs.mkdirSync(path.join(siteRoot, '.reactpress'), { recursive: true }); + + for (const entry of fs.readdirSync(sourceRuntime, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const sourcePath = path.join(sourceRuntime, entry.name); + const targetPath = path.join(targetRuntime, entry.name); + if (fs.existsSync(targetPath)) continue; + try { + if (!fs.statSync(sourcePath).isDirectory()) continue; + fs.mkdirSync(targetRuntime, { recursive: true }); + fs.symlinkSync(sourcePath, targetPath, 'dir'); + } catch { + // skip broken entries + } + } +} + +export async function initLocalProject( + projectRoot: string, + options: { force?: boolean; port?: number } = {}, +): Promise<{ ok: boolean; projectRoot: string; message: string }> { + const paths = getProjectPaths(projectRoot); + const port = options.port ?? 3002; + + if ((await fs.pathExists(paths.configPath)) && !options.force) { + return { + ok: false, + projectRoot, + message: '目录已是 ReactPress 项目。使用 --force 覆盖配置,或 --local 初始化 SQLite 本地模式。', + }; + } + + await fs.ensureDir(projectRoot); + ensureLocalSite(projectRoot, port); + const config = (await fs.readJson(paths.configPath)) as ReactPressConfig; + await saveConfig(projectRoot, config); + await syncEnvFromConfig(projectRoot, config); + + return { + ok: true, + projectRoot, + message: 'ReactPress 本地项目(SQLite)初始化完成。运行 reactpress dev --local 启动。', + }; +} diff --git a/cli/src/core/utils/cli-context.ts b/cli/src/core/utils/cli-context.ts new file mode 100644 index 00000000..1f171268 --- /dev/null +++ b/cli/src/core/utils/cli-context.ts @@ -0,0 +1,9 @@ +let projectCwd: string | undefined; + +export function setProjectCwd(cwd?: string): void { + projectCwd = cwd ? cwd : undefined; +} + +export function getProjectCwd(): string { + return projectCwd ?? process.cwd(); +} diff --git a/cli/src/core/utils/paths.ts b/cli/src/core/utils/paths.ts new file mode 100644 index 00000000..2a3671a4 --- /dev/null +++ b/cli/src/core/utils/paths.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path'; + +export const CONFIG_DIR = '.reactpress'; +export const CONFIG_FILE = 'config.json'; +export const PID_FILE = 'server.pid'; +export const ENV_FILE = '.env'; +/** Relative SQLite file under project root (runtime data lives in `.reactpress/`). */ +export const SQLITE_REL_PATH = join(CONFIG_DIR, 'reactpress.db'); + +/** CLI 包根目录(编译后位于 out/core/utils → ../../../) */ +export function getPackageRoot(): string { + return join(__dirname, '..', '..', '..'); +} + +export function getTemplatesDir(): string { + return join(getPackageRoot(), 'templates'); +} + +export interface ProjectPaths { + projectRoot: string; + reactpressDir: string; + configPath: string; + pidPath: string; + envPath: string; + dockerComposePath: string; + dbDataDir: string; + sqlitePath: string; + uploadsDir: string; +} + +export function getProjectPaths(projectRoot: string): ProjectPaths { + const reactpressDir = join(projectRoot, CONFIG_DIR); + return { + projectRoot, + reactpressDir, + configPath: join(reactpressDir, CONFIG_FILE), + pidPath: join(reactpressDir, PID_FILE), + envPath: join(projectRoot, ENV_FILE), + dockerComposePath: join(reactpressDir, 'docker-compose.yml'), + dbDataDir: reactpressDir, + sqlitePath: join(reactpressDir, 'reactpress.db'), + uploadsDir: join(projectRoot, 'uploads'), + }; +} diff --git a/cli/src/core/utils/platform.ts b/cli/src/core/utils/platform.ts new file mode 100644 index 00000000..e044bfd9 --- /dev/null +++ b/cli/src/core/utils/platform.ts @@ -0,0 +1,25 @@ +import { platform } from 'node:os'; + +export function isWindows(): boolean { + return platform() === 'win32'; +} + +export function isMac(): boolean { + return platform() === 'darwin'; +} + +export function getDockerComposeCommand(): string { + return isWindows() ? 'docker-compose.exe' : 'docker-compose'; +} + +export function getNpmCommand(): string { + return isWindows() ? 'npm.cmd' : 'npm'; +} + +export function getNpxCommand(): string { + return isWindows() ? 'npx.cmd' : 'npx'; +} + +export function getNodeCommand(): string { + return process.execPath; +} diff --git a/cli/src/core/utils/port.ts b/cli/src/core/utils/port.ts new file mode 100644 index 00000000..ed26b07c --- /dev/null +++ b/cli/src/core/utils/port.ts @@ -0,0 +1,30 @@ +import net from 'node:net'; + +const DEFAULT_MAX_ATTEMPTS = 100; + +export function isPortAvailable(port: number, host = '0.0.0.0'): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, host); + }); +} + +export async function findAvailablePort( + startPort: number, + maxAttempts = DEFAULT_MAX_ATTEMPTS, +): Promise { + for (let port = startPort; port < startPort + maxAttempts; port++) { + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error(`在 ${startPort}-${startPort + maxAttempts - 1} 范围内未找到可用端口`); +} + +export function isDockerPortBindError(output: string): boolean { + return /port is already allocated|address already in use/i.test(output); +} diff --git a/cli/lib/api-dev-runner.js b/cli/src/lib/api-dev-runner.ts similarity index 90% rename from cli/lib/api-dev-runner.js rename to cli/src/lib/api-dev-runner.ts index 4bbde612..4caed570 100644 --- a/cli/lib/api-dev-runner.js +++ b/cli/src/lib/api-dev-runner.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +// @ts-nocheck const { runApiDev } = require('./api-dev'); const { ensureOriginalCwd } = require('./root'); diff --git a/cli/lib/api-dev.js b/cli/src/lib/api-dev.ts similarity index 67% rename from cli/lib/api-dev.js rename to cli/src/lib/api-dev.ts index cac3fc4c..501d0c76 100644 --- a/cli/lib/api-dev.js +++ b/cli/src/lib/api-dev.ts @@ -1,6 +1,8 @@ +// @ts-nocheck const { spawn } = require('child_process'); const path = require('path'); const { ensureProjectEnvironment } = require('./bootstrap'); +const { applyAutoLocalDevFallback } = require('./dev'); const { getServerBin, getServerDir, @@ -10,6 +12,8 @@ const { const { stopApi } = require('./lifecycle'); const { ensureOriginalCwd } = require('./root'); const { t } = require('./i18n'); +const { ensureApiPortFree } = require('./ports'); +const { ensureDevDatabase } = require('./docker'); let apiChild; @@ -59,11 +63,24 @@ function startApiDev(projectRoot) { } async function runApiDev(projectRoot = ensureOriginalCwd()) { - try { - await ensureProjectEnvironment(projectRoot); - } catch (err) { - console.error(t('dev.envFailed'), err.message || err); - process.exit(1); + const skipEnvBootstrap = process.env.REACTPRESS_DEV_DB_READY === '1'; + + if (!skipEnvBootstrap) { + try { + await ensureProjectEnvironment(projectRoot, { skipDatabase: true }); + } catch (err) { + console.error(t('dev.envFailed'), err.message || err); + process.exit(1); + } + + await applyAutoLocalDevFallback(projectRoot, { needsLocalApi: true }); + + try { + await ensureDevDatabase(projectRoot); + } catch (err) { + console.error(t('dev.dbEnsureFailed', { message: err.message || err })); + process.exit(1); + } } process.on('SIGINT', () => { @@ -75,6 +92,14 @@ async function runApiDev(projectRoot = ensureOriginalCwd()) { process.exit(0); }); + if (process.env.REACTPRESS_DEV_PORTS_READY !== '1') { + const { reused, port: apiPort } = await ensureApiPortFree(projectRoot); + if (reused) { + console.log(t('dev.apiReusing', { port: apiPort })); + return; + } + } + startApiDev(projectRoot); } diff --git a/cli/lib/bootstrap.js b/cli/src/lib/bootstrap.ts similarity index 59% rename from cli/lib/bootstrap.js rename to cli/src/lib/bootstrap.ts index 9d5dd7ba..7ca0b691 100644 --- a/cli/lib/bootstrap.js +++ b/cli/src/lib/bootstrap.ts @@ -1,34 +1,37 @@ -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) { +// @ts-nocheck +import fs from 'node:fs'; +import path from 'node:path'; + +import { setProjectCwd } from '../core/utils/cli-context'; +import { saveConfig, syncEnvFromConfig, loadConfig, isReactPressProject } from '../core/services/config'; +import { ensureDatabase, ensureDatabaseHostPort } from '../core/services/database'; +import { initProject } from '../core/services/init'; +import { getProjectPaths, getTemplatesDir } from '../core/utils/paths'; +import { ensureOriginalCwd, isMonorepoCheckout } from './root'; +import { t } from './i18n'; + +async function copyTemplateFile(src: string, dest: string): Promise { 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'); +export async function initMonorepoProject( + projectRoot: string, + { force = false, local = false }: { force?: boolean; local?: boolean } = {}, +): Promise<{ ok: boolean; projectRoot: string; message: string }> { + if (local) { + return initProject({ directory: projectRoot, force, local: true }); + } const paths = getProjectPaths(projectRoot); const templatesDir = getTemplatesDir(); if (fs.existsSync(paths.configPath) && !force) { - const config = await (await importCliModule('services/config.js')).loadConfig(projectRoot); + const config = await 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: false, projectRoot, message: dbResult.message ?? t('bootstrap.dbPendingShort') }; } return { ok: true, projectRoot, message: t('bootstrap.configReady') }; } @@ -36,11 +39,11 @@ async function initMonorepoProject(projectRoot, { force = false } = {}) { await fs.promises.mkdir(paths.reactpressDir, { recursive: true }); await copyTemplateFile( path.join(templatesDir, 'docker-compose.yml'), - paths.dockerComposePath + paths.dockerComposePath, ); const config = JSON.parse( - await fs.promises.readFile(path.join(templatesDir, 'config.default.json'), 'utf8') + await fs.promises.readFile(path.join(templatesDir, 'config.default.json'), 'utf8'), ); await saveConfig(projectRoot, config); await syncEnvFromConfig(projectRoot, config); @@ -50,14 +53,13 @@ async function initMonorepoProject(projectRoot, { force = false } = {}) { 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 }), + message: t('bootstrap.projectDbPending', { message: dbResult.message ?? '' }), }; } @@ -68,14 +70,13 @@ async function initMonorepoProject(projectRoot, { force = false } = {}) { }; } -async function ensureProjectEnvironment(projectRoot = ensureOriginalCwd()) { +export async function ensureProjectEnvironment( + projectRoot = ensureOriginalCwd(), + options: { skipDatabase?: boolean } = {}, +): Promise<{ ok: boolean; projectRoot: string; message: string | null }> { 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); @@ -85,7 +86,6 @@ async function ensureProjectEnvironment(projectRoot = ensureOriginalCwd()) { 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')); @@ -94,21 +94,21 @@ async function ensureProjectEnvironment(projectRoot = ensureOriginalCwd()) { } const config = await loadConfig(root); + if (options.skipDatabase) { + return { ok: true, projectRoot: root, message: null }; + } + 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, -}; +export { isMonorepoCheckout }; diff --git a/cli/lib/build.js b/cli/src/lib/build.ts similarity index 59% rename from cli/lib/build.js rename to cli/src/lib/build.ts index ee70bc19..3042ebb7 100644 --- a/cli/lib/build.js +++ b/cli/src/lib/build.ts @@ -1,29 +1,48 @@ +// @ts-nocheck 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 { hasWeb } = require('./project-type'); const { t } = require('./i18n'); +const { shouldBuildToolkit } = require('./toolkit-build'); +const { hasUsableProductionBuild, readActiveThemeBuildState } = require('./theme-prod'); +const { resolveBuildNodeEnv } = require('./prod-memory'); const FORBIDDEN_SCRIPTS = new Set(['build']); /** @type {Record} */ const BUILD_STEPS = { toolkit: [{ script: 'build:toolkit', labelKey: 'build.label.toolkit' }], + plugins: [{ script: 'build:plugins', labelKey: 'build.label.plugins' }], server: [{ script: 'build:server', labelKey: 'build.label.server' }], - client: [{ script: 'build:client', labelKey: 'build.label.client' }], + web: [{ script: 'build:web', labelKey: 'build.label.web' }], + theme: [{ script: 'build:theme', labelKey: 'build.label.theme' }], 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 TARGETS = [...Object.keys(BUILD_STEPS), 'all']; -const buildChildEnv = { REACTPRESS_BUILD_ACTIVE: '1' }; +function getBuildSteps(target, projectRoot) { + if (target !== 'all') { + return BUILD_STEPS[target]; + } + + const steps = [ + { script: 'build:toolkit', labelKey: 'build.label.toolkit' }, + { script: 'build:plugins', labelKey: 'build.label.plugins' }, + { script: 'build:server', labelKey: 'build.label.server' }, + ]; + if (hasWeb(projectRoot)) { + steps.push({ script: 'build:web', labelKey: 'build.label.web' }); + } + steps.push({ script: 'build:theme', labelKey: 'build.label.theme' }); + return steps; +} + +const buildChildEnv = resolveBuildNodeEnv({ REACTPRESS_BUILD_ACTIVE: '1' }); function readPackageScripts(packageJsonPath) { try { @@ -57,10 +76,35 @@ function resolveBuildInvocation(script, projectRoot) { } } - 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:plugins') { + const pluginsDir = path.join(root, 'plugins'); + if (fs.existsSync(path.join(pluginsDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: pluginsDir }; + } + const rootScripts = readPackageScripts(path.join(root, 'package.json')); + if (rootScripts['build:plugins']) { + return { command: 'pnpm', args: ['run', 'build:plugins'], cwd: root }; + } + } + + if (script === 'build:web') { + const webDir = path.join(root, 'web'); + if (fs.existsSync(path.join(webDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: webDir }; + } + const rootScripts = readPackageScripts(path.join(root, 'package.json')); + if (rootScripts['build:web']) { + return { command: 'pnpm', args: ['run', 'build:web'], cwd: root }; + } + return null; + } + + if (script === 'build:theme') { + const { readActiveThemeManifest, resolveThemeDirectory } = require('./theme-runtime'); + const { activeTheme } = readActiveThemeManifest(root); + const themeDir = resolveThemeDirectory(root, activeTheme); + if (themeDir && fs.existsSync(path.join(themeDir, 'package.json'))) { + return { command: 'pnpm', args: ['run', 'build'], cwd: themeDir }; } } @@ -88,7 +132,7 @@ async function runBuild(target = 'all', projectRoot = ensureOriginalCwd()) { throw new Error(t('build.recursive')); } - const steps = BUILD_STEPS[target]; + const steps = getBuildSteps(target, projectRoot); if (!steps) { throw new Error( t('build.unknownTarget', { @@ -118,6 +162,24 @@ async function runBuild(target = 'all', projectRoot = ensureOriginalCwd()) { const stepStarted = Date.now(); const badge = stepBadge(current, total); + if (script === 'build:toolkit' && !shouldBuildToolkit(projectRoot)) { + console.log(` ${badge} ${ok(t('build.stepSkippedFresh', { label: stepLabel }))}`); + continue; + } + + if (script === 'build:theme') { + const themeState = readActiveThemeBuildState(projectRoot); + if ( + themeState && + hasUsableProductionBuild(themeState.themeDir, themeState.activeTheme) + ) { + console.log( + ` ${badge} ${ok(t('build.stepSkippedReuse', { label: stepLabel, id: themeState.activeTheme }))}`, + ); + continue; + } + } + const invocation = resolveBuildInvocation(script, projectRoot); if (!invocation) { console.log(` ${badge} ${warn(t('build.stepSkipped', { label: stepLabel }))}`); @@ -158,5 +220,6 @@ module.exports = { runBuild, TARGETS, BUILD_STEPS, + getBuildSteps, resolveBuildInvocation, }; diff --git a/cli/src/lib/context-status.ts b/cli/src/lib/context-status.ts new file mode 100644 index 00000000..16a21d2e --- /dev/null +++ b/cli/src/lib/context-status.ts @@ -0,0 +1,157 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const { describeProject } = require('./project-type'); +const { isDockerRunning } = require('./docker'); +const { isNginxContainerRunning } = require('./nginx'); +const { + DEV_PORTS, + readEnvPort, + isPortListening, +} = require('./ports'); +const { + loadServerSiteUrl, + loadWebAdminUrl, + isHttpResponding, + checkHealth, + getHealthUrl, +} = require('./http'); + +const SERVICE_ORDER = ['sqlite', 'mysql', 'server', 'docker', 'nginx', 'web']; + +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 resolveDbType(projectRoot) { + try { + const { resolveDatabaseProfile } = require('../core/services/database/profile'); + const profile = await resolveDatabaseProfile(projectRoot); + return profile.type; + } catch { + const env = parseEnvFile(projectRoot); + if (String(env.DB_TYPE || '').toLowerCase() === 'sqlite') return 'sqlite'; + try { + const configPath = path.join(projectRoot, '.reactpress/config.json'); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (config.database && config.database.mode === 'embedded-sqlite') return 'sqlite'; + } + } catch { + // ignore + } + return 'mysql'; + } +} + +async function probeMysql(projectRoot) { + const port = readEnvPort(projectRoot, 'DB_PORT', DEV_PORTS.MYSQL); + if (isPortListening(port)) return true; + 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 || DEV_PORTS.MYSQL), + 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 probeSqlite(projectRoot) { + const { probeSqliteDatabase } = require('../core/services/database/sqlite'); + const result = await probeSqliteDatabase(projectRoot); + return result.ok; +} + +async function probeServer(projectRoot) { + const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + if (isPortListening(port)) return true; + const [httpOk, health] = await Promise.all([ + isHttpResponding(loadServerSiteUrl(projectRoot), 1500), + checkHealth(getHealthUrl(projectRoot)), + ]); + return httpOk || health.ok; +} + +async function probeWeb(projectRoot) { + const port = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + if (isPortListening(port)) return true; + return isHttpResponding(loadWebAdminUrl(projectRoot), 1500); +} + +function resolveServiceChecks(dbType) { + const dbId = dbType === 'sqlite' ? 'sqlite' : 'mysql'; + return [dbId, 'server', 'docker', 'nginx', 'web']; +} + +async function probeService(projectRoot, id) { + switch (id) { + case 'sqlite': + return probeSqlite(projectRoot); + case 'mysql': + return probeMysql(projectRoot); + case 'server': + return probeServer(projectRoot); + case 'docker': + return Promise.resolve(isDockerRunning()); + case 'nginx': + return Promise.resolve(isNginxContainerRunning()); + case 'web': + return probeWeb(projectRoot); + default: + return false; + } +} + +async function emitProgress(onProgress, payload) { + if (!onProgress) return; + onProgress(payload); + await new Promise((resolve) => setImmediate(resolve)); +} + +async function fetchContextStatus(projectRoot, { onProgress } = {}) { + const project = describeProject(projectRoot); + await emitProgress(onProgress, { phase: 'start', id: '__config' }); + const dbType = await resolveDbType(projectRoot); + const ids = resolveServiceChecks(dbType); + await emitProgress(onProgress, { phase: 'ready', total: ids.length + 1 }); + await emitProgress(onProgress, { phase: 'done', id: '__config' }); + const components = await Promise.all( + ids.map(async (id) => { + await emitProgress(onProgress, { phase: 'start', id }); + const ok = await probeService(projectRoot, id); + await emitProgress(onProgress, { phase: 'done', id, ok }); + return { id, ok }; + }), + ); + return { components, project, dbType }; +} + +module.exports = { + fetchContextStatus, + resolveServiceChecks, + resolveDbType, + parseEnvFile, + probeService, + SERVICE_ORDER, +}; diff --git a/cli/src/lib/database-mode.ts b/cli/src/lib/database-mode.ts new file mode 100644 index 00000000..d84999cf --- /dev/null +++ b/cli/src/lib/database-mode.ts @@ -0,0 +1,41 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const { ensureOriginalCwd } = require('./root'); + +function isDesktopLocalMode() { + return process.env.REACTPRESS_DESKTOP_LOCAL === '1'; +} + +function readSqliteModeFromProject(projectRoot) { + try { + const configPath = path.join(projectRoot, '.reactpress/config.json'); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (config.database?.mode === 'embedded-sqlite') return true; + } + } catch { + // ignore + } + try { + const envPath = path.join(projectRoot, '.env'); + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf8'); + if (/^DB_TYPE=sqlite/m.test(content)) return true; + } + } catch { + // ignore + } + return false; +} + +function isLocalSqliteMode(projectRoot = ensureOriginalCwd()) { + if (process.env.REACTPRESS_LOCAL_MODE === '1' || isDesktopLocalMode()) return true; + return readSqliteModeFromProject(projectRoot); +} + +module.exports = { + isDesktopLocalMode, + isLocalSqliteMode, + readSqliteModeFromProject, +}; diff --git a/cli/lib/db-backup.js b/cli/src/lib/db-backup.ts similarity index 99% rename from cli/lib/db-backup.js rename to cli/src/lib/db-backup.ts index 729cb5f9..89433e2a 100644 --- a/cli/lib/db-backup.js +++ b/cli/src/lib/db-backup.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); diff --git a/cli/src/lib/dev-banner.ts b/cli/src/lib/dev-banner.ts new file mode 100644 index 00000000..3c2f24bd --- /dev/null +++ b/cli/src/lib/dev-banner.ts @@ -0,0 +1,159 @@ +// @ts-nocheck +const { + brand, + icon, + ok, + divider, + padRight, + terminalWidth, + gradientText, + palette, + pulseBar, + statusLights, +} = require('../ui/theme'); +const { + loadClientSiteUrl, + loadWebAdminUrl, + loadServerSiteUrl, + getApiPrefix, + getHealthUrl, +} = require('./http'); +const { hasWeb } = require('./project-type'); +const { nginxEntryUrl } = require('./nginx'); +const { t } = require('./i18n'); + +function normalizeUrl(value, fallback) { + const raw = String(value || fallback || '').trim(); + return raw.replace(/\/$/, ''); +} + +function getDevUrls(projectRoot) { + const client = normalizeUrl(loadClientSiteUrl(projectRoot), 'http://localhost:3001'); + const server = normalizeUrl(loadServerSiteUrl(projectRoot), 'http://localhost:3002'); + const prefix = normalizeUrl(getApiPrefix(projectRoot), '/api') || '/api'; + const admin = hasWeb(projectRoot) + ? normalizeUrl(loadWebAdminUrl(projectRoot), 'http://localhost:3000') + : `${client}/admin`; + return { + site: 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, + webOnly = false, + desktop = false, + localWeb = false, + nginx = false, + hasThemeSite = false, + dbOk = true, + adminApiOrigin = null, + clientApiOrigin = null, + localApiUrl = null, + dbType = null, + } = {} +) { + const urls = getDevUrls(projectRoot); + const w = Math.min(terminalWidth() - 4, 56); + const readyKey = apiOnly + ? 'devBanner.readyApi' + : desktop + ? 'devBanner.readyDesktop' + : localWeb + ? 'devBanner.readyLocalWeb' + : webOnly + ? 'devBanner.readyWeb' + : 'devBanner.ready'; + + const useLocalDesktopApi = Boolean((desktop || localWeb) && localApiUrl); + const lights = useLocalDesktopApi || dbOk ? 'online' : 'degraded'; + const readyGradient = + useLocalDesktopApi || dbOk ? [palette.green, palette.accent] : [palette.amber, palette.muted]; + + console.log(''); + console.log( + ` ${useLocalDesktopApi || dbOk ? icon.ok : icon.warn} ${gradientText(t(readyKey), readyGradient, { bold: true })} ${statusLights(lights)}` + ); + console.log(` ${brand.primary('╔' + '═'.repeat(w) + '╗')}`); + + if (useLocalDesktopApi) { + const dbLabel = + dbType === 'sqlite' ? t('devBanner.sqliteEmbedded') : t('devBanner.mysqlDocker'); + console.log(urlLine(t('devBanner.database'), dbLabel, { underline: false })); + console.log(urlLine(t('devBanner.api'), localApiUrl)); + console.log(urlLine(t('devBanner.admin'), urls.admin)); + if (hasThemeSite) { + console.log(urlLine(t('devBanner.site'), urls.site)); + } + const healthUrl = String(localApiUrl || '') + .replace(/\/api\/?$/, '') + '/api/health'; + console.log(urlLine(t('devBanner.health'), healthUrl, { underline: false })); + console.log( + ` ${brand.muted(' ')}${brand.dim(t(localWeb ? 'devBanner.localWebHint' : 'devBanner.desktopLocalHint'))}` + ); + } else if (nginx) { + const entry = nginxEntryUrl(projectRoot); + if (!apiOnly && (hasThemeSite || !webOnly)) { + console.log(urlLine(t('devBanner.site'), entry)); + } + if (!apiOnly && hasWeb(projectRoot)) { + console.log(urlLine(t('devBanner.admin'), `${entry}/admin/`)); + } + console.log(urlLine(t('devBanner.api'), `${entry}/api`, { underline: false })); + if (clientApiOrigin) { + console.log( + ` ${brand.muted(' ')}${brand.dim(t('devBanner.nginxRemoteHint', { url: clientApiOrigin }))}` + ); + } else { + console.log(` ${brand.muted(' ')}${brand.dim(t('devBanner.nginxHint'))}`); + } + if (adminApiOrigin) { + console.log( + ` ${brand.muted(' ')}${brand.dim(t('devBanner.adminRemoteHint', { url: adminApiOrigin }))}` + ); + } + } else { + if (!apiOnly) { + if (!webOnly) { + 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)} ${ + useLocalDesktopApi + ? brand.success(t('devBanner.localModeGo')) + : dbOk + ? brand.success(t('devBanner.allSystemsGo')) + : brand.warn(t('devBanner.dbDegraded')) + }` + ); + } + + 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/src/lib/dev-child-io.ts b/cli/src/lib/dev-child-io.ts new file mode 100644 index 00000000..ffa2c983 --- /dev/null +++ b/cli/src/lib/dev-child-io.ts @@ -0,0 +1,83 @@ +// @ts-nocheck +const { spawn } = require('child_process'); + +const DEV_OUTPUT_NOISE = [ + /^>/, + /^◇ injected env/, + /^◈ /, + /^warn\s+- Invalid next\.config/, + /^Browserslist:/, + /^event - /, + /^wait - /, + /^Warning: \[antd/, + /^Warning: Route file/, + /^If this file is not intended/, + /^Current configuration:/, + /^ \d\. Rename/, + /^ routeFileIgnore/, + /^See more info here/, + /^\s+- The root value/, + /^\s+- The value at/, + /^\(node:\d+\) MaxListenersExceededWarning/, + /^\(Use `node --trace-warnings/, + /^ready - started server/, + /^vite:react-swc\]/, + /^\s*VITE\+/, + /^\s*➜\s+Network:/, + /^\s*➜\s+Local:/, + /^\s*➜\s+press h \+/, +]; + +function isDevOutputQuiet() { + return process.env.REACTPRESS_DEV_VERBOSE !== '1'; +} + +function isNoiseLine(line) { + const trimmed = line.trim(); + if (!trimmed) return true; + return DEV_OUTPUT_NOISE.some((re) => re.test(trimmed)); +} + +function pipeFiltered(stream, target) { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString(); + let idx; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!isNoiseLine(line)) { + target.write(`${line}\n`); + } + } + }); + stream.on('end', () => { + if (buffer.trim() && !isNoiseLine(buffer)) { + target.write(`${buffer}\n`); + } + }); +} + +/** + * Spawn with filtered stdout/stderr unless REACTPRESS_DEV_VERBOSE=1. + */ +function spawnDevChild(command, args, options = {}) { + if (!isDevOutputQuiet()) { + return spawn(command, args, { ...options, stdio: options.stdio ?? 'inherit' }); + } + + const child = spawn(command, args, { + ...options, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (child.stdout) pipeFiltered(child.stdout, process.stdout); + if (child.stderr) pipeFiltered(child.stderr, process.stderr); + + return child; +} + +module.exports = { + isDevOutputQuiet, + spawnDevChild, +}; diff --git a/cli/src/lib/dev-log.ts b/cli/src/lib/dev-log.ts new file mode 100644 index 00000000..c811d22d --- /dev/null +++ b/cli/src/lib/dev-log.ts @@ -0,0 +1,68 @@ +// @ts-nocheck +const { t } = require('./i18n'); + +let startedAt = 0; +/** @type {Map} */ +const marks = new Map(); + +function startDevTimer() { + startedAt = Date.now(); + marks.clear(); + marks.set('start', 0); +} + +function markDevPhase(name) { + if (!startedAt) startDevTimer(); + marks.set(name, Date.now() - startedAt); +} + +function isDevVerbose() { + return process.env.REACTPRESS_DEV_VERBOSE === '1'; +} + +function formatDuration(ms) { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function logDevSection(messageKey, vars = {}) { + console.log(`\n${t(messageKey, vars)}`); +} + +function logDevStatus(messageKey, vars = {}) { + console.log(` ${t(messageKey, vars)}`); +} + +function logDevLine(messageKey, vars = {}) { + const msg = t(messageKey, vars); + console.log(msg.startsWith('[reactpress]') ? msg : `[reactpress] ${msg}`); +} + +function logDevDetail(messageKey, vars = {}) { + if (isDevVerbose()) logDevStatus(messageKey, vars); +} + +function logDevTimingSummary(extra = {}) { + markDevPhase('ready'); + const readyMs = marks.get('ready') ?? Date.now() - startedAt; + const infraMs = marks.has('infra') ? marks.get('infra') - (marks.get('start') || 0) : null; + const servicesMs = marks.has('services') ? marks.get('services') - (marks.get('infra') || 0) : null; + + const parts = [`${(readyMs / 1000).toFixed(1)}s`]; + if (infraMs != null) parts.push(`${t('dev.timingInfra')} ${formatDuration(infraMs)}`); + if (servicesMs != null) parts.push(`${t('dev.timingServices')} ${formatDuration(servicesMs)}`); + if (extra.apiReused) parts.push(t('dev.timingApiReused')); + + logDevStatus('dev.timingReady', { summary: parts.join(' · ') }); +} + +module.exports = { + startDevTimer, + markDevPhase, + isDevVerbose, + logDevSection, + logDevStatus, + logDevLine, + logDevDetail, + logDevTimingSummary, +}; diff --git a/cli/src/lib/dev-session.ts b/cli/src/lib/dev-session.ts new file mode 100644 index 00000000..b6f60b8c --- /dev/null +++ b/cli/src/lib/dev-session.ts @@ -0,0 +1,120 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +function lockFilePath(projectRoot) { + const { devSessionSuffix } = require('./ports'); + return path.join(projectRoot, '.reactpress', `dev-session${devSessionSuffix()}.json`); +} + +function isPidAlive(pid) { + const n = parseInt(pid, 10); + if (!Number.isFinite(n) || n <= 0) return false; + try { + process.kill(n, 0); + return true; + } catch { + return false; + } +} + +function readDevSession(projectRoot) { + try { + return JSON.parse(fs.readFileSync(lockFilePath(projectRoot), 'utf8')); + } catch { + return null; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Ensure a single `reactpress dev` owner per project directory. + * Stops a stale lock holder from a crashed prior run when its PID is gone, + * or signals a still-running prior session before taking over ports. + */ +async function acquireDevSession(projectRoot) { + const resolvedRoot = path.resolve(projectRoot); + const lockPath = lockFilePath(resolvedRoot); + const existing = readDevSession(resolvedRoot); + + if (existing?.pid && existing.pid !== process.pid) { + if (isPidAlive(existing.pid)) { + console.warn( + `[reactpress] Replacing dev session pid ${existing.pid} (started ${existing.startedAt || 'unknown'})`, + ); + try { + process.kill(existing.pid, 'SIGTERM'); + } catch { + // prior session may have exited during signal + } + await sleep(400); + if (isPidAlive(existing.pid)) { + try { + process.kill(existing.pid, 'SIGKILL'); + } catch { + // ignore + } + await sleep(200); + } + // Do not run `docker compose down` here — DB/nginx containers must survive dev restarts. + } + } + + const { + releaseStaleDevStackPorts, + resolveDevStackPorts, + applyDevStackPortsToEnv, + readInstanceIndex, + } = require('./ports'); + const stack = resolveDevStackPorts(resolvedRoot); + applyDevStackPortsToEnv(stack); + const instance = stack.admin !== 3000 || stack.api !== 3002 ? readInstanceIndex() : 0; + if (instance > 0) { + console.log( + `[reactpress] Dev instance ${instance} — admin :${stack.admin}, api :${stack.api}, visitor :${stack.visitor}, preview :${stack.preview}`, + ); + } + await releaseStaleDevStackPorts(resolvedRoot); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + `${JSON.stringify( + { + pid: process.pid, + ppid: process.ppid, + startedAt: new Date().toISOString(), + projectRoot: resolvedRoot, + }, + null, + 2, + )}\n`, + ); +} + +function releaseDevSession(projectRoot) { + try { + const existing = readDevSession(projectRoot); + if (existing?.pid === process.pid) { + fs.unlinkSync(lockFilePath(projectRoot)); + } + } catch { + // ignore + } +} + +function isDevSessionOwner(projectRoot) { + const existing = readDevSession(projectRoot); + return !existing?.pid || existing.pid === process.pid; +} + +module.exports = { + acquireDevSession, + releaseDevSession, + readDevSession, + isDevSessionOwner, + isPidAlive, +}; diff --git a/cli/src/lib/dev.ts b/cli/src/lib/dev.ts new file mode 100644 index 00000000..177abcd1 --- /dev/null +++ b/cli/src/lib/dev.ts @@ -0,0 +1,1112 @@ +// @ts-nocheck +const { spawn, spawnSync } = require('child_process'); +const { spawnDevChild } = require('./dev-child-io'); +const fs = require('fs'); +const path = require('path'); +const ora = require('ora'); +const { runBuild } = require('./build'); +const { ensureProjectEnvironment } = require('./bootstrap'); +const { + loadWebAdminUrl, + loadClientSiteUrl, + loadServerSiteUrl, + getHealthUrl, + checkHealth, + waitForHttp, +} = require('./http'); +const { printDevReadyBanner } = require('./dev-banner'); +const { startDevNginx, stopDevNginx, nginxEntryUrl } = require('./nginx'); +const { ensureOriginalCwd, isMonorepoCheckout } = require('./root'); +const { detectProjectType, hasWeb, hasToolkit } = require('./project-type'); +const { + hasResolvableActiveTheme, + hasThemePackages, + readActiveThemeManifest, + resolveThemeDirectory, +} = require('./theme-runtime'); +const { shouldBuildToolkit } = require('./toolkit-build'); +const { buildLocalPlugins } = require('./plugin-build'); +const { startThemeSiteWithWatch, stopThemeSite } = require('./theme-dev'); +const { scheduleBackgroundThemeBuilds } = require('./theme-prod'); +const { + shouldBlockOnThemeWarmup, + warmupThemeDevRoutes, + warmupThemeDevRoutesInBackground, +} = require('./theme-warmup'); +const { DEV_PORTS, ensureApiPortFree, ensurePortFree, readEnvPort, isPortListening } = + require('./ports'); +const { ensureDevDatabase, probeMysqlHost } = require('./docker'); +const { acquireDevSession, releaseDevSession } = require('./dev-session'); +const { checkNodeVersion, checkDocker } = require('./doctor'); +const { + startDevTimer, + markDevPhase, + isDevVerbose, + logDevLine, + logDevDetail, + logDevStatus, + logDevTimingSummary, +} = require('./dev-log'); +const { t } = require('./i18n'); +const { isDesktopLocalMode, isLocalSqliteMode } = require('./database-mode'); +const { + resolveRemoteThemeApiBase, + readDevClientApiOrigin, + normalizeRemoteOrigin, +} = require('./remote-dev'); + +const CLIENT_READY_TIMEOUT_MS = 120_000; +const API_READY_TIMEOUT_MS = 180_000; +const DEV_POLL_MS = 250; +const DEV_POLL_FAST_MS = 150; + +function shouldWaitForThemeInForeground() { + if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') return true; + return process.env.REACTPRESS_DEV_WAIT_THEME === '1'; +} + +function logDevPhase(step, total, messageKey, vars = {}) { + console.log(''); + console.log(`[reactpress] [${step}/${total}] ${t(messageKey, vars)}`); +} + +async function applyAutoLocalDevFallback(projectRoot, { needsLocalApi = true } = {}) { + if (!needsLocalApi) return false; + if (isLocalSqliteMode(projectRoot)) { + process.env.REACTPRESS_SKIP_NGINX = '1'; + return false; + } + if (process.env.REACTPRESS_FORCE_MYSQL === '1') return false; + + const docker = checkDocker(); + const mysqlOk = docker.ok ? await probeMysqlHost(projectRoot) : false; + if (docker.ok && mysqlOk) return false; + + process.env.REACTPRESS_LOCAL_MODE = '1'; + process.env.REACTPRESS_SKIP_NGINX = '1'; + console.log(''); + console.log(`[reactpress] ${t(docker.ok ? 'dev.autoLocalNoMysql' : 'dev.autoLocalNoDocker')}`); + return true; +} + +function desktopPhaseKey(defaultKey) { + if (isLocalSqliteMode()) { + const localMap = { + 'dev.phasePrerequisites': 'dev.phasePrerequisitesDesktop', + 'dev.phaseInfra': 'dev.phaseInfraDesktop', + 'dev.phaseServices': 'dev.phaseServicesLocalWeb', + }; + if (localMap[defaultKey]) return localMap[defaultKey]; + } + if (!isDesktopLocalMode()) return defaultKey; + const map = { + 'dev.phasePrerequisites': 'dev.phasePrerequisitesDesktop', + 'dev.phaseInfra': 'dev.phaseInfraDesktop', + 'dev.phaseServices': 'dev.phaseServicesDesktop', + }; + return map[defaultKey] || defaultKey; +} + +function formatDevFailureHint() { + return [ + t('dev.nextSteps'), + t('dev.nextDoctor'), + t('dev.nextDocker'), + t('dev.nextEnv'), + ].join('\n'); +} + +let apiChild; +let webChild; +let desktopChild; +let shuttingDown = false; +let nginxEnabled = false; +/** When false, admin/API child exit during startup must not tear down the stack. */ +let devServicesReady = false; + +function shutdown(signal = 'SIGINT') { + if (shuttingDown) return; + shuttingDown = true; + stopThemeSite(); + if (nginxEnabled) stopDevNginx(ensureOriginalCwd()); + const stopEmbeddedApi = + signal === 'SIGINT' || devServicesReady || !isDesktopLocalMode(); + if (stopEmbeddedApi) { + try { + const { stopLocalServer } = require(path.join(ensureOriginalCwd(), 'desktop/out/main/local-server.js')); + stopLocalServer(); + } catch { + // desktop local API not running + } + } + if (desktopChild && !desktopChild.killed) desktopChild.kill(signal); + if (webChild && !webChild.killed) webChild.kill(signal); + if (apiChild && !apiChild.killed) apiChild.kill(signal); + try { + releaseDevSession(ensureOriginalCwd()); + } catch { + // ignore + } +} + +async function buildToolkit(projectRoot) { + if (!hasToolkit(projectRoot)) return; + if (!shouldBuildToolkit(projectRoot)) { + logDevDetail('dev.toolkitUpToDate'); + } else { + await runBuild('toolkit', projectRoot); + } + try { + buildLocalPlugins(projectRoot); + } catch (err) { + console.error(`[reactpress] ${err.message || err}`); + throw err; + } +} + +async function spawnApi(projectRoot) { + const { reused, port: apiPort } = await ensureApiPortFree(projectRoot, { allowReuse: true }); + if (reused) { + logDevStatus('dev.apiReusing', { port: apiPort }); + return { reused: true, port: apiPort }; + } + + const apiDevRunner = path.join(__dirname, 'api-dev-runner.js'); + logDevStatus('dev.startingApi'); + apiChild = spawn(process.execPath, [apiDevRunner], { + stdio: 'inherit', + cwd: projectRoot, + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + REACTPRESS_DEV_SESSION_PID: String(process.pid), + REACTPRESS_DEV_DB_READY: '1', + REACTPRESS_DEV_PORTS_READY: '1', + }, + }); + + apiChild.on('close', (code) => { + if (shuttingDown) { + process.exit(code ?? 0); + return; + } + if (webChild && !webChild.killed) webChild.kill('SIGINT'); + process.exit(code ?? 1); + }); + return { reused: false, port: apiPort }; +} + +async function waitForApiReady( + projectRoot, + { readyMessageKey = 'dev.apiReady', alreadyHealthy = false } = {}, +) { + const healthUrl = getHealthUrl(projectRoot); + const apiPort = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + + if (alreadyHealthy) { + const health = await checkHealth(healthUrl, 2000); + if (health.ok) { + logDevStatus(readyMessageKey); + return; + } + } + + const useSpinner = isDevVerbose() && process.stdout.isTTY; + const spinner = useSpinner + ? ora({ + text: t('dev.waitingApi', { url: healthUrl }), + color: 'magenta', + spinner: 'dots', + }).start() + : null; + if (!useSpinner) logDevStatus('dev.waitingApiQuiet'); + + const deadline = Date.now() + API_READY_TIMEOUT_MS; + let lastHint = ''; + + while (Date.now() < deadline) { + if (!isPortListening(apiPort)) { + lastHint = t('dev.waitingApiCompile', { port: apiPort }); + if (spinner) { + spinner.text = `${t('dev.waitingApi', { url: healthUrl })} — ${lastHint}`; + } + await new Promise((r) => setTimeout(r, DEV_POLL_MS)); + continue; + } + + const health = await checkHealth(healthUrl, 2500); + if (health.ok) { + if (spinner) spinner.succeed(t(readyMessageKey)); + else logDevStatus(readyMessageKey); + return; + } + + if (health.data?.database === 'down') { + lastHint = t('dev.healthDbDown'); + } else if (health.statusCode === 200 && health.data?.status === 'degraded') { + lastHint = t('dev.healthDegraded'); + } else if (health.statusCode === 0) { + lastHint = t('dev.waitingApiStarting'); + } else if (health.statusCode > 0) { + lastHint = `HTTP ${health.statusCode}`; + } + + if (spinner && lastHint) { + spinner.text = `${t('dev.waitingApi', { url: healthUrl })} — ${lastHint}`; + } + await new Promise((r) => setTimeout(r, DEV_POLL_FAST_MS)); + } + + if (spinner) spinner.fail(t('dev.apiTimeout', { seconds: API_READY_TIMEOUT_MS / 1000 })); + else console.error(t('dev.apiTimeout', { seconds: API_READY_TIMEOUT_MS / 1000 })); + shutdown('SIGINT'); + process.exit(1); +} + +function handlePrimaryDevChildClose(code, label = 'dev', projectRoot = ensureOriginalCwd()) { + if (!devServicesReady) { + console.warn( + `[reactpress] ${label} process exited during startup (code ${code ?? 'unknown'}) — waiting for services…`, + ); + return; + } + const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + if (isDesktopLocalMode() && label === 'Admin dev') { + return; + } + if (label === 'Admin dev' && isPortListening(adminPort)) { + return; + } + if (!shuttingDown) shutdown('SIGINT'); + process.exit(code ?? 0); +} + +async function spawnAdminWeb( + projectRoot, + { + behindNginx = false, + integratedStack = false, + adminApiOrigin = null, + waitForReady = true, + } = {}, +) { + const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + await ensurePortFree(adminPort, { label: 'admin' }); + + logDevDetail('dev.startingAdmin', { url: loadWebAdminUrl(projectRoot) }); + const adminEnv = { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + WEB_ADMIN_PORT: String(adminPort), + }; + if (nginxEnabled && process.env.REACTPRESS_NGINX_ENTRY_URL) { + adminEnv.REACTPRESS_NGINX_ENTRY_URL = process.env.REACTPRESS_NGINX_ENTRY_URL; + } else { + adminEnv.REACTPRESS_SKIP_DEV_PORT_REDIRECT = '1'; + } + if (behindNginx) { + adminEnv.VITE_ADMIN_BASE = '/admin/'; + process.env.REACTPRESS_BEHIND_NGINX = '1'; + } + // Full stack (API + theme site): theme install/activate must hit Nest, not MSW. + if (integratedStack || adminApiOrigin) { + adminEnv.VITE_ENABLE_MOCK = 'false'; + adminEnv.VITE_AUTH_MODE = 'server'; + if (adminApiOrigin) { + // Vite proxies `/api` → `${target}/api/...`; target is host-only origin. + adminEnv.VITE_DEV_API_PROXY_TARGET = normalizeRemoteOrigin(adminApiOrigin) || adminApiOrigin; + } else if (isDesktopLocalMode() && process.env.REACTPRESS_DESKTOP_LOCAL_API) { + // Local SQLite API uses the same port as SERVER_PORT (default :3002). + adminEnv.VITE_DEV_API_PROXY_TARGET = process.env.REACTPRESS_DESKTOP_LOCAL_API.replace( + /\/api\/?$/, + '', + ); + } else { + adminEnv.VITE_DEV_API_PROXY_TARGET = loadServerSiteUrl(projectRoot).replace(/\/$/, ''); + } + } + + webChild = isDesktopLocalMode() + ? spawn(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', ['exec', 'vp', 'dev'], { + cwd: path.join(projectRoot, 'web'), + env: adminEnv, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + : spawnDevChild('pnpm', ['run', '--dir', './web', 'dev'], { + shell: true, + cwd: projectRoot, + env: adminEnv, + }); + + webChild.on('close', (code) => { + handlePrimaryDevChildClose(code, 'Admin dev', projectRoot); + }); + + if (!waitForReady) return Promise.resolve(true); + + const readyUrl = loadWebAdminUrl(projectRoot); + return waitForHttp(readyUrl, CLIENT_READY_TIMEOUT_MS, DEV_POLL_MS).then((ready) => { + if (!ready) { + console.warn( + t('dev.adminSlow', { + seconds: CLIENT_READY_TIMEOUT_MS / 1000, + url: readyUrl, + }), + ); + } + return ready; + }); +} + +function assertDevPrerequisites() { + const node = checkNodeVersion(); + if (!node.ok) { + console.error(`[reactpress] ${node.message}`); + if (node.fix) console.error(` → ${node.fix}`); + console.error(formatDevFailureHint()); + process.exit(1); + } + if (!isLocalSqliteMode()) { + const docker = checkDocker(); + if (!docker.ok) { + console.error(`[reactpress] ${docker.message}`); + if (docker.fix) console.error(` → ${docker.fix}`); + console.error(formatDevFailureHint()); + process.exit(1); + } + } + logDevStatus( + isDesktopLocalMode() || process.env.REACTPRESS_LOCAL_MODE === '1' + ? 'dev.prerequisitesOkDesktop' + : 'dev.prerequisitesOk', + { version: process.version }, + ); +} + +async function prepareDevInfrastructure(projectRoot, { needsLocalApi = true } = {}) { + await acquireDevSession(projectRoot); + if (isLocalSqliteMode(projectRoot)) { + const { ensureLocalSite } = require('../core/services/local-site'); + const { ensureSqliteDatabase } = require('../core/services/database/sqlite'); + const { syncEnvFromConfig, loadConfig } = require('../core/services/config'); + const { getMonorepoRoot } = require('./root'); + const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + if (process.env.REACTPRESS_LOCAL_MODE === '1') { + ensureLocalSite(projectRoot, port, { monorepoRoot: getMonorepoRoot() }); + } else { + const config = await loadConfig(projectRoot); + await syncEnvFromConfig(projectRoot, config); + } + const sqliteResult = await ensureSqliteDatabase(projectRoot); + if (!sqliteResult.ok) { + throw new Error(sqliteResult.message || 'SQLite 数据库未就绪'); + } + nginxEnabled = false; + delete process.env.REACTPRESS_NGINX_ENTRY_URL; + process.env.REACTPRESS_SKIP_DEV_PORT_REDIRECT = '1'; + return; + } + const planNginx = process.env.REACTPRESS_SKIP_NGINX !== '1'; + const clientApiOrigin = readDevClientApiOrigin(projectRoot); + + const [, nginxResult] = await Promise.all([ + needsLocalApi ? ensureDevDatabase(projectRoot, { quiet: true }) : Promise.resolve(true), + planNginx ? startDevNginx(projectRoot) : Promise.resolve(false), + ]); + + nginxEnabled = nginxResult; + if (nginxEnabled) { + process.env.REACTPRESS_NGINX_ENTRY_URL = nginxEntryUrl(projectRoot); + delete process.env.REACTPRESS_SKIP_DEV_PORT_REDIRECT; + if (clientApiOrigin) { + logDevStatus('dev.nginxReadyRemote', { + url: nginxEntryUrl(projectRoot), + api: clientApiOrigin, + }); + } else { + logDevStatus('dev.nginxReady', { url: nginxEntryUrl(projectRoot) }); + } + } else { + delete process.env.REACTPRESS_NGINX_ENTRY_URL; + process.env.REACTPRESS_SKIP_DEV_PORT_REDIRECT = '1'; + } +} + +async function startDevStack( + projectRoot, + { + webOnly = false, + themeOnly = false, + desktopMode = false, + localWebMode = false, + infraDone = false, + apiOrigins = { admin: null, client: null, needsLocalApi: true }, + } = {}, +) { + const { admin: adminApiOrigin, client: clientApiOrigin, needsLocalApi } = apiOrigins; + if (!infraDone) { + logDevPhase(1, 3, desktopPhaseKey('dev.phasePrerequisites')); + assertDevPrerequisites(); + logDevPhase(2, 3, desktopPhaseKey('dev.phaseInfra')); + try { + await prepareDevInfrastructure(projectRoot, { needsLocalApi }); + } catch (err) { + console.error(t('dev.dbEnsureFailed', { message: err.message || err })); + console.error(formatDevFailureHint()); + process.exit(1); + } + } else if ( + needsLocalApi && + !isLocalSqliteMode(projectRoot) && + !(await probeMysqlHost(projectRoot)) + ) { + console.error( + t('dev.dbEnsureFailed', { + message: t('docker.devStartBlocked', { + port: readEnvPort(projectRoot, 'DB_PORT', DEV_PORTS.MYSQL), + }), + }), + ); + console.error(formatDevFailureHint()); + process.exit(1); + } + + const includeAdmin = webOnly && hasWeb(projectRoot); + // Always run theme watchers when packages exist — admin activate/preview writes manifests + // and relies on fs.watch even if the current active theme is not yet resolvable. + const includeThemeSite = + themeOnly || + (desktopMode && hasThemePackages(projectRoot)) || + (!webOnly && hasThemePackages(projectRoot)); + if (!webOnly && !themeOnly && includeThemeSite && !hasResolvableActiveTheme(projectRoot)) { + const { activeTheme } = readActiveThemeManifest(projectRoot); + console.warn( + `[reactpress] ${t('themeDev.notFound', { id: activeTheme })} — ${t('dev.themeSiteSkipped')}`, + ); + } + if (includeThemeSite) { + const { validateBundledThemes, validateCatalogThemes } = require('./theme-registry'); + const bundled = validateBundledThemes(projectRoot); + if (bundled.missing.length) { + console.warn( + `[reactpress] themes/package.json lists bundled theme(s) without theme.json: ${bundled.missing.join(', ')}`, + ); + } + const catalog = validateCatalogThemes(projectRoot); + if (catalog.missing.length) { + console.warn( + `[reactpress] themes/package.json lists catalog dir(s) without reactpress.theme in package.json: ${catalog.missing.join(', ')}`, + ); + } + } + const planNginx = process.env.REACTPRESS_SKIP_NGINX !== '1'; + const includeWebInStack = hasWeb(projectRoot) && !webOnly && !themeOnly; + + if (infraDone) { + logDevPhase( + 3, + 3, + localWebMode ? 'dev.phaseServicesLocalWeb' : desktopPhaseKey('dev.phaseServices'), + ); + } + + markDevPhase('services'); + const readinessWaits = []; + let apiSpawn = null; + + const prewarmPreviewBuilds = + includeThemeSite && + isDesktopLocalMode() && + process.env.REACTPRESS_SKIP_PREVIEW_BUILD !== '1'; + if (prewarmPreviewBuilds) { + const { warmupAllPreviewThemeBuilds } = require('./theme-prod'); + logDevStatus('dev.previewPrewarmStarting'); + readinessWaits.push(warmupAllPreviewThemeBuilds(projectRoot)); + } + + if (adminApiOrigin || clientApiOrigin) { + if (adminApiOrigin) { + logDevStatus('dev.adminApiRemote', { url: adminApiOrigin }); + } + if (clientApiOrigin) { + logDevStatus('dev.clientApiRemote', { url: clientApiOrigin }); + } + } + if (needsLocalApi) { + logDevStatus('dev.phaseApi'); + apiSpawn = await spawnApi(projectRoot); + const readyMessageKey = webOnly || hasWeb(projectRoot) ? 'dev.apiReadyAdmin' : 'dev.apiReady'; + readinessWaits.push( + waitForApiReady(projectRoot, { + readyMessageKey, + alreadyHealthy: Boolean(apiSpawn?.reused), + }), + ); + } + + if (!includeAdmin && !includeThemeSite && !includeWebInStack) { + await Promise.all(readinessWaits); + printDevReadyBanner(projectRoot, { apiOnly: true, nginx: nginxEnabled }); + console.log(t('dev.standaloneHint')); + return; + } + + const waitThemeInForeground = includeThemeSite && shouldWaitForThemeInForeground(); + let themeSiteStarted = false; + + if (includeWebInStack) { + logDevDetail('dev.phaseAdmin'); + if (planNginx) process.env.REACTPRESS_BEHIND_NGINX = '1'; + logDevDetail('dev.startingAdmin', { url: loadWebAdminUrl(projectRoot) }); + spawnAdminWeb(projectRoot, { + behindNginx: planNginx, + integratedStack: true, + adminApiOrigin, + waitForReady: false, + }); + const adminBase = loadWebAdminUrl(projectRoot).replace(/\/$/, ''); + const adminProbe = planNginx ? `${adminBase}/admin/` : `${adminBase}/`; + readinessWaits.push(waitForHttp(adminProbe, 120_000, DEV_POLL_MS)); + } else if (includeAdmin) { + logDevDetail('dev.phaseAdmin'); + spawnAdminWeb(projectRoot, { + behindNginx: desktopMode || localWebMode ? false : planNginx, + integratedStack: desktopMode || localWebMode || isLocalSqliteMode(), + adminApiOrigin, + waitForReady: false, + }); + const adminBase = loadWebAdminUrl(projectRoot).replace(/\/$/, ''); + const adminProbe = planNginx ? `${adminBase}/admin/` : `${adminBase}/`; + readinessWaits.push(waitForHttp(adminProbe, 120_000, DEV_POLL_MS)); + } + + if (includeThemeSite) { + const { activeTheme } = readActiveThemeManifest(projectRoot); + logDevStatus('dev.themeStarting', { + id: activeTheme, + port: readEnvPort(projectRoot, 'CLIENT_PORT', DEV_PORTS.VISITOR), + }); + if (planNginx) process.env.REACTPRESS_BEHIND_NGINX = '1'; + if (clientApiOrigin) { + delete process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API; + const themeApiBase = resolveRemoteThemeApiBase(clientApiOrigin); + process.env.REACTPRESS_THEME_API_URL = themeApiBase; + process.env.REACTPRESS_THEME_PUBLIC_API_URL = planNginx + ? `${nginxEntryUrl(projectRoot).replace(/\/$/, '')}/api` + : themeApiBase; + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + if (themeDir) { + const nextCache = path.join(themeDir, '.next'); + if (fs.existsSync(nextCache)) { + fs.rmSync(nextCache, { recursive: true, force: true }); + logDevDetail('dev.themeCacheClearedForRemote'); + } + } + } else { + process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API = '1'; + } + const themeBoot = startThemeSiteWithWatch(projectRoot); + const themeWait = themeBoot.then(async (started) => { + themeSiteStarted = started; + if (!started) return false; + const clientUrl = loadClientSiteUrl(projectRoot); + const port = readEnvPort(projectRoot, 'CLIENT_PORT', DEV_PORTS.VISITOR); + const portOpen = await (async () => { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (isPortListening(port)) return true; + await new Promise((r) => setTimeout(r, DEV_POLL_MS)); + } + return false; + })(); + if (portOpen) { + if (isDesktopLocalMode()) { + const { warmupThemeHomepage } = require('./theme-warmup'); + await warmupThemeHomepage(projectRoot, clientUrl); + } else if (shouldBlockOnThemeWarmup()) { + await warmupThemeDevRoutes(projectRoot); + } else { + warmupThemeDevRoutesInBackground(projectRoot); + } + } + return portOpen; + }); + if (waitThemeInForeground) { + readinessWaits.push(themeWait); + } else { + themeWait.then((ready) => { + if (ready) logDevDetail('dev.themeReadyQuiet', { url: loadClientSiteUrl(projectRoot) }); + }).catch(() => {}); + } + } + + if (readinessWaits.length > 1) { + logDevStatus('dev.waitingProxies'); + } + await Promise.all(readinessWaits); + if (readinessWaits.length > 1) { + logDevStatus('dev.proxiesReady'); + } + + if (includeWebInStack && planNginx && nginxEnabled) { + const adminViaNginx = `${nginxEntryUrl(projectRoot).replace(/\/$/, '')}/admin/`; + waitForHttp(adminViaNginx, 15_000, DEV_POLL_MS).then((adminOk) => { + if (!adminOk) { + console.warn(t('dev.adminNginxSlow', { url: adminViaNginx })); + } + }); + } else if (planNginx && !nginxEnabled) { + startDevNginx(projectRoot).then((enabled) => { + nginxEnabled = enabled; + if (enabled) { + console.log(t('dev.nginxReady', { url: nginxEntryUrl(projectRoot) })); + } + }); + } + + let dbOk = true; + if (needsLocalApi) { + if (isDesktopLocalMode() || isLocalSqliteMode(projectRoot)) { + const { probeSqliteDatabase } = require('../core/services/database/sqlite'); + dbOk = (await probeSqliteDatabase(projectRoot)).ok; + } else { + dbOk = await probeMysqlHost(projectRoot); + } + } + if (needsLocalApi && !dbOk && !isDesktopLocalMode() && !isLocalSqliteMode(projectRoot)) { + console.warn(t('dev.mysqlUnreachable')); + } + + if (includeThemeSite && process.env.REACTPRESS_SKIP_PREVIEW_BUILD !== '1' && !prewarmPreviewBuilds) { + const { activeTheme } = readActiveThemeManifest(projectRoot); + scheduleBackgroundThemeBuilds(projectRoot, { excludeThemeId: activeTheme }); + } + + printDevReadyBanner(projectRoot, { + webOnly: (includeAdmin && !includeThemeSite) || localWebMode, + desktop: desktopMode && !localWebMode, + localWeb: localWebMode, + nginx: nginxEnabled, + hasThemeSite: includeThemeSite, + dbOk, + adminApiOrigin, + clientApiOrigin, + localApiUrl: isDesktopLocalMode() ? process.env.REACTPRESS_DESKTOP_LOCAL_API || null : null, + dbType: + isDesktopLocalMode() || isLocalSqliteMode(projectRoot) + ? 'sqlite' + : needsLocalApi + ? 'mysql' + : null, + }); + + logDevTimingSummary({ + apiReused: Boolean(apiSpawn?.reused), + }); + + devServicesReady = true; +} + +async function runDevStartup( + projectRoot, + { + webOnly = false, + themeOnly = false, + desktopMode = false, + localWebMode = false, + skipPrepareInfra = false, + apiOrigins = { admin: null, client: null, needsLocalApi: true }, + } = {}, +) { + startDevTimer(); + try { + const result = await ensureProjectEnvironment(projectRoot, { skipDatabase: true }); + if (result.message && isDevVerbose()) console.log(`[reactpress] ${result.message}`); + } catch (err) { + console.error(t('dev.envFailed'), err.message || err); + console.error(formatDevFailureHint()); + process.exit(1); + } + + await applyAutoLocalDevFallback(projectRoot, { needsLocalApi: apiOrigins.needsLocalApi }); + + logDevPhase(1, 3, desktopPhaseKey('dev.phasePrerequisites')); + assertDevPrerequisites(); + + logDevPhase(2, 3, desktopPhaseKey('dev.phaseInfra')); + try { + const infraTasks = [buildToolkit(projectRoot)]; + if (!skipPrepareInfra) { + infraTasks.unshift( + prepareDevInfrastructure(projectRoot, { needsLocalApi: apiOrigins.needsLocalApi }), + ); + } + await Promise.all(infraTasks); + markDevPhase('infra'); + } catch (err) { + console.error(t('dev.dbEnsureFailed', { message: err.message || err })); + console.error(formatDevFailureHint()); + process.exit(1); + } + + await startDevStack(projectRoot, { + webOnly, + themeOnly, + desktopMode, + localWebMode, + infraDone: true, + apiOrigins, + }); +} + +function loadDesktopBootstrap(projectRoot) { + return require(path.join(projectRoot, 'desktop/scripts/bootstrap-local-api.cjs')); +} + +async function startDesktopLocalApi(projectRoot, { forceRestart = false } = {}) { + const desktopDir = path.join(projectRoot, 'desktop'); + if (!fs.existsSync(path.join(desktopDir, 'package.json'))) { + console.error(`[reactpress] ${t('dev.desktopMissing')}`); + process.exit(1); + } + + const { + resolveDevStackPorts, + applyDevStackPortsToEnv, + resolveApiPortForBind, + } = require('./ports'); + let stack = resolveDevStackPorts(projectRoot); + applyDevStackPortsToEnv(stack); + + const boot = await loadDesktopBootstrap(projectRoot); + console.log(''); + logDevStatus('dev.desktopLocalApiStarting'); + + const resolved = await resolveApiPortForBind(projectRoot, { preferred: stack.api }); + if (resolved.shifted || resolved.port !== stack.api) { + stack = { ...stack, api: resolved.port }; + applyDevStackPortsToEnv(stack); + } + + const { siteRoot, localApiBase } = await boot.startDesktopLocalApi(projectRoot, { + forceRestart, + port: resolved.port, + }); + process.env.REACTPRESS_DESKTOP_LOCAL_API = localApiBase; + process.env.REACTPRESS_DESKTOP_SITE_ROOT = siteRoot; + process.env.REACTPRESS_THEME_API_URL = localApiBase; + process.env.REACTPRESS_THEME_PUBLIC_API_URL = localApiBase; + const localApiOrigin = localApiBase.replace(/\/api\/?$/, ''); + process.env.VITE_DEV_API_PROXY_TARGET = localApiOrigin; + logDevStatus('dev.desktopLocalApiReady', { url: localApiBase, db: t('dev.dbTypeSqlite') }); + return { siteRoot, localApiBase }; +} + +async function ensureDesktopLocalApiHealthy(projectRoot, { forceRestart = false } = {}) { + if (!isDesktopLocalMode()) return true; + const base = process.env.REACTPRESS_DESKTOP_LOCAL_API?.trim(); + if (!base) return false; + const healthUrl = `${base.replace(/\/api\/?$/, '')}/api/health`; + const health = await checkHealth(healthUrl, 2500); + if (health.ok && !forceRestart) return true; + console.warn('[reactpress] Local API unreachable — restarting embedded SQLite API…'); + await startDesktopLocalApi(projectRoot, { forceRestart: true }); + const retry = await checkHealth(healthUrl, 5000); + if (!retry.ok) { + console.warn(`[reactpress] Local API still unhealthy after restart (${healthUrl})`); + } + return retry.ok; +} + +function registerDevShutdownHandlers(projectRoot) { + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => { + // Stray SIGTERM during local web boot must not tear down the embedded API (Vite still starting). + if (!devServicesReady && isDesktopLocalMode()) return; + shutdown('SIGTERM'); + }); + process.on('exit', () => { + try { + releaseDevSession(projectRoot); + } catch { + // ignore + } + }); +} + +/** Block until the primary dev child exits (admin Vite, Electron shell, or Nest API). */ +function waitUntilDevChildExit(projectRoot = ensureOriginalCwd()) { + return new Promise((resolve) => { + const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + + const waitForShutdownSignal = () => { + const onSignal = () => resolve(0); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + }; + + const child = webChild || desktopChild || apiChild; + if (!child) { + waitForShutdownSignal(); + return; + } + + const finish = (code) => { + if (child === webChild && isPortListening(adminPort)) { + waitForShutdownSignal(); + return; + } + resolve(code ?? 0); + }; + + if (child.exitCode != null) { + finish(child.exitCode); + return; + } + if (child.killed) { + finish(1); + return; + } + child.once('close', (code) => finish(code)); + }); +} + +function canUseDesktopLocalStack(projectRoot) { + return ( + isMonorepoCheckout(projectRoot) && + fs.existsSync(path.join(projectRoot, 'desktop', 'package.json')) + ); +} + +async function spawnDesktopApp(projectRoot) { + const desktopDir = path.join(projectRoot, 'desktop'); + if (!fs.existsSync(path.join(desktopDir, 'package.json'))) { + console.error(`[reactpress] ${t('dev.desktopMissing')}`); + shutdown('SIGINT'); + process.exit(1); + } + + const adminUrl = loadWebAdminUrl(projectRoot).replace(/\/$/, ''); + logDevStatus('dev.desktopStarting', { url: adminUrl }); + + const boot = await loadDesktopBootstrap(projectRoot); + boot.ensureDesktopBuilt(projectRoot); + + desktopChild = spawnDevChild( + 'pnpm', + ['exec', 'cross-env', `VITE_DEV_SERVER_URL=${adminUrl}`, 'ELECTRON_IS_DEV=1', 'pnpm', 'run', 'dev:shell'], + { + shell: true, + cwd: desktopDir, + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + VITE_DEV_SERVER_URL: adminUrl, + ELECTRON_IS_DEV: '1', + }, + }, + ); + + desktopChild.on('close', (code) => { + handlePrimaryDevChildClose(code, 'Desktop shell'); + }); +} + +async function runLocalMonorepoDev(projectRoot = ensureOriginalCwd()) { + if (!hasWeb(projectRoot)) { + console.error(t('dev.noWeb')); + process.exit(1); + } + + process.env.REACTPRESS_SKIP_NGINX = '1'; + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + registerDevShutdownHandlers(projectRoot); + + console.log(''); + logDevLine('dev.localFullIntro'); + + await prepareDevInfrastructure(projectRoot, { needsLocalApi: false }); + await startDesktopLocalApi(projectRoot, { forceRestart: true }); + + await runDevStartup(projectRoot, { + skipPrepareInfra: true, + apiOrigins: { admin: null, client: null, needsLocalApi: false }, + }); + await ensureDesktopLocalApiHealthy(projectRoot); + await new Promise((resolve) => { + const interval = setInterval(() => { + void ensureDesktopLocalApiHealthy(projectRoot).catch(() => {}); + }, 20_000); + const onStop = (signal) => { + clearInterval(interval); + shutdown(signal); + resolve(0); + }; + process.once('SIGINT', () => onStop('SIGINT')); + process.once('SIGTERM', () => onStop('SIGTERM')); + }); + process.exit(0); +} + +async function runDesktopDev(projectRoot = ensureOriginalCwd()) { + if (!hasWeb(projectRoot)) { + console.error(t('dev.noWeb')); + process.exit(1); + } + + process.env.REACTPRESS_SKIP_NGINX = '1'; + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + registerDevShutdownHandlers(projectRoot); + + console.log(''); + logDevLine('dev.desktopIntro'); + + await prepareDevInfrastructure(projectRoot, { needsLocalApi: false }); + await startDesktopLocalApi(projectRoot, { forceRestart: true }); + + await runDevStartup(projectRoot, { + webOnly: true, + desktopMode: true, + skipPrepareInfra: true, + apiOrigins: { admin: null, client: null, needsLocalApi: false }, + }); + await spawnDesktopApp(projectRoot); + const exitCode = await waitUntilDevChildExit(projectRoot); + process.exit(exitCode); +} + +async function runLocalWebDev( + projectRoot = ensureOriginalCwd(), + { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {}, +) { + if (!hasWeb(projectRoot)) { + console.error(t('dev.noWeb')); + process.exit(1); + } + + registerDevShutdownHandlers(projectRoot); + + if (canUseDesktopLocalStack(projectRoot)) { + delete process.env.REACTPRESS_LOCAL_MODE; + process.env.REACTPRESS_SKIP_NGINX = '1'; + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + + console.log(''); + logDevLine('dev.localWebIntro'); + + await prepareDevInfrastructure(projectRoot, { needsLocalApi: false }); + await startDesktopLocalApi(projectRoot, { forceRestart: true }); + + await runDevStartup(projectRoot, { + webOnly: true, + desktopMode: true, + localWebMode: true, + skipPrepareInfra: true, + apiOrigins: { admin: null, client: null, needsLocalApi: false }, + }); + await ensureDesktopLocalApiHealthy(projectRoot); + await new Promise((resolve) => { + const interval = setInterval(() => { + void ensureDesktopLocalApiHealthy(projectRoot).catch(() => {}); + }, 20_000); + const onStop = (signal) => { + clearInterval(interval); + shutdown(signal); + resolve(0); + }; + process.once('SIGINT', () => onStop('SIGINT')); + process.once('SIGTERM', () => onStop('SIGTERM')); + }); + process.exit(0); + return; + } + + process.env.REACTPRESS_LOCAL_MODE = '1'; + process.env.REACTPRESS_SKIP_NGINX = '1'; + + console.log(''); + logDevLine('dev.localWebIntro'); + + await runDevStartup(projectRoot, { webOnly: true, apiOrigins }); +} + +async function runThemeDev( + projectRoot = ensureOriginalCwd(), + { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {}, +) { + if (!hasResolvableActiveTheme(projectRoot)) { + console.error(t('dev.themeSiteSkipped')); + process.exit(1); + } + + process.env.REACTPRESS_THEME_DEV_ONLY = '1'; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('exit', () => { + try { + releaseDevSession(projectRoot); + } catch { + // ignore + } + }); + + await runDevStartup(projectRoot, { themeOnly: true, apiOrigins }); +} + +async function runWebDev( + projectRoot = ensureOriginalCwd(), + { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {}, +) { + if (!hasWeb(projectRoot)) { + console.error(t('dev.noWeb')); + process.exit(1); + } + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('exit', () => { + try { + releaseDevSession(projectRoot); + } catch { + // ignore + } + }); + + await runDevStartup(projectRoot, { webOnly: true, apiOrigins }); +} + +async function runDev( + projectRoot = ensureOriginalCwd(), + { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {}, +) { + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('exit', () => { + try { + releaseDevSession(projectRoot); + } catch { + // ignore + } + }); + + await runDevStartup(projectRoot, { apiOrigins }); +} + +module.exports = { + runDev, + runWebDev, + runLocalWebDev, + runLocalMonorepoDev, + runThemeDev, + runDesktopDev, + runDevStartup, + buildToolkit, + assertDevPrerequisites, + applyAutoLocalDevFallback, + prepareDevInfrastructure, + startDevStack, + detectProjectType, + nginxEntryUrl, +}; diff --git a/cli/src/lib/docker.ts b/cli/src/lib/docker.ts new file mode 100644 index 00000000..e2d07c42 --- /dev/null +++ b/cli/src/lib/docker.ts @@ -0,0 +1,434 @@ +// @ts-nocheck +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 } = 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 parseEnvValue(projectRoot, key, fallback) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const match = content.match(new RegExp(`^${key}=(.+)$`, 'm')); + if (match) return match[1].trim().replace(/^['"]|['"]$/g, ''); + } catch { + // ignore + } + return fallback; +} + +function parseDbPort(projectRoot) { + const raw = parseEnvValue(projectRoot, 'DB_PORT', '3306'); + const port = parseInt(raw, 10); + return Number.isFinite(port) && port > 0 ? port : 3306; +} + +function isPortListening(port, host = '127.0.0.1') { + const byLsof = spawnSync('lsof', [`-iTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' }); + if (byLsof.status === 0 && byLsof.stdout.trim()) return true; + const byNc = spawnSync('nc', ['-z', host, String(port)], { stdio: 'ignore' }); + return byNc.status === 0; +} + +function isDbContainerRunning(container) { + const res = spawnSync('docker', ['inspect', '-f', '{{.State.Running}}', container], { + encoding: 'utf8', + }); + return res.status === 0 && res.stdout.trim() === 'true'; +} + +async 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 dbPort = parseDbPort(projectRoot); + + if (isPortListening(dbPort)) { + if (await probeMysqlHost(projectRoot)) { + console.log(t('docker.dbReuseExisting', { port: dbPort })); + const nginxOnly = runCompose(['up', '-d', '--no-deps', 'nginx'], ctx); + if (nginxOnly.status !== 0) { + throw new Error(t('docker.notRunning')); + } + console.log(t('docker.started')); + return; + } + console.warn(t('docker.dbPortInUseRecycle', { port: dbPort })); + spawnSync('docker', ['rm', '-f', 'reactpress_db'], { stdio: 'ignore' }); + const result = runCompose(['up', '-d', '--no-deps', 'nginx'], ctx); + if (result.status !== 0) { + throw new Error(t('docker.notRunning')); + } + console.log(t('docker.started')); + return; + } + + const dbResult = runCompose(['up', '-d', 'db'], ctx); + if (dbResult.status !== 0) { + throw new Error(t('docker.notRunning')); + } + const nginxResult = runCompose(['up', '-d', '--no-deps', 'nginx'], ctx); + if (nginxResult.status !== 0) { + throw new Error(t('docker.notRunning')); + } + console.log(t('docker.started')); +} + +async function ensureDevDatabase(projectRoot, { quiet = false } = {}) { + const { isLocalSqliteMode } = require('./database-mode'); + if (isLocalSqliteMode(projectRoot)) { + const { ensureSqliteDatabase } = require('../core/services/database/sqlite'); + const { syncEnvFromConfig, loadConfig } = require('../core/services/config'); + const config = await loadConfig(projectRoot); + await syncEnvFromConfig(projectRoot, config); + const result = await ensureSqliteDatabase(projectRoot); + if (!result.ok) { + throw new Error(result.message || 'SQLite 数据库未就绪'); + } + return; + } + + const dbPort = parseDbPort(projectRoot); + const ctx = resolveComposeContext(projectRoot); + const container = resolveDbContainerName(ctx, projectRoot); + + const finishWhenReady = async (maxAttempts = 60) => { + if (await waitForMysql(projectRoot, maxAttempts, { quiet })) return true; + return false; + }; + + if (await probeMysqlHost(projectRoot) && (await finishWhenReady(4))) { + return; + } + + if (!quiet) console.log(t('docker.ensureDevDb')); + if (!isDockerRunning()) { + throw new Error(t('docker.devStartBlocked', { port: dbPort })); + } + + for (const name of new Set([container, 'reactpress_db', 'reactpress_cli_db'])) { + spawnSync('docker', ['start', name], { stdio: 'ignore' }); + } + await new Promise((r) => setTimeout(r, 1000)); + if (await finishWhenReady(45)) return; + + const composeStdio = quiet ? 'ignore' : 'inherit'; + + if (!isPortListening(dbPort)) { + const dbResult = runCompose(['up', '-d', '--remove-orphans', 'db'], ctx, { stdio: composeStdio }); + if (dbResult.status !== 0) { + throw new Error(t('docker.mysqlNotReady')); + } + } else if (await probeMysqlHost(projectRoot)) { + if (!quiet) console.log(t('docker.dbReuseExisting', { port: dbPort })); + if (await finishWhenReady(30)) return; + } else { + if (!quiet) console.warn(t('docker.dbPortInUseRecycle', { port: dbPort })); + spawnSync('docker', ['rm', '-f', 'reactpress_db'], { stdio: 'ignore' }); + await new Promise((r) => setTimeout(r, 500)); + const recreate = runCompose(['up', '-d', '--remove-orphans', 'db'], ctx, { stdio: composeStdio }); + if (recreate.status !== 0) { + throw new Error(t('docker.mysqlNotReady')); + } + } + + if (await finishWhenReady(60)) return; + + throw new Error( + t('docker.dbPortConflict', { + port: dbPort, + }), + ); +} + +/** Gate API boot — MySQL must accept connections on DB_PORT (avoids Nest retrying on :3307). */ +async function requireMysqlBeforeApi(projectRoot) { + const dbPort = parseDbPort(projectRoot); + if (await probeMysqlHost(projectRoot) && (await waitForMysql(projectRoot, 5))) { + return; + } + await ensureDevDatabase(projectRoot); + if (!(await probeMysqlHost(projectRoot))) { + throw new Error( + t('docker.dbPortConflict', { + port: dbPort, + }), + ); + } +} + +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 probeMysqlHost(projectRoot) { + const host = parseEnvValue(projectRoot, 'DB_HOST', '127.0.0.1'); + const port = parseDbPort(projectRoot); + const user = parseEnvValue(projectRoot, 'DB_USER', 'reactpress'); + const password = + parseEnvValue(projectRoot, 'DB_PASSWD', '') || + parseEnvValue(projectRoot, 'DB_PASSWORD', 'reactpress'); + const database = parseEnvValue(projectRoot, 'DB_DATABASE', 'reactpress'); + + let mysql; + try { + mysql = require('mysql2/promise'); + } catch { + try { + mysql = require(path.join(projectRoot, 'server/node_modules/mysql2/promise')); + } catch { + return false; + } + } + + try { + const conn = await mysql.createConnection({ + host, + port, + user, + password, + database, + connectTimeout: 3000, + }); + await conn.ping(); + await conn.end(); + return true; + } catch { + return false; + } +} + +async function waitForMysql(projectRoot, maxAttempts = 30, { quiet = false } = {}) { + const ctx = resolveComposeContext(projectRoot); + const container = resolveDbContainerName(ctx, projectRoot); + const { user, password } = resolveDbCredentialsFromEnv(projectRoot); + const dbPort = parseDbPort(projectRoot); + + if (!isDbContainerRunning(container) && isPortListening(dbPort)) { + const ready = await probeMysqlHost(projectRoot); + if (ready) { + if (!quiet) console.log(t('docker.mysqlExternalReady', { port: dbPort })); + return true; + } + } + + const useSpinner = !quiet && process.stdout.isTTY; + const spinner = useSpinner + ? ora({ + text: t('docker.waitingMysql'), + color: 'magenta', + spinner: 'dots', + }).start() + : null; + + let attempts = 0; + while (attempts < maxAttempts) { + if (isDbContainerRunning(container)) { + const probe = spawnSync( + 'docker', + ['exec', container, 'mysql', `-u${user}`, `-p${password}`, '-e', 'SELECT 1'], + { stdio: 'ignore' } + ); + if (probe.status === 0) { + if (spinner) spinner.succeed(t('docker.mysqlReady')); + return true; + } + } else if (await probeMysqlHost(projectRoot)) { + if (spinner) spinner.succeed(t('docker.mysqlExternalReady', { port: dbPort })); + return true; + } + attempts += 1; + if (spinner) { + spinner.text = t('docker.waitingMysqlProgress', { attempts, max: maxAttempts }); + } + await new Promise((r) => setTimeout(r, 1000)); + } + if (spinner) spinner.fail(t('docker.mysqlTimeout')); + return false; +} + +async function dockerStartWithDev(projectRoot) { + await startDockerServices(projectRoot); + const ready = await waitForMysql(projectRoot); + if (!ready) { + throw new Error(t('docker.mysqlNotReady')); + } + + const { runDev } = require('./dev'); + await runDev(projectRoot); +} + +/** + * 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': + await 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)); + await 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, + ensureDevDatabase, + requireMysqlBeforeApi, + probeMysqlHost, + isDockerRunning, + resolveComposeContext, + pickDockerComposeCommand, + mysqldumpFromDbContainer, +}; diff --git a/cli/lib/doctor.js b/cli/src/lib/doctor.ts similarity index 92% rename from cli/lib/doctor.js rename to cli/src/lib/doctor.ts index 13d0e8ea..3d17f764 100644 --- a/cli/lib/doctor.js +++ b/cli/src/lib/doctor.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const fs = require('fs'); const net = require('net'); const path = require('path'); @@ -105,6 +106,20 @@ async function checkPorts(projectRoot) { async function checkDatabase(projectRoot) { const env = parseEnv(projectRoot); + const dbType = String(env.DB_TYPE || 'mysql').toLowerCase(); + + if (dbType === 'sqlite') { + const { probeSqliteDatabase } = require('../core/services/database/sqlite'); + const result = await probeSqliteDatabase(projectRoot); + return { + ok: result.ok, + message: result.ok + ? t('doctor.dbSqliteOk', { detail: result.message ?? '' }) + : t('doctor.dbSqliteBad', { error: result.message ?? '' }), + fix: result.ok ? undefined : t('doctor.dbSqliteFix'), + }; + } + const host = env.DB_HOST || '127.0.0.1'; const port = parseInt(env.DB_PORT || '3306', 10); const user = env.DB_USER || 'root'; @@ -263,4 +278,8 @@ async function runDoctor(projectRoot) { return failed === 0 ? 0 : 1; } -module.exports = { runDoctor }; +module.exports = { + runDoctor, + checkNodeVersion, + checkDocker, +}; diff --git a/cli/src/lib/health-parse.test.ts b/cli/src/lib/health-parse.test.ts new file mode 100644 index 00000000..30ee285d --- /dev/null +++ b/cli/src/lib/health-parse.test.ts @@ -0,0 +1,37 @@ +// @ts-nocheck +const assert = require('assert'); +const { + normalizeHealthPayload, + isHealthPayloadReady, + isHealthHttpReady, +} = require('./health-parse'); + +assert.strictEqual( + isHealthHttpReady(200, { + statusCode: 200, + success: true, + data: { status: 'ok', database: 'up', version: '3.0.0' }, + }), + true, +); + +assert.strictEqual( + isHealthHttpReady(200, { status: 'ok', database: 'up' }), + true, +); + +assert.strictEqual( + isHealthHttpReady(200, { + statusCode: 200, + success: true, + data: { status: 'degraded', database: 'down' }, + }), + false, +); + +assert.strictEqual( + isHealthPayloadReady(normalizeHealthPayload({ status: 'ok', database: 'up' })), + true, +); + +console.log('health-parse.test.js: ok'); diff --git a/cli/src/lib/health-parse.ts b/cli/src/lib/health-parse.ts new file mode 100644 index 00000000..cef94280 --- /dev/null +++ b/cli/src/lib/health-parse.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +/** + * Parse `/api/health` JSON — supports raw Nest payload and TransformInterceptor wrapper. + */ + +function normalizeHealthPayload(body) { + if (!body || typeof body !== 'object') return null; + if ( + body.data && + typeof body.data === 'object' && + (Object.prototype.hasOwnProperty.call(body.data, 'status') || + Object.prototype.hasOwnProperty.call(body.data, 'database')) + ) { + return body.data; + } + return body; +} + +function isHealthPayloadReady(payload) { + if (!payload || typeof payload !== 'object') return false; + if (Object.prototype.hasOwnProperty.call(payload, 'database')) { + return payload.status === 'ok' && payload.database === 'up'; + } + return payload.status === 'ok' || payload.status === 'OK'; +} + +function isHealthHttpReady(statusCode, body) { + if (statusCode !== 200) return false; + return isHealthPayloadReady(normalizeHealthPayload(body)); +} + +module.exports = { + normalizeHealthPayload, + isHealthPayloadReady, + isHealthHttpReady, +}; diff --git a/cli/lib/http.js b/cli/src/lib/http.ts similarity index 64% rename from cli/lib/http.js rename to cli/src/lib/http.ts index 403c486a..79cabe77 100644 --- a/cli/lib/http.js +++ b/cli/src/lib/http.ts @@ -1,6 +1,13 @@ +// @ts-nocheck const fs = require('fs'); const http = require('http'); const path = require('path'); +const { DEV_PORTS } = require('./ports'); +const { + normalizeHealthPayload, + isHealthPayloadReady, + isHealthHttpReady, +} = require('./health-parse'); function loadServerSiteUrl(projectRoot) { const envPath = path.join(projectRoot, '.env'); @@ -16,6 +23,27 @@ function loadServerSiteUrl(projectRoot) { return 'http://localhost:3002'; } +function loadWebAdminUrl(projectRoot) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const urlMatch = content.match(/^WEB_ADMIN_URL=(.+)$/m); + if (urlMatch) { + return urlMatch[1].trim().replace(/^['"]|['"]$/g, ''); + } + const portMatch = content.match(/^WEB_ADMIN_PORT=(.+)$/m); + if (portMatch) { + const port = parseInt(portMatch[1].trim(), 10); + if (Number.isInteger(port) && port > 0) { + return `http://localhost:${port}`; + } + } + } catch { + // ignore + } + return `http://localhost:${DEV_PORTS.ADMIN_WEB}`; +} + function loadClientSiteUrl(projectRoot) { const envPath = path.join(projectRoot, '.env'); try { @@ -50,11 +78,25 @@ function getHealthUrl(projectRoot) { return `${base}${prefix}/health`; } +/** Use IPv4 loopback for probes — `localhost` often resolves to `::1` while Nest binds IPv4. */ +function parseProbeTarget(urlString) { + const parsed = new URL(urlString); + const host = parsed.hostname; + if (host === 'localhost' || host === '::1' || host === '[::1]') { + parsed.hostname = '127.0.0.1'; + } + return parsed; +} + +function normalizeProbeUrl(urlString) { + return parseProbeTarget(urlString).toString(); +} + function probeHttp(url, timeoutMs = 3000) { return new Promise((resolve) => { let parsed; try { - parsed = new URL(url); + parsed = parseProbeTarget(url); } catch { resolve({ ok: false, statusCode: 0, data: null }); return; @@ -64,6 +106,7 @@ function probeHttp(url, timeoutMs = 3000) { { hostname: parsed.hostname, port, + family: 4, path: parsed.pathname + (parsed.search || ''), method: 'GET', timeout: timeoutMs, @@ -99,12 +142,18 @@ function probeHttp(url, timeoutMs = 3000) { * 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; + const primary = await probeHttp(normalizeProbeUrl(url), timeoutMs); + const payload = normalizeHealthPayload(primary.data); + if (isHealthHttpReady(primary.statusCode, primary.data)) { + return { ok: true, statusCode: primary.statusCode, data: payload }; + } + if (primary.ok) { + return { ok: false, statusCode: primary.statusCode, data: payload ?? primary.data }; + } if (primary.statusCode === 404 || primary.statusCode === 0) { try { - const parsed = new URL(url); + const parsed = parseProbeTarget(url); const prefix = parsed.pathname.replace(/\/health\/?$/, '') || '/api'; const candidates = [ `${parsed.origin}${prefix}/`, @@ -115,9 +164,9 @@ async function checkHealth(url, timeoutMs = 3000) { const alt = await probeHttp(fallback, timeoutMs); if (alt.statusCode === 200) { return { - ok: true, + ok: false, statusCode: 200, - data: { status: 'ok', database: 'unknown' }, + data: { status: 'degraded', database: 'unknown' }, }; } } @@ -133,7 +182,7 @@ function isHttpResponding(url, timeoutMs = 2000) { return new Promise((resolve) => { let parsed; try { - parsed = new URL(url); + parsed = parseProbeTarget(url); } catch { resolve(false); return; @@ -144,6 +193,7 @@ function isHttpResponding(url, timeoutMs = 2000) { { hostname: parsed.hostname, port, + family: 4, path: parsed.pathname || '/', method: 'GET', timeout: timeoutMs, @@ -171,12 +221,29 @@ async function waitForHttp(url, timeoutMs = 120_000, intervalMs = 500) { return false; } +/** Poll until HTTP 200 — used for theme dev homepage compile readiness. */ +async function waitForHttpOk(url, timeoutMs = 120_000, intervalMs = 500) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await probeHttp(normalizeProbeUrl(url), Math.min(intervalMs + 500, 3000)); + if (result.ok) return true; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +} + module.exports = { + isHealthPayloadReady, loadServerSiteUrl, + loadWebAdminUrl, loadClientSiteUrl, getApiPrefix, getHealthUrl, + normalizeProbeUrl, checkHealth, isHttpResponding, waitForHttp, + waitForHttpOk, + probeHttp, + normalizeProbeUrl, }; diff --git a/cli/lib/i18n/index.js b/cli/src/lib/i18n/index.ts similarity index 88% rename from cli/lib/i18n/index.js rename to cli/src/lib/i18n/index.ts index afeedd02..299ae618 100644 --- a/cli/lib/i18n/index.js +++ b/cli/src/lib/i18n/index.ts @@ -15,8 +15,12 @@ let locale = resolveLocale(); function t(key, vars = {}) { const table = STRINGS[locale] || STRINGS.en; let text = table[key] ?? STRINGS.en[key] ?? key; + if (text == null) { + text = key == null ? '' : String(key); + } + text = String(text); for (const [name, value] of Object.entries(vars)) { - text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)); + text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value ?? '')); } return text; } diff --git a/cli/lib/i18n/strings.js b/cli/src/lib/i18n/strings.ts similarity index 61% rename from cli/lib/i18n/strings.js rename to cli/src/lib/i18n/strings.ts index 4469cc24..7654a798 100644 --- a/cli/lib/i18n/strings.js +++ b/cli/src/lib/i18n/strings.ts @@ -1,13 +1,25 @@ /** 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.description': + 'ReactPress 4.0 CLI — publishing platform: init, dev, plugins, desktop, themes, build, publish', 'cli.init.description': 'Initialize project (.reactpress/config.json + .env + Docker MySQL)', 'cli.init.directory': 'Project directory', 'cli.init.force': 'Overwrite existing config', + 'cli.init.local': 'Initialize with embedded SQLite (no Docker)', 'cli.dev.description': 'Zero-config dev: env check + toolkit build + API + frontend', 'cli.dev.apiOnly': 'API only (watch)', + 'cli.dev.local': 'Local SQLite mode (no Docker/nginx)', 'cli.dev.clientOnly': 'Frontend only', + 'cli.dev.webOnly': 'Admin SPA + API (web/)', + 'cli.dev.remoteOrigin': 'Default remote API URL; with no admin/client flags, both use remote', + 'cli.dev.adminOrigin': 'Admin API: local | remote | URL (remote uses --remote-origin default)', + 'cli.dev.clientOrigin': 'Client/theme API (nginx /api): local | remote | URL', + 'cli.dev.remoteOriginRequired': '--remote-origin requires a URL (e.g. api.gaoredu.com)', + 'cli.dev.remoteDefaultRequired': 'Use remote with a URL: --remote-origin or pass a URL to admin/client-origin', + 'cli.dev.invalidOrigin': 'Invalid origin; use local, remote, or a host/URL', + 'cli.dev.remoteOriginIncompatibleApiOnly': 'Remote API flags cannot be used with --api-only', + 'cli.desktopDev.description': 'Desktop dev: embedded SQLite API + admin SPA + Electron (no Docker/MySQL)', 'cli.server.description': 'Manage API service', 'cli.server.start.description': 'Start API (wait until HTTP ready)', 'cli.server.start.pm2': 'Start with PM2 (production)', @@ -18,6 +30,24 @@ const STRINGS = { 'cli.client.description': 'Manage frontend', 'cli.client.start': 'Start Next.js client', 'cli.client.start.pm2': 'Start with PM2', + 'cli.client.restart': 'Rebuild active theme and restart visitor client', + 'themeProd.building': 'Building active theme "{id}"…', + 'themeProd.installingDeps': 'Installing dependencies for theme "{id}"…', + 'themeProd.reusingBuild': 'Reusing existing build for theme "{id}" (skip rebuild)', + 'themeProd.restarting': 'Restarting visitor client for theme "{id}"…', + 'themeProd.restarted': 'Visitor client is running theme "{id}".', + 'themePreview.backgroundBuildScheduled': + 'Scheduling background production builds for {count} preview theme(s)…', + 'themePreview.warmingAll': 'Pre-building {count} theme preview(s) for fast switching…', + 'themePreview.warmingAllSkipped': '{count} preview build(s) already up to date', + 'themePreview.installingDeps': 'Installing dependencies for preview theme "{id}"…', + 'themePreview.building': 'Building preview theme "{id}"…', + 'themePreview.reusingBuild': 'Reusing existing build for preview theme "{id}" (skip rebuild)', + 'themePreview.buildDone': 'Preview theme "{id}" build finished.', + 'themePreview.buildFailed': 'Preview theme "{id}" build failed: {message}', + 'themePreview.starting': + 'Preview theme "{id}" → {url} (port {port}, {dir}, {mode})', + 'themePreview.ready': 'Preview ready: {url} (theme: {id})', 'cli.build.description': 'Build production artifacts', 'cli.docker.description': 'Docker dev environment (MySQL + nginx)', 'cli.docker.up': 'Start Docker services and wait for MySQL', @@ -44,9 +74,9 @@ const STRINGS = { '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.publish.description': 'Build and publish npm packages', + 'cli.publish.build': 'Build publish artifacts only', + 'cli.publish.publish': 'Publish core packages to npm', 'cli.start.description': 'Production: start API + frontend', 'cli.help.examples': 'Examples:', 'cli.help.interactive': ' reactpress Interactive menu', @@ -56,10 +86,36 @@ const STRINGS = { '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 ', + 'cli.help.build': + ' reactpress build -t all Build (toolkit|plugins|server|web|theme|docs|all)', + 'cli.help.publish': ' reactpress publish Publish npm packages (maintainers)', + 'cli.help.theme': ' reactpress theme add Install theme from npm', + 'cli.help.themeList': ' reactpress theme list List available themes', + 'cli.help.initLocal': ' reactpress init --local Initialize with SQLite (no Docker)', + 'cli.help.devLocal': ' reactpress dev --local SQLite dev (no Docker/nginx)', + 'cli.help.desktop': ' reactpress desktop dev Desktop (Electron + SQLite)', + 'cli.help.plugin': ' reactpress plugin install seo Install a plugin', + 'cli.help.dbBackup': ' reactpress db backup Backup MySQL database', + 'cli.plugin.description': 'Manage ReactPress plugins', + 'cli.plugin.install.description': 'Install a local plugin into .reactpress/plugins', + 'cli.plugin.install.id': 'Plugin id from plugins/ registry', + 'cli.plugin.list.description': 'List registered plugins', + 'cli.theme.description': 'Install and manage themes', + 'cli.theme.add.description': 'Install a theme from an npm package spec or .tgz file', + 'cli.theme.add.spec': 'npm package spec (e.g. @fecommunity/reactpress-theme-starter@1.0.0-beta.0)', + 'cli.theme.add.catalog': 'Install from themes/{dir}/package.json by theme id', + 'cli.theme.add.skipDeps': 'Skip pnpm/npm install in the theme directory', + 'cli.theme.list.description': 'List available theme packages', + 'themeInstall.specRequired': 'Theme npm spec is required', + 'themeInstall.installing': 'Installing theme from npm: {spec}', + 'themeInstall.success': 'Theme "{name}" installed as "{id}" → {dir}', + 'themeInstall.nextActivate': 'Enable it in Admin → Appearance → Themes, or: POST /extension/themes/{id}/activate', + 'themeInstall.listHeading': 'Available themes:', + 'themeInstall.listEmpty': 'No theme packages found under themes/ or .reactpress/runtime/', + 'cli.build.target': 'Build target: toolkit | plugins | server | web | theme | docs | all', + 'cli.build.lowMem': 'Cap Node heap for builds and skip unchanged steps (2GB VPS)', + 'banner.subtitle': '· Publishing platform · v4.0', + 'banner.tagline': 'Not a CMS — ship in ~60s', /** 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', @@ -67,22 +123,36 @@ const STRINGS = { 'banner.pulsePending': 'INIT', 'banner.label.mode': 'MODE', 'banner.label.path': 'PATH', + 'banner.service.sqlite': 'SQLite', + 'banner.service.mysql': 'MySQL', + 'banner.service.server': 'Server', + 'banner.service.docker': 'Docker', + 'banner.service.nginx': 'Nginx', + 'banner.service.web': 'Web', + 'banner.nginxRunning': 'running', + 'banner.nginxStopped': 'stopped', + 'banner.feat.sqlite': 'SQLite', + 'banner.feat.plugins': 'Plugins', + 'banner.feat.desktop': 'Desktop', + 'banner.feat.themes': 'Themes', 'banner.mode.standalone': 'STANDALONE', 'banner.mode.monorepo': 'MONOREPO', 'banner.mode.uninitialized': 'UNINITIALIZED', 'banner.systemLabel': 'SYSTEM', 'banner.systemOnline': 'ONLINE', + 'banner.systemPartial': 'PARTIAL', + 'banner.systemError': 'ERROR', 'banner.systemPending': 'PENDING', - 'menu.dev': 'Zero-config dev (env + DB + API + frontend)', - 'menu.init': 'Initialize project (.reactpress + .env + database)', + 'menu.dev': 'Full-stack dev', + 'menu.init': 'Initialize project', 'menu.status': 'View project status', - 'menu.doctor': 'Environment diagnostics (doctor)', - 'menu.devApi': 'API only (dev watch)', + 'menu.doctor': 'Environment check', + 'menu.devApi': 'API only (watch)', 'menu.devClient': 'Frontend only', - 'menu.serverStart': 'Start API (production background)', + 'menu.serverStart': 'Start API (production)', 'menu.serverStop': 'Stop API', 'menu.serverRestart': 'Restart API', - 'menu.build': 'Build (toolkit → server → client)', + 'menu.build': 'Production build', 'menu.buildTarget': 'What do you want to build?', 'menu.buildAll': 'All (toolkit → server → client)', 'menu.dockerStart': 'Docker dev (DB + nginx + full stack)', @@ -92,6 +162,8 @@ const STRINGS = { 'menu.publish': 'Publish npm packages (interactive)', 'menu.exit': 'Exit', 'menu.prompt': 'Choose an action', + 'menu.subPrompt': '{section}', + 'menu.backToMain': 'Back to main menu', 'menu.back': 'Return to main menu?', 'menu.retry': 'Return to main menu and retry?', 'menu.startingDev': 'Starting full-stack dev…', @@ -100,11 +172,36 @@ const STRINGS = { 'menu.opening': 'Opening {url}', 'menu.goodbye': ' Goodbye.', 'menu.section.run': 'Run', - 'menu.section.lifecycle': 'Lifecycle', - 'menu.section.build': 'Build & Deploy', + 'menu.section.quick': 'Quick start', + 'menu.section.explore': 'More', + 'menu.section.extend': 'Dev alternatives', + 'menu.section.lifecycle': 'Service control', + 'menu.section.build': 'Build & deploy', 'menu.section.tools': 'Tools', + 'menu.group.extend': 'Dev alternatives', + 'menu.group.lifecycle': 'Service control', + 'menu.group.build': 'Build & deploy', + 'menu.group.tools': 'Tools & utilities', + 'menu.groupHint.extend': 'desktop · web · SQLite · themes', + 'menu.groupHint.lifecycle': 'API · frontend · start/stop', + 'menu.groupHint.build': 'build · Docker · publish', + 'menu.groupHint.tools': 'nginx · backup · admin', + 'menu.devDesktop': 'Desktop dev', + 'menu.hint.devDesktop': 'SQLite + Electron', + 'menu.devWeb': 'Admin SPA + API', + 'menu.hint.devWeb': 'web/ dev server', + 'menu.devLocalWeb': 'Local web', + 'menu.hint.devLocalWeb': 'SQLite in browser', + 'menu.initLocal': 'Initialize (SQLite)', + 'menu.hint.initLocal': 'no Docker', + 'menu.themeList': 'List themes', + 'menu.hint.themeList': 'theme list', + 'menu.pluginList': 'List plugins', + 'menu.hint.pluginList': 'plugin list', + 'menu.dbBackup': 'Backup database', + 'menu.hint.dbBackup': 'mysqldump', '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.shortcuts': '↑/↓ navigate · 1-9 quick pick · enter select · ctrl+c quit', 'menu.statusHeader': 'Status', 'menu.contextStandalone': 'Project mode · standalone (using bundled API)', 'menu.contextMonorepo': 'Project mode · monorepo (server/src + client/)', @@ -148,11 +245,77 @@ const STRINGS = { '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.phaseApi': 'API → :3002', + 'dev.phasePrerequisites': 'Checking Node.js and Docker…', + 'dev.phaseInfra': 'Starting MySQL and nginx…', + 'dev.phaseServices': 'Starting API, admin SPA, and theme…', + 'dev.phasePrerequisitesDesktop': 'Checking Node.js…', + 'dev.phaseInfraDesktop': 'Building toolkit & local workspace…', + 'dev.phaseServicesDesktop': 'Starting admin SPA and Electron…', + 'dev.phaseServicesLocalWeb': 'Starting admin SPA (browser preview)…', + 'dev.previewPrewarmStarting': 'Pre-building theme previews for fast switching…', + 'dev.remoteApiUsing': 'Using remote API (nginx /api → {url})', + 'dev.adminApiRemote': 'Admin API → {url}', + 'dev.clientApiRemote': 'Client API (nginx /api) → {url}', + 'dev.nginxReadyRemote': 'nginx ready at {url} (client API → {api})', + 'dev.checkNodeOk': 'Node.js {version}', + 'dev.checkDockerOk': 'Docker is running', + 'dev.prerequisitesOk': '✓ Node {version} · ✓ Docker', + 'dev.prerequisitesOkDesktop': '✓ Node {version} · SQLite (no Docker)', + 'dev.apiKept': 'Keeping healthy API on port {port}', + 'dev.timingReady': 'Ready in {summary}', + 'dev.timingInfra': 'infra', + 'dev.timingServices': 'services', + 'dev.timingApiReused': 'API reused', + 'dev.waitingApiQuiet': 'Waiting for API…', + 'dev.mysqlReadyQuiet': 'MySQL ready', + 'dev.themeStarting': 'Theme "{id}" → :{port}', + 'dev.themeCacheClearedForRemote': 'Cleared theme .next (remote API — stale SERVER_API_URL)', + 'dev.themeReadyQuiet': 'Visitor site ready → {url}', + 'dev.phaseAdmin': 'Starting admin SPA (internal :3000/admin/)…', + 'dev.phaseTheme': 'Starting active theme site (internal :3001)…', + 'dev.phaseClient': 'Starting legacy client…', + 'dev.phaseNginx': 'Starting nginx unified entry (:80)…', + 'dev.phaseNginxWait': 'Waiting for admin & theme, then nginx…', + 'dev.waitingProxies': 'Waiting for admin & theme…', + 'dev.startingAdmin': 'Admin dev server → {url}', + 'dev.adminNginxSlow': '[reactpress] Admin via nginx not ready: {url} — ensure Vite base is /admin/ (restart pnpm dev)', + 'dev.nginxReady': 'Nginx → {url}', + 'dev.proxiesReady': 'Admin & theme dev servers listening', + 'dev.portApiBusy': '[reactpress] Port {port} is already in use (API not healthy). Stop the other process or run: reactpress doctor', + 'dev.portApiBusyHint': '[reactpress] Tip: lsof -i :3002 · avoid running pnpm dev in two terminals', + 'dev.startingApi': 'Starting API (port 3002)…', + 'dev.waitingApi': 'Waiting for API: {url}', + 'dev.waitingApiCompile': 'compiling (port {port} not listening yet)', + 'dev.waitingApiStarting': 'port open, waiting for health', + 'dev.healthDbDown': 'database down', + 'dev.healthDegraded': 'API degraded', '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.apiReusing': 'API on :{port} (healthy, skipped restart)', + 'dev.apiReady': 'API ready', + 'dev.toolkitUpToDate': 'Toolkit build skipped (dist is up to date; set REACTPRESS_FORCE_TOOLKIT_BUILD=1 to rebuild)', + 'dev.themeSiteSkipped': 'visitor site (:3001) will not start until the theme package exists', + 'dev.themeBackground': 'Visitor site (:3001) compiling in background — banner shows when API & admin are ready', + 'dev.themeBackgroundReady': 'Visitor site ready: {url}', + 'themeDev.starting': '[reactpress] Active theme "{id}" → {url} (port {port}, {dir})', + 'themeDev.startingShort': 'Theme "{id}" on :{port} ({dir})', + 'themeDev.cacheCleared': 'Cleared theme .next (REACTPRESS_CLEAR_THEME_CACHE=1)', + 'themeDev.cacheStaleCleared': 'Cleared theme .next (missing {marker})', + 'themeDev.apiSplit': '[reactpress] Theme API — SSR: {ssr} · browser: {browser}', + 'themeDev.ready': '[reactpress] Public site ready: {url} (theme: {id})', + 'themeDev.slow': '[reactpress] Theme site slow to start: {url}', + 'themeDev.notFound': 'Theme package "{id}" not found', + 'themeDev.invalidManifest': + 'Ignored invalid active-theme.json (only themes/ or .reactpress/runtime/ packages apply)', + 'themeDev.unavailable': 'will not listen', + 'themeDev.restart': 'active-theme.json changed — restarting theme on :3001…', + 'themeDev.restartFailed': 'theme restart failed: {message}', + 'themeDev.portBusy': 'Port {port} still in use after stopping the previous theme — skip restart (retry activate or restart pnpm dev)', + 'themeDev.portBusyHint': 'Or free the port manually: {cmd}', + 'dev.apiReadyAdmin': 'API ready · starting admin', 'dev.clientSlow': '[reactpress] Frontend not responding within {seconds}s; it may still be compiling. Visit {url} later', + 'dev.adminSlow': '[reactpress] Admin SPA not responding within {seconds}s; it may still be compiling. Visit {url} later', + 'dev.noWeb': '[reactpress] web/ directory not found; cannot start admin dev stack.', 'dev.envFailed': '[reactpress] Environment setup failed:', 'dev.toolkitFailed': 'toolkit build failed with exit code: {code}', 'dev.nextSteps': 'Suggested next steps:', @@ -160,15 +323,47 @@ const STRINGS = { '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.', + 'dev.desktopStarting': 'Starting Electron desktop → {url}', + 'dev.desktopMissing': 'desktop/ package not found — run from monorepo root', + 'dev.desktopIntro': 'Desktop dev — local-first mode (SQLite embedded, no Docker/MySQL)', + 'dev.localWebIntro': 'Local web dev — SQLite API + admin SPA in browser (no Docker/Electron)', + 'dev.localFullIntro': 'Local full-stack dev — SQLite API + admin + theme (no Docker/MySQL)', + 'dev.autoLocalNoDocker': + 'Docker unavailable — auto-switching to local SQLite mode (no MySQL required)', + 'dev.autoLocalNoMysql': 'MySQL unreachable — auto-switching to local SQLite mode', + 'dev.desktopLocalApiStarting': 'Starting embedded SQLite API…', + 'dev.desktopLocalApiReady': '✓ Local API ({db}) → {url}', + 'dev.desktopLocalApi': 'Local SQLite API → {url}', + 'dev.dbTypeSqlite': 'SQLite', 'devBanner.ready': 'ReactPress dev environment is ready', + 'devBanner.readyWeb': 'ReactPress admin dev environment is ready', + 'devBanner.readyLocalWeb': 'ReactPress local web dev environment is ready', + 'devBanner.readyDesktop': 'ReactPress desktop dev environment is ready', + 'devBanner.readyApi': 'ReactPress API is ready', 'devBanner.site': 'Site', 'devBanner.admin': 'Admin', 'devBanner.api': 'API', + 'devBanner.database': 'Database', + 'devBanner.sqliteEmbedded': 'SQLite (embedded, no Docker)', + 'devBanner.mysqlDocker': 'MySQL (Docker)', 'devBanner.swagger': 'Swagger', 'devBanner.health': 'Health', + 'devBanner.desktopLocalHint': 'Default login admin/admin · switch to remote API in Settings', + 'devBanner.localWebHint': 'Open the admin URL in your browser · default login admin/admin', + 'devBanner.localModeGo': 'LOCAL MODE READY', 'devBanner.hint': 'Diagnostics: reactpress doctor · Status: reactpress status', + 'devBanner.nginxHint': 'Internal ports 3001 (site) / 3002 (API) / 3003 (preview) / 3000 (admin) — use URLs above only', + 'devBanner.nginxRemoteHint': 'Client /api proxied to {url}', + 'devBanner.adminRemoteHint': 'Admin /api proxied to {url}', 'devBanner.shortcuts': 'Ctrl+C stop', 'devBanner.allSystemsGo': 'ALL SYSTEMS GO', + 'devBanner.dbDegraded': 'DB OFFLINE — run reactpress docker up', + 'dev.nginxSkippedDocker': '[reactpress] Docker not running — skipping nginx (use direct ports or start Docker)', + 'dev.nginxStartFailed': '[reactpress] nginx failed to start: {message}', + 'dev.dbEnsureFailed': '[reactpress] Database not ready: {message}', + 'dev.mysqlUnreachable': + '[reactpress] MySQL is not reachable — theme/admin API calls will fail. Start Docker, then: reactpress docker up', + 'dev.nginxSlow': '[reactpress] nginx entry slow to respond: {url}', 'doctor.nodeBad': 'Node.js {version} (requires ≥ 18)', 'doctor.nodeFix': 'Install Node.js 18+: https://nodejs.org/', 'doctor.dockerOk': 'Docker engine is available', @@ -183,6 +378,9 @@ const STRINGS = { '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.dbSqliteOk': 'SQLite ready ({detail})', + 'doctor.dbSqliteBad': 'SQLite check failed: {error}', + 'doctor.dbSqliteFix': 'Run reactpress init --local or check DB_DATABASE 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', @@ -211,7 +409,7 @@ const STRINGS = { 'status.dir': 'Project {path}', 'status.apiSource': 'API source {source}', 'status.apiSource.monorepo': 'monorepo server/', - 'status.apiSource.bundle': 'reactpress-cli', + 'status.apiSource.bundle': '@fecommunity/reactpress', 'status.configOk': '.reactpress/config.json', 'status.configBad': 'not initialized', 'status.envOk': '.env', @@ -283,9 +481,21 @@ const STRINGS = { '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.devStartBlocked': + 'MySQL is not reachable on 127.0.0.1:{port} and Docker is not running. Start Docker Desktop, then run: reactpress docker up — or point DB_* in .env to an external MySQL instance.', 'docker.started': '[reactpress] Docker services started.', 'docker.waitingMysql': '[reactpress] Waiting for MySQL…', 'docker.mysqlReady': '[reactpress] MySQL is ready.', + 'docker.mysqlExternalReady': '[reactpress] Using existing MySQL on port {port}.', + 'docker.dbPortInUse': + '[reactpress] Port {port} is already in use — skipping reactpress_db, using existing MySQL on that port.', + 'docker.dbReuseExisting': + '[reactpress] MySQL already reachable on port {port} — keeping Docker DB container', + 'docker.dbPortInUseRecycle': + '[reactpress] Port {port} is in use but MySQL is not reachable — recreating reactpress_db container…', + 'docker.dbPortConflict': + '[reactpress] MySQL on port {port} is unreachable. Run: docker start reactpress_cli_db reactpress_db OR docker compose -f docker-compose.dev.yml up -d db', + 'docker.ensureDevDb': '[reactpress] MySQL not reachable — starting Docker database…', 'docker.waitingMysqlProgress': '[reactpress] Waiting for MySQL… ({attempts}/{max})', 'docker.mysqlTimeout': '[reactpress] MySQL not ready within timeout.', 'docker.mysqlNotReady': 'MySQL is not ready', @@ -333,10 +543,14 @@ const STRINGS = { 'build.step': '[{current}/{total}] {label}', 'build.stepDone': '[{current}/{total}] {label} ({seconds}s)', 'build.stepSkipped': 'skipped {label} (not part of this project layout)', + 'build.stepSkippedFresh': 'skipped {label} (dist up to date)', + 'build.stepSkippedReuse': 'skipped {label} — reusing build for "{id}"', 'build.done': 'Build finished in {seconds}s', 'build.label.toolkit': 'Toolkit', + 'build.label.plugins': 'Plugins', 'build.label.server': 'API (server)', - 'build.label.client': 'Frontend (client)', + 'build.label.web': 'Admin (web)', + 'build.label.theme': 'Visitor theme', 'build.label.docs': 'Documentation (docs)', 'pm2.startFailed': '[reactpress] PM2 failed to start API:', 'pm2.exitCode': 'PM2 exited with code {code}', @@ -422,13 +636,24 @@ const STRINGS = { 'bundle.port.notFound': 'No available port in range {start}-{end}', }, zh: { - 'cli.description': 'ReactPress 全栈 CLI — 初始化、开发、构建、部署、发布', + 'cli.description': 'ReactPress 4.0 CLI — 初始化、开发、插件、桌面、主题、构建与发布', 'cli.init.description': '初始化项目 (.reactpress/config.json + .env + Docker MySQL)', 'cli.init.directory': '项目目录', 'cli.init.force': '覆盖已有配置', + 'cli.init.local': '使用嵌入式 SQLite 初始化(无需 Docker)', 'cli.dev.description': '零配置开发: 环境检查 + toolkit 构建 + API + 前端', 'cli.dev.apiOnly': '仅启动 API (watch)', + 'cli.dev.local': '本地 SQLite 模式(无需 Docker/nginx)', 'cli.dev.clientOnly': '仅启动前端', + 'cli.dev.webOnly': '管理后台 + API (web/)', + 'cli.dev.remoteOrigin': '默认远程 API;未指定 admin/client 时两者均走远程', + 'cli.dev.adminOrigin': '管理后台 API:local | remote | URL(remote 用 --remote-origin 默认值)', + 'cli.dev.clientOrigin': '访客站 API(nginx /api):local | remote | URL', + 'cli.dev.remoteOriginRequired': '--remote-origin 需要填写地址(如 api.gaoredu.com)', + 'cli.dev.remoteDefaultRequired': 'remote 需配合 URL:使用 --remote-origin 或为 admin/client-origin 填写地址', + 'cli.dev.invalidOrigin': '无效的 origin;请使用 local、remote 或主机/URL', + 'cli.dev.remoteOriginIncompatibleApiOnly': '远程 API 参数不能与 --api-only 同时使用', + 'cli.desktopDev.description': '桌面开发:内嵌 SQLite API + 管理后台 + Electron(无需 Docker/MySQL)', 'cli.server.description': '管理 API 服务', 'cli.server.start.description': '启动 API(等待 HTTP 就绪)', 'cli.server.start.pm2': '使用 PM2 启动(生产)', @@ -439,6 +664,22 @@ const STRINGS = { 'cli.client.description': '管理前端', 'cli.client.start': '启动 Next.js 客户端', 'cli.client.start.pm2': '使用 PM2 启动', + 'cli.client.restart': '重新构建当前主题并重启访客端', + 'themeProd.building': '正在构建当前主题「{id}」…', + 'themeProd.installingDeps': '正在为主题「{id}」安装依赖…', + 'themeProd.reusingBuild': '复用主题「{id}」已有构建,跳过重新构建', + 'themeProd.restarting': '正在为主题「{id}」重启访客端…', + 'themeProd.restarted': '访客端已切换为主题「{id}」。', + 'themePreview.backgroundBuildScheduled': '正在后台构建 {count} 个预览主题(生产模式)…', + 'themePreview.warmingAll': '正在预构建 {count} 个主题预览(加速切换)…', + 'themePreview.warmingAllSkipped': '{count} 个预览构建已是最新,已跳过', + 'themePreview.installingDeps': '正在为预览主题「{id}」安装依赖…', + 'themePreview.building': '正在构建预览主题「{id}」…', + 'themePreview.reusingBuild': '复用预览主题「{id}」已有构建,跳过重新构建', + 'themePreview.buildDone': '预览主题「{id}」构建完成。', + 'themePreview.buildFailed': '预览主题「{id}」构建失败:{message}', + 'themePreview.starting': '预览主题「{id}」→ {url}(端口 {port},{dir},{mode})', + 'themePreview.ready': '预览已就绪:{url}(主题:{id})', 'cli.build.description': '构建生产产物', 'cli.docker.description': 'Docker 开发环境 (MySQL + nginx)', 'cli.docker.up': '仅启动 Docker 服务并等待 MySQL', @@ -465,9 +706,9 @@ const STRINGS = { 'cli.db.description': '数据库运维', 'cli.db.backup': '使用 mysqldump 备份当前项目数据库', 'cli.db.backup.output': '输出 SQL 文件路径', - 'cli.publish.description': '构建并发布 npm 包 (交互式)', - 'cli.publish.build': '仅构建所有包', - 'cli.publish.publish': '交互式发布', + 'cli.publish.description': '构建并发布 npm 包', + 'cli.publish.build': '仅构建发布产物', + 'cli.publish.publish': '发布核心 npm 包', 'cli.start.description': '生产模式: 启动 API + 前端', 'cli.help.examples': '示例:', 'cli.help.interactive': ' reactpress 交互式菜单', @@ -477,32 +718,72 @@ const STRINGS = { '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 ', + 'cli.help.build': + ' reactpress build -t all 构建 (toolkit|plugins|server|web|theme|docs|all)', + 'cli.help.publish': ' reactpress publish 发布 npm 包(维护者)', + 'cli.help.theme': ' reactpress theme add 从 npm 安装主题', + 'cli.help.themeList': ' reactpress theme list 列出可用主题', + 'cli.help.initLocal': ' reactpress init --local SQLite 初始化(无需 Docker)', + 'cli.help.devLocal': ' reactpress dev --local SQLite 开发(无需 Docker/nginx)', + 'cli.help.desktop': ' reactpress desktop dev 桌面客户端(Electron + SQLite)', + 'cli.help.plugin': ' reactpress plugin install seo 安装插件', + 'cli.help.dbBackup': ' reactpress db backup 备份 MySQL 数据库', + 'cli.plugin.description': '管理 ReactPress 插件', + 'cli.plugin.install.description': '安装本地插件到 .reactpress/plugins', + 'cli.plugin.install.id': 'plugins/ 注册表中的插件 id', + 'cli.plugin.list.description': '列出已注册插件', + 'cli.theme.description': '安装与管理主题', + 'cli.theme.add.description': '从 npm 包 spec 或 .tgz 文件安装主题', + 'cli.theme.add.spec': 'npm 包 spec(如 @fecommunity/reactpress-theme-starter@1.0.0-beta.0)', + 'cli.theme.add.catalog': '按 themes/{dir}/package.json 中的主题 id 安装', + 'cli.theme.add.skipDeps': '跳过主题目录内的 pnpm/npm install', + 'cli.theme.list.description': '列出可用主题包', + 'themeInstall.specRequired': '请提供 npm 主题包 spec', + 'themeInstall.installing': '正在从 npm 安装主题: {spec}', + 'themeInstall.success': '主题「{name}」已安装为「{id}」→ {dir}', + 'themeInstall.nextActivate': '请在管理后台「外观 → 主题」中启用,或调用 POST /extension/themes/{id}/activate', + 'themeInstall.listHeading': '可用主题:', + 'themeInstall.listEmpty': '在 themes/ 或 .reactpress/runtime/ 下未找到主题包', + 'cli.build.target': '构建目标: toolkit | plugins | server | web | theme | docs | all', + 'cli.build.lowMem': '低内存模式:限制构建堆内存并跳过未变化步骤(2G 小机)', + 'banner.subtitle': '· 发布平台 · v4.0', + 'banner.tagline': '不是 CMS — 约 60 秒上线', /** 左侧装饰性进度条标签(不是网址;仓库地址放在卡片顶部 Title 正下方)。 */ 'banner.pulseLabel': '准备', 'banner.pulseReady': '就绪', 'banner.pulsePending': '初始化', 'banner.label.mode': '模式', 'banner.label.path': '路径', + 'banner.service.sqlite': 'SQLite', + 'banner.service.mysql': 'MySQL', + 'banner.service.server': 'Server', + 'banner.service.docker': 'Docker', + 'banner.service.nginx': 'Nginx', + 'banner.service.web': 'Web', + 'banner.nginxRunning': '运行中', + 'banner.nginxStopped': '未运行', + 'banner.feat.sqlite': 'SQLite', + 'banner.feat.plugins': '插件', + 'banner.feat.desktop': 'Desktop', + 'banner.feat.themes': '主题', 'banner.mode.standalone': '独立项目', 'banner.mode.monorepo': 'MONOREPO', 'banner.mode.uninitialized': '未初始化', 'banner.systemLabel': '系统', 'banner.systemOnline': '在线', + 'banner.systemPartial': '部分就绪', + 'banner.systemError': '异常', 'banner.systemPending': '准备中', - 'menu.dev': '零配置开发 (env + DB + API + 前端)', - 'menu.init': '初始化项目 (.reactpress + .env + 数据库)', + 'menu.dev': '全栈开发', + 'menu.init': '初始化项目', 'menu.status': '查看项目状态', - 'menu.doctor': '环境诊断 (doctor)', - 'menu.devApi': '仅启动 API (开发 watch)', - 'menu.devClient': '仅启动前端', - 'menu.serverStart': '启动 API (后台生产)', + 'menu.doctor': '环境检查', + 'menu.devApi': '仅 API (watch)', + 'menu.devClient': '仅前端', + 'menu.serverStart': '启动 API (生产)', 'menu.serverStop': '停止 API', 'menu.serverRestart': '重启 API', - 'menu.build': '构建 (toolkit → server → client)', + 'menu.build': '生产构建', 'menu.buildTarget': '选择要构建的目标', 'menu.buildAll': '全部 (toolkit → server → client)', 'menu.dockerStart': 'Docker 开发环境 (DB + nginx + 全栈)', @@ -512,6 +793,8 @@ const STRINGS = { 'menu.publish': '发布 npm 包 (交互式)', 'menu.exit': '退出', 'menu.prompt': '选择操作', + 'menu.subPrompt': '{section}', + 'menu.backToMain': '返回主菜单', 'menu.back': '返回主菜单?', 'menu.retry': '返回主菜单重试?', 'menu.startingDev': '启动全栈开发…', @@ -520,11 +803,36 @@ const STRINGS = { 'menu.opening': '打开 {url}', 'menu.goodbye': ' 再见。', 'menu.section.run': '运行', - 'menu.section.lifecycle': '生命周期', + 'menu.section.quick': '快速开始', + 'menu.section.explore': '更多', + 'menu.section.extend': '开发替代方案', + 'menu.section.lifecycle': '服务控制', 'menu.section.build': '构建与部署', 'menu.section.tools': '工具', + 'menu.group.extend': '开发替代方案', + 'menu.group.lifecycle': '服务控制', + 'menu.group.build': '构建与部署', + 'menu.group.tools': '工具与实用功能', + 'menu.groupHint.extend': '桌面 · Web · SQLite · 主题', + 'menu.groupHint.lifecycle': 'API · 前端 · 启停', + 'menu.groupHint.build': '构建 · Docker · 发布', + 'menu.groupHint.tools': 'nginx · 备份 · 后台', + 'menu.devDesktop': '桌面开发', + 'menu.hint.devDesktop': 'SQLite + Electron', + 'menu.devWeb': '管理后台 + API', + 'menu.hint.devWeb': 'web/ 开发', + 'menu.devLocalWeb': '本地 Web', + 'menu.hint.devLocalWeb': '浏览器 + SQLite', + 'menu.initLocal': 'SQLite 初始化', + 'menu.hint.initLocal': '无需 Docker', + 'menu.themeList': '列出主题', + 'menu.hint.themeList': 'theme list', + 'menu.pluginList': '列出插件', + 'menu.hint.pluginList': 'plugin list', + 'menu.dbBackup': '备份数据库', + 'menu.hint.dbBackup': 'mysqldump', 'menu.tip': '提示:上下方向键选择,回车确认,Ctrl+C 退出。', - 'menu.shortcuts': '↑/↓ 选择 · 回车 确认 · esc 返回 · Ctrl+C 退出', + 'menu.shortcuts': '↑/↓ 选择 · 1-9 快捷 · 回车确认 · Ctrl+C 退出', 'menu.statusHeader': '当前状态', 'menu.contextStandalone': '项目类型 · 独立项目(使用内置 API)', 'menu.contextMonorepo': '项目类型 · monorepo(server/src + client/)', @@ -568,11 +876,77 @@ const STRINGS = { 'menu.hint.publish': '仅维护者使用', 'menu.hint.exit': '', 'menu.actionPrefix': '操作', - 'dev.startingApi': '[reactpress] 正在启动 API(首次可能需安装依赖,请稍候)…', - 'dev.waitingApi': '[reactpress] 等待 API 就绪: {url}', + 'dev.phaseApi': 'API → :3002', + 'dev.phasePrerequisites': '检查 Node.js 与 Docker…', + 'dev.phaseInfra': '启动 MySQL 与 nginx…', + 'dev.phaseServices': '启动 API、管理后台与主题…', + 'dev.phasePrerequisitesDesktop': '检查 Node.js…', + 'dev.phaseInfraDesktop': '构建 toolkit 与本地工作区…', + 'dev.phaseServicesDesktop': '启动管理后台与 Electron…', + 'dev.phaseServicesLocalWeb': '启动管理后台(浏览器预览)…', + 'dev.previewPrewarmStarting': '预构建主题预览以加速切换…', + 'dev.remoteApiUsing': '使用远程 API(nginx /api → {url})', + 'dev.adminApiRemote': '管理后台 API → {url}', + 'dev.clientApiRemote': '访客站 API(nginx /api)→ {url}', + 'dev.nginxReadyRemote': 'nginx 已就绪:{url}(访客站 API → {api})', + 'dev.checkNodeOk': 'Node.js {version}', + 'dev.checkDockerOk': 'Docker 已运行', + 'dev.prerequisitesOk': '✓ Node {version} · ✓ Docker', + 'dev.prerequisitesOkDesktop': '✓ Node {version} · SQLite(无需 Docker)', + 'dev.apiKept': '复用端口 {port} 上已健康的 API', + 'dev.timingReady': '启动完成 · {summary}', + 'dev.timingInfra': '基础设施', + 'dev.timingServices': '服务', + 'dev.timingApiReused': 'API 已复用', + 'dev.waitingApiQuiet': '等待 API…', + 'dev.mysqlReadyQuiet': 'MySQL 已就绪', + 'dev.themeStarting': '主题「{id}」→ :{port}', + 'dev.themeCacheClearedForRemote': '已清除主题 .next(远程 API,避免陈旧 SERVER_API_URL)', + 'dev.themeReadyQuiet': '访客站已就绪 → {url}', + 'dev.phaseAdmin': '启动管理后台(内部 :3000/admin/)…', + 'dev.phaseTheme': '启动当前启用主题(内部 :3001)…', + 'dev.phaseClient': '启动旧版 client 前端…', + 'dev.phaseNginx': '启动 nginx 统一入口(:80)…', + 'dev.phaseNginxWait': '等待管理端与主题就绪后启动 nginx…', + 'dev.waitingProxies': '等待管理后台与主题…', + 'dev.startingAdmin': '管理后台 → {url}', + 'dev.adminNginxSlow': '[reactpress] 经 nginx 访问管理端未就绪: {url} — 请确认 Vite base 为 /admin/(重新执行 pnpm dev)', + 'dev.nginxReady': 'Nginx → {url}', + 'dev.proxiesReady': '管理后台与主题开发服务已监听', + 'dev.portApiBusy': '[reactpress] 端口 {port} 已被占用(且 API 健康检查未通过)。请结束占用进程或运行: reactpress doctor', + 'dev.portApiBusyHint': '[reactpress] 提示: lsof -i :3002 · 请勿在两个终端同时运行 pnpm dev', + 'dev.startingApi': '启动 API(端口 3002)…', + 'dev.waitingApi': '等待 API: {url}', + 'dev.waitingApiCompile': '编译中(端口 {port} 尚未监听)', + 'dev.waitingApiStarting': '端口已开,等待健康检查', + 'dev.healthDbDown': '数据库未连接', + 'dev.healthDegraded': 'API 降级', '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.apiReusing': 'API :{port} 已健康,跳过重启', + 'dev.apiReady': 'API 已就绪', + 'dev.toolkitUpToDate': '已跳过 toolkit 构建(dist 为最新;设置 REACTPRESS_FORCE_TOOLKIT_BUILD=1 可强制重建)', + 'dev.themeSiteSkipped': '访客站(:3001)需在主题包就绪后才会启动', + 'dev.themeBackground': '访客站(:3001)后台编译中 — API 与管理端就绪后将显示启动横幅', + 'dev.themeBackgroundReady': '访客站已就绪: {url}', + 'themeDev.starting': '[reactpress] 已启用主题「{id}」→ {url}(端口 {port},{dir})', + 'themeDev.startingShort': '主题「{id}」→ :{port}({dir})', + 'themeDev.cacheCleared': '已清理主题 .next(REACTPRESS_CLEAR_THEME_CACHE=1)', + 'themeDev.cacheStaleCleared': '已清理主题 .next(缺少 {marker})', + 'themeDev.apiSplit': '[reactpress] 主题 API — 服务端渲染: {ssr} · 浏览器: {browser}', + 'themeDev.ready': '[reactpress] 访客站已就绪: {url}(主题: {id})', + 'themeDev.slow': '[reactpress] 主题站点启动较慢: {url}', + 'themeDev.notFound': '未找到主题包「{id}」', + 'themeDev.invalidManifest': + '已忽略无效的 active-theme.json(仅 themes/ 或 .reactpress/runtime/ 下的主题包会生效)', + 'themeDev.unavailable': '无法监听', + 'themeDev.restart': 'active-theme.json 已更新,正在重启 :3001 主题进程…', + 'themeDev.restartFailed': '主题重启失败: {message}', + 'themeDev.portBusy': '端口 {port} 仍被占用,已跳过本次主题重启(请再次启用主题或重启 pnpm dev)', + 'themeDev.portBusyHint': '也可手动释放端口: {cmd}', + 'dev.apiReadyAdmin': 'API 已就绪 · 启动管理后台', 'dev.clientSlow': '[reactpress] 前端在 {seconds}s 内未响应,可能仍在编译。稍后访问 {url}', + 'dev.adminSlow': '[reactpress] 管理后台在 {seconds}s 内未响应,可能仍在编译。稍后访问 {url}', + 'dev.noWeb': '[reactpress] 未找到 web/ 目录,无法启动管理后台开发栈。', 'dev.envFailed': '[reactpress] 环境准备失败:', 'dev.toolkitFailed': 'toolkit 构建失败,退出码: {code}', 'dev.nextSteps': '下一步建议:', @@ -580,15 +954,46 @@ const STRINGS = { 'dev.nextDocker': ' → reactpress docker up 启动嵌入式 MySQL', 'dev.nextEnv': ' → 检查 .env 中 DB_* 与 SERVER_SITE_URL', 'dev.standaloneHint': '[reactpress] 独立项目:当前目录仅启动 API,前端请单独构建。', + 'dev.desktopStarting': '正在启动 Electron 桌面客户端 → {url}', + 'dev.desktopMissing': '未找到 desktop/ 包 — 请在 monorepo 根目录运行', + 'dev.desktopIntro': '桌面开发 — 本地优先模式(内嵌 SQLite,无需 Docker/MySQL)', + 'dev.localWebIntro': '本地 Web 开发 — SQLite API + 浏览器管理后台(无需 Docker/Electron)', + 'dev.localFullIntro': '本地全栈开发 — SQLite API + 管理后台 + 访客主题(无需 Docker/MySQL)', + 'dev.autoLocalNoDocker': 'Docker 不可用,已自动切换为本地 SQLite 模式(无需 MySQL)', + 'dev.autoLocalNoMysql': 'MySQL 未就绪,已自动切换为本地 SQLite 模式', + 'dev.desktopLocalApiStarting': '正在启动内嵌 SQLite API…', + 'dev.desktopLocalApiReady': '✓ 本地 API({db})→ {url}', + 'dev.desktopLocalApi': '本地 SQLite API → {url}', + 'dev.dbTypeSqlite': 'SQLite', 'devBanner.ready': 'ReactPress 开发环境已就绪', + 'devBanner.readyWeb': 'ReactPress 管理后台开发环境已就绪', + 'devBanner.readyLocalWeb': 'ReactPress 本地 Web 开发环境已就绪', + 'devBanner.readyDesktop': 'ReactPress 桌面开发环境已就绪', + 'devBanner.readyApi': 'ReactPress API 已就绪', 'devBanner.site': '前台', 'devBanner.admin': '管理端', 'devBanner.api': 'API', + 'devBanner.database': '数据库', + 'devBanner.sqliteEmbedded': 'SQLite(内嵌,无需 Docker)', + 'devBanner.mysqlDocker': 'MySQL(Docker)', 'devBanner.swagger': 'Swagger', 'devBanner.health': '健康检查', + 'devBanner.desktopLocalHint': '默认账号 admin/admin · 可在设置中切换远程 API 或同步', + 'devBanner.localWebHint': '在浏览器打开管理端地址 · 默认账号 admin/admin', + 'devBanner.localModeGo': '本地模式就绪', 'devBanner.hint': '诊断: reactpress doctor · 状态: reactpress status', + 'devBanner.nginxHint': '内部端口:3001 访客站 / 3002 API / 3003 主题预览 / 3000 管理后台 — 请只使用上方地址', + 'devBanner.nginxRemoteHint': '访客站 /api 已代理至 {url}', + 'devBanner.adminRemoteHint': '管理后台 /api 已代理至 {url}', 'devBanner.shortcuts': 'Ctrl+C 停止', 'devBanner.allSystemsGo': '一切就绪', + 'devBanner.dbDegraded': '数据库未连接 — 请执行 reactpress docker up', + 'dev.nginxSkippedDocker': '[reactpress] Docker 未运行,已跳过 nginx(请启动 Docker 或直连各端口)', + 'dev.nginxStartFailed': '[reactpress] nginx 启动失败: {message}', + 'dev.dbEnsureFailed': '[reactpress] 数据库未就绪: {message}', + 'dev.mysqlUnreachable': + '[reactpress] MySQL 不可达,主题/后台接口会报错。请先启动 Docker,再执行: reactpress docker up', + 'dev.nginxSlow': '[reactpress] nginx 入口响应较慢: {url}', 'doctor.nodeBad': 'Node.js {version}(需要 ≥ 18)', 'doctor.nodeFix': '请安装 Node.js 18+:https://nodejs.org/', 'doctor.dockerOk': 'Docker 引擎可用', @@ -603,6 +1008,9 @@ const STRINGS = { 'doctor.dbOk': 'MySQL {host}:{port}/{database} 连通', 'doctor.dbBad': '数据库连接失败: {error}', 'doctor.dbFix': '运行 reactpress docker up 或检查 .env 中 DB_* 配置', + 'doctor.dbSqliteOk': 'SQLite 就绪 ({detail})', + 'doctor.dbSqliteBad': 'SQLite 检测失败: {error}', + 'doctor.dbSqliteFix': '运行 reactpress init --local 或检查 .env 中 DB_DATABASE', 'doctor.apiOk': 'API 健康检查通过 ({url})', 'doctor.apiBad': 'API 未响应健康检查 ({url})', 'doctor.apiFix': '运行 reactpress server start 或 reactpress dev', @@ -631,7 +1039,7 @@ const STRINGS = { 'status.dir': '项目目录 {path}', 'status.apiSource': 'API 来源 {source}', 'status.apiSource.monorepo': 'monorepo server/', - 'status.apiSource.bundle': 'reactpress-cli', + 'status.apiSource.bundle': '@fecommunity/reactpress', 'status.configOk': '.reactpress/config.json', 'status.configBad': '未初始化', 'status.envOk': '.env', @@ -703,9 +1111,21 @@ const STRINGS = { 'docker.stopFailed': '[reactpress] 停止 Docker 失败:', 'docker.starting': '[reactpress] 正在启动 Docker 服务…', 'docker.notRunning': 'Docker 未运行,请先启动 Docker Desktop。', + 'docker.devStartBlocked': + '无法连接 127.0.0.1:{port} 上的 MySQL,且 Docker 未运行。请先启动 Docker Desktop,再执行:reactpress docker up — 或在 .env 中将 DB_* 指向已有 MySQL 实例。', 'docker.started': '[reactpress] Docker 服务已启动。', 'docker.waitingMysql': '[reactpress] 等待 MySQL 就绪…', 'docker.mysqlReady': '[reactpress] MySQL 已就绪。', + 'docker.mysqlExternalReady': '[reactpress] 已使用端口 {port} 上的现有 MySQL。', + 'docker.dbPortInUse': + '[reactpress] 端口 {port} 已被占用 — 跳过 reactpress_db,改用该端口上的现有 MySQL。', + 'docker.dbReuseExisting': + '[reactpress] 端口 {port} 上 MySQL 已可用 — 保留 Docker 数据库容器', + 'docker.dbPortInUseRecycle': + '[reactpress] 端口 {port} 被占用且 MySQL 不可达 — 正在重建 reactpress_db 容器…', + 'docker.dbPortConflict': + '[reactpress] 端口 {port} 上的 MySQL 不可达。请执行:docker start reactpress_cli_db reactpress_db 或 docker compose -f docker-compose.dev.yml up -d db', + 'docker.ensureDevDb': '[reactpress] MySQL 不可达 — 正在启动 Docker 数据库…', 'docker.waitingMysqlProgress': '[reactpress] 等待 MySQL… ({attempts}/{max})', 'docker.mysqlTimeout': '[reactpress] MySQL 在超时时间内未就绪。', 'docker.mysqlNotReady': 'MySQL 未就绪', @@ -753,10 +1173,14 @@ const STRINGS = { 'build.step': '[{current}/{total}] {label}', 'build.stepDone': '[{current}/{total}] {label} ({seconds}s)', 'build.stepSkipped': '已跳过 {label}(当前项目无对应源码包)', + 'build.stepSkippedFresh': '已跳过 {label}(dist 已是最新)', + 'build.stepSkippedReuse': '已跳过 {label} — 复用主题「{id}」已有构建', 'build.done': '构建完成,耗时 {seconds}s', 'build.label.toolkit': 'Toolkit', + 'build.label.plugins': '插件 (plugins)', 'build.label.server': 'API (server)', - 'build.label.client': '前端 (client)', + 'build.label.web': '管理后台 (web)', + 'build.label.theme': '访客主题 (theme)', 'build.label.docs': '文档 (docs)', 'pm2.startFailed': '[reactpress] PM2 启动 API 失败:', 'pm2.exitCode': 'PM2 退出码 {code}', diff --git a/cli/lib/lifecycle.js b/cli/src/lib/lifecycle.ts similarity index 99% rename from cli/lib/lifecycle.js rename to cli/src/lib/lifecycle.ts index e1c2ce7b..03137b08 100644 --- a/cli/lib/lifecycle.js +++ b/cli/src/lib/lifecycle.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const { spawn } = require('child_process'); const ora = require('ora'); const { ensureProjectEnvironment } = require('./bootstrap'); diff --git a/cli/src/lib/nginx.ts b/cli/src/lib/nginx.ts new file mode 100644 index 00000000..3c9cfdbb --- /dev/null +++ b/cli/src/lib/nginx.ts @@ -0,0 +1,661 @@ +// @ts-nocheck +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 { readDevClientApiOrigin } = require('./remote-dev'); + +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}`; +} + +function readDevNginxPorts(projectRoot) { + const { DEV_PORTS, readEnvPort, readVisitorPort } = require('./ports'); + return { + adminPort: readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB), + visitorPort: readVisitorPort(projectRoot), + apiPort: readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API), + }; +} + +function resolveRemoteUpstreamHost(remoteApiOrigin) { + const raw = typeof remoteApiOrigin === 'string' ? remoteApiOrigin.trim() : ''; + if (!raw) return ''; + try { + return new URL(raw).host; + } catch { + return raw.replace(/^https?:\/\//i, '').split('/')[0]; + } +} + +function renderApiProxyBlock(remoteApiOrigin, apiPort) { + if (remoteApiOrigin) { + const upstreamHost = resolveRemoteUpstreamHost(remoteApiOrigin); + return ` # REST API (remote upstream) + location /api { + proxy_pass ${remoteApiOrigin}; + proxy_ssl_server_name on; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host ${upstreamHost}; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + proxy_redirect off; + }`; + } + + return ` # REST API (Nest on host :${apiPort}, keep /api prefix) + location /api { + proxy_pass http://host.docker.internal:${apiPort}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + proxy_redirect off; + }`; +} + +function renderPublicUploadsProxyBlock(remoteApiOrigin, apiPort) { + const proxyTarget = remoteApiOrigin + ? remoteApiOrigin.replace(/\/api\/?$/, '') + : `http://host.docker.internal:${apiPort}`; + + return ` # Uploaded media (API static /public) + location /public/ { + proxy_pass ${proxyTarget}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + proxy_connect_timeout 300; + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + access_log off; + }`; +} + +function renderDevNginxConfig({ adminPort, visitorPort, apiPort, clientApiOrigin = null }) { + const apiBlock = renderApiProxyBlock(clientApiOrigin, apiPort); + const publicBlock = renderPublicUploadsProxyBlock(clientApiOrigin, apiPort); + return `server { + listen 80; + server_name localhost; + charset utf-8; + + # Visitor site (active theme Next.js on host :${visitorPort}) + location / { + proxy_pass http://host.docker.internal:${visitorPort}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + proxy_redirect off; + } + + # Admin SPA (Vite base /admin/, host :${adminPort}) + location /admin/ { + proxy_pass http://host.docker.internal:${adminPort}/admin/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } + + location = /admin { + return 301 /admin/; + } + +${publicBlock} + +${apiBlock} + + # Next.js dev/HMR rewrites chunks frequently — never cache /_next (prod nginx keeps long cache). + location /_next/ { + proxy_pass http://host.docker.internal:${visitorPort}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + } + + location /health { + access_log off; + return 200 "healthy\\n"; + add_header Content-Type text/plain; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} +`; +} + +function isDevNginxConfigStale(projectRoot, configPath) { + const { adminPort, visitorPort, apiPort } = readDevNginxPorts(projectRoot); + const clientApiOrigin = readDevClientApiOrigin(projectRoot); + let content; + try { + content = fs.readFileSync(configPath, 'utf8'); + } catch { + return true; + } + if (content.includes(':5173')) return true; + if (!content.includes(`host.docker.internal:${adminPort}/admin/`)) return true; + if (!content.includes(`host.docker.internal:${visitorPort}`)) return true; + if (clientApiOrigin) { + if (!content.includes(`proxy_pass ${clientApiOrigin}`)) return true; + if (content.includes(`host.docker.internal:${apiPort}`)) return true; + } else if (!content.includes(`host.docker.internal:${apiPort}`)) { + return true; + } + // Dev must not long-cache Next chunks (breaks client-side nav after on-demand compile). + if (content.includes('expires 1y') && content.includes('/_next/')) return true; + if (!content.includes('location /public/')) return true; + return false; +} + +function isProdNginxConfigStale(projectRoot, configPath) { + const { visitorPort, apiPort } = readDevNginxPorts(projectRoot); + let content = ''; + try { + content = fs.readFileSync(configPath, 'utf8'); + } catch { + return true; + } + if (content.includes('host.docker.internal:13001') || content.includes('host.docker.internal:13002')) { + return true; + } + if (!content.includes(`host.docker.internal:${visitorPort}`)) return true; + if (!content.includes(`host.docker.internal:${apiPort}`)) return true; + if (!content.includes('location /public/')) return true; + return false; +} + +function renderProdNginxConfig(projectRoot) { + const templatePath = bundledTemplatePath('prod'); + const { visitorPort, apiPort } = readDevNginxPorts(projectRoot); + let content = fs.readFileSync(templatePath, 'utf8'); + content = content.replace(/host\.docker\.internal:3001/g, `host.docker.internal:${visitorPort}`); + content = content.replace(/host\.docker\.internal:3002/g, `host.docker.internal:${apiPort}`); + return content; +} + +function writeProdNginxConfig(projectRoot) { + const configPath = resolveNginxConfigPath(projectRoot, 'prod'); + const content = renderProdNginxConfig(projectRoot); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + const existed = fs.existsSync(configPath); + const previous = existed ? fs.readFileSync(configPath, 'utf8') : ''; + fs.writeFileSync(configPath, content, 'utf8'); + return { + configPath, + changed: content !== previous, + created: !existed, + mode: 'prod', + }; +} + +function writeDevNginxConfig(projectRoot) { + const configPath = resolveNginxConfigPath(projectRoot, 'dev'); + const ports = readDevNginxPorts(projectRoot); + const clientApiOrigin = readDevClientApiOrigin(projectRoot); + const content = renderDevNginxConfig({ ...ports, clientApiOrigin }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + const existed = fs.existsSync(configPath); + const previous = existed ? fs.readFileSync(configPath, 'utf8') : ''; + fs.writeFileSync(configPath, content, 'utf8'); + return { + configPath, + changed: content !== previous, + created: !existed, + mode: 'dev', + }; +} + +/** + * Write default nginx config from CLI templates when missing (or when force). + * + * @returns {{ configPath: string, created: boolean, mode: 'dev' | 'prod', changed?: boolean }} + */ +function ensureNginxConfig(projectRoot, options = {}) { + const mode = resolveNginxMode(options); + const configPath = resolveNginxConfigPath(projectRoot, mode); + + if (mode === 'dev') { + if (options.force || !fs.existsSync(configPath) || isDevNginxConfigStale(projectRoot, configPath)) { + const result = writeDevNginxConfig(projectRoot); + return { configPath: result.configPath, created: result.created || result.changed, changed: result.changed, mode }; + } + return { configPath, created: false, changed: false, mode }; + } + + if (mode === 'prod') { + if (options.force || !fs.existsSync(configPath) || isProdNginxConfigStale(projectRoot, configPath)) { + const result = writeProdNginxConfig(projectRoot); + return { + configPath: result.configPath, + created: result.created || result.changed, + changed: result.changed, + mode, + }; + } + return { configPath, created: false, changed: false, 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 composeArgs = ['up', '-d', '--no-deps', '--remove-orphans', ctx.service]; + const result = runComposeOnContext(ctx, composeArgs, { + stdio: options.quiet ? 'ignore' : 'inherit', + }); + if (result.status !== 0) { + throw new Error(t('nginx.startFailed')); + } + } else { + startNginxContainer(configPath, port); + } + + if (!options.quiet) { + 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'), + }; +} + +/** + * Start dev reverse proxy (Docker). Returns false when skipped or failed (non-fatal). + * @returns {Promise} + */ +async function startDevNginx(projectRoot) { + if (process.env.REACTPRESS_SKIP_NGINX === '1') { + return false; + } + if (!isDockerRunning()) { + console.warn(t('dev.nginxSkippedDocker')); + return false; + } + try { + const { changed } = writeDevNginxConfig(projectRoot); + nginxUp(projectRoot, { quiet: true }); + if (changed && isNginxContainerRunning()) { + try { + nginxReload(); + } catch { + nginxRestart(projectRoot, { quiet: true }); + } + } + const probeMs = Math.max( + 1000, + parseInt(process.env.REACTPRESS_NGINX_PROBE_MS || '4000', 10) || 4000, + ); + const healthy = await probeNginxHealth(projectRoot, probeMs); + if (!healthy) { + console.warn(t('dev.nginxSlow', { url: nginxEntryUrl(projectRoot) })); + } + return true; + } catch (err) { + console.warn(t('dev.nginxStartFailed', { message: err.message || String(err) })); + return false; + } +} + +function stopDevNginx(projectRoot) { + try { + nginxDown(projectRoot); + } catch { + stopNginxContainer(); + } +} + +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, + renderDevNginxConfig, + renderProdNginxConfig, + nginxEntryUrl, + resolveNginxPort, + isNginxContainerRunning, + probeNginxHealth, + checkNginx, + runNginxCommand, + startDevNginx, + stopDevNginx, +}; diff --git a/cli/lib/paths.js b/cli/src/lib/paths.ts similarity index 61% rename from cli/lib/paths.js rename to cli/src/lib/paths.ts index 42351d46..76612677 100644 --- a/cli/lib/paths.js +++ b/cli/src/lib/paths.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const fs = require('fs'); const path = require('path'); const { ensureOriginalCwd, getMonorepoRoot } = require('./root'); @@ -17,7 +18,10 @@ function hasMonorepoServerSource(projectRoot) { } function getCliPackageRoot() { - const ownRoot = path.join(__dirname, '..'); + const ownRoot = path.join(__dirname, '..', '..'); + if (fs.existsSync(path.join(ownRoot, 'out', 'bin', 'reactpress.js'))) { + return ownRoot; + } if (fs.existsSync(path.join(ownRoot, 'dist', 'index.js'))) { return ownRoot; } @@ -30,6 +34,16 @@ function getCliPackageRoot() { } } +function getCliVersion() { + try { + const pkgPath = path.join(getCliPackageRoot(), 'package.json'); + const pkg = require(pkgPath); + return typeof pkg.version === 'string' ? pkg.version : 'dev'; + } catch { + return 'dev'; + } +} + function getBundledServerDir() { return path.join(getCliPackageRoot(), 'server'); } @@ -68,21 +82,36 @@ function canStartLocalApi(projectRoot) { ); } -function getClientBin(projectRoot) { - const binPath = path.join( - resolveProjectRoot(projectRoot), - 'client', - 'bin', - 'reactpress-client.js' - ); - if (!fs.existsSync(binPath)) { +function getThemeBin(projectRoot) { + const root = resolveProjectRoot(projectRoot); + const { readActiveThemeManifest, resolveThemeDirectory } = require('./theme-runtime'); + const { activeTheme } = readActiveThemeManifest(root); + const themeDir = resolveThemeDirectory(root, activeTheme); + if (!themeDir) { 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.` + `Active theme not found: ${activeTheme}. Activate a theme in Admin → Appearance.` ); - err.code = 'REACTPRESS_CLIENT_NOT_FOUND'; + err.code = 'REACTPRESS_THEME_NOT_FOUND'; throw err; } - return binPath; + const binPath = path.join(themeDir, 'bin', 'reactpress-client.js'); + if (fs.existsSync(binPath)) { + return binPath; + } + const genericBin = path.join(getCliPackageRoot(), 'bin', 'reactpress-theme-client.js'); + if (fs.existsSync(genericBin)) { + return genericBin; + } + const err = new Error( + `Theme entry not found: ${binPath}. Run from a ReactPress project with an installed theme.` + ); + err.code = 'REACTPRESS_THEME_BIN_NOT_FOUND'; + throw err; +} + +/** @deprecated Use getThemeBin */ +function getClientBin(projectRoot) { + return getThemeBin(projectRoot); } function getPidFile(projectRoot) { @@ -98,11 +127,13 @@ module.exports = { isUsingMonorepoServer, canStartLocalApi, getCliPackageRoot, + getCliVersion, getBundledServerDir, getServerDir, getServerBin, getSwaggerPath, getServerMain, + getThemeBin, getClientBin, getPidFile, }; diff --git a/cli/src/lib/plugin-build.ts b/cli/src/lib/plugin-build.ts new file mode 100644 index 00000000..322b53a4 --- /dev/null +++ b/cli/src/lib/plugin-build.ts @@ -0,0 +1,97 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function readLocalPluginIds(projectRoot) { + const pkgPath = path.join(projectRoot, 'plugins', 'package.json'); + if (!fs.existsSync(pkgPath)) return []; + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const local = pkg?.reactpress?.local; + return Array.isArray(local) ? local.filter((id) => typeof id === 'string') : []; + } catch { + return []; + } +} + +function readPluginManifest(pluginDir) { + const manifestPath = path.join(pluginDir, 'plugin.json'); + if (!fs.existsSync(manifestPath)) return null; + try { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + return null; + } +} + +function newestMtime(root, relDir) { + const dir = path.join(root, relDir); + if (!fs.existsSync(dir)) return 0; + let max = 0; + const walk = (current) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) max = Math.max(max, fs.statSync(full).mtimeMs); + } + }; + walk(dir); + return max; +} + +function shouldBuildPlugin(pluginDir) { + const pkgPath = path.join(pluginDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return false; + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + } catch { + return false; + } + if (!pkg.scripts?.build) return false; + + const manifest = readPluginManifest(pluginDir); + const moduleRel = manifest?.server?.module; + if (!moduleRel || typeof moduleRel !== 'string') return false; + + const entry = path.join(pluginDir, moduleRel.replace(/^\.\//, '')); + if (!fs.existsSync(entry)) return true; + + const srcMtime = newestMtime(pluginDir, 'src'); + const entryMtime = fs.statSync(entry).mtimeMs; + return srcMtime > entryMtime; +} + +function buildPlugin(pluginDir, { quiet = false } = {}) { + const name = path.basename(pluginDir); + if (!quiet) { + console.log(`[reactpress] Building plugin "${name}"…`); + } + const result = spawnSync('pnpm', ['run', 'build'], { + cwd: pluginDir, + stdio: quiet ? 'pipe' : 'inherit', + env: process.env, + }); + if (result.status !== 0) { + const stderr = result.stderr?.toString?.() ?? ''; + throw new Error(`Plugin "${name}" build failed${stderr ? `: ${stderr.trim()}` : ''}`); + } +} + +function buildLocalPlugins(projectRoot, options = {}) { + const ids = readLocalPluginIds(projectRoot); + for (const id of ids) { + const pluginDir = path.join(projectRoot, 'plugins', id); + if (!fs.existsSync(pluginDir)) continue; + if (!shouldBuildPlugin(pluginDir)) continue; + buildPlugin(pluginDir, options); + } +} + +module.exports = { + readLocalPluginIds, + shouldBuildPlugin, + buildPlugin, + buildLocalPlugins, +}; diff --git a/cli/src/lib/plugin-cli.ts b/cli/src/lib/plugin-cli.ts new file mode 100644 index 00000000..9ed8d4d2 --- /dev/null +++ b/cli/src/lib/plugin-cli.ts @@ -0,0 +1,144 @@ +// @ts-nocheck +const chalk = require('chalk'); +const fs = require('fs'); +const path = require('path'); + +const PLUGIN_RUNTIME_REL = path.join('.reactpress', 'plugins'); +const PLUGIN_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const COPY_SKIP_NAMES = new Set([ + 'node_modules', + '.git', + 'dist', + '.turbo', + 'coverage', + '.reactpress', + '.cache', + 'package-lock.json', +]); + +function isValidPluginId(id) { + return typeof id === 'string' && PLUGIN_ID_RE.test(id) && id.length <= 64; +} + +function readPluginsPackageMeta(projectRoot) { + const pkgPath = path.join(projectRoot, 'plugins', 'package.json'); + if (!fs.existsSync(pkgPath)) return { local: [] }; + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const local = Array.isArray(pkg.reactpress?.local) + ? pkg.reactpress.local.filter((id) => typeof id === 'string') + : []; + return { local }; + } catch { + return { local: [] }; + } +} + +function readPluginManifest(pluginDir) { + const manifestPath = path.join(pluginDir, 'plugin.json'); + if (!fs.existsSync(manifestPath)) return null; + try { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + return null; + } +} + +function copyDir(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (COPY_SKIP_NAMES.has(entry.name)) continue; + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isSymbolicLink()) { + continue; + } else if (entry.isDirectory()) { + copyDir(from, to); + } else if (entry.isFile()) { + fs.copyFileSync(from, to); + } + } +} + +function removeDir(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) removeDir(full); + else fs.unlinkSync(full); + } + fs.rmdirSync(dir); +} + +function materializeRuntimePlugin(projectRoot, templatePath, targetDir) { + const forceCopy = + process.env.REACTPRESS_PLUGIN_RUNTIME_COPY === '1' || process.env.NODE_ENV === 'production'; + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + if (fs.existsSync(targetDir)) removeDir(targetDir); + if (!forceCopy) { + const linkTarget = path.relative(path.dirname(targetDir), templatePath); + fs.symlinkSync(linkTarget, targetDir, 'dir'); + return; + } + copyDir(templatePath, targetDir); +} + +function installLocalPlugin(projectRoot, id) { + if (!isValidPluginId(id)) { + throw new Error(`Invalid plugin id "${id}"`); + } + const templatePath = path.join(projectRoot, 'plugins', id); + if (!fs.existsSync(templatePath)) { + throw new Error(`Plugin template "${id}" not found under plugins/`); + } + const manifest = readPluginManifest(templatePath); + if (!manifest?.id) { + throw new Error(`Plugin "${id}" has invalid plugin.json`); + } + const targetDir = path.join(projectRoot, PLUGIN_RUNTIME_REL, id); + materializeRuntimePlugin(projectRoot, templatePath, targetDir); + return { pluginId: manifest.id, name: manifest.name, pluginDirRel: PLUGIN_RUNTIME_REL }; +} + +function listAvailablePluginIds(projectRoot) { + const { local } = readPluginsPackageMeta(projectRoot); + const runtimeDir = path.join(projectRoot, PLUGIN_RUNTIME_REL); + const installed = fs.existsSync(runtimeDir) + ? fs + .readdirSync(runtimeDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + : []; + return [...new Set([...local, ...installed])]; +} + +function runPluginInstall(projectRoot, id) { + const result = installLocalPlugin(projectRoot, id); + console.log( + chalk.green('[reactpress]'), + `Installed plugin "${result.name}" (${result.pluginId}) → ${result.pluginDirRel}/${result.pluginId}/`, + ); + console.log(chalk.gray(`Activate via admin /plugins or: reactpress plugin activate ${result.pluginId}`)); + return result; +} + +function runPluginList(projectRoot) { + const ids = listAvailablePluginIds(projectRoot); + if (!ids.length) { + console.log('No plugins registered.'); + return; + } + console.log('Available plugins:'); + for (const id of ids.sort()) { + const runtime = path.join(projectRoot, PLUGIN_RUNTIME_REL, id); + const installed = fs.existsSync(runtime); + console.log(` - ${id}${installed ? ' (installed)' : ''}`); + } +} + +module.exports = { + installLocalPlugin, + listAvailablePluginIds, + runPluginInstall, + runPluginList, +}; diff --git a/cli/lib/pm2.js b/cli/src/lib/pm2.ts similarity index 98% rename from cli/lib/pm2.js rename to cli/src/lib/pm2.ts index 1473b061..01e8c7d9 100644 --- a/cli/lib/pm2.js +++ b/cli/src/lib/pm2.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const { spawn } = require('child_process'); const { getServerBin, getServerDir } = require('./paths'); const { ensureOriginalCwd } = require('./root'); diff --git a/cli/src/lib/ports.ts b/cli/src/lib/ports.ts new file mode 100644 index 00000000..23d9fe42 --- /dev/null +++ b/cli/src/lib/ports.ts @@ -0,0 +1,561 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const net = require('net'); +const { spawnSync } = require('child_process'); + +/** + * Local dev port map (keep in sync with `.env`, README, and `theme.service.ts` defaults). + * + * | Port | Service | + * |------|---------| + * | 3000 | Admin SPA — Vite (`web/`, `WEB_ADMIN_URL`) | + * | 3001 | Visitor site — active theme Next.js (`CLIENT_SITE_URL`) | + * | 3002 | API server (`SERVER_PORT`) | + * | 3003 | Admin theme preview only (`REACTPRESS_PREVIEW_PORT`, `preview-theme.json`) | + * | 3306 | MySQL (`DB_PORT`) | + */ +const DEV_PORTS = { + ADMIN_WEB: 3000, + VISITOR: 3001, + API: 3002, + THEME_PREVIEW: 3003, + MYSQL: 3306, +}; + +/** Offset applied per `REACTPRESS_INSTANCE` (instance 1 → admin :3010, api :3012, …). */ +const INSTANCE_PORT_STEP = 10; + +function readProcessEnvPort(key) { + const raw = process.env[key]?.trim(); + if (!raw) return null; + const n = parseInt(raw.replace(/^['"]|['"]$/g, ''), 10); + return Number.isInteger(n) && n > 0 ? n : null; +} + +function readInstanceIndex() { + const raw = process.env.REACTPRESS_INSTANCE ?? process.env.REACTPRESS_DEV_INSTANCE ?? '0'; + const n = parseInt(String(raw).trim(), 10); + if (!Number.isInteger(n) || n < 0 || n > 99) return 0; + return n; +} + +function instancePortOffset() { + return readInstanceIndex() * INSTANCE_PORT_STEP; +} + +/** + * Resolve a dev-stack port: process.env override → `.env` (instance 0 only) → default + instance offset. + */ +function resolveStackPort(projectRoot, envKey, defaultBase) { + const fromProcess = readProcessEnvPort(envKey); + if (fromProcess) return fromProcess; + + const instance = readInstanceIndex(); + const shiftedDefault = defaultBase + instance * INSTANCE_PORT_STEP; + + if (instance === 0) { + try { + const content = fs.readFileSync(path.join(projectRoot, '.env'), 'utf8'); + const match = content.match(new RegExp(`^${envKey}=(.+)$`, 'm')); + if (match) { + const n = parseInt(match[1].trim().replace(/^['"]|['"]$/g, ''), 10); + if (Number.isInteger(n) && n > 0) return n; + } + } catch { + // ignore + } + } + + return shiftedDefault; +} + +function resolveDevStackPorts(projectRoot) { + const offset = instancePortOffset(); + const visitorFromProcess = readProcessEnvPort('CLIENT_PORT'); + let visitor = DEV_PORTS.VISITOR + offset; + if (visitorFromProcess) { + visitor = visitorFromProcess; + } else if (readInstanceIndex() === 0) { + visitor = readVisitorPort(projectRoot); + } + + return { + admin: resolveStackPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB), + visitor, + api: resolveStackPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API), + preview: resolveStackPort(projectRoot, 'REACTPRESS_PREVIEW_PORT', DEV_PORTS.THEME_PREVIEW), + }; +} + +/** Push resolved ports into `process.env` so child processes share one port map. */ +function applyDevStackPortsToEnv(ports) { + process.env.WEB_ADMIN_PORT = String(ports.admin); + process.env.SERVER_PORT = String(ports.api); + process.env.REACTPRESS_LOCAL_API_PORT = String(ports.api); + process.env.CLIENT_PORT = String(ports.visitor); + process.env.REACTPRESS_PREVIEW_PORT = String(ports.preview); + process.env.CLIENT_SITE_URL = `http://localhost:${ports.visitor}`; + process.env.SERVER_SITE_URL = `http://127.0.0.1:${ports.api}`; + if (!process.env.VITE_DEV_API_PROXY_TARGET?.trim()) { + process.env.VITE_DEV_API_PROXY_TARGET = `http://127.0.0.1:${ports.api}`; + } +} + +function devSessionSuffix() { + const instance = readInstanceIndex(); + return instance > 0 ? `-instance-${instance}` : ''; +} + +/** Ports theme `next dev` must not bind to (reserved for other services). 3003 is allowed — preview theme. */ +/** Never kill listeners on DB / infra ports during dev port cleanup. */ +const PROTECTED_KILL_PORTS = new Set([3306, 3307, 5432, 6379]); + +const BLOCKED_THEME_DEV_PORTS = new Set([ + 22, + 80, + 443, + 3000, + 3002, + 5173, + 5432, + 6379, + 8080, + 8443, + 3306, + 3307, +]); + +function readEnvPort(projectRoot, key, fallback) { + const fromProcess = readProcessEnvPort(key); + if (fromProcess) return fromProcess; + try { + const content = fs.readFileSync(path.join(projectRoot, '.env'), 'utf8'); + const match = content.match(new RegExp(`^${key}=(.+)$`, 'm')); + if (match) { + const n = parseInt(match[1].trim().replace(/^['"]|['"]$/g, ''), 10); + if (Number.isInteger(n) && n > 0) return n; + } + } catch { + // ignore + } + return fallback; +} + +function readVisitorPort(projectRoot) { + const fromEnv = readEnvPort(projectRoot, 'CLIENT_PORT', null); + if (fromEnv) return fromEnv; + try { + const content = fs.readFileSync(path.join(projectRoot, '.env'), 'utf8'); + const match = content.match(/^CLIENT_SITE_URL=(.+)$/m); + if (match) { + const url = new URL(match[1].trim().replace(/^['"]|['"]$/g, '')); + const n = parseInt(url.port || String(DEV_PORTS.VISITOR), 10); + if (Number.isInteger(n) && n > 0) return n; + } + } catch { + // ignore + } + return DEV_PORTS.VISITOR; +} + +function isPortListening(port) { + const n = parseInt(port, 10); + if (!Number.isInteger(n) || n < 1) return false; + const result = spawnSync('lsof', [`-tiTCP:${n}`, '-sTCP:LISTEN'], { encoding: 'utf8' }); + return result.status === 0 && Boolean(result.stdout?.trim()); +} + +/** Kill processes listening on `port`. Returns PIDs signalled. */ +function killPortListeners(port, signal = 'KILL') { + const n = parseInt(port, 10); + if (!Number.isInteger(n) || n < 1) return []; + + const flag = signal === 'TERM' ? '-TERM' : '-9'; + const result = spawnSync('lsof', [`-tiTCP:${n}`, '-sTCP:LISTEN'], { encoding: 'utf8' }); + if (result.status !== 0 || !result.stdout?.trim()) return []; + + const pids = []; + for (const pid of result.stdout.trim().split(/\s+/)) { + if (!pid) continue; + spawnSync('kill', [flag, pid], { stdio: 'ignore' }); + pids.push(pid); + } + return pids; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getListenerPids(port) { + const n = parseInt(port, 10); + if (!Number.isInteger(n) || n < 1) return []; + const result = spawnSync('lsof', [`-tiTCP:${n}`, '-sTCP:LISTEN'], { encoding: 'utf8' }); + if (result.status !== 0 || !result.stdout?.trim()) return []; + return result.stdout.trim().split(/\s+/).filter(Boolean); +} + +function collectDescendantPids(rootPid) { + const root = parseInt(rootPid, 10); + if (!Number.isFinite(root) || root <= 0) return []; + + const out = []; + const queue = [String(root)]; + const seen = new Set(); + + while (queue.length) { + const pid = queue.shift(); + if (!pid || seen.has(pid)) continue; + seen.add(pid); + + const children = spawnSync('pgrep', ['-P', pid], { encoding: 'utf8' }); + if (children.status !== 0 || !children.stdout?.trim()) continue; + + for (const child of children.stdout.trim().split(/\s+/)) { + if (!child || seen.has(child)) continue; + out.push(child); + queue.push(child); + } + } + return out; +} + +function collectAncestorPids(pid, maxDepth = 10) { + const out = []; + let current = parseInt(pid, 10); + for (let i = 0; i < maxDepth; i += 1) { + if (!Number.isFinite(current) || current <= 1) break; + const ppidRes = spawnSync('ps', ['-o', 'ppid=', '-p', String(current)], { encoding: 'utf8' }); + const parent = parseInt(ppidRes.stdout?.trim(), 10); + if (!Number.isFinite(parent) || parent <= 1) break; + out.push(String(parent)); + current = parent; + } + return out; +} + +function getProcessCommand(pid) { + const res = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }); + return (res.stdout || '').trim(); +} + +function isDockerInfrastructureProcess(pid) { + const cmd = getProcessCommand(pid).toLowerCase(); + if (!cmd) return false; + return ( + cmd.includes('com.docker') || + cmd.includes('docker desktop') || + cmd.includes('dockerd') || + cmd.includes('vpnkit') || + cmd.includes('containerd') || + (cmd.includes('docker') && cmd.includes('proxy')) + ); +} + +/** Nest / pnpm API dev parent — killing only the listener leaves watch respawning children. */ +function isReactPressApiProcess(pid, projectRoot) { + if (isDockerInfrastructureProcess(pid)) return false; + const cmd = getProcessCommand(pid); + if (!cmd) return false; + if ( + /nest start|api-dev-runner|server\/dist\/starter|@nestjs\/cli|reactpress\.js/.test(cmd) + ) { + return true; + } + const serverDir = path.join(path.resolve(projectRoot), 'server'); + const res = spawnSync('lsof', ['-p', String(pid)], { encoding: 'utf8' }); + if (res.status !== 0) return false; + return res.stdout.split('\n').some((line) => { + if (!line.includes(' cwd ')) return false; + const parts = line.trim().split(/\s+/); + const cwd = parts[parts.length - 1]; + return cwd === serverDir || cwd.startsWith(`${serverDir}${path.sep}`); + }); +} + +function signalPidSet(pids, signal) { + const flag = signal === 'TERM' ? '-TERM' : '-9'; + for (const pid of pids) { + if (!pid || pid === String(process.pid)) continue; + spawnSync('kill', [flag, pid], { stdio: 'ignore' }); + } +} + +function collectApiPortProcessTree(projectRoot, port) { + const toSignal = new Set(); + for (const pid of getListenerPids(port)) { + if (isDockerInfrastructureProcess(pid)) continue; + toSignal.add(pid); + for (const child of collectDescendantPids(pid)) { + if (!isDockerInfrastructureProcess(child)) toSignal.add(child); + } + for (const ancestor of collectAncestorPids(pid)) { + if (isReactPressApiProcess(ancestor, projectRoot)) toSignal.add(ancestor); + } + } + return toSignal; +} + +/** Stop Nest / api-dev listeners on `port` (TERM then KILL). */ +async function stopApiPortListeners(projectRoot, port, { label = 'API' } = {}) { + const n = parseInt(port, 10); + if (!Number.isInteger(n) || n < 1 || !isPortListening(n)) return true; + + console.warn( + `[reactpress] Port ${n} (${label}) is busy — stopping existing API processes…`, + ); + + const toSignal = collectApiPortProcessTree(projectRoot, n); + signalPidSet(toSignal, 'TERM'); + await sleep(600); + + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (!isPortListening(n)) return true; + for (const pid of getListenerPids(n)) { + toSignal.add(pid); + for (const child of collectDescendantPids(pid)) toSignal.add(child); + } + signalPidSet(toSignal, 'KILL'); + await sleep(500); + } + + if (isPortListening(n)) { + console.warn( + `[reactpress] Port ${n} (${label}) still in use — try: lsof -tiTCP:${n} -sTCP:LISTEN | xargs kill -9`, + ); + return false; + } + return true; +} + +/** + * Free API port: skip when health check passes; otherwise stop listener + nest watch tree. + * @returns {{ reused: boolean, port: number }} + */ +async function ensureApiPortFree(projectRoot, { allowReuse = true } = {}) { + const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + const { getHealthUrl, checkHealth } = require('./http'); + if (allowReuse) { + const health = await checkHealth(getHealthUrl(projectRoot), 1500); + if (health.ok) { + return { reused: true, port }; + } + } + + if (!isPortListening(port)) { + return { reused: false, port }; + } + + await stopApiPortListeners(projectRoot, port); + return { reused: false, port }; +} + +/** Always stop API listeners — used when replacing a prior `reactpress dev` session. */ +async function forceReleaseApiPort(projectRoot) { + const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + if (!isPortListening(port)) return port; + console.warn(`[reactpress] Releasing API port ${port} for new dev session…`); + await stopApiPortListeners(projectRoot, port); + return port; +} + +/** Stop orphaned dev-stack listeners after session takeover or crash. */ +async function forceReleaseDevStackPorts(projectRoot) { + await forceReleaseApiPort(projectRoot); + + const previewPort = readEnvPort( + projectRoot, + 'REACTPRESS_PREVIEW_PORT', + DEV_PORTS.THEME_PREVIEW, + ); + const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + const visitorPort = readVisitorPort(projectRoot); + + for (const [port, label] of [ + [adminPort, 'admin'], + [visitorPort, 'visitor site'], + [previewPort, 'theme preview'], + ]) { + await ensurePortFree(port, { label }); + } +} + +/** + * Free only unhealthy or non-API listeners — keeps a healthy API to skip Nest cold compile. + */ +async function releaseStaleDevStackPorts(projectRoot) { + if (process.env.REACTPRESS_FORCE_PORT_RESET === '1') { + await forceReleaseDevStackPorts(projectRoot); + return; + } + + const { getHealthUrl, checkHealth } = require('./http'); + const apiPort = resolveStackPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + + if (isPortListening(apiPort)) { + const healthUrl = `http://127.0.0.1:${apiPort}/api/health`; + const health = await checkHealth(healthUrl, 2000); + if (health.ok) { + const { logDevDetail } = require('./dev-log'); + logDevDetail('dev.apiKept', { port: apiPort }); + } else { + await stopApiPortListeners(projectRoot, apiPort); + } + } + + const previewPort = readEnvPort( + projectRoot, + 'REACTPRESS_PREVIEW_PORT', + DEV_PORTS.THEME_PREVIEW, + ); + const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + const visitorPort = readVisitorPort(projectRoot); + + for (const [port, label] of [ + [adminPort, 'admin'], + [visitorPort, 'visitor site'], + [previewPort, 'theme preview'], + ]) { + if (isPortListening(port)) { + await ensurePortFree(port, { label, maxWaitMs: 5000 }); + } + } +} + +/** + * If `port` is in use, terminate listeners (TERM then KILL) and wait until free. + */ +async function ensurePortFree(port, { label = 'service', maxWaitMs = 8000 } = {}) { + const n = parseInt(port, 10); + if (!Number.isInteger(n) || n < 1) return false; + + if (PROTECTED_KILL_PORTS.has(n)) { + if (isPortListening(n)) { + console.warn(`[reactpress] Port ${n} (${label}) is protected — leaving existing listener`); + } + return true; + } + + if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') { + const desktopApi = process.env.REACTPRESS_DESKTOP_LOCAL_API?.trim(); + if (desktopApi) { + try { + const desktopPort = parseInt(new URL(desktopApi).port || String(DEV_PORTS.API), 10); + if (Number.isInteger(desktopPort) && desktopPort === n) { + if (isPortListening(n)) { + console.warn( + `[reactpress] Port ${n} (${label}) is the embedded local API — leaving listener`, + ); + } + return true; + } + } catch { + // ignore malformed REACTPRESS_DESKTOP_LOCAL_API + } + } + } + + if (!isPortListening(n)) return true; + + console.warn(`[reactpress] Port ${n} (${label}) is busy — stopping existing listener…`); + killPortListeners(n, 'TERM'); + await sleep(500); + + const deadline = Date.now() + maxWaitMs; + while (Date.now() < deadline) { + if (!isPortListening(n)) return true; + killPortListeners(n, 'KILL'); + await sleep(500); + } + + if (isPortListening(n)) { + console.warn( + `[reactpress] Port ${n} (${label}) still in use — try: lsof -tiTCP:${n} -sTCP:LISTEN | xargs kill -9`, + ); + return false; + } + return true; +} + +function isPortBindable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); +} + +/** + * Pick an API port: reuse healthy listener, else preferred, else next free slot in range. + * @returns {{ port: number, reused: boolean, shifted?: boolean }} + */ +async function resolveApiPortForBind(projectRoot, { preferred } = {}) { + const start = preferred ?? resolveStackPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API); + const healthUrl = `http://127.0.0.1:${start}/api/health`; + const { checkHealth } = require('./http'); + + const health = await checkHealth(healthUrl, 1500); + if (health.ok) { + return { port: start, reused: true }; + } + + if (!isPortListening(start)) { + return { port: start, reused: false }; + } + + for (let port = start; port < start + 20; port += 1) { + if (!isPortListening(port) && (await isPortBindable(port))) { + console.warn(`[reactpress] API port ${start} is busy — using :${port}`); + return { port, reused: false, shifted: true }; + } + } + + throw new Error(`No available API port near ${start} (set SERVER_PORT or REACTPRESS_INSTANCE)`); +} + +/** Free theme/admin ports (API handled in {@link forceReleaseDevStackPorts} / {@link spawnApi}). */ +async function ensureDevStackPorts(projectRoot) { + const previewPort = readEnvPort( + projectRoot, + 'REACTPRESS_PREVIEW_PORT', + DEV_PORTS.THEME_PREVIEW, + ); + const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB); + const visitorPort = readVisitorPort(projectRoot); + + for (const [port, label] of [ + [adminPort, 'admin'], + [visitorPort, 'visitor site'], + [previewPort, 'theme preview'], + ]) { + await ensurePortFree(port, { label }); + } +} + +module.exports = { + DEV_PORTS, + INSTANCE_PORT_STEP, + BLOCKED_THEME_DEV_PORTS, + readEnvPort, + readVisitorPort, + readInstanceIndex, + instancePortOffset, + resolveStackPort, + resolveDevStackPorts, + applyDevStackPortsToEnv, + resolveApiPortForBind, + devSessionSuffix, + isPortListening, + killPortListeners, + ensurePortFree, + ensureApiPortFree, + forceReleaseApiPort, + forceReleaseDevStackPorts, + releaseStaleDevStackPorts, + ensureDevStackPorts, +}; diff --git a/cli/lib/process.js b/cli/src/lib/process.ts similarity index 98% rename from cli/lib/process.js rename to cli/src/lib/process.ts index d0f3ced1..95fd7731 100644 --- a/cli/lib/process.js +++ b/cli/src/lib/process.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const fs = require('fs'); const path = require('path'); const { getPidFile } = require('./paths'); diff --git a/cli/src/lib/prod-memory.ts b/cli/src/lib/prod-memory.ts new file mode 100644 index 00000000..3c50572d --- /dev/null +++ b/cli/src/lib/prod-memory.ts @@ -0,0 +1,55 @@ +// @ts-nocheck +/** + * Optional production memory tuning — only active when REACTPRESS_LOW_MEM=1. + * Default deploy does not set this; all limits stay at normal Node/PM2 defaults. + */ + +function isLowMemMode() { + return process.env.REACTPRESS_LOW_MEM === '1'; +} + +/** Apply heap cap only when low-mem or explicitly configured. */ +function resolveBuildMaxOldSpaceMb() { + const fromEnv = parseInt(process.env.REACTPRESS_BUILD_MAX_OLD_SPACE_MB || '', 10); + if (Number.isInteger(fromEnv) && fromEnv >= 256) return fromEnv; + if (isLowMemMode()) return 768; + return null; +} + +function resolveBuildNodeEnv(baseEnv = process.env) { + const mb = resolveBuildMaxOldSpaceMb(); + if (!mb) return { ...baseEnv }; + const flag = `--max-old-space-size=${mb}`; + const existing = baseEnv.NODE_OPTIONS || ''; + if (existing.includes('max-old-space-size')) { + return { ...baseEnv }; + } + return { + ...baseEnv, + NODE_OPTIONS: existing ? `${existing} ${flag}` : flag, + }; +} + +function getPm2ServerMemoryRestart() { + if (process.env.REACTPRESS_PM2_SERVER_MEMORY) { + return process.env.REACTPRESS_PM2_SERVER_MEMORY; + } + if (isLowMemMode()) return '384M'; + return '1G'; +} + +function getPm2ClientMemoryRestart() { + if (process.env.REACTPRESS_PM2_CLIENT_MEMORY) { + return process.env.REACTPRESS_PM2_CLIENT_MEMORY; + } + if (isLowMemMode()) return '512M'; + return '1G'; +} + +module.exports = { + isLowMemMode, + resolveBuildMaxOldSpaceMb, + resolveBuildNodeEnv, + getPm2ServerMemoryRestart, + getPm2ClientMemoryRestart, +}; diff --git a/cli/lib/project-type.js b/cli/src/lib/project-type.ts similarity index 64% rename from cli/lib/project-type.js rename to cli/src/lib/project-type.ts index 32e53012..6d497c22 100644 --- a/cli/lib/project-type.js +++ b/cli/src/lib/project-type.ts @@ -1,9 +1,10 @@ +// @ts-nocheck 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 + * editable `server/src`, `web/`, `client/`, `toolkit/`) or a standalone project that * was created with `reactpress init` and relies on the bundled runtime. * * @param {string} root absolute project root @@ -35,6 +36,14 @@ function hasClient(root) { return fs.existsSync(path.join(root, 'client', 'package.json')); } +/** + * Admin SPA (`web/`), preferred over client `/admin` in monorepo dev. + * @param {string} root + */ +function hasWeb(root) { + return fs.existsSync(path.join(root, 'web', 'package.json')); +} + /** * @param {string} root */ @@ -49,6 +58,22 @@ function hasToolkit(root) { return fs.existsSync(path.join(root, 'toolkit', 'package.json')); } +/** + * Electron desktop client (`desktop/`). + * @param {string} root + */ +function hasDesktop(root) { + return fs.existsSync(path.join(root, 'desktop', 'package.json')); +} + +/** + * Official plugins workspace (`plugins/`). + * @param {string} root + */ +function hasPluginsWorkspace(root) { + return fs.existsSync(path.join(root, 'plugins', 'package.json')); +} + /** * @param {string} root */ @@ -58,8 +83,11 @@ function describeProject(root) { type, root, hasClient: hasClient(root), + hasWeb: hasWeb(root), hasServerSource: hasServerSource(root), hasToolkit: hasToolkit(root), + hasDesktop: hasDesktop(root), + hasPluginsWorkspace: hasPluginsWorkspace(root), }; } @@ -67,6 +95,9 @@ module.exports = { detectProjectType, describeProject, hasClient, + hasWeb, hasServerSource, hasToolkit, + hasDesktop, + hasPluginsWorkspace, }; diff --git a/cli/src/lib/publish.ts b/cli/src/lib/publish.ts new file mode 100644 index 00000000..9dfa58d8 --- /dev/null +++ b/cli/src/lib/publish.ts @@ -0,0 +1,424 @@ +#!/usr/bin/env node +// @ts-nocheck + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const inquirer = require('inquirer'); +const { t } = require('./i18n'); +const { getMonorepoRoot } = require('./root'); + +const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/; +const NPM_REGISTRY = 'https://registry.npmjs.org'; + +/** Publish order: dependencies first, CLI last. */ +const CORE_PUBLISH_PACKAGES = [ + { + name: '@fecommunity/reactpress-toolkit', + path: 'toolkit', + description: 'API client and utilities toolkit', + }, + { + name: '@fecommunity/reactpress-web', + path: 'web', + description: 'Admin SPA static assets and Node static server helpers', + }, + { + name: '@fecommunity/reactpress-server', + path: 'server', + description: t('publish.pkg.server'), + deprecated: true, + }, + { + name: '@fecommunity/reactpress', + path: 'cli', + description: t('publish.pkg.main'), + }, +]; + +function getWorkspaceRoot() { + const root = getMonorepoRoot(); + if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) { + return root; + } + return process.cwd(); +} + +function getCurrentVersion(packagePath) { + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return pkg.version; +} + +function getCanonicalVersion() { + return getCurrentVersion('cli'); +} + +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 = String(version).match(/^(.*)-beta\.(\d+)$/); + if (match) return `${match[1]}-beta.${parseInt(match[2], 10) + 1}`; + return `${base}-beta.0`; + } + default: + return version; + } +} + +function resolveNpmTag(version, explicitTag) { + if (explicitTag) return explicitTag; + return String(version).includes('-') ? 'beta' : 'latest'; +} + +function parseCliPublishOptions(argv = process.argv.slice(2)) { + const opts = { + publish: argv.includes('--publish'), + build: argv.includes('--build'), + noBuild: argv.includes('--no-build'), + yes: argv.includes('--yes'), + tag: undefined, + version: undefined, + otp: process.env.NPM_OTP || undefined, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--tag' && argv[i + 1]) opts.tag = argv[++i]; + else if (arg.startsWith('--tag=')) opts.tag = arg.slice('--tag='.length); + else if (arg === '--version' && argv[i + 1]) opts.version = argv[++i]; + else if (arg.startsWith('--version=')) opts.version = arg.slice('--version='.length); + else if (arg === '--otp' && argv[i + 1]) opts.otp = argv[++i]; + else if (arg.startsWith('--otp=')) opts.otp = arg.slice('--otp='.length); + } + + return opts; +} + +function printPackageVersions() { + console.log(chalk.cyan('📋 Package versions:')); + for (const pkg of CORE_PUBLISH_PACKAGES) { + console.log(chalk.gray(` ${pkg.name}: ${getCurrentVersion(pkg.path)}`)); + } + console.log(chalk.gray(` reactpress (root): ${getCurrentVersion('.')}`)); + console.log(); +} + +function checkEnvironment() { + try { + execSync('pnpm --version', { stdio: 'ignore' }); + } catch { + console.log(chalk.red('❌ pnpm is not installed.')); + return false; + } + + try { + execSync(`pnpm whoami --registry ${NPM_REGISTRY}`, { stdio: 'ignore' }); + } catch { + console.log( + chalk.red(`❌ Not logged in to npm. Run: pnpm login --registry ${NPM_REGISTRY}`), + ); + return false; + } + + return true; +} + +function updateVersion(packagePath, newVersion) { + const root = getWorkspaceRoot(); + const pkgPath = path.join(root, 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(` ✓ ${packagePath}: ${oldVersion} → ${newVersion}`)); +} + +function syncMonorepoVersions(targetVersion) { + console.log(chalk.blue(`\n✏️ Syncing version → ${targetVersion}`)); + updateVersion('.', targetVersion); + for (const pkg of CORE_PUBLISH_PACKAGES) { + updateVersion(pkg.path, targetVersion); + } + const desktopPkg = path.join(getWorkspaceRoot(), 'desktop/package.json'); + if (fs.existsSync(desktopPkg)) { + updateVersion('desktop', targetVersion); + } +} + +function fixWorkspaceDependenciesForPublish(packagePath, packageVersions) { + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + for (const depType of ['dependencies', 'devDependencies', 'peerDependencies']) { + if (!pkg[depType]) continue; + for (const [depName, depValue] of Object.entries(pkg[depType])) { + if (!String(depValue).startsWith('workspace:')) continue; + const depPackage = CORE_PUBLISH_PACKAGES.find((p) => p.name === depName); + if (depPackage && packageVersions[depName]) { + pkg[depType][depName] = packageVersions[depName]; + } + } + } + + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +} + +function restoreWorkspaceDependenciesAfterPublish(packagePath, publishedVersion) { + const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + for (const depType of ['dependencies', 'devDependencies', 'peerDependencies']) { + if (!pkg[depType]) continue; + for (const [depName, depValue] of Object.entries(pkg[depType])) { + const depPackage = CORE_PUBLISH_PACKAGES.find((p) => p.name === depName); + if (depPackage && depValue === publishedVersion) { + pkg[depType][depName] = 'workspace:*'; + } + } + } + + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +} + +function run(cmd, cwd) { + execSync(cmd, { cwd, stdio: 'inherit' }); +} + +function buildAllForPublish() { + const root = getWorkspaceRoot(); + console.log(chalk.blue('\n🔨 Building publish artifacts...\n')); + run('pnpm run build', path.join(root, 'toolkit')); + run('pnpm run build', path.join(root, 'server')); + run('pnpm run build', path.join(root, 'web')); + run('node scripts/sync-bundled-core.mjs', path.join(root, 'cli')); + run('node scripts/sync-monorepo-server.mjs', path.join(root, 'cli')); + run('pnpm run build', path.join(root, 'cli')); + console.log(chalk.green('\n✅ Build complete\n')); +} + +function publishPackage(packagePath, packageName, tag, otp) { + const otpFlag = otp ? ` --otp ${otp}` : ''; + const cmd = `pnpm publish --access public --tag ${tag} --registry ${NPM_REGISTRY} --no-git-checks${otpFlag}`; + console.log(chalk.blue(`\n🚀 ${packageName}@${getCurrentVersion(packagePath)} (${tag})`)); + run(cmd, path.join(getWorkspaceRoot(), packagePath)); + console.log(chalk.green(`✅ ${packageName} published`)); +} + +async function promptPublishPlan(defaults = {}) { + const current = getCanonicalVersion(); + const { channel } = await inquirer.prompt([ + { + type: 'list', + name: 'channel', + message: 'Release channel:', + choices: [ + { name: `Beta prerelease (npm tag: beta)`, value: 'beta' }, + { name: `Stable release (npm tag: latest)`, value: 'latest' }, + ], + default: defaults.tag === 'latest' ? 1 : 0, + }, + ]); + + const { versionMode } = await inquirer.prompt([ + { + type: 'list', + name: 'versionMode', + message: `Version (current: ${current}):`, + choices: [ + { name: `Keep ${current}`, value: 'keep' }, + { name: `Bump beta (${incrementVersion(current, 'beta')})`, value: 'beta' }, + { name: `Bump patch (${incrementVersion(current, 'patch')})`, value: 'patch' }, + { name: 'Enter custom version', value: 'custom' }, + ], + }, + ]); + + let version = current; + if (versionMode === 'beta' || versionMode === 'patch') { + version = incrementVersion(current, versionMode); + } else if (versionMode === 'custom') { + const { customVersion } = await inquirer.prompt([ + { + type: 'input', + name: 'customVersion', + message: 'Semver version for all core packages:', + default: current, + validate: (input) => SEMVER_RE.test(input) || 'Use semver, e.g. 4.0.0-beta.0', + }, + ]); + version = customVersion; + } + + const tag = channel === 'beta' ? 'beta' : resolveNpmTag(version, channel); + + console.log(chalk.cyan(`\nPlan: ${version} → npm tag "${tag}"\n`)); + printPackageVersions(); + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Publish all core packages?', + default: false, + }, + ]); + + if (!confirm) { + console.log(chalk.yellow('Cancelled.')); + return null; + } + + return { version, tag, otp: process.env.NPM_OTP }; +} + +async function executePublish(plan, options = {}) { + const { version, tag, otp } = plan; + const noBuild = options.noBuild === true; + + if (!SEMVER_RE.test(version)) { + throw new Error(`Invalid semver: ${version}`); + } + + syncMonorepoVersions(version); + + if (!noBuild) { + buildAllForPublish(); + } + + const packageVersions = {}; + for (const pkg of CORE_PUBLISH_PACKAGES) { + packageVersions[pkg.name] = version; + } + + for (const pkg of CORE_PUBLISH_PACKAGES) { + fixWorkspaceDependenciesForPublish(pkg.path, packageVersions); + try { + publishPackage(pkg.path, pkg.name, tag, otp); + } finally { + restoreWorkspaceDependenciesAfterPublish(pkg.path, version); + } + } + + console.log(chalk.green(`\n🎉 Published ${CORE_PUBLISH_PACKAGES.length} packages @ ${version} (${tag})`)); + console.log(chalk.cyan('\nVerify:')); + console.log(chalk.gray(` npm view @fecommunity/reactpress dist-tags`)); + if (tag === 'beta') { + console.log(chalk.gray(` npm i -g @fecommunity/reactpress@beta`)); + } else { + console.log(chalk.gray(` npm i -g @fecommunity/reactpress@${version}`)); + } + console.log(chalk.cyan('\nNext:')); + console.log(chalk.gray(` git tag v${version} && git push && git push --tags`)); +} + +async function buildPackages() { + console.log(chalk.blue('🏗️ ReactPress publish build\n')); + printPackageVersions(); + buildAllForPublish(); +} + +async function publishPackages(cliOptions = {}) { + console.log(chalk.blue('📦 ReactPress Package Publisher\n')); + + if (!checkEnvironment()) { + process.exit(1); + } + + printPackageVersions(); + + let plan = null; + + if (cliOptions.version || cliOptions.tag) { + const version = cliOptions.version || getCanonicalVersion(); + const tag = resolveNpmTag(version, cliOptions.tag); + plan = { version, tag, otp: cliOptions.otp }; + + console.log(chalk.cyan(`Plan: ${version} → npm tag "${tag}"\n`)); + + if (!cliOptions.yes) { + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Publish all core packages?', + default: false, + }, + ]); + if (!confirm) { + console.log(chalk.yellow('Cancelled.')); + return; + } + } + } else if (cliOptions.yes) { + const version = getCanonicalVersion(); + plan = { version, tag: resolveNpmTag(version), otp: cliOptions.otp }; + } else { + plan = await promptPublishPlan(cliOptions); + if (!plan) return; + } + + await executePublish(plan, cliOptions); +} + +async function main() { + const opts = parseCliPublishOptions(); + + if (opts.build) { + await buildPackages(); + return; + } + + if (opts.publish) { + await publishPackages(opts); + return; + } + + console.log(chalk.blue('📦 ReactPress publish\n')); + console.log('Usage:'); + console.log(' pnpm run publish:build'); + console.log(' pnpm run publish:packages'); + console.log(''); + console.log('Options:'); + console.log(' --publish Publish (interactive if no --version/--yes)'); + console.log(' --build Build publish artifacts only'); + console.log(' --tag beta|latest npm dist-tag (default: auto from version)'); + console.log(' --version 4.0.0-beta.0 Target semver for all core packages'); + console.log(' --yes Skip confirmation'); + console.log(' --no-build Skip build before publish'); + console.log(' --otp npm 2FA (or NPM_OTP env)'); + console.log(''); + console.log('Examples:'); + console.log(' NPM_OTP=123456 pnpm run publish:packages -- --yes'); + console.log(' pnpm run publish:packages -- --tag beta --version 4.0.0-beta.0 --yes'); +} + +module.exports = { + main, + buildPackages, + publishPackages, + incrementVersion, + resolveNpmTag, + CORE_PUBLISH_PACKAGES, +}; + +if (require.main === module) { + main().catch((error) => { + console.error(chalk.red('❌ Publish failed:'), error.message || error); + process.exit(1); + }); +} diff --git a/cli/src/lib/remote-dev.ts b/cli/src/lib/remote-dev.ts new file mode 100644 index 00000000..bcd9aefd --- /dev/null +++ b/cli/src/lib/remote-dev.ts @@ -0,0 +1,177 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +/** Normalize user input (e.g. api.gaoredu.com) to an HTTPS origin without trailing slash. */ +function normalizeRemoteOrigin(input) { + const raw = typeof input === 'string' ? input.trim() : ''; + if (!raw) return null; + + let origin = raw; + if (!/^https?:\/\//i.test(origin)) { + origin = `https://${origin}`; + } + return origin.replace(/\/$/, ''); +} + +function readEnvValue(projectRoot, key) { + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const match = content.match(new RegExp(`^${key}=(.+)$`, 'm')); + if (match) { + return match[1].trim().replace(/^['"]|['"]$/g, ''); + } + } catch { + // ignore + } + return null; +} + +function readOriginFromEnv(projectRoot, envKey) { + const fromShell = normalizeRemoteOrigin(process.env[envKey]); + if (fromShell) return fromShell; + return normalizeRemoteOrigin(readEnvValue(projectRoot, envKey)); +} + +/** Default remote API URL (--remote-origin or REACTPRESS_DEV_REMOTE_ORIGIN). */ +function readDevRemoteDefault(projectRoot) { + return readOriginFromEnv(projectRoot, 'REACTPRESS_DEV_REMOTE_ORIGIN'); +} + +/** @deprecated use readDevClientApiOrigin */ +function readDevRemoteOrigin(projectRoot) { + return readDevClientApiOrigin(projectRoot); +} + +/** Remote admin API origin; null = local Nest. */ +function readDevAdminApiOrigin(projectRoot) { + return readOriginFromEnv(projectRoot, 'REACTPRESS_DEV_ADMIN_API_ORIGIN'); +} + +/** Remote client/theme API origin (nginx /api); null = local Nest. */ +function readDevClientApiOrigin(projectRoot) { + return readOriginFromEnv(projectRoot, 'REACTPRESS_DEV_CLIENT_API_ORIGIN'); +} + +/** + * Parse one origin flag: local | remote | URL/host. + * @returns {{ url: string|null } | { error: string }} + */ +function parseOriginSpec(value, remoteDefault) { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (!trimmed) return { url: null }; + + const lower = trimmed.toLowerCase(); + if (lower === 'local') return { url: null }; + if (lower === 'remote') { + if (!remoteDefault) return { error: 'REMOTE_DEFAULT_REQUIRED' }; + return { url: remoteDefault }; + } + + const url = normalizeRemoteOrigin(trimmed); + if (!url) return { error: 'INVALID_ORIGIN' }; + return { url }; +} + +/** + * Resolve admin/client API targets for this dev session. + * @returns {{ admin: string|null, client: string|null, remoteDefault: string|null, needsLocalApi: boolean, error?: string }} + */ +function resolveDevApiOrigins(projectRoot, cli = {}) { + const remoteDefault = + normalizeRemoteOrigin(cli.remoteOrigin) || readDevRemoteDefault(projectRoot); + + const onlyRemoteShorthand = + cli.remoteOrigin !== undefined && + cli.adminOrigin === undefined && + cli.clientOrigin === undefined; + + const resolveSide = (cliValue, envKey, useRemoteShorthand) => { + if (cliValue !== undefined) { + return parseOriginSpec(cliValue, remoteDefault); + } + const fromEnv = readOriginFromEnv(projectRoot, envKey); + if (fromEnv) return { url: fromEnv }; + if (useRemoteShorthand && remoteDefault) return { url: remoteDefault }; + return { url: null }; + }; + + const adminParsed = resolveSide( + cli.adminOrigin, + 'REACTPRESS_DEV_ADMIN_API_ORIGIN', + onlyRemoteShorthand, + ); + if (adminParsed.error) return { error: adminParsed.error }; + + const clientParsed = resolveSide( + cli.clientOrigin, + 'REACTPRESS_DEV_CLIENT_API_ORIGIN', + onlyRemoteShorthand, + ); + if (clientParsed.error) return { error: clientParsed.error }; + + const admin = adminParsed.url; + const client = clientParsed.url; + + return { + admin, + client, + remoteDefault, + needsLocalApi: !admin || !client, + }; +} + +function applyDevApiOriginsToEnv(origins) { + if (origins.remoteDefault) { + process.env.REACTPRESS_DEV_REMOTE_ORIGIN = origins.remoteDefault; + } else { + delete process.env.REACTPRESS_DEV_REMOTE_ORIGIN; + } + + if (origins.admin) { + process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN = origins.admin; + } else { + delete process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN; + } + + if (origins.client) { + process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN = origins.client; + } else { + delete process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN; + } +} + +/** @deprecated use resolveDevApiOrigins */ +function applyDevRemoteOrigin(cliValue) { + const normalized = normalizeRemoteOrigin(cliValue); + if (normalized) { + process.env.REACTPRESS_DEV_REMOTE_ORIGIN = normalized; + process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN = normalized; + process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN = normalized; + } + return normalized; +} + +/** Nest client base URL (includes /api when origin is host-only). */ +function resolveRemoteThemeApiBase(origin) { + const base = String(origin || '') + .trim() + .replace(/\/$/, ''); + if (!base) return '/api'; + if (/\/api$/i.test(base)) return base; + return `${base}/api`; +} + +module.exports = { + normalizeRemoteOrigin, + readDevRemoteDefault, + readDevRemoteOrigin, + readDevAdminApiOrigin, + readDevClientApiOrigin, + parseOriginSpec, + resolveDevApiOrigins, + applyDevApiOriginsToEnv, + applyDevRemoteOrigin, + resolveRemoteThemeApiBase, +}; diff --git a/cli/lib/root.js b/cli/src/lib/root.ts similarity index 99% rename from cli/lib/root.js rename to cli/src/lib/root.ts index cfb53a79..6de8ef4d 100644 --- a/cli/lib/root.js +++ b/cli/src/lib/root.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const fs = require('fs'); const path = require('path'); diff --git a/cli/lib/spawn.js b/cli/src/lib/spawn.ts similarity index 79% rename from cli/lib/spawn.js rename to cli/src/lib/spawn.ts index 4753e978..3d18fa8d 100644 --- a/cli/lib/spawn.js +++ b/cli/src/lib/spawn.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const { spawn, spawnSync } = require('child_process'); const path = require('path'); const chalk = require('chalk'); @@ -8,7 +9,7 @@ const { t, resolveLocale } = require('./i18n'); function runSync(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd || ensureOriginalCwd(), - stdio: 'inherit', + stdio: options.stdio ?? 'inherit', env: { ...process.env, REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(), @@ -74,9 +75,22 @@ function spawnDetached(scriptPath, args = [], options = {}) { return child; } -function runReactpressCli(args, options = {}) { - const cliBin = path.join(getCliPackageRoot(), 'dist', 'index.js'); - return runSync(process.execPath, [cliBin, ...args], options); +async function runReactpressCli(args, options = {}) { + const { initProject } = require('../core/services/init'); + const directory = args[1] ?? options.cwd ?? ensureOriginalCwd(); + const force = args.includes('--force'); + const local = args.includes('--local'); + const result = await initProject({ + directory: path.resolve(String(directory)), + force, + local, + }); + console.log(`[reactpress] ${result.message}`); + if (!result.ok) { + const err = new Error(result.message); + (err as NodeJS.ErrnoException & { exitCode?: number }).exitCode = 1; + throw err; + } } function resolveCliScript(relativePath) { diff --git a/cli/lib/status.js b/cli/src/lib/status.ts similarity index 99% rename from cli/lib/status.js rename to cli/src/lib/status.ts index a14ce81d..a47f1cb8 100644 --- a/cli/lib/status.js +++ b/cli/src/lib/status.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const fs = require('fs'); const path = require('path'); const { diff --git a/cli/src/lib/theme-catalog.ts b/cli/src/lib/theme-catalog.ts new file mode 100644 index 00000000..0e285f39 --- /dev/null +++ b/cli/src/lib/theme-catalog.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +/** @deprecated Import from ./theme-registry — kept for backward compatibility. */ +module.exports = require('./theme-registry'); diff --git a/cli/src/lib/theme-cli.ts b/cli/src/lib/theme-cli.ts new file mode 100644 index 00000000..26355dfd --- /dev/null +++ b/cli/src/lib/theme-cli.ts @@ -0,0 +1,54 @@ +// @ts-nocheck +const chalk = require('chalk'); +const { installThemeFromNpm } = require('./theme-install'); +const { readThemeLock } = require('./theme-lock'); +const { readThemeCatalog, resolveCatalogInstallSpec } = require('./theme-catalog'); +const { listAvailableThemeIds } = require('./theme-runtime'); +const { t } = require('./i18n'); + +async function runThemeAdd(projectRoot, spec, options = {}) { + const trimmed = String(spec || '').trim(); + if (!trimmed) { + throw new Error(t('themeInstall.specRequired')); + } + + const resolvedSpec = resolveCatalogInstallSpec(projectRoot, trimmed) || trimmed; + console.log(chalk.cyan('[reactpress]'), t('themeInstall.installing', { spec: resolvedSpec })); + const result = await installThemeFromNpm(projectRoot, resolvedSpec, { + skipDependencies: options.skipDependencies === true, + }); + + console.log( + chalk.green('[reactpress]'), + t('themeInstall.success', { + id: result.themeId, + name: result.name, + dir: result.themeDirRel, + }), + ); + console.log(chalk.gray(t('themeInstall.nextActivate', { id: result.themeId }))); + return result; +} + +function runThemeList(projectRoot) { + const ids = listAvailableThemeIds(projectRoot); + const lock = readThemeLock(projectRoot); + if (!ids.length) { + console.log(t('themeInstall.listEmpty')); + return; + } + console.log(t('themeInstall.listHeading')); + for (const id of ids.sort()) { + const npm = lock.themes[id]; + if (npm?.source === 'npm') { + console.log(` - ${id} (${npm.spec})`); + } else { + console.log(` - ${id}`); + } + } +} + +module.exports = { + runThemeAdd, + runThemeList, +}; diff --git a/cli/src/lib/theme-dev.ts b/cli/src/lib/theme-dev.ts new file mode 100644 index 00000000..c9599099 --- /dev/null +++ b/cli/src/lib/theme-dev.ts @@ -0,0 +1,844 @@ +// @ts-nocheck +const { spawnSync } = require('child_process'); +const { spawnDevChild } = require('./dev-child-io'); +const path = require('path'); +const fs = require('fs'); +const { loadClientSiteUrl, loadServerSiteUrl, getApiPrefix, waitForHttpOk } = require('./http'); +const { warmupThemeHomepage } = require('./theme-warmup'); +const { nginxEntryUrl } = require('./nginx'); +const { + readActiveThemeManifest, + readPreviewThemeManifest, + resolveThemeDirectory, + readManifestSignature, + readPreviewManifestSignature, + getPreviewThemePort, + isThemePackageDir, + isAllowedThemePort, + themeWorkspaceRoot, + listAvailableThemeIds, +} = require('./theme-runtime'); +const { isDevVerbose, logDevDetail, logDevLine } = require('./dev-log'); +const { resolveProjectRoot } = require('./paths'); +const { t } = require('./i18n'); +const { + ensurePreviewThemeRunning, + stopAllPreviewPool, + stopPreviewPoolTheme, + isPreviewHomepageReady, + getPreviewSiteUrlForPort, + getPreviewProxyPort, + ensurePreviewProxyRunning, + resolvePreviewThemeLaunchPlan, + spawnThemeProcess, + withPreviewPortLock, + setPreviewProxyTarget, + isIntegratedDesktopDev, +} = require('./theme-preview-pool'); +const { enqueueThemeBuild, ensureThemeDependenciesInstalled } = require('./theme-prod'); + +let themeChild = null; +let themeWatchStop = null; +let runningSignature = null; +let trackedThemePid = null; +let restartChain = Promise.resolve(); + +let previewRunningSignature = null; +let previewRestartChain = Promise.resolve(); + +/** Drop `.next` only when it does not match the theme package React major (avoids wiping React 17 caches). */ +function cleanStaleThemeDevCache(themeDir) { + if (process.env.REACTPRESS_KEEP_THEME_CACHE === '1') return; + + const nextDir = path.join(themeDir, '.next'); + if (!fs.existsSync(nextDir)) return; + + if (process.env.REACTPRESS_CLEAR_THEME_CACHE === '1') { + fs.rmSync(nextDir, { recursive: true, force: true }); + logDevDetail('themeDev.cacheCleared'); + return; + } + + let expectedMajor = '17'; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8')); + const reactDep = pkg.dependencies?.react || pkg.devDependencies?.react || ''; + const match = String(reactDep).match(/(\d+)/); + if (match) expectedMajor = match[1]; + } catch { + return; + } + + try { + const marker = `react@${expectedMajor}`; + const result = spawnSync('grep', ['-rl', marker, nextDir], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (result.stdout?.trim()) return; + + fs.rmSync(nextDir, { recursive: true, force: true }); + logDevDetail('themeDev.cacheStaleCleared', { marker }); + } catch { + // ignore grep / rm failures + } +} + +function getClientPort(projectRoot) { + try { + const url = new URL(loadClientSiteUrl(projectRoot)); + const port = parseInt(url.port || '3001', 10); + if (isAllowedThemePort(port)) return String(port); + } catch { + // fall through + } + return '3001'; +} + +function assertThemePort(port) { + const n = parseInt(port, 10); + if (!isAllowedThemePort(n)) { + throw new Error(`Refusing theme dev on protected port ${port}`); + } + return n; +} + +function isPortListening(port) { + const result = spawnSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' }); + return result.status === 0 && Boolean(result.stdout?.trim()); +} + +function getProcessCwd(pid) { + const n = parseInt(pid, 10); + if (!Number.isFinite(n) || n <= 0) return null; + const res = spawnSync('lsof', ['-p', String(n)], { encoding: 'utf8' }); + if (res.status !== 0) return null; + const line = res.stdout.split('\n').find((row) => row.includes(' cwd ')); + if (!line) return null; + const parts = line.trim().split(/\s+/); + return parts[parts.length - 1] || null; +} + +function isUnderThemesDir(projectRoot, cwd) { + if (!cwd) return false; + const themesRoot = path.join(path.resolve(projectRoot), 'themes'); + const resolved = path.resolve(cwd); + return resolved === themesRoot || resolved.startsWith(`${themesRoot}${path.sep}`); +} + +/** Child PIDs of `rootPid` (pnpm → next dev tree). */ +function collectDescendantPids(rootPid) { + const root = parseInt(rootPid, 10); + if (!Number.isFinite(root) || root <= 0) return []; + + const out = []; + const queue = [String(root)]; + const seen = new Set(); + + while (queue.length) { + const pid = queue.shift(); + if (!pid || seen.has(pid)) continue; + seen.add(pid); + + const children = spawnSync('pgrep', ['-P', pid], { encoding: 'utf8' }); + if (children.status !== 0 || !children.stdout?.trim()) continue; + + for (const child of children.stdout.trim().split(/\s+/)) { + if (!child || seen.has(child)) continue; + out.push(child); + queue.push(child); + } + } + return out; +} + +function isPidSafeToSignal(pid) { + const n = parseInt(pid, 10); + if (!Number.isFinite(n) || n <= 1) return false; + if (n === process.pid) return false; + if (process.ppid && n === process.ppid) return false; + return true; +} + +/** LISTEN pids for this theme dev port (package cwd, themes/, or tracked child tree). */ +function getThemeListenerPids(projectRoot, port) { + const result = spawnSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' }); + if (result.status !== 0 || !result.stdout?.trim()) return []; + + const allowed = new Set(); + + if (trackedThemePid && isPidSafeToSignal(trackedThemePid)) { + allowed.add(String(trackedThemePid)); + for (const child of collectDescendantPids(trackedThemePid)) { + if (isPidSafeToSignal(child)) allowed.add(child); + } + } + + for (const pid of result.stdout.trim().split(/\s+/)) { + if (!isPidSafeToSignal(pid)) continue; + const cwd = getProcessCwd(pid); + if ( + cwd && + (isThemePackageDir(projectRoot, cwd) || isUnderThemesDir(projectRoot, cwd)) + ) { + allowed.add(pid); + for (const child of collectDescendantPids(pid)) { + if (isPidSafeToSignal(child)) allowed.add(child); + } + } + } + + return [...allowed]; +} + +function signalPids(pids, signal) { + const flag = signal === 'KILL' ? '-9' : '-TERM'; + for (const pid of pids) { + if (isPidSafeToSignal(pid)) { + spawnSync('kill', [flag, pid], { stdio: 'ignore' }); + } + } +} + +function killThemeListenersOnPort(projectRoot, port, signal = 'TERM') { + signalPids(getThemeListenerPids(projectRoot, port), signal); +} + +function waitForPortFree(port, timeoutMs = 10_000) { + const start = Date.now(); + return new Promise((resolve) => { + const tick = () => { + if (!isPortListening(port)) { + resolve(true); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(false); + return; + } + setTimeout(tick, 250); + }; + tick(); + }); +} + +async function releaseThemePort(projectRoot, port, { fast = false } = {}) { + stopActiveThemeProcess(); + + const maxAttempts = fast ? 3 : 4; + const waitSchedule = fast ? [1200, 800, 800] : [12_000, 6000, 6000, 6000]; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const signal = fast ? (attempt === 0 ? 'TERM' : 'KILL') : attempt < 2 ? 'TERM' : 'KILL'; + killThemeListenersOnPort(projectRoot, port, signal); + if (trackedThemePid && isPidSafeToSignal(trackedThemePid)) { + const tree = [String(trackedThemePid), ...collectDescendantPids(trackedThemePid)]; + signalPids(tree, signal); + } + const waitMs = waitSchedule[attempt] ?? 6000; + const freed = await waitForPortFree(port, waitMs); + if (freed) { + trackedThemePid = null; + return true; + } + } + + trackedThemePid = null; + return false; +} + +/** Optional theme-only API override (admin / Nest API stay on SERVER_SITE_URL). */ +function readThemeApiOverride(projectRoot, envKey) { + const fromShell = process.env[envKey]?.trim(); + if (fromShell) return fromShell.replace(/\/$/, ''); + + const envPath = path.join(projectRoot, '.env'); + try { + const content = fs.readFileSync(envPath, 'utf8'); + const match = content.match(new RegExp(`^${envKey}=(.+)$`, 'm')); + if (match) { + const raw = match[1].trim().replace(/^['"]|['"]$/g, ''); + if (raw) return raw.replace(/\/$/, ''); + } + } catch { + // ignore + } + return null; +} + +function useLocalThemeApiInDev() { + return process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API === '1'; +} + +function buildLocalThemeApiUrl(projectRoot, { forBrowser = false } = {}) { + const desktopApi = process.env.REACTPRESS_DESKTOP_LOCAL_API?.trim().replace(/\/$/, ''); + if (desktopApi) { + return desktopApi; + } + if (forBrowser && process.env.REACTPRESS_BEHIND_NGINX === '1') { + return `${nginxEntryUrl(projectRoot).replace(/\/$/, '')}/api`; + } + const server = loadServerSiteUrl(projectRoot).replace(/\/$/, ''); + const prefix = getApiPrefix(projectRoot).replace(/\/$/, '') || '/api'; + return `${server}${prefix.startsWith('/') ? prefix : `/${prefix}`}`; +} + +/** Direct Nest API — used for Next.js SSR (runs before nginx is up). */ +function buildThemeServerApiUrl(projectRoot) { + if (useLocalThemeApiInDev()) { + return buildLocalThemeApiUrl(projectRoot, { forBrowser: false }); + } + + const override = readThemeApiOverride(projectRoot, 'REACTPRESS_THEME_API_URL'); + if (override) return override; + + return buildLocalThemeApiUrl(projectRoot); +} + +/** Browser-facing API — nginx unified entry when behind proxy. */ +function buildThemePublicApiUrl(projectRoot) { + if (useLocalThemeApiInDev()) { + return buildLocalThemeApiUrl(projectRoot, { forBrowser: true }); + } + + const publicOverride = readThemeApiOverride(projectRoot, 'REACTPRESS_THEME_PUBLIC_API_URL'); + if (publicOverride) return publicOverride; + + const themeOverride = readThemeApiOverride(projectRoot, 'REACTPRESS_THEME_API_URL'); + if (themeOverride) return themeOverride; + + return buildLocalThemeApiUrl(projectRoot); +} + +/** @deprecated use buildThemeServerApiUrl / buildThemePublicApiUrl */ +function buildThemeApiUrl(projectRoot) { + return buildThemeServerApiUrl(projectRoot); +} + +function buildThemeChildEnv(projectRoot, { port, serverApiUrl, publicApiUrl, themeId }) { + const keys = [ + 'PATH', + 'HOME', + 'USER', + 'LANG', + 'LC_ALL', + 'NODE_ENV', + 'PNPM_HOME', + 'npm_config_user_agent', + ]; + const env = {}; + for (const key of keys) { + if (process.env[key] !== undefined) env[key] = process.env[key]; + } + return { + ...env, + PORT: String(port), + // Next inlines SERVER_API_URL from next.config (localhost); override for remote dev SSR. + SERVER_API_URL: serverApiUrl, + REACTPRESS_API_URL: serverApiUrl, + NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl, + REACTPRESS_THEME_ID: themeId || '', + REACTPRESS_ORIGINAL_CWD: projectRoot, + REACTPRESS_SKIP_BROWSER_OPEN: '1', + NEXT_IGNORE_INCORRECT_LOCKFILE: '1', + NEXT_TELEMETRY_DISABLED: '1', + ...(process.env.REACTPRESS_NGINX_ENTRY_URL + ? { + REACTPRESS_NGINX_ENTRY_URL: process.env.REACTPRESS_NGINX_ENTRY_URL, + NGINX_ENTRY_URL: process.env.REACTPRESS_NGINX_ENTRY_URL, + NEXT_PUBLIC_REACTPRESS_ADMIN_URL: `${String(process.env.REACTPRESS_NGINX_ENTRY_URL).replace(/\/$/, '')}/admin`, + } + : { REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1' }), + ...(process.env.REACTPRESS_DESKTOP_LOCAL === '1' || process.env.REACTPRESS_DESKTOP_SITE_ROOT + ? { REACTPRESS_HONOR_PREVIEW: '1' } + : {}), + }; +} + +function stopThemeProcess(childRef, trackedPidRef) { + const child = childRef.current; + if (!child || child.killed) { + childRef.current = null; + return; + } + + const pid = child.pid; + if (pid && isPidSafeToSignal(pid)) { + trackedPidRef.current = pid; + } + try { + if (process.platform !== 'win32' && pid && isPidSafeToSignal(pid)) { + try { + process.kill(-pid, 'SIGTERM'); + } catch { + spawnSync('pkill', ['-TERM', '-P', String(pid)], { stdio: 'ignore' }); + child.kill('SIGTERM'); + } + for (const descendant of collectDescendantPids(pid)) { + if (isPidSafeToSignal(descendant)) { + spawnSync('kill', ['-TERM', descendant], { stdio: 'ignore' }); + } + } + } else if (pid && isPidSafeToSignal(pid)) { + child.kill('SIGTERM'); + } + } catch { + // ignore — process may already be gone + } + childRef.current = null; +} + +const activeChildRef = { get current() { return themeChild; }, set current(v) { themeChild = v; } }; +const activeTrackedPidRef = { + get current() { return trackedThemePid; }, + set current(v) { trackedThemePid = v; }, +}; + +function stopActiveThemeProcess() { + stopThemeProcess(activeChildRef, activeTrackedPidRef); +} + +function stopPreviewThemeProcess() { + /* Preview pool stays warm — torn down only on full dev shutdown. */ +} + +function stopThemeSite() { + if (themeWatchStop) { + themeWatchStop(); + themeWatchStop = null; + } + stopActiveThemeProcess(); + void stopAllPreviewPool(resolveProjectRoot()); + runningSignature = null; + previewRunningSignature = null; + restartChain = Promise.resolve(); + previewRestartChain = Promise.resolve(); +} + +async function spawnThemeSite(projectRoot, { onClose } = {}) { + const signature = readManifestSignature(projectRoot); + if (!signature) { + console.warn(`[reactpress] ${t('themeDev.invalidManifest')}`); + runningSignature = null; + return null; + } + + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + const port = assertThemePort(getClientPort(projectRoot)); + const serverApiUrl = buildThemeServerApiUrl(projectRoot); + const publicApiUrl = buildThemePublicApiUrl(projectRoot); + const siteUrl = loadClientSiteUrl(projectRoot); + + if (!themeDir || !isThemePackageDir(projectRoot, themeDir)) { + console.warn( + `[reactpress] ${t('themeDev.notFound', { id: activeTheme })} — ${siteUrl} ${t('themeDev.unavailable')}`, + ); + runningSignature = null; + return null; + } + + const relDir = path.relative(projectRoot, themeDir) || themeDir; + const launch = resolvePreviewThemeLaunchPlan(themeDir, port); + const useProduction = launch.mode === 'production'; + + logDevDetail('themeDev.startingShort', { + id: activeTheme, + port, + dir: relDir, + mode: useProduction ? 'production' : 'dev', + }); + if (isDevVerbose()) { + logDevLine('themeDev.apiSplit', { ssr: serverApiUrl, browser: publicApiUrl }); + } + + if (useProduction) { + try { + ensureThemeDependenciesInstalled(projectRoot, themeDir, activeTheme, 'themeProd'); + } catch (err) { + console.warn( + `[reactpress] ${t('themePreview.buildFailed', { + id: activeTheme, + message: err.message || err, + })}`, + ); + runningSignature = null; + return null; + } + try { + await enqueueThemeBuild(projectRoot, activeTheme, { logPrefix: 'themeProd' }); + const { ensureBuildAllowsPreviewFrame } = require('./theme-preview-frame'); + ensureBuildAllowsPreviewFrame(themeDir, '.next'); + } catch (err) { + console.warn( + `[reactpress] ${t('themePreview.buildFailed', { + id: activeTheme, + message: err.message || err, + })}`, + ); + runningSignature = null; + return null; + } + } else { + cleanStaleThemeDevCache(themeDir); + } + + try { + const { ensurePreviewFrameAllowed } = require('./theme-preview-frame'); + ensurePreviewFrameAllowed(themeDir); + } catch { + // ignore — preview patch optional for themes without next.config headers + } + + if (useProduction) { + themeChild = spawnThemeProcess(projectRoot, { + themeDir, + themeId: activeTheme, + port, + serverApiUrl, + publicApiUrl, + launch, + role: 'visitor', + }); + } else { + themeChild = spawnDevChild('pnpm', ['run', 'dev'], { + cwd: themeDir, + detached: process.platform !== 'win32', + shell: process.platform === 'win32', + env: buildThemeChildEnv(projectRoot, { port, serverApiUrl, publicApiUrl, themeId: activeTheme }), + }); + } + + const child = themeChild; + trackedThemePid = child.pid ?? null; + runningSignature = signature; + + child.on('close', (code) => { + if (themeChild === child) { + themeChild = null; + trackedThemePid = null; + if (runningSignature === signature) { + runningSignature = null; + } + } + if (onClose) onClose(code); + }); + + if (process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API !== '1') { + const homepageUrl = `${siteUrl.replace(/\/$/, '')}/`; + const pollMs = parseInt(process.env.REACTPRESS_THEME_READY_POLL_MS || '150', 10) || 150; + waitForHttpOk(homepageUrl, 120_000, pollMs).then((ready) => { + if (ready && runningSignature === signature) { + console.log(t('themeDev.ready', { url: siteUrl, id: activeTheme })); + warmupThemeHomepage(projectRoot, siteUrl).catch(() => {}); + } else if (!ready && runningSignature === signature) { + console.warn(t('themeDev.slow', { url: siteUrl })); + } + }); + } + + return themeChild; +} + +async function restartThemeSite(projectRoot, { onClose } = {}) { + const signature = readManifestSignature(projectRoot); + if (!signature) return; + + if (signature === runningSignature && themeChild && !themeChild.killed) { + return; + } + + const port = assertThemePort(getClientPort(projectRoot)); + let freed = await releaseThemePort(projectRoot, port, { fast: true }); + if (!freed || isPortListening(port)) { + freed = await releaseThemePort(projectRoot, port, { fast: true }); + } + if (!freed || isPortListening(port)) { + console.warn(`[reactpress] ${t('themeDev.portBusy', { port })}`); + console.warn( + `[reactpress] ${t('themeDev.portBusyHint', { + port, + cmd: `lsof -tiTCP:${port} -sTCP:LISTEN | xargs kill -9`, + })}`, + ); + return; + } + + await spawnThemeSite(projectRoot, { onClose }); +} + +async function restartPreviewThemeSite(projectRoot, { onClose } = {}) { + await withPreviewPortLock(async () => { + const signature = readPreviewManifestSignature(projectRoot); + + if (!signature) { + previewRunningSignature = null; + await stopAllPreviewPool(projectRoot); + if (onClose) onClose(0); + return; + } + + const previewManifest = readPreviewThemeManifest(projectRoot); + const themeId = previewManifest?.activeTheme; + if (!themeId) return; + + const { activeTheme } = readActiveThemeManifest(projectRoot); + if (themeId === activeTheme) { + previewRunningSignature = null; + await stopAllPreviewPool(projectRoot); + if (onClose) onClose(0); + return; + } + + await ensurePreviewProxyRunning(getPreviewProxyPort()); + + if (signature === previewRunningSignature) { + const { previewPool } = require('./theme-preview-pool'); + const entry = previewPool.get(themeId); + const proxyPort = getPreviewProxyPort(); + const childAlive = + entry?.child && + !entry.child.killed && + entry.child.exitCode == null && + entry.child.signalCode == null; + if (childAlive && entry?.backendPort) { + setPreviewProxyTarget(entry.backendPort); + entry.lastUsed = Date.now(); + const ready = await isPreviewHomepageReady(projectRoot, proxyPort); + if (ready) return; + } + if (entry) { + stopPreviewPoolTheme(themeId); + } + previewRunningSignature = null; + } + + const serverApiUrl = buildThemeServerApiUrl(projectRoot); + const publicApiUrl = buildThemePublicApiUrl(projectRoot); + + const result = await ensurePreviewThemeRunning(projectRoot, themeId, { + serverApiUrl, + publicApiUrl, + }); + + if (!result) { + previewRunningSignature = null; + console.warn(`[reactpress] Preview failed to start for theme "${themeId}"`); + if (onClose) onClose(1); + return; + } + + previewRunningSignature = signature; + if (result.reused) { + console.log(`[reactpress] Preview ready (reused): ${result.url} (theme: ${themeId})`); + } else { + console.log(`[reactpress] Preview ready: ${result.url} (theme: ${themeId})`); + } + if (onClose) onClose(0); + }); +} + +async function prewarmPreviewThemeBackends(projectRoot) { + if (process.env.REACTPRESS_SKIP_PREVIEW_BUILD === '1') return; + if (!isIntegratedDesktopDev()) return; + + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeIds = listAvailableThemeIds(projectRoot).filter((id) => id !== activeTheme); + if (themeIds.length === 0) return; + + console.log( + `[reactpress] ${t('dev.previewPrewarmStarting')} (${themeIds.length} backend(s) on :${getPreviewProxyPort()}+)`, + ); + + await ensurePreviewProxyRunning(getPreviewProxyPort()); + const serverApiUrl = buildThemeServerApiUrl(projectRoot); + const publicApiUrl = buildThemePublicApiUrl(projectRoot); + + for (const themeId of themeIds) { + try { + const result = await ensurePreviewThemeRunning(projectRoot, themeId, { + serverApiUrl, + publicApiUrl, + }); + if (result?.reused) { + console.log(`[reactpress] Preview backend reused for "${themeId}" (:${result.backendPort})`); + } else if (result) { + console.log(`[reactpress] Preview backend ready for "${themeId}" (:${result.backendPort})`); + } + } catch (err) { + console.warn( + `[reactpress] Preview backend prewarm failed for "${themeId}": ${err.message || err}`, + ); + } + } +} + +function watchActiveThemeManifest(projectRoot, onChange) { + const manifestPath = path.join(themeWorkspaceRoot(projectRoot), '.reactpress', 'active-theme.json'); + const dir = path.dirname(manifestPath); + fs.mkdirSync(dir, { recursive: true }); + + let lastSignature = readManifestSignature(projectRoot); + let debounce = null; + + const scheduleCheck = () => { + clearTimeout(debounce); + debounce = setTimeout(() => { + const next = readManifestSignature(projectRoot); + if (!next || next === lastSignature) return; + lastSignature = next; + console.log(`\n[reactpress] ${t('themeDev.restart')}`); + restartChain = restartChain + .then(() => onChange()) + .catch((err) => { + console.warn(`[reactpress] ${t('themeDev.restartFailed', { message: err.message || err })}`); + }); + }, 200); + }; + + const watcher = fs.watch(dir, scheduleCheck); + const poller = setInterval(scheduleCheck, 1000); + + return () => { + clearTimeout(debounce); + clearInterval(poller); + watcher.close(); + }; +} + +function watchPreviewThemeManifest(projectRoot, onChange) { + const manifestPath = path.join(themeWorkspaceRoot(projectRoot), '.reactpress', 'preview-theme.json'); + const dir = path.dirname(manifestPath); + fs.mkdirSync(dir, { recursive: true }); + + let lastSignature = readPreviewManifestSignature(projectRoot); + let debounce = null; + /** Delay teardown when manifest is deleted — admin preview session may remount immediately. */ + let clearDebounce = null; + const PREVIEW_CLEAR_GRACE_MS = 500; + + const enqueueRestart = (nextSignature) => { + if (nextSignature === lastSignature) return; + lastSignature = nextSignature; + if (nextSignature) { + console.log('\n[reactpress] preview-theme.json changed — restarting preview theme…'); + } + previewRestartChain = previewRestartChain + .then(() => onChange()) + .catch((err) => { + console.warn( + `[reactpress] ${t('themeDev.restartFailed', { message: err.message || err })}`, + ); + }); + }; + + const scheduleCheck = () => { + clearTimeout(debounce); + debounce = setTimeout(() => { + const next = readPreviewManifestSignature(projectRoot); + if (next === lastSignature) return; + + if (!next && lastSignature) { + if (clearDebounce) clearTimeout(clearDebounce); + clearDebounce = setTimeout(() => { + clearDebounce = null; + const still = readPreviewManifestSignature(projectRoot); + if (still) { + if (still !== lastSignature) enqueueRestart(still); + return; + } + enqueueRestart(''); + }, PREVIEW_CLEAR_GRACE_MS); + return; + } + + if (clearDebounce) { + clearTimeout(clearDebounce); + clearDebounce = null; + } + enqueueRestart(next); + }, 200); + }; + + const watcher = fs.watch(dir, (event, filename) => { + if (filename && filename !== 'preview-theme.json') return; + scheduleCheck(); + }); + const poller = setInterval(scheduleCheck, 1000); + + return () => { + clearTimeout(debounce); + if (clearDebounce) clearTimeout(clearDebounce); + clearInterval(poller); + watcher.close(); + }; +} + +/** Drop stale preview manifest so `pnpm dev` does not auto-start :3003 from a prior admin session. */ +function clearPreviewThemeManifestFile(projectRoot) { + const manifestPath = path.join(themeWorkspaceRoot(projectRoot), '.reactpress', 'preview-theme.json'); + if (fs.existsSync(manifestPath)) { + fs.unlinkSync(manifestPath); + } +} + +async function releaseThemePortIfBusy(projectRoot, port, options) { + if (!isPortListening(port)) return true; + return releaseThemePort(projectRoot, port, options); +} + +async function prepareThemeDevBoot(projectRoot) { + clearPreviewThemeManifestFile(projectRoot); + stopActiveThemeProcess(); + previewRunningSignature = null; + runningSignature = null; + trackedThemePid = null; + + await ensurePreviewProxyRunning(getPreviewProxyPort()); + + const visitorPort = assertThemePort(getClientPort(projectRoot)); + await releaseThemePortIfBusy(projectRoot, visitorPort); +} + +async function startThemeSiteWithWatch(projectRoot, { onClose } = {}) { + await prepareThemeDevBoot(projectRoot); + + const restartActive = () => restartThemeSite(projectRoot, { onClose }); + const restartPreview = () => restartPreviewThemeSite(projectRoot, { onClose }); + + restartChain = restartChain.then(() => restartThemeSite(projectRoot, { onClose })); + await restartChain; + + const stopActiveWatch = watchActiveThemeManifest(projectRoot, restartActive); + const stopPreviewWatch = watchPreviewThemeManifest(projectRoot, restartPreview); + + const initialPreviewSignature = readPreviewManifestSignature(projectRoot); + if (initialPreviewSignature) { + previewRestartChain = previewRestartChain.then(() => restartPreviewThemeSite(projectRoot, { onClose })); + } + + themeWatchStop = () => { + stopActiveWatch(); + stopPreviewWatch(); + }; + + if (isIntegratedDesktopDev()) { + void prewarmPreviewThemeBackends(projectRoot); + } + + return Boolean(runningSignature && themeChild && !themeChild.killed); +} + +module.exports = { + spawnThemeSite, + restartThemeSite, + startThemeSiteWithWatch, + stopThemeSite, + getClientPort, + buildThemeApiUrl, + buildThemeServerApiUrl, + buildThemePublicApiUrl, + readManifestSignature, + isPortListening, + getThemeListenerPids, +}; diff --git a/cli/src/lib/theme-env.ts b/cli/src/lib/theme-env.ts new file mode 100644 index 00000000..520ceddc --- /dev/null +++ b/cli/src/lib/theme-env.ts @@ -0,0 +1,112 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +const { loadClientSiteUrl, loadServerSiteUrl, getApiPrefix } = require('./http'); + +function parseEnvFile(content) { + const out = {}; + for (const line of String(content).split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) continue; + const key = trimmed.slice(0, idx).trim(); + let value = trimmed.slice(idx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +function readProjectEnv(projectRoot) { + const envPath = path.join(path.resolve(projectRoot), '.env'); + if (!fs.existsSync(envPath)) return {}; + try { + return parseEnvFile(fs.readFileSync(envPath, 'utf8')); + } catch { + return {}; + } +} + +function buildThemeEnvOverrides(projectRoot, projectEnv = readProjectEnv(projectRoot)) { + const clientSiteUrl = ( + projectEnv.CLIENT_SITE_URL || loadClientSiteUrl(projectRoot) + ).replace(/\/$/, ''); + const serverSiteUrl = ( + projectEnv.SERVER_SITE_URL || loadServerSiteUrl(projectRoot) + ).replace(/\/$/, ''); + const apiPrefix = projectEnv.SERVER_API_PREFIX || getApiPrefix(projectRoot) || '/api'; + const normalizedPrefix = apiPrefix.startsWith('/') ? apiPrefix : `/${apiPrefix}`; + const apiUrl = + projectEnv.REACTPRESS_API_URL || `${serverSiteUrl}${normalizedPrefix}`.replace(/\/$/, ''); + + const publicApiUrl = + projectEnv.NEXT_PUBLIC_REACTPRESS_API_URL || + projectEnv.REACTPRESS_THEME_PUBLIC_API_URL || + apiUrl; + + const adminUrl = + projectEnv.NEXT_PUBLIC_REACTPRESS_ADMIN_URL || + projectEnv.WEB_ADMIN_URL || + 'http://localhost:3000'; + + return { + REACTPRESS_API_URL: apiUrl, + SERVER_API_URL: apiUrl, + NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl, + CLIENT_SITE_URL: clientSiteUrl, + NEXT_PUBLIC_REACTPRESS_ADMIN_URL: adminUrl.replace(/\/$/, ''), + }; +} + +function upsertEnvLines(existingContent, overrides) { + const lines = existingContent.split('\n'); + for (const [key, value] of Object.entries(overrides)) { + if (value == null || value === '') continue; + const entry = `${key}=${value}`; + const index = lines.findIndex((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith('#') && trimmed.startsWith(`${key}=`); + }); + if (index >= 0) { + lines[index] = entry; + } else { + lines.push(entry); + } + } + return `${lines.join('\n').trimEnd()}\n`; +} + +/** + * Point an installed theme's `.env` at the host ReactPress project API URLs. + */ +function syncThemeEnvFromProject(projectRoot, themeDir) { + const root = path.resolve(projectRoot); + const dir = path.resolve(themeDir); + const overrides = buildThemeEnvOverrides(root); + const envPath = path.join(dir, '.env'); + const examplePath = path.join(dir, '.env.example'); + + let base = ''; + if (fs.existsSync(envPath)) { + base = fs.readFileSync(envPath, 'utf8'); + } else if (fs.existsSync(examplePath)) { + base = fs.readFileSync(examplePath, 'utf8'); + } + + const next = upsertEnvLines(base, overrides); + fs.writeFileSync(envPath, next, 'utf8'); + return envPath; +} + +module.exports = { + buildThemeEnvOverrides, + readProjectEnv, + syncThemeEnvFromProject, +}; diff --git a/cli/src/lib/theme-install.ts b/cli/src/lib/theme-install.ts new file mode 100644 index 00000000..4072a202 --- /dev/null +++ b/cli/src/lib/theme-install.ts @@ -0,0 +1,379 @@ +// @ts-nocheck +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { upsertNpmThemeLock } = require('./theme-lock'); +const { buildThemeEnvOverrides, syncThemeEnvFromProject } = require('./theme-env'); +const { ensurePreviewFrameAllowed } = require('./theme-preview-frame'); +const { resolveCatalogInstallSpec } = require('./theme-registry'); + +const THEME_ID_RE = /^[a-z0-9][a-z0-9-]*$/i; +const THEME_RUNTIME_REL = path.join('.reactpress', 'runtime'); +const COPY_SKIP_NAMES = new Set([ + 'node_modules', + '.next', + '.git', + 'dist', + '.turbo', + 'coverage', + '.reactpress', + '.cache', + 'package-lock.json', +]); + +function isValidThemeId(id) { + return typeof id === 'string' && THEME_ID_RE.test(id) && id.length <= 64; +} + +function parseNpmSpec(spec) { + const trimmed = String(spec || '').trim(); + if (!trimmed) { + return { error: 'EMPTY_SPEC' }; + } + if (trimmed.endsWith('.tgz') || trimmed.endsWith('.tar.gz')) { + const resolved = path.resolve(trimmed); + if (!fs.existsSync(resolved)) { + return { error: 'TARBALL_NOT_FOUND', path: resolved }; + } + return { kind: 'tarball', path: resolved }; + } + if (/^file:/i.test(trimmed)) { + const filePath = trimmed.replace(/^file:/i, ''); + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + return { error: 'TARBALL_NOT_FOUND', path: resolved }; + } + return { kind: 'tarball', path: resolved }; + } + return { kind: 'npm', spec: trimmed }; +} + +function readThemeManifestFromDir(dir) { + const manifestPath = path.join(dir, 'theme.json'); + if (!fs.existsSync(manifestPath)) return null; + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (raw && typeof raw.id === 'string' && typeof raw.name === 'string') { + return raw; + } + } catch { + // ignore + } + return null; +} + +function inferThemeIdFromPackageName(name) { + if (typeof name !== 'string' || !name) return null; + const base = name.includes('/') ? name.split('/').pop() : name; + const match = base.match(/(?:reactpress-)?(?:template-)?(.+)$/i); + if (!match) return null; + const id = match[1].replace(/^template-/, ''); + return isValidThemeId(id) ? id : null; +} + +function resolveThemeIdentity(packageDir) { + const manifest = readThemeManifestFromDir(packageDir); + if (manifest?.id && isValidThemeId(manifest.id)) { + return { + themeId: manifest.id, + manifest, + }; + } + + const pkgPath = path.join(packageDir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const fromReactpress = pkg.reactpress?.themeId || pkg.reactpress?.id; + if (typeof fromReactpress === 'string' && isValidThemeId(fromReactpress)) { + return { + themeId: fromReactpress, + manifest: manifest || { + id: fromReactpress, + name: typeof pkg.description === 'string' ? pkg.description : fromReactpress, + version: typeof pkg.version === 'string' ? pkg.version : '1.0.0', + }, + }; + } + const inferred = inferThemeIdFromPackageName(pkg.name); + if (inferred) { + return { + themeId: inferred, + manifest: manifest || { + id: inferred, + name: typeof pkg.description === 'string' ? pkg.description : inferred, + version: typeof pkg.version === 'string' ? pkg.version : '1.0.0', + }, + packageName: pkg.name, + packageVersion: pkg.version, + }; + } + } catch { + // ignore + } + } + + return null; +} + +function isThemePackageDir(dir) { + return ( + fs.existsSync(path.join(dir, 'theme.json')) || + fs.existsSync(path.join(dir, 'package.json')) + ); +} + +function copyDir(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (COPY_SKIP_NAMES.has(entry.name)) continue; + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isSymbolicLink()) { + const link = fs.readlinkSync(from); + fs.symlinkSync(link, to); + } else if (entry.isDirectory()) { + copyDir(from, to); + } else if (entry.isFile()) { + fs.copyFileSync(from, to); + } + } +} + +function removeDir(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const target = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + fs.unlinkSync(target); + } else if (entry.isDirectory()) { + removeDir(target); + } else { + fs.unlinkSync(target); + } + } + fs.rmdirSync(dir); +} + +function extractTarball(tarballPath, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + const result = spawnSync('tar', ['-xzf', tarballPath, '-C', destDir], { + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || result.stdout?.trim() || 'Failed to extract theme tarball'); + } + const packageDir = path.join(destDir, 'package'); + if (fs.existsSync(packageDir) && fs.statSync(packageDir).isDirectory()) { + return packageDir; + } + const entries = fs.readdirSync(destDir, { withFileTypes: true }).filter((d) => d.isDirectory()); + if (entries.length === 1) { + return path.join(destDir, entries[0].name); + } + if (isThemePackageDir(destDir)) { + return destDir; + } + throw new Error('Theme package root not found after extracting tarball'); +} + +function npmPack(spec, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + const result = spawnSync('npm', ['pack', spec, '--pack-destination', destDir], { + stdio: 'pipe', + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + const message = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); + throw new Error(message || `npm pack failed for "${spec}"`); + } + const files = fs + .readdirSync(destDir) + .filter((name) => name.endsWith('.tgz') || name.endsWith('.tar.gz')) + .map((name) => path.join(destDir, name)) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + if (!files.length) { + throw new Error(`npm pack produced no tarball for "${spec}"`); + } + return files[0]; +} + +function ensureRuntimeThemeTsconfigBase(projectRoot, runtimeDir) { + const baseSrc = path.join(path.resolve(projectRoot), 'tsconfig.base.json'); + if (!fs.existsSync(baseSrc)) return; + const runtimeBase = path.join(runtimeDir, 'tsconfig.base.json'); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.copyFileSync(baseSrc, runtimeBase); +} + +function readThemePackageManager(themeDir) { + const pkgPath = path.join(themeDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return null; + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return typeof pkg.packageManager === 'string' ? pkg.packageManager : null; + } catch { + return null; + } +} + +function themePrefersPnpm(themeDir) { + if (fs.existsSync(path.join(themeDir, 'pnpm-lock.yaml'))) return true; + const pm = readThemePackageManager(themeDir); + return typeof pm === 'string' && pm.startsWith('pnpm@'); +} + +function installThemeDependencies(themeDir, projectRoot) { + const envOverrides = buildThemeEnvOverrides(projectRoot); + const installEnv = { + ...process.env, + ...envOverrides, + npm_config_ignore_scripts: 'false', + }; + + const usePnpm = themePrefersPnpm(themeDir); + let result; + + if (usePnpm) { + result = spawnSync('pnpm', ['install', '--ignore-workspace'], { + cwd: themeDir, + stdio: 'inherit', + shell: process.platform === 'win32', + env: installEnv, + }); + } else { + result = spawnSync('npm', ['install', '--legacy-peer-deps'], { + cwd: themeDir, + stdio: 'inherit', + shell: process.platform === 'win32', + env: installEnv, + }); + } + + if (result.status !== 0 && usePnpm) { + result = spawnSync('npm', ['install', '--legacy-peer-deps'], { + cwd: themeDir, + stdio: 'inherit', + shell: process.platform === 'win32', + env: installEnv, + }); + } + + if (result.status !== 0 && !usePnpm) { + result = spawnSync('npm', ['install', '--ignore-scripts', '--legacy-peer-deps'], { + cwd: themeDir, + stdio: 'inherit', + shell: process.platform === 'win32', + env: installEnv, + }); + } + + if (result.status !== 0) { + throw new Error(`${usePnpm ? 'pnpm' : 'npm'} install failed in theme directory`); + } + + syncThemeEnvFromProject(projectRoot, themeDir); +} + +function readPackageMeta(packageDir) { + const pkgPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return { name: undefined, version: undefined }; + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return { + name: typeof pkg.name === 'string' ? pkg.name : undefined, + version: typeof pkg.version === 'string' ? pkg.version : undefined, + }; + } catch { + return { name: undefined, version: undefined }; + } +} + +/** + * Install a theme from an npm spec or local .tgz into `.reactpress/runtime/{id}/`. + * @param {string} projectRoot + * @param {string} spec npm package spec or path to .tgz + * @param {{ skipDependencies?: boolean }} [options] + */ +async function installThemeFromNpm(projectRoot, spec, options = {}) { + const root = path.resolve(projectRoot); + const resolvedSpec = resolveCatalogInstallSpec(root, spec) || spec; + const parsed = parseNpmSpec(resolvedSpec); + if (parsed.error === 'EMPTY_SPEC') { + throw new Error('Theme npm spec is required'); + } + if (parsed.error === 'TARBALL_NOT_FOUND') { + throw new Error(`Theme tarball not found: ${parsed.path}`); + } + + const tmpRoot = path.join(root, '.reactpress', 'tmp', `theme-npm-${crypto.randomBytes(4).toString('hex')}`); + fs.mkdirSync(tmpRoot, { recursive: true }); + + try { + const tarballPath = + parsed.kind === 'tarball' ? parsed.path : npmPack(parsed.spec, tmpRoot); + const extractDir = path.join(tmpRoot, 'extract'); + const packageDir = extractTarball(tarballPath, extractDir); + + if (!isThemePackageDir(packageDir)) { + throw new Error('Package is not a ReactPress theme (missing theme.json or package.json)'); + } + + const identity = resolveThemeIdentity(packageDir); + if (!identity?.themeId) { + throw new Error('Could not resolve theme id from theme.json or package.json'); + } + + const { themeId, manifest } = identity; + const runtimeRoot = path.join(root, THEME_RUNTIME_REL); + const targetDir = path.join(runtimeRoot, themeId); + const pkgMeta = readPackageMeta(packageDir); + + if (fs.existsSync(targetDir)) { + removeDir(targetDir); + } + fs.mkdirSync(runtimeRoot, { recursive: true }); + copyDir(packageDir, targetDir); + ensureRuntimeThemeTsconfigBase(root, runtimeRoot); + + if (!options.skipDependencies) { + installThemeDependencies(targetDir, root); + } else { + syncThemeEnvFromProject(root, targetDir); + } + ensurePreviewFrameAllowed(targetDir); + + const npmSpec = parsed.kind === 'npm' ? parsed.spec : resolvedSpec; + upsertNpmThemeLock(root, themeId, { + spec: npmSpec, + resolvedVersion: pkgMeta.version || manifest.version || '0.0.0', + packageName: pkgMeta.name, + }); + + return { + themeId, + name: manifest.name, + version: manifest.version || pkgMeta.version || '0.0.0', + packageName: pkgMeta.name, + npmSpec, + themeDir: targetDir, + themeDirRel: path.relative(root, targetDir), + }; + } finally { + removeDir(tmpRoot); + } +} + +module.exports = { + THEME_RUNTIME_REL, + parseNpmSpec, + resolveThemeIdentity, + installThemeFromNpm, + installThemeDependencies, + isValidThemeId, +}; diff --git a/cli/src/lib/theme-lock.ts b/cli/src/lib/theme-lock.ts new file mode 100644 index 00000000..cae14928 --- /dev/null +++ b/cli/src/lib/theme-lock.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +const LOCK_REL = path.join('.reactpress', 'themes.lock.json'); +const LOCK_VERSION = 1; + +function lockPath(projectRoot) { + return path.join(path.resolve(projectRoot), LOCK_REL); +} + +function readThemeLock(projectRoot) { + const file = lockPath(projectRoot); + if (!fs.existsSync(file)) { + return { version: LOCK_VERSION, themes: {} }; + } + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!raw || typeof raw !== 'object') { + return { version: LOCK_VERSION, themes: {} }; + } + return { + version: typeof raw.version === 'number' ? raw.version : LOCK_VERSION, + themes: raw.themes && typeof raw.themes === 'object' ? raw.themes : {}, + }; + } catch { + return { version: LOCK_VERSION, themes: {} }; + } +} + +function writeThemeLock(projectRoot, lock) { + const file = lockPath(projectRoot); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`, 'utf8'); +} + +function upsertNpmThemeLock(projectRoot, themeId, entry) { + const lock = readThemeLock(projectRoot); + lock.themes[themeId] = { + source: 'npm', + spec: entry.spec, + resolvedVersion: entry.resolvedVersion, + packageName: entry.packageName, + installedAt: entry.installedAt || new Date().toISOString(), + }; + writeThemeLock(projectRoot, lock); + return lock.themes[themeId]; +} + +function getNpmThemeLockEntry(projectRoot, themeId) { + const lock = readThemeLock(projectRoot); + const entry = lock.themes[themeId]; + if (!entry || entry.source !== 'npm') return null; + return entry; +} + +function removeThemeLockEntry(projectRoot, themeId) { + const lock = readThemeLock(projectRoot); + if (!lock.themes[themeId]) return false; + delete lock.themes[themeId]; + writeThemeLock(projectRoot, lock); + return true; +} + +module.exports = { + LOCK_REL, + readThemeLock, + writeThemeLock, + upsertNpmThemeLock, + getNpmThemeLockEntry, + removeThemeLockEntry, +}; diff --git a/cli/src/lib/theme-paths.ts b/cli/src/lib/theme-paths.ts new file mode 100644 index 00000000..c56bb9ea --- /dev/null +++ b/cli/src/lib/theme-paths.ts @@ -0,0 +1,82 @@ +// @ts-nocheck +const path = require('path'); + +/** Shared path and id constants for theme runtime, registry, and server bridge. */ +const REACTPRESS_DIR = '.reactpress'; +const THEMES_DIR = 'themes'; + +const THEME_RUNTIME_REL = path.join(REACTPRESS_DIR, 'runtime'); +const LEGACY_THEMES_RUNTIME_REL = path.join(THEMES_DIR, 'runtime'); +const ACTIVE_THEME_MANIFEST_REL = path.join(REACTPRESS_DIR, 'active-theme.json'); +const PREVIEW_THEME_MANIFEST_REL = path.join(REACTPRESS_DIR, 'preview-theme.json'); +const PREVIEW_POOL_MANIFEST_REL = path.join(REACTPRESS_DIR, 'preview-pool.json'); +const THEME_LOCK_REL = path.join(REACTPRESS_DIR, 'themes.lock.json'); + +const THEMES_PACKAGE_REL = path.join(THEMES_DIR, 'package.json'); +const THEMES_CATALOG_REL = path.join(THEMES_DIR, 'catalog.json'); +const CLI_CATALOG_TEMPLATE_REL = path.join('cli', 'templates', 'theme-catalog.json'); + +/** Reserved under `themes/` — not bundled templates or theme source trees. */ +const THEMES_RESERVED_SUBDIRS = ['starter', 'bundled', 'core', 'theme-starter']; +const THEMES_LEGACY_STARTER_SUBDIRS = ['starter', 'bundled', 'core']; + +const THEME_ID_RE = /^[a-z0-9][a-z0-9-]*$/i; +const DEFAULT_ACTIVE_THEME = 'hello-world'; +const PREVIEW_PROXY_PORT = 3003; +const PREVIEW_BACKEND_BASE = 3004; +const DEFAULT_PREVIEW_POOL_MAX = 3; + +function getPreviewPoolMaxSize() { + const parsed = parseInt(process.env.REACTPRESS_PREVIEW_POOL_MAX || '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_PREVIEW_POOL_MAX; +} + +function getPreviewProxyPort() { + const parsed = parseInt(process.env.REACTPRESS_PREVIEW_PORT || '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : PREVIEW_PROXY_PORT; +} + +function getPreviewBackendPorts() { + const max = getPreviewPoolMaxSize(); + const parsed = parseInt(process.env.REACTPRESS_PREVIEW_BACKEND_BASE || '', 10); + const base = Number.isInteger(parsed) && parsed > 0 ? parsed : PREVIEW_BACKEND_BASE; + return Array.from({ length: max }, (_, index) => base + index); +} + +/** Public preview URL port (proxy). Backend slots use getPreviewBackendPorts(). */ +const PREVIEW_POOL_PORTS = [PREVIEW_PROXY_PORT]; + +function themesRoot(projectRoot) { + return path.join(path.resolve(projectRoot), THEMES_DIR); +} + +function runtimeRoot(projectRoot) { + return path.join(path.resolve(projectRoot), THEME_RUNTIME_REL); +} + +module.exports = { + REACTPRESS_DIR, + THEMES_DIR, + THEME_RUNTIME_REL, + LEGACY_THEMES_RUNTIME_REL, + ACTIVE_THEME_MANIFEST_REL, + PREVIEW_THEME_MANIFEST_REL, + PREVIEW_POOL_MANIFEST_REL, + THEME_LOCK_REL, + THEMES_PACKAGE_REL, + THEMES_CATALOG_REL, + CLI_CATALOG_TEMPLATE_REL, + THEMES_RESERVED_SUBDIRS, + THEMES_LEGACY_STARTER_SUBDIRS, + THEME_ID_RE, + DEFAULT_ACTIVE_THEME, + PREVIEW_PROXY_PORT, + PREVIEW_BACKEND_BASE, + DEFAULT_PREVIEW_POOL_MAX, + PREVIEW_POOL_PORTS, + getPreviewPoolMaxSize, + getPreviewProxyPort, + getPreviewBackendPorts, + themesRoot, + runtimeRoot, +}; diff --git a/cli/src/lib/theme-placeholder-cover.ts b/cli/src/lib/theme-placeholder-cover.ts new file mode 100644 index 00000000..96ed0c41 --- /dev/null +++ b/cli/src/lib/theme-placeholder-cover.ts @@ -0,0 +1,102 @@ +/** + * SVG placeholder cover for themes without a cover image file. + * Used by the extension API and web dev mocks. + */ + +export type ThemePlaceholderCoverOptions = { + id: string; + name: string; + primary?: string; + accent?: string; + version?: string; +}; + +function escapeXml(text: string): string { + return String(text).replace(/[<>&"']/g, (ch) => { + const map: Record = { + "<": "<", + ">": ">", + "&": "&", + '"': """, + "'": "'", + }; + return map[ch] ?? ch; + }); +} + +function sanitizeColor(color: string | undefined, fallback: string): string { + if (!color) return fallback; + const trimmed = String(color).trim(); + if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed; + return fallback; +} + +function safeSvgId(id: string): string { + return String(id).replace(/[^a-zA-Z0-9_-]/g, "") || "theme"; +} + +export function buildThemePlaceholderCoverSvg(options: ThemePlaceholderCoverOptions): string { + const svgId = safeSvgId(options.id); + const primary = sanitizeColor(options.primary, "#2563eb"); + const accent = sanitizeColor(options.accent, "#7c3aed"); + const rawName = String(options.name ?? options.id ?? "Theme"); + const safeName = escapeXml(rawName.length > 48 ? `${rawName.slice(0, 45)}…` : rawName); + const version = options.version ? escapeXml(String(options.version)) : ""; + const versionBadge = version + ? ` + v${version}` + : ""; + + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REACTPRESS + ${versionBadge} + + + + + + + + + + + + + + + + + + ${safeName} + Theme Preview +`; +} diff --git a/cli/src/lib/theme-preview-frame.ts b/cli/src/lib/theme-preview-frame.ts new file mode 100644 index 00000000..53c0b470 --- /dev/null +++ b/cli/src/lib/theme-preview-frame.ts @@ -0,0 +1,113 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +const PATCH_MARKER = '.reactpress-preview-frame-patched'; +const X_FRAME_OPTIONS_RE = + /\{\s*key:\s*['"]X-Frame-Options['"],\s*value:\s*['"]SAMEORIGIN['"]\s*\},?\s*\n?/g; +const X_FRAME_OPTIONS_HEADER_KEY = 'X-Frame-Options'; + +/** Admin iframe loads theme on another port — skip X-Frame-Options in local/desktop dev. */ +function shouldHonorThemePreviewFrame() { + if (process.env.REACTPRESS_HONOR_PREVIEW === '1') return true; + if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') return true; + if (process.env.REACTPRESS_DESKTOP_SITE_ROOT?.trim()) return true; + return false; +} + +/** + * Next.js bakes `headers()` into routes-manifest.json at build time. + * Strip X-Frame-Options so admin iframes work without a full rebuild. + */ +function stripBakedFrameOptionsFromBuild(themeDir, distDir = '.next') { + if (!themeDir || !distDir) return false; + + const manifestPath = path.join(themeDir, distDir, 'routes-manifest.json'); + if (!fs.existsSync(manifestPath)) return false; + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + return false; + } + + if (!Array.isArray(manifest.headers)) return false; + + let changed = false; + for (const entry of manifest.headers) { + if (!entry || !Array.isArray(entry.headers)) continue; + const nextHeaders = entry.headers.filter( + (header) => header?.key !== X_FRAME_OPTIONS_HEADER_KEY, + ); + if (nextHeaders.length !== entry.headers.length) { + entry.headers = nextHeaders; + changed = true; + } + } + + if (!changed) return false; + + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + return true; +} + +/** Patch next.config and strip baked headers when admin preview needs iframe embedding. */ +function ensureBuildAllowsPreviewFrame(themeDir, distDir = '.next') { + if (!shouldHonorThemePreviewFrame()) return false; + ensurePreviewFrameAllowed(themeDir); + return stripBakedFrameOptionsFromBuild(themeDir, distDir); +} + +const X_FRAME_OPTIONS_PATCH = `...(process.env.REACTPRESS_HONOR_PREVIEW === '1' + ? [] + : [{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }]), + `; + +/** + * Admin preview iframes load :3003 from a different origin than /admin/. + * Drop X-Frame-Options for preview dev only (REACTPRESS_HONOR_PREVIEW=1). + */ +function ensurePreviewFrameAllowed(themeDir) { + if (!themeDir || !fs.existsSync(themeDir)) return false; + + const markerPath = path.join(themeDir, PATCH_MARKER); + const configPath = path.join(themeDir, 'next.config.js'); + const configMjsPath = path.join(themeDir, 'next.config.mjs'); + + const target = fs.existsSync(configPath) + ? configPath + : fs.existsSync(configMjsPath) + ? configMjsPath + : null; + + if (!target) return false; + if (fs.existsSync(markerPath)) return true; + + let src = fs.readFileSync(target, 'utf8'); + if (!X_FRAME_OPTIONS_RE.test(src)) { + fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8'); + return false; + } + + X_FRAME_OPTIONS_RE.lastIndex = 0; + if (src.includes('REACTPRESS_HONOR_PREVIEW')) { + fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8'); + return true; + } + + const next = src.replace(X_FRAME_OPTIONS_RE, X_FRAME_OPTIONS_PATCH); + if (next === src) return false; + + fs.writeFileSync(target, next, 'utf8'); + fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8'); + return true; +} + +module.exports = { + PATCH_MARKER, + shouldHonorThemePreviewFrame, + stripBakedFrameOptionsFromBuild, + ensureBuildAllowsPreviewFrame, + ensurePreviewFrameAllowed, +}; diff --git a/cli/src/lib/theme-preview-pool.ts b/cli/src/lib/theme-preview-pool.ts new file mode 100644 index 00000000..7ba6e32a --- /dev/null +++ b/cli/src/lib/theme-preview-pool.ts @@ -0,0 +1,570 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const { spawnDevChild } = require('./dev-child-io'); +const { loadClientSiteUrl, normalizeProbeUrl, probeHttp, waitForHttpOk } = require('./http'); +const { isPortListening, killPortListeners } = require('./ports'); +const { + resolveThemeDirectory, + isThemePackageDir, + themeWorkspaceRoot, +} = require('./theme-runtime'); +const { + getPreviewBackendPorts, + getPreviewPoolMaxSize, + getPreviewProxyPort, + PREVIEW_POOL_PORTS, +} = require('./theme-paths'); +const { + enqueueThemeBuild, + resolvePreviewThemeEnv, + ensureThemeDependenciesInstalled, + PREVIEW_DIST_DIR, +} = require('./theme-prod'); +const { warmupThemeHomepage } = require('./theme-warmup'); +const { + ensurePreviewFrameAllowed, + ensureBuildAllowsPreviewFrame, + shouldHonorThemePreviewFrame, +} = require('./theme-preview-frame'); +const { + ensurePreviewProxyRunning, + setPreviewProxyTarget, + stopPreviewProxy, +} = require('./theme-preview-proxy'); +const { t } = require('./i18n'); + +const PREVIEW_POOL_MANIFEST = path.join('.reactpress', 'preview-pool.json'); +const PREVIEW_READY_POLL_MS = 100; +const PREVIEW_READY_TIMEOUT_MS = + process.env.REACTPRESS_DESKTOP_LOCAL === '1' ? 15_000 : 120_000; +const PREVIEW_PORT_RELEASE_PAUSE_MS = + process.env.REACTPRESS_DESKTOP_LOCAL === '1' ? 120 : 400; + +/** @type {Map} */ +const previewPool = new Map(); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getPreviewPoolManifestPath(projectRoot) { + return path.join(themeWorkspaceRoot(projectRoot), PREVIEW_POOL_MANIFEST); +} + +function getPreviewSiteUrlForPort(projectRoot, port) { + try { + const url = new URL(loadClientSiteUrl(projectRoot)); + url.port = String(port); + return `${url.origin}/`; + } catch { + return `http://127.0.0.1:${port}/`; + } +} + +function getPreviewPublicUrl(projectRoot) { + return getPreviewSiteUrlForPort(projectRoot, getPreviewProxyPort()); +} + +function readPreviewPoolManifest(projectRoot) { + const manifestPath = getPreviewPoolManifestPath(projectRoot); + if (!fs.existsSync(manifestPath)) return {}; + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + return raw && typeof raw === 'object' ? raw : {}; + } catch { + return {}; + } +} + +function writePreviewPoolManifest(projectRoot) { + const manifestPath = getPreviewPoolManifestPath(projectRoot); + const proxyPort = getPreviewProxyPort(); + const next = {}; + for (const [themeId, entry] of previewPool) { + if (!entry?.backendPort) continue; + next[themeId] = { + port: String(proxyPort), + backendPort: String(entry.backendPort), + url: getPreviewSiteUrlForPort(projectRoot, proxyPort), + updatedAt: new Date(entry.lastUsed || Date.now()).toISOString(), + }; + } + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); +} + +async function isBackendReady(projectRoot, backendPort) { + const url = `${getPreviewSiteUrlForPort(projectRoot, backendPort).replace(/\/$/, '')}/`; + const result = await probeHttp(normalizeProbeUrl(url), 1200); + return result.ok; +} + +async function isPreviewHomepageReady(projectRoot, port) { + const url = `${getPreviewSiteUrlForPort(projectRoot, port).replace(/\/$/, '')}/`; + const result = await probeHttp(normalizeProbeUrl(url), 1200); + return result.ok; +} + +function stopPreviewPoolChild(entry) { + const child = entry?.child; + if (!child || child.killed) { + if (entry) entry.child = null; + return; + } + const pid = child.pid; + try { + if (process.platform !== 'win32' && pid) { + try { + process.kill(-pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } else if (pid) { + child.kill('SIGTERM'); + } + } catch { + // ignore + } + entry.child = null; +} + +function stopPreviewPoolTheme(themeId) { + const entry = previewPool.get(themeId); + if (!entry) return; + stopPreviewPoolChild(entry); + previewPool.delete(themeId); +} + +async function stopAllPreviewPool(projectRoot) { + for (const themeId of [...previewPool.keys()]) { + stopPreviewPoolTheme(themeId); + } + await stopPreviewProxy(); + const manifestPath = getPreviewPoolManifestPath(projectRoot); + if (fs.existsSync(manifestPath)) { + fs.unlinkSync(manifestPath); + } +} + +async function releasePreviewPort(port) { + if (!isPortListening(port)) return true; + killPortListeners(port, 'TERM'); + await sleep(PREVIEW_PORT_RELEASE_PAUSE_MS); + if (!isPortListening(port)) return true; + killPortListeners(port, 'KILL'); + await sleep(PREVIEW_PORT_RELEASE_PAUSE_MS); + return !isPortListening(port); +} + +/** Serialize preview pool mutations (desktop + CLI). */ +let previewPortLock = Promise.resolve(); + +function withPreviewPortLock(fn) { + const run = previewPortLock.then(() => fn()); + previewPortLock = run.catch(() => {}); + return run; +} + +function allocateBackendPort(themeId) { + const existing = previewPool.get(themeId); + if (existing?.backendPort) { + return existing.backendPort; + } + + const backendPorts = getPreviewBackendPorts(); + const usedPorts = new Set( + [...previewPool.values()] + .map((entry) => entry.backendPort) + .filter((port) => Number.isInteger(port)), + ); + + for (const port of backendPorts) { + if (!usedPorts.has(port)) return port; + } + + let oldestId = null; + let oldestAt = Infinity; + for (const [id, entry] of previewPool) { + if (id === themeId) continue; + const ts = entry.lastUsed || 0; + if (ts < oldestAt) { + oldestAt = ts; + oldestId = id; + } + } + + if (oldestId) { + const evicted = previewPool.get(oldestId); + const port = evicted?.backendPort ?? backendPorts[0]; + stopPreviewPoolTheme(oldestId); + return port; + } + + return backendPorts[0]; +} + +function isChildAlive(child) { + return Boolean(child && !child.killed && child.exitCode == null && child.signalCode == null); +} + +function buildPreviewResult(projectRoot, themeId, backendPort, reused) { + const proxyPort = getPreviewProxyPort(); + return { + themeId, + port: proxyPort, + backendPort, + url: getPreviewPublicUrl(projectRoot), + reused, + }; +} + +async function activateWarmPreviewEntry(projectRoot, themeId, entry) { + setPreviewProxyTarget(entry.backendPort); + entry.lastUsed = Date.now(); + writePreviewPoolManifest(projectRoot); + const proxyPort = getPreviewProxyPort(); + const ready = await isPreviewHomepageReady(projectRoot, proxyPort); + if (!ready) { + const homepageUrl = `${getPreviewPublicUrl(projectRoot).replace(/\/$/, '')}/`; + await waitForHttpOk(homepageUrl, PREVIEW_READY_TIMEOUT_MS, PREVIEW_READY_POLL_MS); + } + return buildPreviewResult(projectRoot, themeId, entry.backendPort, true); +} + +/** + * Spawn a theme server child for desktop visitor/preview roles. + * Caller owns lifecycle logging and process tracking. + */ +function spawnThemeProcess(projectRoot, options) { + const { spawn } = require('child_process'); + const { + themeDir, + themeId, + port, + serverApiUrl, + publicApiUrl, + launch, + role = 'visitor', + extraEnv = {}, + } = options; + const distDir = role === 'preview' ? PREVIEW_DIST_DIR : '.next'; + const { cmd, args } = launch; + + if (launch.mode === 'production' && shouldHonorThemePreviewFrame()) { + ensureBuildAllowsPreviewFrame(themeDir, distDir); + } + + return spawn(cmd, args, { + cwd: themeDir, + detached: process.platform !== 'win32', + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...resolvePreviewThemeEnv(projectRoot, themeDir, port, { + mode: launch.mode, + distDir, + }), + SERVER_API_URL: serverApiUrl, + REACTPRESS_API_URL: serverApiUrl, + NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl, + REACTPRESS_THEME_ID: themeId, + REACTPRESS_HONOR_PREVIEW: role === 'preview' || shouldHonorThemePreviewFrame() ? '1' : '0', + REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1', + REACTPRESS_SKIP_BROWSER_OPEN: '1', + REACTPRESS_DESKTOP_THEME_ROLE: role, + ...extraEnv, + }, + }); +} + +function themeHasCustomServer(themeDir) { + return fs.existsSync(path.join(themeDir, 'server.js')); +} + +function themeHasDevScript(themeDir) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8')); + return typeof pkg.scripts?.dev === 'string'; + } catch { + return false; + } +} + +function themeUsesAppRouter(themeDir) { + return fs.existsSync(path.join(themeDir, 'app')); +} + +function isThemeOnlyDevMode() { + return process.env.REACTPRESS_THEME_DEV_ONLY === '1'; +} + +function isIntegratedDesktopDev() { + if (isThemeOnlyDevMode()) return false; + if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') return true; + if (process.env.REACTPRESS_DESKTOP_SITE_ROOT?.trim()) return true; + return false; +} + +function shouldPreferProductionLaunch(themeDir) { + if (isThemeOnlyDevMode()) return false; + if (themeUsesAppRouter(themeDir)) return true; + if (isIntegratedDesktopDev() && themeHasCustomServer(themeDir)) return true; + return false; +} + +/** Resolve Next CLI bin — theme-local, NODE_PATH, or packaged Resources/runtime-deps. */ +function resolveThemeNextBin(themeDir) { + const rel = path.join('next', 'dist', 'bin', 'next'); + const local = path.join(themeDir, 'node_modules', rel); + if (fs.existsSync(local)) return local; + + const nodePath = process.env.NODE_PATH?.split(path.delimiter).filter(Boolean) ?? []; + for (const dir of nodePath) { + const candidate = path.join(dir, rel); + if (fs.existsSync(candidate)) return candidate; + } + + const monorepoRoot = process.env.REACTPRESS_MONOREPO_ROOT?.trim(); + if (monorepoRoot) { + const bundled = [ + path.join(monorepoRoot, 'runtime-deps', 'node_modules', rel), + path.join(monorepoRoot, 'node_modules', rel), + ]; + for (const candidate of bundled) { + if (fs.existsSync(candidate)) return candidate; + } + } + + try { + return require.resolve('next/dist/bin/next', { paths: [themeDir, ...nodePath] }); + } catch { + return null; + } +} + +function resolvePreviewThemeLaunchPlan(themeDir, port, options = {}) { + const preferProduction = + options.preferProduction ?? shouldPreferProductionLaunch(themeDir); + + if (!preferProduction && themeHasDevScript(themeDir)) { + return { mode: 'dev', cmd: 'pnpm', args: ['run', 'dev', '--', '--port', String(port)] }; + } + + const nextBin = resolveThemeNextBin(themeDir); + // Prefer `next start -p` — hello-world server.js uses Next CLI internals that ignore `-p` on Next 15. + if (preferProduction && nextBin) { + return { mode: 'production', cmd: process.execPath, args: [nextBin, 'start', '-p', String(port)] }; + } + + if (themeHasCustomServer(themeDir)) { + return { mode: 'production', cmd: 'node', args: ['server.js'] }; + } + + if (nextBin) { + return { mode: 'production', cmd: process.execPath, args: [nextBin, 'start', '-p', String(port)] }; + } + + return { mode: 'production', cmd: 'pnpm', args: ['run', 'start'] }; +} + +/** @type {Map>} */ +const previewEnsureInflight = new Map(); + +async function ensurePreviewThemeRunning( + projectRoot, + themeId, + { serverApiUrl, publicApiUrl, spawnOptions = {} } = {}, +) { + const inflight = previewEnsureInflight.get(themeId); + if (inflight) return inflight; + + const job = startPreviewThemeRunning(projectRoot, themeId, { + serverApiUrl, + publicApiUrl, + spawnOptions, + }).finally(() => { + if (previewEnsureInflight.get(themeId) === job) { + previewEnsureInflight.delete(themeId); + } + }); + previewEnsureInflight.set(themeId, job); + return job; +} + +async function startPreviewThemeRunning( + projectRoot, + themeId, + { serverApiUrl, publicApiUrl, spawnOptions = {} } = {}, +) { + let themeDir = resolveThemeDirectory(projectRoot, themeId); + if (spawnOptions.resolveThemeDir) { + themeDir = spawnOptions.resolveThemeDir(projectRoot, themeId) || themeDir; + } + if (!themeDir || !isThemePackageDir(projectRoot, themeDir)) { + return null; + } + + await ensurePreviewProxyRunning(getPreviewProxyPort()); + + const pooled = previewPool.get(themeId); + if (pooled && isChildAlive(pooled.child)) { + const backendReady = await isBackendReady(projectRoot, pooled.backendPort); + if (backendReady) { + return activateWarmPreviewEntry(projectRoot, themeId, pooled); + } + stopPreviewPoolChild(pooled); + } + + const backendPort = allocateBackendPort(themeId); + await releasePreviewPort(backendPort); + + try { + ensureThemeDependenciesInstalled(projectRoot, themeDir, themeId, 'themePreview'); + ensurePreviewFrameAllowed(themeDir); + } catch (err) { + console.warn( + `[reactpress] ${t('themePreview.buildFailed', { + id: themeId, + message: err.message || err, + })}`, + ); + return null; + } + + let launch = resolvePreviewThemeLaunchPlan(themeDir, backendPort); + if (typeof spawnOptions.normalizeLaunch === 'function') { + launch = spawnOptions.normalizeLaunch(launch, { + themeDir, + port: backendPort, + projectRoot, + themeId, + }); + } + + if (launch.mode === 'production') { + try { + await enqueueThemeBuild(projectRoot, themeId, { + logPrefix: 'themePreview', + distDir: PREVIEW_DIST_DIR, + }); + ensureBuildAllowsPreviewFrame(themeDir, PREVIEW_DIST_DIR); + } catch (err) { + console.warn( + `[reactpress] ${t('themePreview.buildFailed', { + id: themeId, + message: err.message || err, + })}`, + ); + return null; + } + } + + const relDir = path.relative(projectRoot, themeDir) || themeDir; + const modeLabel = launch.mode === 'dev' ? 'dev' : 'production'; + console.log( + `[reactpress] ${t('themePreview.starting', { + id: themeId, + url: getPreviewPublicUrl(projectRoot), + port: backendPort, + dir: relDir, + mode: modeLabel, + })}`, + ); + + const { cmd, args } = launch; + const child = spawnOptions.useThemeProcessSpawn + ? spawnThemeProcess(projectRoot, { + themeDir, + themeId, + port: backendPort, + serverApiUrl, + publicApiUrl, + launch, + role: 'preview', + extraEnv: spawnOptions.extraEnv || {}, + }) + : spawnDevChild(cmd, args, { + cwd: themeDir, + detached: process.platform !== 'win32', + shell: process.platform === 'win32', + env: { + ...resolvePreviewThemeEnv(projectRoot, themeDir, backendPort, { + mode: launch.mode, + distDir: PREVIEW_DIST_DIR, + }), + SERVER_API_URL: serverApiUrl, + REACTPRESS_API_URL: serverApiUrl, + NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl, + REACTPRESS_THEME_ID: themeId, + REACTPRESS_HONOR_PREVIEW: '1', + REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1', + REACTPRESS_SKIP_BROWSER_OPEN: '1', + ...(spawnOptions.extraEnv || {}), + }, + }); + + previewPool.set(themeId, { child, backendPort, lastUsed: Date.now() }); + writePreviewPoolManifest(projectRoot); + + child.on('exit', () => { + const current = previewPool.get(themeId); + if (current?.child === child) { + previewPool.delete(themeId); + writePreviewPoolManifest(projectRoot); + } + }); + + const backendUrl = `${getPreviewSiteUrlForPort(projectRoot, backendPort).replace(/\/$/, '')}/`; + const backendReady = await waitForHttpOk( + backendUrl, + PREVIEW_READY_TIMEOUT_MS, + PREVIEW_READY_POLL_MS, + ); + if (!backendReady) { + console.warn(t('themeDev.slow', { url: backendUrl })); + } + + setPreviewProxyTarget(backendPort); + writePreviewPoolManifest(projectRoot); + + const homepageUrl = `${getPreviewPublicUrl(projectRoot).replace(/\/$/, '')}/`; + const proxyReady = await waitForHttpOk(homepageUrl, PREVIEW_READY_TIMEOUT_MS, PREVIEW_READY_POLL_MS); + if (proxyReady) { + console.log(`[reactpress] ${t('themePreview.ready', { url: homepageUrl, id: themeId })}`); + warmupThemeHomepage(projectRoot, homepageUrl).catch(() => {}); + } else { + console.warn(t('themeDev.slow', { url: homepageUrl })); + } + + return buildPreviewResult(projectRoot, themeId, backendPort, false); +} + +module.exports = { + PREVIEW_POOL_PORTS, + PREVIEW_POOL_MANIFEST, + previewPool, + getPreviewProxyPort, + getPreviewBackendPorts, + getPreviewPoolMaxSize, + getPreviewSiteUrlForPort, + getPreviewPublicUrl, + readPreviewPoolManifest, + writePreviewPoolManifest, + ensurePreviewThemeRunning, + ensurePreviewProxyRunning, + stopPreviewPoolTheme, + stopAllPreviewPool, + isPreviewHomepageReady, + isBackendReady, + resolvePreviewThemeLaunchPlan, + themeUsesAppRouter, + shouldPreferProductionLaunch, + isIntegratedDesktopDev, + shouldHonorThemePreviewFrame, + releasePreviewPort, + withPreviewPortLock, + spawnThemeProcess, + allocateBackendPort, + setPreviewProxyTarget, +}; diff --git a/cli/src/lib/theme-preview-proxy.ts b/cli/src/lib/theme-preview-proxy.ts new file mode 100644 index 00000000..64609ac3 --- /dev/null +++ b/cli/src/lib/theme-preview-proxy.ts @@ -0,0 +1,117 @@ +// @ts-nocheck +/** Stable :3003 front door — forwards to warm theme backends on :3004+. */ +const http = require('http'); +const { getPreviewProxyPort } = require('./theme-paths'); + +const PREVIEW_CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', +}; + +let proxyTargetPort = null; +/** @type {import('http').Server | null} */ +let proxyServer = null; +let listenPort = null; + +function setPreviewProxyTarget(backendPort) { + proxyTargetPort = backendPort; +} + +function getPreviewProxyTarget() { + return proxyTargetPort; +} + +function proxyRequest(req, res) { + if (req.method === 'OPTIONS') { + res.writeHead(204, PREVIEW_CORS_HEADERS); + res.end(); + return; + } + + if (!proxyTargetPort) { + res.writeHead(503, { + ...PREVIEW_CORS_HEADERS, + 'Content-Type': 'text/plain; charset=utf-8', + }); + res.end('Theme preview starting…'); + return; + } + + const headers = { + ...req.headers, + host: `127.0.0.1:${proxyTargetPort}`, + }; + delete headers.origin; + delete headers.referer; + + const proxyReq = http.request( + { + hostname: '127.0.0.1', + port: proxyTargetPort, + path: req.url, + method: req.method, + headers, + }, + (proxyRes) => { + const outHeaders = { ...proxyRes.headers, ...PREVIEW_CORS_HEADERS }; + res.writeHead(proxyRes.statusCode || 502, outHeaders); + proxyRes.pipe(res); + }, + ); + + proxyReq.on('error', () => { + if (!res.headersSent) { + res.writeHead(502, { + ...PREVIEW_CORS_HEADERS, + 'Content-Type': 'text/plain; charset=utf-8', + }); + res.end('Preview backend unavailable'); + } + }); + + if (req.method === 'GET' || req.method === 'HEAD') { + proxyReq.end(); + } else { + req.pipe(proxyReq); + } +} + +function ensurePreviewProxyRunning(port) { + const proxyPort = port ?? getPreviewProxyPort(); + if (proxyServer && listenPort === proxyPort) { + return Promise.resolve(proxyServer); + } + + return stopPreviewProxy().then( + () => + new Promise((resolve, reject) => { + const server = http.createServer(proxyRequest); + server.on('error', reject); + server.listen(proxyPort, '127.0.0.1', () => { + proxyServer = server; + listenPort = proxyPort; + resolve(server); + }); + }), + ); +} + +function stopPreviewProxy() { + proxyTargetPort = null; + if (!proxyServer) return Promise.resolve(); + + const server = proxyServer; + proxyServer = null; + listenPort = null; + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +module.exports = { + setPreviewProxyTarget, + getPreviewProxyTarget, + ensurePreviewProxyRunning, + stopPreviewProxy, +}; diff --git a/cli/src/lib/theme-prod.ts b/cli/src/lib/theme-prod.ts new file mode 100644 index 00000000..82bcc834 --- /dev/null +++ b/cli/src/lib/theme-prod.ts @@ -0,0 +1,439 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { runSync, runNodeScript, resolveCliScript } = require('./spawn'); +const { getThemeBin, resolveProjectRoot } = require('./paths'); +const { + readActiveThemeManifest, + resolveThemeDirectory, + listAvailableThemeIds, +} = require('./theme-runtime'); +const { t } = require('./i18n'); +const { resolveBuildNodeEnv } = require('./prod-memory'); +const { shouldHonorThemePreviewFrame } = require('./theme-preview-frame'); + +function resolveProductionThemeEnv(projectRoot, themeDir) { + const nginxEntry = ( + process.env.REACTPRESS_NGINX_ENTRY_URL || + process.env.NGINX_ENTRY_URL || + 'http://localhost' + ).replace(/\/$/, ''); + const visitorPort = + process.env.CLIENT_PORT || process.env.PORT || '3001'; + const serverApiUrl = + process.env.REACTPRESS_THEME_API_URL || + process.env.SERVER_API_URL || + process.env.REACTPRESS_API_URL || + `${nginxEntry}/api`; + const publicApiUrl = + process.env.REACTPRESS_THEME_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_REACTPRESS_API_URL || + `${nginxEntry}/api`; + + const clientSiteUrl = + process.env.CLIENT_SITE_URL?.trim() || `http://127.0.0.1:${visitorPort}`; + + return { + ...process.env, + NODE_ENV: 'production', + REACTPRESS_ORIGINAL_CWD: projectRoot, + REACTPRESS_THEME_DIR: themeDir, + PORT: String(visitorPort), + CLIENT_PORT: String(visitorPort), + CLIENT_SITE_URL: clientSiteUrl, + NGINX_ENTRY_URL: nginxEntry, + REACTPRESS_NGINX_ENTRY_URL: nginxEntry, + REACTPRESS_API_URL: serverApiUrl, + SERVER_API_URL: serverApiUrl, + NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl, + NEXT_PUBLIC_REACTPRESS_ADMIN_URL: + process.env.NEXT_PUBLIC_REACTPRESS_ADMIN_URL || `${nginxEntry}/admin`, + }; +} + +function resolveThemeClientBin(projectRoot, themeDir) { + const themeBin = path.join(themeDir, 'bin', 'reactpress-client.js'); + if (fs.existsSync(themeBin)) return themeBin; + const generic = resolveCliScript('bin/reactpress-theme-client.js'); + if (fs.existsSync(generic)) return generic; + throw new Error(`Theme entry not found under ${themeDir}`); +} + +const LAUNCH_FILE_REL_PATHS = ['server.js']; + +function syncThemeLaunchFilesFromTemplate(projectRoot, themeId, themeDir) { + const templateDir = path.join(resolveProjectRoot(projectRoot), 'themes', themeId); + if (!templateDir || !fs.existsSync(templateDir)) return; + if (path.resolve(templateDir) === path.resolve(themeDir)) return; + + for (const rel of LAUNCH_FILE_REL_PATHS) { + const src = path.join(templateDir, rel); + const dest = path.join(themeDir, rel); + if (!fs.existsSync(src)) continue; + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } + + const templatePkg = path.join(templateDir, 'package.json'); + const destPkg = path.join(themeDir, 'package.json'); + if (fs.existsSync(templatePkg) && fs.existsSync(destPkg)) { + try { + const srcScripts = JSON.parse(fs.readFileSync(templatePkg, 'utf8')).scripts || {}; + const destPkgJson = JSON.parse(fs.readFileSync(destPkg, 'utf8')); + destPkgJson.scripts = { ...destPkgJson.scripts, start: srcScripts.start, dev: srcScripts.dev }; + fs.writeFileSync(destPkg, `${JSON.stringify(destPkgJson, null, 2)}\n`, 'utf8'); + } catch { + // ignore corrupt package.json + } + } +} + +const PREVIEW_DIST_DIR = '.next-preview'; +const BUILD_STAMP_REL = path.join('.next', '.reactpress-theme-id'); + +function resolveBuildDistDir(options = {}) { + return options.distDir || '.next'; +} + +function buildStampRel(distDir) { + return path.join(distDir, '.reactpress-theme-id'); +} + +function writeThemeBuildStamp(themeDir, themeId, options = {}) { + const distDir = resolveBuildDistDir(options); + const stampPath = path.join(themeDir, buildStampRel(distDir)); + fs.mkdirSync(path.dirname(stampPath), { recursive: true }); + fs.writeFileSync(stampPath, themeId, 'utf8'); +} + +function newestSourceMtime(rootDir, depth = 0) { + if (!fs.existsSync(rootDir)) return 0; + let max = 0; + for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.next' || entry.name === PREVIEW_DIST_DIR) { + continue; + } + const full = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + if (depth < 10) max = Math.max(max, newestSourceMtime(full, depth + 1)); + continue; + } + if (entry.isFile()) max = Math.max(max, fs.statSync(full).mtimeMs); + } + return max; +} + +/** Post-build artifacts — not source changes; ignored when comparing freshness. */ +const GENERATED_PUBLIC_FILES = new Set([ + 'robots.txt', + 'sitemap.xml', + 'sitemap-0.xml', + 'sitemap-1.xml', +]); + +function themeSourcesNewerThanBuild(themeDir, distDir = '.next') { + const stampPath = path.join(themeDir, buildStampRel(distDir)); + if (!fs.existsSync(stampPath)) return true; + const buildMtime = fs.statSync(stampPath).mtimeMs; + + for (const rel of [ + 'app', + 'pages', + 'src', + 'public', + 'theme.json', + 'package.json', + 'next.config.js', + ]) { + const target = path.join(themeDir, rel); + if (!fs.existsSync(target)) continue; + const stat = fs.statSync(target); + if (stat.isDirectory()) { + if (rel === 'public') { + for (const entry of fs.readdirSync(target, { withFileTypes: true })) { + if (!entry.isFile() || GENERATED_PUBLIC_FILES.has(entry.name)) continue; + if (fs.statSync(path.join(target, entry.name)).mtimeMs > buildMtime) return true; + } + continue; + } + if (newestSourceMtime(target) > buildMtime) return true; + continue; + } + if (stat.mtimeMs > buildMtime) return true; + } + return false; +} + +function hasProductionBuildArtifacts(nextDir) { + if (fs.existsSync(path.join(nextDir, 'BUILD_ID'))) return true; + // Next 12 Pages Router — no BUILD_ID at dist root + return fs.existsSync(path.join(nextDir, 'server', 'pages-manifest.json')); +} + +function hasUsableProductionBuild(themeDir, themeId, options = {}) { + if (process.env.REACTPRESS_FORCE_THEME_BUILD === '1') return false; + const distDir = resolveBuildDistDir(options); + const nextDir = path.join(themeDir, distDir); + if (!hasProductionBuildArtifacts(nextDir)) return false; + if (!fs.existsSync(path.join(nextDir, 'server'))) return false; + const stampPath = path.join(themeDir, buildStampRel(distDir)); + if (!fs.existsSync(stampPath)) return false; + try { + if (fs.readFileSync(stampPath, 'utf8').trim() !== themeId) return false; + } catch { + return false; + } + if (themeSourcesNewerThanBuild(themeDir, distDir)) return false; + return true; +} + +function resolvePreviewThemeEnv(projectRoot, themeDir, port, options = {}) { + const distDir = options.distDir || PREVIEW_DIST_DIR; + const base = resolveProductionThemeEnv(projectRoot, themeDir); + let clientSiteUrl = base.CLIENT_SITE_URL; + try { + const url = new URL(clientSiteUrl || 'http://127.0.0.1:3001'); + url.port = String(port); + clientSiteUrl = url.origin; + } catch { + clientSiteUrl = `http://127.0.0.1:${port}`; + } + return { + ...base, + NODE_ENV: options.mode === 'dev' ? 'development' : 'production', + INIT_CWD: themeDir, + NEXT_DIST_DIR: distDir, + PORT: String(port), + CLIENT_PORT: String(port), + CLIENT_SITE_URL: clientSiteUrl, + REACTPRESS_THEME_DIR: themeDir, + NEXT_TELEMETRY_DISABLED: '1', + NEXT_IGNORE_INCORRECT_LOCKFILE: '1', + }; +} + +function canResolveSharedNext(themeDir) { + const searchPaths = [themeDir]; + const nodePath = String(process.env.NODE_PATH || '').trim(); + if (nodePath) { + searchPaths.push(...nodePath.split(path.delimiter).filter(Boolean)); + } + try { + require.resolve('next/package.json', { paths: searchPaths }); + return true; + } catch { + return false; + } +} + +function ensureThemeDependenciesInstalled(projectRoot, themeDir, themeId, logPrefix = 'themePreview') { + if (process.env.REACTPRESS_SKIP_THEME_INSTALL === '1' || canResolveSharedNext(themeDir)) { + return; + } + + const nextModule = path.join(themeDir, 'node_modules', 'next'); + if (fs.existsSync(nextModule)) return; + + const { installThemeDependencies } = require('./theme-install'); + const installingKey = + logPrefix === 'themePreview' ? 'themePreview.installingDeps' : 'themeProd.installingDeps'; + console.log(`[reactpress] ${t(installingKey, { id: themeId })}`); + installThemeDependencies(themeDir, projectRoot); +} + +function resolveThemeBuildState(projectRoot, themeId) { + const themeDir = resolveThemeDirectory(projectRoot, themeId); + if (!themeDir || !fs.existsSync(path.join(themeDir, 'package.json'))) { + return null; + } + return { themeId, themeDir }; +} + +function readActiveThemeBuildState(projectRoot) { + const { activeTheme } = readActiveThemeManifest(projectRoot); + const state = resolveThemeBuildState(projectRoot, activeTheme); + if (!state) return null; + return { activeTheme, themeDir: state.themeDir }; +} + +/** @type {Promise} */ +let themeBuildChain = Promise.resolve(); + +function doBuildThemeSync( + projectRoot, + themeId, + { force = false, logPrefix = 'themeProd', distDir } = {}, +) { + const state = resolveThemeBuildState(projectRoot, themeId); + if (!state) { + const err = new Error(`Theme not found: ${themeId}`); + err.code = 'REACTPRESS_THEME_NOT_FOUND'; + throw err; + } + const { themeDir } = state; + const buildDistDir = + distDir || (logPrefix === 'themePreview' ? PREVIEW_DIST_DIR : '.next'); + + if (!force && hasUsableProductionBuild(themeDir, themeId, { distDir: buildDistDir })) { + if (logPrefix === 'themePreview') { + console.log(`[reactpress] ${t('themePreview.reusingBuild', { id: themeId })}`); + } else { + console.log(`[reactpress] ${t('themeProd.reusingBuild', { id: themeId })}`); + } + return { themeId, themeDir, skippedBuild: true }; + } + + syncThemeLaunchFilesFromTemplate(projectRoot, themeId, themeDir); + ensureThemeDependenciesInstalled(projectRoot, themeDir, themeId, logPrefix); + + const buildingKey = + logPrefix === 'themePreview' ? 'themePreview.building' : 'themeProd.building'; + console.log(`[reactpress] ${t(buildingKey, { id: themeId })}`); + runSync('pnpm', ['run', 'build'], { + cwd: themeDir, + stdio: ['ignore', 'inherit', 'inherit'], + env: resolveBuildNodeEnv({ + ...resolveProductionThemeEnv(projectRoot, themeDir), + NEXT_DIST_DIR: buildDistDir, + CI: '1', + ...(logPrefix === 'themeProd' ? { REACTPRESS_BUILD_ACTIVE: '1' } : {}), + ...(shouldHonorThemePreviewFrame() || logPrefix === 'themePreview' + ? { REACTPRESS_HONOR_PREVIEW: '1' } + : {}), + }), + }); + if (shouldHonorThemePreviewFrame() || logPrefix === 'themePreview') { + const { stripBakedFrameOptionsFromBuild } = require('./theme-preview-frame'); + stripBakedFrameOptionsFromBuild(themeDir, buildDistDir); + } + writeThemeBuildStamp(themeDir, themeId, { distDir: buildDistDir }); + return { themeId, themeDir, skippedBuild: false }; +} + +function enqueueThemeBuild(projectRoot, themeId, options = {}) { + const task = themeBuildChain.then(() => doBuildThemeSync(projectRoot, themeId, options)); + themeBuildChain = task.catch(() => {}); + return task; +} + +function buildTheme(projectRoot, themeId, options = {}) { + return doBuildThemeSync(projectRoot, themeId, options); +} + +function buildActiveTheme(projectRoot, { force = false } = {}) { + const { activeTheme } = readActiveThemeManifest(projectRoot); + const result = doBuildThemeSync(projectRoot, activeTheme, { force, logPrefix: 'themeProd' }); + return { activeTheme, themeDir: result.themeDir, skippedBuild: result.skippedBuild }; +} + +function scheduleBackgroundThemeBuilds(projectRoot, { excludeThemeId } = {}) { + if (process.env.REACTPRESS_SKIP_PREVIEW_BUILD === '1') return; + + const activeTheme = + excludeThemeId || readActiveThemeManifest(projectRoot).activeTheme; + const themeIds = listAvailableThemeIds(projectRoot).filter((id) => id !== activeTheme); + if (themeIds.length === 0) return; + + console.log( + `[reactpress] ${t('themePreview.backgroundBuildScheduled', { count: themeIds.length })}`, + ); + + setImmediate(() => { + void warmupAllPreviewThemeBuilds(projectRoot, { themeIds }).catch(() => {}); + }); +} + +/** + * Pre-build `.next-preview` for catalog themes so admin preview switches stay under ~10s. + * Runs builds in parallel (local/desktop dev only — awaited before the ready banner). + */ +async function warmupAllPreviewThemeBuilds( + projectRoot, + { themeIds, concurrency = 2, excludeThemeId } = {}, +) { + if (process.env.REACTPRESS_SKIP_PREVIEW_BUILD === '1') return { built: 0, skipped: 0 }; + + const activeTheme = excludeThemeId || readActiveThemeManifest(projectRoot).activeTheme; + const ids = (themeIds || listAvailableThemeIds(projectRoot)).filter((id) => id !== activeTheme); + if (ids.length === 0) return { built: 0, skipped: 0 }; + + const { mapWithConcurrency } = require('./theme-warmup'); + const limit = Math.max( + 1, + parseInt(process.env.REACTPRESS_PREVIEW_BUILD_CONCURRENCY || String(concurrency), 10) || + concurrency, + ); + + console.log(`[reactpress] ${t('themePreview.warmingAll', { count: ids.length })}`); + + let built = 0; + let skipped = 0; + + await mapWithConcurrency(ids, limit, async (themeId) => { + const state = resolveThemeBuildState(projectRoot, themeId); + if (!state) return; + if (hasUsableProductionBuild(state.themeDir, themeId, { distDir: PREVIEW_DIST_DIR })) { + skipped += 1; + return; + } + try { + const result = await enqueueThemeBuild(projectRoot, themeId, { + logPrefix: 'themePreview', + distDir: PREVIEW_DIST_DIR, + }); + if (result.skippedBuild) skipped += 1; + else { + built += 1; + console.log(`[reactpress] ${t('themePreview.buildDone', { id: themeId })}`); + } + } catch (err) { + console.warn( + `[reactpress] ${t('themePreview.buildFailed', { + id: themeId, + message: err.message || err, + })}`, + ); + } + }); + + if (skipped > 0) { + console.log(`[reactpress] ${t('themePreview.warmingAllSkipped', { count: skipped })}`); + } + return { built, skipped }; +} + +/** + * Rebuild active theme and restart PM2 visitor process (production deploy). + */ +async function restartProductionVisitorClient(projectRoot = resolveProjectRoot()) { + const { activeTheme, themeDir } = buildActiveTheme(projectRoot); + const bin = resolveThemeClientBin(projectRoot, themeDir); + const env = resolveProductionThemeEnv(projectRoot, themeDir); + + spawnSync('pm2', ['delete', 'reactpress-client'], { stdio: 'ignore' }); + spawnSync('pm2', ['delete', '@fecommunity/reactpress-template-hello-world'], { + stdio: 'ignore', + }); + + console.log(`[reactpress] ${t('themeProd.restarting', { id: activeTheme })}`); + await runNodeScript(bin, ['--pm2'], { cwd: projectRoot, env }); + console.log(`[reactpress] ${t('themeProd.restarted', { id: activeTheme })}`); +} + +module.exports = { + PREVIEW_DIST_DIR, + buildActiveTheme, + buildTheme, + enqueueThemeBuild, + scheduleBackgroundThemeBuilds, + warmupAllPreviewThemeBuilds, + restartProductionVisitorClient, + resolveProductionThemeEnv, + resolvePreviewThemeEnv, + ensureThemeDependenciesInstalled, + hasUsableProductionBuild, + readActiveThemeBuildState, + resolveThemeBuildState, + writeThemeBuildStamp, +}; diff --git a/cli/src/lib/theme-registry.ts b/cli/src/lib/theme-registry.ts new file mode 100644 index 00000000..1cb67f5f --- /dev/null +++ b/cli/src/lib/theme-registry.ts @@ -0,0 +1,151 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +const { getCliPackageRoot } = require('./paths'); +const { + THEMES_PACKAGE_REL, + themesRoot, +} = require('./theme-paths'); +const { + readThemesRegistryMeta, + readThemesPackageMeta, + readNpmThemeSources, + readThemeSources, + readNpmEntryFromPackageDir, + normalizeNpmCatalogEntry, + isValidNpmCatalogEntry, + validateLocalThemes, + validateNpmThemes, + validateBundledThemes, + validateCatalogThemes, +} = require('./theme-sources'); + +/** Official npm spec for the theme-starter package. */ +const OFFICIAL_THEME_STARTER_SPEC = '@fecommunity/reactpress-theme-starter@1.0.0-beta.0'; +const OFFICIAL_THEME_STARTER_ID = 'reactpress-theme-starter'; +/** Catalog anchor directory under themes/ (metadata in package.json). */ +const OFFICIAL_THEME_STARTER_DIR = 'theme-starter'; + +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function readLegacyCatalogFile(projectRoot, filePath) { + if (!fs.existsSync(filePath)) return null; + const raw = readJsonFile(filePath); + if (!raw) return null; + const themes = (Array.isArray(raw.themes) ? raw.themes : []) + .map(normalizeNpmCatalogEntry) + .filter(Boolean); + if (!themes.length) return null; + return { + version: typeof raw.version === 'number' ? raw.version : 1, + themes, + source: path.relative(path.resolve(projectRoot), filePath) || filePath, + }; +} + +function readCatalogFromRegistry(projectRoot) { + const npmSources = readNpmThemeSources(projectRoot); + if (npmSources.length) { + return { + version: 1, + themes: npmSources.map(({ kind, ...entry }) => entry), + source: THEMES_PACKAGE_REL, + }; + } + return null; +} + +/** Aggregate npm catalog from themes/package.json, then CLI template fallback. */ +function readThemeCatalog(projectRoot) { + const fromRegistry = readCatalogFromRegistry(projectRoot); + if (fromRegistry) return fromRegistry; + + const legacyCatalog = path.join(themesRoot(projectRoot), 'catalog.json'); + const legacy = readLegacyCatalogFile(projectRoot, legacyCatalog); + if (legacy) return legacy; + + const templatePath = path.join(getCliPackageRoot(), 'templates', 'theme-catalog.json'); + const fromTemplate = readLegacyCatalogFile(projectRoot, templatePath); + if (fromTemplate) return fromTemplate; + + return { version: 1, themes: [], source: null }; +} + +function findCatalogTheme(projectRoot, idOrSpec) { + const needle = String(idOrSpec || '').trim(); + if (!needle) return null; + const { themes } = readThemeCatalog(projectRoot); + return ( + themes.find((entry) => entry.id === needle || entry.npm === needle) ?? + themes.find((entry) => entry.npm.startsWith(needle) || needle.startsWith(entry.id)) ?? + null + ); +} + +function resolveCatalogInstallSpec(projectRoot, input) { + const trimmed = String(input || '').trim(); + if (!trimmed) return null; + if (trimmed === 'reactpress-theme-starter' || trimmed === OFFICIAL_THEME_STARTER_ID) { + const fromCatalog = findCatalogTheme(projectRoot, OFFICIAL_THEME_STARTER_ID); + if (fromCatalog?.npm) return fromCatalog.npm; + return OFFICIAL_THEME_STARTER_SPEC; + } + const fromCatalog = findCatalogTheme(projectRoot, trimmed); + if (fromCatalog?.npm) return fromCatalog.npm; + return trimmed; +} + +/** Map npm catalog metadata to a minimal ThemeManifest-shaped object. */ +function catalogEntryToManifest(entry) { + if (!entry) return null; + return { + id: entry.id, + name: entry.name, + version: entry.version, + description: entry.description, + author: entry.author, + themeUri: entry.themeUri, + previewUrl: entry.previewUrl, + cover: entry.cover, + tags: entry.tags, + requires: entry.requires, + }; +} + +/** Build aggregated catalog JSON for CLI template sync. */ +function buildAggregatedCatalog(projectRoot) { + const { themes } = readCatalogFromRegistry(projectRoot) ?? { themes: [] }; + return { + version: 1, + themes: themes.map(({ dir, ...entry }) => entry), + }; +} + +module.exports = { + OFFICIAL_THEME_STARTER_SPEC, + OFFICIAL_THEME_STARTER_ID, + OFFICIAL_THEME_STARTER_DIR, + isValidNpmCatalogEntry, + isValidCatalogEntry: isValidNpmCatalogEntry, + normalizeCatalogEntry: normalizeNpmCatalogEntry, + readThemesRegistryMeta, + readThemesPackageMeta, + readPackageCatalogEntry: readNpmEntryFromPackageDir, + readThemeSources, + readThemeCatalog, + findCatalogTheme, + resolveCatalogInstallSpec, + catalogEntryToManifest, + validateLocalThemes, + validateNpmThemes, + validateBundledThemes, + validateCatalogThemes, + buildAggregatedCatalog, +}; diff --git a/cli/src/lib/theme-runtime.ts b/cli/src/lib/theme-runtime.ts new file mode 100644 index 00000000..cf054603 --- /dev/null +++ b/cli/src/lib/theme-runtime.ts @@ -0,0 +1,377 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +const { DEV_PORTS, BLOCKED_THEME_DEV_PORTS } = require('./ports'); +const { + REACTPRESS_DIR, + THEME_RUNTIME_REL, + LEGACY_THEMES_RUNTIME_REL, + ACTIVE_THEME_MANIFEST_REL, + PREVIEW_THEME_MANIFEST_REL, + THEMES_RESERVED_SUBDIRS, + THEMES_LEGACY_STARTER_SUBDIRS, + THEME_ID_RE, + DEFAULT_ACTIVE_THEME, +} = require('./theme-paths'); + +const MANIFEST_REL = ACTIVE_THEME_MANIFEST_REL; +const PREVIEW_MANIFEST_REL = PREVIEW_THEME_MANIFEST_REL; +const DEFAULT_PREVIEW_THEME_PORT = DEV_PORTS.THEME_PREVIEW; +const BLOCKED_DEV_PORTS = BLOCKED_THEME_DEV_PORTS; + +/** Desktop dev stores manifests under `.reactpress/desktop-dev-site/` (embedded SQLite site root). */ +function themeWorkspaceRoot(projectRoot) { + const site = process.env.REACTPRESS_DESKTOP_SITE_ROOT?.trim(); + return site ? path.resolve(site) : path.resolve(projectRoot); +} + +function resolveMonorepoRoot(projectRoot) { + const fromEnv = process.env.REACTPRESS_MONOREPO_ROOT?.trim(); + if (fromEnv) return path.resolve(fromEnv); + let dir = path.resolve(projectRoot); + for (let depth = 0; depth < 10; depth += 1) { + if (fs.existsSync(path.join(dir, 'cli', 'lib', 'theme-registry.js'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return path.resolve(projectRoot); +} + +function isValidThemeId(id) { + return typeof id === 'string' && THEME_ID_RE.test(id) && id.length <= 64; +} + +function isUnderDir(child, parent) { + const rel = path.relative(path.resolve(parent), path.resolve(child)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function themeRoots(projectRoot) { + const root = themeWorkspaceRoot(projectRoot); + const themes = path.join(root, 'themes'); + return { + themes, + runtime: path.join(root, THEME_RUNTIME_REL), + legacyThemesRuntime: path.join(root, LEGACY_THEMES_RUNTIME_REL), + legacyStarter: THEMES_LEGACY_STARTER_SUBDIRS.map((name) => path.join(themes, name)), + legacyBundled: path.join(root, 'templates'), + }; +} + +function isThemePackageAt(dir) { + return ( + fs.existsSync(path.join(dir, 'package.json')) || + fs.existsSync(path.join(dir, 'theme.json')) + ); +} + +/** `readdir` symlinks report as links, not directories — follow for desktop-dev-site theme seeds. */ +function isResolvableThemeDirEntry(entry, parentDir) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return false; + try { + return fs.statSync(path.join(parentDir, entry.name)).isDirectory(); + } catch { + return false; + } +} + +function isThemePackageDir(projectRoot, dir) { + if (!dir) return false; + const resolved = path.resolve(dir); + const { themes, runtime, legacyThemesRuntime, legacyStarter, legacyBundled } = + themeRoots(projectRoot); + + if (isUnderDir(resolved, runtime) && isThemePackageAt(resolved)) { + return true; + } + + if (isUnderDir(resolved, legacyThemesRuntime) && isThemePackageAt(resolved)) { + return true; + } + + if (isUnderDir(resolved, themes)) { + const rel = path.relative(themes, resolved); + const top = rel.split(path.sep)[0]; + if (top && !THEMES_RESERVED_SUBDIRS.includes(top) && isThemePackageAt(resolved)) { + return true; + } + } + + for (const base of [...legacyStarter, legacyBundled]) { + if (!fs.existsSync(base)) continue; + if (isUnderDir(resolved, base) && isThemePackageAt(resolved)) { + return true; + } + } + + return false; +} + +function isAllowedThemePort(port) { + const n = Number(port); + return Number.isInteger(n) && n >= 1024 && n <= 65535 && !BLOCKED_DEV_PORTS.has(n); +} + +function isAllowedThemeDirRel(themeDir) { + if (typeof themeDir !== 'string') return false; + if (themeDir.includes('..') || path.isAbsolute(themeDir)) return false; + + if (themeDir.startsWith(`${THEME_RUNTIME_REL}/`)) { + return true; + } + + if (themeDir.startsWith(`${LEGACY_THEMES_RUNTIME_REL}/`)) { + return true; + } + + if (themeDir.startsWith('themes/') && !themeDir.startsWith(`${LEGACY_THEMES_RUNTIME_REL}/`)) { + const rest = themeDir.slice('themes/'.length); + const top = rest.split('/')[0]; + if (top && !THEMES_RESERVED_SUBDIRS.includes(top)) { + return true; + } + } + + const legacyPrefixes = THEMES_LEGACY_STARTER_SUBDIRS.map((name) => `themes/${name}/`); + if (legacyPrefixes.some((prefix) => themeDir.startsWith(prefix))) { + return true; + } + + return themeDir.startsWith('templates/'); +} + +function readActiveThemeManifest(projectRoot) { + const manifestPath = path.join(themeWorkspaceRoot(projectRoot), MANIFEST_REL); + if (!fs.existsSync(manifestPath)) { + return { activeTheme: DEFAULT_ACTIVE_THEME }; + } + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const id = + typeof raw.activeTheme === 'string' && isValidThemeId(raw.activeTheme) + ? raw.activeTheme + : DEFAULT_ACTIVE_THEME; + const themeDir = isAllowedThemeDirRel(raw.themeDir) ? raw.themeDir : undefined; + return { activeTheme: id, themeDir, updatedAt: raw.updatedAt }; + } catch { + return { activeTheme: DEFAULT_ACTIVE_THEME }; + } +} + +function resolveThemeDirectory(projectRoot, themeId) { + if (!isValidThemeId(themeId)) return null; + + const root = themeWorkspaceRoot(projectRoot); + const runtime = path.join(root, THEME_RUNTIME_REL, themeId); + if (isThemePackageAt(runtime)) return runtime; + + const legacyRuntime = path.join(root, LEGACY_THEMES_RUNTIME_REL, themeId); + if (isThemePackageAt(legacyRuntime)) return legacyRuntime; + + const template = path.join(root, 'themes', themeId); + if (isThemePackageAt(template) && !THEMES_RESERVED_SUBDIRS.includes(themeId)) { + return template; + } + + for (const legacyStarterName of THEMES_LEGACY_STARTER_SUBDIRS) { + const legacyStarter = path.join(root, 'themes', legacyStarterName, themeId); + if (isThemePackageAt(legacyStarter)) return legacyStarter; + } + + const legacyBundled = path.join(root, 'templates', themeId); + if (isThemePackageAt(legacyBundled)) return legacyBundled; + + const mono = resolveMonorepoRoot(projectRoot); + if (mono !== root) { + const monoRuntime = path.join(mono, THEME_RUNTIME_REL, themeId); + if (isThemePackageAt(monoRuntime)) return monoRuntime; + const monoTheme = path.join(mono, 'themes', themeId); + if (isThemePackageAt(monoTheme) && !THEMES_RESERVED_SUBDIRS.includes(themeId)) { + return monoTheme; + } + } + + return null; +} + +function readManifestSignatureFromPath(projectRoot, manifestRel) { + const root = themeWorkspaceRoot(projectRoot); + const manifestPath = path.join(root, manifestRel); + try { + if (!fs.existsSync(manifestPath)) return ''; + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const id = typeof raw.activeTheme === 'string' ? raw.activeTheme : ''; + if (!isValidThemeId(id)) return ''; + const themeDir = resolveThemeDirectory(projectRoot, id); + if (!themeDir) return ''; + const rel = path.relative(root, themeDir); + return `${id}:${rel}`; + } catch { + return ''; + } +} + +function readManifestSignature(projectRoot) { + return readManifestSignatureFromPath(projectRoot, MANIFEST_REL); +} + +function readPreviewManifestSignature(projectRoot) { + const root = themeWorkspaceRoot(projectRoot); + const manifestPath = path.join(root, PREVIEW_MANIFEST_REL); + try { + if (!fs.existsSync(manifestPath)) return ''; + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const id = typeof raw.activeTheme === 'string' ? raw.activeTheme : ''; + if (!isValidThemeId(id)) return ''; + const themeDir = resolveThemeDirectory(projectRoot, id); + if (!themeDir) return ''; + const rel = path.relative(root, themeDir); + const stamp = typeof raw.updatedAt === 'string' ? raw.updatedAt : ''; + return `${id}:${rel}:${stamp}`; + } catch { + return ''; + } +} + +function readPreviewThemeManifest(projectRoot) { + const root = themeWorkspaceRoot(projectRoot); + const manifestPath = path.join(root, PREVIEW_MANIFEST_REL); + if (!fs.existsSync(manifestPath)) { + return null; + } + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const id = + typeof raw.activeTheme === 'string' && isValidThemeId(raw.activeTheme) + ? raw.activeTheme + : null; + if (!id) return null; + const themeDir = resolveThemeDirectory(projectRoot, id); + return { activeTheme: id, themeDir: themeDir ? path.relative(root, themeDir) : null }; + } catch { + return null; + } +} + +function getPreviewThemePort() { + const fromEnv = parseInt(process.env.REACTPRESS_PREVIEW_PORT || '', 10); + if (Number.isInteger(fromEnv) && isAllowedThemePort(fromEnv)) { + return String(fromEnv); + } + return String(DEFAULT_PREVIEW_THEME_PORT); +} + +function hasResolvableActiveTheme(projectRoot) { + if (!hasThemePackages(projectRoot)) return false; + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + return Boolean(themeDir && isThemePackageDir(projectRoot, themeDir)); +} + +/** Installed / bundled theme ids (active-theme.json entries may point into runtime/). */ +function listAvailableThemeIds(projectRoot) { + const ids = new Set(); + const { themes, runtime, legacyThemesRuntime, legacyStarter, legacyBundled } = + themeRoots(projectRoot); + + for (const dir of [runtime, legacyThemesRuntime]) { + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!isResolvableThemeDirEntry(entry, dir) || !isValidThemeId(entry.name)) continue; + if (isThemePackageAt(path.join(dir, entry.name))) ids.add(entry.name); + } + } + + if (fs.existsSync(themes)) { + for (const entry of fs.readdirSync(themes, { withFileTypes: true })) { + if (!isResolvableThemeDirEntry(entry, themes)) continue; + if (THEMES_RESERVED_SUBDIRS.includes(entry.name)) continue; + if (!isValidThemeId(entry.name)) continue; + if (isThemePackageAt(path.join(themes, entry.name))) ids.add(entry.name); + } + } + + for (const base of [...legacyStarter, legacyBundled]) { + if (!fs.existsSync(base)) continue; + for (const entry of fs.readdirSync(base, { withFileTypes: true })) { + if (!isResolvableThemeDirEntry(entry, base) || !isValidThemeId(entry.name)) continue; + if (isThemePackageAt(path.join(base, entry.name))) ids.add(entry.name); + } + } + + return [...ids].sort(); +} + +function hasThemePackages(projectRoot) { + const { themes, runtime, legacyThemesRuntime, legacyStarter, legacyBundled } = + themeRoots(projectRoot); + + for (const dir of [runtime, legacyThemesRuntime]) { + if (!fs.existsSync(dir)) continue; + if ( + fs + .readdirSync(dir, { withFileTypes: true }) + .some((entry) => isResolvableThemeDirEntry(entry, dir)) + ) { + return true; + } + } + + if (fs.existsSync(themes)) { + if ( + fs + .readdirSync(themes, { withFileTypes: true }) + .some( + (entry) => + isResolvableThemeDirEntry(entry, themes) && + !THEMES_RESERVED_SUBDIRS.includes(entry.name), + ) + ) { + return true; + } + } + + for (const dir of [...legacyStarter, legacyBundled]) { + if (!fs.existsSync(dir)) continue; + if ( + fs + .readdirSync(dir, { withFileTypes: true }) + .some((entry) => isResolvableThemeDirEntry(entry, dir)) + ) { + return true; + } + } + + return false; +} + +module.exports = { + MANIFEST_REL, + PREVIEW_MANIFEST_REL, + DEFAULT_PREVIEW_THEME_PORT, + THEME_ID_RE, + THEME_RUNTIME_REL, + LEGACY_THEMES_RUNTIME_REL, + THEMES_RESERVED_SUBDIRS, + THEMES_LEGACY_STARTER_SUBDIRS, + BLOCKED_DEV_PORTS, + isValidThemeId, + isThemePackageDir, + isAllowedThemePort, + isAllowedThemeDirRel, + readActiveThemeManifest, + resolveThemeDirectory, + readManifestSignature, + readPreviewManifestSignature, + readPreviewThemeManifest, + getPreviewThemePort, + hasThemePackages, + hasResolvableActiveTheme, + listAvailableThemeIds, + themeRoots, + themeWorkspaceRoot, +}; diff --git a/cli/src/lib/theme-sources.ts b/cli/src/lib/theme-sources.ts new file mode 100644 index 00000000..61422f4b --- /dev/null +++ b/cli/src/lib/theme-sources.ts @@ -0,0 +1,377 @@ +// @ts-nocheck +/** + * ReactPress theme sources — unified model. + * + * TWO SOURCES (how a theme is installed into `.reactpress/runtime/{id}/`): + * local — copy from `themes/{id}/` (must contain `theme.json`) + * npm — `npm pack` a package spec, then copy into runtime + * + * NPM REGISTRY SPEC (canonical): + * themes/{anchor}/package.json → dependencies + reactpress.theme (see npm-catalog.schema.json) + * themes/package.json → reactpress.npm: ["{anchor}", …] + * + * THREE LAYERS: + * themes/ registry — what is available to install + * .reactpress/runtime/ materialized — installed copies the CLI runs + * DB + *.json activation — which theme is active / previewing + * + * Legacy: reactpress.bundled / catalog keys; inline objects in reactpress.npm array. + */ +const fs = require('fs'); +const path = require('path'); + +const { THEMES_PACKAGE_REL, themesRoot } = require('./theme-paths'); + +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function formatNpmInstallSpec(name, version) { + return `${name.trim()}@${version.trim()}`; +} + +/** Split `package@version` into structured dependency (supports scoped packages). */ +function parseNpmSpecToDependency(spec) { + const trimmed = String(spec || '').trim(); + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex <= 0) return null; + const name = trimmed.slice(0, atIndex).trim(); + const version = trimmed.slice(atIndex + 1).trim(); + if (!name || !version) return null; + return { name, version }; +} + +function readPackageDependencies(raw) { + const deps = raw?.dependencies; + if (!deps || typeof deps !== 'object') return null; + for (const [name, version] of Object.entries(deps)) { + if (isNonEmptyString(name) && isNonEmptyString(version)) { + return { name: name.trim(), version: String(version).trim() }; + } + } + return null; +} + +function readThemeDependency(theme, raw) { + const fromDeps = raw ? readPackageDependencies(raw) : null; + if (fromDeps) return fromDeps; + + if (theme && typeof theme === 'object') { + const dep = theme.dependency; + if (dep && typeof dep === 'object' && isNonEmptyString(dep.name) && isNonEmptyString(dep.version)) { + return { name: dep.name.trim(), version: dep.version.trim() }; + } + if (isNonEmptyString(theme.npm)) { + return parseNpmSpecToDependency(theme.npm); + } + } + + const pkgName = typeof raw?.name === 'string' ? raw.name : undefined; + const pkgVersion = typeof raw?.version === 'string' ? raw.version : undefined; + if (isNonEmptyString(pkgName) && isNonEmptyString(pkgVersion)) { + return { name: pkgName.trim(), version: pkgVersion.trim() }; + } + + return null; +} + +function resolveThemeNpmSpec(theme, raw) { + const dependency = readThemeDependency(theme, raw); + return dependency ? formatNpmInstallSpec(dependency.name, dependency.version) : undefined; +} + +function isValidNpmCatalogEntry(entry) { + return ( + entry && + isNonEmptyString(entry.id) && + isNonEmptyString(entry.name) && + (isNonEmptyString(entry.npm) || + (entry.dependency && + typeof entry.dependency === 'object' && + isNonEmptyString(entry.dependency.name) && + isNonEmptyString(entry.dependency.version))) + ); +} + +function normalizeNpmCatalogEntry(entry) { + if (!entry || !isNonEmptyString(entry.id) || !isNonEmptyString(entry.name)) return null; + + let dependency = + entry.dependency && + typeof entry.dependency === 'object' && + isNonEmptyString(entry.dependency.name) && + isNonEmptyString(entry.dependency.version) + ? { name: entry.dependency.name.trim(), version: entry.dependency.version.trim() } + : undefined; + + let npmSpec = isNonEmptyString(entry.npm) ? entry.npm.trim() : undefined; + if (dependency) { + npmSpec = formatNpmInstallSpec(dependency.name, dependency.version); + } else if (npmSpec) { + dependency = parseNpmSpecToDependency(npmSpec) ?? undefined; + } + + if (!npmSpec) return null; + + return { + id: entry.id.trim(), + name: entry.name, + version: typeof entry.version === 'string' ? entry.version : '0.0.0', + description: entry.description, + author: entry.author, + authorUri: entry.authorUri, + themeUri: entry.themeUri, + previewUrl: entry.previewUrl, + cover: entry.cover, + tags: Array.isArray(entry.tags) ? entry.tags : undefined, + dependency, + npm: npmSpec, + featured: entry.featured === true, + requires: entry.requires, + dir: typeof entry.dir === 'string' ? entry.dir.trim() : undefined, + }; +} + +/** Read `themes/package.json` registry lists (local ids + npm catalog refs). */ +function readThemesRegistryMeta(projectRoot) { + const pkgPath = path.join(path.resolve(projectRoot), THEMES_PACKAGE_REL); + if (!fs.existsSync(pkgPath)) { + return { local: [], npm: [] }; + } + + const raw = readJsonFile(pkgPath); + if (!raw?.reactpress || typeof raw.reactpress !== 'object') { + return { local: [], npm: [] }; + } + + const reactpress = raw.reactpress; + const localSource = reactpress.local ?? reactpress.bundled; + const npmSource = reactpress.npm ?? reactpress.catalog; + + const local = Array.isArray(localSource) + ? localSource.filter((id) => isNonEmptyString(id)).map((id) => id.trim()) + : []; + + const npm = Array.isArray(npmSource) ? npmSource : []; + + return { local, npm }; +} + +/** @deprecated Use readThemesRegistryMeta — kept for existing imports. */ +function readThemesPackageMeta(projectRoot) { + const { local, npm } = readThemesRegistryMeta(projectRoot); + const catalog = npm + .map((item) => { + if (isNonEmptyString(item)) return item.trim(); + if (item && typeof item === 'object' && isNonEmptyString(item.id)) return item.id.trim(); + return null; + }) + .filter(Boolean); + return { bundled: local, catalog, local, npm }; +} + +function readNpmEntryFromPackageDir(catalogDir, pkgPath) { + const raw = readJsonFile(pkgPath); + if (!raw) return null; + + const theme = + raw.reactpress && typeof raw.reactpress === 'object' && raw.reactpress.theme + ? raw.reactpress.theme + : null; + if (!theme || !isNonEmptyString(theme.id)) return null; + + const pkgVersion = typeof raw.version === 'string' ? raw.version : '0.0.0'; + const dependency = readThemeDependency(theme, raw); + const npmSpec = dependency ? formatNpmInstallSpec(dependency.name, dependency.version) : undefined; + if (!npmSpec) return null; + + return normalizeNpmCatalogEntry({ + id: theme.id, + dir: catalogDir, + name: + isNonEmptyString(theme.name) ? theme.name : isNonEmptyString(raw.description) ? raw.description : theme.id, + version: isNonEmptyString(theme.version) ? theme.version : dependency.version ?? pkgVersion, + description: + isNonEmptyString(theme.description) ? theme.description : isNonEmptyString(raw.description) ? raw.description : undefined, + author: isNonEmptyString(theme.author) ? theme.author : raw.author, + authorUri: isNonEmptyString(theme.authorUri) ? theme.authorUri : undefined, + themeUri: + isNonEmptyString(theme.themeUri) ? theme.themeUri : isNonEmptyString(raw.homepage) ? raw.homepage : undefined, + previewUrl: isNonEmptyString(theme.previewUrl) ? theme.previewUrl.trim() : undefined, + cover: isNonEmptyString(theme.cover) ? theme.cover.trim() : undefined, + tags: Array.isArray(theme.tags) ? theme.tags : undefined, + dependency, + npm: npmSpec, + featured: theme.featured === true, + requires: isNonEmptyString(theme.requires) ? theme.requires : undefined, + }); +} + +function readInlineNpmEntry(item) { + if (!item || typeof item !== 'object') return null; + const dependency = + readPackageDependencies(item) ?? + (item.dependency && + typeof item.dependency === 'object' && + isNonEmptyString(item.dependency.name) && + isNonEmptyString(item.dependency.version) + ? { name: item.dependency.name.trim(), version: item.dependency.version.trim() } + : isNonEmptyString(item.npm) + ? parseNpmSpecToDependency(item.npm) + : undefined); + if (!dependency && !isNonEmptyString(item.npm)) return null; + return normalizeNpmCatalogEntry({ + id: item.id, + name: item.name, + version: item.version, + description: item.description, + author: item.author, + authorUri: item.authorUri, + themeUri: item.themeUri, + previewUrl: item.previewUrl, + cover: item.cover, + tags: item.tags, + dependency, + npm: dependency ? formatNpmInstallSpec(dependency.name, dependency.version) : item.npm, + featured: item.featured, + requires: item.requires, + }); +} + +/** Local theme ids registered in themes/package.json with theme.json on disk. */ +function readLocalThemeSources(projectRoot) { + const root = path.resolve(projectRoot); + const { local } = readThemesRegistryMeta(root); + const templates = themesRoot(root); + const sources = []; + + for (const id of local) { + const themeJson = path.join(templates, id, 'theme.json'); + if (!fs.existsSync(themeJson)) continue; + const manifest = readJsonFile(themeJson); + if (!manifest?.id) continue; + sources.push({ + kind: 'local', + id: manifest.id, + dir: id, + manifest, + }); + } + + return sources; +} + +/** npm catalog entries — anchor dirs (themes/{anchor}/package.json) are canonical. */ +function readNpmThemeSources(projectRoot) { + const root = path.resolve(projectRoot); + const { npm } = readThemesRegistryMeta(root); + const templates = themesRoot(root); + const byId = new Map(); + + for (const item of npm) { + if (isNonEmptyString(item)) { + const dir = item.trim(); + const entry = readNpmEntryFromPackageDir(dir, path.join(templates, dir, 'package.json')); + if (entry) byId.set(entry.id, { kind: 'npm', ...entry }); + continue; + } + + const inline = readInlineNpmEntry(item); + if (inline) byId.set(inline.id, { kind: 'npm', ...inline }); + } + + return [...byId.values()]; +} + +/** Combined registry view used by CLI and server. */ +function readThemeSources(projectRoot) { + return { + local: readLocalThemeSources(projectRoot), + npm: readNpmThemeSources(projectRoot), + }; +} + +function validateLocalThemes(projectRoot) { + const root = path.resolve(projectRoot); + const { local } = readThemesRegistryMeta(root); + const missing = []; + const templates = themesRoot(root); + + for (const id of local) { + const themeJson = path.join(templates, id, 'theme.json'); + if (!fs.existsSync(themeJson)) { + missing.push(id); + } + } + return { local, missing }; +} + +/** @deprecated Use validateLocalThemes */ +function validateBundledThemes(projectRoot) { + const { local, missing } = validateLocalThemes(projectRoot); + return { bundled: local, missing }; +} + +function validateNpmThemes(projectRoot) { + const root = path.resolve(projectRoot); + const { npm } = readThemesRegistryMeta(root); + const missing = []; + const templates = themesRoot(root); + + for (const item of npm) { + if (isNonEmptyString(item)) { + const dir = item.trim(); + const pkgPath = path.join(templates, dir, 'package.json'); + const themeJson = path.join(templates, dir, 'theme.json'); + if (fs.existsSync(themeJson)) { + missing.push(`${dir}/ must not contain theme.json (npm anchor vs local theme)`); + } + const entry = readNpmEntryFromPackageDir(dir, pkgPath); + if (!entry) missing.push(`${dir}/package.json (dependencies + reactpress.theme)`); + continue; + } + if (!readInlineNpmEntry(item)) { + missing.push(`inline npm entry (id + dependencies or npm required): ${JSON.stringify(item)}`); + } + } + + return { npm, missing }; +} + +/** @deprecated Use validateNpmThemes */ +function validateCatalogThemes(projectRoot) { + const { npm, missing } = validateNpmThemes(projectRoot); + const catalog = npm + .map((item) => (isNonEmptyString(item) ? item.trim() : null)) + .filter(Boolean); + return { catalog, missing }; +} + +module.exports = { + readThemesRegistryMeta, + readThemesPackageMeta, + readLocalThemeSources, + readNpmThemeSources, + readThemeSources, + readNpmEntryFromPackageDir, + readInlineNpmEntry, + formatNpmInstallSpec, + parseNpmSpecToDependency, + readPackageDependencies, + readThemeDependency, + resolveThemeNpmSpec, + normalizeNpmCatalogEntry, + isValidNpmCatalogEntry, + validateLocalThemes, + validateNpmThemes, + validateBundledThemes, + validateCatalogThemes, +}; diff --git a/cli/src/lib/theme-warmup.ts b/cli/src/lib/theme-warmup.ts new file mode 100644 index 00000000..12a9442d --- /dev/null +++ b/cli/src/lib/theme-warmup.ts @@ -0,0 +1,172 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { readActiveThemeManifest, resolveThemeDirectory } = require('./theme-runtime'); +const { loadClientSiteUrl } = require('./http'); + +/** Placeholder for dynamic segments — only triggers page bundle compilation in dev. */ +const WARMUP_PARAM = '__reactpress_dev_warmup__'; + +function pageFileToRoute(pageFile) { + let route = String(pageFile) + .replace(/^pages\//, '') + .replace(/\.(tsx|ts|jsx|js)$/, ''); + if (route === 'index') return '/'; + route = route.replace(/\/index$/, ''); + route = route.replace(/\[([^\]]+)\]/g, WARMUP_PARAM); + return `/${route}`; +} + +/** Dynamic SSR routes need real API data — warmup only compiles static visitor pages. */ +function isWarmupSafeRoute(route) { + if (!route || typeof route !== 'string') return false; + if (route.includes(WARMUP_PARAM)) return false; + if (route.startsWith('/admin')) return false; + return true; +} + +function collectWarmupRoutes(themeDir) { + const themeJsonPath = path.join(themeDir, 'theme.json'); + const routes = new Set(['/']); + let fromThemeJson = false; + + if (fs.existsSync(path.join(themeDir, 'app'))) { + routes.add('/'); + } + + if (fs.existsSync(themeJsonPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(themeJsonPath, 'utf8')); + const templates = manifest?.reactpress?.templates; + if (templates && typeof templates === 'object') { + fromThemeJson = true; + for (const file of Object.values(templates)) { + if (typeof file !== 'string') continue; + const route = pageFileToRoute(file); + if (isWarmupSafeRoute(route)) routes.add(route); + } + } + } catch { + // fall through to pages scan + } + } + + if (!fromThemeJson) { + const pagesDir = path.join(themeDir, 'pages'); + if (fs.existsSync(pagesDir)) { + walkPages(pagesDir, pagesDir).forEach((file) => { + const rel = path.relative(themeDir, file); + if (rel.startsWith(`pages${path.sep}admin${path.sep}`) || rel.includes(`${path.sep}admin${path.sep}`)) { + return; + } + const route = pageFileToRoute(rel); + if (isWarmupSafeRoute(route)) routes.add(route); + }); + } + } + + routes.add('/404'); + return [...routes].filter(isWarmupSafeRoute); +} + +function walkPages(pagesDir, currentDir, files = []) { + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + if (entry.name.startsWith('_') || entry.name === 'api' || entry.name === 'admin') continue; + walkPages(pagesDir, fullPath, files); + continue; + } + if (/\.(tsx|ts|jsx|js)$/.test(entry.name)) { + if (entry.name.startsWith('_')) continue; + files.push(fullPath); + } + } + return files; +} + +function fetchRoute(baseUrl, routePath) { + return new Promise((resolve) => { + const normalizedBase = String(baseUrl || 'http://127.0.0.1:3001').replace(/\/$/, ''); + const url = `${normalizedBase}${routePath.startsWith('/') ? routePath : `/${routePath}`}`; + const req = http.get(url, { timeout: 120_000 }, (res) => { + res.resume(); + resolve(res.statusCode >= 200 && res.statusCode < 500); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function mapWithConcurrency(items, concurrency, fn) { + if (!items.length) return []; + const limit = Math.max(1, Math.min(concurrency, items.length)); + const results = new Array(items.length); + let index = 0; + + async function worker() { + while (index < items.length) { + const i = index; + index += 1; + results[i] = await fn(items[i], i); + } + } + + await Promise.all(Array.from({ length: limit }, () => worker())); + return results; +} + +/** + * SSR-hit theme routes on the internal dev port so Next.js compiles page chunks + * before the browser performs client-side navigation (avoids webpack module mismatch). + */ +async function warmupThemeDevRoutes(projectRoot) { + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + if (!themeDir) return { ok: false, routes: [] }; + + const baseUrl = loadClientSiteUrl(projectRoot); + const routes = collectWarmupRoutes(themeDir); + const concurrency = Math.max( + 1, + parseInt(process.env.REACTPRESS_THEME_WARMUP_CONCURRENCY || '6', 10) || 6, + ); + await mapWithConcurrency(routes, concurrency, (route) => fetchRoute(baseUrl, route)); + return { ok: true, routes, themeId: activeTheme }; +} + +function shouldBlockOnThemeWarmup() { + return process.env.REACTPRESS_THEME_WARMUP === '1'; +} + +/** Fire-and-forget SSR compile — off by default (saves ~10–20s Next compile after banner). */ +function warmupThemeDevRoutesInBackground(projectRoot) { + if (process.env.REACTPRESS_SKIP_THEME_WARMUP !== '0') return; + if (process.env.REACTPRESS_THEME_WARMUP !== '1') return; + warmupThemeDevRoutes(projectRoot).catch(() => { + // non-fatal — first browser navigation will compile anyway + }); +} + +/** SSR-hit homepage so first visitor load after theme switch is fast. */ +async function warmupThemeHomepage(projectRoot, baseUrl) { + const url = (baseUrl || loadClientSiteUrl(projectRoot)).replace(/\/$/, ''); + await fetchRoute(url, '/'); + return { ok: true }; +} + +module.exports = { + WARMUP_PARAM, + pageFileToRoute, + isWarmupSafeRoute, + collectWarmupRoutes, + mapWithConcurrency, + shouldBlockOnThemeWarmup, + warmupThemeDevRoutes, + warmupThemeDevRoutesInBackground, + warmupThemeHomepage, +}; diff --git a/cli/src/lib/toolkit-build.ts b/cli/src/lib/toolkit-build.ts new file mode 100644 index 00000000..35b8b334 --- /dev/null +++ b/cli/src/lib/toolkit-build.ts @@ -0,0 +1,54 @@ +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); + +const SOURCE_EXT = /\.(ts|tsx|js|json)$/; + +function newestSourceMtime(dir, depth = 0) { + if (!fs.existsSync(dir)) return 0; + let max = 0; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (depth < 12) max = Math.max(max, newestSourceMtime(full, depth + 1)); + continue; + } + if (!SOURCE_EXT.test(entry.name)) continue; + max = Math.max(max, fs.statSync(full).mtimeMs); + } + return max; +} + +/** + * Whether `pnpm run build` in toolkit/ is needed before dev. + * Skips when dist is newer than all toolkit/src sources (unless forced). + */ +function shouldBuildToolkit(projectRoot) { + if (process.env.REACTPRESS_FORCE_TOOLKIT_BUILD === '1') return true; + if (process.env.REACTPRESS_SKIP_TOOLKIT_BUILD === '1') return false; + + const toolkitDir = path.join(path.resolve(projectRoot), 'toolkit'); + const distEntry = path.join(toolkitDir, 'dist', 'index.js'); + if (!fs.existsSync(distEntry)) return true; + + const srcDir = path.join(toolkitDir, 'src'); + if (!fs.existsSync(srcDir)) return false; + + const distMtime = fs.statSync(distEntry).mtimeMs; + const localesDir = path.join(toolkitDir, 'src', 'config', 'locales'); + const localesDist = path.join(toolkitDir, 'dist', 'config', 'locales'); + if (fs.existsSync(localesDir)) { + const localesMtime = newestSourceMtime(localesDir); + if (!fs.existsSync(localesDist) || localesMtime > fs.statSync(localesDist).mtimeMs) { + return true; + } + } + + return newestSourceMtime(srcDir) > distMtime; +} + +module.exports = { + shouldBuildToolkit, + newestSourceMtime, +}; diff --git a/cli/src/types/config.ts b/cli/src/types/config.ts new file mode 100644 index 00000000..243fe3b2 --- /dev/null +++ b/cli/src/types/config.ts @@ -0,0 +1,64 @@ +/** ReactPress 4.x 数据库模式 */ +export type DatabaseMode = 'embedded-docker' | 'external' | 'embedded-sqlite'; + +export type DatabaseType = 'mysql' | 'sqlite'; + +export interface ReactPressConfig { + version: number; + database: { + mode: DatabaseMode; + containerName?: string; + host?: string; + port?: number; + user?: string; + password?: string; + database?: string; + /** SQLite 文件路径(相对项目根或绝对路径) */ + sqlitePath?: string; + }; + server: { + port: number; + clientUrl?: string; + serverUrl?: string; + apiPrefix?: string; + siteUrl?: string; + }; +} + +export interface EnvMap { + [key: string]: string; +} + +export interface MysqlCredentials { + host: string; + port: number; + user: string; + password: string; + database: string; +} + +export interface SqliteCredentials { + database: string; +} + +export interface DatabaseProfile { + type: DatabaseType; + mode: DatabaseMode; + mysql?: MysqlCredentials; + sqlite?: SqliteCredentials; +} + +export interface DatabaseEnsureResult { + ok: boolean; + message?: string; +} + +export interface ServerStatus { + running: boolean; + pid?: number; + port?: number; + url?: string; + databaseReady: boolean; + databaseMode: DatabaseMode; + databaseType: DatabaseType; +} diff --git a/cli/src/ui/banner-startup.ts b/cli/src/ui/banner-startup.ts new file mode 100644 index 00000000..3d8fb33d --- /dev/null +++ b/cli/src/ui/banner-startup.ts @@ -0,0 +1,139 @@ +// @ts-nocheck +const { + printBanner, + composeBannerLines, + deriveSystemState, + resolveLayout, + bannerColumns, +} = require('./banner'); +const { getCliVersion } = require('../lib/paths'); +const { fetchContextStatus, resolveDbType, resolveServiceChecks } = require('../lib/context-status'); + +const CARD_MIN = 68; +const FRAME_MS = 55; +const MIN_ANIMATION_MS = 2000; + +function isInteractiveTTY() { + return Boolean(process.stdout.isTTY && process.env.TERM !== 'dumb'); +} + +function mergeStartupComponents(serviceIds, checked) { + const byId = Object.fromEntries((checked || []).map((c) => [c.id, c.ok])); + return serviceIds.map((id) => { + if (byId[id] === undefined) return { id, ok: 'pending' }; + return { id, ok: byId[id] }; + }); +} + +function paintLines(lines, prevLineCount) { + if (prevLineCount > 0) { + process.stdout.write(`\x1b[${prevLineCount}A`); + } + for (const line of lines) { + process.stdout.write('\x1b[2K'); + process.stdout.write(`${line}\n`); + } +} + +async function runStartupBanner(projectRoot, project) { + const status = await fetchContextStatus(projectRoot); + printBanner({ + projectRoot, + project, + status, + systemState: deriveSystemState(project, status), + }); + return status; +} + +async function runAnimatedStartupBanner(projectRoot, project) { + const dbType = await resolveDbType(projectRoot); + const serviceIds = resolveServiceChecks(dbType); + const version = getCliVersion(); + const cols = bannerColumns(); + const { showAscii, width } = resolveLayout(cols); + + let total = 0; + let completed = 0; + let checked = []; + let frame = 0; + let prevLineCount = 0; + let timer = null; + let statusResult = null; + const animStart = Date.now(); + + function renderFrame(ready = false) { + const components = mergeStartupComponents(serviceIds, checked); + const bannerOpts = { + projectRoot, + project, + status: { components }, + systemState: ready ? deriveSystemState(project, { components }) : 'pending', + }; + const startup = { frame, completed, total, ready }; + return composeBannerLines(version, bannerOpts, { showAscii, width, startup }); + } + + function paint(ready = false) { + const lines = renderFrame(ready); + paintLines(lines, prevLineCount); + prevLineCount = lines.length; + } + + const fetchPromise = fetchContextStatus(projectRoot, { + onProgress({ phase, total: nextTotal, id, ok }) { + if (phase === 'ready') total = nextTotal; + if (phase === 'done' && id && !String(id).startsWith('__')) { + completed += 1; + checked = [...checked.filter((c) => c.id !== id), { id, ok: Boolean(ok) }]; + } + }, + }).then((result) => { + statusResult = result; + return result; + }); + + timer = setInterval(() => { + frame += 1; + paint(false); + }, FRAME_MS); + paint(false); + + const status = await fetchPromise; + if (timer) { + const remain = Math.max(0, MIN_ANIMATION_MS - (Date.now() - animStart)); + if (remain > 0) { + await new Promise((resolve) => setTimeout(resolve, remain)); + } + clearInterval(timer); + timer = null; + } + + completed = total; + + const systemState = deriveSystemState(project, status); + const finalLines = composeBannerLines( + version, + { projectRoot, project, status, systemState }, + { showAscii, width }, + ); + paintLines(finalLines, prevLineCount); + + return statusResult || status; +} + +async function refreshBannerWithStartup(projectRoot, project, { animated = true } = {}) { + const cols = bannerColumns(); + if (!animated || !isInteractiveTTY() || cols < CARD_MIN) { + return runStartupBanner(projectRoot, project); + } + return runAnimatedStartupBanner(projectRoot, project); +} + +module.exports = { + refreshBannerWithStartup, + runAnimatedStartupBanner, + runStartupBanner, + mergeStartupComponents, + isInteractiveTTY, +}; diff --git a/cli/src/ui/banner.ts b/cli/src/ui/banner.ts new file mode 100644 index 00000000..b1f0ac57 --- /dev/null +++ b/cli/src/ui/banner.ts @@ -0,0 +1,558 @@ +// @ts-nocheck +const os = require('os'); +const chalk = require('chalk'); +const { + brand, + icon, + palette, + visibleLength, + padRight, + gradientText, + macTrafficLights, + systemStatusBadge, + statusPill, + pulseBar, +} = require('./theme'); +const { t } = require('../lib/i18n'); +const { getCliVersion } = require('../lib/paths'); + +/** Full ANSI Shadow logo — 81 cells wide. */ +const TECH_LOGO = [ + '██████╗ ███████╗ █████╗ ██████╗████████╗██████╗ ██████╗ ███████╗███████╗███████╗', + '██╔══██╗██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝', + '██████╔╝█████╗ ███████║██║ ██║ ██████╔╝██████╔╝█████╗ ███████╗███████╗', + '██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██╔═══╝ ██╔══██╗██╔══╝ ╚════██║╚════██║', + '██║ ██║███████╗██║ ██║╚██████╗ ██║ ██║ ██║ ██║███████╗███████║███████║', + '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝', +]; + +const LOGO_WIDTH = TECH_LOGO[0].length; +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'; +const REPO_DISPLAY = 'github.com/fecommunity/reactpress'; +const CARD_MIN = 68; +const ASCII_CARD_PAD = 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 homify(p) { + if (!p) return p; + const home = os.homedir(); + if (home && p.startsWith(home)) return '~' + p.slice(home.length); + return p; +} + +function truncateText(text, maxLen) { + const raw = String(text); + if (visibleLength(raw) <= maxLen) return raw; + if (maxLen <= 3) return '…'; + let out = '…'; + for (let i = raw.length - 1; i >= 0; i -= 1) { + const candidate = '…' + raw.slice(i); + if (visibleLength(candidate) <= maxLen) { + out = candidate; + break; + } + } + return out; +} + +function bannerColumns() { + const raw = Number(process.stdout.columns); + return raw >= 48 ? raw : 80; +} + +/** Core dev stack — Docker/Nginx are shown but do not affect the SYSTEM badge. */ +const CORE_SYSTEM_SERVICES = new Set(['sqlite', 'mysql', 'server', 'web']); + +function deriveSystemState(project, status) { + const type = project && project.type; + if (type !== 'monorepo' && type !== 'standalone') { + return 'pending'; + } + if (!status || !Array.isArray(status.components) || status.components.length === 0) { + return 'pending'; + } + const core = status.components.filter((c) => CORE_SYSTEM_SERVICES.has(c.id)); + if (core.length === 0) return 'pending'; + const okCount = core.filter((c) => c.ok).length; + if (okCount === core.length) return 'online'; + if (okCount === 0) return 'error'; + return 'partial'; +} + +function resolveSystemState(options) { + if (options && options.systemState) return options.systemState; + if (options && options.status) { + return deriveSystemState(options.project, options.status); + } + const type = options && options.project && options.project.type; + if (type === 'monorepo' || type === 'standalone') return 'pending'; + return 'pending'; +} + +function componentLabel(component) { + return t(`banner.service.${component.id}`).trim(); +} + +function computeLabelWidth(components) { + const labels = [ + t('banner.label.mode').trim(), + t('banner.label.path').trim(), + ...(components || []).map((c) => componentLabel(c)), + ]; + return Math.max(...labels.map((label) => visibleLength(label)), 4); +} + +function componentStatusPill(component) { + if (component.ok === 'pending') { + return statusPill('pending', { pending: t('banner.pulsePending') }); + } + if (component.id === 'sqlite' || component.id === 'mysql') { + return statusPill(component.ok, { + on: t('menu.statusReady'), + off: t('menu.statusNotReady'), + }); + } + if (component.id === 'docker') { + return statusPill(component.ok, { + on: t('menu.statusYes'), + off: t('menu.statusNo'), + }); + } + if (component.id === 'nginx') { + return statusPill(component.ok, { + on: t('banner.nginxRunning'), + off: t('banner.nginxStopped'), + }); + } + return statusPill(component.ok, { + on: t('menu.statusOn'), + off: t('menu.statusOff'), + }); +} + +function appendComponentStatusRows(lines, innerWidth, status, lw) { + if (!status || !Array.isArray(status.components)) return; + for (const component of status.components) { + lines.push( + cardRow(infoRow(componentLabel(component), componentStatusPill(component), lw), innerWidth), + ); + } +} + +function resolveLayout(cols) { + const showAscii = cols >= LOGO_WIDTH + ASCII_CARD_PAD; + const width = showAscii + ? Math.min(cols - 2, Math.max(LOGO_WIDTH + ASCII_CARD_PAD, 88)) + : Math.min(Math.max(CARD_MIN, cols - 4), 88); + return { showAscii, width }; +} + +function centerLine(content, innerWidth) { + const pad = Math.max(0, Math.floor((innerWidth - visibleLength(content)) / 2)); + return ' '.repeat(pad) + content; +} + +function renderLogoLines() { + return TECH_LOGO.map((line, i) => gradientText(line, LOGO_GRADIENTS[i])); +} + +function mixHexLocal(a, b, t) { + const parse = (h) => { + const s = String(h || '#000000').replace('#', ''); + return { + r: parseInt(s.substring(0, 2), 16), + g: parseInt(s.substring(2, 4), 16), + b: parseInt(s.substring(4, 6), 16), + }; + }; + const pa = parse(a); + const pb = parse(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); + const pad = (n) => n.toString(16).padStart(2, '0'); + return `#${pad(r)}${pad(g)}${pad(bl)}`; +} + +/** Animated logo — horizontal light streak sweeps across ASCII art. */ +function renderAnimatedLogoLines(frame = 0, revealRatio = 1) { + const beamSpeed = 3.6; + const beamGlow = 16; + const cycleLen = LOGO_WIDTH + beamGlow * 2; + const head = (frame * beamSpeed) % cycleLen; + const trail = (head - beamGlow * 0.75 + cycleLen) % cycleLen; + const streamHot = '#FFFFFF'; + const streamCore = palette.accent; + const streamTrail = palette.pink; + + function beamIntensity(col, rowIndex, beamHead) { + const pos = col + rowIndex * 2.2; + const raw = Math.abs(pos - beamHead); + const wrapped = Math.min(raw, cycleLen - raw); + if (wrapped >= beamGlow) return 0; + const t = 1 - wrapped / beamGlow; + return t * t; + } + + function paintChar(ch, ci, rowIndex, colors, lineLen) { + if (ch === ' ') return ch; + + const n = Math.max(lineLen - 1, 1); + const ratio = ci / n; + const base = mixHexLocal(colors[0], colors[1] || colors[0], ratio); + const dimBase = mixHexLocal(base, palette.surface, 0.85); + + const primary = beamIntensity(ci, rowIndex, head); + const secondary = beamIntensity(ci, rowIndex, trail) * 0.55; + const glow = Math.min(1, primary + secondary); + + if (glow <= 0.03) { + return chalk.hex(dimBase)(ch); + } + + let lit = dimBase; + if (primary > 0.65) { + lit = mixHexLocal(streamHot, streamCore, 1 - primary); + } else if (glow > 0.3) { + lit = mixHexLocal(mixHexLocal(dimBase, streamTrail, 0.15), streamCore, glow); + } else { + lit = mixHexLocal(dimBase, streamTrail, glow * 0.9); + } + + const c = chalk.hex(lit); + return glow > 0.45 ? c.bold(ch) : c(ch); + } + + return TECH_LOGO.map((line, rowIndex) => { + const rowThreshold = (rowIndex + 1) / TECH_LOGO.length; + if (revealRatio < rowThreshold - 0.06) { + const ghost = mixHexLocal(palette.border, palette.surface, 0.5); + return chalk.hex(ghost)( + [...line] + .map((ch) => (ch === ' ' ? ch : '░')) + .join(''), + ); + } + + const colors = LOGO_GRADIENTS[rowIndex]; + return [...line] + .map((ch, ci) => paintChar(ch, ci, rowIndex, colors, line.length)) + .join(''); + }); +} + +function startupPulseRow(innerWidth, { completed = 0, total = 0, ready = false, frame = 0 } = {}) { + const barWidth = Math.min(24, Math.max(8, innerWidth - 22)); + let filled = 0; + if (total > 0) { + filled = Math.round((completed / total) * barWidth); + if (!ready) filled = Math.min(barWidth, filled + (frame % 2)); + } else { + filled = 4 + (frame % Math.max(1, barWidth - 4)); + } + const label = brand.muted(padRight(t('banner.pulseLabel').trim(), 6)); + const bar = pulseBar(barWidth, filled); + const status = ready + ? brand.success(t('banner.pulseReady')) + : brand.warn(t('banner.pulsePending')); + return centerLine(`${label}${bar} ${status}`, innerWidth); +} + +function cardTop(width) { + return brand.border(` ╭${'─'.repeat(width - 2)}╮`); +} + +function cardBottom(width) { + return brand.border(` ╰${'─'.repeat(width - 2)}╯`); +} + +function cardRow(content, innerWidth) { + const padded = padRight(content, innerWidth); + return brand.border(' │ ') + padded + brand.border(' │'); +} + +function cardGap(innerWidth) { + return cardRow('', innerWidth); +} + +function titleBarRow(innerWidth, version, systemState) { + const lights = macTrafficLights() + brand.dim(' '); + const lightsW = visibleLength(lights); + const title = brand.dim(`reactpress · v${version}`); + const titleW = visibleLength(title); + const status = systemStatusBadge(systemState); + const statusW = visibleLength(status); + + const titleStart = Math.max(lightsW + 1, Math.floor((innerWidth - titleW) / 2)); + const padBeforeTitle = Math.max(0, titleStart - lightsW); + + let line = lights + ' '.repeat(padBeforeTitle) + title; + const gap = innerWidth - visibleLength(line) - statusW; + if (gap >= 2) { + line += ' '.repeat(gap) + status; + } else if (gap >= 0) { + line += ' '.repeat(gap); + } + return padRight(line, innerWidth); +} + +function repoRow(innerWidth) { + const link = + brand.dim(icon.link + ' ') + + hyperlink(REPO_URL, brand.dim.underline(REPO_DISPLAY)); + return centerLine(link, innerWidth); +} + +function headerRows(innerWidth, version, systemState) { + return [titleBarRow(innerWidth, version, systemState), repoRow(innerWidth)]; +} + +function wordmarkHero() { + const bars = brand.border('═══'); + const word = gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], { + bold: true, + }); + return `${bars} ${word} ${bars}`; +} + +/** Compact wordmark with the same streaming-light sweep. */ +function renderAnimatedWordmark(frame = 0) { + const text = 'REACTPRESS'; + const beamSpeed = 3.2; + const beamGlow = 6; + const cycleLen = text.length + beamGlow * 2; + const head = (frame * beamSpeed) % cycleLen; + const streamHot = '#F0FDFF'; + + const bars = brand.border('═══'); + const painted = [...text] + .map((ch, ci) => { + const raw = Math.abs(ci + 0.5 - head); + const wrapped = Math.min(raw, cycleLen - raw); + const glow = wrapped >= beamGlow ? 0 : Math.pow(1 - wrapped / beamGlow, 2); + const base = mixHexLocal(palette.pink, palette.accent, ci / Math.max(text.length - 1, 1)); + const dimBase = mixHexLocal(base, palette.surface, 0.65); + if (glow <= 0.05) return chalk.hex(dimBase).bold(ch); + const lit = + glow > 0.75 + ? mixHexLocal(streamHot, palette.accent, 1 - glow) + : mixHexLocal(dimBase, palette.accent, glow); + const c = chalk.hex(lit); + return glow > 0.5 ? c.bold(ch) : c(ch); + }) + .join(''); + return `${bars} ${painted} ${bars}`; +} + +function taglineRow() { + return `${brand.accent('◇')} ${brand.accent(t('banner.tagline').trim())}`; +} + +function softRule(innerWidth) { + const w = Math.min(innerWidth - 4, Math.max(28, innerWidth - 16)); + return centerLine(brand.border('─'.repeat(w)), innerWidth); +} + +function modeChip(type) { + if (type === 'monorepo') { + return chalk.bgHex(palette.primary).hex('#0B1220').bold(` ${t('banner.mode.monorepo').trim()} `); + } + if (type === 'standalone') { + return chalk.bgHex(palette.accent).hex('#0B1220').bold(` ${t('banner.mode.standalone').trim()} `); + } + return chalk.bgHex(palette.gray).hex('#0B1220').bold(` ${t('banner.mode.uninitialized').trim()} `); +} + +function infoRow(label, value, lw) { + return brand.muted(padRight(label, lw)) + brand.border('│ ') + value; +} + +function commandRail() { + const items = ['init', 'dev', 'build', 'deploy', 'publish']; + return items + .map((name) => brand.primary('》 ') + gradientText(name, [palette.primary, palette.accent])) + .join(brand.dim(' ')); +} + +function appendInfoRows(lines, innerWidth, options, lw) { + if (options.project) { + lines.push( + cardRow(infoRow(t('banner.label.mode').trim(), modeChip(options.project.type), lw), innerWidth), + ); + } + if (options.projectRoot) { + const pathValue = + brand.primary('▶') + + brand.dim(' ') + + brand.dim(truncateText(homify(options.projectRoot), innerWidth - lw - 8)); + lines.push(cardRow(infoRow(t('banner.label.path').trim(), pathValue, lw), innerWidth)); + } + appendComponentStatusRows(lines, innerWidth, options.status, lw); +} + +function composeBannerLines(version, options, { showAscii, width, startup } = {}) { + const innerWidth = width - 4; + const systemState = startup && !startup.ready ? 'pending' : resolveSystemState(options); + const components = (options.status && options.status.components) || []; + const lw = computeLabelWidth(components); + const lines = []; + + lines.push(''); + lines.push(cardTop(width)); + for (const row of headerRows(innerWidth, version, systemState)) { + lines.push(cardRow(row, innerWidth)); + } + lines.push(cardGap(innerWidth)); + + if (showAscii) { + const logoLines = + startup && !startup.ready + ? renderAnimatedLogoLines(startup.frame || 0, 1) + : renderLogoLines(); + for (const logoLine of logoLines) { + lines.push(cardRow(centerLine(logoLine, innerWidth), innerWidth)); + } + lines.push(cardGap(innerWidth)); + lines.push(cardRow(centerLine(taglineRow(), innerWidth), innerWidth)); + if (startup && !startup.ready) { + lines.push( + cardRow( + startupPulseRow(innerWidth, { + completed: startup.completed, + total: startup.total, + ready: startup.ready, + frame: startup.frame || 0, + }), + innerWidth, + ), + ); + } + } else { + const wordmark = + startup && !startup.ready ? renderAnimatedWordmark(startup.frame || 0) : wordmarkHero(); + lines.push(cardRow(centerLine(wordmark, innerWidth), innerWidth)); + lines.push(cardRow(centerLine(taglineRow(), innerWidth), innerWidth)); + if (startup && !startup.ready) { + lines.push( + cardRow( + startupPulseRow(innerWidth, { + completed: startup.completed, + total: startup.total, + ready: startup.ready, + frame: startup.frame || 0, + }), + innerWidth, + ), + ); + } + } + + lines.push(cardRow(softRule(innerWidth), innerWidth)); + appendInfoRows(lines, innerWidth, options, lw); + lines.push(cardBottom(width)); + lines.push(` ${commandRail()}`); + lines.push(''); + return lines; +} + +function printCardBanner(version, options, { showAscii, width, startup } = {}) { + const lines = composeBannerLines(version, options, { showAscii, width, startup }); + for (const line of lines) console.log(line); +} + +function printCompactServiceStatus(status) { + if (!status || !Array.isArray(status.components)) return; + const lw = computeLabelWidth(status.components); + for (const component of status.components) { + console.log( + ` ${brand.muted(padRight(componentLabel(component), lw))}${brand.border('│ ')}${componentStatusPill(component)}`, + ); + } +} + +function printCompactBanner(version, options) { + const systemState = resolveSystemState(options); + + console.log(''); + console.log( + ` ${macTrafficLights()} ${brand.dim(`reactpress · v${version}`)} ${systemStatusBadge(systemState)}`, + ); + console.log(` ${brand.muted(icon.link)} ${hyperlink(REPO_URL, brand.dim.underline(REPO_DISPLAY))}`); + console.log(''); + for (const logoLine of renderLogoLines()) { + console.log(` ${logoLine}`); + } + console.log(` ${centerLine(taglineRow(), bannerColumns() - 4)}`); + if (options.project) console.log(` ${modeChip(options.project.type)}`); + if (options.projectRoot) { + console.log(` ${brand.primary('▶')} ${brand.dim(homify(options.projectRoot))}`); + } + printCompactServiceStatus(options.status); + console.log(` ${commandRail()}`); + console.log(''); +} + +function printBanner(options = {}) { + const version = getCliVersion(); + const cols = bannerColumns(); + const { showAscii, width } = resolveLayout(cols); + + if (cols < CARD_MIN) { + printCompactBanner(version, options); + return; + } + + printCardBanner(version, options, { showAscii, width }); +} + +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.border(` ╭${horizontal}╮`); + const bottom = brand.border(` ╰${horizontal}╯`); + const body = lines.map((line) => { + const padded = padRight(line, innerWidth); + return brand.border(' │ ') + padded + brand.border(' │'); + }); + return [top, ...body, bottom]; +} + +module.exports = { + printBanner, + visibleLength, + padRight, + TECH_LOGO, + LOGO_GRADIENTS, + LOGO_WIDTH, + box, + commandRail, + renderLogoLines, + renderAnimatedLogoLines, + renderAnimatedWordmark, + composeBannerLines, + startupPulseRow, + resolveLayout, + bannerColumns, + titleBarRow, + macTrafficLights, + deriveSystemState, + resolveSystemState, + CORE_SYSTEM_SERVICES, +}; diff --git a/cli/src/ui/interactive.ts b/cli/src/ui/interactive.ts new file mode 100644 index 00000000..fbcade3f --- /dev/null +++ b/cli/src/ui/interactive.ts @@ -0,0 +1,461 @@ +// @ts-nocheck +const inquirer = require('inquirer'); +const ora = require('ora'); +const open = require('open'); +const { refreshBannerWithStartup } = require('./banner-startup'); +const { + brand, + label, + ok, + fail, + padRight, + visibleLength, +} = require('./theme'); +const { ensureOriginalCwd } = require('../lib/root'); +const { describeProject } = require('../lib/project-type'); +const { hasResolvableActiveTheme } = require('../lib/theme-runtime'); +const { ensureProjectEnvironment, initMonorepoProject } = require('../lib/bootstrap'); +const { runDev, runThemeDev, runWebDev, runLocalWebDev, runDesktopDev } = 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, TARGETS } = require('../lib/build'); +const { loadClientSiteUrl } = require('../lib/http'); +const { t } = require('../lib/i18n'); + +const GROUP_PREFIX = '__group:'; + +function menuSection(title) { + return new inquirer.Separator(` ${brand.border('──')} ${brand.dim(String(title).toUpperCase())}`); +} + +function formatChoice(key, text, hint) { + const keyCol = key ? brand.primary(`${key}.`) : ' '; + const hintPart = hint ? brand.dim(` ${hint}`) : ''; + return `${keyCol} ${text}${hintPart}`; +} + +function choice(labelKey, value, hintKey) { + return { + _label: t(labelKey), + _hint: hintKey ? t(hintKey) : '', + value, + }; +} + +function toInquirerChoices(items, { start = 1, alignHints = true } = {}) { + const actionable = items.filter((item) => item._label); + const labelWidth = alignHints + ? Math.min(Math.max(...actionable.map((item) => visibleLength(item._label)), 0), 34) + : 0; + + let n = start - 1; + return items.map((item) => { + if (item instanceof inquirer.Separator) { + return item; + } + const shortLabel = item._short || stripAnsi(item._label || item.name || item.value); + if (item.noShortcut) { + return { + name: item._label, + value: item.value, + short: shortLabel, + }; + } + n += 1; + const key = n <= 9 ? String(n) : ''; + const labelText = labelWidth ? padRight(item._label, labelWidth) : item._label; + return { + name: formatChoice(key, labelText, item._hint), + value: item.value, + short: shortLabel, + }; + }); +} + +function stripAnsi(text) { + return String(text || '').replace(/\u001b\[[0-9;]*m/g, ''); +} + +function buildMenuGroups(project) { + const standalone = project.type === 'standalone'; + const monorepo = project.type === 'monorepo'; + const showTheme = hasResolvableActiveTheme(project.root); + + const run = { + key: 'run', + title: t('menu.section.run'), + items: [ + 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'), + ], + }; + + const extendItems = []; + if (project.hasDesktop) { + extendItems.push(choice('menu.devDesktop', 'dev:desktop', 'menu.hint.devDesktop')); + } + if (project.hasWeb) { + extendItems.push(choice('menu.devWeb', 'dev:web', 'menu.hint.devWeb')); + extendItems.push(choice('menu.devLocalWeb', 'dev:local-web', 'menu.hint.devLocalWeb')); + } + extendItems.push(choice('menu.initLocal', 'init:local', 'menu.hint.initLocal')); + extendItems.push(choice('menu.themeList', 'theme:list', 'menu.hint.themeList')); + if (monorepo && project.hasPluginsWorkspace) { + extendItems.push(choice('menu.pluginList', 'plugin:list', 'menu.hint.pluginList')); + } + + const lifecycleItems = [choice('menu.devApi', 'dev:api', 'menu.hint.devApi')]; + if (showTheme) { + lifecycleItems.push(choice('menu.devClient', 'dev:client', 'menu.hint.devClient')); + } + lifecycleItems.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') + ); + + const buildItems = [choice('menu.build', 'build', 'menu.hint.build')]; + if (monorepo) { + buildItems.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) { + buildItems.push( + choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'), + choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop') + ); + } + if (monorepo) { + buildItems.push(choice('menu.publish', 'publish', 'menu.hint.publish')); + } + + const toolItems = [ + 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.dbBackup', 'db:backup', 'menu.hint.dbBackup'), + choice('menu.openAdmin', 'open:admin', 'menu.hint.openAdmin'), + ]; + + const groups = [run]; + if (extendItems.length > 0) { + groups.push({ key: 'extend', title: t('menu.section.extend'), items: extendItems }); + } + groups.push( + { key: 'lifecycle', title: t('menu.section.lifecycle'), items: lifecycleItems }, + { key: 'build', title: t('menu.section.build'), items: buildItems }, + { key: 'tools', title: t('menu.section.tools'), items: toolItems } + ); + return groups; +} + +function getTopLevelMenu(project) { + const groups = buildMenuGroups(project); + const runGroup = groups[0]; + const subGroups = groups.slice(1); + + const items = [menuSection(t('menu.section.quick')), ...runGroup.items]; + + if (subGroups.length > 0) { + items.push(menuSection(t('menu.section.explore'))); + for (const group of subGroups) { + items.push({ + _label: t(`menu.group.${group.key}`), + _hint: t(`menu.groupHint.${group.key}`), + value: `${GROUP_PREFIX}${group.key}`, + }); + } + } + + items.push( + new inquirer.Separator(), + { _label: brand.dim(t('menu.exit')), value: 'exit', noShortcut: true } + ); + return toInquirerChoices(items, { alignHints: false }); +} + +function getSubMenu(group) { + const items = [ + menuSection(group.title), + ...group.items, + new inquirer.Separator(), + { _label: brand.dim(`← ${t('menu.backToMain')}`), value: '__back', noShortcut: true }, + ]; + return toInquirerChoices(items); +} + +function getMenuActions(project) { + const groups = buildMenuGroups(project); + const items = []; + for (const group of groups) { + items.push(menuSection(group.title), ...group.items); + } + items.push(new inquirer.Separator(), choice('menu.exit', 'exit')); + return toInquirerChoices(items); +} + +async function pickMenuAction(project) { + const groups = buildMenuGroups(project); + const groupMap = Object.fromEntries(groups.map((group) => [group.key, group])); + + while (true) { + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: `${brand.primary('›')} ${t('menu.prompt')}`, + pageSize: 14, + loop: false, + choices: getTopLevelMenu(project), + }, + ]); + + if (!action || typeof action !== 'string') { + continue; + } + + if (!action.startsWith(GROUP_PREFIX)) { + return action; + } + + const groupKey = action.slice(GROUP_PREFIX.length); + const group = groupMap[groupKey]; + if (!group) { + continue; + } + + const { subAction } = await inquirer.prompt([ + { + type: 'list', + name: 'subAction', + message: `${brand.primary('›')} ${t('menu.subPrompt', { section: group.title })}`, + pageSize: 14, + loop: false, + choices: getSubMenu(group), + }, + ]); + + if (subAction !== '__back') { + return subAction; + } + } +} + +async function refreshInteractiveBanner(projectRoot, project, { animated = true } = {}) { + return refreshBannerWithStartup(projectRoot, project, { animated }); +} + +async function withSpinner(text, fn) { + const spinner = ora({ text, color: 'gray', 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 runThemeDev(projectRoot); + return false; + case 'dev:desktop': + await runDesktopDev(projectRoot); + return false; + case 'dev:web': + await runWebDev(projectRoot); + return false; + case 'dev:local-web': + process.env.REACTPRESS_LOCAL_MODE = '1'; + process.env.REACTPRESS_SKIP_NGINX = '1'; + await runLocalWebDev(projectRoot); + return false; + case 'init:local': { + const { isMonorepoCheckout } = require('../lib/root'); + const { initProject } = require('../core/services/init'); + const result = await withSpinner(t('menu.initProject'), async () => { + if (isMonorepoCheckout(projectRoot)) { + return initMonorepoProject(projectRoot, { local: true }); + } + return initProject({ directory: projectRoot, force: false, local: true }); + }); + console.log(ok(result.message || t('menu.done'))); + return true; + } + case 'theme:list': + require('../lib/theme-cli').runThemeList(projectRoot); + return true; + case 'plugin:list': + require('../lib/plugin-cli').runPluginList(projectRoot); + return true; + case 'db:backup': + await withSpinner(t('cli.db.backup'), async () => { + await runDbBackup(projectRoot); + }); + return true; + 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); + + await refreshInteractiveBanner(projectRoot, project, { animated: true }); + console.log(` ${brand.dim(t('menu.shortcuts'))}`); + console.log(''); + + let loop = true; + while (loop) { + const action = await pickMenuAction(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 refreshInteractiveBanner(projectRoot, project, { animated: false }); + } + } 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 refreshInteractiveBanner(projectRoot, project, { animated: false }); + } + } + } +} + +module.exports = { + runInteractiveLoop, + runMenuAction, + getMenuActions, + buildMenuGroups, + pickMenuAction, +}; diff --git a/cli/ui/theme.js b/cli/src/ui/theme.ts similarity index 74% rename from cli/ui/theme.js rename to cli/src/ui/theme.ts index 7b06f7ab..37faed44 100644 --- a/cli/ui/theme.js +++ b/cli/src/ui/theme.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const chalk = require('chalk'); /** @@ -13,6 +14,12 @@ const palette = { red: '#EF4444', gray: '#6B7280', dim: '#9CA3AF', + border: '#4A4580', + surface: '#12152A', + macRed: '#FF5F57', + macYellow: '#FEBC2E', + macGreen: '#28C840', + macGray: '#5C5C5C', }; const brand = { @@ -24,6 +31,7 @@ const brand = { error: chalk.hex(palette.red), muted: chalk.hex(palette.gray), dim: chalk.hex(palette.dim), + border: chalk.hex(palette.border), bold: chalk.bold, }; @@ -121,14 +129,63 @@ function pulseBar(width = 24, filled = Math.ceil(width * 0.7)) { return `${head}${tail}`; } +/** Single-line status probe progress: bar + percent + optional service label. */ +function statusProgressLine({ completed, total, label, barWidth = 24, filled } = {}) { + const safeTotal = Math.max(1, total || 1); + const barFilled = + filled !== undefined ? filled : Math.round((completed / safeTotal) * barWidth); + const pct = Math.min(100, Math.round((completed / safeTotal) * 100)); + const bar = pulseBar(barWidth, barFilled); + const labelPart = label ? brand.dim(` ${label}`) : ''; + return `${bar} ${brand.muted(`${pct}%`)}${labelPart}`; +} + /** - * Three-light status indicator used in the top-right of the banner. - * Mimics the running-light cluster you'd see on a server rack. + * macOS Terminal-style traffic-light window controls (red · yellow · green). + */ +function macTrafficLights() { + return ( + chalk.hex(palette.macRed)('●') + + brand.dim(' ') + + chalk.hex(palette.macYellow)('●') + + brand.dim(' ') + + chalk.hex(palette.macGreen)('●') + ); +} + +/** + * Unified status badge — color encodes health (online / partial / error / pending). + * @param {'online'|'partial'|'error'|'pending'} state + */ +function systemStatusBadge(state = 'pending') { + const { t } = require('../lib/i18n'); + const words = { + online: 'banner.systemOnline', + partial: 'banner.systemPartial', + error: 'banner.systemError', + pending: 'banner.systemPending', + }; + const painters = { + online: brand.success, + partial: brand.warn, + error: brand.error, + pending: brand.muted, + }; + const key = words[state] || words.pending; + const paint = painters[state] || painters.pending; + return paint(t(key).trim()); +} + +/** + * Three-light status indicator used in status panels. */ function statusLights(state = 'online') { if (state === 'offline') { return `${brand.muted('●')} ${brand.muted('●')} ${brand.muted('●')}`; } + if (state === 'degraded') { + return `${brand.warn('●')} ${brand.muted('●')} ${brand.muted('○')}`; + } if (state === 'pending') { return `${brand.warn('●')} ${brand.warn('●')} ${brand.muted('○')}`; } @@ -136,7 +193,7 @@ function statusLights(state = 'online') { } function hex2rgb(h) { - const s = h.replace('#', ''); + const s = String(h || '#000000').replace('#', ''); return { r: parseInt(s.substring(0, 2), 16), g: parseInt(s.substring(2, 4), 16), @@ -205,7 +262,12 @@ function info(text) { } function chip(text, color = brand.primary) { - return color(`[ ${text} ]`); + return color(` ${text} `); +} + +/** Compact pill badge for banner feature highlights. */ +function badge(text, color = palette.accent) { + return chalk.bgHex(palette.surface).hex(color).bold(` ${text} `); } function kv(key, value, { keyWidth = 10, valueColor = (s) => s } = {}) { @@ -233,12 +295,9 @@ function statusPill(state, labels = {}) { */ 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}`; + const ruleLen = Math.min(40, Math.max(12, w - visibleLength(title) - 6)); + const rule = brand.dim('─'.repeat(ruleLen)); + return ` ${brand.dim(title)} ${rule}`; } module.exports = { @@ -251,6 +310,7 @@ module.exports = { warn, info, chip, + badge, kv, statusPill, sectionHeader, @@ -261,5 +321,8 @@ module.exports = { divider, gradientText, pulseBar, + statusProgressLine, statusLights, + macTrafficLights, + systemStatusBadge, }; diff --git a/cli/templates/config.default.json b/cli/templates/config.default.json index 71441022..5d3bcd19 100644 --- a/cli/templates/config.default.json +++ b/cli/templates/config.default.json @@ -1,12 +1,13 @@ { "version": 1, "database": { - "mode": "embedded-docker", - "containerName": "reactpress_cli_db" + "mode": "embedded-sqlite", + "sqlitePath": ".reactpress/reactpress.db" }, "server": { "port": 3002, "clientUrl": "http://localhost:3001", - "serverUrl": "http://localhost:3002" + "serverUrl": "http://127.0.0.1:3002", + "apiPrefix": "/api" } } diff --git a/cli/templates/config.local.json b/cli/templates/config.local.json new file mode 100644 index 00000000..5d3bcd19 --- /dev/null +++ b/cli/templates/config.local.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "database": { + "mode": "embedded-sqlite", + "sqlitePath": ".reactpress/reactpress.db" + }, + "server": { + "port": 3002, + "clientUrl": "http://localhost:3001", + "serverUrl": "http://127.0.0.1:3002", + "apiPrefix": "/api" + } +} diff --git a/cli/templates/env.local.default b/cli/templates/env.local.default new file mode 100644 index 00000000..630d7934 --- /dev/null +++ b/cli/templates/env.local.default @@ -0,0 +1,10 @@ +# ReactPress — local SQLite mode (auto-generated) +DB_TYPE=sqlite +DB_DATABASE=.reactpress/reactpress.db +SERVER_PORT=3002 +SERVER_SITE_URL=http://127.0.0.1:3002 +CLIENT_SITE_URL=http://localhost:3001 +SERVER_API_PREFIX=/api +REACTPRESS_UPLOAD_DIR=uploads +ADMIN_USER=admin +ADMIN_PASSWD=admin diff --git a/cli/templates/theme-catalog.json b/cli/templates/theme-catalog.json new file mode 100644 index 00000000..eaa37aee --- /dev/null +++ b/cli/templates/theme-catalog.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "themes": [ + { + "id": "reactpress-theme-starter", + "name": "ReactPress Theme Starter", + "version": "1.0.0-beta.0", + "description": "官方 Next.js 15 主题 — Tailwind CSS、知识库、评论、深色模式,Lighthouse 95+。", + "author": "ReactPress", + "themeUri": "https://github.com/fecommunity/reactpress-theme-starter", + "previewUrl": "https://reactpress-theme-starter.vercel.app", + "tags": [ + "官方", + "Tailwind", + "App Router", + "Next.js 15" + ], + "dependency": { + "name": "@fecommunity/reactpress-theme-starter", + "version": "1.0.0-beta.0" + }, + "npm": "@fecommunity/reactpress-theme-starter@1.0.0-beta.0", + "featured": true, + "requires": ">=3.0.0" + } + ] +} diff --git a/cli/tests/banner.test.js b/cli/tests/banner.test.js new file mode 100644 index 00000000..d5cc5ebf --- /dev/null +++ b/cli/tests/banner.test.js @@ -0,0 +1,174 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + printBanner, + TECH_LOGO, + LOGO_WIDTH, + commandRail, + renderLogoLines, + renderAnimatedLogoLines, + composeBannerLines, + deriveSystemState, + resolveSystemState, + resolveLayout, +} = require('../out/ui/banner'); +const { mergeStartupComponents } = require('../out/ui/banner-startup'); +const { systemStatusBadge } = require('../out/ui/theme'); + +const sampleComponents = [ + { id: 'mysql', ok: false }, + { id: 'server', ok: true }, + { id: 'docker', ok: false }, + { id: 'nginx', ok: false }, + { id: 'web', ok: true }, +]; + +describe('banner', () => { + it('printBanner renders service rows under PATH', () => { + const lines = []; + const origLog = console.log; + const prevCols = process.stdout.columns; + + console.log = (...args) => lines.push(args.join(' ')); + Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true }); + + try { + printBanner({ + projectRoot: '/tmp/demo-project', + project: { type: 'monorepo' }, + systemState: 'partial', + status: { components: sampleComponents }, + }); + } finally { + console.log = origLog; + Object.defineProperty(process.stdout, 'columns', { + value: prevCols, + configurable: true, + }); + } + + const output = lines.join('\n'); + assert.match(output, /██████╗/); + assert.match(output, /MySQL/); + assert.match(output, /Server/); + assert.match(output, /Docker/); + assert.match(output, /Nginx/); + assert.match(output, /Web/); + assert.doesNotMatch(output, /:3000/); + assert.doesNotMatch(output, /Toolkit/); + }); + + it('deriveSystemState ignores optional docker/nginx for SYSTEM badge', () => { + const project = { type: 'monorepo' }; + assert.equal( + deriveSystemState(project, { + components: [ + { id: 'sqlite', ok: true }, + { id: 'server', ok: true }, + { id: 'docker', ok: false }, + { id: 'nginx', ok: false }, + { id: 'web', ok: true }, + ], + }), + 'online', + ); + }); + + it('deriveSystemState maps core service checks to four states', () => { + const project = { type: 'monorepo' }; + assert.equal(deriveSystemState({ type: 'unknown' }, null), 'pending'); + assert.equal( + deriveSystemState(project, { + components: [ + { id: 'server', ok: true }, + { id: 'web', ok: true }, + ], + }), + 'online', + ); + assert.equal( + deriveSystemState(project, { + components: [ + { id: 'server', ok: true }, + { id: 'web', ok: false }, + ], + }), + 'partial', + ); + assert.equal( + deriveSystemState(project, { + components: [ + { id: 'server', ok: false }, + { id: 'web', ok: false }, + ], + }), + 'error', + ); + }); + + it('resolveSystemState derives from status when present', () => { + assert.equal( + resolveSystemState({ + project: { type: 'monorepo' }, + status: { + components: [ + { id: 'server', ok: true }, + { id: 'web', ok: true }, + ], + }, + }), + 'online', + ); + }); + + it('commandRail lists core commands', () => { + const rail = commandRail(); + assert.match(rail, /init/); + assert.match(rail, /publish/); + }); + + it('renderAnimatedLogoLines applies streaming highlight across logo', () => { + const full = renderAnimatedLogoLines(30, 1); + assert.equal(full.length, TECH_LOGO.length); + assert.match(full[0].replace(/\u001b\[[0-9;]*m/g, ''), /^██████╗/); + assert.match(full[TECH_LOGO.length - 1].replace(/\u001b\[[0-9;]*m/g, ''), /╚═╝/); + }); + + it('composeBannerLines includes startup pulse row while probing', () => { + const { showAscii, width } = resolveLayout(100); + const lines = composeBannerLines( + '4.0.0', + { + projectRoot: '/tmp/demo', + project: { type: 'monorepo' }, + status: { + components: mergeStartupComponents(['mysql', 'server'], []), + }, + }, + { + showAscii, + width, + startup: { frame: 2, completed: 1, total: 3, ready: false }, + }, + ); + const output = lines.join('\n'); + assert.match(output, /INIT|初始化/); + assert.match(output, /▰/); + }); + + it('systemStatusBadge shows status text only', () => { + const plain = (value) => String(value).replace(/\u001b\[[0-9;]*m/g, ''); + assert.equal(plain(systemStatusBadge('online')), 'ONLINE'); + assert.equal(plain(systemStatusBadge('pending')), 'PENDING'); + assert.doesNotMatch(plain(systemStatusBadge('online')), /SYSTEM/); + }); + + it('mergeStartupComponents marks unchecked services as pending', () => { + const merged = mergeStartupComponents(['mysql', 'server'], [{ id: 'mysql', ok: true }]); + assert.equal(merged[0].ok, true); + assert.equal(merged[1].ok, 'pending'); + }); +}); diff --git a/cli/tests/build.test.js b/cli/tests/build.test.js index 5b35247e..e25ca549 100644 --- a/cli/tests/build.test.js +++ b/cli/tests/build.test.js @@ -1,6 +1,8 @@ +const fs = require('fs'); +const path = require('path'); const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { resolveBuildInvocation, TARGETS } = require('../lib/build'); +const { resolveBuildInvocation, TARGETS } = require('../out/lib/build'); const { createMonorepoFixture, createStandaloneProject, rmDir } = require('./helpers/tmp-project'); describe('lib/build', () => { @@ -15,10 +17,10 @@ describe('lib/build', () => { } }); - it('skips client build when client/ is missing', () => { + it('skips theme build when active theme is missing', () => { const root = createStandaloneProject(); try { - const inv = resolveBuildInvocation('build:client', root); + const inv = resolveBuildInvocation('build:theme', root); assert.equal(inv, null); } finally { rmDir(root); @@ -28,5 +30,22 @@ describe('lib/build', () => { it('exposes known targets', () => { assert.ok(TARGETS.includes('all')); assert.ok(TARGETS.includes('toolkit')); + assert.ok(TARGETS.includes('web')); + }); + + it('includes web in all steps when web/ exists', () => { + const root = createMonorepoFixture(); + try { + fs.mkdirSync(path.join(root, 'web')); + fs.writeFileSync( + path.join(root, 'web', 'package.json'), + JSON.stringify({ name: 'web', scripts: { build: 'echo build' } }) + ); + const { getBuildSteps } = require('../out/lib/build'); + const steps = getBuildSteps('all', root); + assert.ok(steps.some((s) => s.script === 'build:web')); + } finally { + rmDir(root); + } }); }); diff --git a/cli/tests/cli-version.test.js b/cli/tests/cli-version.test.js new file mode 100644 index 00000000..1037a77b --- /dev/null +++ b/cli/tests/cli-version.test.js @@ -0,0 +1,27 @@ +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, '..'); + +describe('CLI version resolution', () => { + it('reads version from package root (not out/package.json)', () => { + const { getCliVersion, getCliPackageRoot } = require('../out/lib/paths'); + const root = getCliPackageRoot(); + const expected = JSON.parse( + fs.readFileSync(path.join(CLI_ROOT, 'package.json'), 'utf8'), + ).version; + assert.equal(getCliVersion(), expected); + assert.equal( + fs.existsSync(path.join(root, 'package.json')), + true, + 'package root must contain package.json', + ); + assert.equal( + fs.existsSync(path.join(root, 'out', 'package.json')), + false, + 'compiled out/ must not rely on out/package.json', + ); + }); +}); diff --git a/cli/tests/context-status.test.js b/cli/tests/context-status.test.js new file mode 100644 index 00000000..e0333e43 --- /dev/null +++ b/cli/tests/context-status.test.js @@ -0,0 +1,63 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + resolveServiceChecks, + resolveDbType, + parseEnvFile, +} = require('../out/lib/context-status'); + +describe('context-status', () => { + it('parseEnvFile reads DB_TYPE from .env', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-status-')); + try { + fs.writeFileSync(path.join(dir, '.env'), 'DB_TYPE=sqlite\nDB_DATABASE=data/app.db\n'); + const env = parseEnvFile(dir); + assert.equal(env.DB_TYPE, 'sqlite'); + assert.equal(env.DB_DATABASE, 'data/app.db'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolveDbType prefers sqlite from .env when config is missing', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-status-')); + try { + fs.writeFileSync(path.join(dir, '.env'), 'DB_TYPE=sqlite\n'); + assert.equal(await resolveDbType(dir), 'sqlite'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolveDbType defaults to mysql without sqlite hints', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-status-')); + try { + assert.equal(await resolveDbType(dir), 'mysql'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolveServiceChecks includes sqlite or mysql plus core services', () => { + assert.deepEqual(resolveServiceChecks('sqlite'), [ + 'sqlite', + 'server', + 'docker', + 'nginx', + 'web', + ]); + assert.deepEqual(resolveServiceChecks('mysql'), [ + 'mysql', + 'server', + 'docker', + 'nginx', + 'web', + ]); + }); +}); diff --git a/cli/tests/database-fallback.test.js b/cli/tests/database-fallback.test.js new file mode 100644 index 00000000..73c95808 --- /dev/null +++ b/cli/tests/database-fallback.test.js @@ -0,0 +1,80 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('database sqlite fallback', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-db-fallback-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('promotes embedded-docker to sqlite when docker is unavailable', async () => { + const reactpressDir = path.join(tmpDir, '.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(tmpDir, '.env'), + [ + 'DB_HOST=127.0.0.1', + 'DB_PORT=3307', + 'DB_USER=reactpress', + 'DB_PASSWD=reactpress', + 'DB_DATABASE=reactpress', + 'SERVER_PORT=3002', + ].join('\n'), + ); + + const { ensureDatabase } = require('../out/core/services/database'); + const { loadConfig } = require('../out/core/services/config'); + const { isDockerAvailable } = require('../out/core/services/exec'); + + if (isDockerAvailable()) { + return; + } + + const config = await loadConfig(tmpDir); + const result = await ensureDatabase(tmpDir, config); + assert.equal(result.ok, true); + + const next = await loadConfig(tmpDir); + assert.equal(next.database.mode, 'embedded-sqlite'); + + const env = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8'); + assert.match(env, /^DB_TYPE=sqlite/m); + }); +}); + +describe('database-mode', () => { + it('detects sqlite mode from config.json', () => { + const { readSqliteModeFromProject } = require('../out/lib/database-mode'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-db-mode-')); + try { + fs.mkdirSync(path.join(tmpDir, '.reactpress'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.reactpress/config.json'), + JSON.stringify({ database: { mode: 'embedded-sqlite' } }), + ); + assert.equal(readSqliteModeFromProject(tmpDir), true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/tests/dev-auto-local.test.js b/cli/tests/dev-auto-local.test.js new file mode 100644 index 00000000..8a952b92 --- /dev/null +++ b/cli/tests/dev-auto-local.test.js @@ -0,0 +1,46 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +describe('dev auto local fallback', () => { + const envBackup = { ...process.env }; + + beforeEach(() => { + delete process.env.REACTPRESS_LOCAL_MODE; + delete process.env.REACTPRESS_SKIP_NGINX; + delete process.env.REACTPRESS_FORCE_MYSQL; + delete process.env.REACTPRESS_DESKTOP_LOCAL; + }); + + afterEach(() => { + process.env = { ...envBackup }; + }); + + it('enables local mode when docker is unavailable', async () => { + const { applyAutoLocalDevFallback } = require('../out/lib/dev'); + const { checkDocker } = require('../out/lib/doctor'); + const docker = checkDocker(); + if (docker.ok) { + // Cannot simulate missing docker in CI — skip when docker is running. + return; + } + + const enabled = await applyAutoLocalDevFallback(process.cwd(), { needsLocalApi: true }); + assert.equal(enabled, true); + assert.equal(process.env.REACTPRESS_LOCAL_MODE, '1'); + assert.equal(process.env.REACTPRESS_SKIP_NGINX, '1'); + }); + + it('respects explicit local mode and force mysql flag', async () => { + const { applyAutoLocalDevFallback } = require('../out/lib/dev'); + + process.env.REACTPRESS_LOCAL_MODE = '1'; + const alreadyLocal = await applyAutoLocalDevFallback(process.cwd(), { needsLocalApi: true }); + assert.equal(alreadyLocal, false); + + delete process.env.REACTPRESS_LOCAL_MODE; + process.env.REACTPRESS_FORCE_MYSQL = '1'; + const forced = await applyAutoLocalDevFallback(process.cwd(), { needsLocalApi: true }); + assert.equal(forced, false); + assert.equal(process.env.REACTPRESS_LOCAL_MODE, undefined); + }); +}); diff --git a/cli/tests/docker.test.js b/cli/tests/docker.test.js index 06142777..53d5912c 100644 --- a/cli/tests/docker.test.js +++ b/cli/tests/docker.test.js @@ -1,7 +1,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const path = require('path'); -const { resolveComposeContext } = require('../lib/docker'); +const { resolveComposeContext } = require('../out/lib/docker'); const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); describe('lib/docker', () => { diff --git a/cli/tests/http.test.js b/cli/tests/http.test.js index 8b8d7d04..719a8043 100644 --- a/cli/tests/http.test.js +++ b/cli/tests/http.test.js @@ -5,7 +5,8 @@ const { loadClientSiteUrl, getApiPrefix, getHealthUrl, -} = require('../lib/http'); + normalizeProbeUrl, +} = require('../out/lib/http'); const { createStandaloneProject, rmDir } = require('./helpers/tmp-project'); describe('lib/http', () => { @@ -20,4 +21,15 @@ describe('lib/http', () => { rmDir(root); } }); + + it('normalizes localhost probes to IPv4 loopback', () => { + assert.equal( + normalizeProbeUrl('http://localhost:3002/api/health'), + 'http://127.0.0.1:3002/api/health', + ); + assert.equal( + normalizeProbeUrl('http://[::1]:3001/'), + 'http://127.0.0.1:3001/', + ); + }); }); diff --git a/cli/tests/i18n.test.js b/cli/tests/i18n.test.js index d6e511d9..b09e73e4 100644 --- a/cli/tests/i18n.test.js +++ b/cli/tests/i18n.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { t, setLocale, getLocale } = require('../lib/i18n'); +const { t, setLocale, getLocale } = require('../out/lib/i18n'); describe('lib/i18n', () => { it('translates known keys in en and zh', () => { @@ -17,4 +17,10 @@ describe('lib/i18n', () => { setLocale('en'); assert.match(t('menu.opening', { url: 'http://x' }), /http:\/\/x/); }); + + it('does not throw when key or vars are missing', () => { + setLocale('en'); + assert.equal(t(undefined), ''); + assert.equal(t('dev.timingReady', { summary: undefined }), 'Ready in '); + }); }); diff --git a/cli/tests/nginx.test.js b/cli/tests/nginx.test.js index 540b6bb1..f9f4702c 100644 --- a/cli/tests/nginx.test.js +++ b/cli/tests/nginx.test.js @@ -6,7 +6,8 @@ const { resolveNginxConfigPath, resolveNginxComposeContext, ensureNginxConfig, -} = require('../lib/nginx'); + renderDevNginxConfig, +} = require('../out/lib/nginx'); const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); describe('lib/nginx', () => { @@ -39,7 +40,44 @@ describe('lib/nginx', () => { assert.equal(created, true); assert.equal(configPath, target); assert.ok(fs.existsSync(target)); - assert.ok(fs.readFileSync(target, 'utf8').includes('host.docker.internal')); + const content = fs.readFileSync(target, 'utf8'); + assert.ok(content.includes('host.docker.internal')); + assert.ok(content.includes('host.docker.internal:3000/admin/')); + assert.ok(!content.includes(':5173')); + assert.ok(!content.includes('expires 1y')); + } finally { + rmDir(root); + } + }); + + it('renderDevNginxConfig uses local API port by default', () => { + const content = renderDevNginxConfig({ + adminPort: 3000, + visitorPort: 3001, + apiPort: 3002, + }); + assert.ok(content.includes('host.docker.internal:3002')); + }); + + it('ensureNginxConfig refreshes stale prod nginx (13001 → env ports)', () => { + const root = createMonorepoFixture(); + try { + const configPath = path.join(root, 'nginx.conf'); + fs.writeFileSync( + configPath, + 'location / { proxy_pass http://host.docker.internal:13001; }\nlocation /api { proxy_pass http://host.docker.internal:13002; }\n', + ); + fs.writeFileSync( + path.join(root, '.env'), + 'CLIENT_SITE_URL=http://localhost:3001\nSERVER_SITE_URL=http://localhost:3002\n', + ); + const { changed } = ensureNginxConfig(root, { prod: true }); + assert.equal(changed, true); + const content = fs.readFileSync(configPath, 'utf8'); + assert.ok(content.includes('host.docker.internal:3001')); + assert.ok(content.includes('host.docker.internal:3002')); + assert.ok(!content.includes('13001')); + assert.ok(!content.includes('13002')); } finally { rmDir(root); } diff --git a/cli/tests/parity-pack.test.js b/cli/tests/parity-pack.test.js index 9754d0a8..e9128e9a 100644 --- a/cli/tests/parity-pack.test.js +++ b/cli/tests/parity-pack.test.js @@ -10,12 +10,12 @@ 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', + 'out/lib/root.js', + 'out/lib/publish.js', + 'out/lib/project-type.js', + 'out/ui/interactive.js', + 'out/ui/banner.js', + 'out/ui/theme.js', 'dist/index.js', 'templates/env.default', 'templates/config.default.json', @@ -36,7 +36,7 @@ describe('publish/local file parity', () => { const top = required.split('/')[0]; assert.ok( declared.has(top) || declared.has(required), - `package.json files[] missing "${top}" (needed for ${required})` + `package.json files[] missing "${top}" (needed for ${required})`, ); } }); diff --git a/cli/tests/ports.test.js b/cli/tests/ports.test.js new file mode 100644 index 00000000..eb9c6252 --- /dev/null +++ b/cli/tests/ports.test.js @@ -0,0 +1,67 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +describe('dev stack ports', () => { + const envBackup = { ...process.env }; + + beforeEach(() => { + delete process.env.REACTPRESS_INSTANCE; + delete process.env.REACTPRESS_DEV_INSTANCE; + delete process.env.SERVER_PORT; + delete process.env.WEB_ADMIN_PORT; + delete process.env.CLIENT_PORT; + delete process.env.REACTPRESS_PREVIEW_PORT; + }); + + afterEach(() => { + process.env = { ...envBackup }; + }); + + it('defaults to standard dev ports', () => { + const { resolveDevStackPorts } = require('../out/lib/ports'); + const ports = resolveDevStackPorts(process.cwd()); + assert.equal(ports.admin, 3000); + assert.equal(ports.visitor, 3001); + assert.equal(ports.api, 3002); + assert.equal(ports.preview, 3003); + }); + + it('offsets ports by REACTPRESS_INSTANCE', () => { + process.env.REACTPRESS_INSTANCE = '2'; + const { resolveDevStackPorts } = require('../out/lib/ports'); + const ports = resolveDevStackPorts(process.cwd()); + assert.equal(ports.admin, 3020); + assert.equal(ports.visitor, 3021); + assert.equal(ports.api, 3022); + assert.equal(ports.preview, 3023); + }); + + it('process.env overrides instance offset', () => { + process.env.REACTPRESS_INSTANCE = '1'; + process.env.SERVER_PORT = '3999'; + const { resolveDevStackPorts } = require('../out/lib/ports'); + const ports = resolveDevStackPorts(process.cwd()); + assert.equal(ports.api, 3999); + assert.equal(ports.admin, 3010); + }); + + it('applyDevStackPortsToEnv sets SERVER_PORT and proxy target', () => { + const { applyDevStackPortsToEnv } = require('../out/lib/ports'); + applyDevStackPortsToEnv({ + admin: 3010, + visitor: 3011, + api: 3012, + preview: 3013, + }); + assert.equal(process.env.SERVER_PORT, '3012'); + assert.equal(process.env.REACTPRESS_LOCAL_API_PORT, '3012'); + assert.equal(process.env.VITE_DEV_API_PROXY_TARGET, 'http://127.0.0.1:3012'); + }); + + it('devSessionSuffix includes instance id', () => { + const { devSessionSuffix } = require('../out/lib/ports'); + assert.equal(devSessionSuffix(), ''); + process.env.REACTPRESS_INSTANCE = '3'; + assert.equal(devSessionSuffix(), '-instance-3'); + }); +}); diff --git a/cli/tests/project-type.test.js b/cli/tests/project-type.test.js index d4982ad9..3606d626 100644 --- a/cli/tests/project-type.test.js +++ b/cli/tests/project-type.test.js @@ -4,7 +4,7 @@ const { detectProjectType, describeProject, hasClient, -} = require('../lib/project-type'); +} = require('../out/lib/project-type'); const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); describe('lib/project-type', () => { diff --git a/cli/tests/publish-version.test.js b/cli/tests/publish-version.test.js index e1051a9d..1eb4eafe 100644 --- a/cli/tests/publish-version.test.js +++ b/cli/tests/publish-version.test.js @@ -20,7 +20,7 @@ function incrementVersion(version, type) { case 'beta': { const match = version.match(/^(.*)-beta\.(\d+)$/); if (match) return `${match[1]}-beta.${parseInt(match[2], 10) + 1}`; - return `${version}-beta.1`; + return `${base}-beta.0`; } default: return version; @@ -36,7 +36,11 @@ describe('publish version bump', () => { assert.equal(incrementVersion('3.0', 'patch'), '3.0.1'); }); - it('bumps beta', () => { + it('bumps beta from stable', () => { + assert.equal(incrementVersion('4.0.0', 'beta'), '4.0.0-beta.0'); + }); + + it('bumps beta prerelease', () => { assert.equal(incrementVersion('3.0.0-beta.1', 'beta'), '3.0.0-beta.2'); }); }); diff --git a/cli/tests/remote-dev.test.js b/cli/tests/remote-dev.test.js new file mode 100644 index 00000000..d26810d5 --- /dev/null +++ b/cli/tests/remote-dev.test.js @@ -0,0 +1,98 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { renderDevNginxConfig } = require('../out/lib/nginx'); +const { + normalizeRemoteOrigin, + resolveRemoteThemeApiBase, + parseOriginSpec, + resolveDevApiOrigins, + applyDevApiOriginsToEnv, +} = require('../out/lib/remote-dev'); + +describe('lib/remote-dev', () => { + it('normalizes bare hostnames to https origins', () => { + assert.equal(normalizeRemoteOrigin('api.gaoredu.com'), 'https://api.gaoredu.com'); + assert.equal(normalizeRemoteOrigin('https://api.gaoredu.com/'), 'https://api.gaoredu.com'); + assert.equal(normalizeRemoteOrigin(''), null); + }); + + it('parseOriginSpec supports local, remote, and URL', () => { + assert.deepEqual(parseOriginSpec('local', 'https://api.gaoredu.com'), { url: null }); + assert.deepEqual(parseOriginSpec('remote', 'https://api.gaoredu.com'), { + url: 'https://api.gaoredu.com', + }); + assert.deepEqual(parseOriginSpec('remote', null), { error: 'REMOTE_DEFAULT_REQUIRED' }); + assert.deepEqual(parseOriginSpec('api.gaoredu.com', null), { url: 'https://api.gaoredu.com' }); + }); + + it('resolveDevApiOrigins splits admin and client', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-origins-')); + try { + const mixed = resolveDevApiOrigins(root, { + remoteOrigin: 'https://api.gaoredu.com', + adminOrigin: 'local', + clientOrigin: 'remote', + }); + assert.equal(mixed.admin, null); + assert.equal(mixed.client, 'https://api.gaoredu.com'); + assert.equal(mixed.needsLocalApi, true); + + const both = resolveDevApiOrigins(root, { + remoteOrigin: 'api.gaoredu.com', + }); + assert.equal(both.admin, 'https://api.gaoredu.com'); + assert.equal(both.client, 'https://api.gaoredu.com'); + assert.equal(both.needsLocalApi, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('applyDevApiOriginsToEnv sets per-side env keys', () => { + const keys = [ + 'REACTPRESS_DEV_REMOTE_ORIGIN', + 'REACTPRESS_DEV_ADMIN_API_ORIGIN', + 'REACTPRESS_DEV_CLIENT_API_ORIGIN', + ]; + const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + try { + applyDevApiOriginsToEnv({ + remoteDefault: 'https://api.gaoredu.com', + admin: null, + client: 'https://api.gaoredu.com', + }); + assert.equal(process.env.REACTPRESS_DEV_REMOTE_ORIGIN, 'https://api.gaoredu.com'); + assert.equal(process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN, undefined); + assert.equal(process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN, 'https://api.gaoredu.com'); + } finally { + for (const key of keys) { + if (prev[key] === undefined) delete process.env[key]; + else process.env[key] = prev[key]; + } + } + }); + + it('builds theme API base with /api suffix', () => { + assert.equal( + resolveRemoteThemeApiBase('https://api.gaoredu.com'), + 'https://api.gaoredu.com/api', + ); + }); +}); + +describe('renderDevNginxConfig client /api', () => { + it('proxies /api to remote upstream when clientApiOrigin is set', () => { + const content = renderDevNginxConfig({ + adminPort: 3000, + visitorPort: 3001, + apiPort: 3002, + clientApiOrigin: 'https://api.gaoredu.com', + }); + assert.ok(content.includes('proxy_pass https://api.gaoredu.com')); + assert.ok(content.includes('proxy_set_header Host api.gaoredu.com')); + assert.ok(!content.includes('host.docker.internal:3002')); + }); +}); diff --git a/cli/tests/root.test.js b/cli/tests/root.test.js index 7b9dc672..c1a8c167 100644 --- a/cli/tests/root.test.js +++ b/cli/tests/root.test.js @@ -6,7 +6,7 @@ const { isProjectRoot, isPublishedCliRoot, getMonorepoRoot, -} = require('../lib/root'); +} = require('../out/lib/root'); const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); describe('lib/root', () => { diff --git a/cli/tests/sqlite-path.test.js b/cli/tests/sqlite-path.test.js new file mode 100644 index 00000000..fa95ead8 --- /dev/null +++ b/cli/tests/sqlite-path.test.js @@ -0,0 +1,36 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('sqlite paths', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-sqlite-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('defaults to .reactpress/reactpress.db', async () => { + const { getProjectPaths } = require('../out/core/utils/paths'); + const paths = getProjectPaths(tmpDir); + assert.equal(paths.sqlitePath, path.join(tmpDir, '.reactpress', 'reactpress.db')); + }); + + it('migrates legacy data/reactpress.db on resolve', async () => { + const legacyDir = path.join(tmpDir, 'data'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'reactpress.db'), 'legacy'); + + const { resolveSqlitePath } = require('../out/core/services/database/sqlite'); + const resolved = await resolveSqlitePath(tmpDir); + const expected = path.join(tmpDir, '.reactpress', 'reactpress.db'); + + assert.equal(resolved, expected); + assert.equal(fs.readFileSync(expected, 'utf8'), 'legacy'); + }); +}); diff --git a/cli/tests/theme-catalog.test.js b/cli/tests/theme-catalog.test.js new file mode 100644 index 00000000..35d5af1e --- /dev/null +++ b/cli/tests/theme-catalog.test.js @@ -0,0 +1,13 @@ +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +describe('lib/theme-catalog (re-export)', () => { + it('re-exports theme-registry API', () => { + const catalog = require('../out/lib/theme-catalog'); + const registry = require('../out/lib/theme-registry'); + assert.equal(catalog.OFFICIAL_THEME_STARTER_ID, registry.OFFICIAL_THEME_STARTER_ID); + assert.equal(typeof catalog.readThemeCatalog, 'function'); + assert.equal(typeof catalog.validateBundledThemes, 'function'); + }); +}); diff --git a/cli/tests/theme-dev-watch.test.js b/cli/tests/theme-dev-watch.test.js new file mode 100644 index 00000000..bc3330a2 --- /dev/null +++ b/cli/tests/theme-dev-watch.test.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + hasThemePackages, + hasResolvableActiveTheme, + readActiveThemeManifest, + resolveThemeDirectory, + readManifestSignature, +} = require('../out/lib/theme-runtime'); +const { createStandaloneProject, rmDir } = require('./helpers/tmp-project'); + +const HELLO_WORLD = path.join(__dirname, '../../themes/hello-world'); + +describe('theme dev watcher prerequisites', () => { + it('detects theme packages even when active theme is missing on disk', () => { + const root = createStandaloneProject(); + try { + const runtimeHello = path.join(root, '.reactpress', 'runtime', 'hello-world'); + fs.mkdirSync(path.dirname(runtimeHello), { recursive: true }); + fs.cpSync(HELLO_WORLD, runtimeHello, { recursive: true }); + + const manifestPath = path.join(root, '.reactpress', 'active-theme.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ activeTheme: 'missing-theme', themeDir: null }, null, 2), + ); + + assert.equal(hasThemePackages(root), true); + assert.equal(hasResolvableActiveTheme(root), false); + assert.equal(readManifestSignature(root), ''); + assert.ok(resolveThemeDirectory(root, 'hello-world')); + } finally { + rmDir(root); + } + }); + + it('manifest signature updates when active theme becomes resolvable', () => { + const root = createStandaloneProject(); + try { + const runtimeHello = path.join(root, '.reactpress', 'runtime', 'hello-world'); + fs.mkdirSync(path.dirname(runtimeHello), { recursive: true }); + fs.cpSync(HELLO_WORLD, runtimeHello, { recursive: true }); + + const manifestPath = path.join(root, '.reactpress', 'active-theme.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ activeTheme: 'missing-theme' }, null, 2), + ); + assert.equal(readManifestSignature(root), ''); + + fs.writeFileSync( + manifestPath, + JSON.stringify( + { + activeTheme: 'hello-world', + themeDir: '.reactpress/runtime/hello-world', + updatedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + + const signature = readManifestSignature(root); + assert.match(signature, /^hello-world:/); + assert.equal(readActiveThemeManifest(root).activeTheme, 'hello-world'); + assert.equal(hasResolvableActiveTheme(root), true); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/tests/theme-install.test.js b/cli/tests/theme-install.test.js new file mode 100644 index 00000000..576a727b --- /dev/null +++ b/cli/tests/theme-install.test.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseNpmSpec, + resolveThemeIdentity, + installThemeFromNpm, +} = require('../out/lib/theme-install'); +const { readThemeLock } = require('../out/lib/theme-lock'); +const { createStandaloneProject, rmDir } = require('./helpers/tmp-project'); + +const HELLO_WORLD_THEME = path.join(__dirname, '../../themes/hello-world'); + +describe('lib/theme-install', () => { + it('parseNpmSpec accepts npm specs and tarball paths', () => { + assert.deepEqual(parseNpmSpec('@fecommunity/reactpress-template-hello-world@3.0.4'), { + kind: 'npm', + spec: '@fecommunity/reactpress-template-hello-world@3.0.4', + }); + assert.equal(parseNpmSpec('').error, 'EMPTY_SPEC'); + }); + + it('resolveThemeIdentity reads theme.json id', () => { + const identity = resolveThemeIdentity(HELLO_WORLD_THEME); + assert.equal(identity?.themeId, 'hello-world'); + assert.equal(identity?.manifest?.name, 'Hello World'); + }); + + it('installThemeFromNpm materializes a local npm pack into runtime', async () => { + const root = createStandaloneProject(); + const packDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-pack-')); + try { + const packResult = spawnSync( + 'npm', + ['pack', HELLO_WORLD_THEME, '--pack-destination', packDir], + { encoding: 'utf8', shell: process.platform === 'win32' }, + ); + assert.equal(packResult.status, 0, packResult.stderr || packResult.stdout); + + const tarball = fs + .readdirSync(packDir) + .find((name) => name.endsWith('.tgz')); + assert.ok(tarball, 'npm pack should produce a tarball'); + + const result = await installThemeFromNpm(root, path.join(packDir, tarball), { + skipDependencies: true, + }); + assert.equal(result.themeId, 'hello-world'); + assert.ok(fs.existsSync(path.join(root, '.reactpress', 'runtime', 'hello-world', 'theme.json'))); + + const lock = readThemeLock(root); + assert.equal(lock.themes['hello-world']?.source, 'npm'); + assert.match(lock.themes['hello-world']?.spec ?? '', /\.tgz$/); + } finally { + rmDir(root); + rmDir(packDir); + } + }); +}); diff --git a/cli/tests/theme-paths.test.js b/cli/tests/theme-paths.test.js new file mode 100644 index 00000000..08e9f3fe --- /dev/null +++ b/cli/tests/theme-paths.test.js @@ -0,0 +1,27 @@ +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + THEME_RUNTIME_REL, + THEMES_CATALOG_REL, + PREVIEW_POOL_PORTS, + PREVIEW_PROXY_PORT, + getPreviewBackendPorts, + themesRoot, +} = require('../out/lib/theme-paths'); + +describe('lib/theme-paths', () => { + it('exports stable relative paths', () => { + assert.equal(THEME_RUNTIME_REL, '.reactpress/runtime'); + assert.equal(THEMES_CATALOG_REL, 'themes/catalog.json'); + assert.deepEqual(PREVIEW_POOL_PORTS, [3003]); + assert.equal(PREVIEW_PROXY_PORT, 3003); + assert.deepEqual(getPreviewBackendPorts(), [3004, 3005, 3006]); + }); + + it('themesRoot resolves under project root', () => { + const root = path.join(__dirname, '../..'); + assert.equal(themesRoot(root), path.join(root, 'themes')); + }); +}); diff --git a/cli/tests/theme-preview-frame.test.js b/cli/tests/theme-preview-frame.test.js new file mode 100644 index 00000000..33d2786a --- /dev/null +++ b/cli/tests/theme-preview-frame.test.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + ensurePreviewFrameAllowed, + stripBakedFrameOptionsFromBuild, + shouldHonorThemePreviewFrame, + PATCH_MARKER, +} = require('../out/lib/theme-preview-frame'); + +describe('lib/theme-preview-frame', () => { + it('patches next.config.js to skip X-Frame-Options during admin preview', () => { + const themeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-frame-')); + try { + fs.writeFileSync( + path.join(themeDir, 'next.config.js'), + `module.exports = { + async headers() { + return [{ + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + ], + }]; + }, +};`, + ); + + assert.equal(ensurePreviewFrameAllowed(themeDir), true); + assert.ok(fs.existsSync(path.join(themeDir, PATCH_MARKER))); + + const patched = fs.readFileSync(path.join(themeDir, 'next.config.js'), 'utf8'); + assert.match(patched, /REACTPRESS_HONOR_PREVIEW === '1'/); + assert.match(patched, /\?\s*\[\]\s*:\s*\[\{ key: 'X-Frame-Options'/); + + assert.equal(ensurePreviewFrameAllowed(themeDir), true); + } finally { + fs.rmSync(themeDir, { recursive: true, force: true }); + } + }); + + it('strips baked X-Frame-Options from routes-manifest.json', () => { + const themeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-frame-manifest-')); + try { + const distDir = '.next-preview'; + const manifestDir = path.join(themeDir, distDir); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'routes-manifest.json'), + `${JSON.stringify( + { + headers: [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + ], + }, + ], + }, + null, + 2, + )}\n`, + ); + + assert.equal(stripBakedFrameOptionsFromBuild(themeDir, distDir), true); + const parsed = JSON.parse( + fs.readFileSync(path.join(manifestDir, 'routes-manifest.json'), 'utf8'), + ); + const keys = parsed.headers[0].headers.map((h) => h.key); + assert.deepEqual(keys, ['X-Content-Type-Options']); + assert.equal(stripBakedFrameOptionsFromBuild(themeDir, distDir), false); + } finally { + fs.rmSync(themeDir, { recursive: true, force: true }); + } + }); + + it('detects desktop local preview frame mode', () => { + const prev = process.env.REACTPRESS_DESKTOP_LOCAL; + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + try { + assert.equal(shouldHonorThemePreviewFrame(), true); + } finally { + if (prev === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL; + else process.env.REACTPRESS_DESKTOP_LOCAL = prev; + } + }); +}); diff --git a/cli/tests/theme-preview-pool.test.js b/cli/tests/theme-preview-pool.test.js new file mode 100644 index 00000000..13b7ee9a --- /dev/null +++ b/cli/tests/theme-preview-pool.test.js @@ -0,0 +1,90 @@ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolvePreviewThemeLaunchPlan } = require('../out/lib/theme-preview-pool'); + +const HELLO_WORLD = path.join(__dirname, '../../themes/hello-world'); +const STARTER = path.join(__dirname, '../../.reactpress/runtime/reactpress-theme-starter'); + +describe('lib/theme-preview-pool launch plan', () => { + it('prefers dev script for hello-world outside integrated desktop dev', () => { + const prev = process.env.REACTPRESS_DESKTOP_LOCAL; + delete process.env.REACTPRESS_DESKTOP_LOCAL; + delete process.env.REACTPRESS_DESKTOP_SITE_ROOT; + try { + const plan = resolvePreviewThemeLaunchPlan(HELLO_WORLD, 3003); + assert.equal(plan.mode, 'dev'); + assert.equal(plan.cmd, 'pnpm'); + assert.deepEqual(plan.args, ['run', 'dev', '--', '--port', '3003']); + } finally { + if (prev === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL; + else process.env.REACTPRESS_DESKTOP_LOCAL = prev; + } + }); + + it('uses production next start for hello-world in dev:web:local stack', () => { + const prevLocal = process.env.REACTPRESS_DESKTOP_LOCAL; + const prevSite = process.env.REACTPRESS_DESKTOP_SITE_ROOT; + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + try { + const plan = resolvePreviewThemeLaunchPlan(HELLO_WORLD, 3004); + assert.equal(plan.mode, 'production'); + assert.equal(plan.cmd, process.execPath); + assert.match(plan.args.join(' '), /next start -p 3004/); + } finally { + if (prevLocal === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL; + else process.env.REACTPRESS_DESKTOP_LOCAL = prevLocal; + if (prevSite === undefined) delete process.env.REACTPRESS_DESKTOP_SITE_ROOT; + else process.env.REACTPRESS_DESKTOP_SITE_ROOT = prevSite; + } + }); + + it('uses shared next when packaged theme has no local node_modules', () => { + const prevLocal = process.env.REACTPRESS_DESKTOP_LOCAL; + const prevPath = process.env.NODE_PATH; + const prevRoot = process.env.REACTPRESS_MONOREPO_ROOT; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-theme-packaged-')); + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + process.env.NODE_PATH = path.join(HELLO_WORLD, 'node_modules'); + process.env.REACTPRESS_MONOREPO_ROOT = path.join(__dirname, '../..'); + fs.copyFileSync(path.join(HELLO_WORLD, 'package.json'), path.join(tmpDir, 'package.json')); + fs.copyFileSync(path.join(HELLO_WORLD, 'server.js'), path.join(tmpDir, 'server.js')); + try { + const plan = resolvePreviewThemeLaunchPlan(tmpDir, 3005); + assert.equal(plan.mode, 'production'); + assert.equal(plan.cmd, process.execPath); + assert.match(plan.args.join(' '), /next start -p 3005/); + assert.doesNotMatch(plan.args.join(' '), /server\.js/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (prevLocal === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL; + else process.env.REACTPRESS_DESKTOP_LOCAL = prevLocal; + if (prevPath === undefined) delete process.env.NODE_PATH; + else process.env.NODE_PATH = prevPath; + if (prevRoot === undefined) delete process.env.REACTPRESS_MONOREPO_ROOT; + else process.env.REACTPRESS_MONOREPO_ROOT = prevRoot; + } + }); + + it('uses production launch for App Router reactpress-theme-starter (fast switch when pre-built)', () => { + const plan = resolvePreviewThemeLaunchPlan(STARTER, 3003); + assert.equal(plan.mode, 'production'); + assert.equal(plan.cmd, process.execPath); + assert.match(plan.args.join(' '), /next/); + }); + + it('allows admin iframe when REACTPRESS_DESKTOP_LOCAL is set', () => { + const { shouldHonorThemePreviewFrame } = require('../out/lib/theme-preview-pool'); + const prev = process.env.REACTPRESS_DESKTOP_LOCAL; + process.env.REACTPRESS_DESKTOP_LOCAL = '1'; + try { + assert.equal(shouldHonorThemePreviewFrame(), true); + } finally { + if (prev === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL; + else process.env.REACTPRESS_DESKTOP_LOCAL = prev; + } + }); +}); diff --git a/cli/tests/theme-registry.test.js b/cli/tests/theme-registry.test.js new file mode 100644 index 00000000..f19d2c45 --- /dev/null +++ b/cli/tests/theme-registry.test.js @@ -0,0 +1,89 @@ +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + OFFICIAL_THEME_STARTER_ID, + OFFICIAL_THEME_STARTER_SPEC, + readThemeCatalog, + readThemesPackageMeta, + readThemesRegistryMeta, + readThemeSources, + resolveCatalogInstallSpec, + validateLocalThemes, + validateNpmThemes, + validateBundledThemes, + validateCatalogThemes, + catalogEntryToManifest, +} = require('../out/lib/theme-registry'); + +const REPO_ROOT = path.join(__dirname, '../..'); + +describe('lib/theme-registry', () => { + it('reads official theme-starter from themes/theme-starter/package.json anchor', () => { + const catalog = readThemeCatalog(REPO_ROOT); + const starter = catalog.themes.find((entry) => entry.id === OFFICIAL_THEME_STARTER_ID); + assert.ok(starter); + assert.equal(starter.npm, OFFICIAL_THEME_STARTER_SPEC); + assert.deepEqual(starter.dependency, { + name: '@fecommunity/reactpress-theme-starter', + version: '1.0.0-beta.0', + }); + assert.equal(starter.featured, true); + assert.equal(starter.dir, 'theme-starter'); + assert.equal(starter.previewUrl, 'https://reactpress-theme-starter.vercel.app'); + assert.equal(catalog.source, 'themes/package.json'); + }); + + it('resolveCatalogInstallSpec maps catalog id to npm spec', () => { + assert.equal( + resolveCatalogInstallSpec(REPO_ROOT, OFFICIAL_THEME_STARTER_ID), + OFFICIAL_THEME_STARTER_SPEC, + ); + }); + + it('readThemesRegistryMeta lists local ids and npm anchor dirs', () => { + const meta = readThemesRegistryMeta(REPO_ROOT); + assert.ok(meta.local.includes('hello-world')); + assert.ok(meta.npm.includes('theme-starter')); + }); + + it('readThemesPackageMeta keeps bundled/catalog aliases for legacy callers', () => { + const meta = readThemesPackageMeta(REPO_ROOT); + assert.ok(meta.bundled.includes('hello-world')); + assert.ok(meta.local.includes('hello-world')); + }); + + it('readThemeSources exposes local and npm kinds', () => { + const sources = readThemeSources(REPO_ROOT); + assert.ok(sources.local.some((entry) => entry.id === 'hello-world' && entry.kind === 'local')); + assert.ok( + sources.npm.some((entry) => entry.id === OFFICIAL_THEME_STARTER_ID && entry.kind === 'npm'), + ); + }); + + it('catalogEntryToManifest maps catalog metadata', () => { + const entry = readThemeCatalog(REPO_ROOT).themes[0]; + const manifest = catalogEntryToManifest(entry); + assert.equal(manifest.id, entry.id); + assert.equal(manifest.name, entry.name); + }); + + it('validateLocalThemes reports missing template dirs', () => { + const { local, missing } = validateLocalThemes(REPO_ROOT); + assert.ok(local.includes('hello-world')); + assert.equal(missing.length, 0); + }); + + it('validateNpmThemes accepts theme-starter anchor package.json', () => { + const { missing } = validateNpmThemes(REPO_ROOT); + assert.equal(missing.length, 0); + }); + + it('validateBundledThemes and validateCatalogThemes remain as aliases', () => { + const bundled = validateBundledThemes(REPO_ROOT); + const catalog = validateCatalogThemes(REPO_ROOT); + assert.equal(bundled.missing.length, 0); + assert.equal(catalog.missing.length, 0); + }); +}); diff --git a/cli/tests/theme-warmup.test.js b/cli/tests/theme-warmup.test.js new file mode 100644 index 00000000..d27a5e14 --- /dev/null +++ b/cli/tests/theme-warmup.test.js @@ -0,0 +1,55 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const { pageFileToRoute, collectWarmupRoutes, isWarmupSafeRoute } = require('../out/lib/theme-warmup'); +const { createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); + +describe('lib/theme-warmup', () => { + it('filters dynamic and admin routes from warmup', () => { + assert.equal(isWarmupSafeRoute('/'), true); + assert.equal(isWarmupSafeRoute('/archives'), true); + assert.equal(isWarmupSafeRoute('/tag/__reactpress_dev_warmup__'), false); + assert.equal(isWarmupSafeRoute('/admin/article'), false); + }); + + it('maps template files to warmup routes', () => { + assert.equal(pageFileToRoute('pages/index.tsx'), '/'); + assert.equal(pageFileToRoute('pages/about.tsx'), '/about'); + assert.equal(pageFileToRoute('pages/tag/[tag].tsx'), '/tag/__reactpress_dev_warmup__'); + assert.equal(pageFileToRoute('pages/category/[category].tsx'), '/category/__reactpress_dev_warmup__'); + assert.equal(pageFileToRoute('pages/article/[id].tsx'), '/article/__reactpress_dev_warmup__'); + }); + + it('collects routes from theme.json templates', () => { + const root = createMonorepoFixture(); + try { + const themeDir = path.join(root, 'themes', 'demo-theme'); + const pagesDir = path.join(themeDir, 'pages'); + require('fs').mkdirSync(path.join(pagesDir, 'tag'), { recursive: true }); + require('fs').writeFileSync( + path.join(themeDir, 'theme.json'), + JSON.stringify({ + id: 'demo-theme', + reactpress: { + templates: { + home: 'pages/index.tsx', + 'archive-tag': 'pages/tag/[tag].tsx', + search: 'pages/search.tsx', + }, + }, + }), + ); + require('fs').writeFileSync(path.join(pagesDir, 'index.tsx'), ''); + require('fs').writeFileSync(path.join(pagesDir, 'search.tsx'), ''); + require('fs').writeFileSync(path.join(pagesDir, 'tag', '[tag].tsx'), ''); + + const routes = collectWarmupRoutes(themeDir); + assert.ok(routes.includes('/')); + assert.ok(routes.includes('/search')); + assert.ok(!routes.some((r) => r.includes('__reactpress_dev_warmup__'))); + assert.ok(routes.includes('/404')); + } finally { + rmDir(root); + } + }); +}); diff --git a/cli/tests/toolkit-build.test.js b/cli/tests/toolkit-build.test.js new file mode 100644 index 00000000..2e330dce --- /dev/null +++ b/cli/tests/toolkit-build.test.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { shouldBuildToolkit } = require('../out/lib/toolkit-build'); +const { createMonorepoFixture, rmDir } = require('./helpers/tmp-project'); + +describe('lib/toolkit-build', () => { + it('requires build when dist is missing', () => { + const root = createMonorepoFixture(); + try { + assert.equal(shouldBuildToolkit(root), true); + } finally { + rmDir(root); + } + }); + + it('skips build when dist is newer than src', () => { + const root = createMonorepoFixture(); + try { + const toolkitDir = path.join(root, 'toolkit'); + const distDir = path.join(toolkitDir, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), 'module.exports = {};\n'); + const srcDir = path.join(toolkitDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export {};\n'); + const past = new Date(Date.now() - 60_000); + fs.utimesSync(path.join(srcDir, 'index.ts'), past, past); + const future = new Date(Date.now() + 60_000); + fs.utimesSync(path.join(distDir, 'index.js'), future, future); + assert.equal(shouldBuildToolkit(root), false); + } finally { + rmDir(root); + } + }); + + it('honors REACTPRESS_SKIP_TOOLKIT_BUILD', () => { + const root = createMonorepoFixture(); + const prev = process.env.REACTPRESS_SKIP_TOOLKIT_BUILD; + process.env.REACTPRESS_SKIP_TOOLKIT_BUILD = '1'; + try { + assert.equal(shouldBuildToolkit(root), false); + } finally { + if (prev === undefined) delete process.env.REACTPRESS_SKIP_TOOLKIT_BUILD; + else process.env.REACTPRESS_SKIP_TOOLKIT_BUILD = prev; + rmDir(root); + } + }); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 00000000..7b0f13c8 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "moduleDetection": "force", + "outDir": "out", + "rootDir": "src", + "strict": false, + "noImplicitAny": false, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "out", "dist", "server", "tests"] +} diff --git a/cli/ui/banner.js b/cli/ui/banner.js deleted file mode 100644 index c4fa9f77..00000000 --- a/cli/ui/banner.js +++ /dev/null @@ -1,441 +0,0 @@ -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 deleted file mode 100644 index 282a2b08..00000000 --- a/cli/ui/interactive.js +++ /dev/null @@ -1,380 +0,0 @@ -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/client/Dockerfile b/client/Dockerfile deleted file mode 100644 index 2e70ebfc..00000000 --- a/client/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -# 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 deleted file mode 100644 index 154717ef..00000000 --- a/client/README.md +++ /dev/null @@ -1,341 +0,0 @@ -# @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 deleted file mode 100755 index c045124c..00000000 --- a/client/bin/reactpress-client.js +++ /dev/null @@ -1,190 +0,0 @@ -#!/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 deleted file mode 100644 index 4f11a03d..00000000 --- a/client/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// 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 deleted file mode 100644 index 63aa6ab1..00000000 --- a/client/next-sitemap.js +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 4ff813f5..00000000 --- a/client/next.config.js +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 6f4e531d..00000000 --- a/client/package.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "name": "@fecommunity/reactpress-client", - "version": "3.7.0", - "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", - "@monaco-editor/react": "^4.6.0", - "@fecommunity/reactpress-toolkit": "workspace:*", - "fs-extra": "^10.0.0", - "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", - "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", - "echarts-for-react": "^3.0.2", - "echarts": "^5.6.0" - }, - "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 deleted file mode 100644 index 5e5e5df3..00000000 --- a/client/pages/404.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 18c4b309..00000000 --- a/client/pages/_app.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import '@/theme/index.scss'; -import 'highlight.js/styles/atom-one-dark.css'; -import 'viewerjs/dist/viewer.css'; - -import { NProgress } from '@components/NProgress'; -import { ConfigProvider, theme } from 'antd'; -import { IntlMessages, NextIntlProvider } from 'next-intl'; -import App from 'next/app'; -import { default as Router } from 'next/router'; - -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 deleted file mode 100644 index 4e515f7b..00000000 --- a/client/pages/_document.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index f49fff68..00000000 --- a/client/src/components/AdvanceSearch/index.module.scss +++ /dev/null @@ -1,129 +0,0 @@ -.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 deleted file mode 100644 index 32f0b68d..00000000 --- a/client/src/components/AdvanceSearch/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 739d9689..00000000 --- a/client/src/components/Analytics/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 0537d667..00000000 --- a/client/src/components/Animation/Opacity.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 6db5262c..00000000 --- a/client/src/components/Animation/Spring.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 49177aa9..00000000 --- a/client/src/components/Animation/Trail.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index da7b353c..00000000 --- a/client/src/components/Animation/Transition.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 133b2094..00000000 --- a/client/src/components/ArticleCarousel/index.module.scss +++ /dev/null @@ -1,85 +0,0 @@ -.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 deleted file mode 100644 index 8f00e92a..00000000 --- a/client/src/components/ArticleCarousel/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index b38c9083..00000000 --- a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss +++ /dev/null @@ -1,38 +0,0 @@ -.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 deleted file mode 100644 index fd0d2daf..00000000 --- a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx +++ /dev/null @@ -1,212 +0,0 @@ -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 deleted file mode 100644 index 8a69d715..00000000 --- a/client/src/components/ArticleEditor/index.module.scss +++ /dev/null @@ -1,31 +0,0 @@ -.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 deleted file mode 100644 index ca25ec07..00000000 --- a/client/src/components/ArticleEditor/index.tsx +++ /dev/null @@ -1,247 +0,0 @@ -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 deleted file mode 100644 index 5d1c64fa..00000000 --- a/client/src/components/ArticleList/index.module.scss +++ /dev/null @@ -1,265 +0,0 @@ -.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 deleted file mode 100644 index a305bc19..00000000 --- a/client/src/components/ArticleList/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** - * 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 deleted file mode 100644 index c4a27426..00000000 --- a/client/src/components/ArticleRecommend/index.module.scss +++ /dev/null @@ -1,152 +0,0 @@ -.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 deleted file mode 100644 index 25f72083..00000000 --- a/client/src/components/ArticleRecommend/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index fa76f47b..00000000 --- a/client/src/components/Categories/index.module.scss +++ /dev/null @@ -1,58 +0,0 @@ -.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 deleted file mode 100644 index 93c9baf2..00000000 --- a/client/src/components/Categories/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index c148b106..00000000 --- a/client/src/components/Comment/CommentAction/CommentAction.tsx +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index c7d34545..00000000 --- a/client/src/components/Comment/CommentAction/CommentArticle.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 11cf73a9..00000000 --- a/client/src/components/Comment/CommentAction/CommentContent.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index fd358130..00000000 --- a/client/src/components/Comment/CommentAction/CommentHTML.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 9588928d..00000000 --- a/client/src/components/Comment/CommentAction/CommentStatus.tsx +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 7bf73382..00000000 --- a/client/src/components/Comment/CommentAction/index.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.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 deleted file mode 100644 index 75a2633c..00000000 --- a/client/src/components/Comment/CommentEditor/Emoji/emojis.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 04887ca9..00000000 --- a/client/src/components/Comment/CommentEditor/Emoji/index.module.scss +++ /dev/null @@ -1,36 +0,0 @@ -.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 deleted file mode 100644 index 5843f2a4..00000000 --- a/client/src/components/Comment/CommentEditor/Emoji/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index b408edc3..00000000 --- a/client/src/components/Comment/CommentEditor/index.module.scss +++ /dev/null @@ -1,60 +0,0 @@ -.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 deleted file mode 100644 index 7b9a0304..00000000 --- a/client/src/components/Comment/CommentEditor/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -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); - } - }} - >
    - )} -