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
+5
View File
@@ -0,0 +1,5 @@
STATS_ENDPOINT=https://api.tinybird.co
STATS_TOKEN=p.ey....
STATS_LOCAL_ENDPOINT=http://localhost:7181
STATS_LOCAL_TOKEN=p.ey...
STATS_LOCAL_DATASOURCE=analytics_events
+1
View File
@@ -0,0 +1 @@
tailwind.config.cjs
+70
View File
@@ -0,0 +1,70 @@
/* 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: {
// 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: 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
+93
View File
@@ -0,0 +1,93 @@
# Ghost Stats App
Ghost Admin Stats micro-frontend that provides analytics and insights for Ghost sites.
## Features
### Top Content Analytics
- **Growth Tab**: Shows which posts and pages drove the most member conversions
- **Web Tab**: Shows which posts and pages received the most visitors
### URL Linking
All content in the analytics tables is now clickable:
- **Posts**: Click to view detailed post analytics
- **Pages**: Click to view the page on the frontend site
- **System Pages**: Click to view homepage, tag pages, author pages, etc. on the frontend site
The app automatically determines the appropriate action:
- Posts with analytics data → Navigate to post analytics page
- Pages and system pages → Open frontend URL in new tab
### Supported System Pages
- Homepage (`/`)
- Tag pages (`/tag/slug/`, `/tags/slug/`)
- Author pages (`/author/slug/`, `/authors/slug/`)
- Custom pages and other frontend URLs
## Development
### Prerequisites
- Node.js (version as specified in the root package.json)
- Yarn
### Setup
This app is part of the Ghost monorepo. After cloning the Ghost repository:
```bash
# Install dependencies from the root directory
pnpm
# Run pnpm dev in the root of the repo
pnpm dev
```
### Build
```bash
pnpm build
```
This will create a production build in the `dist` directory.
### Testing
```bash
# Run all tests
pnpm test
# Run only unit tests
pnpm test:unit
# Run tests in watch mode during development
pnpm test:watch
# Run tests with coverage report
pnpm test:coverage
```
### Linting
```bash
# Lint all files
pnpm lint
# Lint only source code
pnpm lint:code
# Lint only test files
pnpm lint:test
```
## License
MIT - See LICENSE file for details.
## URL Utilities
The app includes URL helper utilities in `src/utils/url-helpers.ts`:
- `getFrontendUrl()`: Generate full frontend URLs from attribution paths
- `shouldMakeClickable()`: Determine if content should be clickable
- `getClickHandler()`: Get appropriate click handler for content type
+16
View File
@@ -0,0 +1,16 @@
<!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>Ghost Traffic Analytics</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/standalone.tsx"></script>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
{
"name": "@tryghost/stats",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TryGhost/Ghost/tree/main/apps/stats"
},
"author": "Ghost Foundation",
"files": [
"LICENSE",
"README.md",
"dist/"
],
"main": "./dist/stats.umd.cjs",
"module": "./dist/stats.js",
"exports": {
".": {
"import": "./dist/stats.js",
"require": "./dist/stats.umd.cjs"
},
"./api": "./src/api.ts"
},
"private": true,
"scripts": {
"dev": "vite build --watch",
"dev:start": "vite",
"test": "pnpm test:unit --coverage",
"test:unit": "vitest run test/unit",
"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",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"build": "tsc && vite build",
"lint": "pnpm run lint:code && pnpm run lint:test",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src",
"lint:code:fix": "eslint --ext .js,.ts,.cjs,.tsx --cache --fix src",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test",
"preview": "vite preview"
},
"devDependencies": {
"@faker-js/faker": "9.9.0",
"@playwright/test": "1.59.1",
"@tanstack/react-query": "4.36.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "14.3.1",
"@types/jest": "29.5.14",
"@types/react": "18.3.28",
"@types/react-svg-map": "2.1.4",
"@vitest/coverage-v8": "^1.6.1",
"@vitejs/plugin-react": "4.7.0",
"dotenv": "17.3.1",
"msw": "2.12.14",
"tailwindcss": "^4.2.2",
"vite": "5.4.21",
"vite-plugin-svgr": "4.5.0",
"vitest": "1.6.1"
},
"dependencies": {
"@svg-maps/world": "1.0.1",
"@tryghost/admin-x-framework": "workspace:*",
"@tryghost/shade": "workspace:*",
"i18n-iso-countries": "7.14.0",
"moment": "2.24.0",
"moment-timezone": "0.5.45",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-svg-map": "2.2.0"
},
"nx": {
"targets": {
"dev": {
"dependsOn": [
"^build"
]
},
"test:unit": {
"dependsOn": [
"^build"
]
},
"test:acceptance": {
"dependsOn": [
"^build"
]
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
export default adminXPlaywrightConfig();
+6
View File
@@ -0,0 +1,6 @@
/**
* Public API for cross-package imports.
* Admin uses these exports instead of reaching into src/ directly.
*/
export {default as GlobalDataProvider} from './providers/global-data-provider';
export {routes} from './routes';
+34
View File
@@ -0,0 +1,34 @@
import GlobalDataProvider from './providers/global-data-provider';
import StatsErrorBoundary from '@components/errors/stats-error-boundary';
import {APP_ROUTE_PREFIX, routes} from '@src/routes';
import {AppProvider, BaseAppProps, FrameworkProvider, Outlet, RouterProvider} from '@tryghost/admin-x-framework';
import {ShadeApp} from '@tryghost/shade/app';
export {useAppContext} from '@tryghost/admin-x-framework';
const App: React.FC<BaseAppProps> = ({framework, designSystem, appSettings}) => {
return (
<FrameworkProvider
{...framework}
queryClientOptions={{
staleTime: 0, // Always consider data stale (matches Ember admin route behavior)
refetchOnMount: true, // Always refetch when component mounts (matches Ember route model)
refetchOnWindowFocus: false // Disable window focus refetch (Ember admin doesn't have this)
}}
>
<AppProvider appSettings={appSettings}>
<RouterProvider prefix={APP_ROUTE_PREFIX} routes={routes}>
<StatsErrorBoundary>
<GlobalDataProvider>
<ShadeApp className="shade-stats app-container" darkMode={designSystem.darkMode} fetchKoenigLexical={null}>
<Outlet />
</ShadeApp>
</GlobalDataProvider>
</StatsErrorBoundary>
</RouterProvider>
</AppProvider>
</FrameworkProvider>
);
};
export default App;
+6
View File
@@ -0,0 +1,6 @@
import './styles/index.css';
import App from './app';
export {
App as AdminXApp
};
+27
View File
@@ -0,0 +1,27 @@
import {RouteObject, lazyComponent} from '@tryghost/admin-x-framework';
export const APP_ROUTE_PREFIX = '/';
export const routes: RouteObject[] = [
{
path: 'analytics',
children: [
{
index: true,
lazy: lazyComponent(() => import('./views/Stats/Overview'))
},
{
path: 'web',
lazy: lazyComponent(() => import('./views/Stats/Web'))
},
{
path: 'growth',
lazy: lazyComponent(() => import('./views/Stats/Growth'))
},
{
path: 'newsletters',
lazy: lazyComponent(() => import('./views/Stats/Newsletters'))
}
]
}
];
+20
View File
@@ -0,0 +1,20 @@
import './styles/index.css';
import App from './app';
import renderShadeApp from '@tryghost/admin-x-framework/test/render-shade';
import {AppSettings} from '@tryghost/admin-x-framework';
// Use test overrides if available, otherwise use defaults
const defaultAppSettings: AppSettings = {
paidMembersEnabled: true,
newslettersEnabled: true,
analytics: {
emailTrackOpens: true,
emailTrackClicks: true,
membersTrackSources: true,
outboundLinkTagging: true,
webAnalytics: true
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderShadeApp(App, {appSettings: defaultAppSettings} as any);
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts-test'
]
};
+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();
+15
View File
@@ -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"]
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"],
/* 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"]
}
+32
View File
@@ -0,0 +1,32 @@
import adminXViteConfig from '@tryghost/admin-x-framework/vite';
import pkg from './package.json';
import svgr from 'vite-plugin-svgr';
import {resolve} from 'path';
export default (function viteConfig() {
return adminXViteConfig({
packageName: pkg.name,
plugins: [
svgr()
],
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')
}
}
}
});
});
+13
View File
@@ -0,0 +1,13 @@
import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config';
import {resolve} from 'path';
export default createVitestConfig({
aliases: {
'@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')
}
});