This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,27 @@
|
||||
# Ghost Admin (React)
|
||||
|
||||
New React-based Ghost admin interface, gradually replacing the existing Ember admin.
|
||||
|
||||
## Architecture
|
||||
|
||||
Uses an **Ember Bridge** system for smooth migration:
|
||||
- Routes ported to React render React components
|
||||
- Unported routes fall back to the existing Ember admin
|
||||
- Both share the same UI space seamlessly
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start development server (from monorepo root)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Building for Production
|
||||
|
||||
```bash
|
||||
# Build production bundle
|
||||
pnpm nx run @tryghost/admin:build
|
||||
```
|
||||
|
||||
This outputs to `apps/admin/dist/` and updates the assets in `ghost/core/core/built/admin/`.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tailwindcss from 'eslint-plugin-tailwindcss'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'
|
||||
import ghostPlugin from 'eslint-plugin-ghost';
|
||||
|
||||
const noHardcodedGhostPaths = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow hardcoded /ghost/ paths that break subdirectory installations',
|
||||
},
|
||||
messages: {
|
||||
noHardcodedPath: 'Do not hardcode /ghost/ paths. Use getGhostPaths() from @tryghost/admin-x-framework/helpers to support subdirectory installations.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const pattern = /^\/ghost\//;
|
||||
return {
|
||||
Literal(node) {
|
||||
if (typeof node.value === 'string' && pattern.test(node.value)) {
|
||||
context.report({node, messageId: 'noHardcodedPath'});
|
||||
}
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
const first = node.quasis[0];
|
||||
if (first && pattern.test(first.value.raw)) {
|
||||
context.report({node, messageId: 'noHardcodedPath'});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
'no-hardcoded-ghost-paths': noHardcodedGhostPaths,
|
||||
},
|
||||
};
|
||||
const tailwindCssConfig = `${import.meta.dirname}/src/index.css`;
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
plugins: {
|
||||
'no-relative-import-paths': noRelativeImportPaths,
|
||||
ghost: ghostPlugin,
|
||||
local: localPlugin,
|
||||
tailwindcss,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
settings: {
|
||||
tailwindcss: {
|
||||
config: tailwindCssConfig,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
|
||||
'no-restricted-imports': ['error', {
|
||||
paths: [{
|
||||
name: '@tryghost/shade',
|
||||
message: 'Import from layered subpaths instead (components/primitives/patterns/utils/app/tokens).',
|
||||
}],
|
||||
}],
|
||||
'tailwindcss/classnames-order': 'error',
|
||||
'tailwindcss/no-contradicting-classname': 'error',
|
||||
},
|
||||
},
|
||||
// Apply no-relative-import-paths rule for src files (auto-fix supported)
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: true, rootDir: 'src', prefix: '@' },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Prevent hardcoded /ghost/ paths in production code (not tests, where mocks need fixed paths)
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: ['src/**/*.test.*'],
|
||||
rules: {
|
||||
'local/no-hardcoded-ghost-paths': 'error',
|
||||
},
|
||||
},
|
||||
// Apply no-relative-import-paths rule for test-utils files
|
||||
// Note: auto-fix may produce incorrect paths for cross-directory imports
|
||||
// Use the correct alias manually: @/* for src/, @test-utils/* for test-utils/
|
||||
{
|
||||
files: ['test-utils/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<title>Ghost</title>
|
||||
|
||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
|
||||
<meta name="pinterest" content="nopin" />
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Ghost" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ghost" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
</head>
|
||||
<body class="react-admin">
|
||||
<div id="ember-alerts-wormhole"></div>
|
||||
<div id="root"></div>
|
||||
<div id="ember-app">
|
||||
<div class="ember-load-indicator">
|
||||
<div class="gh-loading-content">
|
||||
<video width="100" height="100" loop autoplay muted playsinline preload="metadata" style="width: 100px; height: 100px;">
|
||||
<source src="/src/assets/videos/logo-loader.mp4" type="video/mp4" />
|
||||
<div class="gh-loading-spinner"></div>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ember-basic-dropdown-wormhole"></div>
|
||||
<div id="ember-modal-wormhole"></div>
|
||||
<div id="ember-liquid-wormhole"></div>
|
||||
<div id="ember-notifications-wormhole"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "@tryghost/admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "pnpm test:unit",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/activitypub": "workspace:*",
|
||||
"@tryghost/admin-x-framework": "workspace:*",
|
||||
"@tryghost/admin-x-settings": "workspace:*",
|
||||
"@tryghost/koenig-lexical": "1.7.30",
|
||||
"@tryghost/posts": "workspace:*",
|
||||
"@tryghost/shade": "workspace:*",
|
||||
"@tryghost/stats": "workspace:*",
|
||||
"mingo": "2.5.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"zod": "4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:eslint9",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/react": "18.3.28",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vitejs/plugin-react-swc": "4.1.0",
|
||||
"eslint": "catalog:eslint9",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
|
||||
"globals": "17.4.0",
|
||||
"jest-extended": "7.0.0",
|
||||
"jsdom": "28.1.0",
|
||||
"msw": "2.12.14",
|
||||
"sirv": "3.0.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.58.0",
|
||||
"vite": "7.1.12",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "4.1.2"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"dev": {
|
||||
"dependsOn": [
|
||||
"ghost-admin:dev",
|
||||
"@tryghost/admin-x-framework:dev",
|
||||
"@tryghost/admin-x-design-system:dev",
|
||||
"@tryghost/shade:dev"
|
||||
]
|
||||
},
|
||||
"build:dev": {
|
||||
"dependsOn": [
|
||||
"build",
|
||||
{
|
||||
"projects": [
|
||||
"ghost-admin"
|
||||
],
|
||||
"target": "build:dev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"outputs": [
|
||||
"{projectRoot}/dist",
|
||||
"{workspaceRoot}/ghost/core/core/built/admin"
|
||||
],
|
||||
"dependsOn": [
|
||||
"build",
|
||||
{
|
||||
"projects": [
|
||||
"ghost-admin"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": [
|
||||
"@tryghost/admin-x-framework",
|
||||
"@tryghost/admin-x-design-system",
|
||||
"@tryghost/shade"
|
||||
],
|
||||
"target": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Outlet } from "@tryghost/admin-x-framework";
|
||||
import { useCurrentUser } from "@tryghost/admin-x-framework/api/current-user";
|
||||
import { EmberProvider, EmberFallback, EmberRoot } from "./ember-bridge";
|
||||
import { AdminLayout } from "./layout/admin-layout";
|
||||
import { useEmberAuthSync, useEmberDataSync } from "./ember-bridge";
|
||||
|
||||
function App() {
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
useEmberAuthSync();
|
||||
useEmberDataSync();
|
||||
|
||||
return (
|
||||
<EmberProvider>
|
||||
{currentUser ?
|
||||
<AdminLayout>
|
||||
<Outlet />
|
||||
<EmberRoot />
|
||||
</AdminLayout>
|
||||
:
|
||||
<>
|
||||
<EmberFallback />
|
||||
<EmberRoot />
|
||||
</>
|
||||
}
|
||||
</EmberProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,178 @@
|
||||
@source "../../shade/src/**/*.{ts,tsx}";
|
||||
@source "../../posts/src/**/*.{ts,tsx}";
|
||||
@source "../../stats/src/**/*.{ts,tsx}";
|
||||
@source "../../activitypub/src/**/*.{ts,tsx}";
|
||||
@source "../../admin-x-settings/src/**/*.{ts,tsx}";
|
||||
@source "../../admin-x-design-system/src/**/*.{ts,tsx}";
|
||||
@source "../../../node_modules/@tryghost/kg-unsplash-selector/dist/**/*.js";
|
||||
|
||||
@import "@tryghost/shade/styles.css";
|
||||
|
||||
/* Legacy utility compatibility (Spirit/Tachyons-style percentages). */
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Keep admin-x heading line-height consistent with pre-migration settings render. */
|
||||
.admin-x-base h1,
|
||||
.admin-x-base h2,
|
||||
.admin-x-base h3,
|
||||
.admin-x-base h4,
|
||||
.admin-x-base h5 {
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
/* ActivityPub onboarding animations previously defined in admin tailwind config */
|
||||
@keyframes lineExpand {
|
||||
0% {
|
||||
transform: scaleX(0);
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
transform-origin: right;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboarding-handle-bg {
|
||||
opacity: 1;
|
||||
animation: fadeIn 0.2s ease-in 0.5s forwards;
|
||||
}
|
||||
|
||||
.animate-onboarding-handle-line {
|
||||
animation: lineExpand 0.2s ease-in-out 0.7s forwards;
|
||||
}
|
||||
|
||||
.before\:animate-onboarding-handle-bg::before {
|
||||
opacity: 1;
|
||||
animation: fadeIn 0.2s ease-in 0.5s forwards;
|
||||
}
|
||||
|
||||
.after\:animate-onboarding-handle-line::after {
|
||||
animation: lineExpand 0.2s ease-in-out 0.7s forwards;
|
||||
transform: scaleX(1) !important;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.animate-onboarding-handle-label {
|
||||
opacity: 1;
|
||||
animation: fadeIn 0.2s ease-in 1.2s forwards;
|
||||
}
|
||||
|
||||
.animate-onboarding-next-button {
|
||||
opacity: 1;
|
||||
animation: fadeIn 0.2s ease-in 2s forwards;
|
||||
}
|
||||
|
||||
.animate-onboarding-followers {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
animation: fadeIn 0.2s ease-in 0.5s forwards, scale 0.3s ease-in 0.5s forwards;
|
||||
}
|
||||
|
||||
.break-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Base layout - grid structure for alerts and main content */
|
||||
body.react-admin {
|
||||
height: 100svh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 100%;
|
||||
overflow: hidden; /* Prevent body scroll */
|
||||
}
|
||||
|
||||
/* Alerts show at the top and push content down */
|
||||
body.react-admin #ember-alerts-wormhole {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
z-index: 0; /* Hide alerts when settings app modal is open */
|
||||
}
|
||||
|
||||
/* Main app container - pass through to children (.shade.shade-admin) */
|
||||
body.react-admin #root {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Ensure ShadeApp takes full grid space */
|
||||
body.react-admin #root .shade.shade-admin {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* iOS safe area handling for mobile navbar */
|
||||
body.react-admin .safe-area-inset-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Ensure the Ember app renders in the correct position and takes full width/height */
|
||||
body.react-admin #ember-app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Temporary overrides until Ember transition is finished */
|
||||
body.react-admin .gh-canvas-header {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
body.react-admin .gh-canvas-breadcrumb+.gh-canvas-title {
|
||||
padding-top: 0px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
body.react-admin .gh-canvas-title,
|
||||
body.react-admin [data-header="header-title"] {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
body.react-admin [data-header="header"] {
|
||||
padding-top: 24px;
|
||||
/* padding-bottom: 18px; */
|
||||
}
|
||||
|
||||
body.react-admin [data-navbar="navbar"] {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
body.react-admin #ember-app .gh-viewport {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
body.react-admin .gh-canvas-header {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
body.react-admin .members-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.react-admin [data-test-table="members"] thead,
|
||||
body.react-admin [data-test-table="members"] tr {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./app.tsx";
|
||||
import { FrameworkProvider, RouterProvider } from "@tryghost/admin-x-framework";
|
||||
import { ShadeApp } from "@tryghost/shade/app";
|
||||
|
||||
import { routes } from "./routes.tsx";
|
||||
import { navigateTo } from "./utils/navigation";
|
||||
import { AppProvider } from "./providers/app-provider";
|
||||
|
||||
const framework = {
|
||||
ghostVersion: "",
|
||||
externalNavigate: (link: { route: string; isExternal: boolean }) => {
|
||||
navigateTo(link.route);
|
||||
},
|
||||
unsplashConfig: {
|
||||
Authorization: "Client-ID 8672af113b0a8573edae3aa3713886265d9bb741d707f6c01a486cde8c278980",
|
||||
"Accept-Version": "v1",
|
||||
"Content-Type": "application/json",
|
||||
"App-Pragma": "no-cache",
|
||||
"X-Unsplash-Cache": true,
|
||||
},
|
||||
sentryDSN: null,
|
||||
onUpdate: (dataType: string, response: unknown) => {
|
||||
window.EmberBridge?.state.onUpdate(dataType, response);
|
||||
},
|
||||
onInvalidate: (dataType: string) => {
|
||||
window.EmberBridge?.state.onInvalidate(dataType);
|
||||
},
|
||||
onDelete: (dataType: string, id: string) => {
|
||||
window.EmberBridge?.state.onDelete(dataType, id);
|
||||
},
|
||||
};
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<FrameworkProvider {...framework}>
|
||||
<RouterProvider prefix={"/"} routes={routes}>
|
||||
<AppProvider>
|
||||
<ShadeApp
|
||||
className="shade-admin"
|
||||
darkMode={false}
|
||||
fetchKoenigLexical={null}
|
||||
>
|
||||
<App />
|
||||
</ShadeApp>
|
||||
</AppProvider>
|
||||
</RouterProvider>
|
||||
</FrameworkProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {MembersRoute} from './members-route';
|
||||
|
||||
const {mockCanManageMembers, mockUseCurrentUser} = vi.hoisted(() => ({
|
||||
mockCanManageMembers: vi.fn(),
|
||||
mockUseCurrentUser: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tryghost/admin-x-framework', () => ({
|
||||
Outlet: () => React.createElement('div', {'data-testid': 'outlet'}),
|
||||
Navigate: ({replace, to}: {replace?: boolean; to: string}) => React.createElement('div', {
|
||||
'data-replace': String(Boolean(replace)),
|
||||
'data-testid': 'navigate',
|
||||
'data-to': to
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('@tryghost/admin-x-framework/api/current-user', () => ({
|
||||
useCurrentUser: mockUseCurrentUser
|
||||
}));
|
||||
|
||||
vi.mock('@tryghost/admin-x-framework/api/users', () => ({
|
||||
canManageMembers: mockCanManageMembers
|
||||
}));
|
||||
|
||||
describe('MembersRoute', () => {
|
||||
beforeEach(() => {
|
||||
mockCanManageMembers.mockReturnValue(true);
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
data: {
|
||||
id: '1',
|
||||
roles: [{name: 'Administrator'}]
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the nested members routes for authorized users', () => {
|
||||
render(<MembersRoute />);
|
||||
|
||||
expect(screen.getByTestId('outlet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects users without member permissions to home', () => {
|
||||
mockCanManageMembers.mockReturnValue(false);
|
||||
|
||||
render(<MembersRoute />);
|
||||
|
||||
expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/');
|
||||
expect(screen.getByTestId('navigate')).toHaveAttribute('data-replace', 'true');
|
||||
});
|
||||
|
||||
it('renders nothing while the current user is still loading', () => {
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
const {container} = render(<MembersRoute />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('redirects to home when the current user is unavailable after loading', () => {
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
render(<MembersRoute />);
|
||||
|
||||
expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {Navigate, Outlet} from "@tryghost/admin-x-framework";
|
||||
import {useCurrentUser} from "@tryghost/admin-x-framework/api/current-user";
|
||||
import {canManageMembers} from "@tryghost/admin-x-framework/api/users";
|
||||
|
||||
export function MembersRoute() {
|
||||
const {data: currentUser, isError, isLoading} = useCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
if (isError || !isLoading) {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!canManageMembers(currentUser)) {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {Navigate} from "@tryghost/admin-x-framework";
|
||||
import {useCurrentUser} from "@tryghost/admin-x-framework/api/current-user";
|
||||
|
||||
const MyProfileRedirect = () => {
|
||||
const {data: currentUser, isError, isLoading} = useCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
if (isError || !isLoading) {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate replace to={`/settings/staff/${currentUser.slug}`} />;
|
||||
};
|
||||
|
||||
export default MyProfileRedirect;
|
||||
@@ -0,0 +1,11 @@
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">404</h1>
|
||||
<span className="text-grey-500" aria-hidden="true">|</span>
|
||||
<h2 className="text-lg text-grey-700">Page not found</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {type RouteObject, Outlet, lazyComponent, redirect} from "@tryghost/admin-x-framework";
|
||||
|
||||
// ActivityPub
|
||||
import { FeatureFlagsProvider, routes as activityPubRoutes } from "@tryghost/activitypub/api";
|
||||
|
||||
// Posts (aka tags and post analytics)
|
||||
import { PostsAppContextProvider, routes as postRoutes } from "@tryghost/posts/api";
|
||||
|
||||
// Stats (aka analytics)
|
||||
import { GlobalDataProvider, routes as statsRoutes } from "@tryghost/stats/api";
|
||||
import MyProfileRedirect from "./my-profile-redirect";
|
||||
|
||||
// Ember
|
||||
import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge";
|
||||
import type { RouteHandle } from "./ember-bridge";
|
||||
import { MembersRoute } from "./members-route";
|
||||
|
||||
import { NotFound } from "./not-found";
|
||||
|
||||
// Routes handled by the Ember admin app. React delegates these to Ember via
|
||||
// EmberFallback. When migrating a route to React, remove its entry from here.
|
||||
const EMBER_ROUTES: string[] = [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/site",
|
||||
"/launch",
|
||||
"/setup/*",
|
||||
"/signin/*",
|
||||
"/signout",
|
||||
"/signup/*",
|
||||
"/reset/*",
|
||||
"/pro/*",
|
||||
"/posts",
|
||||
"/posts/analytics/:postId/mentions",
|
||||
"/posts/analytics/:postId/debug",
|
||||
"/restore",
|
||||
"/pages",
|
||||
"/editor/*",
|
||||
"/tags/new",
|
||||
"/explore/*",
|
||||
"/migrate/*",
|
||||
"/members/new",
|
||||
"/members/:member_id",
|
||||
"/members-activity",
|
||||
"/designsandbox",
|
||||
"/mentions",
|
||||
];
|
||||
|
||||
const emberFallbackHandle = { allowInForceUpgrade: true } satisfies RouteHandle;
|
||||
|
||||
const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map(path => ({
|
||||
path,
|
||||
Component: EmberFallback,
|
||||
handle: emberFallbackHandle,
|
||||
}));
|
||||
|
||||
const membersRoute: RouteObject = {
|
||||
path: "/members",
|
||||
element: <MembersRoute />,
|
||||
handle: emberFallbackHandle,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyComponent(() => import("@tryghost/posts/members"))
|
||||
},
|
||||
{
|
||||
path: "import",
|
||||
lazy: lazyComponent(() => import("@tryghost/posts/members"))
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
// ForceUpgradeGuard wraps all routes to redirect to /pro when in force upgrade mode.
|
||||
// Routes with handle.allowInForceUpgrade: true bypass this protection.
|
||||
element: <ForceUpgradeGuard />,
|
||||
children: [
|
||||
{
|
||||
// Override the tag detail route from the posts app to ensure we
|
||||
// correctly delegate to Ember since we can't remove the blank screen in
|
||||
// the posts app. The blank screen needs to be there to prevent the
|
||||
// router error fallback from triggering when navigating from the tag
|
||||
// list to a tag detail page.
|
||||
path: "/tags/:tagSlug",
|
||||
Component: EmberFallback,
|
||||
handle: emberFallbackHandle,
|
||||
},
|
||||
membersRoute,
|
||||
{
|
||||
element: (
|
||||
<PostsAppContextProvider value={{ fromAnalytics: true }}>
|
||||
<Outlet />
|
||||
</PostsAppContextProvider>
|
||||
),
|
||||
// Filter out catch-all routes
|
||||
children: postRoutes[0].children!.filter((route) => route.path !== "*"),
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<GlobalDataProvider>
|
||||
<Outlet />
|
||||
</GlobalDataProvider>
|
||||
),
|
||||
children: statsRoutes,
|
||||
},
|
||||
{
|
||||
path: `network`,
|
||||
loader: () => redirect("/activitypub"),
|
||||
},
|
||||
{
|
||||
path: "my-profile",
|
||||
Component: MyProfileRedirect,
|
||||
handle: { allowInForceUpgrade: true } satisfies RouteHandle,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: (
|
||||
<FeatureFlagsProvider>
|
||||
<Outlet />
|
||||
</FeatureFlagsProvider>
|
||||
),
|
||||
children: activityPubRoutes,
|
||||
},
|
||||
{
|
||||
path: `settings/*`,
|
||||
lazy: lazyComponent(() => import("./settings/settings")),
|
||||
handle: { allowInForceUpgrade: true } satisfies RouteHandle,
|
||||
},
|
||||
// Ember-handled routes
|
||||
...emberFallbackRoutes,
|
||||
{
|
||||
// 404 catch-all for routes not handled by React or Ember
|
||||
path: "*",
|
||||
Component: NotFound,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '@tryghost/limit-service'
|
||||
declare module '@tryghost/nql'
|
||||
declare module '@tryghost/koenig-lexical'
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { expect } from "vitest";
|
||||
import matchers from "jest-extended";
|
||||
import { setupShadeMocks } from "@tryghost/admin-x-framework/test/setup";
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
setupShadeMocks();
|
||||
@@ -0,0 +1,13 @@
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { expect } from "vitest";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
export async function waitForQuerySettled<T>(result: { current: UseQueryResult<T, unknown> }) {
|
||||
await waitFor(
|
||||
() => {
|
||||
// Query is settled when it has reached a terminal state (success or error)
|
||||
const isSettled = (result.current.isSuccess || result.current.isError) && !result.current.isFetching;
|
||||
expect(isSettled).toBe(true);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "vitest/globals"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@test-utils/*": ["./test-utils/*"],
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src", "test-utils"],
|
||||
"references": [
|
||||
{ "path": "../admin-x-framework/tsconfig.declaration.json" },
|
||||
{ "path": "../posts/tsconfig.declaration.json" },
|
||||
{ "path": "../stats/tsconfig.declaration.json" },
|
||||
{ "path": "../activitypub/tsconfig.declaration.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["./vite*.ts",]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Plugin, ProxyOptions } from "vite";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { getSubdir, GHOST_URL } from "./vite.config";
|
||||
|
||||
/**
|
||||
* Resolves the configured Ghost site URL by calling the admin api site endpoint
|
||||
* with retries (up to 20 seconds).
|
||||
*/
|
||||
async function resolveGhostSiteUrl() {
|
||||
const MAX_ATTEMPTS = 20;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const siteEndpoint = new URL('ghost/api/admin/site/', GHOST_URL);
|
||||
const response = await fetch(siteEndpoint);
|
||||
const data = (await response.json()) as { site: { url: string } };
|
||||
return {
|
||||
url: data.site.url,
|
||||
host: new URL(data.site.url).host,
|
||||
};
|
||||
} catch (error) {
|
||||
if (attempt === MAX_ATTEMPTS) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to resolve Ghost site URL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates proxy configuration for Ghost Admin API requests. Rewrites cookies
|
||||
* and headers to work with Ghost's security middleware.
|
||||
*/
|
||||
function createAdminApiProxy(site: {
|
||||
url: string;
|
||||
host: string;
|
||||
}): Record<string, ProxyOptions> {
|
||||
// When running the dev server against the backend on HTTPS, we need to
|
||||
// remove the same site and secure flags from the cookie. Otherwise, the
|
||||
// browser won't set it correctly since the dev server is running on HTTP.
|
||||
const rewriteCookies = (proxyRes: IncomingMessage) => {
|
||||
const cookies = proxyRes.headers["set-cookie"];
|
||||
if (Array.isArray(cookies)) {
|
||||
proxyRes.headers["set-cookie"] = cookies.map((cookie) => {
|
||||
return cookie
|
||||
.split(";")
|
||||
.filter((v) => v.trim().toLowerCase() !== "secure")
|
||||
.filter((v) => v.trim().toLowerCase() !== "samesite=none")
|
||||
.join("; ");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const subdir = getSubdir();
|
||||
|
||||
return {
|
||||
[`^${subdir}/ghost/api/.*`]: {
|
||||
target: site.url,
|
||||
changeOrigin: true,
|
||||
followRedirects: true,
|
||||
autoRewrite: true,
|
||||
cookieDomainRewrite: {
|
||||
"*": site.host,
|
||||
},
|
||||
configure(proxy) {
|
||||
proxy.on("proxyRes", rewriteCookies);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates proxy configuration for Ember CLI live reload script.
|
||||
*/
|
||||
function createEmberLiveReloadProxy(): Record<string, ProxyOptions> {
|
||||
return {
|
||||
"^/ember-cli-live-reload.js": {
|
||||
target: "http://localhost:4200",
|
||||
changeOrigin: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that injects proxy configurations for:
|
||||
* 1. Ghost Admin API - proxies /ghost/api requests to the Ghost backend
|
||||
* 2. Ember Live Reload - proxies ember-cli-live-reload.js to Ember dev server
|
||||
*/
|
||||
export function ghostBackendProxyPlugin(): Plugin {
|
||||
let siteUrl!: { url: string; host: string };
|
||||
|
||||
return {
|
||||
name: "ghost-backend-proxy",
|
||||
|
||||
async configResolved(config) {
|
||||
// Only resolve backend URL for dev/preview, not for builds or tests
|
||||
if (config.command !== 'serve' || config.mode === 'test') return;
|
||||
|
||||
try {
|
||||
// We expect this to succeed immediately, but if the backend
|
||||
// server is getting started, it might need some time.
|
||||
// In that case, this lets the user know in case we're barking
|
||||
// up the wrong tree (aka the GHOST_URL is wrong.)
|
||||
const timeout = setTimeout(() => {
|
||||
config.logger.info(`Trying to reach Ghost Admin API at ${GHOST_URL}...`);
|
||||
}, 1000);
|
||||
|
||||
siteUrl = await resolveGhostSiteUrl();
|
||||
clearTimeout(timeout);
|
||||
|
||||
config.logger.info(`👻 Using backend url: ${siteUrl.url}`);
|
||||
} catch (error) {
|
||||
config.logger
|
||||
.error(`Could not reach Ghost Admin API at: ${GHOST_URL}
|
||||
|
||||
Ensure the Ghost backend is running. If needed, set the GHOST_URL environment variable to the correct URL.
|
||||
`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
configureServer(server) {
|
||||
if (!siteUrl) return;
|
||||
|
||||
server.config.server.proxy = {
|
||||
...server.config.server.proxy,
|
||||
...createAdminApiProxy(siteUrl),
|
||||
...createEmberLiveReloadProxy(),
|
||||
};
|
||||
},
|
||||
|
||||
configurePreviewServer(server) {
|
||||
if (!siteUrl) return;
|
||||
|
||||
server.config.preview.proxy = {
|
||||
...server.config.preview.proxy,
|
||||
...createAdminApiProxy(siteUrl),
|
||||
};
|
||||
},
|
||||
} as const satisfies Plugin;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Plugin, ViteDevServer, PreviewServer } from "vite";
|
||||
|
||||
/**
|
||||
* Vite plugin that redirects admin deep-link URLs to hash-based URLs.
|
||||
*
|
||||
* Mirrors ghost/core/core/server/web/admin/middleware/redirect-admin-urls.js
|
||||
* so that direct navigation to paths like /ghost/posts/123 redirects to /ghost/#/posts/123
|
||||
*
|
||||
* By registering as a post-middleware, static assets and API requests are handled first,
|
||||
* and only unhandled requests trigger the redirect.
|
||||
*/
|
||||
export function deepLinksPlugin(): Plugin {
|
||||
function addRedirectMiddleware(server: ViteDevServer | PreviewServer) {
|
||||
const base = (server.config.base ?? "/ghost").replace(/\/$/, "");
|
||||
const pathRegex = new RegExp(`^${base}/(.+)`);
|
||||
|
||||
return () => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const match = req.originalUrl?.match(pathRegex);
|
||||
|
||||
if (match) {
|
||||
res.writeHead(302, { Location: `${base}/#/${match[1]}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "deep-links",
|
||||
configureServer: addRedirectMiddleware,
|
||||
configurePreviewServer: addRedirectMiddleware,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {PluginOption, HtmlTagDescriptor, ResolvedConfig} from 'vite';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import sirv from 'sirv';
|
||||
|
||||
const GHOST_ADMIN_PATH = path.resolve(__dirname, '../../ghost/core/core/built/admin');
|
||||
const GHOST_ADMIN_DIST = path.resolve(__dirname, '../../ghost/admin/dist');
|
||||
|
||||
function isAbsoluteUrl(url: string): boolean {
|
||||
return url.startsWith('http://') ||
|
||||
url.startsWith('https://') ||
|
||||
url.startsWith('/');
|
||||
}
|
||||
|
||||
function prefixUrl(url: string, base: string): string {
|
||||
if (isAbsoluteUrl(url)) return url;
|
||||
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}/${url}`;
|
||||
}
|
||||
|
||||
// Vite plugin to extract styles and scripts from Ghost admin index.html
|
||||
export function emberAssetsPlugin() {
|
||||
let config: ResolvedConfig;
|
||||
|
||||
return {
|
||||
name: 'ember-assets',
|
||||
configResolved(resolvedConfig) {
|
||||
config = resolvedConfig;
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: 'post',
|
||||
handler() {
|
||||
// Read from Ember's own build output (not the combined output
|
||||
// in built/admin which gets overwritten by closeBundle and would
|
||||
// accumulate duplicate path prefixes on repeated builds)
|
||||
const indexPath = path.resolve(GHOST_ADMIN_DIST, 'index.html');
|
||||
try {
|
||||
const indexContent = fs.readFileSync(indexPath, 'utf-8');
|
||||
const base = config.base || '/';
|
||||
|
||||
// Extract stylesheets
|
||||
const styleRegex = /<link[^>]*rel="stylesheet"[^>]*href="([^"]*)"[^>]*>/g;
|
||||
const styles: HtmlTagDescriptor[] = [];
|
||||
let styleMatch;
|
||||
while ((styleMatch = styleRegex.exec(indexContent)) !== null) {
|
||||
styles.push({
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'stylesheet',
|
||||
href: prefixUrl(styleMatch[1], base)
|
||||
}
|
||||
});
|
||||
}
|
||||
// Extract scripts
|
||||
const scriptRegex = /<script[^>]*src="([^"]*)"[^>]*><\/script>/g;
|
||||
const scripts: HtmlTagDescriptor[] = [];
|
||||
let scriptMatch;
|
||||
while ((scriptMatch = scriptRegex.exec(indexContent)) !== null) {
|
||||
scripts.push({
|
||||
tag: 'script',
|
||||
injectTo: 'body',
|
||||
attrs: {
|
||||
src: prefixUrl(scriptMatch[1], base)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract meta tags
|
||||
const metaRegex = /<meta name="ghost-admin\/config\/environment" content="([^"]*)"[^>]*>/g;
|
||||
const metaTags: HtmlTagDescriptor[] = [];
|
||||
let metaMatch;
|
||||
while ((metaMatch = metaRegex.exec(indexContent)) !== null) {
|
||||
metaTags.push({
|
||||
tag: 'meta',
|
||||
attrs: {
|
||||
name: 'ghost-admin/config/environment',
|
||||
content: metaMatch[1]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate the virtual module content
|
||||
return [...styles, ...scripts, ...metaTags];
|
||||
} catch (error) {
|
||||
console.warn('Failed to read Ghost admin index.html:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
// Serve Ember assets from the filesystem in development
|
||||
const assetsMiddleware = sirv(path.resolve(GHOST_ADMIN_PATH, 'assets'), {
|
||||
dev: true,
|
||||
etag: true
|
||||
});
|
||||
|
||||
const base = (server.config.base ?? '/ghost').replace(/\/$/, '');
|
||||
const assetsPrefix = `${base}/assets/`;
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.startsWith(assetsPrefix)) {
|
||||
const originalUrl = req.url;
|
||||
req.url = req.url.replace(assetsPrefix, '/');
|
||||
assetsMiddleware(req, res, () => {
|
||||
req.url = originalUrl;
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
closeBundle() {
|
||||
// Only copy assets during production builds
|
||||
if (config.command === 'build') {
|
||||
try {
|
||||
// All legacy admin assets gets copied to the Ghost core
|
||||
// admin assets folder by the Ember build
|
||||
const ghostAssetsDir = path.resolve(GHOST_ADMIN_PATH, 'assets');
|
||||
|
||||
// React admin build output (apps/admin/dist/)
|
||||
const reactAssetsDir = path.resolve(config.build.outDir, 'assets');
|
||||
const reactIndexFile = path.resolve(config.build.outDir, 'index.html');
|
||||
|
||||
// Copy Ember assets to React build output to enable use of
|
||||
// vite preview. This also prevents stale Ember assets from
|
||||
// overwriting fresh ones in the next step.
|
||||
fs.cpSync(ghostAssetsDir, reactAssetsDir, { recursive: true });
|
||||
|
||||
// Copy combined assets back to Ghost core admin assets folder
|
||||
fs.cpSync(reactAssetsDir, ghostAssetsDir, {
|
||||
recursive: true,
|
||||
force: true
|
||||
});
|
||||
|
||||
// Copy React index.html, overwriting the existing index.html
|
||||
const forwardIndexFile = path.resolve(GHOST_ADMIN_PATH, 'index.html');
|
||||
fs.copyFileSync(reactIndexFile, forwardIndexFile);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to copy admin assets: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const satisfies PluginOption;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { resolve } from "path";
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import type { PluginOption } from "vite";
|
||||
const require = createRequire(import.meta.url);
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
import { emberAssetsPlugin } from "./vite-ember-assets";
|
||||
import { ghostBackendProxyPlugin } from "./vite-backend-proxy";
|
||||
import { deepLinksPlugin } from "./vite-deep-links";
|
||||
|
||||
export const GHOST_URL = process.env.GHOST_URL ?? "http://localhost:2368/";
|
||||
const GHOST_CARDS_PATH = resolve(__dirname, "../../ghost/core/core/frontend/src/cards");
|
||||
|
||||
/**
|
||||
* Extracts the subdirectory path from GHOST_URL.
|
||||
* e.g., "http://localhost:2368/blog/" -> "/blog"
|
||||
* "http://localhost:2368/" -> ""
|
||||
*/
|
||||
export function getSubdir(): string {
|
||||
const url = new URL(GHOST_URL);
|
||||
return url.pathname.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the Vite base path.
|
||||
* - If GHOST_CDN_URL is set, use it (for CDN deployments)
|
||||
* - Otherwise, use the subdir + /ghost (e.g., "/ghost" or "/blog/ghost")
|
||||
* - For builds without CDN, use "./" for relative paths in index-forward.html
|
||||
*/
|
||||
function getBase(command: 'build' | 'serve'): string {
|
||||
if (process.env.GHOST_CDN_URL) {
|
||||
return process.env.GHOST_CDN_URL;
|
||||
}
|
||||
// During build, use relative paths so index-forward.html works when served from any subdir
|
||||
if (command === 'build') {
|
||||
return './';
|
||||
}
|
||||
// During dev, use absolute path based on GHOST_URL subdir
|
||||
return `${getSubdir()}/ghost`;
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: getBase(command),
|
||||
plugins: [tailwindcss() as PluginOption, react(), emberAssetsPlugin(), ghostBackendProxyPlugin(), deepLinksPlugin(), tsconfigPaths()],
|
||||
define: {
|
||||
"process.env.DEBUG": false, // Shim env var utilized by the @tryghost/nql package
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ghost-cards": GHOST_CARDS_PATH,
|
||||
// TODO: Remove this when @tryghost/nql is updated
|
||||
mingo: require.resolve("mingo/dist/mingo.js"),
|
||||
},
|
||||
// Shim node modules utilized by the @tryghost/nql package
|
||||
external: ["fs", "path", "util"],
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./test-utils/setup.ts"],
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user