diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index bb1523940d1..20ca24ba1bb 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -7,8 +7,3 @@ Please include a description of your change & check your PR against this list, t
- [ ] The build will pass (run `yarn test:all` and `yarn lint`)
We appreciate your contribution!
-
----
-
-
-copilot:summary
diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml
index fcd4fddbebc..fb6a08de204 100644
--- a/.github/actions/restore-cache/action.yml
+++ b/.github/actions/restore-cache/action.yml
@@ -6,13 +6,13 @@ runs:
steps:
- name: Check dependency cache
id: dep-cache
- uses: actions/cache/restore@v3
+ uses: actions/cache/restore@v4
with:
path: ${{ env.CACHED_DEPENDENCY_PATHS }}
key: ${{ env.DEPENDENCY_CACHE_KEY }}
- name: Check build cache
- uses: actions/cache/restore@v3
+ uses: actions/cache/restore@v4
id: build-cache
with:
path: ${{ env.CACHED_BUILD_PATHS }}
diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js
index e022c50b55c..83c9f30c955 100644
--- a/.github/scripts/dev.js
+++ b/.github/scripts/dev.js
@@ -1,7 +1,9 @@
+const fs = require('fs');
const path = require('path');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
+const chalk = require('chalk');
const concurrently = require('concurrently');
// check we're running on Node 18 and above
@@ -15,6 +17,20 @@ const config = require('../../ghost/core/core/shared/config/loader').loadNconf({
customConfigPath: path.join(__dirname, '../../ghost/core')
});
+const tsPackages = fs.readdirSync(path.resolve(__dirname, '../../ghost'), {withFileTypes: true})
+ .filter(dirent => dirent.isDirectory())
+ .map(dirent => dirent.name)
+ .filter(packageFolder => {
+ try {
+ const packageJson = require(path.resolve(__dirname, `../../ghost/${packageFolder}/package.json`));
+ return packageJson.scripts?.['build:ts'];
+ } catch (err) {
+ return false;
+ }
+ })
+ .map(packageFolder => `ghost/${packageFolder}`)
+ .join(',');
+
const liveReloadBaseUrl = config.getSubdir() || '/ghost/';
const siteUrl = config.getSiteUrl();
@@ -44,32 +60,40 @@ const COMMAND_ADMIN = {
const COMMAND_TYPESCRIPT = {
name: 'ts',
- command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts',
+ command: `while [ 1 ]; do nx watch --projects=${tsPackages} -- nx run \\$NX_PROJECT_NAME:build:ts; done`,
cwd: path.resolve(__dirname, '../../'),
prefixColor: 'cyan',
env: {}
};
-const COMMAND_ADMINX = {
+const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings,@tryghost/admin-x-activitypub';
+
+const COMMANDS_ADMINX = [{
+ name: 'adminXDeps',
+ command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build; done',
+ cwd: path.resolve(__dirname, '../..'),
+ prefixColor: '#C72AF7',
+ env: {}
+}, {
name: 'adminX',
- command: 'yarn dev',
- cwd: path.resolve(__dirname, '../../apps/admin-x-settings'),
- prefixColor: '#C35831',
+ command: `nx run-many --projects=${adminXApps} --parallel=${adminXApps.length} --targets=dev`,
+ cwd: path.resolve(__dirname, '../../apps/admin-x-settings', '../../apps/admin-x-activitypub'),
+ prefixColor: '#C72AF7',
env: {}
-};
+}];
if (DASH_DASH_ARGS.includes('ghost')) {
commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT];
} else if (DASH_DASH_ARGS.includes('admin')) {
- commands = [COMMAND_ADMIN, COMMAND_ADMINX];
+ commands = [COMMAND_ADMIN, ...COMMANDS_ADMINX];
} else {
- commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT, COMMAND_ADMIN, COMMAND_ADMINX];
+ commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT, COMMAND_ADMIN, ...COMMANDS_ADMINX];
}
if (DASH_DASH_ARGS.includes('portal') || DASH_DASH_ARGS.includes('all')) {
commands.push({
name: 'portal',
- command: 'yarn dev',
+ command: 'nx run @tryghost/portal:dev',
cwd: path.resolve(__dirname, '../../apps/portal'),
prefixColor: 'magenta',
env: {}
@@ -92,7 +116,7 @@ if (DASH_DASH_ARGS.includes('portal') || DASH_DASH_ARGS.includes('all')) {
if (DASH_DASH_ARGS.includes('signup') || DASH_DASH_ARGS.includes('all')) {
commands.push({
name: 'signup-form',
- command: DASH_DASH_ARGS.includes('signup') ? 'yarn dev' : 'yarn preview',
+ command: DASH_DASH_ARGS.includes('signup') ? 'nx run @tryghost/signup-form:dev' : 'nx run @tryghost/signup-form:preview',
cwd: path.resolve(__dirname, '../../apps/signup-form'),
prefixColor: 'magenta',
env: {}
@@ -103,7 +127,7 @@ if (DASH_DASH_ARGS.includes('signup') || DASH_DASH_ARGS.includes('all')) {
if (DASH_DASH_ARGS.includes('announcement-bar') || DASH_DASH_ARGS.includes('announcementBar') || DASH_DASH_ARGS.includes('announcementbar') || DASH_DASH_ARGS.includes('all')) {
commands.push({
name: 'announcement-bar',
- command: 'yarn dev',
+ command: 'nx run @tryghost/announcement-bar:dev',
cwd: path.resolve(__dirname, '../../apps/announcement-bar'),
prefixColor: '#DC9D00',
env: {}
@@ -114,7 +138,7 @@ if (DASH_DASH_ARGS.includes('announcement-bar') || DASH_DASH_ARGS.includes('anno
if (DASH_DASH_ARGS.includes('search') || DASH_DASH_ARGS.includes('all')) {
commands.push({
name: 'search',
- command: 'yarn dev',
+ command: 'nx run @tryghost/sodo-search:dev',
cwd: path.resolve(__dirname, '../../apps/sodo-search'),
prefixColor: '#23de43',
env: {}
@@ -153,7 +177,7 @@ if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
commands.push({
name: 'comments',
- command: 'yarn dev',
+ command: 'nx run @tryghost/comments-ui:dev',
cwd: path.resolve(__dirname, '../../apps/comments-ui'),
prefixColor: '#E55137',
env: {}
@@ -165,7 +189,6 @@ async function handleStripe() {
if (DASH_DASH_ARGS.includes('offline')) {
return;
}
- console.log('Fetching Stripe secret token..');
let stripeSecret;
try {
@@ -198,6 +221,8 @@ async function handleStripe() {
process.exit(0);
}
+ console.log(`Running projects: ${commands.map(c => chalk.green(c.name)).join(', ')}`);
+
const {result} = concurrently(commands, {
prefix: 'name',
killOthers: ['failure', 'success']
@@ -206,6 +231,10 @@ async function handleStripe() {
try {
await result;
} catch (err) {
- console.error('\nExecuting dev command failed, ensure dependencies are up-to-date by running `yarn fix`\n');
+ console.error();
+ console.error(chalk.red(`Executing dev command failed:`) + `\n`);
+ console.error(chalk.red(`If you've recently done a \`yarn main\`, dependencies might be out of sync. Try running \`${chalk.green('yarn fix')}\` to fix this.`));
+ console.error(chalk.red(`If not, something else went wrong. Please report this to the Ghost team.`));
+ console.error();
}
})();
diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml
new file mode 100644
index 00000000000..a8d89941f7d
--- /dev/null
+++ b/.github/scripts/docker-compose.yml
@@ -0,0 +1,39 @@
+version: '3.8'
+
+name: ghost
+
+services:
+ mysql:
+ image: mysql:8.0.35
+ container_name: ghost-mysql
+ # We'll need to look into how we can further fine tune the memory usage/performance here
+ command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: ghost
+ restart: always
+ volumes:
+ # Turns out you can drop .sql or .sql.gz files in here, cool!
+ - ./mysql-preload:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: "mysql -uroot -proot ghost -e 'select 1'"
+ interval: 1s
+ retries: 120
+ redis:
+ image: redis:7.0
+ container_name: ghost-redis
+ ports:
+ - "6379:6379"
+ restart: always
+ jaeger:
+ image: jaegertracing/all-in-one:1.58
+ container_name: ghost-jaeger
+ ports:
+ - "4318:4318"
+ - "16686:16686"
+ - "9411:9411"
+ restart: always
+ environment:
+ COLLECTOR_ZIPKIN_HOST_PORT: :9411
\ No newline at end of file
diff --git a/.github/scripts/mysql-preload/.keep b/.github/scripts/mysql-preload/.keep
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/.github/scripts/setup.js b/.github/scripts/setup.js
new file mode 100644
index 00000000000..f0558b298d7
--- /dev/null
+++ b/.github/scripts/setup.js
@@ -0,0 +1,138 @@
+const {spawn} = require('child_process');
+const fs = require('fs').promises;
+const path = require('path');
+
+const chalk = require('chalk');
+const inquirer = require('inquirer');
+
+/**
+ * Run a command and stream output to the console
+ *
+ * @param {string} command
+ * @param {string[]} args
+ * @param {object} options
+ */
+async function runAndStream(command, args, options) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ stdio: 'inherit',
+ ...options
+ });
+
+ child.on('close', (code) => {
+ if (code === 0) {
+ resolve(code);
+ } else {
+ reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`));
+ }
+ });
+
+ });
+}
+
+(async () => {
+ if (process.env.NODE_ENV !== 'development') {
+ console.log(chalk.yellow(`NODE_ENV is not development, skipping setup`));
+ return;
+ }
+
+ const coreFolder = path.join(__dirname, '../../ghost/core');
+ const rootFolder = path.join(__dirname, '../..');
+ const config = require('../../ghost/core/core/shared/config/loader').loadNconf({
+ customConfigPath: coreFolder
+ });
+
+ const dbClient = config.get('database:client');
+ const isUsingDocker = config.get('database:docker');
+
+ // Only reset data if we are using Docker
+ let resetData = false;
+
+ if (!dbClient.includes('mysql')) {
+ let mysqlSetup = false;
+ console.log(chalk.blue(`Attempting to setup MySQL via Docker`));
+ try {
+ await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')});
+ mysqlSetup = true;
+ } catch (err) {
+ console.error(chalk.red('Failed to run MySQL Docker container'), err);
+ console.error(chalk.red('Hint: is Docker installed and running?'));
+ }
+
+ if (mysqlSetup) {
+ resetData = true;
+ console.log(chalk.blue(`Adding MySQL credentials to config.local.json`));
+ const currentConfigPath = path.join(coreFolder, 'config.local.json');
+
+ let currentConfig;
+ try {
+ currentConfig = require(currentConfigPath);
+ } catch (err) {
+ currentConfig = {};
+ }
+
+ currentConfig.database = {
+ client: 'mysql',
+ docker: true,
+ connection: {
+ host: '127.0.0.1',
+ user: 'root',
+ password: 'root',
+ database: 'ghost'
+ }
+ };
+
+ try {
+ await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4));
+ } catch (err) {
+ console.error(chalk.red('Failed to write config.local.json'), err);
+ console.log(chalk.yellow(`Please add the following to config.local.json:\n`), JSON.stringify(currentConfig, null, 4));
+ process.exit(1);
+ }
+ }
+ } else {
+ if (isUsingDocker) {
+ const yesAll = process.argv.includes('-y');
+ const noAll = process.argv.includes('-n');
+ const {confirmed} =
+ yesAll ? {confirmed: true}
+ : (
+ noAll ? {confirmed: false}
+ : await inquirer.prompt({name: 'confirmed', type:'confirm', message: 'MySQL is running via Docker, do you want to reset the Docker container? This will delete all existing data.', default: false})
+ );
+
+ if (confirmed) {
+ console.log(chalk.yellow(`Resetting Docker container`));
+
+ try {
+ await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')});
+ resetData = true;
+ } catch (err) {
+ console.error(chalk.red('Failed to run MySQL Docker container'), err);
+ console.error(chalk.red('Hint: is Docker installed and running?'));
+ }
+ }
+ } else {
+ console.log(chalk.green(`MySQL already configured locally. Stop your local database and delete your "database" configuration in config.local.json to switch to Docker.`));
+ }
+ }
+
+ console.log(chalk.blue(`Running knex-migrator init`));
+ await runAndStream('yarn', ['knex-migrator', 'init'], {cwd: coreFolder});
+ if (process.argv.includes('--no-seed')) {
+ console.log(chalk.yellow(`Skipping seed data`));
+ console.log(chalk.yellow(`Done`));
+ return;
+ }
+ if (resetData) {
+ const xxl = process.argv.includes('--xxl');
+
+ if (xxl) {
+ console.log(chalk.blue(`Resetting all data (with xxl)`));
+ await runAndStream('yarn', ['reset:data:xxl'], {cwd: rootFolder});
+ } else {
+ console.log(chalk.blue(`Resetting all data`));
+ await runAndStream('yarn', ['reset:data'], {cwd: rootFolder});
+ }
+ }
+})();
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f42641eaafa..e09d3b804cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
name: CI
on:
pull_request:
+ types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches:
- main
@@ -12,7 +13,6 @@ on:
env:
FORCE_COLOR: 1
HEAD_COMMIT: ${{ github.sha }}
- GITHUB_CONTEXT: ${{ toJson(github) }}
CACHED_DEPENDENCY_PATHS: |
${{ github.workspace }}/node_modules
${{ github.workspace }}/apps/*/node_modules
@@ -43,6 +43,11 @@ jobs:
ref: ${{ env.HEAD_COMMIT }}
fetch-depth: 2
+ - name: Output GitHub context
+ run: echo "$GITHUB_CONTEXT"
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+
- name: Get metadata (push)
if: github.event_name == 'push'
run: |
@@ -58,7 +63,7 @@ jobs:
echo "BASE_COMMIT=$BASE_COMMIT" >> $GITHUB_ENV
- name: Determine added packages
- uses: dorny/paths-filter@v2.11.1
+ uses: dorny/paths-filter@v2.12.0
id: added
with:
filters: |
@@ -144,7 +149,7 @@ jobs:
echo "$EOF" >> "$GITHUB_ENV"
- name: Nx cache
- uses: actions/cache@v3
+ uses: actions/cache@v4
id: cache_nx
with:
path: .nxcache
@@ -152,7 +157,7 @@ jobs:
restore-keys: ${{needs.job_get_metadata.outputs.is_main == 'false' && env.NX_CACHE_RESTORE_KEYS || 'nx-never-restore'}}
- name: Check dependency cache
- uses: actions/cache@v3
+ uses: actions/cache@v4
id: cache_dependencies
with:
path: ${{ env.CACHED_DEPENDENCY_PATHS }}
@@ -160,14 +165,14 @@ jobs:
restore-keys: ${{needs.job_get_metadata.outputs.is_main == 'false' && env.DEPENDENCY_CACHE_RESTORE_KEYS || 'dep-never-restore'}}
- name: Check build cache
- uses: actions/cache@v3
+ uses: actions/cache@v4
id: cache_built_packages
with:
path: ${{ env.CACHED_BUILD_PATHS }}
key: ${{ env.HEAD_COMMIT }}
- name: Set up Node
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -192,7 +197,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 100
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -203,7 +208,7 @@ jobs:
env:
DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }}
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
with:
path: ghost/**/.eslintcache
key: eslint-cache
@@ -228,7 +233,7 @@ jobs:
|| needs.job_get_metadata.outputs.changed_core == 'true'
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
with:
node-version: "18.12.1"
@@ -252,7 +257,7 @@ jobs:
COVERAGE: true
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
with:
node-version: "18.12.1"
@@ -269,7 +274,7 @@ jobs:
- name: Merge Admin test coverage
run: yarn ember coverage-merge
working-directory: ghost/admin
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
name: admin-coverage
path: ghost/*/coverage/cobertura-coverage.xml
@@ -285,7 +290,7 @@ jobs:
name: Browser tests
timeout-minutes: 60
runs-on:
- labels: ubuntu-latest-4-cores
+ labels: ubuntu-latest
needs: [job_get_metadata, job_install_deps]
if: needs.job_get_metadata.outputs.changed_any_code == 'true' && (needs.job_get_metadata.outputs.is_main == 'true' || needs.job_get_metadata.outputs.has_browser_tests_label == 'true')
concurrency:
@@ -294,7 +299,7 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: true
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -321,7 +326,7 @@ jobs:
- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
name: Check if Playwright browser is cached
id: playwright-cache
with:
@@ -352,26 +357,74 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
if: always()
with:
name: browser-tests-playwright-report
path: ghost/core/playwright-report
retention-days: 30
+ job_perf-tests:
+ runs-on:
+ labels: ubuntu-latest-4-cores
+ needs: [job_get_metadata, job_install_deps]
+ if: needs.job_get_metadata.outputs.changed_core == 'true' && needs.job_get_metadata.outputs.is_main == 'true'
+ name: Performance tests
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ - uses: actions/setup-node@v4
+ env:
+ FORCE_COLOR: 0
+ with:
+ node-version: '18.12.1'
+
+ - name: Restore caches
+ uses: ./.github/actions/restore-cache
+ env:
+ DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }}
+
+ - name: Install hyperfine
+ run: |
+ export HYPERFINE_VERSION=1.18.0
+ wget https://github.com/sharkdp/hyperfine/releases/download/v$HYPERFINE_VERSION/hyperfine-v$HYPERFINE_VERSION-x86_64-unknown-linux-gnu.tar.gz
+ tar -zxvf hyperfine-v$HYPERFINE_VERSION-x86_64-unknown-linux-gnu.tar.gz
+ mv hyperfine-v$HYPERFINE_VERSION-x86_64-unknown-linux-gnu/hyperfine /usr/local/bin
+ chmod +x /usr/local/bin/hyperfine
+
+ - name: Run hyperfine on boot
+ working-directory: ghost/core
+ run: hyperfine --show-output --warmup 3 'GHOST_CI_SHUTDOWN_AFTER_BOOT=1 node index.js' --export-json boot-perf.json
+
+ - name: Convert data
+ working-directory: ghost/core
+ run: |
+ jq '[{ name: "Boot time", unit: "s", value: .results[0].median, range: ((.results[0].max - .results[0].min) | tostring) }]' < boot-perf.json > boot-perf-formatted.json
+
+ - name: Run analysis
+ uses: benchmark-action/github-action-benchmark@v1.20.3
+ with:
+ tool: 'customSmallerIsBetter'
+ output-file-path: ghost/core/boot-perf-formatted.json
+ benchmark-data-dir-path: ""
+ gh-repository: github.com/TryGhost/Ghost-Benchmarks
+ github-token: ${{ secrets.CANARY_DOCKER_BUILD }}
+ auto-push: true
+
job_unit-tests:
runs-on: ubuntu-latest
needs: [job_get_metadata, job_install_deps]
if: needs.job_get_metadata.outputs.changed_any_code == 'true'
strategy:
matrix:
- node: [ '18.12.1' ]
+ node: [ '18.12.1', '20.11.1' ]
name: Unit tests (Node ${{ matrix.node }})
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -384,7 +437,7 @@ jobs:
- run: yarn nx affected -t test:unit --base=${{ needs.job_get_metadata.outputs.BASE_COMMIT }}
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
if: startsWith(matrix.node, '18')
with:
name: unit-coverage
@@ -403,7 +456,7 @@ jobs:
if: needs.job_get_metadata.outputs.changed_core == 'true'
strategy:
matrix:
- node: [ '18.12.1' ]
+ node: [ '18.12.1', '20.11.1' ]
env:
- DB: mysql8
NODE_ENV: testing-mysql
@@ -418,7 +471,7 @@ jobs:
name: Database tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }})
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -467,7 +520,7 @@ jobs:
endTime="$(date +%s)"
echo "test_time=$(($endTime-$startTime))" >> $GITHUB_ENV
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
if: startsWith(matrix.node, '18') && contains(matrix.env.DB, 'mysql')
with:
name: e2e-coverage
@@ -539,7 +592,9 @@ jobs:
name: Regression tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }})
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ with:
+ submodules: true
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -590,7 +645,7 @@ jobs:
CI: true
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -604,7 +659,7 @@ jobs:
- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
name: Check if Playwright browser is cached
id: playwright-cache
with:
@@ -621,7 +676,7 @@ jobs:
- name: Upload test results
if: always()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: admin-x-settings-playwright-report
path: apps/admin-x-settings/playwright-report
@@ -643,7 +698,7 @@ jobs:
CI: true
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -657,7 +712,7 @@ jobs:
- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
name: Check if Playwright browser is cached
id: playwright-cache
with:
@@ -674,7 +729,7 @@ jobs:
- name: Upload test results
if: always()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: comments-ui-playwright-report
path: apps/comments-ui/playwright-report
@@ -696,7 +751,7 @@ jobs:
CI: true
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -710,7 +765,7 @@ jobs:
- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
name: Check if Playwright browser is cached
id: playwright-cache
with:
@@ -727,7 +782,7 @@ jobs:
- name: Upload test results
if: always()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: signup-form-playwright-report
path: apps/signup-form/playwright-report
@@ -750,7 +805,7 @@ jobs:
with:
fetch-depth: 0
submodules: true
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -779,7 +834,7 @@ jobs:
echo "V4_DIR=$DIR" >> $GITHUB_ENV
ghost install v4 --local -d $DIR
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
env:
FORCE_COLOR: 0
with:
@@ -789,6 +844,13 @@ jobs:
run: |
ghost update -f -d $V4_DIR --archive $(pwd)/ghost/core/ghost.tgz
+ - name: Save Ghost CLI Debug Logs
+ if: failure()
+ uses: actions/upload-artifact@v3
+ with:
+ name: ghost-cli-debug-logs
+ path: /home/runner/.ghost/logs/
+
- name: Clean Install
run: |
DIR=$(mktemp -d)
@@ -825,7 +887,7 @@ jobs:
- name: Restore Admin coverage
if: contains(needs.job_admin-tests.result, 'success')
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
name: admin-coverage
@@ -842,7 +904,7 @@ jobs:
- name: Restore E2E coverage
if: contains(needs.job_database-tests.result, 'success')
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
name: e2e-coverage
diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml
deleted file mode 100644
index 9e8e243cf9a..00000000000
--- a/.github/workflows/i18n.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: i18n check
-on:
- pull_request_target:
- types: [opened]
- paths:
- - 'ghost/i18n/locales/**'
-jobs:
- create-label:
- runs-on: ubuntu-latest
- if: github.repository_owner == 'TryGhost'
- name: Add i18n label
- steps:
- - uses: actions/github-script@v6
- with:
- script: |
- github.rest.issues.addLabels({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- labels: ["i18n"]
- })
diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml
index 18d0adbcf1b..885fa98f7e5 100644
--- a/.github/workflows/migration-review.yml
+++ b/.github/workflows/migration-review.yml
@@ -38,6 +38,7 @@ jobs:
- [ ] Uses the correct utils
- [ ] Contains a minimal changeset
- [ ] Does not mix DDL/DML operations
+ - [ ] Tested in MySQL and SQLite
### Schema changes
diff --git a/.gitignore b/.gitignore
index 3162730395a..b3e179754d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -83,6 +83,7 @@ typings/
*.db
*.db-journal
+/ghost/core/test-results/
/ghost/core/core/server/data/export/exported*
/ghost/core/content/tmp/*
/ghost/core/content/data/*
@@ -133,7 +134,7 @@ Caddyfile
/apps/comments-ui/umd
/apps/comments-ui/playwright-report
/ghost/comments-ui/playwright/.cache/
-/ghost/comments-ui/test-results/
+/apps/comments-ui/test-results/
# Portal
!/apps/portal/.env
@@ -166,3 +167,8 @@ tsconfig.tsbuildinfo
/apps/admin-x-settings/test-results/
/apps/admin-x-settings/playwright-report/
/apps/admin-x-settings/playwright/.cache/
+
+# Tinybird
+.tinyb
+.venv
+.diff_tmp
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 3e00ee2b0b8..698afa15d2b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,6 +4,21 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Backend",
+ "skipFiles": [
+ "/**"
+ ],
+ "program": "${workspaceFolder}/.github/scripts/dev.js",
+ "args": [
+ "--ghost"
+ ],
+ "autoAttachChildProcesses": true,
+ "outputCapture": "std",
+ "console": "integratedTerminal",
+ },
{
"type": "node",
"request": "launch",
diff --git a/LICENSE b/LICENSE
index b52cfae1945..ce0968e726b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2013-2023 Ghost Foundation
+Copyright (c) 2013-2024 Ghost Foundation
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
diff --git a/PRIVACY.md b/PRIVACY.md
index 5d6bd677029..667393649c3 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -44,8 +44,4 @@ Ghost outputs basic meta tags to allow rich snippets of your content to be recog
- Schema.org - http://schema.org/docs/documents.html
- Open Graph - http://ogp.me/
-- Twitter cards - https://dev.twitter.com/cards/overview
-
-### Default Theme
-
-The default theme which comes with Ghost loads a copy of jQuery from the jQuery Foundation's [public CDN](https://code.jquery.com/jquery-3.4.1.min.js).
+- Twitter cards - https://dev.twitter.com/cards/overview
\ No newline at end of file
diff --git a/README.md b/README.md
index dff64aaeda9..7fa794ca2ce 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
Twitter
-
+
@@ -29,9 +29,7 @@
-
- Love open source? We're hiring DevOps engineers to work on Ghost full-time.
-
+
@@ -84,7 +82,7 @@ For anyone wishing to contribute to Ghost or to hack/customize core files we rec
# Ghost sponsors
-We'd like to extend big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
+A big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
**[DigitalOcean](https://m.do.co/c/9ff29836d717)** • **[Fastly](https://www.fastly.com/)**
@@ -92,12 +90,13 @@ We'd like to extend big thanks to our sponsors and partners who make Ghost possi
# Getting help
-You can find answers to a huge variety of questions, along with a large community of helpful developers over on the [Ghost forum](https://forum.ghost.org/) - replies are generally very quick. **Ghost(Pro)** customers also have access to 24/7 email support.
+Everyone can get help and support from a large community of developers over on the [Ghost forum](https://forum.ghost.org/). **Ghost(Pro)** customers have access to 24/7 email support.
-To stay up to date with all the latest news and product updates, make sure you [subscribe to our blog](https://ghost.org/blog/) — or you can always follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
+To stay up to date with all the latest news and product updates, make sure you [subscribe to our changelog newsletter](https://ghost.org/changelog/) — or follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
# Copyright & license
-Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
+Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
+Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
diff --git a/apps/admin-x-activitypub/.eslintignore b/apps/admin-x-activitypub/.eslintignore
new file mode 100644
index 00000000000..9944eccea22
--- /dev/null
+++ b/apps/admin-x-activitypub/.eslintignore
@@ -0,0 +1 @@
+tailwind.config.cjs
diff --git a/apps/admin-x-activitypub/.eslintrc.cjs b/apps/admin-x-activitypub/.eslintrc.cjs
new file mode 100644
index 00000000000..919b0f2cdf6
--- /dev/null
+++ b/apps/admin-x-activitypub/.eslintrc.cjs
@@ -0,0 +1,56 @@
+/* eslint-env node */
+module.exports = {
+ root: true,
+ extends: [
+ 'plugin:ghost/ts',
+ 'plugin:react/recommended',
+ 'plugin:react-hooks/recommended'
+ ],
+ plugins: [
+ 'ghost',
+ 'react-refresh',
+ 'tailwindcss'
+ ],
+ settings: {
+ react: {
+ version: 'detect'
+ }
+ },
+ rules: {
+ // sort multiple import lines into alphabetical groups
+ 'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
+ memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
+ }],
+
+ // 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', {config: 'tailwind.config.cjs'}],
+ 'tailwindcss/enforces-negative-arbitrary-values': ['warn', {config: 'tailwind.config.cjs'}],
+ 'tailwindcss/enforces-shorthand': ['warn', {config: 'tailwind.config.cjs'}],
+ 'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}],
+ 'tailwindcss/no-arbitrary-value': 'off',
+ 'tailwindcss/no-custom-classname': 'off',
+ 'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}]
+ }
+};
diff --git a/apps/admin-x-activitypub/.gitignore b/apps/admin-x-activitypub/.gitignore
new file mode 100644
index 00000000000..68565785a7f
--- /dev/null
+++ b/apps/admin-x-activitypub/.gitignore
@@ -0,0 +1,3 @@
+dist
+playwright-report
+test-results
diff --git a/apps/admin-x-activitypub/index.html b/apps/admin-x-activitypub/index.html
new file mode 100644
index 00000000000..60bd860b4a7
--- /dev/null
+++ b/apps/admin-x-activitypub/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ AdminX Standalone
+
+
+
+
+
+
diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json
new file mode 100644
index 00000000000..d73be09910f
--- /dev/null
+++ b/apps/admin-x-activitypub/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@tryghost/admin-x-activitypub",
+ "version": "0.0.1",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/TryGhost/Ghost/tree/main/apps/admin-x-activitypub"
+ },
+ "author": "Ghost Foundation",
+ "files": [
+ "LICENSE",
+ "README.md",
+ "dist/"
+ ],
+ "main": "./dist/admin-x-activitypub.umd.cjs",
+ "module": "./dist/admin-x-activitypub.js",
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "scripts": {
+ "dev": "vite build --watch",
+ "dev:start": "vite",
+ "build": "tsc && vite build",
+ "lint": "yarn run lint:code && yarn 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:unit": "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 yarn test:acceptance --headed",
+ "test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.38.1",
+ "@testing-library/react": "14.3.1",
+ "@tryghost/admin-x-design-system": "0.0.0",
+ "@tryghost/admin-x-framework": "0.0.0",
+ "@types/jest": "29.5.12",
+ "@types/react": "18.3.3",
+ "@types/react-dom": "18.3.0",
+ "jest": "29.7.0",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "ts-jest": "29.1.5"
+ },
+ "nx": {
+ "targets": {
+ "build": {
+ "dependsOn": [
+ "^build"
+ ]
+ },
+ "dev": {
+ "dependsOn": [
+ "^build"
+ ]
+ },
+ "test:unit": {
+ "dependsOn": [
+ "^build",
+ "test:unit"
+ ]
+ },
+ "test:acceptance": {
+ "dependsOn": [
+ "^build",
+ "test:acceptance"
+ ]
+ }
+ }
+ }
+}
diff --git a/apps/admin-x-activitypub/playwright.config.mjs b/apps/admin-x-activitypub/playwright.config.mjs
new file mode 100644
index 00000000000..8fa59553e54
--- /dev/null
+++ b/apps/admin-x-activitypub/playwright.config.mjs
@@ -0,0 +1,3 @@
+import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
+
+export default adminXPlaywrightConfig();
diff --git a/apps/admin-x-activitypub/postcss.config.cjs b/apps/admin-x-activitypub/postcss.config.cjs
new file mode 100644
index 00000000000..8799f4acf82
--- /dev/null
+++ b/apps/admin-x-activitypub/postcss.config.cjs
@@ -0,0 +1 @@
+module.exports = require('@tryghost/admin-x-design-system/postcss.config.cjs');
diff --git a/apps/admin-x-activitypub/public/styles/reader.css b/apps/admin-x-activitypub/public/styles/reader.css
new file mode 100644
index 00000000000..4cdaee420e1
--- /dev/null
+++ b/apps/admin-x-activitypub/public/styles/reader.css
@@ -0,0 +1,1928 @@
+
+.gh-whats-new-canvas .gh-canvas-header-content {
+ margin-bottom: -1px;
+ padding: 8px 0 16px;
+ align-items: center;
+}
+
+.gh-whats-new {
+ flex-grow: 2;
+ color: var(--darkgrey);
+ font-size: 1.5rem;
+ letter-spacing: 0;
+ margin-top: -24px;
+}
+
+.gh-whats-new-heading {
+ display: flex;
+ align-items: center;
+ font-size: 1.5rem;
+ letter-spacing: 0;
+ line-height: 1.3em;
+ font-weight: 700;
+ margin: 0;
+}
+
+.gh-whats-new-heading svg {
+ width: 20px;
+ height: 20px;
+ margin-top: -2px;
+ margin-right: 12px;
+}
+
+.gh-whats-new-heading svg path {
+ fill: var(--pink);
+}
+
+.gh-wn-header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin: -32px -32px 0;
+ padding: 18px 18px 12px;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ overflow: hidden;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background: var(--pink);
+ background: linear-gradient(135deg, color-mod(var(--pink) h(-10) s(+5%) l(-10%)) 0%, rgba(173,38,180,1) 100%);
+}
+
+.gh-wn-header .background-img {
+ position: absolute;
+ top: -30px;
+ left: 0;
+}
+
+.gh-wn-header h2 {
+ font-size: 1.3rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: #FFF;
+ margin: 0 8px 4px;
+}
+
+.gh-wn-header svg path {
+ fill: #fff;
+}
+
+.gh-wn-close {
+ stroke: #FFF;
+ opacity: 0.6;
+ transition: all 0.2s ease-in-out;
+}
+
+.gh-wn-close:hover {
+ opacity: 1.0;
+}
+
+.gh-wn-entry {
+ margin: 0 0 5vmin;
+ padding-bottom: 5vmin;
+ width: 100%;
+ border-bottom: 1px solid var(--lightgrey-l2);
+ color: inherit;
+ text-decoration: none;
+}
+
+.gh-wn-content {
+ max-width: 620px;
+}
+
+.gh-whats-new-canvas .gh-wn-content {
+ margin: 0 auto;
+}
+
+.gh-wn-entry h4 {
+ font-size: 1.2rem;
+ font-weight: 500;
+ letter-spacing: 0;
+ text-transform: uppercase;
+ margin: 24px 0 4px;
+ color: var(--midlightgrey);
+}
+
+.gh-wn-entry h1 {
+ font-size: 3.7rem;
+ line-height: 1.3em;
+ font-weight: 700;
+ letter-spacing: -0.021em;
+ color: var(--black);
+ margin-bottom: 16px;
+}
+
+.gh-whats-new-canvas .gh-wn-entry h1,
+.gh-whats-new-canvas .gh-wn-entry h4 {
+ max-width: 620px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.gh-wn-entry h2 {
+ border-bottom: none;
+ font-size: 1.9rem;
+ padding-bottom: 0;
+ margin-bottom: 20px;
+}
+
+.gh-wn-entry p,
+.gh-wn-entry li {
+ line-height: 1.6em;
+}
+
+.gh-wn-entry li {
+ margin-bottom: 12px;
+}
+
+.gh-wn-entry p {
+ margin: 0 0 20px;
+ padding: 0;
+}
+
+.gh-wn-entry figure {
+ margin-bottom: 24px;
+ overflow: hidden;
+}
+
+.gh-wn-entry img {
+ height: auto;
+}
+
+.gh-wn-entry hr {
+ border-top: 1px solid var(--whitegrey-l1);
+ margin: 24px 0;
+}
+
+
+/* Bookmark card details */
+.gh-wn-entry .kg-bookmark-card {
+ margin-bottom: 20px;
+}
+
+.gh-wn-entry .kg-bookmark-container {
+ display: flex;
+ font-family: var(--font-family);
+ color: var(--darkgrey);
+ text-decoration: none;
+ min-height: 148px;
+ box-shadow: 0px 2px 5px -1px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.09);
+ border-radius: 3px;
+}
+
+.gh-wn-entry .kg-bookmark-content {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: 16px;
+}
+
+.gh-wn-entry .kg-bookmark-title {
+ font-size: 1.3rem;
+ line-height: 1.5em;
+ font-weight: 600;
+ color: color(var(--midgrey) l(-30%));
+}
+
+.gh-wn-entry .kg-bookmark-container:hover .kg-bookmark-title {
+ color: var(--blue);
+}
+
+.gh-wn-entry .kg-bookmark-description {
+ display: -webkit-box;
+ font-size: 1.25rem;
+ line-height: 1.5em;
+ color: color(var(--midgrey) l(-10%));
+ font-weight: 400;
+ margin-top: 12px;
+ max-height: 36px;
+ overflow-y: hidden;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+.gh-wn-entry .kg-bookmark-thumbnail {
+ position: relative;
+ min-width: 40%;
+ max-height: 100%;
+}
+
+.gh-wn-entry .kg-bookmark-thumbnail img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 0 3px 3px 0;
+}
+
+.gh-wn-entry .kg-bookmark-metadata {
+ display: flex;
+ align-items: center;
+ font-size: 1.25rem;
+ font-weight: 400;
+ color: color(var(--midgrey) l(-10%));
+ margin-top: 14px;
+ flex-wrap: wrap;
+}
+
+.gh-wn-entry .kg-bookmark-icon {
+ width: 18px;
+ height: 18px;
+ margin-right: 8px;
+}
+
+.gh-wn-entry .kg-bookmark-author {
+ line-height: 1.5em;
+}
+
+.gh-wn-entry .kg-bookmark-author:after {
+ content: "•";
+ margin: 0 6px;
+}
+
+.gh-wn-entry .kg-bookmark-publisher {
+ overflow: hidden;
+ line-height: 1.5em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 160px;
+}
+
+.gh-wn-entry .gh-wn-footer {
+ margin: 0 -32px -32px;
+ padding: 14px 32px 16px;
+ border-top: 1px solid var(--whitegrey);
+ justify-content: space-between;
+}
+
+.gh-wn-footer {
+ position: relative;
+ margin-top: 14px;
+ margin-bottom: -13px;
+}
+
+.gh-wn-footer:before {
+ position: absolute;
+ content: "";
+ top: -14px;
+ left: -32px;
+ right: -32px;
+ height: 6px;
+ background: rgba(255,255,255,0);
+ box-shadow:
+ 0 -0.3px 1px rgba(0, 0, 0, 0.03),
+ 0 -4px 7px rgba(0, 0, 0, 0.06);
+}
+
+.gh-about-container {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ grid-gap: 80px;
+}
+
+.gh-whats-new-canvas .gh-about-container {
+ display: flex;
+ grid-template-columns: unset;
+ grid-gap: unset;
+ margin: 0 auto;
+ max-width: 920px;
+ margin-top: 60px;
+}
+
+.gh-about-container h2 {
+ font-size: 1.65rem;
+ line-height: 1.4em;
+ font-weight: 600;
+ border-bottom: 1px solid var(--lightgrey-l2);
+ padding-bottom: 12px;
+ margin-bottom: 12px;
+}
+
+.gh-about-box {
+ position: sticky;
+ top: 96px;
+ right: 0;
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ height: max-content;
+ border-radius: 3px;
+ min-width: 300px;
+}
+
+.gh-about-box.grey {
+ border: none;
+ background: var(--main-color-content-greybg);
+}
+
+@media (max-width: 1380px) {
+ .gh-wn-content {
+ max-width: 36vw;
+ }
+}
+
+@media (max-width: 1120px) {
+ .gh-wn-content {
+ max-width: 680px;
+ }
+
+ .gh-about-box {
+ position: relative;
+ top: unset;
+ right: unset;
+ }
+
+ .gh-about-container {
+ grid-template-columns: unset;
+ grid-template-rows: auto;
+ grid-gap: 32px;
+ }
+
+ .gh-whats-new {
+ grid-row: 3/4;
+ }
+
+
+ .gh-about-header-actions a {
+ display: none;
+ }
+
+ .gh-wn-entry iframe {
+ max-width: 100%;
+ }
+}
+
+/* Custom card styles
+/* ---------------------------------------------------------- */
+
+.gh-whats-new .kg-audio-card {
+ display: flex;
+ width: 100%;
+ min-height: 96px;
+ border-radius: 3px;
+ box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25);
+ margin-bottom: 1.5em;
+}
+
+.gh-whats-new .kg-audio-card+.gh-whats-new .kg-audio-card {
+ margin-top: 1em;
+}
+
+.gh-whats-new .kg-audio-thumbnail {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 80px;
+ min-width: 80px;
+ margin: 8px;
+ background: transparent;
+ object-fit: cover;
+ aspect-ratio: 1/1;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-audio-thumbnail.placeholder {
+ background: var(--accent-color);
+}
+
+.gh-whats-new .kg-audio-thumbnail.placeholder svg {
+ width: 24px;
+ height: 24px;
+ fill: white;
+}
+
+.gh-whats-new .kg-audio-player-container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ flex: 1;
+ --seek-before-width: 0%;
+ --volume-before-width: 100%;
+ --buffered-width: 0%;
+}
+
+.gh-whats-new .kg-audio-title {
+ width: 100%;
+ margin: 8px 0 0 0;
+ padding: 8px 12px;
+ border: none;
+ font-family: inherit;
+ font-size: 1.15em;
+ font-weight: 700;
+ line-height: 1.15em;
+ background: transparent;
+}
+
+.gh-whats-new .kg-audio-player {
+ display: flex;
+ flex-grow: 1;
+ align-items: center;
+ padding: 8px 12px;
+}
+
+.gh-whats-new .kg-audio-current-time {
+ min-width: 38px;
+ padding: 0 4px;
+ font-family: inherit;
+ font-size: .85em;
+ font-weight: 500;
+ line-height: 1.4em;
+ white-space: nowrap;
+}
+
+.gh-whats-new .kg-audio-time {
+ width: 56px;
+ color: #ababab;
+ font-family: inherit;
+ font-size: .85em;
+ font-weight: 500;
+ line-height: 1.4em;
+ white-space: nowrap;
+}
+
+.gh-whats-new .kg-audio-duration {
+ padding: 0 4px;
+}
+
+.gh-whats-new .kg-audio-play-icon,
+.gh-whats-new .kg-audio-pause-icon {
+ position: relative;
+ bottom: 1px;
+ padding: 0px 4px 0 0;
+ font-size: 0;
+ background: transparent;
+}
+
+.gh-whats-new .kg-audio-hide {
+ display: none !important;
+}
+
+.gh-whats-new .kg-audio-play-icon svg,
+.gh-whats-new .kg-audio-pause-icon svg {
+ width: 14px;
+ height: 14px;
+ fill: currentColor;
+}
+
+.gh-whats-new .kg-audio-seek-slider {
+ flex-grow: 1;
+ margin: 0 4px;
+ width: 100%;
+}
+
+@media (max-width: 640px) {
+ .gh-whats-new .kg-audio-seek-slider {
+ display: none;
+ }
+}
+
+.gh-whats-new .kg-audio-playback-rate {
+ min-width: 37px;
+ padding: 0 4px;
+ font-family: inherit;
+ font-size: .85em;
+ font-weight: 600;
+ line-height: 1.4em;
+ text-align: left;
+ background: transparent;
+ white-space: nowrap;
+}
+
+@media (max-width: 640px) {
+ .gh-whats-new .kg-audio-playback-rate {
+ padding-left: 8px;
+ }
+}
+
+.gh-whats-new .kg-audio-mute-icon,
+.gh-whats-new .kg-audio-unmute-icon {
+ position: relative;
+ bottom: -1px;
+ padding: 0 4px;
+ font-size: 0;
+ background: transparent;
+}
+
+@media (max-width: 640px) {
+ .gh-whats-new .kg-audio-mute-icon,
+ .gh-whats-new .kg-audio-unmute-icon {
+ margin-left: auto;
+ }
+}
+
+.gh-whats-new .kg-audio-mute-icon svg,
+.gh-whats-new .kg-audio-unmute-icon svg {
+ width: 16px;
+ height: 16px;
+ fill: currentColor;
+}
+
+.gh-whats-new .kg-audio-volume-slider {
+ flex-grow: 1;
+ width: 100%;
+ min-width: 50px;
+ max-width: 80px;
+}
+
+@media (max-width: 400px) {
+ .gh-whats-new .kg-audio-volume-slider {
+ display: none;
+ }
+}
+
+.gh-whats-new .kg-audio-seek-slider::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ width: var(--seek-before-width) !important;
+ height: 4px;
+ cursor: pointer;
+ background-color: currentColor;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-audio-volume-slider::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ width: var(--volume-before-width) !important;
+ height: 4px;
+ cursor: pointer;
+ background-color: currentColor;
+ border-radius: 2px;
+}
+
+/* Resetting browser styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-audio-player-container input[type=range] {
+ position: relative;
+ -webkit-appearance: none;
+ background: transparent;
+}
+
+.gh-whats-new .kg-audio-player-container input[type=range]:focus {
+ outline: none;
+}
+
+.gh-whats-new .kg-audio-player-container input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+}
+
+.gh-whats-new .kg-audio-player-container input[type=range]::-ms-track {
+ cursor: pointer;
+ border-color: transparent;
+ color: transparent;
+ background: transparent;
+}
+
+.gh-whats-new .kg-audio-player-container button {
+ display: flex;
+ align-items: center;
+ border: 0;
+ cursor: pointer;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"] {
+ height: auto;
+ padding: 0;
+ border: 0;
+}
+
+/* Chrome & Safari styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ background: rgba(124, 139, 154, 0.25);
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-webkit-slider-thumb {
+ position: relative;
+ box-sizing: content-box;
+ width: 13px;
+ height: 13px;
+ margin: -5px 0 0 0;
+ border: 0;
+ cursor: pointer;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24);
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]:active::-webkit-slider-thumb {
+ transform: scale(1.2);
+}
+
+/* Firefox styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ background: rgba(124, 139, 154, 0.25);
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-moz-range-progress {
+ background: currentColor;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-moz-range-thumb {
+ box-sizing: content-box;
+ width: 13px;
+ height: 13px;
+ border: 0;
+ cursor: pointer;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24);
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]:active::-moz-range-thumb {
+ transform: scale(1.2);
+}
+
+/* Edge & IE styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-track {
+ width: 100%;
+ height: 3px;
+ border: solid transparent;
+ color: transparent;
+ cursor: pointer;
+ background: transparent;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-fill-lower {
+ background: #fff;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-fill-upper {
+ background: currentColor;
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-thumb {
+ box-sizing: content-box;
+ width: 13px;
+ height: 13px;
+ border: 0;
+ cursor: pointer;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24);
+}
+
+.gh-whats-new .kg-audio-player-container input[type="range"]:active::-ms-thumb {
+ transform: scale(1.2);
+}
+
+.gh-whats-new .kg-product-card {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ width: 100%;
+ margin-bottom: 1.5em;
+}
+
+.gh-whats-new .kg-product-card-container {
+ display: grid;
+ grid-template-columns: auto min-content;
+ align-items: center;
+ grid-row-gap: 16px;
+ background: transparent;
+ max-width: 550px;
+ width: 100%;
+}
+
+.gh-whats-new .kg-product-card-image {
+ grid-column: 1 / 3;
+ justify-self: center;
+}
+
+.gh-whats-new .kg-product-card-title-container {
+ grid-column: 1 / 2;
+}
+
+.gh-whats-new .kg-product-card h4.kg-product-card-title {
+ font-family: var(--font-family);
+ text-decoration: none;
+ font-weight: 700;
+ font-size: 1.4em;
+ margin-top: 0;
+ margin-bottom: 0;
+ line-height: 1.15em;
+ text-transform: none;
+ color: inherit;
+}
+
+.gh-whats-new .kg-product-card-description {
+ grid-column: 1 / 3;
+}
+
+.gh-whats-new .kg-product-card .kg-product-card-description p,
+.gh-whats-new .kg-product-card .kg-product-card-description ol,
+.gh-whats-new .kg-product-card .kg-product-card-description ul {
+ font-family: var(--font-family);
+ font-size: 0.9em;
+ line-height: 1.5em;
+ opacity: .7;
+}
+
+.gh-whats-new .kg-product-card .kg-product-card-description p:not(:first-of-type) {
+ margin-top: 0.8em;
+ margin-bottom: 0;
+}
+
+.gh-whats-new .kg-product-card .kg-product-card-description p:first-of-type {
+ margin-top: -4px;
+}
+
+.gh-whats-new .kg-product-card .kg-product-card-description ul,
+.gh-whats-new .kg-product-card .kg-product-card-description ol {
+ margin-top: 0.95em;
+}
+
+.gh-whats-new .kg-product-card .kg-product-card-description li+li {
+ margin-top: 0.2em;
+}
+
+.gh-whats-new .kg-product-card-rating {
+ display: flex;
+ align-items: center;
+ grid-column: 2 / 3;
+ align-self: start;
+ justify-self: end;
+ padding-left: 16px;
+}
+
+@media (max-width: 400px) {
+ .gh-whats-new .kg-product-card-title-container {
+ grid-column: 1 / 3;
+ }
+
+ .gh-whats-new .kg-product-card-rating {
+ grid-column: 1 / 3;
+ justify-self: start;
+ margin-top: -15px;
+ padding-left: 0;
+ }
+}
+
+.gh-whats-new .kg-product-card-rating-star {
+ height: 28px;
+ width: 20px;
+}
+
+.gh-whats-new .kg-product-card-rating-star svg {
+ width: 16px;
+ height: 16px;
+ fill: currentColor;
+ opacity: 0.15;
+}
+
+.gh-whats-new .kg-product-card-rating-star svg path {
+ fill: unset;
+}
+
+.gh-whats-new .kg-product-card-rating-active.kg-product-card-rating-star svg {
+ opacity: 1;
+}
+
+.gh-whats-new .kg-product-card a.kg-product-card-button {
+ justify-content: center;
+ grid-column: 1 / 3;
+ display: flex;
+ position: static;
+ align-items: center;
+ font-family: var(--font-family);
+ font-size: 0.95em;
+ font-weight: 600;
+ line-height: 1em;
+ text-decoration: none;
+ width: 100%;
+ height: 2.4em;
+ border-radius: 5px;
+ padding: 0 1.2em;
+ transition: opacity 0.2s ease-in-out;
+ margin: 0;
+}
+
+.gh-whats-new .kg-product-card a.kg-product-card-btn-accent {
+ background-color: var(--accent-color);
+ color: #fff;
+}
+
+.gh-whats-new .kg-blockquote-alt {
+ font-size: 1.5em;
+ font-style: italic;
+ line-height: 1.7em;
+ text-align: center;
+ padding: 0 2.5em;
+}
+
+@media (max-width: 800px) {
+ .gh-whats-new .kg-blockquote-alt {
+ font-size: 1.4em;
+ padding-left: 2em;
+ padding-right: 2em;
+ }
+}
+
+@media (max-width: 600px) {
+ .gh-whats-new .kg-blockquote-alt {
+ font-size: 1.2em;
+ padding-left: 1.75em;
+ padding-right: 1.75em;
+ }
+}
+
+.gh-whats-new .kg-button-card {
+ display: flex;
+ position: static;
+ align-items: center;
+ width: 100%;
+ justify-content: flex-start;
+ padding: 30px 0;
+}
+
+.gh-whats-new .kg-button-card.kg-align-left {
+ justify-content: flex-start;
+}
+
+.gh-whats-new .kg-button-card a.kg-btn {
+ display: flex;
+ position: static;
+ align-items: center;
+ padding: 0 1.2em;
+ height: 2.4em;
+ line-height: 1em;
+ font-family: var(--font-family);
+ font-size: 0.95em;
+ font-weight: 600;
+ text-decoration: none;
+ border-radius: 5px;
+ transition: opacity 0.2s ease-in-out;
+}
+
+.gh-whats-new .kg-button-card a.kg-btn:hover {
+ opacity: 0.85;
+}
+
+.gh-whats-new .kg-button-card a.kg-btn-accent {
+ background-color: var(--accent-color);
+ color: #fff;
+}
+
+.gh-whats-new .kg-callout-card {
+ display: flex;
+ padding: 1.2em 1.6em;
+ border-radius: 3px;
+}
+
+.gh-whats-new .kg-callout-card-grey {
+ background: rgba(124, 139, 154, 0.13);
+}
+
+.gh-whats-new .kg-callout-card-white {
+ background: transparent;
+ box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25);
+}
+
+.gh-whats-new .kg-callout-card-blue {
+ background: rgba(33, 172, 232, 0.12);
+}
+
+.gh-whats-new .kg-callout-card-green {
+ background: rgba(52, 183, 67, 0.12);
+}
+
+.gh-whats-new .kg-callout-card-yellow {
+ background: rgba(240, 165, 15, 0.13);
+}
+
+.gh-whats-new .kg-callout-card-red {
+ background: rgba(209, 46, 46, 0.11);
+}
+
+.gh-whats-new .kg-callout-card-pink {
+ background: rgba(225, 71, 174, 0.11);
+}
+
+.gh-whats-new .kg-callout-card-purple {
+ background: rgba(135, 85, 236, 0.12);
+}
+
+.gh-whats-new .kg-callout-card-accent {
+ background: var(--ghost-accent-color);
+ color: #fff;
+}
+
+.gh-whats-new .kg-callout-card-accent a {
+ color: #fff;
+}
+
+.gh-whats-new .kg-callout-card div.kg-callout-emoji {
+ padding-right: .8em;
+ line-height: 1.25em;
+ font-size: 1.15em;
+}
+
+.gh-whats-new .kg-callout-card div.kg-callout-text {
+ font-size: .95em;
+ line-height: 1.5em;
+}
+
+.gh-whats-new .kg-callout-card + .kg-callout-card {
+ margin-top: 1em;
+}
+
+.gh-whats-new .kg-file-card {
+ display: flex;
+}
+
+.gh-whats-new .kg-file-card a.kg-file-card-container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: inherit;
+ padding: 6px;
+ min-height: 92px;
+ border: 1px solid rgb(124 139 154 / 25%);
+ border-radius: 3px;
+ transition: all ease-in-out 0.35s;
+ text-decoration: none;
+ width: 100%;
+}
+
+.gh-whats-new .kg-file-card a.kg-file-card-container:hover {
+ border: 1px solid rgb(124 139 154 / 35%);
+}
+
+.gh-whats-new .kg-file-card-contents {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ margin: 4px 8px;
+}
+
+.gh-whats-new .kg-file-card-title {
+ font-size: 1.15em;
+ font-weight: 700;
+ line-height: 1.3em;
+}
+
+.gh-whats-new .kg-file-card-caption {
+ font-size: 0.95em;
+ line-height: 1.5em;
+ opacity: 0.6;
+}
+
+.gh-whats-new .kg-file-card-metadata {
+ display: inline;
+ font-size: 0.825em;
+ line-height: 1.5em;
+ margin-top: 2px;
+}
+
+.gh-whats-new .kg-file-card-filename {
+ display: inline;
+ font-weight: 500;
+}
+
+.gh-whats-new .kg-file-card-filesize {
+ display: inline-block;
+ font-size: 0.925em;
+ opacity: 0.6;
+}
+
+.gh-whats-new .kg-file-card-filesize:before {
+ display: inline-block;
+ content: "\2022";
+ margin-right: 4px;
+}
+
+.gh-whats-new .kg-file-card-icon {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 80px;
+ min-width: 80px;
+ height: 100%;
+}
+
+.gh-whats-new .kg-file-card-icon:before {
+ position: absolute;
+ display: block;
+ content: "";
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: currentColor;
+ opacity: 0.06;
+ transition: opacity ease-in-out 0.35s;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-file-card a.kg-file-card-container:hover .kg-file-card-icon:before {
+ opacity: 0.08;
+}
+
+.gh-whats-new .kg-file-card-icon svg {
+ width: 24px;
+ height: 24px;
+ color: var(--ghost-accent-color);
+}
+
+/* Size variations */
+.gh-whats-new .kg-file-card-medium a.kg-file-card-container {
+ min-height: 72px;
+}
+
+.gh-whats-new .kg-file-card-medium .kg-file-card-caption {
+ opacity: 1.0;
+ font-weight: 500;
+}
+
+.gh-whats-new .kg-file-card-small a.kg-file-card-container {
+ min-height: 52px;
+}
+
+.gh-whats-new .kg-file-card-small .kg-file-card-metadata {
+ font-size: 1.0em;
+ margin-top: 0;
+}
+
+.gh-whats-new .kg-file-card-small .kg-file-card-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.gh-whats-new .kg-file-card + .kg-file-card {
+ margin-top: 1em;
+}
+
+.gh-whats-new .kg-nft-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.gh-whats-new .kg-nft-card a.kg-nft-card-container {
+ position: static;
+ display: flex;
+ flex: auto;
+ flex-direction: column;
+ text-decoration: none;
+ font-family: var(--font-family);
+ font-size: 14px;
+ font-weight: 400;
+ box-shadow: 0 2px 6px -2px rgb(0 0 0 / 10%), 0 0 1px rgb(0 0 0 / 40%);
+ width: 100%;
+ max-width: 512px;
+ color: #222;
+ background: #fff;
+ border-radius: 5px;
+ transition: none;
+}
+
+.gh-whats-new .kg-nft-card * {
+ position: static;
+}
+
+.gh-whats-new .kg-nft-metadata {
+ padding: 20px;
+ width: 100%;
+}
+
+.gh-whats-new .kg-nft-image {
+ border-radius: 5px 5px 0 0;
+ width: 100%;
+}
+
+.gh-whats-new .kg-nft-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+}
+
+.gh-whats-new .kg-nft-header h4.kg-nft-title {
+ font-family: inherit;
+ font-size: 19px;
+ font-weight: 700;
+ line-height: 1.3em;
+ min-width: unset;
+ max-width: unset;
+ margin: 0;
+ color: #222;
+}
+
+.gh-whats-new .kg-nft-opensea-logo {
+ margin-top: 2px;
+ width: 100px;
+ object-fit: scale-down;
+}
+
+.gh-whats-new .kg-nft-creator {
+ font-family: inherit;
+ line-height: 1.4em;
+ margin: 4px 0 0;
+ color: #ababab;
+}
+
+.gh-whats-new .kg-nft-creator span {
+ font-weight: 500;
+ color: #222;
+}
+
+.gh-whats-new .kg-nft-card p.kg-nft-description {
+ font-family: inherit;
+ font-size: 14px;
+ line-height: 1.4em;
+ margin: 20px 0 0;
+ color: #222;
+}
+
+.gh-whats-new .kg-toggle-card {
+ background: transparent;
+ box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25);
+ border-radius: 4px;
+ padding: 1.2em;
+}
+
+.gh-whats-new .kg-toggle-card[data-kg-toggle-state="close"] .kg-toggle-content{
+ height: 0;
+ overflow: hidden;
+ transition: opacity .5s ease, top .35s ease;
+ opacity: 0;
+ top: -0.5em;
+ position: relative;
+}
+
+.gh-whats-new .kg-toggle-content {
+ height: auto;
+ opacity: 1;
+ transition: opacity 1s ease, top .35s ease;
+ top: 0;
+ position: relative;
+}
+
+.gh-whats-new .kg-toggle-card[data-kg-toggle-state="close"] svg {
+ transform: unset;
+}
+
+.gh-whats-new .kg-toggle-heading {
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.gh-whats-new .kg-toggle-card h4.kg-toggle-heading-text {
+ font-size: 1.15em;
+ font-weight: 700;
+ line-height: 1.3em;
+ margin-top: 0;
+ margin-bottom: 0;
+ text-transform: none;
+ color: inherit;
+}
+
+.gh-whats-new .kg-toggle-content p:first-of-type {
+ margin-top: 0.5em;
+}
+
+.gh-whats-new .kg-toggle-card .kg-toggle-content p,
+.gh-whats-new .kg-toggle-card .kg-toggle-content ol,
+.gh-whats-new .kg-toggle-card .kg-toggle-content ul {
+ font-size: 0.95em;
+ line-height: 1.5em;
+ margin-top: 0.95em;
+}
+
+.gh-whats-new .kg-toggle-card li + li {
+ margin-top: 0.5em;
+}
+
+.gh-whats-new .kg-toggle-card-icon {
+ height: 24px;
+ width: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-left: 1em;
+ background: none;
+ border: 0;
+}
+
+.gh-whats-new .kg-toggle-heading svg {
+ width: 14px;
+ color: rgba(124, 139, 154, 0.5);
+ transition: all 0.3s;
+ transform: rotate(-180deg);
+}
+
+.gh-whats-new .kg-toggle-heading path {
+ fill: none;
+ stroke: currentcolor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.5;
+ fill-rule: evenodd;
+}
+
+.gh-whats-new .kg-toggle-card + .kg-toggle-card {
+ margin-top: 1em;
+}
+
+.gh-whats-new .kg-video-card {
+ position: relative;
+ --seek-before-width: 0%;
+ --volume-before-width: 100%;
+ --buffered-width: 0%;
+}
+
+.gh-whats-new .kg-video-card video {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+.gh-whats-new .kg-video-container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.gh-whats-new .kg-video-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-image: linear-gradient(180deg,rgba(0,0,0,0.3) 0,transparent 70%,transparent 100%);
+ z-index: 99;
+ transition: opacity .2s ease-in-out;
+}
+
+.gh-whats-new .kg-video-large-play-icon {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 72px;
+ height: 72px;
+ padding: 0;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 50%;
+ transition: opacity .2s ease-in-out;
+}
+
+.gh-whats-new .kg-video-large-play-icon svg {
+ width: 20px;
+ height: auto;
+ margin-left: 2px;
+ fill: #fff;
+}
+
+.gh-whats-new .kg-video-player-container {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ height: 80px;
+ background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,.5));
+ z-index: 99;
+ transition: opacity .2s ease-in-out;
+
+}
+
+.gh-whats-new .kg-video-player {
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ z-index: 99;
+ padding: 12px 16px;
+}
+
+.gh-whats-new .kg-video-current-time {
+ min-width: 38px;
+ padding: 0 4px;
+ color: #fff;
+ font-family: inherit;
+ font-size: .85em;
+ font-weight: 500;
+ line-height: 1.4em;
+ white-space: nowrap;
+}
+
+.gh-whats-new .kg-video-time {
+ color: rgba(255, 255, 255, 0.6);
+ font-family: inherit;
+ font-size: .85em;
+ font-weight: 500;
+ line-height: 1.4em;
+ white-space: nowrap;
+}
+
+.gh-whats-new .kg-video-duration {
+ padding: 0 4px;
+}
+
+.gh-whats-new .kg-video-play-icon,
+.gh-whats-new .kg-video-pause-icon {
+ position: relative;
+ padding: 0px 4px 0 0;
+ font-size: 0;
+ background: transparent;
+}
+
+.gh-whats-new .kg-video-hide {
+ display: none !important;
+}
+
+.gh-whats-new .kg-video-hide-animated {
+ opacity: 0 !important;
+ transition: opacity .2s ease-in-out;
+ cursor: initial;
+}
+
+.gh-whats-new .kg-video-play-icon svg,
+.gh-whats-new .kg-video-pause-icon svg {
+ width: 14px;
+ height: 14px;
+ fill: #fff;
+}
+
+.gh-whats-new .kg-video-seek-slider {
+ flex-grow: 1;
+ margin: 0 4px;
+}
+
+@media (max-width: 520px) {
+ .gh-whats-new .kg-video-seek-slider {
+ display: none;
+ }
+}
+
+.gh-whats-new .kg-video-playback-rate {
+ min-width: 37px;
+ padding: 0 4px;
+ color: #fff;
+ font-family: inherit;
+ font-size: .85em;
+ font-weight: 600;
+ line-height: 1.4em;
+ text-align: left;
+ background: transparent;
+ white-space: nowrap;
+}
+
+@media (max-width: 520px) {
+ .gh-whats-new .kg-video-playback-rate {
+ padding-left: 8px;
+ }
+}
+
+.gh-whats-new .kg-video-mute-icon,
+.gh-whats-new .kg-video-unmute-icon {
+ position: relative;
+ bottom: -1px;
+ padding: 0 4px;
+ font-size: 0;
+ background: transparent;
+}
+
+@media (max-width: 520px) {
+ .gh-whats-new .kg-video-mute-icon,
+ .gh-whats-new .kg-video-unmute-icon {
+ margin-left: auto;
+ }
+}
+
+.gh-whats-new .kg-video-mute-icon svg,
+.gh-whats-new .kg-video-unmute-icon svg {
+ width: 16px;
+ height: 16px;
+ fill: #fff;
+}
+
+.gh-whats-new .kg-video-volume-slider {
+ width: 80px;
+}
+
+@media (max-width: 300px) {
+ .gh-whats-new .kg-video-volume-slider {
+ display: none;
+ }
+}
+
+.gh-whats-new .kg-video-seek-slider::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ width: var(--seek-before-width) !important;
+ height: 4px;
+ cursor: pointer;
+ background-color: #EBEEF0;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-video-volume-slider::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ width: var(--volume-before-width) !important;
+ height: 4px;
+ cursor: pointer;
+ background-color: #EBEEF0;
+ border-radius: 2px;
+}
+
+/* Resetting browser styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-video-card input[type=range] {
+ position: relative;
+ -webkit-appearance: none;
+ background: transparent;
+}
+
+.gh-whats-new .kg-video-card input[type=range]:focus {
+ outline: none;
+}
+
+.gh-whats-new .kg-video-card input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+}
+
+.gh-whats-new .kg-video-card input[type=range]::-ms-track {
+ cursor: pointer;
+ border-color: transparent;
+ color: transparent;
+ background: transparent;
+}
+
+.gh-whats-new .kg-video-card button {
+ display: flex;
+ align-items: center;
+ border: 0;
+ cursor: pointer;
+}
+
+.gh-whats-new .kg-video-card input[type="range"] {
+ height: auto;
+ padding: 0;
+ border: 0;
+}
+
+/* Chrome & Safari styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-video-card input[type="range"]::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-video-card input[type="range"]::-webkit-slider-thumb {
+ position: relative;
+ box-sizing: content-box;
+ width: 13px;
+ height: 13px;
+ margin: -5px 0 0 0;
+ border: 0;
+ cursor: pointer;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24);
+}
+
+.gh-whats-new .kg-video-card input[type="range"]:active::-webkit-slider-thumb {
+ transform: scale(1.2);
+}
+
+/* Firefox styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-video-card input[type="range"]::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-video-card input[type="range"]::-moz-range-progress {
+ background: #EBEEF0;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-video-card input[type="range"]::-moz-range-thumb {
+ box-sizing: content-box;
+ width: 13px;
+ height: 13px;
+ border: 0;
+ cursor: pointer;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24);
+}
+
+.gh-whats-new .kg-video-card input[type="range"]:active::-moz-range-thumb {
+ transform: scale(1.2);
+}
+
+/* Edge & IE styles
+/* --------------------------------------------------------------- */
+
+.gh-whats-new .kg-video-card input[type="range"]::-ms-track {
+ width: 100%;
+ height: 3px;
+ border: solid transparent;
+ color: transparent;
+ cursor: pointer;
+ background: transparent;
+}
+
+.gh-whats-new .kg-video-card input[type="range"]::-ms-fill-lower {
+ background: #fff;
+}
+
+.gh-whats-new .kg-video-card input[type="range"]::-ms-fill-upper {
+ background: #EBEEF0;
+}
+
+.gh-whats-new .kg-video-card input[type="range"]::-ms-thumb {
+ box-sizing: content-box;
+ width: 13px;
+ height: 13px;
+ border: 0;
+ cursor: pointer;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24);
+}
+
+.gh-whats-new .kg-video-card input[type="range"]:active::-ms-thumb {
+ transform: scale(1.2);
+}
+
+/* File card styles */
+.gh-whats-new .kg-file-card {
+ display: flex;
+}
+
+.gh-whats-new .kg-file-card a.kg-file-card-container {
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ color: inherit;
+ padding: 6px;
+ min-height: 92px;
+ border: 1px solid rgb(124 139 154 / 25%);
+ border-radius: 3px;
+ transition: all ease-in-out 0.35s;
+ text-decoration: none;
+ width: 100%;
+}
+
+.gh-whats-new .kg-file-card a.kg-file-card-container:hover {
+ border: 1px solid rgb(124 139 154 / 35%);
+}
+
+.gh-whats-new .kg-file-card-contents {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ margin: 4px 8px;
+ width: 100%
+}
+
+.gh-whats-new .kg-file-card-title {
+ font-size: 1.15em;
+ font-weight: 700;
+ line-height: 1.3em;
+}
+
+.gh-whats-new .kg-file-card-caption {
+ font-size: 0.95em;
+ line-height: 1.3em;
+ opacity: 0.6;
+}
+
+.gh-whats-new .kg-file-card-title + .kg-file-card-caption {
+ margin-top: -6px;
+}
+
+.gh-whats-new .kg-file-card-metadata {
+ display: inline;
+ font-size: 0.825em;
+ line-height: 1.3em;
+ margin-top: 2px;
+}
+
+.gh-whats-new .kg-file-card-filename {
+ display: inline;
+ font-weight: 500;
+}
+
+.gh-whats-new .kg-file-card-filesize {
+ display: inline-block;
+ font-size: 0.925em;
+ opacity: 0.6;
+}
+
+.gh-whats-new .kg-file-card-filesize:before {
+ display: inline-block;
+ content: "\2022";
+ margin-right: 4px;
+}
+
+.gh-whats-new .kg-file-card-icon {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 80px;
+ min-width: 80px;
+ height: 100%;
+}
+
+.gh-whats-new .kg-file-card-icon:before {
+ position: absolute;
+ display: block;
+ content: "";
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: currentColor;
+ opacity: 0.06;
+ transition: opacity ease-in-out 0.35s;
+ border-radius: 2px;
+}
+
+.gh-whats-new .kg-file-card a.kg-file-card-container:hover .kg-file-card-icon:before {
+ opacity: 0.08;
+}
+
+.gh-whats-new .kg-file-card-icon svg {
+ width: 24px;
+ height: 24px;
+ color: var(--ghost-accent-color);
+}
+
+.gh-whats-new .kg-file-card-medium a.kg-file-card-container {
+ min-height: 72px;
+}
+
+.gh-whats-new .kg-file-card-medium .kg-file-card-caption {
+ opacity: 1.0;
+ font-weight: 500;
+}
+
+.gh-whats-new .kg-file-card-small a.kg-file-card-container {
+ align-items: center;
+ min-height: 52px;
+}
+
+.gh-whats-new .kg-file-card-small .kg-file-card-metadata {
+ font-size: 1.0em;
+ margin-top: 0;
+}
+
+.gh-whats-new .kg-file-card-small .kg-file-card-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.gh-whats-new .kg-file-card + .kg-file-card {
+ margin-top: 1em;
+}
+
+/* Header card */
+
+.gh-whats-new .kg-header-card {
+ padding: 12vmin 4em;
+ min-height: 20vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-bottom: 1.5em;
+}
+
+.gh-whats-new .kg-header-card.kg-size-small {
+ padding-top: 8vmin;
+ padding-bottom: 8vmin;
+ min-height: 12vh;
+}
+
+.gh-whats-new .kg-header-card.kg-size-large {
+ padding-top: 12vmin;
+ padding-bottom: 12vmin;
+ min-height: 40vh;
+}
+
+.gh-whats-new .kg-header-card.kg-align-left {
+ text-align: left;
+ align-items: flex-start;
+}
+
+.gh-whats-new .kg-header-card.kg-style-dark {
+ background: #151515;
+ color: #ffffff;
+}
+
+.gh-whats-new .kg-header-card.kg-style-light {
+ background-color: #fafafa;
+}
+
+.gh-whats-new .kg-header-card.kg-style-accent {
+ background-color: var(--accent-color);
+}
+
+.gh-whats-new .kg-header-card.kg-style-image {
+ position: relative;
+ background-color: #e7e7e7;
+ background-size: cover;
+ background-position: center;
+}
+
+.gh-whats-new .kg-header-card.kg-style-image::before {
+ position: absolute;
+ display: block;
+ content: "";
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: linear-gradient(0deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2));
+}
+
+.gh-whats-new .kg-header-card h2.kg-header-card-header {
+ font-size: 5em;
+ font-weight: 700;
+ line-height: 1.1em;
+ letter-spacing: -0.01em;
+ margin: 0;
+}
+
+.gh-whats-new .kg-header-card h2.kg-header-card-header strong {
+ font-weight: 800;
+}
+
+.gh-whats-new .kg-header-card.kg-size-small h2.kg-header-card-header {
+ font-size: 4em;
+}
+
+.gh-whats-new .kg-header-card.kg-size-large h2.kg-header-card-header {
+ font-size: 6em;
+}
+
+.gh-whats-new .kg-header-card h3.kg-header-card-subheader {
+ font-size: 1.5em;
+ font-weight: 500;
+ line-height: 1.4em;
+ margin: 0;
+ max-width: 40em;
+}
+
+.gh-whats-new .kg-header-card h2 + h3.kg-header-card-subheader {
+ margin: 0.35em 0 0;
+}
+
+.gh-whats-new .kg-header-card h3.kg-header-card-subheader strong {
+ font-weight: 600;
+}
+
+.gh-whats-new .kg-header-card.kg-size-small h3.kg-header-card-subheader {
+ font-size: 1.25em;
+}
+
+.gh-whats-new .kg-header-card.kg-size-large h3.kg-header-card-subheader {
+ font-size: 1.75em;
+}
+
+.gh-whats-new .kg-header-card:not(.kg-style-light) h2.kg-header-card-header,
+.gh-whats-new .kg-header-card:not(.kg-style-light) h3.kg-header-card-subheader {
+ color: #ffffff;
+}
+
+.gh-whats-new .kg-header-card.kg-style-accent h3.kg-header-card-subheader,
+.gh-whats-new .kg-header-card.kg-style-image h3.kg-header-card-subheader {
+ opacity: 1.0;
+}
+
+.gh-whats-new .kg-header-card.kg-style-image h2.kg-header-card-header,
+.gh-whats-new .kg-header-card.kg-style-image h3.kg-header-card-subheader,
+.gh-whats-new .kg-header-card.kg-style-image a.kg-header-card-button {
+ z-index: 99;
+}
+
+.gh-whats-new .kg-header-card h2.kg-header-card-header a,
+.gh-whats-new .kg-header-card h3.kg-header-card-subheader a {
+ color: var(--ghost-accent-color);
+}
+
+.gh-whats-new .kg-header-card.kg-style-accent h2.kg-header-card-header a,
+.gh-whats-new .kg-header-card.kg-style-accent h3.kg-header-card-subheader a,
+.gh-whats-new .kg-header-card.kg-style-image h2.kg-header-card-header a,
+.gh-whats-new .kg-header-card.kg-style-image h3.kg-header-card-subheader a {
+ color: #fff;
+}
+
+.gh-whats-new .kg-header-card a.kg-header-card-button {
+ display: flex;
+ position: static;
+ align-items: center;
+ fill: #fff;
+ background: #fff;
+ border-radius: 3px;
+ outline: none;
+ font-family: var(--font-family);
+ font-size: 1.05em;
+ font-weight: 600;
+ line-height: 1em;
+ text-align: center;
+ text-decoration: none;
+ letter-spacing: .2px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: #151515;
+ height: 2.7em;
+ padding: 0 1.2em;
+ transition: opacity .2s ease;
+}
+
+.gh-whats-new .kg-header-card h2 + a.kg-header-card-button,
+.gh-whats-new .kg-header-card h3 + a.kg-header-card-button {
+ margin: 1.75em 0 0;
+}
+
+.gh-whats-new .kg-header-card a.kg-header-card-button:hover {
+ opacity: 0.85;
+}
+
+.gh-whats-new .kg-header-card.kg-size-large a.kg-header-card-button {
+ font-size: 1.1em;
+ height: 2.9em;
+}
+
+.gh-whats-new .kg-header-card.kg-size-large h2 + a.kg-header-card-button,
+.gh-whats-new .kg-header-card.kg-size-large h3 + a.kg-header-card-button {
+ margin-top: 2em;
+}
+
+.gh-whats-new .kg-header-card.kg-size-small a.kg-header-card-button {
+ height: 2.4em;
+ font-size: 1em;
+}
+
+.gh-whats-new .kg-header-card.kg-size-small h2 + a.kg-header-card-button,
+.gh-whats-new .kg-header-card.kg-size-small h3 + a.kg-header-card-button {
+ margin-top: 1.5em;
+}
+
+.gh-whats-new .kg-header-card.kg-style-image a.kg-header-card-button,
+.gh-whats-new .kg-header-card.kg-style-dark a.kg-header-card-button {
+ background: #fff;
+ color: #151515;
+}
+
+.gh-whats-new .kg-header-card.kg-style-light a.kg-header-card-button {
+ background: var(--ghost-accent-color);
+ color: #fff;
+}
+
+.gh-whats-new .kg-header-card.kg-style-accent a.kg-header-card-button {
+ background: #fff;
+ color: #151515;
+}
+
diff --git a/apps/admin-x-activitypub/src/App.tsx b/apps/admin-x-activitypub/src/App.tsx
new file mode 100644
index 00000000000..62145ea0ea9
--- /dev/null
+++ b/apps/admin-x-activitypub/src/App.tsx
@@ -0,0 +1,32 @@
+import MainContent from './MainContent';
+import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
+import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
+import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
+
+interface AppProps {
+ framework: TopLevelFrameworkProps;
+ designSystem: DesignSystemAppProps;
+}
+
+const modals = {
+ paths: {
+ 'follow-site': 'FollowSite',
+ 'profile/following': 'ViewFollowing',
+ 'profile/followers': 'ViewFollowers'
+ },
+ load: async () => import('./components/modals')
+};
+
+const App: React.FC = ({framework, designSystem}) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default App;
\ No newline at end of file
diff --git a/apps/admin-x-activitypub/src/MainContent.tsx b/apps/admin-x-activitypub/src/MainContent.tsx
new file mode 100644
index 00000000000..807ec785fb9
--- /dev/null
+++ b/apps/admin-x-activitypub/src/MainContent.tsx
@@ -0,0 +1,63 @@
+import Activities from './components/Activities';
+import Inbox from './components/Inbox';
+import Profile from './components/Profile';
+import Search from './components/Search';
+import {ActivityPubAPI} from './api/activitypub';
+import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
+import {useQuery} from '@tanstack/react-query';
+import {useRouting} from '@tryghost/admin-x-framework/routing';
+
+export function useBrowseInboxForUser(handle: string) {
+ const site = useBrowseSite();
+ const siteData = site.data?.site;
+ const siteUrl = siteData?.url ?? window.location.origin;
+ const api = new ActivityPubAPI(
+ new URL(siteUrl),
+ new URL('/ghost/api/admin/identities/', window.location.origin),
+ handle
+ );
+ return useQuery({
+ queryKey: [`inbox:${handle}`],
+ async queryFn() {
+ return api.getInbox();
+ }
+ });
+}
+
+export function useFollowersForUser(handle: string) {
+ const site = useBrowseSite();
+ const siteData = site.data?.site;
+ const siteUrl = siteData?.url ?? window.location.origin;
+ const api = new ActivityPubAPI(
+ new URL(siteUrl),
+ new URL('/ghost/api/admin/identities/', window.location.origin),
+ handle
+ );
+ return useQuery({
+ queryKey: [`followers:${handle}`],
+ async queryFn() {
+ return api.getFollowers();
+ }
+ });
+}
+
+const MainContent = () => {
+ const {route} = useRouting();
+ const mainRoute = route.split('/')[0];
+ switch (mainRoute) {
+ case 'search':
+ return ;
+ break;
+ case 'activity':
+ return ;
+ break;
+ case 'profile':
+ return ;
+ break;
+ default:
+ return ;
+ break;
+ }
+};
+
+export default MainContent;
diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts
new file mode 100644
index 00000000000..7d505974470
--- /dev/null
+++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts
@@ -0,0 +1,446 @@
+import {Activity, ActivityPubAPI} from './activitypub';
+
+function NotFound() {
+ return new Response(null, {
+ status: 404
+ });
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function JSONResponse(data: any, contentType = 'application/json', status = 200) {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: {
+ 'Content-Type': contentType
+ }
+ });
+}
+
+type Spec = {
+ response: Response,
+ assert?: (resource: URL, init?: RequestInit) => Promise
+};
+
+function Fetch(specs: Record) {
+ return async function (resource: URL, init?: RequestInit): Promise {
+ const spec = specs[resource.href];
+ if (!spec) {
+ return NotFound();
+ }
+ if (spec.assert) {
+ await spec.assert(resource, init);
+ }
+ return spec.response;
+ };
+}
+
+describe('ActivityPubAPI', function () {
+ describe('getInbox', function () {
+ test('It passes the token to the inbox endpoint', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/inbox/index': {
+ async assert(_resource, init) {
+ const headers = new Headers(init?.headers);
+ expect(headers.get('Authorization')).toContain('fake-token');
+ },
+ response: JSONResponse({
+ type: 'Collection',
+ items: []
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await api.getInbox();
+ });
+
+ test('Returns an empty array when the inbox is empty', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/inbox/index': {
+ response: JSONResponse({
+ type: 'Collection',
+ items: []
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getInbox();
+ const expected: never[] = [];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('Returns all the items array when the inbox is not empty', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/inbox/index': {
+ response:
+ JSONResponse({
+ type: 'Collection',
+ items: [{
+ type: 'Create',
+ object: {
+ type: 'Note'
+ }
+ }]
+ })
+ }
+ });
+
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getInbox();
+ const expected: Activity[] = [
+ {
+ type: 'Create',
+ object: {
+ type: 'Note'
+ }
+ }
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('Returns an array when the items key is a single object', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/inbox/index': {
+ response:
+ JSONResponse({
+ type: 'Collection',
+ items: {
+ type: 'Create',
+ object: {
+ type: 'Note'
+ }
+ }
+ })
+ }
+ });
+
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getInbox();
+ const expected: Activity[] = [
+ {
+ type: 'Create',
+ object: {
+ type: 'Note'
+ }
+ }
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe('getFollowing', function () {
+ test('It passes the token to the following endpoint', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/following/index': {
+ async assert(_resource, init) {
+ const headers = new Headers(init?.headers);
+ expect(headers.get('Authorization')).toContain('fake-token');
+ },
+ response: JSONResponse({
+ type: 'Collection',
+ items: []
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await api.getFollowing();
+ });
+
+ test('Returns an empty array when the following is empty', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/following/index': {
+ response: JSONResponse({
+ type: 'Collection',
+ items: []
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getFollowing();
+ const expected: never[] = [];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('Returns all the items array when the following is not empty', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/following/index': {
+ response:
+ JSONResponse({
+ type: 'Collection',
+ items: [{
+ type: 'Person'
+ }]
+ })
+ }
+ });
+
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getFollowing();
+ const expected: Activity[] = [
+ {
+ type: 'Person'
+ }
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('Returns an array when the items key is a single object', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/following/index': {
+ response:
+ JSONResponse({
+ type: 'Collection',
+ items: {
+ type: 'Person'
+ }
+ })
+ }
+ });
+
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getFollowing();
+ const expected: Activity[] = [
+ {
+ type: 'Person'
+ }
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe('getFollowers', function () {
+ test('It passes the token to the followers endpoint', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/followers/index': {
+ async assert(_resource, init) {
+ const headers = new Headers(init?.headers);
+ expect(headers.get('Authorization')).toContain('fake-token');
+ },
+ response: JSONResponse({
+ type: 'Collection',
+ orderedItems: []
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await api.getFollowers();
+ });
+
+ test('Returns an empty array when the followers is empty', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/followers/index': {
+ response: JSONResponse({
+ type: 'Collection',
+ orderedItems: []
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getFollowers();
+ const expected: never[] = [];
+
+ expect(actual).toEqual(expected);
+ });
+
+ test('Returns all the items array when the followers is not empty', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/followers/index': {
+ response:
+ JSONResponse({
+ type: 'Collection',
+ orderedItems: [{
+ type: 'Person'
+ }]
+ })
+ }
+ });
+
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ const actual = await api.getFollowers();
+ const expected: Activity[] = [
+ {
+ type: 'Person'
+ }
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+ });
+
+ describe('follow', function () {
+ test('It passes the token to the follow endpoint', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/actions/follow/@user@domain.com': {
+ async assert(_resource, init) {
+ const headers = new Headers(init?.headers);
+ expect(headers.get('Authorization')).toContain('fake-token');
+ },
+ response: JSONResponse({})
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await api.follow('@user@domain.com');
+ });
+ });
+});
diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts
new file mode 100644
index 00000000000..0549e5994f5
--- /dev/null
+++ b/apps/admin-x-activitypub/src/api/activitypub.ts
@@ -0,0 +1,109 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Actor = any;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Activity = any;
+
+export class ActivityPubAPI {
+ constructor(
+ private readonly apiUrl: URL,
+ private readonly authApiUrl: URL,
+ private readonly handle: string,
+ private readonly fetch: (resource: URL, init?: RequestInit) => Promise = window.fetch.bind(window)
+ ) {}
+
+ private async getToken(): Promise {
+ try {
+ const response = await this.fetch(this.authApiUrl);
+ const json = await response.json();
+ return json?.identities?.[0]?.token || null;
+ } catch (err) {
+ // TODO: Ping sentry?
+ return null;
+ }
+ }
+
+ private async fetchJSON(url: URL, method: 'GET' | 'POST' = 'GET'): Promise