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
+1
View File
@@ -0,0 +1 @@
tailwind.config.cjs
+73
View File
@@ -0,0 +1,73 @@
/* eslint-env node */
const tailwindCssConfig = `${__dirname}/../admin/src/index.css`;
module.exports = {
root: true,
extends: [
'plugin:ghost/ts',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
plugins: [
'ghost',
'react-refresh',
'tailwindcss'
],
settings: {
react: {
version: 'detect'
},
tailwindcss: {
config: tailwindCssConfig
}
},
rules: {
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
// sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
}],
'no-restricted-imports': ['error', {
paths: [{
name: '@tryghost/shade',
message: 'Import from layered subpaths instead (components/primitives/patterns/utils/app/tokens).'
}]
}],
// TODO: re-enable this (maybe fixed fast refresh?)
'react-refresh/only-export-components': 'off',
// suppress errors for missing 'import React' in JSX files, as we don't need it
'react/react-in-jsx-scope': 'off',
// ignore prop-types for now
'react/prop-types': 'off',
// TODO: re-enable these if deemed useful
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-empty-function': 'off',
// custom react rules
'react/jsx-sort-props': ['error', {
reservedFirst: true,
callbacksLast: true,
shorthandLast: true,
locale: 'en'
}],
'react/button-has-type': 'error',
'react/no-array-index-key': 'error',
'react/jsx-key': 'off',
'tailwindcss/classnames-order': 'error',
'tailwindcss/enforces-negative-arbitrary-values': 'warn',
'tailwindcss/enforces-shorthand': 'warn',
'tailwindcss/migration-from-tailwind-2': 'warn',
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': 'error'
}
};
+4
View File
@@ -0,0 +1,4 @@
dist
types
playwright-report
test-results
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AdminX Standalone</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/standalone.tsx"></script>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{
"name": "@tryghost/activitypub",
"version": "3.1.13",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TryGhost/Ghost/tree/main/apps/activitypub"
},
"author": "Ghost Foundation",
"files": [
"LICENSE",
"README.md",
"dist/"
],
"main": "./dist/activitypub.umd.cjs",
"module": "./dist/activitypub.js",
"exports": {
".": {
"import": "./dist/activitypub.js",
"require": "./dist/activitypub.umd.cjs"
},
"./api": "./src/index.tsx"
},
"private": false,
"scripts": {
"dev": "vite build --watch",
"dev:start": "vite",
"build": "tsc && vite build",
"lint": "pnpm run lint:code && pnpm run lint:test",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test",
"test": "pnpm test:unit",
"test:unit": "tsc --noEmit && vitest run",
"test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
"test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 pnpm test:acceptance --headed",
"test:acceptance:full": "ALL_BROWSERS=1 pnpm test:acceptance",
"preview": "vite preview"
},
"devDependencies": {
"@playwright/test": "1.59.1",
"@testing-library/react": "14.3.1",
"@types/dompurify": "3.2.0",
"@types/jest": "29.5.14",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"jest": "29.7.0",
"tailwindcss": "^4.2.2",
"ts-jest": "29.4.9",
"vite": "5.4.21",
"vitest": "1.6.1"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
"^build"
]
},
"dev": {
"dependsOn": [
"^build"
]
},
"test:unit": {
"dependsOn": [
"^build",
"test:unit"
]
},
"test:acceptance": {
"dependsOn": [
"^build",
"test:acceptance"
]
}
}
},
"dependencies": {
"@hookform/resolvers": "5.2.2",
"@radix-ui/react-form": "0.1.8",
"@tanstack/react-query": "4.36.1",
"@tryghost/admin-x-framework": "workspace:*",
"@tryghost/shade": "workspace:*",
"clsx": "2.1.1",
"dompurify": "3.3.1",
"html2canvas-objectfit-fix": "1.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.72.1",
"react-router": "7.14.0",
"sonner": "2.0.7",
"use-debounce": "10.1.1",
"zod": "4.1.12"
}
}
+3
View File
@@ -0,0 +1,3 @@
import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
export default adminXPlaywrightConfig();
+29
View File
@@ -0,0 +1,29 @@
import {FeatureFlagsProvider} from './lib/feature-flags';
import {FrameworkProvider, Outlet, RouterProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {ShadeApp} from '@tryghost/shade/app';
import {routes} from '@src/routes';
interface AppProps {
framework: TopLevelFrameworkProps;
activityPubEnabled?: boolean;
}
const App: React.FC<AppProps> = ({framework, activityPubEnabled}) => {
if (activityPubEnabled === false) {
return null;
}
return (
<FrameworkProvider {...framework}>
<RouterProvider prefix={'/'} routes={routes}>
<FeatureFlagsProvider>
<ShadeApp className="shade-activitypub" darkMode={false} fetchKoenigLexical={null}>
<Outlet />
</ShadeApp>
</FeatureFlagsProvider>
</RouterProvider>
</FrameworkProvider>
);
};
export default App;
+7
View File
@@ -0,0 +1,7 @@
import './styles/index.css';
export {default as AdminXApp} from './app';
export {routes} from './routes';
export {FeatureFlagsProvider} from './lib/feature-flags';
export {useNotificationsCountForUser} from './hooks/use-activity-pub-queries';
+142
View File
@@ -0,0 +1,142 @@
import AppError from '@components/layout/error';
import {Navigate, Outlet, RouteObject, lazyComponent} from '@tryghost/admin-x-framework';
const basePath = import.meta.env.VITE_TEST ? '' : 'activitypub';
export type CustomRouteObject = RouteObject & {
pageTitle?: string;
children?: CustomRouteObject[];
showBackButton?: boolean;
};
export const routes: CustomRouteObject[] = [
{
// Root route that defines the app's base path
path: basePath,
element: <Outlet />,
errorElement: <AppError />, // This will catch all errors in child routes
handle: 'activitypub-basepath',
children: [
{
index: true,
element: <Navigate to="reader" />
},
{
path: 'inbox',
element: <Navigate to="../reader" replace />
},
{
path: 'feed',
element: <Navigate to="../notes" replace />
},
{
path: 'reader',
lazy: lazyComponent(() => import('./views/inbox')),
pageTitle: 'Reader'
},
{
path: 'reader/:postId',
lazy: lazyComponent(() => import('./views/inbox')),
pageTitle: 'Reader'
},
{
path: 'notes',
lazy: lazyComponent(() => import('./views/feed/feed')),
pageTitle: 'Notes'
},
{
path: 'notes/:postId',
lazy: lazyComponent(() => import('./views/feed/note')),
pageTitle: 'Note'
},
{
path: 'notifications',
lazy: lazyComponent(() => import('./views/notifications')),
pageTitle: 'Notifications'
},
{
path: 'explore',
lazy: lazyComponent(() => import('./views/explore')),
pageTitle: 'Explore'
},
{
path: 'explore/:topic',
lazy: lazyComponent(() => import('./views/explore')),
pageTitle: 'Explore'
},
{
path: 'profile',
lazy: lazyComponent(() => import('./views/profile')),
pageTitle: 'Profile'
},
{
path: 'profile/likes',
lazy: lazyComponent(() => import('./views/profile')),
pageTitle: 'Profile'
},
{
path: 'profile/following',
lazy: lazyComponent(() => import('./views/profile')),
pageTitle: 'Profile'
},
{
path: 'profile/followers',
lazy: lazyComponent(() => import('./views/profile')),
pageTitle: 'Profile'
},
{
path: 'profile/:handle/:tab?',
lazy: lazyComponent(() => import('./views/profile')),
pageTitle: 'Profile'
},
{
path: 'preferences',
lazy: lazyComponent(() => import('./views/preferences')),
pageTitle: 'Preferences'
},
{
path: 'preferences/moderation',
lazy: lazyComponent(() => import('./views/preferences/components/moderation')),
pageTitle: 'Moderation',
showBackButton: true
},
{
path: 'preferences/bluesky-sharing',
lazy: lazyComponent(() => import('./views/preferences/components/bluesky-sharing')),
showBackButton: true
},
{
path: 'welcome',
lazy: lazyComponent(() => import('./components/layout/onboarding')),
pageTitle: 'Welcome',
children: [
{
path: '',
element: <Navigate to="1" replace />
},
{
path: '1',
lazy: lazyComponent(() => import('./components/layout/onboarding/step-1'))
},
{
path: '2',
lazy: lazyComponent(() => import('./components/layout/onboarding/step-2'))
},
{
path: '3',
lazy: lazyComponent(() => import('./components/layout/onboarding/step-3'))
},
{
path: '*',
element: <Navigate to="1" replace />
}
]
},
{
path: '*',
lazy: lazyComponent(() => import('./components/layout/error'))
}
]
}
];
+5
View File
@@ -0,0 +1,5 @@
import './styles/index.css';
import App from './app.tsx';
import renderStandaloneApp from '@tryghost/admin-x-framework/test/render';
renderStandaloneApp(App, {});
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts-test'
],
rules: {
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
}
};
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./types",
"emitDeclarationOnly": true,
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"]
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client", "jest"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": "./src",
"paths": {
"@src/*": ["*"],
"@assets/*": ["assets/*"],
"@components/*": ["components/*"],
"@hooks/*": ["hooks/*"],
"@utils/*": ["utils/*"],
"@views/*": ["views/*"]
}
},
"include": ["src", "test"]
}
+72
View File
@@ -0,0 +1,72 @@
import adminXViteConfig from '@tryghost/admin-x-framework/vite';
import pkg from './package.json';
import {resolve} from 'path';
import fs from 'fs';
const GHOST_CARDS_PATH = resolve(__dirname, '../../ghost/core/core/frontend/src/cards');
const validateCardsDirectoryPlugin = (cardsPath) => {
return {
name: 'validate-cards-directory',
buildStart() {
const jsPath = resolve(cardsPath, 'js');
const cssPath = resolve(cardsPath, 'css');
if (!fs.existsSync(cardsPath)) {
throw new Error(`Ghost cards directory not found at: ${cardsPath}`);
}
if (!fs.existsSync(jsPath)) {
throw new Error(`Ghost cards JS directory not found at: ${jsPath}`);
}
if (!fs.existsSync(cssPath)) {
throw new Error(`Ghost cards CSS directory not found at: ${cssPath}`);
}
const jsFiles = fs.readdirSync(jsPath).filter(f => f.endsWith('.js'));
const cssFiles = fs.readdirSync(cssPath).filter(f => f.endsWith('.css'));
if (jsFiles.length === 0) {
throw new Error(`No JavaScript files found in Ghost cards directory: ${jsPath}`);
}
if (cssFiles.length === 0) {
throw new Error(`No CSS files found in Ghost cards directory: ${cssPath}`);
}
console.log(`✓ Found ${jsFiles.length} JS and ${cssFiles.length} CSS card files at: ${cardsPath}`);
}
};
};
export default (function viteConfig() {
const config = adminXViteConfig({
packageName: pkg.name,
entry: resolve(__dirname, 'src/index.tsx'),
overrides: {
test: {
include: [
'./test/unit/**/*',
'./src/**/*.test.ts'
]
},
resolve: {
alias: {
'@src': resolve(__dirname, './src'),
'@assets': resolve(__dirname, './src/assets'),
'@components': resolve(__dirname, './src/components'),
'@hooks': resolve(__dirname, './src/hooks'),
'@utils': resolve(__dirname, './src/utils'),
'@views': resolve(__dirname, './src/views'),
'@ghost-cards': GHOST_CARDS_PATH
}
},
plugins: [
validateCardsDirectoryPlugin(GHOST_CARDS_PATH)
]
}
});
return config;
});
+49
View File
@@ -0,0 +1,49 @@
const tailwindCssConfig = `${__dirname}/../admin/src/index.css`;
module.exports = {
extends: [
'plugin:ghost/ts',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
plugins: [
'ghost',
'react-refresh',
'tailwindcss'
],
settings: {
react: {
version: 'detect'
},
tailwindcss: {
config: tailwindCssConfig
}
},
rules: {
// suppress errors for missing 'import React' in JSX files, as we don't need it
'react/react-in-jsx-scope': 'off',
// ignore prop-types for now
'react/prop-types': 'off',
'react/jsx-sort-props': ['error', {
reservedFirst: true,
callbacksLast: true,
shorthandLast: true,
locale: 'en'
}],
'react/button-has-type': 'error',
'react/no-array-index-key': 'error',
'react/jsx-key': 'off',
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
'tailwindcss/classnames-order': 'error',
'tailwindcss/enforces-negative-arbitrary-values': 'warn',
'tailwindcss/enforces-shorthand': 'warn',
'tailwindcss/migration-from-tailwind-2': 'warn',
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': 'error'
}
};
+2
View File
@@ -0,0 +1,2 @@
es
types
Binary file not shown.
@@ -0,0 +1,38 @@
import {create} from '@storybook/theming/create';
export default create({
base: 'light',
// Typography
fontBase: '"Inter", sans-serif',
fontCode: 'monospace',
brandTitle: 'AdminX Design System',
brandUrl: 'https://ghost.org',
brandImage: 'https://github.com/peterzimon/playground/assets/353959/c4358b4e-232f-4dba-8abb-adb3142ccd89',
brandTarget: '_self',
//
colorPrimary: '#30CF43',
colorSecondary: '#15171A',
// UI
appBg: '#ffffff',
appContentBg: '#ffffff',
appBorderColor: '#EBEEF0',
appBorderRadius: 0,
// Text colors
textColor: '#15171A',
textInverseColor: '#ffffff',
// Toolbar default and active colors
barTextColor: '#9E9E9E',
barSelectedColor: '#15171A',
barBg: '#ffffff',
// Form colors
inputBg: '#ffffff',
inputBorder: '#15171A',
inputTextColor: '#15171A',
inputBorderRadius: 2,
});
@@ -0,0 +1,27 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
{
name: '@storybook/addon-styling',
},
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
async viteFinal(config, options) {
config.resolve!.alias = {
crypto: require.resolve('rollup-plugin-node-builtins')
}
return config;
}
};
export default config;
@@ -0,0 +1,6 @@
import {addons} from '@storybook/manager-api';
import adminxTheme from './adminx-theme';
addons.setConfig({
theme: adminxTheme
});
@@ -0,0 +1,107 @@
import React from 'react';
import '../styles.css';
import './storybook.css';
import type { Preview } from "@storybook/react";
import DesignSystemProvider from '../src/providers/design-system-provider';
import adminxTheme from './adminx-theme';
// import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport';
const customViewports = {
sm: {
name: 'sm',
styles: {
width: '480px',
height: '801px',
},
},
md: {
name: 'md',
styles: {
width: '640px',
height: '801px',
},
},
lg: {
name: 'lg',
styles: {
width: '1024px',
height: '801px',
},
},
xl: {
name: 'xl',
styles: {
width: '1320px',
height: '801px',
},
},
tablet: {
name: 'tablet',
styles: {
width: '860px',
height: '801px',
},
},
};
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
options: {
storySort: {
method: 'alphabetical',
order: ['Welcome', 'Foundations', ['Style Guide', 'Colors', 'Icons', 'ErrorHandling'], 'Global', ['Form', 'Chrome', 'Modal', 'Layout', ['View Container', 'Page Header', 'Page'], 'List', 'Table', '*'], 'Settings', ['Setting Section', 'Setting Group', '*'], 'Experimental'],
},
},
docs: {
theme: adminxTheme,
},
viewport: {
viewports: {
...customViewports,
},
},
},
decorators: [
(Story, context) => {
let {scheme} = context.globals;
return (
<div className={`admin-x-design-system admin-x-base ${scheme === 'dark' ? 'dark' : ''}`} style={{
// padding: '24px',
// width: 'unset',
height: 'unset',
// overflow: 'unset',
background: (scheme === 'dark' ? '#131416' : '')
}}>
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
<DesignSystemProvider fetchKoenigLexical={async () => {}}>
<Story />
</DesignSystemProvider>
</div>);
},
],
globalTypes: {
scheme: {
name: "Scheme",
description: "Select light or dark mode",
defaultValue: "light",
toolbar: {
icon: "mirror",
items: ["light", "dark"],
dynamicTitle: true
}
}
}
};
export default preview;
@@ -0,0 +1,247 @@
/*
* We load Inter in Ember admin, so loading it explicitly here makes the final rendering
* in Storybook match the final rendering when embedded in Ember
*/
@font-face {
font-family: "Inter";
src: url("./Inter.ttf") format("truetype-variations");
font-weight: 100 900;
}
:root {
font-size: 62.5%;
line-height: 1.5;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
html, body, #root {
width: 100%;
height: 100%;
margin: 0;
letter-spacing: unset;
}
.sbdocs-wrapper {
padding: 3vmin !important;
}
.sbdocs-wrapper .sbdocs-content {
max-width: 1320px;
}
.sb-doc {
max-width: 740px;
width: 100%;
margin: 0 auto !important;
}
.sb-doc,
.sb-doc a,
.sb-doc h1,
.sb-doc h2,
.sb-doc h3,
.sb-doc h4,
.sb-doc h5,
.sb-doc h6,
.sb-doc p,
.sb-doc ul li,
.sbdocs-title,
.sb-doc ol li {
font-family: Inter, sans-serif !important;
padding: 0 !important;
}
.sb-doc a {
color: #30CF43;
}
.sb-doc h1 {
font-size: 48px !important;
letter-spacing: -0.04em !important;
margin-bottom: 20px;
}
.sb-doc h2 {
margin-top: 40px !important;
font-size: 27px;
border: none;
margin-bottom: 2px;
}
.sb-doc h3 {
margin-top: 40px !important;
margin-bottom: 4px !important;
font-size: 20px;
}
.sb-doc h4 {
margin: 0 0 4px !important;
}
.sb-doc p,
.sb-doc div,
.sb-doc ul li,
.sb-doc ol li {
font-size: 15px;
line-height: 1.5em;
}
.sb-doc ul li,
.sb-doc ol li {
margin-bottom: 8px;
}
.sb-doc h2 + p,
.sb-doc h3 + p {
margin-top: 8px;
}
.sb-doc img,
.sb-wide img {
margin-top: 40px !important;
margin-bottom: 40px !important;
}
.sb-doc img.small {
max-width: 520px;
margin: 0 auto;
display: block;
}
.sb-doc p.excerpt {
font-size: 19px;
letter-spacing: -0.02em;
}
.sb-doc .highlight {
padding: 12px 20px;
border-radius: 4px;
background: #EBEEF0;
}
.sb-doc .highlight.purple {
background: #F0E9FA;
}
.sb-doc .highlight.purple a {
color: #8E42FF;
}
/* Welcome */
.sb-doc img.main-image {
margin-top: -2vmin !important;
margin-left: -44px;
margin-right: -32px;
margin-bottom: 0 !important;
max-width: unset;
width: calc(100% + 64px);
}
.sb-doc .main-structure-container {
display: flex;
gap: 32px;
margin: 32px 0 80px;
}
.sb-doc .main-structure-container div {
flex-basis: 33%;
}
.sb-doc .main-structure-container div p {
display: flex;
flex-direction: column;
gap: 4px;
}
.sb-doc .main-structure-container img {
margin: 12px 0 !important;
width: 32px;
height: 32px;
}
.sb-doc .main-structure-container div h4 {
border-bottom: 1px solid #EBEEF0;
padding-bottom: 8px !important;
margin-bottom: 8px !important;
}
.sb-doc .main-structure-container div p {
margin: 0;
font-size: 13.5px;
}
/* Colors */
.color-grid {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-top: 20px;
}
.color-grid div {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 4px;
border: 1px solid #EBEEF0;
}
.color-grid .swatch {
display: block;
background: #EFEFEF;
border-radius: 100%;
width: 28px;
height: 28px;
}
.swatch.green {
background: #30CF43;
}
.swatch.black {
background: #15171A;
}
.swatch.white {
background: #FFFFFF;
border: 1px solid #EBEEF0;
}
.swatch.lime {
background: #B5FF18;
}
.swatch.blue {
background: #14B8FF;
}
.swatch.purple {
background: #8E42FF;
}
.swatch.pink {
background: #FB2D8D;
}
.swatch.yellow {
background: #FFB41F;
}
.swatch.red {
background: #F50B23;
}
/* Icons */
.sb-doc .streamline {
display: grid;
grid-template-columns: auto 240px;
gap: 32px;
}
.sbdocs-a {
color: #30CF43 !important;
}
+17
View File
@@ -0,0 +1,17 @@
# Admin X Design
Components, design guidelines and documentation for building apps in Ghost Admin
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `pnpm` to install top-level dependencies.
## Test
- `pnpm lint` run just eslint
- `pnpm test` run lint and tests
+112
View File
@@ -0,0 +1,112 @@
{
"name": "@tryghost/admin-x-design-system",
"type": "module",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/admin-x-design-system",
"author": "Ghost Foundation",
"private": true,
"main": "es/index.js",
"types": "types/index.d.ts",
"sideEffects": false,
"scripts": {
"dev": "vite build --watch",
"build": "tsc -p tsconfig.declaration.json && vite build",
"test": "pnpm test:unit",
"test:unit": "pnpm test:types && vitest run",
"test:types": "tsc --noEmit",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
"lint": "pnpm lint:code && pnpm lint:test",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx test/ --cache",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"files": [
"es",
"types"
],
"devDependencies": {
"@codemirror/lang-html": "6.4.11",
"@codemirror/state": "6.6.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-tooltip": "1.2.8",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/addon-styling": "1.3.7",
"@storybook/blocks": "8.6.14",
"@storybook/preview-api": "^8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-vite": "8.6.14",
"@storybook/testing-library": "0.2.2",
"@tailwindcss/postcss": "4.2.1",
"@testing-library/react": "14.3.1",
"@testing-library/react-hooks": "8.0.1",
"@types/lodash-es": "4.17.12",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@types/validator": "13.15.10",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.4.21",
"c8": "10.1.3",
"chai": "4.5.0",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.24",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"glob": "^10.5.0",
"jsdom": "28.1.0",
"lodash-es": "4.18.1",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"rollup-plugin-node-builtins": "2.1.2",
"sinon": "18.0.1",
"storybook": "8.6.15",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"validator": "13.12.0",
"vite": "5.4.21",
"vite-plugin-svgr": "3.3.0",
"vitest": "1.6.1"
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "7.0.2",
"@ebay/nice-modal-react": "1.2.13",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-form": "0.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@sentry/react": "7.120.4",
"@tryghost/shade": "workspace:*",
"@uiw/react-codemirror": "4.25.2",
"clsx": "2.1.1",
"react-colorful": "5.6.1",
"react-hot-toast": "2.6.0",
"react-select": "5.10.2"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
"^build"
]
},
"test:unit": {
"dependsOn": [
"^build"
]
}
}
}
}
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
'postcss-import': {},
'@tailwindcss/postcss': {},
autoprefixer: {}
}
};
+381
View File
@@ -0,0 +1,381 @@
.admin-x-base {
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box; /* 1 */
max-width: revert;
max-height: revert;
min-width: revert;
min-height: revert;
border-width: 0; /* 2 */
border-style: solid; /* 2 */
border-color: theme('borderColor.DEFAULT', currentColor); /* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
*/
html {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-tab-size: 4; /* 3 */
tab-size: 4; /* 3 */
font-family: theme('fontFamily.sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); /* 4 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0; /* 1 */
line-height: inherit; /* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
padding: 0;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: theme('fontFamily.mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); /* 1 */
font-size: 1em; /* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
margin: 0;
width: auto;
max-width: auto;
}
table td, table th {
padding: unset;
vertical-align: middle;
text-align: left;
line-height: auto;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
color: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 3 */
outline: none;
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
letter-spacing: inherit;
border-radius: inherit;
appearance: auto;
-webkit-appearance: auto;
background: unset;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
/* [type='button'], */
[type='reset'],
[type='submit'] {
-webkit-appearance: button; /* 1 */
background-color: transparent; /* 2 */
background-image: none; /* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: none;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
li {
margin: unset;
line-height: unset;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::placeholder,
textarea::placeholder {
opacity: 1; /* 1 */
@apply text-grey-500; /* 2 */
}
button:focus-visible,
input:focus-visible {
outline: none;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
}
@@ -0,0 +1,18 @@
import type {Meta, StoryObj} from '@storybook/react';
import BoilerPlate from './boilerplate';
const meta = {
title: 'Meta / Boilerplate',
component: BoilerPlate,
tags: ['autodocs']
} satisfies Meta<typeof BoilerPlate>;
export default meta;
type Story = StoryObj<typeof BoilerPlate>;
export const Default: Story = {
args: {
children: 'This is a boilerplate component. Use as a basis to create new components.'
}
};
@@ -0,0 +1,15 @@
import React from 'react';
interface BoilerPlateProps {
children?: React.ReactNode;
}
const BoilerPlate: React.FC<BoilerPlateProps> = ({children}) => {
return (
<>
{children}
</>
);
};
export default BoilerPlate;
@@ -0,0 +1,26 @@
import clsx from 'clsx';
import React from 'react';
import {FetchKoenigLexical} from './global/form/html-editor';
import DesignSystemProvider from './providers/design-system-provider';
export interface DesignSystemAppProps extends React.HTMLProps<HTMLDivElement> {
darkMode: boolean;
fetchKoenigLexical: FetchKoenigLexical;
}
const DesignSystemApp: React.FC<DesignSystemAppProps> = ({darkMode, fetchKoenigLexical, className, children, ...props}) => {
const appClassName = clsx(
'admin-x-base',
className
);
return (
<div className={appClassName} {...props}>
<DesignSystemProvider darkMode={darkMode} fetchKoenigLexical={fetchKoenigLexical}>
{children}
</DesignSystemProvider>
</div>
);
};
export default DesignSystemApp;
+177
View File
@@ -0,0 +1,177 @@
export {ReactComponent as FacebookLogo} from './assets/images/facebook-logo.svg';
export {ReactComponent as GhostLogo} from './assets/images/ghost-logo.svg';
export {ReactComponent as GhostOrb} from './assets/images/ghost-orb.svg';
export {ReactComponent as GoogleLogo} from './assets/images/google-logo.svg';
export {ReactComponent as TwitterLogo} from './assets/images/twitter-logo.svg';
export {ReactComponent as XLogo} from './assets/images/x-logo.svg';
export {default as DesktopChrome} from './global/chrome/desktop-chrome';
export type {DesktopChromeProps} from './global/chrome/desktop-chrome';
export {default as DesktopChromeHeader} from './global/chrome/desktop-chrome-header';
export type {DesktopChromeHeaderProps} from './global/chrome/desktop-chrome-header';
export {default as MobileChrome} from './global/chrome/mobile-chrome';
export type {MobileChromeProps} from './global/chrome/mobile-chrome';
export {default as Checkbox} from './global/form/checkbox';
export type {CheckboxProps} from './global/form/checkbox';
export {default as CheckboxGroup} from './global/form/checkbox-group';
export type {CheckboxGroupProps} from './global/form/checkbox-group';
export {default as CodeEditor} from './global/form/code-editor';
export type {CodeEditorProps, FetchKoenigLexical} from './global/form/code-editor';
export {default as ColorIndicator} from './global/form/color-indicator';
export type {ColorIndicatorProps} from './global/form/color-indicator';
export {default as ColorPicker} from './global/form/color-picker';
export type {ColorPickerProps} from './global/form/color-picker';
export {default as ColorPickerField} from './global/form/color-picker-field';
export type {ColorPickerFieldProps} from './global/form/color-picker-field';
export {default as CurrencyField} from './global/form/currency-field';
export type {CurrencyFieldProps} from './global/form/currency-field';
export {default as FileUpload} from './global/form/file-upload';
export type {FileUploadProps} from './global/form/file-upload';
export {default as Form} from './global/form/form';
export type {FormProps} from './global/form/form';
export {default as HtmlEditor} from './global/form/html-editor';
export type {HtmlEditorProps} from './global/form/html-editor';
export {default as HtmlField} from './global/form/html-field';
export type {HtmlFieldProps} from './global/form/html-field';
export {default as KoenigEditorBase, loadKoenig} from './global/form/koenig-editor-base';
export type {KoenigEditorBaseProps, KoenigInstance, NodeType} from './global/form/koenig-editor-base';
export {default as ImageUpload} from './global/form/image-upload';
export type {ImageUploadProps} from './global/form/image-upload';
export {default as MultiSelect} from './global/form/multi-select';
export type {LoadMultiSelectOptions, MultiSelectOption, MultiSelectProps} from './global/form/multi-select';
export {default as Radio} from './global/form/radio';
export type {RadioProps} from './global/form/radio';
export {default as Select} from './global/form/select';
export type {LoadSelectOptions, SelectOption, SelectOptionGroup, SelectProps} from './global/form/select';
export {default as SelectWithOther} from './global/form/select-with-other';
export type {SelectWithOtherProps} from './global/form/select-with-other';
export {default as TextArea} from './global/form/text-area';
export type {TextAreaProps} from './global/form/text-area';
export {default as TextField} from './global/form/text-field';
export type {TextFieldProps} from './global/form/text-field';
export {default as Toggle} from './global/form/toggle';
export type {ToggleProps} from './global/form/toggle';
export {default as ToggleGroup} from './global/form/toggle-group';
export type {ToggleGroupProps} from './global/form/toggle-group';
export {default as URLTextField} from './global/form/url-text-field';
export type {URLTextFieldProps} from './global/form/url-text-field';
export {default as ConfirmationModal, ConfirmationModalContent} from './global/modal/confirmation-modal';
export type {ConfirmationModalProps} from './global/modal/confirmation-modal';
export {default as LimitModal, LimitModalContent} from './global/modal/limit-modal';
export type {LimitModalProps} from './global/modal/limit-modal';
export {default as Modal, topLevelBackdropClasses} from './global/modal/modal';
export type {ModalProps} from './global/modal/modal';
export {default as ModalPage} from './global/modal/modal-page';
export type {ModalPageProps} from './global/modal/modal-page';
export {default as PreviewModal, PreviewModalContent} from './global/modal/preview-modal';
export type {PreviewModalProps} from './global/modal/preview-modal';
export {default as Avatar} from './global/avatar';
export type {AvatarProps} from './global/avatar';
export {default as Banner} from './global/banner';
export type {BannerProps} from './global/banner';
export {default as Breadcrumbs} from './global/breadcrumbs';
export type {BreadcrumbItem, BreadcrumbsProps} from './global/breadcrumbs';
export {default as Button} from './global/button';
export type {ButtonColor, ButtonProps} from './global/button';
export {default as ButtonGroup} from './global/button-group';
export type {ButtonGroupProps} from './global/button-group';
export {default as ErrorBoundary, withErrorBoundary} from './global/error-boundary';
export type {ErrorBoundaryProps} from './global/error-boundary';
export {default as Heading} from './global/heading';
export type {HeadingProps} from './global/heading';
export {default as Hint} from './global/hint';
export type {HintProps} from './global/hint';
export {default as Icon} from './global/icon';
export type {IconProps} from './global/icon';
export {default as IconLabel} from './global/icon-label';
export type {IconLabelProps} from './global/icon-label';
export {default as InfiniteScrollListener} from './global/infinite-scroll-listener';
export type {InfiniteScrollListenerProps} from './global/infinite-scroll-listener';
export {default as Link} from './global/link';
export type {LinkProps} from './global/link';
export {default as List} from './global/list';
export type {ListProps} from './global/list';
export {default as ListHeading} from './global/list-heading';
export type {ListHeadingProps} from './global/list-heading';
export {default as ListItem} from './global/list-item';
export type {ListItemProps} from './global/list-item';
export {LoadingIndicator} from './global/loading-indicator';
export type {LoadingIndicatorProps} from './global/loading-indicator';
export {default as Menu} from './global/menu';
export type {MenuItem, MenuProps} from './global/menu';
export {default as NoValueLabel} from './global/no-value-label';
export type {NoValueLabelProps} from './global/no-value-label';
export {default as Pagination} from './global/pagination';
export type {PaginationProps} from './global/pagination';
export {default as Popover} from './global/popover';
export type {PopoverProps} from './global/popover';
export {default as Separator} from './global/separator';
export type {SeparatorProps} from './global/separator';
export {DragIndicator, default as SortableList} from './global/sortable-list';
export type {DragIndicatorProps, SortableItemContainerProps, SortableListProps} from './global/sortable-list';
export {default as SortMenu} from './global/sort-menu';
export type {SortMenuProps} from './global/sort-menu';
export {default as StickyFooter} from './global/sticky-footer';
export type {StickyFooterProps} from './global/sticky-footer';
export {default as TabView} from './global/tab-view';
export type {Tab, TabViewProps} from './global/tab-view';
export {default as Table} from './global/table';
export type {ShowMoreData, TableProps} from './global/table';
export {default as TableCell} from './global/table-cell';
export type {TableCellProps} from './global/table-cell';
export {default as TableHead} from './global/table-head';
export type {TableHeadProps} from './global/table-head';
export {default as TableRow} from './global/table-row';
export type {TableRowProps} from './global/table-row';
export {default as Toast, dismissAllToasts, showToast} from './global/toast';
export type {ToastProps} from './global/toast';
export {default as Tooltip} from './global/tooltip';
export type {TooltipProps} from './global/tooltip';
export {default as PageHeader} from './global/layout/page-header';
export type {PageHeaderProps} from './global/layout/page-header';
export {default as Page} from './global/layout/page';
export type {PageTab} from './global/layout/page';
export type {CustomGlobalAction} from './global/layout/page';
export {default as ViewContainer} from './global/layout/view-container';
export type {View} from './global/layout/view-container';
export type {ViewTab} from './global/layout/view-container';
export type {PrimaryActionProps} from './global/layout/view-container';
export {default as DynamicTable} from './global/table/dynamic-table';
export type {DynamicTableProps} from './global/table/dynamic-table';
export type {DynamicTableColumn} from './global/table/dynamic-table';
export type {DynamicTableRow} from './global/table/dynamic-table';
export {default as SettingGroup} from './settings/setting-group';
export type {SettingGroupProps} from './settings/setting-group';
export {default as SettingGroupContent} from './settings/setting-group-content';
export type {SettingGroupContentProps} from './settings/setting-group-content';
export {default as SettingGroupHeader} from './settings/setting-group-header';
export type {SettingGroupHeaderProps} from './settings/setting-group-header';
export {default as SettingNavItem} from './settings/setting-nav-item';
export type {SettingNavItemProps} from './settings/setting-nav-item';
export {default as SettingNavSection} from './settings/setting-nav-section';
export type {SettingNavSectionProps} from './settings/setting-nav-section';
export {default as SettingSection} from './settings/setting-section';
export type {SettingSectionProps} from './settings/setting-section';
export {default as SettingSectionHeader} from './settings/setting-section-header';
export type {SettingSectionHeaderProps} from './settings/setting-section-header';
export {default as SettingValue} from './settings/setting-value';
export type {SettingValueProps} from './settings/setting-value';
export {default as StripeButton} from './settings/stripe-button';
export type {StripeButtonProps} from './settings/stripe-button';
export {default as useGlobalDirtyState} from './hooks/use-global-dirty-state';
export {usePagination} from './hooks/use-pagination';
export type {PaginationData} from './hooks/use-pagination';
export {default as useSortableIndexedList} from './hooks/use-sortable-indexed-list';
export {debounce} from './utils/debounce';
export {confirmIfDirty} from './utils/modals';
export {formatUrl} from './utils/format-url';
export {default as DesignSystemApp} from './design-system-app';
export type {DesignSystemAppProps} from './design-system-app';
export {useFocusContext, useDesignSystem} from './providers/design-system-provider';
+7
View File
@@ -0,0 +1,7 @@
declare module '*.svg' {
// eslint-disable-next-line @typescript-eslint/no-require-imports
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}
+122
View File
@@ -0,0 +1,122 @@
@import './preflight.css';
@import 'tailwindcss/theme.css';
@import '@tryghost/shade/tailwind.theme.css';
@import url(https://fonts.bunny.net/css?family=cardo:400,700);
@import url(https://fonts.bunny.net/css?family=manrope:300,500,700);
@import url(https://fonts.bunny.net/css?family=merriweather:300,700);
@import url(https://fonts.bunny.net/css?family=nunito:400,600,700);
@import url(https://fonts.bunny.net/css?family=old-standard-tt:400,700);
@import url(https://fonts.bunny.net/css?family=prata:400);
@import url(https://fonts.bunny.net/css?family=roboto:400,500,700);
@import url(https://fonts.bunny.net/css?family=rufina:400,500,700);
@import url(https://fonts.bunny.net/css?family=tenor-sans:400);
@import url(https://fonts.bunny.net/css?family=space-grotesk:700);
@import url(https://fonts.bunny.net/css?family=chakra-petch:400);
@import url(https://fonts.bunny.net/css?family=noto-sans:400,700);
@import url(https://fonts.bunny.net/css?family=poppins:400,700);
@import url(https://fonts.bunny.net/css?family=fira-sans:400,700);
@import url(https://fonts.bunny.net/css?family=inter:400,700);
@import url(https://fonts.bunny.net/css?family=noto-serif:400,700);
@import url(https://fonts.bunny.net/css?family=lora:400,700);
@import url(https://fonts.bunny.net/css?family=ibm-plex-serif:400,700);
@import url(https://fonts.bunny.net/css?family=space-mono:400,700);
@import url(https://fonts.bunny.net/css?family=fira-mono:400,700);
@import url(https://fonts.bunny.net/css?family=jetbrains-mono:400,700);
/* Defaults */
@layer base {
/* This just serves as a placeholder; we actually load Inter from a font file in Ember admin */
@font-face {
font-family: "Inter";
src: local("Inter") format("truetype-variations");
font-weight: 100 900;
}
.admin-x-base {
& {
@apply font-sans text-black text-base leading-normal;
}
h1, h2, h3, h4, h5 {
@apply font-bold tracking-tight leading-tighter;
}
h1 {
@apply text-4xl leading-supertight;
}
h2 {
@apply text-2xl;
}
h3 {
@apply text-xl;
}
h4 {
@apply text-lg;
}
h5 {
@apply text-md leading-supertight;
}
h6 {
@apply text-md leading-normal;
}
}
}
.admin-x-base {
line-height: 1.5;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
letter-spacing: unset;
height: 100vh;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
}
/*
Used to be for fixed bottom mobile menu bar
@media (max-width: 800px) {
.admin-x-base {
height: calc(100vh - 55px);
}
} */
.dark .admin-x-base {
color: #FAFAFB;
}
.dark .admin-x-base .gh-loading-orb-container {
background-color: #000000;
}
.dark .admin-x-base .gh-loading-orb {
filter: invert(100%);
}
.admin-x-base .no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome */
}
.admin-x-base .no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Prose classes are for formatting arbitrary HTML that comes from the API */
.gh-prose-links a {
color: #30CF43;
}
+123
View File
@@ -0,0 +1,123 @@
@import './preflight.css';
@import 'tailwindcss/theme.css';
@import '@tryghost/shade/tailwind.theme.css';
@import 'tailwindcss/utilities.css';
@import url(https://fonts.bunny.net/css?family=cardo:400,700);
@import url(https://fonts.bunny.net/css?family=manrope:300,500,700);
@import url(https://fonts.bunny.net/css?family=merriweather:300,700);
@import url(https://fonts.bunny.net/css?family=nunito:400,600,700);
@import url(https://fonts.bunny.net/css?family=old-standard-tt:400,700);
@import url(https://fonts.bunny.net/css?family=prata:400);
@import url(https://fonts.bunny.net/css?family=roboto:400,500,700);
@import url(https://fonts.bunny.net/css?family=rufina:400,500,700);
@import url(https://fonts.bunny.net/css?family=tenor-sans:400);
@import url(https://fonts.bunny.net/css?family=space-grotesk:700);
@import url(https://fonts.bunny.net/css?family=chakra-petch:400);
@import url(https://fonts.bunny.net/css?family=noto-sans:400,700);
@import url(https://fonts.bunny.net/css?family=poppins:400,700);
@import url(https://fonts.bunny.net/css?family=fira-sans:400,700);
@import url(https://fonts.bunny.net/css?family=inter:400,700);
@import url(https://fonts.bunny.net/css?family=noto-serif:400,700);
@import url(https://fonts.bunny.net/css?family=lora:400,700);
@import url(https://fonts.bunny.net/css?family=ibm-plex-serif:400,700);
@import url(https://fonts.bunny.net/css?family=space-mono:400,700);
@import url(https://fonts.bunny.net/css?family=fira-mono:400,700);
@import url(https://fonts.bunny.net/css?family=jetbrains-mono:400,700);
/* Defaults */
@layer base {
/* This just serves as a placeholder; we actually load Inter from a font file in Ember admin */
@font-face {
font-family: "Inter";
src: local("Inter") format("truetype-variations");
font-weight: 100 900;
}
.admin-x-base {
& {
@apply font-sans text-black text-base leading-normal;
}
h1, h2, h3, h4, h5 {
@apply font-bold tracking-tight leading-tighter;
}
h1 {
@apply text-4xl leading-supertight;
}
h2 {
@apply text-2xl;
}
h3 {
@apply text-xl;
}
h4 {
@apply text-lg;
}
h5 {
@apply text-md leading-supertight;
}
h6 {
@apply text-md leading-normal;
}
}
}
.admin-x-base {
line-height: 1.5;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
letter-spacing: unset;
height: 100vh;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
}
/*
Used to be for fixed bottom mobile menu bar
@media (max-width: 800px) {
.admin-x-base {
height: calc(100vh - 55px);
}
} */
.dark .admin-x-base {
color: #FAFAFB;
}
.dark .admin-x-base .gh-loading-orb-container {
background-color: #000000;
}
.dark .admin-x-base .gh-loading-orb {
filter: invert(100%);
}
.admin-x-base .no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome */
}
.admin-x-base .no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Prose classes are for formatting arbitrary HTML that comes from the API */
.gh-prose-links a {
color: #30CF43;
}
@@ -0,0 +1,11 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
],
rules: {
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
}
};
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./types",
"emitDeclarationOnly": true,
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json"]
}
+67
View File
@@ -0,0 +1,67 @@
import react from '@vitejs/plugin-react';
import {globSync} from 'glob';
import {resolve} from 'path';
import svgr from 'vite-plugin-svgr';
import {defineConfig} from 'vitest/config';
// https://vitejs.dev/config/
export default (function viteConfig() {
return defineConfig({
logLevel: process.env.CI ? 'info' : 'warn',
plugins: [
svgr(),
react()
],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VITEST_SEGFAULT_RETRY': 3
},
preview: {
port: 4174
},
build: {
reportCompressedSize: false,
minify: false,
sourcemap: true,
outDir: 'es',
lib: {
formats: ['es'],
entry: globSync(resolve(__dirname, 'src/**/*.{ts,tsx}')).reduce((entries, path) => {
if (path.includes('.stories.') || path.endsWith('.d.ts')) {
return entries;
}
const outPath = path.replace(resolve(__dirname, 'src') + '/', '').replace(/\.(ts|tsx)$/, '');
entries[outPath] = path;
return entries;
}, {} as Record<string, string>)
},
commonjsOptions: {
include: [/packages/, /node_modules/]
},
rollupOptions: {
external: (source) => {
if (source.startsWith('.')) {
return false;
}
if (source.includes('node_modules')) {
return true;
}
return !source.includes(__dirname);
}
}
},
test: {
globals: true, // required for @testing-library/jest-dom extensions
environment: 'jsdom',
include: ['./test/unit/**/*'],
testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000,
...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674
minThreads: 1,
maxThreads: 2
})
}
});
});
+42
View File
@@ -0,0 +1,42 @@
module.exports = {
extends: [
'plugin:ghost/ts',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
plugins: [
'ghost',
'react-refresh',
'tailwindcss'
],
settings: {
react: {
version: 'detect'
}
},
rules: {
// suppress errors for missing 'import React' in JSX files, as we don't need it
'react/react-in-jsx-scope': 'off',
// ignore prop-types for now
'react/prop-types': 'off',
'no-restricted-imports': ['error', {
paths: [{
name: '@tryghost/shade',
message: 'Import from layered subpaths instead (components/primitives/patterns/utils/app/tokens).'
}]
}],
'react/jsx-sort-props': ['error', {
reservedFirst: true,
callbacksLast: true,
shorthandLast: true,
locale: 'en'
}],
'react/button-has-type': 'error',
'react/no-array-index-key': 'error',
'react/jsx-key': 'off',
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
}
};
+2
View File
@@ -0,0 +1,2 @@
dist
types
+22
View File
@@ -0,0 +1,22 @@
# Admin X Framework
Ghost Shared Framework that is used by all the micro-frontends for common functionality like data fetching and routing.
## Pre-requisites
- Run `pnpm` in Ghost monorepo root
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `pnpm` to install top-level dependencies.
## Test
- `pnpm lint` - run just eslint
- `pnpm test` - runs acceptance tests
In package.json you can find other related running options too.
+128
View File
@@ -0,0 +1,128 @@
{
"name": "@tryghost/admin-x-framework",
"type": "module",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/apps/admin-x-framework",
"author": "Ghost Foundation",
"private": true,
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./types/index.d.ts"
},
"./errors": {
"import": "./dist/errors.js",
"require": "./dist/errors.cjs",
"types": "./types/errors.d.ts"
},
"./helpers": {
"import": "./dist/helpers.js",
"require": "./dist/helpers.cjs",
"types": "./types/helpers.d.ts"
},
"./hooks": {
"import": "./dist/hooks.js",
"require": "./dist/hooks.cjs",
"types": "./types/hooks.d.ts"
},
"./routing": {
"import": "./dist/routing.js",
"require": "./dist/routing.cjs",
"types": "./types/routing.d.ts"
},
"./api/*": {
"import": "./dist/api/*.js",
"require": "./dist/api/*.cjs",
"types": "./types/api/*.d.ts"
},
"./utils/post-utils": {
"import": "./dist/utils/post-utils.js",
"require": "./dist/utils/post-utils.cjs",
"types": "./types/utils/post-utils.d.ts"
},
"./vite": {
"import": "./dist/vite.js",
"require": "./dist/vite.cjs",
"types": "./types/vite.d.ts"
},
"./playwright": {
"import": "./dist/playwright.js",
"require": "./dist/playwright.cjs",
"types": "./types/playwright.d.ts"
},
"./test/*": {
"import": "./dist/test/*.js",
"require": "./dist/test/*.cjs",
"types": "./types/test/*.d.ts"
}
},
"sideEffects": false,
"scripts": {
"dev": "vite build --watch",
"build": "tsc -p tsconfig.declaration.json && vite build",
"test": "pnpm test:types && pnpm test:unit",
"test:types": "tsc --noEmit",
"test:unit": "vitest run --coverage",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
"lint": "pnpm lint:code && pnpm lint:test",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx test/ --cache"
},
"files": [
"dist",
"types"
],
"devDependencies": {
"@playwright/test": "1.59.1",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "14.3.1",
"@tryghost/koenig-lexical": "1.7.30",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "^1.6.1",
"c8": "10.1.3",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.24",
"glob": "^10.5.0",
"jsdom": "28.1.0",
"msw": "2.12.14",
"sinon": "18.0.1",
"typescript": "5.9.3",
"vite": "5.4.21",
"vite-plugin-css-injected-by-js": "3.5.2",
"vite-plugin-svgr": "3.3.0",
"vitest": "1.6.1"
},
"dependencies": {
"@ebay/nice-modal-react": "1.2.13",
"@sentry/react": "7.120.4",
"@tanstack/react-query": "4.36.1",
"@tinybirdco/charts": "0.2.4",
"@tryghost/admin-x-design-system": "workspace:*",
"@tryghost/shade": "workspace:*",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "2.6.0",
"react-router": "7.14.0"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
"^build"
]
},
"test:unit": {
"dependsOn": [
"^build"
]
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './utils/errors';
+2
View File
@@ -0,0 +1,2 @@
export * from './utils/helpers';
+10
View File
@@ -0,0 +1,10 @@
export {default as useFilterableApi} from './hooks/use-filterable-api';
export {default as useForm} from './hooks/use-form';
export type {Dirtyable, ErrorMessages, FormHook, OkProps, SaveHandler, SaveState} from './hooks/use-form';
export {default as useHandleError} from './hooks/use-handle-error';
export {usePermission} from './hooks/use-permissions';
export {useKoenigFileUpload, koenigFileUploadTypes} from './hooks/use-koenig-file-upload';
export {useKoenigFetchEmbed} from './hooks/use-koenig-fetch-embed';
export type {KoenigFileUploadType} from './hooks/use-koenig-file-upload';
export {useKoenigLinkSuggestions} from './hooks/use-koenig-link-suggestions';
export {usePinturaConfig} from './hooks/use-pintura-config';
+58
View File
@@ -0,0 +1,58 @@
// Framework
export type {StatsConfig, FrameworkContextType, FrameworkProviderProps, TopLevelFrameworkProps} from './providers/framework-provider';
export {FrameworkProvider, useFramework} from './providers/framework-provider';
// App Context
export type {AppSettings, BaseAppProps, AppContextType, AppProviderProps} from './providers/app-provider';
export {AppContext, AppProvider, useAppContext} from './providers/app-provider';
// Hooks
export {useActiveVisitors} from './hooks/use-active-visitors';
export {default as useForm} from './hooks/use-form';
export type {Dirtyable, ErrorMessages, FormHook, OkProps, SaveHandler, SaveState} from './hooks/use-form';
export {default as useHandleError} from './hooks/use-handle-error';
export {default as useFilterableApi} from './hooks/use-filterable-api';
export {useTinybirdToken} from './hooks/use-tinybird-token';
export type {UseTinybirdTokenResult} from './hooks/use-tinybird-token';
export {useTinybirdQuery} from './hooks/use-tinybird-query';
export type {UseTinybirdQueryOptions} from './hooks/use-tinybird-query';
export {useKoenigFileUpload, koenigFileUploadTypes} from './hooks/use-koenig-file-upload';
export {useKoenigFetchEmbed} from './hooks/use-koenig-fetch-embed';
export type {KoenigFileUploadType} from './hooks/use-koenig-file-upload';
export {useKoenigLinkSuggestions} from './hooks/use-koenig-link-suggestions';
// Currency utilities
export {getSymbol} from './utils/currency';
// Stats utilities
export {getStatEndpointUrl, getToken} from './utils/stats-config';
// Post utilities
export type {Post} from './api/posts';
export {hasBeenEmailed} from './utils/post-utils';
export {isEmailOnly, isPublishedOnly, isPublishedAndEmailed, getPostMetricsToDisplay} from './utils/post-helpers';
export {focusKoenigEditorOnBottomClick} from './utils/focus-koenig-editor-on-bottom-click';
// Source utilities
export {SOURCE_DOMAIN_MAP, getFaviconDomain, extractDomain, isDomainOrSubdomain, processSources, extendSourcesWithPercentages, normalizeSource} from './utils/source-utils';
export type {BaseSourceData, ProcessedSourceData, ExtendSourcesOptions} from './utils/source-utils';
// Routing
export type {RouteObject} from 'react-router';
export type {RouterProviderProps, NavigateOptions} from './providers/router-provider';
export {RouterProvider, useNavigate, useBaseRoute, useRouteHasParams, resetScrollPosition, ScrollRestoration, Navigate} from './providers/router-provider';
export {useNavigationStack} from './providers/navigation-stack-provider';
export {Link, NavLink, Outlet, useLocation, useParams, useSearchParams, redirect, matchRoutes, matchPath, useMatch, useMatches} from 'react-router';
// Lazy component loader
export {lazyComponent} from './utils/lazy-component';
// Data fetching
export type {InfiniteData} from '@tanstack/react-query';
export {useQueryClient} from '@tanstack/react-query';
// API
export type {TinybirdToken, TinybirdTokenResponseType} from './api/tinybird';
export {getTinybirdToken} from './api/tinybird';
export type {FeaturebaseToken, FeaturebaseTokenResponseType} from './api/featurebase';
export {getFeaturebaseToken} from './api/featurebase';
+62
View File
@@ -0,0 +1,62 @@
import {defineConfig, devices, PlaywrightTestConfig} from '@playwright/test';
export const E2E_PORT = 5173;
export function adminXPlaywrightConfig(overrides: Partial<PlaywrightTestConfig> = {}) {
/**
* See https://playwright.dev/docs/test-configuration.
*/
return defineConfig({
testDir: './test/acceptance',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Hardcode to use all cores in CI */
workers: process.env.CI ? '100%' : (process.env.PLAYWRIGHT_SLOWMO ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL: `http://localhost:${E2E_PORT}`,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
launchOptions: {
slowMo: parseInt(process.env.PLAYWRIGHT_SLOWMO ?? '') || 0,
// force GPU hardware acceleration
// (even in headless mode)
args: ['--use-gl=egl']
}
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']}
},
...(process.env.ALL_BROWSERS ? [{
name: 'firefox',
use: {...devices['Desktop Firefox']}
},
{
name: 'webkit',
use: {...devices['Desktop Safari']}
}] : [])
],
/* Run local dev server before starting the tests */
webServer: {
command: `pnpm dev:start`,
url: `http://localhost:${E2E_PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 10000
},
...overrides
});
}
+3
View File
@@ -0,0 +1,3 @@
export {RoutingProvider, useRouteChangeCallback, useRouting} from './providers/routing-provider';
export type {ExternalLink, InternalLink, ModalComponent, RoutingModalProps} from './providers/routing-provider';
+88
View File
@@ -0,0 +1,88 @@
import react from '@vitejs/plugin-react';
import {PluginOption, UserConfig, mergeConfig} from 'vite';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import svgr from 'vite-plugin-svgr';
import {defineConfig} from 'vitest/config';
const externalPlugin = ({externals}: { externals: Record<string, string> }): PluginOption => {
return {
name: 'external-globals',
apply: 'build',
enforce: 'pre',
resolveId(id) {
if (Object.keys(externals).includes(id)) {
// Naming convention for IDs that will be resolved by a plugin
return `\0${id}`;
}
},
async load(id) {
const [originalId, externalName] = Object.entries(externals).find(([key]) => id === `\0${key}`) || [];
if (originalId) {
const module = await import(originalId);
return Object.keys(module).map(key => (key === 'default' ? `export default ${externalName};` : `export const ${key} = ${externalName}.${key};`)).join('\n');
}
}
};
};
// https://vitejs.dev/config/
export default function adminXViteConfig({packageName, entry, overrides}: {packageName: string; entry: string; overrides?: UserConfig}) {
const outputFileName = packageName[0] === '@' ? packageName.slice(packageName.indexOf('/') + 1) : packageName;
const defaultConfig = defineConfig({
logLevel: process.env.CI ? 'info' : 'warn',
plugins: [
svgr(),
react(),
externalPlugin({
externals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}),
cssInjectedByJsPlugin() as PluginOption // Cast to avoid type conflicts
],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VITEST_SEGFAULT_RETRY': 3,
'import.meta.env.GHOST_BUILD_VERSION': JSON.stringify(process.env.GHOST_BUILD_VERSION || '')
},
preview: {
port: 4174
},
build: {
reportCompressedSize: false,
minify: true,
sourcemap: true,
lib: {
formats: ['es'],
entry,
name: packageName,
fileName(format) {
if (format === 'umd') {
return `${outputFileName}.umd.js`;
}
return `${outputFileName}.js`;
}
},
commonjsOptions: {
include: [/packages/, /node_modules/]
}
},
test: {
globals: true, // required for @testing-library/jest-dom extensions
environment: 'jsdom',
include: ['./test/unit/**/*'],
testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000,
...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674
minThreads: 1,
maxThreads: 2
})
}
});
return mergeConfig(defaultConfig, overrides || {});
};
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts-test'
],
rules: {
'ghost/mocha/no-mocha-arrows': 'off',
'@typescript-eslint/no-explicit-any': 'off',
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false]
}
};
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vitest/globals" />
import '@testing-library/jest-dom';
// This file ensures TypeScript knows about vitest globals
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./types",
"emitDeclarationOnly": true,
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client", "vitest/globals"],
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "test"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json"]
}
+68
View File
@@ -0,0 +1,68 @@
import path from 'path';
import react from '@vitejs/plugin-react';
import {globSync} from 'glob';
import {resolve} from 'path';
import {defineConfig} from 'vitest/config';
// https://vitejs.dev/config/
export default (function viteConfig() {
return defineConfig({
logLevel: process.env.CI ? 'info' : 'warn',
plugins: [
react()
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
preview: {
port: 4174
},
build: {
reportCompressedSize: false,
minify: false,
sourcemap: true,
outDir: 'dist',
lib: {
formats: ['es', 'cjs'],
entry: globSync(resolve(__dirname, 'src/**/*.{ts,tsx}')).reduce((entries, libpath) => {
if (libpath.endsWith('.d.ts')) {
return entries;
}
const outPath = libpath.replace(resolve(__dirname, 'src') + '/', '').replace(/\.(ts|tsx)$/, '');
entries[outPath] = libpath;
return entries;
}, {} as Record<string, string>)
},
commonjsOptions: {
include: [/packages/, /node_modules/]
},
rollupOptions: {
external: (source) => {
if (source.startsWith('.')) {
return false;
}
if (source.includes('node_modules')) {
return true;
}
return !source.includes(__dirname);
}
}
},
test: {
globals: true, // required for @testing-library/jest-dom extensions
environment: 'jsdom',
include: ['./test/unit/**/*'],
setupFiles: ['./test/setup.ts'],
testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000,
...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674
minThreads: 1,
maxThreads: 2
})
}
});
});
+1
View File
@@ -0,0 +1 @@
tailwind.config.cjs
+159
View File
@@ -0,0 +1,159 @@
/* eslint-env node */
const tailwindCssConfig = `${__dirname}/../admin/src/index.css`;
module.exports = {
root: true,
extends: [
'plugin:ghost/ts',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
plugins: [
'ghost',
'react-refresh',
'tailwindcss'
],
settings: {
react: {
version: 'detect'
},
tailwindcss: {
config: tailwindCssConfig
}
},
rules: {
// ----------------------
// Rules COPIED from base config, remove these when the config is fixed
// Style Rules
// Require 4 spaces
indent: ['error', 4],
// Require single quotes for strings & properties (allows template literals)
quotes: ['error', 'single', {allowTemplateLiterals: true}],
'quote-props': ['error', 'as-needed'],
// Require semi colons, always at the end of a line
semi: ['error', 'always'],
'semi-style': ['error', 'last'],
// Don't allow dangling commas
'comma-dangle': ['error', 'never'],
// Always require curly braces, and position them at the end and beginning of lines
curly: 'error',
'brace-style': ['error', '1tbs'],
// Don't allow padding inside of blocks
'padded-blocks': ['error', 'never'],
// Require objects to be consistently formatted with newlines
'object-curly-newline': ['error', {consistent: true}],
// Don't allow more than 1 consecutive empty line or an empty 1st line
'no-multiple-empty-lines': ['error', {max: 1, maxBOF: 0}],
// Variables must be camelcase, but properties are not checked
camelcase: ['error', {properties: 'never'}],
// Allow newlines before dots, not after e.g. .then goes on a new line
'dot-location': ['error', 'property'],
// Prefer dot notation over array notation
'dot-notation': ['error'],
// Spacing rules
// Don't allow multiple spaces anywhere
'no-multi-spaces': 'error',
// Anonymous functions have a sape, named functions never do
'space-before-function-paren': ['error', {anonymous: 'always', named: 'never'}],
// Don't put spaces inside of objects or arrays
'object-curly-spacing': ['error', 'never'],
'array-bracket-spacing': ['error', 'never'],
// Allow a max of one space between colons and values
'key-spacing': ['error', {mode: 'strict'}],
// Require spaces before and after keywords like if, else, try, catch etc
'keyword-spacing': 'error',
// No spaces around semis
'semi-spacing': 'error',
// 1 space around arrows
'arrow-spacing': 'error',
// Don't allow spaces inside parenthesis
'space-in-parens': ['error', 'never'],
// Require single spaces either side of operators
'space-unary-ops': 'error',
'space-infix-ops': 'error',
// Best practice rules
// Require === / !==
eqeqeq: ['error', 'always'],
// Don't allow ++ and --
'no-plusplus': ['error', {allowForLoopAfterthoughts: true}],
// Don't allow eval
'no-eval': 'error',
// Throw errors for unnecessary usage of .call or .apply
'no-useless-call': 'error',
// Don't allow console.* calls
'no-console': 'error',
// Prevent [variable shadowing](https://en.wikipedia.org/wiki/Variable_shadowing)
'no-shadow': ['error'],
// Return rules
// Prevent missing return statements in array functions like map & reduce
'array-callback-return': 'error',
'no-constructor-return': 'error',
'no-promise-executor-return': 'error',
// Arrow function styles
// Do not enforce single lines when using arrow functions.
// https://eslint.org/docs/rules/arrow-body-style
'arrow-body-style': 'off',
'arrow-parens': ['error', 'as-needed', {requireForBlockBody: true}],
'implicit-arrow-linebreak': 'error',
'no-confusing-arrow': 'error',
// ----------------------
// Rules NOT COPIED from base config, keep these
// sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
}],
'no-restricted-imports': ['error', {
paths: [{
name: '@tryghost/shade',
message: 'Import from layered subpaths instead (components/primitives/patterns/utils/app/tokens).'
}]
}],
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
// TODO: enable this when we have the time to retroactively go and fix the issues
'prefer-const': 'off',
// TODO: re-enable this (maybe fixed fast refresh?)
'react-refresh/only-export-components': 'off',
// suppress errors for missing 'import React' in JSX files, as we don't need it
'react/react-in-jsx-scope': 'off',
// ignore prop-types for now
'react/prop-types': 'off',
// TODO: re-enable this because otherwise we're just skirting TS
'@typescript-eslint/no-explicit-any': 'warn',
// TODO: re-enable these if deemed useful
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-empty-function': 'off',
// custom react rules
'react/jsx-sort-props': ['error', {
reservedFirst: true,
callbacksLast: true,
shorthandLast: true,
locale: 'en'
}],
'react/button-has-type': 'error',
'react/no-array-index-key': 'error',
'react/jsx-key': 'off',
'tailwindcss/classnames-order': 'error',
'tailwindcss/enforces-negative-arbitrary-values': 'warn',
'tailwindcss/enforces-shorthand': 'warn',
'tailwindcss/migration-from-tailwind-2': 'warn',
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': 'error'
}
};
+33
View File
@@ -0,0 +1,33 @@
# Admin X Settings
Ghost Admin Settings micro-frontend.
## Pre-requisites
- Run `pnpm` in Ghost monorepo root
## Running the app
### Running the development version
Run `pnpm dev` (in this package folder) to start the development server to test/develop the settings standalone. This will generate a demo site from the `index.html` file which renders the app and makes it available on http://localhost:5173
### Running inside Admin
Run `pnpm dev` from the top-level repo. This starts all frontend apps via Docker backend + host dev servers, and AdminX will automatically rebuild when you make changes.
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `pnpm` to install top-level dependencies.
## Test
- `pnpm lint` - run just eslint
- `pnpm test:acceptance` - runs acceptance tests
- `pnpm test:unit` - runs unit tests
- `pnpm test:acceptance path/to/test` - runs a specific test
- `pnpm test:acceptance:slowmo` - runs acceptance tests in slow motion and headed mode, useful for debugging and developing tests
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
/**
* This is used by vite to resolve node builtins. See resolve.alias in vite.config.js
*/
module.exports = {};
+103
View File
@@ -0,0 +1,103 @@
{
"name": "@tryghost/admin-x-settings",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TryGhost/Ghost/tree/main/packages/admin-x-settings"
},
"author": "Ghost Foundation",
"files": [
"LICENSE",
"README.md",
"dist/"
],
"main": "./dist/admin-x-settings.umd.cjs",
"module": "./dist/admin-x-settings.js",
"exports": {
".": {
"import": "./dist/admin-x-settings.js",
"require": "./dist/admin-x-settings.umd.cjs",
"types": "./src/index.tsx"
},
"./src/*": {
"import": "./src/*.tsx",
"require": "./src/*.tsx"
}
},
"private": true,
"scripts": {
"dev": "vite build --watch",
"dev:start": "vite",
"build": "tsc && vite build",
"lint": "pnpm run lint:js",
"lint:js": "eslint --ext .js,.ts,.cjs,.tsx --cache src test",
"test": "pnpm test:unit",
"test:unit": "vitest run --config vitest.config.ts",
"test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' playwright test",
"test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 pnpm test:acceptance --headed",
"test:acceptance:full": "ALL_BROWSERS=1 pnpm test:acceptance",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-html": "6.4.11",
"@dnd-kit/sortable": "7.0.2",
"@ebay/nice-modal-react": "1.2.13",
"@sentry/react": "7.120.4",
"@tanstack/react-query": "4.36.1",
"@tryghost/color-utils": "0.2.16",
"@tryghost/i18n": "workspace:*",
"@tryghost/kg-unsplash-selector": "0.3.26",
"@tryghost/limit-service": "1.5.2",
"@tryghost/nql": "0.12.10",
"@tryghost/timezone-data": "0.4.18",
"@uiw/react-codemirror": "4.25.2",
"clsx": "2.1.1",
"lucide-react": "0.577.0",
"mingo": "2.5.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "2.6.0",
"react-select": "5.10.2",
"sonner": "2.0.7",
"validator": "13.12.0"
},
"devDependencies": {
"@playwright/test": "1.59.1",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "workspace:*",
"@tryghost/admin-x-framework": "workspace:*",
"@tryghost/custom-fonts": "1.0.8",
"@tryghost/shade": "workspace:*",
"@types/node": "22.19.17",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@types/validator": "13.15.10",
"@vitejs/plugin-react": "4.7.0",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.24",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"stylelint": "15.11.0",
"tailwindcss": "^4.2.2",
"vite": "5.4.21",
"vite-plugin-css-injected-by-js": "3.5.2",
"vite-plugin-svgr": "3.3.0",
"vitest": "1.6.1"
},
"nx": {
"targets": {
"dev": {
"dependsOn": [
"^build"
]
},
"test:acceptance": {
"dependsOn": [
"^build"
]
}
}
}
}
@@ -0,0 +1,3 @@
import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
export default adminXPlaywrightConfig();
+1
View File
@@ -0,0 +1 @@
module.exports = require('@tryghost/admin-x-design-system/postcss.config.cjs');
+38
View File
@@ -0,0 +1,38 @@
import MainContent from './main-content';
import NiceModal from '@ebay/nice-modal-react';
import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider';
import SettingsRouter, {loadModals, modalPaths} from './components/providers/settings-router';
import {DesignSystemApp, type DesignSystemAppProps} from '@tryghost/admin-x-design-system';
import {FrameworkProvider, type TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
interface AppProps {
designSystem: DesignSystemAppProps;
upgradeStatus?: UpgradeStatusType;
}
export function App({designSystem, upgradeStatus}: AppProps) {
return (
<SettingsAppProvider upgradeStatus={upgradeStatus}>
{/* NOTE: we need to have an extra NiceModal.Provider here because the one inside DesignSystemApp
is loaded too late for possible modals in RoutingProvider, and it's quite hard to change it at
this point */}
<NiceModal.Provider>
<RoutingProvider basePath='settings' modals={{paths: modalPaths, load: loadModals}}>
<DesignSystemApp className='admin-x-settings' {...designSystem}>
<SettingsRouter />
<MainContent />
</DesignSystemApp>
</RoutingProvider>
</NiceModal.Provider>
</SettingsAppProvider>
);
}
export function StandaloneApp({framework, designSystem, upgradeStatus}: AppProps & {framework: TopLevelFrameworkProps}) {
return (
<FrameworkProvider {...framework}>
<App designSystem={designSystem} upgradeStatus={upgradeStatus} />
</FrameworkProvider>
);
}
+6
View File
@@ -0,0 +1,6 @@
import './styles/index.css';
import {StandaloneApp} from './app.tsx';
export {
StandaloneApp as AdminXApp
};
@@ -0,0 +1,95 @@
import ExitSettingsButton from './components/exit-settings-button';
import Settings from './components/settings';
import Sidebar from './components/sidebar';
import Users from './components/settings/general/users';
import {Heading, confirmIfDirty, topLevelBackdropClasses, useGlobalDirtyState} from '@tryghost/admin-x-design-system';
import {type ReactNode, useEffect} from 'react';
import {canAccessSettings, isEditorUser} from '@tryghost/admin-x-framework/api/users';
import {toast} from 'react-hot-toast';
import {useGlobalData} from './components/providers/global-data-provider';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const EMPTY_KEYWORDS: string[] = [];
const Page: React.FC<{children: ReactNode}> = ({children}) => {
return <>
<div className='fixed top-2 right-0 z-50 m-8 flex justify-end bg-transparent tablet:fixed tablet:top-0' id="done-button-container">
<ExitSettingsButton />
</div>
<div className="fixed top-0 left-0 flex size-full dark:bg-grey-975" id="admin-x-settings-content">
{children}
</div>
</>;
};
const MainContent: React.FC = () => {
const {currentUser} = useGlobalData();
const {loadingModal} = useRouting();
const {isDirty} = useGlobalDirtyState();
const navigateAway = (escLocation: string) => {
window.location.hash = escLocation;
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
// Don't navigate away if a modal is open - let the modal handle ESC
const modalBackdrop = document.getElementById('modal-backdrop');
if (modalBackdrop) {
return;
}
confirmIfDirty(isDirty, () => {
navigateAway('/');
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isDirty]);
useEffect(() => {
// resets any toasts that may have been left open on initial load
toast.remove();
}, []);
// Contributors/Authors only see their profile modal (rendered via routing)
// Don't render the main settings content for them
if (!canAccessSettings(currentUser)) {
return null;
}
if (isEditorUser(currentUser)) {
return (
<Page>
<div className='flex-1 overflow-y-auto bg-white dark:bg-grey-975' id="admin-x-settings-scroller">
<div className='mx-auto max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10'>
<Heading className='mb-[5vmin]'>Settings</Heading>
<Users highlight={false} keywords={EMPTY_KEYWORDS} />
</div>
</div>
</Page>
);
}
return (
<Page>
{loadingModal && <div className={`fixed inset-0 z-40 h-[calc(100vh-55px)] w-[100vw] tablet:h-[100vh] ${topLevelBackdropClasses}`} />}
<div className="fixed inset-x-0 top-0 z-[35] max-w-[calc(100%-16px)] flex-1 basis-[320px] bg-white p-8 tablet:relative tablet:inset-x-auto tablet:top-auto tablet:h-full tablet:overflow-y-scroll tablet:bg-grey-50 tablet:py-0 dark:bg-grey-975 dark:tablet:bg-[#101114]" id="admin-x-settings-sidebar-scroller">
<div className="relative w-full">
<Sidebar />
</div>
</div>
<div className="relative h-full flex-1 overflow-y-scroll bg-white pt-12 tablet:basis-[800px] dark:bg-grey-975 dark:tablet:bg-black" id="admin-x-settings-scroller">
<Settings />
</div>
</Page>
);
};
export default MainContent;
+6
View File
@@ -0,0 +1,6 @@
import './styles/index.css';
import renderStandaloneApp from '@tryghost/admin-x-framework/test/render';
import {StandaloneApp} from './app.tsx';
renderStandaloneApp(StandaloneApp, {
});
+10
View File
@@ -0,0 +1,10 @@
declare module '@tryghost/limit-service'
declare module '@tryghost/nql'
declare module '*.svg' {
// eslint-disable-next-line @typescript-eslint/no-require-imports
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import {setupShadeMocks} from '@tryghost/admin-x-framework/test/setup';
// Set up common mocks for shade components
setupShadeMocks();
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./types",
"emitDeclarationOnly": true,
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"]
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node", "vitest/globals", "@testing-library/jest-dom/vitest"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@src/*": ["./src/*"],
"@test/*": ["./test/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"erasableSyntaxOnly": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "test"]
}
+38
View File
@@ -0,0 +1,38 @@
import adminXViteConfig from '@tryghost/admin-x-framework/vite';
import pkg from './package.json';
import {resolve} from 'path';
import {createRequire} from 'node:module';
const require = createRequire(import.meta.url);
// https://vitejs.dev/config/
export default (function viteConfig() {
return adminXViteConfig({
packageName: pkg.name,
entry: resolve(__dirname, 'src/index.tsx'),
overrides: {
define: {
'process.env.DEBUG': false // Shim env var utilized by the @tryghost/nql package
},
resolve: {
// Shim node modules utilized by the @tryghost/nql package
alias: {
'@src': resolve(__dirname, 'src'),
'@test': resolve(__dirname, 'test'),
fs: 'node-shim.cjs',
path: 'node-shim.cjs',
util: 'node-shim.cjs',
// @TODO: Remove this when @tryghost/nql is updated
mingo: require.resolve('mingo/dist/mingo.js')
}
},
optimizeDeps: {
include: ['@tryghost/kg-unsplash-selector', '@tryghost/custom-fonts']
}
},
build: {
commonjsOptions: {
include: [/ghost\/custom-fonts/]
}
}
});
});
+3
View File
@@ -0,0 +1,3 @@
import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config';
export default createVitestConfig();
+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"],
},
}));
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2026 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39
View File
@@ -0,0 +1,39 @@
# Announcement Bar
## Development
### Pre-requisites
- Run `pnpm` in Ghost monorepo root
- Run `pnpm` in this directory
### Running via Ghost `pnpm dev` in root folder
Announcement Bar runs automatically when using Ghost's development command from the monorepo root:
```bash
pnpm dev
```
This starts all frontend apps (including Announcement Bar.)
## Release
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released.
In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
### Patch release
1. Run `pnpm ship` and select a patch version when prompted
2. Merge the release commit to `main`
### Minor / major release
1. Run `pnpm ship` and select a minor or major version when prompted
2. Merge the release commit to `main`
3. Wait until a new version of Ghost is released
To use the new version of signup form in Ghost, update the version in Ghost core's default configuration (currently at `core/shared/config/default.json`)
# Copyright & License
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).

Some files were not shown because too many files have changed in this diff Show More