first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
+24
View File
@@ -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?
+27
View File
@@ -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/`.
+117
View File
@@ -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 },
],
},
},
])
+36
View File
@@ -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>
+105
View File
@@ -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"
}
]
}
}
}
}
+29
View File
@@ -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;
+178
View File
@@ -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;
}
View File
+52
View File
@@ -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>
);
+79
View File
@@ -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', '/');
});
});
+21
View File
@@ -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 />;
}
+18
View File
@@ -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;
+11
View File
@@ -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>
);
}
+139
View File
@@ -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,
},
],
},
];
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
declare module '@tryghost/limit-service'
declare module '@tryghost/nql'
declare module '@tryghost/koenig-lexical'
+8
View File
@@ -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();
+13
View File
@@ -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);
}
);
}
+41
View File
@@ -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" }
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -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",]
}
+141
View File
@@ -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;
}
+37
View File
@@ -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,
};
}
+145
View File
@@ -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;
}
+72
View File
@@ -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"],
},
}));