From 41def3e88e61d3ad5064da4c00337df0d276b3b2 Mon Sep 17 00:00:00 2001
From: Hannah Wolfe
Date: Thu, 13 Aug 2026 11:25:34 +0100
Subject: [PATCH 01/25] Added internal package golden path checks (#29877)
Make the internal package golden path enforceable rather than relying on
documentation alone.
Introduce `compliant`, `migration`, and `exempt` lifecycle states, allowing
history-preserving imports to remain intentionally transitional until their
separate modernization work.
Add a checker for package metadata, TypeScript/ESM structure, exports, shared
configuration and workspace dependencies. It uses pnpm as the workspace source
of truth and validates the package template without letting template drift
redefine the contract.
Run the check through local linting and lightweight, path-filtered CI.
---
.../SKILL.md | 3 +
.../skills/migrate-internal-package/SKILL.md | 2 +
.../references/legacy-integration.md | 2 +
.github/workflows/ci.yml | 32 ++
package.json | 3 +-
packages/README.md | 17 +
packages/_template/package.json | 3 +
packages/adapters/redirects-base/package.json | 3 +
.../adapters/route-settings-base/package.json | 3 +
packages/admin-api-schema/package.json | 3 +
packages/custom-field-types/package.json | 3 +
packages/i18n/package.json | 4 +
packages/nql-string/package.json | 3 +
packages/parse-email-address/package.json | 3 +
packages/testing/test-data/package.json | 4 +
scripts/check-internal-packages.js | 348 ++++++++++++++++++
scripts/create-package.js | 14 +-
scripts/lib/package-template.js | 12 +
scripts/test/check-internal-packages.test.js | 309 ++++++++++++++++
19 files changed, 764 insertions(+), 7 deletions(-)
create mode 100644 scripts/check-internal-packages.js
create mode 100644 scripts/lib/package-template.js
create mode 100644 scripts/test/check-internal-packages.test.js
diff --git a/.agents/skills/convert-internal-package-to-typescript/SKILL.md b/.agents/skills/convert-internal-package-to-typescript/SKILL.md
index c125c765d4d..a2e892884a5 100644
--- a/.agents/skills/convert-internal-package-to-typescript/SKILL.md
+++ b/.agents/skills/convert-internal-package-to-typescript/SKILL.md
@@ -62,6 +62,9 @@ Use the subject `Changed file extensions to TypeScript`.
Apply the package contract from `packages/README.md`: shared config packages,
minimal package-local config, ESM metadata and exports, standard scripts, and a
single compiled output unless a verified consumer requires an exception.
+Replace the package's `migration` status with
+`ghostPackage.goldenPath: compliant` only after every mechanical golden-path
+check passes.
Use the subject `Converted to TypeScript`.
diff --git a/.agents/skills/migrate-internal-package/SKILL.md b/.agents/skills/migrate-internal-package/SKILL.md
index 66c6d952ca3..208eae9eae1 100644
--- a/.agents/skills/migrate-internal-package/SKILL.md
+++ b/.agents/skills/migrate-internal-package/SKILL.md
@@ -88,6 +88,8 @@ commits manually.
After the subtree commit, add focused integration commits that:
- make the package private with an internal placeholder version;
+- set `ghostPackage.goldenPath` to `migration` and `ghostPackage.reason` to a
+ concise explanation of the remaining modernization work;
- switch Ghost consumers to `workspace:*`;
- update the lockfile with `pnpm`;
- minimally adapt configuration and tests to work in Ghost;
diff --git a/.agents/skills/migrate-internal-package/references/legacy-integration.md b/.agents/skills/migrate-internal-package/references/legacy-integration.md
index 8efa2b3e2f3..bc29be43e92 100644
--- a/.agents/skills/migrate-internal-package/references/legacy-integration.md
+++ b/.agents/skills/migrate-internal-package/references/legacy-integration.md
@@ -9,6 +9,8 @@ modernize the implementation.
Change only what Ghost needs to consume and verify the package:
- set `"private": true` and the internal placeholder version;
+- set `ghostPackage.goldenPath` to `migration` and describe the remaining
+ modernization work in `ghostPackage.reason`;
- point repository metadata at Ghost;
- remove public publishing configuration;
- switch Ghost consumers to `workspace:*`;
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 06bf1df3222..8fb7fb09df3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -161,6 +161,16 @@ jobs:
- 'package.json'
- 'scripts/check-agent-skill-links.js'
- 'scripts/test/check-agent-skill-links.test.js'
+ package-standards:
+ - 'packages/**'
+ - 'package.json'
+ - 'pnpm-workspace.yaml'
+ - 'scripts/check-internal-packages.js'
+ - 'scripts/create-package.js'
+ - 'scripts/lib/constants.js'
+ - 'scripts/lib/package-template.js'
+ - 'scripts/test/check-internal-packages.test.js'
+ - '.github/workflows/ci.yml'
core:
- *shared
# Repository documentation and ownership metadata do not affect
@@ -279,6 +289,7 @@ jobs:
changed_core: ${{ steps.changed.outputs.core }}
changed_any_code: ${{ steps.changed.outputs.any-code }}
changed_docs: ${{ steps.changed.outputs.docs }}
+ changed_package_standards: ${{ steps.changed.outputs.package-standards }}
changed_tb_cli: ${{ steps.changed.outputs.tb-cli }}
# Single gate for the build + browser-E2E lane. True for tags, or when a
# changed file could affect a running Ghost instance (see the `e2e` path
@@ -421,6 +432,26 @@ jobs:
- name: Lint agent skills
run: node scripts/check-agent-skill-links.js
+ job_lint_packages:
+ name: Lint packages
+ runs-on: ubuntu-slim
+ needs: [job_setup]
+ if: needs.job_setup.outputs.changed_package_standards == 'true'
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ - name: Check internal package golden path
+ run: node scripts/check-internal-packages.js
+
+ - name: Test internal package checker
+ run: node --test scripts/test/check-internal-packages.test.js
+
job_i18n:
runs-on: ubuntu-latest
needs: [job_setup]
@@ -2051,6 +2082,7 @@ jobs:
job_migration_integrity_check,
job_lint,
job_lint_docs,
+ job_lint_packages,
job_i18n,
job_build_admin,
job_pack,
diff --git a/package.json b/package.json
index 180d7a19ddd..38b41bf79c8 100644
--- a/package.json
+++ b/package.json
@@ -62,9 +62,10 @@
"docker:rebase": "git fetch ${GHOST_UPSTREAM:-origin} main && git rebase ${GHOST_UPSTREAM:-origin}/main && pnpm install && pnpm nx run-many -t build --projects=@tryghost/shade,@tryghost/admin-x-framework && docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} up -d --build --force-recreate ghost-dev",
"knip": "knip",
"knip:fix": "knip --fix --allow-remove-files=false",
- "lint": "pnpm nx run-many -t lint lint:boundaries && pnpm lint:docs",
+ "lint": "pnpm nx run-many -t lint lint:boundaries && pnpm lint:packages && pnpm lint:docs",
"lint:agent-skills": "node scripts/check-agent-skill-links.js",
"lint:boundaries": "depcruise ghost/core/core apps --config .dependency-cruiser.cjs",
+ "lint:packages": "node scripts/check-internal-packages.js",
"lint:docs": "pnpm lint:agent-skills",
"check": "pnpm lint && pnpm test",
"test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin",
diff --git a/packages/README.md b/packages/README.md
index d28314516a7..55fa831f442 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -14,6 +14,7 @@ New internal packages are private, TypeScript-only ESM libraries:
- use an `@tryghost/` package name;
- set `"version": "0.0.0"` and `"private": true`;
+- set `"ghostPackage": {"goldenPath": "compliant"}`;
- set `"type": "module"`;
- keep authored code in `src/**/*.ts` and tests in `test/**/*.ts`;
- compile production code to `build/` with `tsc`;
@@ -23,6 +24,22 @@ Making a package public or independently versioned is a product and maintenance
decision, not a packaging convenience. Establish its compatibility, release and
support policy before removing `private` or adding publishing automation.
+### Golden path status
+
+Every private package under `packages/` declares its lifecycle state in
+`ghostPackage.goldenPath`:
+
+- `compliant` means the package is mechanically checked against this document;
+- `migration` is temporary while a history-preserving import awaits a separate
+ modernization PR;
+- `exempt` records an intentional long-term exception such as a test-only or
+ multi-runtime package.
+
+Both `migration` and `exempt` require a non-empty `ghostPackage.reason`.
+Public, independently versioned packages do not declare this metadata because
+this internal-only contract does not apply to them. Run `pnpm lint:packages` to
+validate the status and all mechanically enforceable rules.
+
## Package metadata
Use the template's repository, author and license metadata. Point repository
diff --git a/packages/_template/package.json b/packages/_template/package.json
index b12829ea474..6f69858100f 100644
--- a/packages/_template/package.json
+++ b/packages/_template/package.json
@@ -3,6 +3,9 @@
"version": "0.0.0",
"description": "{{DESCRIPTION}}",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"type": "module",
"repository": {
"type": "git",
diff --git a/packages/adapters/redirects-base/package.json b/packages/adapters/redirects-base/package.json
index 05808e44e7c..881bea07d15 100644
--- a/packages/adapters/redirects-base/package.json
+++ b/packages/adapters/redirects-base/package.json
@@ -3,6 +3,9 @@
"version": "0.0.0",
"description": "The adapter-base-redirects package for Ghost.",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"type": "module",
"repository": {
"type": "git",
diff --git a/packages/adapters/route-settings-base/package.json b/packages/adapters/route-settings-base/package.json
index 55121464f13..0e010e7cf17 100644
--- a/packages/adapters/route-settings-base/package.json
+++ b/packages/adapters/route-settings-base/package.json
@@ -3,6 +3,9 @@
"version": "0.0.0",
"description": "The adapter-base-route-settings package for Ghost.",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"type": "module",
"repository": {
"type": "git",
diff --git a/packages/admin-api-schema/package.json b/packages/admin-api-schema/package.json
index 702d358faf9..e1445503d09 100644
--- a/packages/admin-api-schema/package.json
+++ b/packages/admin-api-schema/package.json
@@ -3,6 +3,9 @@
"version": "0.0.0",
"description": "JSON schemas used to validate Ghost Admin API requests",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"type": "module",
"repository": {
"type": "git",
diff --git a/packages/custom-field-types/package.json b/packages/custom-field-types/package.json
index a59f854a616..53d480ae0c8 100644
--- a/packages/custom-field-types/package.json
+++ b/packages/custom-field-types/package.json
@@ -3,6 +3,9 @@
"version": "0.0.0",
"description": "Shared catalog of member custom field types: storage routing and value validation, consumed by Ghost core and admin",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"type": "module",
"repository": {
"type": "git",
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index f54c1fd0cfc..c2f25bf1b42 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -4,6 +4,10 @@
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/i18n",
"author": "Ghost Foundation",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "exempt",
+ "reason": "Ships locale assets and dedicated CommonJS and ESM loaders for browser and server consumers."
+ },
"main": "index.js",
"exports": {
".": {
diff --git a/packages/nql-string/package.json b/packages/nql-string/package.json
index 3c71ebe89e1..5f9973137d6 100644
--- a/packages/nql-string/package.json
+++ b/packages/nql-string/package.json
@@ -2,6 +2,9 @@
"name": "@tryghost/nql-string",
"version": "0.0.0",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/packages/parse-email-address/package.json b/packages/parse-email-address/package.json
index 3d55dcfd46c..2b2a1608b07 100644
--- a/packages/parse-email-address/package.json
+++ b/packages/parse-email-address/package.json
@@ -2,6 +2,9 @@
"name": "@tryghost/parse-email-address",
"version": "0.0.0",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "compliant"
+ },
"type": "module",
"repository": {
"type": "git",
diff --git a/packages/testing/test-data/package.json b/packages/testing/test-data/package.json
index 8dbd4841389..7fcc9fe23c4 100644
--- a/packages/testing/test-data/package.json
+++ b/packages/testing/test-data/package.json
@@ -2,6 +2,10 @@
"name": "@tryghost/test-data",
"version": "0.0.0",
"private": true,
+ "ghostPackage": {
+ "goldenPath": "exempt",
+ "reason": "Test-only source package consumed directly as TypeScript without a production build artifact."
+ },
"type": "module",
"repository": "https://github.com/TryGhost/Ghost/tree/main/testing/test-data",
"author": "Ghost Foundation",
diff --git a/scripts/check-internal-packages.js b/scripts/check-internal-packages.js
new file mode 100644
index 00000000000..eb9bd2963ef
--- /dev/null
+++ b/scripts/check-internal-packages.js
@@ -0,0 +1,348 @@
+import {execFile} from 'node:child_process';
+import {readdir, readFile, realpath, stat} from 'node:fs/promises';
+import path from 'node:path';
+import {promisify} from 'node:util';
+
+import {ROOT_DIR} from './lib/constants.js';
+import {applyPackageTemplateTokens, isValidPackageName} from './lib/package-template.js';
+
+const execFileAsync = promisify(execFile);
+
+const GOLDEN_PATH_STATUSES = new Set(['compliant', 'migration', 'exempt']);
+// These expectations intentionally remain independent of packages/_template.
+// Deriving them from the template would allow accidental template drift to
+// redefine the contract and approve itself.
+const REQUIRED_SCRIPTS = {
+ build: 'tsc',
+ 'test:unit': 'NODE_ENV=testing vitest run --coverage',
+ 'test:types': 'tsc --noEmit -p test/tsconfig.json',
+ test: "pnpm run '/^test:/'",
+ 'lint:code': 'eslint src/ --cache',
+ 'lint:test': 'eslint test/ --cache',
+ lint: "pnpm run '/^lint:/'"
+};
+const REQUIRED_DEV_DEPENDENCIES = {
+ '@internal/cfg-eslint': 'workspace:*',
+ '@internal/cfg-typescript': 'workspace:*',
+ '@internal/cfg-vitest': 'workspace:*',
+ '@types/node': 'catalog:',
+ '@vitest/coverage-v8': 'catalog:',
+ '@typescript/native': 'catalog:',
+ eslint: 'catalog:',
+ typescript: 'catalog:',
+ vitest: 'catalog:'
+};
+
+async function exists(filePath) {
+ try {
+ await stat(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function readJson(filePath) {
+ return JSON.parse(await readFile(filePath, 'utf8'));
+}
+
+async function listWorkspacePackages(rootDirectory) {
+ const {stdout} = await execFileAsync('pnpm', [
+ 'm', 'ls', '--depth', '-1', '--json'
+ ], {
+ cwd: rootDirectory,
+ maxBuffer: 10 * 1024 * 1024
+ });
+
+ return JSON.parse(stdout);
+}
+
+function sameArray(actual, expected) {
+ return Array.isArray(actual) && actual.length === expected.length && actual.every((value, index) => value === expected[index]);
+}
+
+function addExactValueError(errors, manifestPath, actual, expected, field) {
+ if (actual !== expected) {
+ errors.push(`${manifestPath}: ${field} must be ${JSON.stringify(expected)}`);
+ }
+}
+
+function usesStandardConfigFactory(config, moduleName, factoryName) {
+ const importPattern = new RegExp(`^\\s*import\\s*\\{\\s*${factoryName}\\s*\\}\\s*from\\s*['"]${moduleName}['"];?\\s*$`, 'm');
+ const exportPattern = new RegExp(`^\\s*export\\s+default\\s+${factoryName}\\s*\\(`, 'm');
+ return importPattern.test(config) && exportPattern.test(config);
+}
+
+async function findJavaScriptFiles(directory) {
+ if (!await exists(directory)) {
+ return [];
+ }
+
+ const files = [];
+ for (const entry of await readdir(directory, {withFileTypes: true})) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...await findJavaScriptFiles(entryPath));
+ } else if (/\.(?:c|m)?jsx?$/.test(entry.name)) {
+ files.push(entryPath);
+ }
+ }
+ return files;
+}
+
+async function validateConfigFiles(packageDirectory, manifestPath, errors) {
+ const tsconfigPath = path.join(packageDirectory, 'tsconfig.json');
+ const testTsconfigPath = path.join(packageDirectory, 'test', 'tsconfig.json');
+ const eslintPath = path.join(packageDirectory, 'eslint.config.mjs');
+ const vitestPath = path.join(packageDirectory, 'vitest.config.ts');
+
+ for (const requiredPath of [tsconfigPath, testTsconfigPath, eslintPath, vitestPath]) {
+ if (!await exists(requiredPath)) {
+ errors.push(`${manifestPath}: missing ${path.relative(packageDirectory, requiredPath)}`);
+ }
+ }
+
+ if (await exists(tsconfigPath)) {
+ try {
+ const config = await readJson(tsconfigPath);
+ addExactValueError(errors, manifestPath, config.extends, '@internal/cfg-typescript/esm.json', 'tsconfig.json extends');
+ addExactValueError(errors, manifestPath, config.compilerOptions?.rootDir, 'src', 'tsconfig.json compilerOptions.rootDir');
+ addExactValueError(errors, manifestPath, config.compilerOptions?.outDir, 'build', 'tsconfig.json compilerOptions.outDir');
+ if (!sameArray(config.include, ['src/**/*'])) {
+ errors.push(`${manifestPath}: tsconfig.json include must be ["src/**/*"]`);
+ }
+ } catch (error) {
+ errors.push(`${manifestPath}: invalid tsconfig.json (${error.message})`);
+ }
+ }
+
+ if (await exists(testTsconfigPath)) {
+ try {
+ const config = await readJson(testTsconfigPath);
+ addExactValueError(errors, manifestPath, config.extends, '../tsconfig.json', 'test/tsconfig.json extends');
+ addExactValueError(errors, manifestPath, config.compilerOptions?.rootDir, '..', 'test/tsconfig.json compilerOptions.rootDir');
+ addExactValueError(errors, manifestPath, config.compilerOptions?.noEmit, true, 'test/tsconfig.json compilerOptions.noEmit');
+ if (!sameArray(config.include, ['../src/**/*', '**/*'])) {
+ errors.push(`${manifestPath}: test/tsconfig.json include must be ["../src/**/*", "**/*"]`);
+ }
+ } catch (error) {
+ errors.push(`${manifestPath}: invalid test/tsconfig.json (${error.message})`);
+ }
+ }
+
+ if (await exists(eslintPath)) {
+ const config = await readFile(eslintPath, 'utf8');
+ if (!usesStandardConfigFactory(config, '@internal/cfg-eslint', 'nodeLibConfig')) {
+ errors.push(`${manifestPath}: eslint.config.mjs must use nodeLibConfig from @internal/cfg-eslint`);
+ }
+ }
+
+ if (await exists(vitestPath)) {
+ const config = await readFile(vitestPath, 'utf8');
+ if (!usesStandardConfigFactory(config, '@internal/cfg-vitest', 'createVitestConfig')) {
+ errors.push(`${manifestPath}: vitest.config.ts must use createVitestConfig from @internal/cfg-vitest`);
+ }
+ }
+}
+
+async function validateCompliantPackage({rootDirectory, packageDirectory, manifest, workspaceNames, packagePath: explicitPackagePath}) {
+ const manifestPath = path.relative(rootDirectory, path.join(packageDirectory, 'package.json'));
+ const packagePath = explicitPackagePath ?? path.relative(rootDirectory, packageDirectory).split(path.sep).join('/');
+ const errors = [];
+
+ if (typeof manifest.name !== 'string' || !manifest.name.startsWith('@tryghost/') || !isValidPackageName(manifest.name.slice('@tryghost/'.length))) {
+ errors.push(`${manifestPath}: name must use the @tryghost/ form`);
+ }
+ addExactValueError(errors, manifestPath, manifest.version, '0.0.0', 'version');
+ addExactValueError(errors, manifestPath, manifest.private, true, 'private');
+ addExactValueError(errors, manifestPath, manifest.type, 'module', 'type');
+ addExactValueError(errors, manifestPath, manifest.author, 'Ghost Foundation', 'author');
+ addExactValueError(errors, manifestPath, manifest.license, 'MIT', 'license');
+ addExactValueError(errors, manifestPath, manifest.repository?.type, 'git', 'repository.type');
+ addExactValueError(errors, manifestPath, manifest.repository?.url, 'git+https://github.com/TryGhost/Ghost.git', 'repository.url');
+ addExactValueError(errors, manifestPath, manifest.repository?.directory, packagePath, 'repository.directory');
+
+ if (manifest.publishConfig !== undefined) {
+ errors.push(`${manifestPath}: compliant internal packages must not define publishConfig`);
+ }
+ if (!sameArray(manifest.files, ['build'])) {
+ errors.push(`${manifestPath}: files must be ["build"]`);
+ }
+
+ const exports = manifest.exports;
+ if (!exports || typeof exports !== 'object' || Array.isArray(exports)) {
+ errors.push(`${manifestPath}: exports must define explicit entry points`);
+ } else {
+ for (const [entryPoint, conditions] of Object.entries(exports)) {
+ if (!conditions || typeof conditions !== 'object' || Array.isArray(conditions)) {
+ errors.push(`${manifestPath}: exports.${entryPoint} must be an object`);
+ continue;
+ }
+
+ if (!sameArray(Object.keys(conditions), ['source', 'types', 'default'])) {
+ errors.push(`${manifestPath}: exports.${entryPoint} conditions must be source, types, default in that order`);
+ continue;
+ }
+
+ for (const condition of ['source', 'types', 'default']) {
+ if (typeof conditions[condition] !== 'string') {
+ errors.push(`${manifestPath}: exports.${entryPoint}.${condition} must be a string`);
+ }
+ }
+
+ const sourceMatch = typeof conditions.source === 'string' && /^\.\/src\/(.+)\.ts$/.exec(conditions.source);
+ if (!sourceMatch || sourceMatch[1].split('/').includes('..')) {
+ errors.push(`${manifestPath}: exports.${entryPoint}.source must point to ./src/.ts`);
+ continue;
+ }
+
+ const sourceStem = sourceMatch[1];
+ addExactValueError(errors, manifestPath, conditions.types, `./build/${sourceStem}.d.ts`, `exports.${entryPoint}.types`);
+ addExactValueError(errors, manifestPath, conditions.default, `./build/${sourceStem}.js`, `exports.${entryPoint}.default`);
+ if (!await exists(path.join(packageDirectory, conditions.source))) {
+ errors.push(`${manifestPath}: exports.${entryPoint}.source does not exist (${conditions.source})`);
+ }
+ }
+
+ const rootExport = exports['.'];
+ if (!rootExport) {
+ errors.push(`${manifestPath}: exports must define the "." entry point`);
+ } else {
+ if (typeof rootExport.default === 'string') {
+ addExactValueError(errors, manifestPath, manifest.main, rootExport.default.replace(/^\.\//, ''), 'main');
+ }
+ if (typeof rootExport.types === 'string') {
+ addExactValueError(errors, manifestPath, manifest.types, rootExport.types.replace(/^\.\//, ''), 'types');
+ }
+ }
+ }
+
+ for (const [script, expected] of Object.entries(REQUIRED_SCRIPTS)) {
+ addExactValueError(errors, manifestPath, manifest.scripts?.[script], expected, `scripts.${script}`);
+ }
+ for (const [dependency, expected] of Object.entries(REQUIRED_DEV_DEPENDENCIES)) {
+ addExactValueError(errors, manifestPath, manifest.devDependencies?.[dependency], expected, `devDependencies.${dependency}`);
+ }
+ if (!sameArray(manifest.nx?.targets?.build?.outputs, ['{projectRoot}/build'])) {
+ errors.push(`${manifestPath}: nx.targets.build.outputs must be ["{projectRoot}/build"]`);
+ }
+
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
+ for (const [dependency, version] of Object.entries(manifest[section] ?? {})) {
+ if (dependency !== manifest.name && workspaceNames.has(dependency) && version !== 'workspace:*') {
+ errors.push(`${manifestPath}: ${section}.${dependency} must use workspace:*`);
+ }
+ }
+ }
+
+ await validateConfigFiles(packageDirectory, manifestPath, errors);
+
+ for (const javascriptFile of await findJavaScriptFiles(path.join(packageDirectory, 'src'))) {
+ errors.push(`${manifestPath}: authored source must be TypeScript (${path.relative(packageDirectory, javascriptFile)})`);
+ }
+ for (const javascriptFile of await findJavaScriptFiles(path.join(packageDirectory, 'test'))) {
+ errors.push(`${manifestPath}: authored tests must be TypeScript (${path.relative(packageDirectory, javascriptFile)})`);
+ }
+
+ return errors;
+}
+
+export async function checkInternalPackages(rootDirectory) {
+ rootDirectory = await realpath(rootDirectory);
+ const packagesDirectory = path.join(rootDirectory, 'packages');
+ let workspacePackages;
+ try {
+ workspacePackages = await listWorkspacePackages(rootDirectory);
+ } catch (error) {
+ const detail = error.stderr?.trim() || error.message;
+ return [`Unable to list pnpm workspace packages (${detail})`];
+ }
+ const packageDirectories = workspacePackages
+ .map(workspace => workspace.path)
+ .filter(directory => directory.startsWith(`${packagesDirectory}${path.sep}`))
+ .sort();
+ const packages = [];
+ const errors = [];
+
+ for (const packageDirectory of packageDirectories) {
+ const manifestPath = path.join(packageDirectory, 'package.json');
+ try {
+ packages.push({packageDirectory, manifest: await readJson(manifestPath)});
+ } catch (error) {
+ errors.push(`${path.relative(rootDirectory, manifestPath)}: invalid JSON (${error.message})`);
+ }
+ }
+
+ const workspaceNames = new Set();
+ for (const workspace of workspacePackages) {
+ if (workspace.name) {
+ workspaceNames.add(workspace.name);
+ }
+ }
+
+ for (const pkg of packages) {
+ const manifestPath = path.relative(rootDirectory, path.join(pkg.packageDirectory, 'package.json'));
+ const status = pkg.manifest.ghostPackage?.goldenPath;
+
+ if (pkg.manifest.private !== true) {
+ if (status !== undefined) {
+ errors.push(`${manifestPath}: ghostPackage.goldenPath is only valid for private internal packages`);
+ }
+ continue;
+ }
+
+ if (!GOLDEN_PATH_STATUSES.has(status)) {
+ errors.push(`${manifestPath}: ghostPackage.goldenPath must be one of compliant, migration, exempt`);
+ continue;
+ }
+
+ if (status === 'migration' || status === 'exempt') {
+ if (typeof pkg.manifest.ghostPackage.reason !== 'string' || pkg.manifest.ghostPackage.reason.trim().length === 0) {
+ errors.push(`${manifestPath}: ghostPackage.reason is required when goldenPath is ${status}`);
+ }
+ continue;
+ }
+
+ errors.push(...await validateCompliantPackage({
+ rootDirectory,
+ packageDirectory: pkg.packageDirectory,
+ manifest: pkg.manifest,
+ workspaceNames
+ }));
+ }
+
+ const templateManifestPath = path.join(packagesDirectory, '_template', 'package.json');
+ let templateManifest;
+ try {
+ const templateManifestSource = await readFile(templateManifestPath, 'utf8');
+ templateManifest = JSON.parse(applyPackageTemplateTokens(templateManifestSource, {
+ name: 'template',
+ directory: 'packages/template',
+ description: 'Template package'
+ }));
+ } catch (error) {
+ errors.push(`packages/_template/package.json: unreadable or invalid JSON (${error.message})`);
+ }
+
+ if (templateManifest) {
+ errors.push(...await validateCompliantPackage({
+ rootDirectory,
+ packageDirectory: path.dirname(templateManifestPath),
+ packagePath: 'packages/template',
+ manifest: templateManifest,
+ workspaceNames
+ }));
+ }
+
+ return errors;
+}
+
+if (import.meta.main) {
+ const errors = await checkInternalPackages(ROOT_DIR);
+ if (errors.length > 0) {
+ console.error(`Internal package golden path check failed:\n\n${errors.join('\n')}`);
+ process.exitCode = 1;
+ } else {
+ console.log('All private internal packages have a valid golden path status.');
+ }
+}
diff --git a/scripts/create-package.js b/scripts/create-package.js
index bc3e8dcf5ac..19f1ec24291 100644
--- a/scripts/create-package.js
+++ b/scripts/create-package.js
@@ -5,6 +5,7 @@ import {existsSync} from 'node:fs';
import {join, dirname, relative, resolve, sep} from 'node:path';
import {ROOT_DIR} from './lib/constants.js';
+import {applyPackageTemplateTokens, isValidPackageName} from './lib/package-template.js';
const TEMPLATE_DIR = join(ROOT_DIR, 'packages', '_template');
const PACKAGES_DIR = join(ROOT_DIR, 'packages');
@@ -49,7 +50,7 @@ const name = positionals[0];
if (!name) {
fail('Missing package name.');
}
-if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
+if (!isValidPackageName(name)) {
fail(`Invalid package name "${name}". Use lowercase kebab-case, e.g. "email-utils" (no @scope — it becomes @tryghost/${name}).`);
}
@@ -81,10 +82,11 @@ async function walk(dir) {
}
function applyTokens(text) {
- return text
- .replaceAll('{{NAME}}', name)
- .replaceAll('{{DIRECTORY}}', packageDir)
- .replaceAll('{{DESCRIPTION}}', description);
+ return applyPackageTemplateTokens(text, {
+ name,
+ directory: packageDir,
+ description
+ });
}
await mkdir(dirname(targetDir), {recursive: true});
@@ -108,4 +110,4 @@ for (const file of await walk(targetDir)) {
// Clean any stray build artifacts the template dir may have accumulated.
await rm(join(targetDir, 'build'), {recursive: true, force: true});
-process.stdout.write(`\n\x1b[32m✓ Created @tryghost/${name}\x1b[0m at ${packageDir} (ESM-only)\n\nNext steps:\n 1. pnpm install # link the new workspace member\n 2. pnpm --filter @tryghost/${name} test\n 3. Add real code in ${packageDir}/src/index.ts\n\n`);
+process.stdout.write(`\n\x1b[32m✓ Created @tryghost/${name}\x1b[0m at ${packageDir} (ESM-only)\n\nNext steps:\n 1. pnpm install # link the new workspace member\n 2. cd ${packageDir} && pnpm test\n 3. Add real code in ${packageDir}/src/index.ts\n\n`);
diff --git a/scripts/lib/package-template.js b/scripts/lib/package-template.js
new file mode 100644
index 00000000000..a930b6f1e87
--- /dev/null
+++ b/scripts/lib/package-template.js
@@ -0,0 +1,12 @@
+const PACKAGE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+export function isValidPackageName(name) {
+ return PACKAGE_NAME_PATTERN.test(name);
+}
+
+export function applyPackageTemplateTokens(text, {name, directory, description}) {
+ return text
+ .replaceAll('{{NAME}}', name)
+ .replaceAll('{{DIRECTORY}}', directory)
+ .replaceAll('{{DESCRIPTION}}', description);
+}
diff --git a/scripts/test/check-internal-packages.test.js b/scripts/test/check-internal-packages.test.js
new file mode 100644
index 00000000000..8da4c3a64a3
--- /dev/null
+++ b/scripts/test/check-internal-packages.test.js
@@ -0,0 +1,309 @@
+import assert from 'node:assert/strict';
+import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import {afterEach, test} from 'node:test';
+
+import {checkInternalPackages} from '../check-internal-packages.js';
+
+const temporaryDirectories = [];
+
+async function writeJson(filePath, value) {
+ await mkdir(path.dirname(filePath), {recursive: true});
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+async function createRepository() {
+ const rootDirectory = await mkdtemp(path.join(os.tmpdir(), 'ghost-internal-packages-'));
+ temporaryDirectories.push(rootDirectory);
+ await writeJson(path.join(rootDirectory, 'package.json'), {
+ name: 'test-workspace',
+ private: true
+ });
+ await writeFile(path.join(rootDirectory, 'pnpm-workspace.yaml'), "packages:\n - 'packages/**'\n - '!packages/_template'\n - 'koenig/*'\n");
+ const templateManifest = compliantManifest('packages/template');
+ templateManifest.name = '@tryghost/{{NAME}}';
+ templateManifest.description = '{{DESCRIPTION}}';
+ templateManifest.repository.directory = '{{DIRECTORY}}';
+ await createCompliantPackage(rootDirectory, 'packages/_template', templateManifest);
+ return rootDirectory;
+}
+
+function compliantManifest(directory = 'packages/example') {
+ return {
+ name: '@tryghost/example',
+ version: '0.0.0',
+ private: true,
+ type: 'module',
+ repository: {
+ type: 'git',
+ url: 'git+https://github.com/TryGhost/Ghost.git',
+ directory
+ },
+ author: 'Ghost Foundation',
+ license: 'MIT',
+ exports: {
+ '.': {
+ source: './src/index.ts',
+ types: './build/index.d.ts',
+ default: './build/index.js'
+ }
+ },
+ main: 'build/index.js',
+ types: 'build/index.d.ts',
+ scripts: {
+ build: 'tsc',
+ 'test:unit': 'NODE_ENV=testing vitest run --coverage',
+ 'test:types': 'tsc --noEmit -p test/tsconfig.json',
+ test: "pnpm run '/^test:/'",
+ 'lint:code': 'eslint src/ --cache',
+ 'lint:test': 'eslint test/ --cache',
+ lint: "pnpm run '/^lint:/'"
+ },
+ files: ['build'],
+ devDependencies: {...REQUIRED_DEV_DEPENDENCIES_FOR_TEST},
+ nx: {targets: {build: {outputs: ['{projectRoot}/build']}}},
+ ghostPackage: {goldenPath: 'compliant'}
+ };
+}
+
+const REQUIRED_DEV_DEPENDENCIES_FOR_TEST = {
+ '@internal/cfg-eslint': 'workspace:*',
+ '@internal/cfg-typescript': 'workspace:*',
+ '@internal/cfg-vitest': 'workspace:*',
+ '@types/node': 'catalog:',
+ '@vitest/coverage-v8': 'catalog:',
+ '@typescript/native': 'catalog:',
+ eslint: 'catalog:',
+ typescript: 'catalog:',
+ vitest: 'catalog:'
+};
+
+async function createCompliantPackage(rootDirectory, directory = 'packages/example', manifest = compliantManifest(directory)) {
+ const packageDirectory = path.join(rootDirectory, directory);
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+ await writeJson(path.join(packageDirectory, 'tsconfig.json'), {
+ extends: '@internal/cfg-typescript/esm.json',
+ compilerOptions: {rootDir: 'src', outDir: 'build'},
+ include: ['src/**/*']
+ });
+ await writeJson(path.join(packageDirectory, 'test', 'tsconfig.json'), {
+ extends: '../tsconfig.json',
+ compilerOptions: {rootDir: '..', noEmit: true},
+ include: ['../src/**/*', '**/*']
+ });
+ await mkdir(path.join(packageDirectory, 'src'), {recursive: true});
+ await writeFile(path.join(packageDirectory, 'src', 'index.ts'), 'export const value = true;\n');
+ await writeFile(path.join(packageDirectory, 'eslint.config.mjs'), "import {nodeLibConfig} from '@internal/cfg-eslint';\nexport default nodeLibConfig();\n");
+ await writeFile(path.join(packageDirectory, 'vitest.config.ts'), "import {createVitestConfig} from '@internal/cfg-vitest';\nexport default createVitestConfig();\n");
+ return packageDirectory;
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, {recursive: true, force: true})));
+});
+
+test('accepts a compliant nested private package', async () => {
+ const rootDirectory = await createRepository();
+ await createCompliantPackage(rootDirectory, 'packages/adapters/example');
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), []);
+});
+
+test('reports workspace discovery failures through the checker interface', async () => {
+ const rootDirectory = await createRepository();
+ await writeFile(path.join(rootDirectory, 'pnpm-workspace.yaml'), 'packages: [invalid\n');
+
+ assert.match(
+ (await checkInternalPackages(rootDirectory))[0],
+ /^Unable to list pnpm workspace packages/
+ );
+});
+
+test('requires every private package to declare its golden path status', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ delete manifest.ghostPackage;
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), [
+ 'packages/example/package.json: ghostPackage.goldenPath must be one of compliant, migration, exempt'
+ ]);
+});
+
+test('accepts documented migration and exempt states without applying the golden path', async () => {
+ const rootDirectory = await createRepository();
+ await writeJson(path.join(rootDirectory, 'packages', 'migrating', 'package.json'), {
+ name: '@tryghost/migrating',
+ private: true,
+ ghostPackage: {goldenPath: 'migration', reason: 'Imported unchanged before a separate modernization PR.'}
+ });
+ await writeJson(path.join(rootDirectory, 'packages', 'special', 'package.json'), {
+ name: '@tryghost/special',
+ private: true,
+ ghostPackage: {goldenPath: 'exempt', reason: 'This is a source-only test helper.'}
+ });
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), []);
+});
+
+test('requires migration and exempt states to explain the exception', async () => {
+ const rootDirectory = await createRepository();
+ await writeJson(path.join(rootDirectory, 'packages', 'special', 'package.json'), {
+ name: '@tryghost/special',
+ private: true,
+ ghostPackage: {goldenPath: 'exempt'}
+ });
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), [
+ 'packages/special/package.json: ghostPackage.reason is required when goldenPath is exempt'
+ ]);
+});
+
+test('reports manifest, export, config and source violations together', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ manifest.version = '1.0.0';
+ manifest.exports['.'] = {
+ types: './build/index.d.ts',
+ source: './src/index.ts',
+ default: './build/index.js'
+ };
+ delete manifest.scripts.build;
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+ await writeFile(path.join(packageDirectory, 'src', 'legacy.js'), 'module.exports = {};\n');
+ await writeFile(path.join(packageDirectory, 'test', 'legacy.test.js'), 'export {};\n');
+
+ const errors = await checkInternalPackages(rootDirectory);
+ assert.ok(errors.includes('packages/example/package.json: version must be "0.0.0"'));
+ assert.ok(errors.includes('packages/example/package.json: exports.. conditions must be source, types, default in that order'));
+ assert.ok(errors.includes('packages/example/package.json: scripts.build must be "tsc"'));
+ assert.ok(errors.includes('packages/example/package.json: authored source must be TypeScript (src/legacy.js)'));
+ assert.ok(errors.includes('packages/example/package.json: authored tests must be TypeScript (test/legacy.test.js)'));
+});
+
+test('rejects package names that are not kebab-case', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ manifest.name = '@tryghost/example--';
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+
+ assert.ok((await checkInternalPackages(rootDirectory)).includes(
+ 'packages/example/package.json: name must use the @tryghost/ form'
+ ));
+});
+
+test('does not accept config factory names that appear only in comments', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ await writeFile(path.join(packageDirectory, 'eslint.config.mjs'), "// import {nodeLibConfig} from '@internal/cfg-eslint';\n// export default nodeLibConfig();\nexport default [];\n");
+ await writeFile(path.join(packageDirectory, 'vitest.config.ts'), "// import {createVitestConfig} from '@internal/cfg-vitest';\n// export default createVitestConfig();\nexport default {};\n");
+
+ const errors = await checkInternalPackages(rootDirectory);
+ assert.ok(errors.includes('packages/example/package.json: eslint.config.mjs must use nodeLibConfig from @internal/cfg-eslint'));
+ assert.ok(errors.includes('packages/example/package.json: vitest.config.ts must use createVitestConfig from @internal/cfg-vitest'));
+});
+
+test('reports non-string export conditions without terminating validation', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ manifest.exports['.'] = {source: 42, types: false, default: {}};
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+
+ const errors = await checkInternalPackages(rootDirectory);
+ assert.ok(errors.includes('packages/example/package.json: exports...source must be a string'));
+ assert.ok(errors.includes('packages/example/package.json: exports...types must be a string'));
+ assert.ok(errors.includes('packages/example/package.json: exports...default must be a string'));
+});
+
+test('validates the complete package template contract', async () => {
+ const rootDirectory = await createRepository();
+ const templatePath = path.join(rootDirectory, 'packages', '_template', 'package.json');
+ const manifest = compliantManifest('packages/template');
+ manifest.name = '@tryghost/{{NAME}}';
+ manifest.repository.directory = '{{DIRECTORY}}';
+ delete manifest.scripts.build;
+ await writeJson(templatePath, manifest);
+
+ assert.ok((await checkInternalPackages(rootDirectory)).includes(
+ 'packages/_template/package.json: scripts.build must be "tsc"'
+ ));
+});
+
+test('reports an unreadable package template accurately', async () => {
+ const rootDirectory = await createRepository();
+ await rm(path.join(rootDirectory, 'packages', '_template', 'package.json'));
+
+ assert.match(
+ (await checkInternalPackages(rootDirectory))[0],
+ /^packages\/_template\/package\.json: unreadable or invalid JSON/
+ );
+});
+
+test('ignores public packages but rejects golden path metadata on them', async () => {
+ const rootDirectory = await createRepository();
+ await writeJson(path.join(rootDirectory, 'packages', 'public', 'package.json'), {
+ name: '@tryghost/public',
+ version: '1.0.0'
+ });
+ assert.deepEqual(await checkInternalPackages(rootDirectory), []);
+
+ await writeJson(path.join(rootDirectory, 'packages', 'public', 'package.json'), {
+ name: '@tryghost/public',
+ version: '1.0.0',
+ ghostPackage: {goldenPath: 'compliant'}
+ });
+ assert.deepEqual(await checkInternalPackages(rootDirectory), [
+ 'packages/public/package.json: ghostPackage.goldenPath is only valid for private internal packages'
+ ]);
+});
+
+test('requires dependencies on packages in this workspace to use workspace:*', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ manifest.dependencies = {'@tryghost/other': 'catalog:'};
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+ await writeJson(path.join(rootDirectory, 'packages', 'other', 'package.json'), {
+ name: '@tryghost/other',
+ version: '1.0.0'
+ });
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), [
+ 'packages/example/package.json: dependencies.@tryghost/other must use workspace:*'
+ ]);
+});
+
+test('recognizes workspace packages outside packages/', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ manifest.dependencies = {'@tryghost/koenig-example': 'catalog:'};
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+ await writeJson(path.join(rootDirectory, 'koenig', 'example', 'package.json'), {
+ name: '@tryghost/koenig-example'
+ });
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), [
+ 'packages/example/package.json: dependencies.@tryghost/koenig-example must use workspace:*'
+ ]);
+});
+
+test('requires peer dependencies on workspace packages to use workspace:*', async () => {
+ const rootDirectory = await createRepository();
+ const packageDirectory = await createCompliantPackage(rootDirectory);
+ const manifest = compliantManifest();
+ manifest.peerDependencies = {'@tryghost/koenig-example': '>=1'};
+ await writeJson(path.join(packageDirectory, 'package.json'), manifest);
+ await writeJson(path.join(rootDirectory, 'koenig', 'example', 'package.json'), {
+ name: '@tryghost/koenig-example'
+ });
+
+ assert.deepEqual(await checkInternalPackages(rootDirectory), [
+ 'packages/example/package.json: peerDependencies.@tryghost/koenig-example must use workspace:*'
+ ]);
+});
From f146bfaa640fafa61dd643511d20e4c39fd9ff62 Mon Sep 17 00:00:00 2001
From: Hannah Wolfe
Date: Thu, 13 Aug 2026 11:33:51 +0100
Subject: [PATCH 02/25] Added translation and internationalization guides
(#29931)
We're moving our codebase docs into the repository. The PR was done
in two steps - copy and update. The resulting will have one change but
have access to that history.
The resulting docs are split into Translating Ghost aimed at i18n
contributors AND an internal codebase guide for creating translatable copy.
---
docs/README.md | 5 ++
docs/contributing/translating-ghost.md | 84 ++++++++++++++++++++++++
docs/practices/internationalization.md | 91 ++++++++++++++++++++++++++
3 files changed, 180 insertions(+)
create mode 100644 docs/contributing/translating-ghost.md
create mode 100644 docs/practices/internationalization.md
diff --git a/docs/README.md b/docs/README.md
index a8df41a1fc0..516597d955c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -63,6 +63,11 @@ Before contributing, please read:
1. [Contributing Guide](../.github/CONTRIBUTING.md) - Guidelines for contributions
2. [Code of Conduct](../.github/CODE_OF_CONDUCT.md) - Community standards
+To contribute or add translations, see
+[Translating Ghost](contributing/translating-ghost.md). For more detail on
+adding translatable product copy, see the
+[internationalization guide](practices/internationalization.md).
+
### Finding Issues to Work On
- [Good First Issues](https://github.com/TryGhost/Ghost/labels/good%20first%20issue) - Great for newcomers
diff --git a/docs/contributing/translating-ghost.md b/docs/contributing/translating-ghost.md
new file mode 100644
index 00000000000..86a72389f4f
--- /dev/null
+++ b/docs/contributing/translating-ghost.md
@@ -0,0 +1,84 @@
+# Translating Ghost
+
+Ghost can be translated into many languages. Translations cover Ghost's public
+apps, parts of Ghost Core, and emails sent to members.
+
+Ghost uses [i18next](https://www.i18next.com/) and keeps translations in
+[`packages/i18n/locales/`](../../packages/i18n/locales/). Each language has its
+own folder containing separate JSON files for Ghost, Portal, Comments, Signup
+form, and Search.
+
+Within each file, the key on the left is the original English string and the
+value on the right is its translation. An empty value falls back to the English
+string.
+
+## Translating existing strings
+
+1. Find your language in `packages/i18n/locales/`.
+2. Open the JSON file for the part of Ghost you want to translate:
+
+ | File | Where the translation appears |
+ | --- | --- |
+ | `ghost.json` | Ghost Core and emails |
+ | `portal.json` | Portal |
+ | `comments.json` | Comments |
+ | `signup-form.json` | Signup form |
+ | `search.json` | Search |
+
+3. Add or improve the translated values. Leave the English keys unchanged.
+4. Run the translation checks from the repository root:
+
+ ```bash
+ pnpm --filter @tryghost/i18n lint:translations
+ ```
+
+5. Commit the changes and open a pull request following the
+ [contribution workflow](workflow.md).
+
+Keep every `{variable}` and `` from the English string in the translation.
+The words around them can move to suit the language, but their names and
+spelling must not change.
+
+```json
+{
+ "Welcome back, {name}!": "Bon retour, {name} !"
+}
+```
+
+Translate the meaning of the complete message rather than translating each word
+literally. The description for a string in
+[`packages/i18n/locales/context.json`](../../packages/i18n/locales/context.json)
+explains where it appears and what it is intended to communicate.
+
+## Adding a language
+
+Before starting a new language, open an issue or discussion so the locale code
+and scope can be agreed. Ghost supports base languages as well as some regional
+and script variants.
+
+To add an agreed language:
+
+1. Add its code and English label to
+ [`packages/i18n/lib/locale-data.json`](../../packages/i18n/lib/locale-data.json).
+2. From the repository root, run:
+
+ ```bash
+ pnpm --filter @tryghost/i18n translate
+ ```
+
+3. Translate the generated files in `packages/i18n/locales//`.
+4. Run the translation checks and package tests:
+
+ ```bash
+ pnpm --filter @tryghost/i18n lint:translations
+ pnpm --filter @tryghost/i18n test
+ ```
+
+5. Commit the locale metadata and translation files together, then open a pull
+ request.
+
+## Adding product copy
+
+If you are adding or changing translatable strings in the code, see the
+[internationalization guide](../practices/internationalization.md). It covers
+translation helpers, extraction, interpolation, context, and CI checks.
diff --git a/docs/practices/internationalization.md b/docs/practices/internationalization.md
new file mode 100644
index 00000000000..daaf16fee74
--- /dev/null
+++ b/docs/practices/internationalization.md
@@ -0,0 +1,91 @@
+# Internationalization
+
+Use Ghost's internationalization system for product copy that appears in a
+supported translatable surface. The shared `packages/i18n` package extracts
+English source strings and provides locale resources to Ghost Core and the
+public apps.
+
+For contributing translations or adding a language, see
+[Translating Ghost](../contributing/translating-ghost.md).
+
+## Namespaces
+
+Translation files live at
+`packages/i18n/locales//.json`. The extraction scripts define
+five namespaces:
+
+| Namespace | Source |
+| --- | --- |
+| `ghost` | Ghost Core, including server, frontend, and member email templates |
+| `portal` | Portal |
+| `comments` | Comments |
+| `signup-form` | Signup form |
+| `search` | Search |
+
+The English string passed to `t()` is the translation key. English locale values
+are empty so i18next falls back to that key.
+
+## Writing translatable copy
+
+Import and use the `t()` helper established by the app or service you are
+changing. Keep a complete sentence in one translation call so translators can
+change its word order.
+
+```jsx
+// Do
+t('Could not sign in. Please try again.')
+
+// Do not split one message across translation calls
+t('Could not sign in.') + ' ' + t('Please try again.')
+```
+
+Use named variables for dynamic values:
+
+```jsx
+t('Welcome back, {name}!', {name: member.name})
+```
+
+When a message contains a link, button, or other element, keep the full message
+in one string and use `@doist/react-interpolate`:
+
+```jsx
+import Interpolate from '@doist/react-interpolate';
+
+}}
+ string={t('Having trouble? Read the help guide.')}
+/>
+```
+
+Do not build a sentence from translated fragments. Preserve the names of
+`{variables}` and ``: they form part of the runtime contract and are
+validated across locales.
+
+## Extracting strings
+
+After adding or changing a source string, run from the repository root:
+
+```bash
+pnpm --filter @tryghost/i18n translate
+```
+
+This extracts source strings, updates all locale files, and synchronizes
+`packages/i18n/locales/context.json`. Add a useful description for each new
+entry in `context.json` so translators know where the message appears and what
+it means. CI rejects extraction changes and empty context descriptions.
+
+Commit the source change, generated locale changes, and context changes
+together.
+
+## Checking changes
+
+Run the package checks from the repository root:
+
+```bash
+pnpm --filter @tryghost/i18n lint:translations
+pnpm --filter @tryghost/i18n test
+```
+
+The translation linter checks that locale values use the variables defined by
+their English message. The package tests also run extraction, so review the
+resulting diff and commit any expected generated changes.
From 002deb8b5c0f9f6888f8d41e95a98a19b00692a3 Mon Sep 17 00:00:00 2001
From: Austin Burdine
Date: Thu, 13 Aug 2026 07:16:32 -0400
Subject: [PATCH 03/25] =?UTF-8?q?=E2=9C=A8=20Added=20support=20for=20Docke?=
=?UTF-8?q?r=20secrets=20to=20config=20loading=20(#29924)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ref https://github.com/TryGhost/docker-library-ghost/issues/429
- add support for _FILE-suffixed environment variables pointing to local files for secrets loading, in keeping with existing Docker conventions
---
ghost/core/core/shared/config/loader.ts | 13 +-
ghost/core/core/shared/config/secrets.ts | 83 +++++++++++
.../test/unit/shared/config/loader.test.js | 55 +++++++
.../test/unit/shared/config/secrets.test.ts | 137 ++++++++++++++++++
4 files changed, 286 insertions(+), 2 deletions(-)
create mode 100644 ghost/core/core/shared/config/secrets.ts
create mode 100644 ghost/core/test/unit/shared/config/secrets.test.ts
diff --git a/ghost/core/core/shared/config/loader.ts b/ghost/core/core/shared/config/loader.ts
index 6f240a96e16..51659eca5bc 100644
--- a/ghost/core/core/shared/config/loader.ts
+++ b/ghost/core/core/shared/config/loader.ts
@@ -2,6 +2,7 @@ import Nconf from 'nconf';
import path from 'node:path';
import {bindAll as bindUrlHelpers, type BoundHelpers} from '@tryghost/config-url-helpers';
import * as localUtils from './utils';
+import {loadSecretsFromEnv, isSecretFileRef} from './secrets';
import {bindAll as bindHelpers, type ConfigHelpers} from './helpers';
const _debug = require('@tryghost/debug')._base;
@@ -28,9 +29,17 @@ function loadNconf(options?: LoadNconfOptions): ConfigInstance {
// no channel can override the overrides
nconf.file('overrides', path.join(baseConfigPath, 'overrides.json'));
- // command line arguments take precedence, then environment variables
+ // command line arguments take precedence, then secret files, then environment variables
nconf.argv();
- nconf.env({separator: '__', parseValues: true});
+ // secrets are not parsed - a password like `01234` must stay a string
+ nconf.add('secrets', {type: 'literal', store: loadSecretsFromEnv()});
+ nconf.env({
+ separator: '__',
+ parseValues: true,
+ // the secrets store has already resolved these, so keep the file paths themselves
+ // out of config - otherwise e.g. `database:connection` gains a bogus `password_FILE` key
+ transform: ({key, value}: {key: string, value: string}) => (isSecretFileRef(key) ? false : {key, value})
+ });
// Now load various config json files
nconf.file('custom-env', path.join(customConfigPath, 'config.' + env + '.json'));
diff --git a/ghost/core/core/shared/config/secrets.ts b/ghost/core/core/shared/config/secrets.ts
new file mode 100644
index 00000000000..56c15e4e940
--- /dev/null
+++ b/ghost/core/core/shared/config/secrets.ts
@@ -0,0 +1,83 @@
+import fs from 'node:fs';
+import {setWith} from 'lodash';
+
+const SUFFIX = '_file';
+
+type SecretStore = Record;
+
+/**
+ * Whether an env var name is a reference to a secret file rather than a config value itself.
+ */
+function isSecretFileRef(name: string, separator: string = '__'): boolean {
+ if (!name.toLowerCase().endsWith(SUFFIX)) {
+ return false;
+ }
+
+ const keyPath = name.slice(0, -SUFFIX.length).split(separator);
+
+ // the key must be nested, which keeps unrelated env vars such as SSL_CERT_FILE out
+ return keyPath.length >= 2 && keyPath.every(segment => !!segment);
+}
+
+/**
+ * Resolve `_FILE` env vars into config values by reading the file they point at,
+ * so secrets can be mounted (Docker/Swarm secrets, k8s projected volumes, systemd LoadCredential)
+ * instead of being passed as plaintext env vars.
+ *
+ * database__connection__password_FILE=/run/secrets/db_password
+ *
+ * The suffix is matched case-insensitively.
+ *
+ * Returns a plain object suitable for an nconf `literal` store.
+ */
+function loadSecretsFromEnv(env: NodeJS.ProcessEnv = process.env, separator: string = '__'): SecretStore {
+ const store: SecretStore = {};
+ const seen = new Map();
+
+ for (const [name, filePath] of Object.entries(env)) {
+ if (!isSecretFileRef(name, separator) || !filePath) {
+ continue;
+ }
+
+ const varName = name.slice(0, -SUFFIX.length);
+
+ if (env[varName] !== undefined) {
+ // new Error is allowed here, as we do not want config to depend on @tryghost/error
+ // eslint-disable-next-line ghost/ghost-custom/no-native-error
+ throw new Error(`Cannot set both ${varName} and ${name} - use one or the other.`);
+ }
+
+ const duplicate = seen.get(varName);
+
+ if (duplicate) {
+ // env var names are case-sensitive on POSIX, so the same key can be set twice —
+ // only a conflict if they disagree about which file to read
+ if (duplicate.filePath !== filePath) {
+ // eslint-disable-next-line ghost/ghost-custom/no-native-error
+ throw new Error(`Cannot set both ${duplicate.name} and ${name} to different files - they resolve to the same config key.`);
+ }
+
+ continue;
+ }
+
+ seen.set(varName, {name, filePath});
+
+ let contents: string;
+
+ try {
+ contents = fs.readFileSync(filePath, 'utf8');
+ } catch (err) {
+ // eslint-disable-next-line ghost/ghost-custom/no-native-error
+ throw new Error(`Could not read the secret file referenced by ${name}: ${filePath}`, {cause: err});
+ }
+
+ // strip a single trailing newline, matching `$(cat file)` behaviour, but leave
+ // any other surrounding whitespace alone in case it is part of the secret
+ // setWith with Object keeps numeric key segments as plain objects rather than arrays
+ setWith(store, varName.split(separator), contents.replace(/\r?\n$/, ''), Object);
+ }
+
+ return store;
+}
+
+export {loadSecretsFromEnv, isSecretFileRef};
diff --git a/ghost/core/test/unit/shared/config/loader.test.js b/ghost/core/test/unit/shared/config/loader.test.js
index 2949d8bc149..669f93864bc 100644
--- a/ghost/core/test/unit/shared/config/loader.test.js
+++ b/ghost/core/test/unit/shared/config/loader.test.js
@@ -1,4 +1,6 @@
const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
const path = require('path');
const _ = require('lodash');
const configUtils = require('../../../utils/config-utils');
@@ -17,10 +19,18 @@ describe('Config Loader', function () {
let originalArgv;
let customConfig;
let loader;
+ let tmpDir;
+
+ function writeSecret(contents) {
+ const filePath = path.join(tmpDir, 'secret');
+ fs.writeFileSync(filePath, contents);
+ return filePath;
+ }
beforeEach(function () {
originalEnv = _.clone(process.env);
originalArgv = _.clone(process.argv);
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-loader-'));
loader = require('../../../../core/shared/config/loader');
// getNodeEnv() reads process.env.NODE_ENV, so drive that directly
process.env.NODE_ENV = 'testing';
@@ -35,6 +45,7 @@ describe('Config Loader', function () {
afterEach(function () {
process.env = originalEnv;
process.argv = originalArgv;
+ fs.rmSync(tmpDir, {recursive: true, force: true});
sinon.restore();
});
@@ -61,6 +72,50 @@ describe('Config Loader', function () {
assert.equal(customConfig.get('database:client'), 'stronger');
});
+ it('secret file is stronger than file', function () {
+ process.env.logging__level_FILE = writeSecret('warn\n');
+
+ customConfig = loader.loadNconf({
+ baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'),
+ customConfigPath: path.join(__dirname, '../../../utils/fixtures/config')
+ });
+
+ assert.equal(customConfig.get('logging:level'), 'warn');
+ });
+
+ it('argv is stronger than a secret file', function () {
+ process.env.logging__level_FILE = writeSecret('warn\n');
+ process.argv[2] = '--logging:level=stronger';
+
+ customConfig = loader.loadNconf({
+ baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'),
+ customConfigPath: path.join(__dirname, '../../../utils/fixtures/config')
+ });
+
+ assert.equal(customConfig.get('logging:level'), 'stronger');
+ });
+
+ it('does not leak the secret file path into config', function () {
+ process.env.database__connection__password_FILE = writeSecret('hunter2\n');
+
+ customConfig = loader.loadNconf({
+ baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'),
+ customConfigPath: path.join(__dirname, '../../../utils/fixtures/config')
+ });
+
+ assert.equal(customConfig.get('database:connection:password_FILE'), undefined);
+ });
+
+ it('throws if a value and its secret file are both set', function () {
+ process.env.logging__level = 'warn';
+ process.env.logging__level_FILE = writeSecret('error\n');
+
+ assert.throws(() => loader.loadNconf({
+ baseConfigPath: path.join(__dirname, '../../../utils/fixtures/config'),
+ customConfigPath: path.join(__dirname, '../../../utils/fixtures/config')
+ }), /Cannot set both logging__level and logging__level_FILE/);
+ });
+
it('argv or env is NOT stronger than overrides', function () {
process.env.paths__corePath = 'try-to-override';
process.argv[2] = '--paths:corePath=try-to-override';
diff --git a/ghost/core/test/unit/shared/config/secrets.test.ts b/ghost/core/test/unit/shared/config/secrets.test.ts
new file mode 100644
index 00000000000..0be4d19e569
--- /dev/null
+++ b/ghost/core/test/unit/shared/config/secrets.test.ts
@@ -0,0 +1,137 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {loadSecretsFromEnv} from '../../../../core/shared/config/secrets';
+
+describe('Config Secrets', function () {
+ let tmpDir: string;
+
+ function writeSecret(name: string, contents: string): string {
+ const filePath = path.join(tmpDir, name);
+ fs.writeFileSync(filePath, contents);
+ return filePath;
+ }
+
+ beforeEach(function () {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-secrets-'));
+ });
+
+ afterEach(function () {
+ fs.rmSync(tmpDir, {recursive: true, force: true});
+ });
+
+ it('resolves a nested key from a file', function () {
+ const filePath = writeSecret('db', 'hunter2');
+
+ assert.deepEqual(loadSecretsFromEnv({database__connection__password_FILE: filePath}), {
+ database: {connection: {password: 'hunter2'}}
+ });
+ });
+
+ it('matches the suffix case-insensitively', function () {
+ const filePath = writeSecret('db', 'hunter2');
+
+ assert.deepEqual(loadSecretsFromEnv({database__connection__password_file: filePath}), {
+ database: {connection: {password: 'hunter2'}}
+ });
+ });
+
+ it('does not parse values', function () {
+ const filePath = writeSecret('db', '01234');
+
+ assert.deepEqual(loadSecretsFromEnv({database__connection__password_FILE: filePath}), {
+ database: {connection: {password: '01234'}}
+ });
+ });
+
+ it('strips a single trailing newline but keeps other whitespace', function () {
+ const trailing = writeSecret('trailing', 'hunter2\n');
+ const surrounding = writeSecret('surrounding', ' hunter2 ');
+ const multiple = writeSecret('multiple', 'hunter2\n\n');
+
+ assert.deepEqual(loadSecretsFromEnv({a__b_FILE: trailing}), {a: {b: 'hunter2'}});
+ assert.deepEqual(loadSecretsFromEnv({a__b_FILE: surrounding}), {a: {b: ' hunter2 '}});
+ assert.deepEqual(loadSecretsFromEnv({a__b_FILE: multiple}), {a: {b: 'hunter2\n'}});
+ });
+
+ it('merges multiple secrets under a shared parent', function () {
+ const user = writeSecret('user', 'ghost');
+ const pass = writeSecret('pass', 'hunter2');
+
+ assert.deepEqual(loadSecretsFromEnv({
+ mail__options__auth__user_FILE: user,
+ mail__options__auth__pass_FILE: pass
+ }), {
+ mail: {options: {auth: {user: 'ghost', pass: 'hunter2'}}}
+ });
+ });
+
+ it('ignores env vars that are not nested config keys', function () {
+ const filePath = writeSecret('ca', 'not-a-secret');
+
+ assert.deepEqual(loadSecretsFromEnv({
+ SSL_CERT_FILE: filePath,
+ CURL_CA_BUNDLE: filePath,
+ _FILE: filePath,
+ database____password_FILE: filePath
+ }), {});
+ });
+
+ it('ignores env vars with an empty value', function () {
+ assert.deepEqual(loadSecretsFromEnv({database__connection__password_FILE: ''}), {});
+ });
+
+ it('keeps numeric key segments as plain objects', function () {
+ const filePath = writeSecret('token', 'abc');
+ const store = loadSecretsFromEnv({adapters__0__token_FILE: filePath});
+
+ assert.deepEqual(store, {adapters: {0: {token: 'abc'}}});
+ assert.equal(Array.isArray(store.adapters), false);
+ });
+
+ it('throws if the value and its file reference are both set', function () {
+ const filePath = writeSecret('db', 'hunter2');
+
+ assert.throws(() => loadSecretsFromEnv({
+ database__connection__password: 'hunter2',
+ database__connection__password_FILE: filePath
+ }), /Cannot set both database__connection__password and database__connection__password_FILE/);
+ });
+
+ it('allows the same key set twice if both point at the same file', function () {
+ const filePath = writeSecret('db', 'hunter2');
+
+ assert.deepEqual(loadSecretsFromEnv({
+ database__connection__password_FILE: filePath,
+ database__connection__password_file: filePath
+ }), {
+ database: {connection: {password: 'hunter2'}}
+ });
+ });
+
+ it('throws if the same key is set twice pointing at different files', function () {
+ assert.throws(() => loadSecretsFromEnv({
+ database__connection__password_FILE: writeSecret('one', 'hunter2'),
+ database__connection__password_file: writeSecret('two', 'hunter3')
+ }), /to different files/);
+ });
+
+ it('throws if the file cannot be read', function () {
+ const filePath = path.join(tmpDir, 'nope');
+
+ assert.throws(() => loadSecretsFromEnv({database__connection__password_FILE: filePath}), (err: Error) => {
+ assert.match(err.message, /Could not read the secret file referenced by database__connection__password_FILE/);
+ assert.equal((err.cause as NodeJS.ErrnoException).code, 'ENOENT');
+ return true;
+ });
+ });
+
+ it('supports a custom separator', function () {
+ const filePath = writeSecret('db', 'hunter2');
+
+ assert.deepEqual(loadSecretsFromEnv({'database.connection.password_FILE': filePath}, '.'), {
+ database: {connection: {password: 'hunter2'}}
+ });
+ });
+});
From 7481efc9509812c1d1d40715687be0329cf2d614 Mon Sep 17 00:00:00 2001
From: Hannah Wolfe
Date: Thu, 13 Aug 2026 13:00:20 +0100
Subject: [PATCH 04/25] Updated public app development commands (#29932)
Some of the public app READMEs had stale information and were
functionally incorrect. This is part of work to bring codebase docs
into the codebase, and make sure the information is consistent,
correct and coherent.
---
apps/admin-toolbar/README.md | 14 ++++++++++----
apps/announcement-bar/README.md | 14 +++++++-------
apps/comments-ui/README.md | 11 +++++++----
apps/signup-form/README.md | 31 +++++++++++--------------------
apps/sodo-search/README.md | 12 ++++++------
5 files changed, 41 insertions(+), 41 deletions(-)
diff --git a/apps/admin-toolbar/README.md b/apps/admin-toolbar/README.md
index 18679b1eef5..cccf3cd41d5 100644
--- a/apps/admin-toolbar/README.md
+++ b/apps/admin-toolbar/README.md
@@ -7,18 +7,24 @@ scripts where bundle size matters more than ecosystem compatibility.
## Development
+Run `pnpm dev:public` from the monorepo root to start the standard development
+environment and the Admin Toolbar watcher. To work on this package by itself,
+run these commands from this directory:
+
```bash
pnpm build # one-off build
-pnpm dev # build + preview with watch (started automatically by pnpm dev from root)
-pnpm test # build + run tests against UMD bundle
+pnpm dev # watch and rebuild umd/admin-toolbar.min.js
+pnpm test # build + run tests against the built bundle
```
## How it's served
In production, the script is loaded from jsDelivr via the `adminToolbar` config
in `defaults.json`, following the same CDN pattern as portal, comments-ui, and
-the other public apps. In development, the Docker Dockerfile overrides the URL
-to proxy through Caddy to the local vite preview server on port 4176.
+the other public apps. In development, `docker/ghost-dev/Dockerfile` overrides
+that URL to `/ghost/assets/admin-toolbar/admin-toolbar.min.js`, which the dev
+gateway serves straight off disk from this package's `umd/` directory — so the
+watcher's output is picked up on the next request.
# Copyright & License
diff --git a/apps/announcement-bar/README.md b/apps/announcement-bar/README.md
index ad56d5839fa..31402c71829 100644
--- a/apps/announcement-bar/README.md
+++ b/apps/announcement-bar/README.md
@@ -4,17 +4,17 @@
### Pre-requisites
-- Run `pnpm` in Ghost monorepo root
-- Run `pnpm` in this directory
+- Run `pnpm setup` in the Ghost monorepo root
-### Running via Ghost `pnpm dev` in root folder
+### Running via Ghost from the monorepo root
+
+Start Ghost with the public-app watchers enabled:
-Announcement Bar runs automatically when using Ghost's development command from the monorepo root:
```bash
-pnpm dev
+pnpm dev:public
```
-This starts all frontend apps (including Announcement Bar.)
+This starts the standard development environment and the Announcement Bar watcher. To run only the package's build watcher, use `pnpm dev` from this directory.
## Release
@@ -32,7 +32,7 @@ In either case, you need sufficient permissions to release `@tryghost` packages
2. Merge the release commit to `main`
3. Wait until a new version of Ghost is released
-To use the new version of signup form in Ghost, update the version in Ghost core's default configuration (currently at `core/shared/config/default.json`)
+To use the new version of Announcement Bar in Ghost, update the version in Ghost core's default configuration (currently at `core/shared/config/default.json`)
# Copyright & License
diff --git a/apps/comments-ui/README.md b/apps/comments-ui/README.md
index 2df85697efe..223e54a7a54 100644
--- a/apps/comments-ui/README.md
+++ b/apps/comments-ui/README.md
@@ -6,15 +6,18 @@ Comments widget that is embedded at the bottom of posts in Ghost.
### Pre-requisites
-- Run `pnpm` in Ghost monorepo root
+- Run `pnpm setup` in the Ghost monorepo root
-### Running via Ghost `pnpm dev` in root folder
+### Running via Ghost from the monorepo root
+
+Start Ghost with the public-app watchers enabled:
-Comments UI runs automatically when using Ghost's development command from the monorepo root:
```bash
-pnpm dev
+pnpm dev:public
```
+This starts the standard development environment and the Comments UI watcher. To run only the package's build watcher, use `pnpm dev` from this directory.
+
## Release
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
diff --git a/apps/signup-form/README.md b/apps/signup-form/README.md
index bb2106ee9c5..b5562b86102 100644
--- a/apps/signup-form/README.md
+++ b/apps/signup-form/README.md
@@ -6,46 +6,37 @@ Embed a Ghost signup form on any site.
### Pre-requisites
-- Run `pnpm` in Ghost monorepo root
-- Run `pnpm` in this directory
+- Run `pnpm setup` in the Ghost monorepo root
-### Running via Ghost `pnpm dev` in root folder
+### Running via Ghost from the monorepo root
+
+Start Ghost with the public-app watchers enabled:
-Signup Form runs automatically when using Ghost's development command from the monorepo root:
```bash
-pnpm dev
+pnpm dev:public
```
-This starts all frontend apps (including Signup Form.)
+This starts the standard development environment and the Signup Form watcher.
### Running the standalone demo page
Run `pnpm dev:standalone` (in this package folder) to start the standalone development server with HMR for testing/developing the form in isolation.
- This serves the demo page at http://localhost:6173
-`pnpm dev` on its own (in this package folder) only builds `umd/signup-form.min.js` and watches for changes — it does not bind a port. The UMD is served by Caddy at http://localhost:2368/ghost/assets/signup-form/signup-form.min.js when you run `pnpm dev` from the monorepo root.
+`pnpm dev` on its own (in this package folder) only builds `umd/signup-form.min.js` and watches for changes — it does not bind a port. The UMD is served by Caddy at http://localhost:2368/ghost/assets/signup-form/signup-form.min.js when you run `pnpm dev:public` from the monorepo root.
### Using the UMD build during development
Vite by default only supports HRM with an ESM output. But when loading a script on a site as a ESM module (`');
- giftServiceWrapper.service = {
- getPreview: sinon.stub().resolves({
- tier: {id: 'tier_1', name: 'Premium'},
- cadence: 'month',
- duration: 3
- })
- };
+ giftService.getPreview.resolves({
+ tier: {id: 'tier_1', name: 'Premium'},
+ cadence: 'month',
+ duration: 3
+ });
await controller.giftPreview(req, res);
@@ -113,13 +121,11 @@ describe('Gift Preview Controller', function () {
});
it('uses monthly cadence label', async function () {
- giftServiceWrapper.service = {
- getPreview: sinon.stub().resolves({
- tier: {id: 'tier_1', name: 'Premium'},
- cadence: 'month',
- duration: 3
- })
- };
+ giftService.getPreview.resolves({
+ tier: {id: 'tier_1', name: 'Premium'},
+ cadence: 'month',
+ duration: 3
+ });
await controller.giftPreview(req, res);
@@ -131,13 +137,11 @@ describe('Gift Preview Controller', function () {
it('defaults site title to Ghost', async function () {
settingsCache.get.withArgs('title').returns(null);
- giftServiceWrapper.service = {
- getPreview: sinon.stub().resolves({
- tier: {id: 'tier_1', name: 'Premium'},
- cadence: 'year',
- duration: 1
- })
- };
+ giftService.getPreview.resolves({
+ tier: {id: 'tier_1', name: 'Premium'},
+ cadence: 'year',
+ duration: 1
+ });
await controller.giftPreview(req, res);
@@ -149,13 +153,11 @@ describe('Gift Preview Controller', function () {
describe('giftPreviewImage', function () {
it('returns a PNG image for a valid gift', async function () {
- giftServiceWrapper.service = {
- getPreview: sinon.stub().resolves({
- tier: {id: 'tier_1', name: 'Gold'},
- cadence: 'year',
- duration: 1
- })
- };
+ giftService.getPreview.resolves({
+ tier: {id: 'tier_1', name: 'Gold'},
+ cadence: 'year',
+ duration: 1
+ });
await controller.giftPreviewImage(req, res);
From ec53d7394c3fbddf63cb85f7d0262cea17e55c27 Mon Sep 17 00:00:00 2001
From: Rob Lester
Date: Tue, 4 Aug 2026 17:56:22 +0100
Subject: [PATCH 22/25] =?UTF-8?q?=E2=9C=A8=20Released=20the=20React=20memb?=
=?UTF-8?q?er=20details=20screen?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ref https://linear.app/ghost/issue/BER-3848
The React member details screen has until now only been reachable per-site behind a Labs toggle, and self-hosted sites never read the remote flag manifest that switched it on for Ghost(Pro), so promoting the flag to generally available is what actually puts every site on the React screen, while Ghost(Pro) keeps its kill switch because remote overrides still sit above the GA list. Several browser tests had never enabled the flag and so had always exercised the Ember screen, encoding its behaviour: its straight apostrophe, its heading structure, its clickable label, a one-click enable control React does not offer, and its habit of letting an invalid email reach the server and of settling on a "Saved" state after creating a member. Those tests are brought into line with the screen that now actually serves them.
---
.../members/detail/member-activity-feed.tsx | 8 +-
.../advanced/labs/private-features.tsx | 4 -
.../admin/members/member-details-page.ts | 12 +-
.../members-legacy/disable-commenting.test.ts | 23 +-
.../admin/members-legacy/members.test.ts | 18 +-
.../members/import-custom-fields.test.ts | 5 +-
.../members/member-custom-fields.test.ts | 2 +-
e2e/tests/admin/members/member-detail.test.ts | 885 ++++++++----------
ghost/core/core/shared/labs.js | 4 +-
9 files changed, 424 insertions(+), 537 deletions(-)
diff --git a/apps/admin/src/members/detail/member-activity-feed.tsx b/apps/admin/src/members/detail/member-activity-feed.tsx
index c2bc2329b06..9ecf3445a53 100644
--- a/apps/admin/src/members/detail/member-activity-feed.tsx
+++ b/apps/admin/src/members/detail/member-activity-feed.tsx
@@ -185,8 +185,8 @@ const MemberActivityFeed: React.FC = ({memberId, hasMul
// `EmptyIndicator` so the parity assertion for that string holds.
if (!memberId) {
return (
-
-
Activity
+
+
Activity
{/* Same wrapper padding as the Subscriptions empty state
(`member-subscriptions-section.tsx`) so both cards line
@@ -204,8 +204,8 @@ const MemberActivityFeed: React.FC = ({memberId, hasMul
}
return (
-
-
Activity
+
+
Activity
{isLoading ? (
diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx
index d95a68b339f..d1fc22aa5b1 100644
--- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx
+++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx
@@ -55,10 +55,6 @@ const features: Feature[] = [{
title: 'Get helper deduplication',
description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls',
flag: 'getHelperDeduplication'
-}, {
- title: 'React member details',
- description: 'Renders the member detail screen (/members/:id) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.',
- flag: 'memberDetailsReact'
}, {
title: 'React tag details',
description: 'Renders the tag detail screen (/tags/:slug) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.',
diff --git a/e2e/helpers/pages/admin/members/member-details-page.ts b/e2e/helpers/pages/admin/members/member-details-page.ts
index cd62314b20e..54c8ed4c9b6 100644
--- a/e2e/helpers/pages/admin/members/member-details-page.ts
+++ b/e2e/helpers/pages/admin/members/member-details-page.ts
@@ -52,7 +52,6 @@ export class MemberDetailsPage extends AdminPage {
readonly saveButton: Locator;
readonly savedButton: Locator;
- readonly retryButton: Locator;
readonly membersBackLink: Locator;
readonly copyLinkButton: Locator;
@@ -61,14 +60,13 @@ export class MemberDetailsPage extends AdminPage {
readonly confirmLeaveButton: Locator;
readonly settingsSection: SettingsSection;
- readonly activityHeading: Locator;
+ readonly activityFeed: Locator;
readonly disableCommentingModal: Locator;
readonly disableCommentingConfirmButton: Locator;
readonly disableCommentingCancelButton: Locator;
readonly hideCommentsCheckbox: Locator;
readonly commentingDisabledIndicator: Locator;
- readonly enableCommentingLink: Locator;
readonly screenTitle: Locator;
readonly logoutConfirmModal: Locator;
@@ -104,21 +102,19 @@ export class MemberDetailsPage extends AdminPage {
this.saveButton = page.getByRole('button', {name: 'Save'});
this.savedButton = page.getByRole('button', {name: 'Saved'});
- this.retryButton = page.getByRole('button', {name: 'Retry'});
this.membersBackLink = page.locator('[data-test-link="members-back"]').filter({visible: true});
this.copyLinkButton = page.getByRole('button', {name: 'Copy link'});
this.magicLinkInput = page.getByTestId('member-signin-url').filter({visible: true});
this.confirmLeaveButton = page.getByRole('button', {name: 'Leave'});
this.settingsSection = new SettingsSection(page);
- this.activityHeading = page.getByRole('heading', {name: 'Activity', level: 4});
+ this.activityFeed = page.getByRole('region', {name: 'Activity'});
this.disableCommentingModal = page.getByRole('dialog');
this.disableCommentingConfirmButton = this.disableCommentingModal.getByRole('button', {name: 'Disable commenting'});
this.disableCommentingCancelButton = this.disableCommentingModal.getByRole('button', {name: 'Cancel'});
- this.hideCommentsCheckbox = this.disableCommentingModal.getByText('Hide all previous comments');
+ this.hideCommentsCheckbox = this.disableCommentingModal.getByRole('switch', {name: 'Hide all previous comments'});
this.commentingDisabledIndicator = page.getByText('Comments disabled');
- this.enableCommentingLink = page.getByRole('button', {name: 'Enable', exact: true});
this.screenTitle = page.locator('[data-test-screen-title]')
.or(page.getByTestId('member-detail-title'))
@@ -227,6 +223,6 @@ export class MemberDetailsPage extends AdminPage {
}
getActivityEventByText(text: string | RegExp): Locator {
- return this.activityHeading.locator('..').getByText(text);
+ return this.activityFeed.getByText(text);
}
}
diff --git a/e2e/tests/admin/members-legacy/disable-commenting.test.ts b/e2e/tests/admin/members-legacy/disable-commenting.test.ts
index b9ef85d92e8..fc8a4881ce8 100644
--- a/e2e/tests/admin/members-legacy/disable-commenting.test.ts
+++ b/e2e/tests/admin/members-legacy/disable-commenting.test.ts
@@ -36,7 +36,9 @@ test.describe('Ghost Admin - Member Detail Disable Commenting', () => {
await expect(memberDetailsPage.disableCommentingModal).toBeVisible();
await expect(memberDetailsPage.disableCommentingModal).toContainText('Test Member');
- await expect(memberDetailsPage.disableCommentingModal).toContainText('won\'t be able to comment');
+ // Either apostrophe: the warning is what matters, not the glyph the
+ // copy happens to use.
+ await expect(memberDetailsPage.disableCommentingModal).toContainText(/won['’]t be able to comment/);
await expect(memberDetailsPage.disableCommentingConfirmButton).toBeVisible();
await expect(memberDetailsPage.disableCommentingCancelButton).toBeVisible();
});
@@ -147,25 +149,6 @@ test.describe('Ghost Admin - Member Detail Disable Commenting', () => {
await expect(memberDetailsPage.settingsSection.disableCommentingButton).toBeVisible();
await expect(memberDetailsPage.settingsSection.enableCommentingButton).toBeHidden();
});
-
- test('enabling via sidebar link removes indicator', async ({page}) => {
- const {name} = await memberFactory.create();
-
- const membersPage = new MembersPage(page);
- await membersPage.goto();
- await membersPage.getMemberByName(name!).click();
-
- const memberDetailsPage = new MemberDetailsPage(page);
-
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.disableCommentingButton.click();
- await memberDetailsPage.disableCommentingConfirmButton.click();
- await expect(memberDetailsPage.commentingDisabledIndicator).toBeVisible();
-
- await memberDetailsPage.enableCommentingLink.click();
-
- await expect(memberDetailsPage.commentingDisabledIndicator).toBeHidden();
- });
});
});
diff --git a/e2e/tests/admin/members-legacy/members.test.ts b/e2e/tests/admin/members-legacy/members.test.ts
index 9274c25d49c..e75bc4cc7b5 100644
--- a/e2e/tests/admin/members-legacy/members.test.ts
+++ b/e2e/tests/admin/members-legacy/members.test.ts
@@ -21,7 +21,10 @@ test.describe('Ghost Admin - Legacy Member Detail Flows', () => {
const memberDetailsPage = new MemberDetailsPage(page);
await memberDetailsPage.fillMemberDetails(memberToCreate.name!, memberToCreate.email, memberToCreate.note!);
- await memberDetailsPage.save();
+ await memberDetailsPage.saveButton.click();
+ // Creating redirects to the new member's own URL; the Save button never
+ // settles on "Saved" here, so the redirect is the completion signal.
+ await expect(page).toHaveURL(/#\/members\/[0-9a-f]{24}/);
await membersPage.goto();
@@ -38,10 +41,8 @@ test.describe('Ghost Admin - Legacy Member Detail Flows', () => {
const memberDetailsPage = new MemberDetailsPage(page);
await memberDetailsPage.fillMemberDetails(memberToCreate.name!, memberToCreate.email, memberToCreate.note!);
- await memberDetailsPage.saveButton.click();
- await expect(memberDetailsPage.retryButton).toBeVisible();
- await expect(memberDetailsPage.body).toContainText('Invalid Email');
+ await expect(memberDetailsPage.saveButton).toBeDisabled();
});
test('updates an existing member', async ({page}) => {
@@ -83,11 +84,14 @@ test.describe('Ghost Admin - Legacy Member Detail Flows', () => {
await membersPage.openMemberByName(memberToEdit.name!);
const memberDetailsPage = new MemberDetailsPage(page);
+ // Save also starts disabled while the form is untouched, so a valid edit has to
+ // enable it first for the assertion below to be about the email at all.
+ await memberDetailsPage.nameInput.fill('Test Member Edited');
+ await expect(memberDetailsPage.saveButton).toBeEnabled();
+
await memberDetailsPage.emailInput.fill('invalid-email-address');
- await memberDetailsPage.saveButton.click();
- await expect(memberDetailsPage.retryButton).toBeVisible();
- await expect(memberDetailsPage.body).toContainText('Invalid Email');
+ await expect(memberDetailsPage.saveButton).toBeDisabled();
});
test('deletes an existing member', async ({page}) => {
diff --git a/e2e/tests/admin/members/import-custom-fields.test.ts b/e2e/tests/admin/members/import-custom-fields.test.ts
index aad71ae8883..e47d82d1f78 100644
--- a/e2e/tests/admin/members/import-custom-fields.test.ts
+++ b/e2e/tests/admin/members/import-custom-fields.test.ts
@@ -13,13 +13,12 @@ import {usePerTestIsolation} from '@/helpers/playwright/isolation';
* two things only the browser exercises -- the export -> import loop end to end, and the
* mapping step both auto-detecting an exported column and taking a hand-picked target.
*
- * Behind membersCustomFields (the whole feature) and memberDetailsReact (the member detail
- * screen that renders a field's value).
+ * Behind membersCustomFields, which gates the whole feature.
*/
usePerTestIsolation();
test.describe('Ghost Admin - Members import with custom fields', () => {
- test.use({labs: {membersCustomFields: true, memberDetailsReact: true}});
+ test.use({labs: {membersCustomFields: true}});
test('an exported custom field value round-trips back through import, auto-mapped', async ({page}) => {
const ts = Date.now();
diff --git a/e2e/tests/admin/members/member-custom-fields.test.ts b/e2e/tests/admin/members/member-custom-fields.test.ts
index c200a92fdf8..9171b316b56 100644
--- a/e2e/tests/admin/members/member-custom-fields.test.ts
+++ b/e2e/tests/admin/members/member-custom-fields.test.ts
@@ -16,7 +16,7 @@ import {usePerTestIsolation} from '@/helpers/playwright/isolation';
usePerTestIsolation();
test.describe('Ghost Admin - Member custom fields', () => {
- test.use({labs: {membersCustomFields: true, memberDetailsReact: true}});
+ test.use({labs: {membersCustomFields: true}});
test('a field defined in settings takes a value on a member and persists it', async ({page}) => {
const fieldName = `Job title ${Date.now()}`;
diff --git a/e2e/tests/admin/members/member-detail.test.ts b/e2e/tests/admin/members/member-detail.test.ts
index 8f19f0917b4..cc449ca7ff9 100644
--- a/e2e/tests/admin/members/member-detail.test.ts
+++ b/e2e/tests/admin/members/member-detail.test.ts
@@ -7,16 +7,10 @@ import {usePerTestIsolation} from '@/helpers/playwright/isolation';
/**
* Behaviour contract for `/members/:id`.
*
- * Ember and React implementations of this screen coexist behind the
- * `memberDetailsReact` Labs flag, and this file runs the same assertions
- * against both by generating one describe block per flag state. No test body
- * knows which implementation is rendering it, and none may branch on it.
- *
- * A test that passes under one block and fails under the other is a real
- * user-facing difference between the two screens. That is the point: it makes
- * a gap impossible to hide behind a conditional.
- *
- * Anything true of only one implementation does not belong here.
+ * The assertions here were written to run against both the Ember and the React
+ * screen, so they describe what the screen does rather than how it is built.
+ * Keep them that way: a test that reaches for markup specific to the current
+ * implementation stops being a contract and starts being a snapshot.
*/
usePerTestIsolation();
@@ -173,424 +167,414 @@ const captureWrite = async (page: Page, urlPattern: string | RegExp, onCapture?:
return sent;
};
-for (const {implementation, memberDetailsReact} of [
- {implementation: 'Ember', memberDetailsReact: false},
- {implementation: 'React', memberDetailsReact: true}
-] as const) {
- test.describe(`Ghost Admin - Member Detail (${implementation})`, () => {
- test.use({labs: {memberDetailsReact}});
+test.describe('Ghost Admin - Member Detail', () => {
+ let memberFactory: MemberFactory;
+ let memberDetailsPage: MemberDetailsPage;
- let memberFactory: MemberFactory;
- let memberDetailsPage: MemberDetailsPage;
+ test.beforeEach(async ({page}) => {
+ memberFactory = createMemberFactory(page.request);
+ memberDetailsPage = new MemberDetailsPage(page);
+ });
- test.beforeEach(async ({page}) => {
- memberFactory = createMemberFactory(page.request);
- memberDetailsPage = new MemberDetailsPage(page);
- });
+ test('member name renders in the screen title', async ({page}) => {
+ const member = await memberFactory.create({name: 'Ada Lovelace', email: 'ada-detail@ghost.org'});
- test('member name renders in the screen title', async ({page}) => {
- const member = await memberFactory.create({name: 'Ada Lovelace', email: 'ada-detail@ghost.org'});
+ await page.goto(memberPath(member.id));
- await page.goto(memberPath(member.id));
+ await expect(memberDetailsPage.screenTitle).toContainText('Ada Lovelace');
+ });
- await expect(memberDetailsPage.screenTitle).toContainText('Ada Lovelace');
- });
+ test('editing the member name - persists to the server', async ({page}) => {
+ const member = await memberFactory.create({name: 'Grace', email: 'grace-detail@ghost.org'});
- test('editing the member name - persists to the server', async ({page}) => {
- const member = await memberFactory.create({name: 'Grace', email: 'grace-detail@ghost.org'});
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.nameInput.fill('Grace Hopper');
+ await memberDetailsPage.saveButton.click();
- await page.goto(memberPath(member.id));
- await memberDetailsPage.nameInput.fill('Grace Hopper');
- await memberDetailsPage.saveButton.click();
-
- await expect.poll(async () => {
- const res = await page.request.get(`/ghost/api/admin/members/${member.id}/`);
- const body = await res.json();
- return body?.members?.[0]?.name;
- }, {timeout: 10000}).toBe('Grace Hopper');
- });
+ await expect.poll(async () => {
+ const res = await page.request.get(`/ghost/api/admin/members/${member.id}/`);
+ const body = await res.json();
+ return body?.members?.[0]?.name;
+ }, {timeout: 10000}).toBe('Grace Hopper');
+ });
- test('clicking the back link - returns to the members list', async ({page}) => {
- const member = await memberFactory.create({name: 'Grace Hopper', email: 'grace-back-detail@ghost.org'});
+ test('clicking the back link - returns to the members list', async ({page}) => {
+ const member = await memberFactory.create({name: 'Grace Hopper', email: 'grace-back-detail@ghost.org'});
- await page.goto(memberPath(member.id));
- await memberDetailsPage.membersBackLink.click();
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.membersBackLink.click();
- await expect(page).toHaveURL(/#\/members$/);
- });
+ await expect(page).toHaveURL(/#\/members$/);
+ });
- test('impersonation modal - exposes a real signin url', async ({page}) => {
- const member = await memberFactory.create({name: 'Alan Turing', email: 'alan-detail@ghost.org'});
+ test('impersonation modal - exposes a real signin url', async ({page}) => {
+ const member = await memberFactory.create({name: 'Alan Turing', email: 'alan-detail@ghost.org'});
- await page.goto(memberPath(member.id));
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.impersonateButton.click();
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.settingsSection.memberActionsButton.click();
+ await memberDetailsPage.settingsSection.impersonateButton.click();
- // The url is fetched after the modal opens, so assert on the value to
- // let Playwright wait rather than reading the empty initial state.
- await expect(memberDetailsPage.magicLinkInput).toHaveValue(/^https?:\/\/.+/);
- });
+ // The url is fetched after the modal opens, so assert on the value to
+ // let Playwright wait rather than reading the empty initial state.
+ await expect(memberDetailsPage.magicLinkInput).toHaveValue(/^https?:\/\/.+/);
+ });
- test('signing out of all devices - closes the confirmation and stays on the member', async ({page}) => {
- const member = await memberFactory.create({name: 'Rear Admiral', email: 'rear-detail@ghost.org'});
+ test('signing out of all devices - closes the confirmation and stays on the member', async ({page}) => {
+ const member = await memberFactory.create({name: 'Rear Admiral', email: 'rear-detail@ghost.org'});
- await page.goto(memberPath(member.id));
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.signOutOfAllDevices.click();
- // Scoped to the modal so the click can't hit the account-owner
- // "Sign out" button in the admin sidebar dropdown.
- await memberDetailsPage.logoutConfirmModal.getByRole('button', {name: 'Sign out', exact: true}).click();
-
- await expect(memberDetailsPage.logoutConfirmModal).toHaveCount(0);
- await expect(page).toHaveURL(new RegExp(`#/members/${member.id}`));
- });
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.settingsSection.memberActionsButton.click();
+ await memberDetailsPage.settingsSection.signOutOfAllDevices.click();
+ // Scoped to the modal so the click can't hit the account-owner
+ // "Sign out" button in the admin sidebar dropdown.
+ await memberDetailsPage.logoutConfirmModal.getByRole('button', {name: 'Sign out', exact: true}).click();
- test('deleting a member - returns to the list and removes the record', async ({page}) => {
- const member = await memberFactory.create({name: 'Deletable', email: 'delete-detail@ghost.org'});
+ await expect(memberDetailsPage.logoutConfirmModal).toHaveCount(0);
+ await expect(page).toHaveURL(new RegExp(`#/members/${member.id}`));
+ });
- await page.goto(memberPath(member.id));
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.deleteButton.click();
- await memberDetailsPage.settingsSection.confirmDeleteButton.click();
+ test('deleting a member - returns to the list and removes the record', async ({page}) => {
+ const member = await memberFactory.create({name: 'Deletable', email: 'delete-detail@ghost.org'});
- await expect(page).toHaveURL(/#\/members$/);
- const res = await page.request.get(`/ghost/api/admin/members/${member.id}/`);
- expect(res.status()).toBe(404);
- });
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.settingsSection.memberActionsButton.click();
+ await memberDetailsPage.settingsSection.deleteButton.click();
+ await memberDetailsPage.settingsSection.confirmDeleteButton.click();
- test('creating a member - persists to the server and redirects to the detail', async ({page}) => {
- const email = 'new-member-detail@ghost.org';
+ await expect(page).toHaveURL(/#\/members$/);
+ const res = await page.request.get(`/ghost/api/admin/members/${member.id}/`);
+ expect(res.status()).toBe(404);
+ });
- await page.goto(memberPath('new'));
- await memberDetailsPage.nameInput.fill('New Member');
- await memberDetailsPage.emailInput.fill(email);
- await memberDetailsPage.saveButton.click();
-
- let createdId: string | undefined;
- await expect.poll(async () => {
- const res = await page.request.get(`/ghost/api/admin/members/?filter=${encodeURIComponent(`email:'${email}'`)}`);
- const body = await res.json();
- createdId = body?.members?.[0]?.id;
- return body?.members?.[0]?.name;
- }, {timeout: 10000}).toBe('New Member');
- await expect(page).toHaveURL(new RegExp(`#/members/${createdId}(\\?|$)`));
- });
+ test('creating a member - persists to the server and redirects to the detail', async ({page}) => {
+ const email = 'new-member-detail@ghost.org';
+
+ await page.goto(memberPath('new'));
+ await memberDetailsPage.nameInput.fill('New Member');
+ await memberDetailsPage.emailInput.fill(email);
+ await memberDetailsPage.saveButton.click();
+
+ let createdId: string | undefined;
+ await expect.poll(async () => {
+ const res = await page.request.get(`/ghost/api/admin/members/?filter=${encodeURIComponent(`email:'${email}'`)}`);
+ const body = await res.json();
+ createdId = body?.members?.[0]?.id;
+ return body?.members?.[0]?.name;
+ }, {timeout: 10000}).toBe('New Member');
+ await expect(page).toHaveURL(new RegExp(`#/members/${createdId}(\\?|$)`));
+ });
- test('disabling then re-enabling commenting - clears the disabled indicator', async ({page}) => {
- const member = await memberFactory.create({name: 'Commenter', email: 'commenter-toggle@ghost.org'});
+ test('disabling then re-enabling commenting - clears the disabled indicator', async ({page}) => {
+ const member = await memberFactory.create({name: 'Commenter', email: 'commenter-toggle@ghost.org'});
- await page.goto(memberPath(member.id));
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.disableCommentingButton.click();
- await memberDetailsPage.disableCommentingConfirmButton.click();
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.settingsSection.memberActionsButton.click();
+ await memberDetailsPage.settingsSection.disableCommentingButton.click();
+ await memberDetailsPage.disableCommentingConfirmButton.click();
- await expect(memberDetailsPage.commentingDisabledIndicator).toBeVisible();
+ await expect(memberDetailsPage.commentingDisabledIndicator).toBeVisible();
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.enableCommentingButton.click();
+ await memberDetailsPage.settingsSection.memberActionsButton.click();
+ await memberDetailsPage.settingsSection.enableCommentingButton.click();
- await expect(memberDetailsPage.commentingDisabledIndicator).toBeHidden();
- });
+ await expect(memberDetailsPage.commentingDisabledIndicator).toBeHidden();
+ });
- test('sidebar - shows the signup location and created date', async ({page}) => {
- // Members created through the API carry no geolocation, so the
- // location falls back deterministically.
- const member = await memberFactory.create({name: 'Katherine Johnson', email: 'katherine-sidebar@ghost.org'});
+ test('sidebar - shows the signup location and created date', async ({page}) => {
+ // Members created through the API carry no geolocation, so the
+ // location falls back deterministically.
+ const member = await memberFactory.create({name: 'Katherine Johnson', email: 'katherine-sidebar@ghost.org'});
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- await expect(page.getByText('Unknown location')).toBeVisible();
- await expect(page.getByText(/Created/)).toBeVisible();
- });
+ await expect(page.getByText('Unknown location')).toBeVisible();
+ await expect(page.getByText(/Created/)).toBeVisible();
+ });
- test('leaving with unsaved changes - warns before navigating away', async ({page}) => {
- const member = await memberFactory.create({name: 'Grace Hopper', email: 'grace-unsaved@ghost.org'});
+ test('leaving with unsaved changes - warns before navigating away', async ({page}) => {
+ const member = await memberFactory.create({name: 'Grace Hopper', email: 'grace-unsaved@ghost.org'});
- await page.goto(memberPath(member.id));
- await memberDetailsPage.nameInput.fill('Grace B. Hopper');
- await memberDetailsPage.membersBackLink.click();
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.nameInput.fill('Grace B. Hopper');
+ await memberDetailsPage.membersBackLink.click();
- await expect(memberDetailsPage.confirmLeaveButton).toBeVisible();
- await memberDetailsPage.confirmLeaveButton.click();
- await expect(page).toHaveURL(/#\/members$/);
- });
+ await expect(memberDetailsPage.confirmLeaveButton).toBeVisible();
+ await memberDetailsPage.confirmLeaveButton.click();
+ await expect(page).toHaveURL(/#\/members$/);
+ });
- test('leaving with unsaved changes via the sidebar - warns before navigating away', async ({page}) => {
- // The sidebar navigates with native hash anchors rather than
- // client-side router links, so it exercises a different guard path
- // than the back link above; both must warn.
- const sidebar = new SidebarPage(page);
- const member = await memberFactory.create({name: 'Grace Hopper', email: 'grace-unsaved-sidebar@ghost.org'});
+ test('leaving with unsaved changes via the sidebar - warns before navigating away', async ({page}) => {
+ // The sidebar navigates with native hash anchors rather than
+ // client-side router links, so it exercises a different guard path
+ // than the back link above; both must warn.
+ const sidebar = new SidebarPage(page);
+ const member = await memberFactory.create({name: 'Grace Hopper', email: 'grace-unsaved-sidebar@ghost.org'});
- await page.goto(memberPath(member.id));
- await memberDetailsPage.nameInput.fill('Grace B. Hopper');
- await sidebar.getNavLink('Members').click();
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.nameInput.fill('Grace B. Hopper');
+ await sidebar.getNavLink('Members').click();
- await expect(memberDetailsPage.confirmLeaveButton).toBeVisible();
- await memberDetailsPage.confirmLeaveButton.click();
- await expect(page).toHaveURL(/#\/members$/);
- });
+ await expect(memberDetailsPage.confirmLeaveButton).toBeVisible();
+ await memberDetailsPage.confirmLeaveButton.click();
+ await expect(page).toHaveURL(/#\/members$/);
+ });
- test('toggling a newsletter - persists the new state', async ({page}) => {
- const member = await memberFactory.create({name: 'Newsletter Test', email: 'newsletter-toggle@ghost.org'});
+ test('toggling a newsletter - persists the new state', async ({page}) => {
+ const member = await memberFactory.create({name: 'Newsletter Test', email: 'newsletter-toggle@ghost.org'});
- await page.goto(memberPath(member.id));
- // Wait on the toggle, not the checkbox — Ember hides the real input
- // behind a styled span, so the control is never visible itself.
- await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
- const initiallyChecked = await memberDetailsPage.newsletterSubscriptionCheckboxes.first().isChecked();
+ await page.goto(memberPath(member.id));
+ // Wait on the toggle, not the checkbox — Ember hides the real input
+ // behind a styled span, so the control is never visible itself.
+ await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
+ const initiallyChecked = await memberDetailsPage.newsletterSubscriptionCheckboxes.first().isChecked();
- await memberDetailsPage.newsletterSubscriptionToggles.first().click();
- await memberDetailsPage.save();
- await page.reload();
+ await memberDetailsPage.newsletterSubscriptionToggles.first().click();
+ await memberDetailsPage.save();
+ await page.reload();
- await expect(memberDetailsPage.newsletterSubscriptionCheckboxes.first()).toBeChecked({checked: !initiallyChecked});
- });
+ await expect(memberDetailsPage.newsletterSubscriptionCheckboxes.first()).toBeChecked({checked: !initiallyChecked});
+ });
- test('activity feed - view-all link points at this members full activity', async ({page}) => {
- const member = await memberFactory.create({name: 'Activity Target', email: 'activity-viewall@ghost.org'});
- // A fresh member's signup event is written asynchronously, so serve a
- // known event rather than racing it — the link only renders on the
- // populated branch.
- await page.route(/\/ghost\/api\/admin\/members\/events\/?\?/, async (route) => {
- if (route.request().method() !== 'GET') {
- return route.continue();
- }
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- events: [{
- type: 'signup_event',
- data: {
- id: 'evt-1',
- created_at: new Date(0).toISOString(),
- member_id: member.id,
- member: {id: member.id, name: member.name, email: member.email}
- }
- }],
- meta: {pagination: {}}
- })
- });
+ test('activity feed - view-all link points at this members full activity', async ({page}) => {
+ const member = await memberFactory.create({name: 'Activity Target', email: 'activity-viewall@ghost.org'});
+ // A fresh member's signup event is written asynchronously, so serve a
+ // known event rather than racing it — the link only renders on the
+ // populated branch.
+ await page.route(/\/ghost\/api\/admin\/members\/events\/?\?/, async (route) => {
+ if (route.request().method() !== 'GET') {
+ return route.continue();
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ events: [{
+ type: 'signup_event',
+ data: {
+ id: 'evt-1',
+ created_at: new Date(0).toISOString(),
+ member_id: member.id,
+ member: {id: member.id, name: member.name, email: member.email}
+ }
+ }],
+ meta: {pagination: {}}
+ })
});
+ });
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- const viewAll = page.getByRole('link', {name: /View all member activity/});
- await expect(viewAll).toBeVisible();
- await expect(viewAll).toHaveAttribute('href', new RegExp(`#/members-activity/?\\?member=${member.id}`));
- });
+ const viewAll = page.getByRole('link', {name: /View all member activity/});
+ await expect(viewAll).toBeVisible();
+ await expect(viewAll).toHaveAttribute('href', new RegExp(`#/members-activity/?\\?member=${member.id}`));
+ });
- test.describe('Subscriptions', () => {
- // The Subscriptions section is gated on paid members being enabled.
- test.use({stripeEnabled: true});
+ test.describe('Subscriptions', () => {
+ // The Subscriptions section is gated on paid members being enabled.
+ test.use({stripeEnabled: true});
- test('paid subscription - shows the tier, price, interval and renewal date', async ({page}) => {
- const member = await memberFactory.create({name: 'Paid Member', email: 'paid-sub@ghost.org'});
- await seedSubscriptions(page, member.id, 'paid', [paidSubscription()]);
+ test('paid subscription - shows the tier, price, interval and renewal date', async ({page}) => {
+ const member = await memberFactory.create({name: 'Paid Member', email: 'paid-sub@ghost.org'});
+ await seedSubscriptions(page, member.id, 'paid', [paidSubscription()]);
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- await expect(page.getByText('Bronze').first()).toBeVisible();
- await expect(page.getByText('10.50').first()).toBeVisible();
- await expect(page.getByText(/month/i).first()).toBeVisible();
- await expect(page.getByText(/Renews 15 Feb 2026/).first()).toBeVisible();
- });
+ await expect(page.getByText('Bronze').first()).toBeVisible();
+ await expect(page.getByText('10.50').first()).toBeVisible();
+ await expect(page.getByText(/month/i).first()).toBeVisible();
+ await expect(page.getByText(/Renews 15 Feb 2026/).first()).toBeVisible();
+ });
- test('subscription set to cancel - shows remaining access rather than a renewal', async ({page}) => {
- const member = await memberFactory.create({name: 'Cancelling Member', email: 'cancelling-sub@ghost.org'});
- await seedSubscriptions(page, member.id, 'paid', [paidSubscription({cancel_at_period_end: true})]);
+ test('subscription set to cancel - shows remaining access rather than a renewal', async ({page}) => {
+ const member = await memberFactory.create({name: 'Cancelling Member', email: 'cancelling-sub@ghost.org'});
+ await seedSubscriptions(page, member.id, 'paid', [paidSubscription({cancel_at_period_end: true})]);
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- await expect(page.getByText(/Has access until\s+15 Feb 2026/).first()).toBeVisible();
- await expect(page.getByText(/Renews/)).toHaveCount(0);
- });
+ await expect(page.getByText(/Has access until\s+15 Feb 2026/).first()).toBeVisible();
+ await expect(page.getByText(/Renews/)).toHaveCount(0);
+ });
- test('complimentary subscription - shows the tier without a price', async ({page}) => {
- const member = await memberFactory.create({name: 'Comp Member', email: 'comp-sub@ghost.org'});
- await seedSubscriptions(page, member.id, 'comped', [compSubscription()]);
+ test('complimentary subscription - shows the tier without a price', async ({page}) => {
+ const member = await memberFactory.create({name: 'Comp Member', email: 'comp-sub@ghost.org'});
+ await seedSubscriptions(page, member.id, 'comped', [compSubscription()]);
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- await expect(page.getByText('Bronze').first()).toBeVisible();
- await expect(page.getByText(/Complimentary/i).first()).toBeVisible();
- });
+ await expect(page.getByText('Bronze').first()).toBeVisible();
+ await expect(page.getByText(/Complimentary/i).first()).toBeVisible();
+ });
- test('gift subscription - offers no actions menu', async ({page}) => {
- // A gift is bought by someone else and can't be cancelled or
- // revoked from here, so the row deliberately has no menu.
- const member = await memberFactory.create({name: 'Gift Member', email: 'gift-sub@ghost.org'});
- await seedSubscriptions(page, member.id, 'gift', [giftSubscription()]);
+ test('gift subscription - offers no actions menu', async ({page}) => {
+ // A gift is bought by someone else and can't be cancelled or
+ // revoked from here, so the row deliberately has no menu.
+ const member = await memberFactory.create({name: 'Gift Member', email: 'gift-sub@ghost.org'});
+ await seedSubscriptions(page, member.id, 'gift', [giftSubscription()]);
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- await expect(page.getByText('Bronze').first()).toBeVisible();
- await expect(memberDetailsPage.subscriptionActionsButton).toHaveCount(0);
- });
+ await expect(page.getByText('Bronze').first()).toBeVisible();
+ await expect(memberDetailsPage.subscriptionActionsButton).toHaveCount(0);
+ });
- test('cancelling a subscription - asks the server to cancel at period end', async ({page}) => {
- const member = await memberFactory.create({name: 'Cancel Member', email: 'cancel-action@ghost.org'});
- const subs = [paidSubscription()];
- await seedSubscriptions(page, member.id, 'paid', subs);
- // Reflect the write back into the seeded read so the screen can
- // re-render from it, the way a real refetch would.
- const sent = await captureWrite(page, `**/members/${member.id}/subscriptions/sub_paid_123/**`, (body) => {
- subs[0].cancel_at_period_end = body.cancel_at_period_end as boolean;
- });
-
- await page.goto(memberPath(member.id));
- await memberDetailsPage.subscriptionActionsButton.click();
- await memberDetailsPage.cancelSubscriptionButton.click();
-
- // The request is the contract — the server doesn't care which UI sent it.
- await expect.poll(() => sent.body?.cancel_at_period_end).toBe(true);
+ test('cancelling a subscription - asks the server to cancel at period end', async ({page}) => {
+ const member = await memberFactory.create({name: 'Cancel Member', email: 'cancel-action@ghost.org'});
+ const subs = [paidSubscription()];
+ await seedSubscriptions(page, member.id, 'paid', subs);
+ // Reflect the write back into the seeded read so the screen can
+ // re-render from it, the way a real refetch would.
+ const sent = await captureWrite(page, `**/members/${member.id}/subscriptions/sub_paid_123/**`, (body) => {
+ subs[0].cancel_at_period_end = body.cancel_at_period_end as boolean;
});
- test('continuing a cancelled subscription - asks the server to resume it', async ({page}) => {
- const member = await memberFactory.create({name: 'Continue Member', email: 'continue-action@ghost.org'});
- const subs = [paidSubscription({cancel_at_period_end: true})];
- await seedSubscriptions(page, member.id, 'paid', subs);
- const sent = await captureWrite(page, `**/members/${member.id}/subscriptions/sub_paid_123/**`, (body) => {
- subs[0].cancel_at_period_end = body.cancel_at_period_end as boolean;
- });
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.subscriptionActionsButton.click();
+ await memberDetailsPage.cancelSubscriptionButton.click();
- await page.goto(memberPath(member.id));
- await memberDetailsPage.subscriptionActionsButton.click();
- await memberDetailsPage.continueSubscriptionButton.click();
+ // The request is the contract — the server doesn't care which UI sent it.
+ await expect.poll(() => sent.body?.cancel_at_period_end).toBe(true);
+ });
- await expect.poll(() => sent.body?.cancel_at_period_end).toBe(false);
+ test('continuing a cancelled subscription - asks the server to resume it', async ({page}) => {
+ const member = await memberFactory.create({name: 'Continue Member', email: 'continue-action@ghost.org'});
+ const subs = [paidSubscription({cancel_at_period_end: true})];
+ await seedSubscriptions(page, member.id, 'paid', subs);
+ const sent = await captureWrite(page, `**/members/${member.id}/subscriptions/sub_paid_123/**`, (body) => {
+ subs[0].cancel_at_period_end = body.cancel_at_period_end as boolean;
});
- test('removing a complimentary subscription - puts back only the surviving tiers', async ({page}) => {
- const member = await memberFactory.create({name: 'Multi Comp', email: 'multi-comp@ghost.org'});
- await seedSubscriptions(page, member.id, 'comped', [compSubscription()], {tiers: TWO_COMP_TIERS()});
- const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.subscriptionActionsButton.click();
+ await memberDetailsPage.continueSubscriptionButton.click();
+
+ await expect.poll(() => sent.body?.cancel_at_period_end).toBe(false);
+ });
- await page.goto(memberPath(member.id));
- await memberDetailsPage.removeComplimentarySubscription();
+ test('removing a complimentary subscription - puts back only the surviving tiers', async ({page}) => {
+ const member = await memberFactory.create({name: 'Multi Comp', email: 'multi-comp@ghost.org'});
+ await seedSubscriptions(page, member.id, 'comped', [compSubscription()], {tiers: TWO_COMP_TIERS()});
+ const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
- await expect.poll(() => sentTiers(sent)?.length).toBe(1);
- expect(sentTiers(sent)?.[0].id).toBe('tier_silver');
- });
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.removeComplimentarySubscription();
+
+ await expect.poll(() => sentTiers(sent)?.length).toBe(1);
+ expect(sentTiers(sent)?.[0].id).toBe('tier_silver');
});
+ });
- test.describe('New member screen', () => {
- // The Subscriptions section is gated on paid members being enabled, so
- // stripe has to be on for it to render at all.
- test.use({stripeEnabled: true});
+ test.describe('New member screen', () => {
+ // The Subscriptions section is gated on paid members being enabled, so
+ // stripe has to be on for it to render at all.
+ test.use({stripeEnabled: true});
- test('newsletters section - renders a toggle list', async ({page}) => {
- await page.goto(memberPath('new'));
+ test('newsletters section - renders a toggle list', async ({page}) => {
+ await page.goto(memberPath('new'));
- await expect(page.getByRole('heading', {name: 'Newsletters', exact: true})).toBeVisible();
- await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
- });
+ await expect(page.getByRole('heading', {name: 'Newsletters', exact: true})).toBeVisible();
+ await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
+ });
- test('newsletters section - toggles are checked by default', async ({page}) => {
- // Newsletters with subscribe_on_signup and members visibility are
- // pre-selected on create, so the admin can see what the new member
- // will land subscribed to. Assert every toggle rather than the
- // first, which would pass even if a later default were missed.
- await page.goto(memberPath('new'));
- await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
-
- const count = await memberDetailsPage.newsletterSubscriptionCheckboxes.count();
- expect(count).toBeGreaterThan(0);
- for (let i = 0; i < count; i++) {
- await expect(memberDetailsPage.newsletterSubscriptionCheckboxes.nth(i)).toBeChecked();
- }
- });
+ test('newsletters section - toggles are checked by default', async ({page}) => {
+ // Newsletters with subscribe_on_signup and members visibility are
+ // pre-selected on create, so the admin can see what the new member
+ // will land subscribed to. Assert every toggle rather than the
+ // first, which would pass even if a later default were missed.
+ await page.goto(memberPath('new'));
+ await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
- test('activity section - shows an empty state', async ({page}) => {
- await page.goto(memberPath('new'));
+ const count = await memberDetailsPage.newsletterSubscriptionCheckboxes.count();
+ expect(count).toBeGreaterThan(0);
+ for (let i = 0; i < count; i++) {
+ await expect(memberDetailsPage.newsletterSubscriptionCheckboxes.nth(i)).toBeChecked();
+ }
+ });
- await expect(page.getByText('All events related to this member will be shown here.')).toBeVisible();
- });
+ test('activity section - shows an empty state', async ({page}) => {
+ await page.goto(memberPath('new'));
- test('subscriptions section - shows an empty state', async ({page}) => {
- await page.goto(memberPath('new'));
+ await expect(page.getByText('All events related to this member will be shown here.')).toBeVisible();
+ });
- await expect(page.getByRole('heading', {name: 'Subscriptions', exact: true})).toBeVisible();
- await expect(page.getByRole('heading', {name: 'No subscriptions', exact: true})).toBeVisible();
- });
+ test('subscriptions section - shows an empty state', async ({page}) => {
+ await page.goto(memberPath('new'));
+
+ await expect(page.getByRole('heading', {name: 'Subscriptions', exact: true})).toBeVisible();
+ await expect(page.getByRole('heading', {name: 'No subscriptions', exact: true})).toBeVisible();
});
+ });
- test.describe('Engagement section', () => {
- const stubMemberRead = interceptMemberRead;
+ test.describe('Engagement section', () => {
+ const stubMemberRead = interceptMemberRead;
- test('member has received no emails - shows an empty state', async ({page}) => {
- const member = await memberFactory.create({name: 'Ada Lovelace', email: 'engagement-empty@ghost.org'});
+ test('member has received no emails - shows an empty state', async ({page}) => {
+ const member = await memberFactory.create({name: 'Ada Lovelace', email: 'engagement-empty@ghost.org'});
- await page.goto(memberPath(member.id));
+ await page.goto(memberPath(member.id));
- await expect(page.getByRole('heading', {name: 'Engagement'})).toBeVisible();
- await expect(page.getByText(/We[’']ll show Ada[’']s email stats here/)).toBeVisible();
- });
+ await expect(page.getByRole('heading', {name: 'Engagement'})).toBeVisible();
+ await expect(page.getByText(/We[’']ll show Ada[’']s email stats here/)).toBeVisible();
+ });
- test('member has received emails - shows counts and open rate', async ({page}) => {
- const member = await memberFactory.create({name: 'Stats Member', email: 'engagement-stats@ghost.org'});
- // Inject the stats so the branch renders deterministically rather
- // than depending on what the fixture database happens to hold.
- await stubMemberRead(page, member.id, (m) => {
- m.email_count = 12;
- m.email_opened_count = 9;
- m.email_open_rate = 75;
- });
-
- await page.goto(memberPath(member.id));
-
- const engagement = memberDetailsPage.engagementSection;
- await expect(engagement.getByText('Emails received')).toBeVisible();
- await expect(engagement.getByText('12', {exact: true})).toBeVisible();
- await expect(engagement.getByText('Emails opened')).toBeVisible();
- await expect(engagement.getByText('9', {exact: true})).toBeVisible();
- await expect(engagement.getByText('Average open rate')).toBeVisible();
- await expect(engagement.getByText(/75\s*%/)).toBeVisible();
+ test('member has received emails - shows counts and open rate', async ({page}) => {
+ const member = await memberFactory.create({name: 'Stats Member', email: 'engagement-stats@ghost.org'});
+ // Inject the stats so the branch renders deterministically rather
+ // than depending on what the fixture database happens to hold.
+ await stubMemberRead(page, member.id, (m) => {
+ m.email_count = 12;
+ m.email_opened_count = 9;
+ m.email_open_rate = 75;
});
- test('email fields absent from the payload - shows the empty state', async ({page}) => {
- const member = await memberFactory.create({name: 'Ada Lovelace', email: 'engagement-undef@ghost.org'});
- await stubMemberRead(page, member.id, (m) => {
- delete m.email_count;
- delete m.email_opened_count;
- delete m.email_open_rate;
- });
+ await page.goto(memberPath(member.id));
- await page.goto(memberPath(member.id));
+ const engagement = memberDetailsPage.engagementSection;
+ await expect(engagement.getByText('Emails received')).toBeVisible();
+ await expect(engagement.getByText('12', {exact: true})).toBeVisible();
+ await expect(engagement.getByText('Emails opened')).toBeVisible();
+ await expect(engagement.getByText('9', {exact: true})).toBeVisible();
+ await expect(engagement.getByText('Average open rate')).toBeVisible();
+ await expect(engagement.getByText(/75\s*%/)).toBeVisible();
+ });
- await expect(page.getByRole('heading', {name: 'Engagement'})).toBeVisible();
- await expect(page.getByText(/We[’']ll show Ada[’']s email stats here/)).toBeVisible();
+ test('email fields absent from the payload - shows the empty state', async ({page}) => {
+ const member = await memberFactory.create({name: 'Ada Lovelace', email: 'engagement-undef@ghost.org'});
+ await stubMemberRead(page, member.id, (m) => {
+ delete m.email_count;
+ delete m.email_opened_count;
+ delete m.email_open_rate;
});
- test('open rate not yet calculated - shows the placeholder', async ({page}) => {
- const member = await memberFactory.create({name: 'Early Stats', email: 'engagement-null@ghost.org'});
- // The server sends a null rate until the member has been sent 5
- // newsletters; both the count and a bare % would be misleading.
- await stubMemberRead(page, member.id, (m) => {
- m.email_count = 3;
- m.email_opened_count = 2;
- m.email_open_rate = null;
- });
+ await page.goto(memberPath(member.id));
- await page.goto(memberPath(member.id));
+ await expect(page.getByRole('heading', {name: 'Engagement'})).toBeVisible();
+ await expect(page.getByText(/We[’']ll show Ada[’']s email stats here/)).toBeVisible();
+ });
- await expect(page.getByText('This metric is calculated once a member has received 5 newsletters.')).toBeVisible();
+ test('open rate not yet calculated - shows the placeholder', async ({page}) => {
+ const member = await memberFactory.create({name: 'Early Stats', email: 'engagement-null@ghost.org'});
+ // The server sends a null rate until the member has been sent 5
+ // newsletters; both the count and a bare % would be misleading.
+ await stubMemberRead(page, member.id, (m) => {
+ m.email_count = 3;
+ m.email_opened_count = 2;
+ m.email_open_rate = null;
});
+
+ await page.goto(memberPath(member.id));
+
+ await expect(page.getByText('This metric is calculated once a member has received 5 newsletters.')).toBeVisible();
});
});
-}
+});
/**
- * Known divergences between the two implementations.
- *
- * Everything above this point is generic and must stay that way. A test only
- * belongs here when the *behaviour* — not the markup — exists in one
- * implementation and not the other, and we have decided not to close the gap.
- * Each one records what the divergence is and why it stands, so the difference
- * is a deliberate, visible decision rather than a silently narrowed test.
+ * Behaviours with no counterpart in the generic suite above: they assert an
+ * affordance specific to this screen, or need a Stripe-enabled environment.
*/
-test.describe('Ghost Admin - Member Detail - known divergences', () => {
+test.describe('Ghost Admin - Member Detail - screen-specific behaviour', () => {
+ test.use({stripeEnabled: true});
+
let memberFactory: MemberFactory;
let memberDetailsPage: MemberDetailsPage;
@@ -599,184 +583,109 @@ test.describe('Ghost Admin - Member Detail - known divergences', () => {
memberDetailsPage = new MemberDetailsPage(page);
});
- /**
- * Adding a complimentary subscription is the one flow split purely on
- * interaction rather than behaviour. Both screens compute the same
- * `expiry_at` from the same duration options and send the same request, but
- * one picks a tier with radio buttons and the other with a dropdown. Driving
- * both from one test would put a branch inside the page object, which buys
- * less than it costs — so the assertion is duplicated instead, and the two
- * tests must be kept in step.
- */
- test.describe('Ember', () => {
- test.use({labs: {memberDetailsReact: false}, stripeEnabled: true});
-
- test('adding a complimentary subscription - grants the chosen tier forever', async ({page}) => {
- const member = await memberFactory.create({name: 'Comp Grant', email: 'comp-grant-ember@ghost.org'});
- const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
+ test('adding a complimentary subscription - grants the chosen tier forever', async ({page}) => {
+ const member = await memberFactory.create({name: 'Comp Grant', email: 'comp-grant-react@ghost.org'});
+ const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
+
+ await page.goto(memberPath(member.id));
+ await page.getByRole('button', {name: /Add complimentary subscription/}).click();
+ await page.getByTestId('comp-tier-select').click();
+ const option = memberDetailsPage.reactCompTierOptions.first();
+ const chosenTierId = await option.getAttribute('data-tier-id');
+ await option.click();
+ await page.getByTestId('comp-add-confirm').click();
+
+ await expect.poll(() => sentTiers(sent)?.length).toBe(1);
+ expect(sentTiers(sent)?.[0].id).toBe(chosenTierId);
+ expect(sentTiers(sent)?.[0].expiry_at ?? null).toBeNull();
+ });
- await page.goto(memberPath(member.id));
- await page.getByRole('button', {name: /Add complimentary subscription/}).click();
- const option = memberDetailsPage.emberCompTierOptions.first();
- const chosenTierId = await option.getAttribute('data-test-tier-option');
- await option.click();
- await memberDetailsPage.emberSaveCompTierButton.click();
+ test('invalid email - save is disabled', async ({page}) => {
+ // The submit is blocked rather than allowed to fail, so there is no
+ // server error to surface.
+ const member = await memberFactory.create({name: 'Invalid Email', email: 'valid-react@ghost.org'});
- await expect.poll(() => sentTiers(sent)?.length).toBe(1);
- // The tier the admin picked, not merely some tier.
- expect(sentTiers(sent)?.[0].id).toBe(chosenTierId);
- // Forever is the default, so no expiry is sent.
- expect(sentTiers(sent)?.[0].expiry_at ?? null).toBeNull();
- });
-
- test('invalid email - save stays enabled and the server rejects it', async ({page}) => {
- // Ember validates on submit: Save is always clickable, and the
- // failed attempt reports why. React disables Save instead — see the
- // React-side counterpart below.
- const member = await memberFactory.create({name: 'Invalid Email', email: 'valid-ember@ghost.org'});
+ await page.goto(memberPath(member.id));
+ await memberDetailsPage.emailInput.fill('not-an-email');
- await page.goto(memberPath(member.id));
- await memberDetailsPage.emailInput.fill('not-an-email');
- await memberDetailsPage.saveButton.click();
+ await expect(memberDetailsPage.saveButton).toBeDisabled();
+ });
- await expect(memberDetailsPage.retryButton).toBeVisible();
- await expect(memberDetailsPage.body).toContainText('Invalid Email');
+ test('failed member load - offers a retry rather than a not-found message', async ({page}) => {
+ // A failed load renders a recoverable panel rather than dead-ending
+ // the route.
+ const member = await memberFactory.create({name: 'Retry Target', email: 'retry-target@ghost.org'});
+ let requests = 0;
+ const memberReadRegex = new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`);
+ await page.route(memberReadRegex, async (route) => {
+ if (route.request().method() !== 'GET') {
+ return route.continue();
+ }
+ requests += 1;
+ if (requests === 1) {
+ return route.fulfill({status: 500, contentType: 'application/json', body: JSON.stringify({errors: [{message: 'boom'}]})});
+ }
+ return route.continue();
});
- test('commenting can be re-enabled from the sidebar indicator', async ({page}) => {
- // Ember offers a one-click Enable next to the "Comments disabled"
- // indicator. React only exposes this through the actions menu, which
- // the generic suite already covers for both.
- const member = await memberFactory.create({name: 'Sidebar Enable', email: 'sidebar-enable@ghost.org'});
+ await page.goto(memberPath(member.id));
- await page.goto(memberPath(member.id));
- await memberDetailsPage.settingsSection.memberActionsButton.click();
- await memberDetailsPage.settingsSection.disableCommentingButton.click();
- await memberDetailsPage.disableCommentingConfirmButton.click();
- await expect(memberDetailsPage.commentingDisabledIndicator).toBeVisible();
+ const errorPanel = page.getByTestId('member-detail-load-error');
+ await expect(errorPanel).toBeVisible();
+ // A server error must not be reported as a missing member, anywhere
+ // on the screen. Asserting only the body copy previously let the
+ // breadcrumb go on claiming "Member not found" beside a
+ // "couldn't load" message.
+ await expect(page.getByText(/not found/i)).toHaveCount(0);
+ await expect(page.getByText(/couldn[’']t be found/)).toHaveCount(0);
- await memberDetailsPage.enableCommentingLink.click();
+ await errorPanel.getByRole('button', {name: 'Retry'}).click();
- await expect(memberDetailsPage.commentingDisabledIndicator).toBeHidden();
- });
+ await expect(memberDetailsPage.screenTitle).toHaveText('Retry Target');
+ await expect(errorPanel).toHaveCount(0);
});
- test.describe('React', () => {
- test.use({labs: {memberDetailsReact: true}, stripeEnabled: true});
+ test.describe('Removing a complimentary subscription', () => {
+ test.use({stripeEnabled: true});
- // Ember counterpart above — keep the assertions identical.
- test('adding a complimentary subscription - grants the chosen tier forever', async ({page}) => {
- const member = await memberFactory.create({name: 'Comp Grant', email: 'comp-grant-react@ghost.org'});
+ test('preserves the expiry date on surviving tiers', async ({page}) => {
+ // The server treats a tier arriving without `expiry_at` as null and
+ // wipes the pivot (`models/member.js` updateTierExpiry), so the
+ // whole set has to be sent back with expiries intact.
+ const member = await memberFactory.create({name: 'Multi Comp', email: 'multi-comp-react@ghost.org'});
+ await seedSubscriptions(page, member.id, 'comped', [compSubscription()], {tiers: TWO_COMP_TIERS()});
const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
await page.goto(memberPath(member.id));
- await page.getByRole('button', {name: /Add complimentary subscription/}).click();
- await page.getByTestId('comp-tier-select').click();
- const option = memberDetailsPage.reactCompTierOptions.first();
- const chosenTierId = await option.getAttribute('data-tier-id');
- await option.click();
- await page.getByTestId('comp-add-confirm').click();
+ await memberDetailsPage.removeComplimentarySubscription();
await expect.poll(() => sentTiers(sent)?.length).toBe(1);
- expect(sentTiers(sent)?.[0].id).toBe(chosenTierId);
- expect(sentTiers(sent)?.[0].expiry_at ?? null).toBeNull();
+ expect(sentTiers(sent)?.[0].expiry_at).toBe(SILVER_EXPIRY);
});
- test('invalid email - save is disabled', async ({page}) => {
- // React blocks the submit rather than letting it fail, so there is
- // no server error to surface. Deliberate; the Ember counterpart
- // above pins the other behaviour.
- const member = await memberFactory.create({name: 'Invalid Email', email: 'valid-react@ghost.org'});
-
- await page.goto(memberPath(member.id));
- await memberDetailsPage.emailInput.fill('not-an-email');
-
- await expect(memberDetailsPage.saveButton).toBeDisabled();
- });
-
- test('failed member load - offers a retry rather than a not-found message', async ({page}) => {
- // Ember has no equivalent control: a failed load surfaces as an
- // alert and the route errors. React renders a recoverable panel, so
- // there is nothing generic to assert.
- const member = await memberFactory.create({name: 'Retry Target', email: 'retry-target@ghost.org'});
- let requests = 0;
- const memberReadRegex = new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`);
- await page.route(memberReadRegex, async (route) => {
- if (route.request().method() !== 'GET') {
- return route.continue();
- }
- requests += 1;
- if (requests === 1) {
- return route.fulfill({status: 500, contentType: 'application/json', body: JSON.stringify({errors: [{message: 'boom'}]})});
- }
- return route.continue();
- });
+ test('asks for confirmation first', async ({page}) => {
+ const member = await memberFactory.create({name: 'Confirm Comp', email: 'confirm-comp@ghost.org'});
+ await seedSubscriptions(page, member.id, 'comped', [compSubscription()], {tiers: TWO_COMP_TIERS()});
+ const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
await page.goto(memberPath(member.id));
+ await memberDetailsPage.subscriptionActionsButton.first().click();
+ await memberDetailsPage.removeComplimentaryButton.click();
- const errorPanel = page.getByTestId('member-detail-load-error');
- await expect(errorPanel).toBeVisible();
- // A server error must not be reported as a missing member, anywhere
- // on the screen. Asserting only the body copy previously let the
- // breadcrumb go on claiming "Member not found" beside a
- // "couldn't load" message.
- await expect(page.getByText(/not found/i)).toHaveCount(0);
- await expect(page.getByText(/couldn[’']t be found/)).toHaveCount(0);
-
- await errorPanel.getByRole('button', {name: 'Retry'}).click();
-
- await expect(memberDetailsPage.screenTitle).toHaveText('Retry Target');
- await expect(errorPanel).toHaveCount(0);
- });
-
- test.describe('Removing a complimentary subscription', () => {
- test.use({stripeEnabled: true});
-
- test('preserves the expiry date on surviving tiers', async ({page}) => {
- // The server treats a tier arriving without `expiry_at` as null and
- // wipes the pivot (`models/member.js` updateTierExpiry), so the
- // whole set has to be sent back with expiries intact. Ember sends
- // `{id}` only and destroys the expiry on every surviving comp tier.
- // React sends `expiry_at` explicitly. Not generic: a shared version
- // of this test would fail on Ember, and we are not backporting.
- const member = await memberFactory.create({name: 'Multi Comp', email: 'multi-comp-react@ghost.org'});
- await seedSubscriptions(page, member.id, 'comped', [compSubscription()], {tiers: TWO_COMP_TIERS()});
- const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
-
- await page.goto(memberPath(member.id));
- await memberDetailsPage.removeComplimentarySubscription();
-
- await expect.poll(() => sentTiers(sent)?.length).toBe(1);
- expect(sentTiers(sent)?.[0].expiry_at).toBe(SILVER_EXPIRY);
- });
-
- test('asks for confirmation first', async ({page}) => {
- // Ember removes the comp the moment the menu item is clicked.
- const member = await memberFactory.create({name: 'Confirm Comp', email: 'confirm-comp@ghost.org'});
- await seedSubscriptions(page, member.id, 'comped', [compSubscription()], {tiers: TWO_COMP_TIERS()});
- const sent = await captureWrite(page, new RegExp(`/ghost/api/admin/members/${member.id}/\\??[^/]*$`));
-
- await page.goto(memberPath(member.id));
- await memberDetailsPage.subscriptionActionsButton.first().click();
- await memberDetailsPage.removeComplimentaryButton.click();
-
- await expect(page.getByRole('alertdialog', {name: /Remove complimentary subscription/})).toBeVisible();
- expect(sent.body).toBeUndefined();
- });
+ await expect(page.getByRole('alertdialog', {name: /Remove complimentary subscription/})).toBeVisible();
+ expect(sent.body).toBeUndefined();
});
+ });
- test('new member - seeded newsletter defaults do not count as unsaved changes', async ({page}) => {
- // React seeds the create form's newsletter defaults and treats that
- // seeded state as pristine. Ember's new record is dirty from the
- // start, so it traps on an untouched form — a generic version of
- // this test would fail there by design.
- await page.goto(memberPath('new'));
- await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
+ test('new member - seeded newsletter defaults do not count as unsaved changes', async ({page}) => {
+ // The create form seeds its newsletter defaults and treats that seeded
+ // state as pristine, so an untouched form must not trap on leave.
+ await page.goto(memberPath('new'));
+ await expect(memberDetailsPage.newsletterSubscriptionToggles.first()).toBeVisible();
- await memberDetailsPage.membersBackLink.click();
+ await memberDetailsPage.membersBackLink.click();
- await expect(page).toHaveURL(/#\/members(\?|$)/);
- await expect(memberDetailsPage.confirmLeaveButton).toHaveCount(0);
- });
+ await expect(page).toHaveURL(/#\/members(\?|$)/);
+ await expect(memberDetailsPage.confirmLeaveButton).toHaveCount(0);
});
});
diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js
index 8e9f97eb7be..27fb55db26c 100644
--- a/ghost/core/core/shared/labs.js
+++ b/ghost/core/core/shared/labs.js
@@ -27,7 +27,8 @@ const messages = {
// flags in this list always return `true`, allows quick global enable prior to full flag removal
const GA_FEATURES = [
- 'automationAnalytics'
+ 'automationAnalytics',
+ 'memberDetailsReact'
];
// These features are considered publicly available and can be enabled/disabled by users
@@ -51,7 +52,6 @@ const PRIVATE_FEATURES = [
'themeTranslation',
'pictureImageFormats',
'getHelperDeduplication',
- 'memberDetailsReact',
'membersCustomFields',
'paywallImprovements',
'giftSubCustomization',
From cac9f333fdfed67fdd72f5ebba6b9701bc628101 Mon Sep 17 00:00:00 2001
From: Rob Lester
Date: Tue, 4 Aug 2026 17:57:57 +0100
Subject: [PATCH 23/25] Cleaned up the memberDetailsReact flag and deleted the
Ember member screen
ref https://linear.app/ghost/issue/BER-3848
With the React member details screen generally available the old Ember screen is unreachable, yet it still has to be kept compiling and passing tests, so it goes along with the flag that used to choose between the two implementations. Removing the Ember member routes lets the member URLs fall through Ember's existing catch-all into React, which is how every already-migrated screen works. Three surviving templates still linked to the deleted route and would have thrown once it was gone, so they now use plain links to the member URL, the same pattern already used elsewhere for React-owned screens. The browser page object also no longer has to match two sets of markup for the same control.
---
.../src/layout/app-sidebar/nav-content.tsx | 2 +-
apps/admin/src/member-detail-gate.test.tsx | 102 ---
apps/admin/src/member-detail-gate.tsx | 16 -
...r-detail-custom-fields.acceptance.test.tsx | 2 +-
...ber-detail-leave-guard.acceptance.test.tsx | 8 +-
.../src/members/detail/member-detail.tsx | 6 +-
apps/admin/src/routes.tsx | 8 +-
.../components/gh-member-details-activity.hbs | 2 +-
.../app/components/gh-member-label-input.hbs | 42 -
.../app/components/gh-member-label-input.js | 173 -----
.../components/gh-member-settings-form.hbs | 311 --------
.../app/components/gh-member-settings-form.js | 242 ------
.../components/member/activity-feed-empty.hbs | 7 -
.../app/components/member/activity-feed.hbs | 70 --
.../app/components/member/activity-feed.js | 36 -
.../member/newsletter-preference.hbs | 65 --
.../member/newsletter-preference.js | 94 ---
.../member/subscription-detail-box.hbs | 49 --
.../member/subscription-detail-box.js | 17 -
.../members/modals/delete-member.hbs | 52 --
.../members/modals/delete-member.js | 47 --
.../members/modals/disable-commenting.hbs | 45 --
.../members/modals/disable-commenting.js | 39 -
.../members/modals/logout-member.hbs | 30 -
.../members/modals/logout-member.js | 31 -
.../app/components/modal-member-tier.hbs | 89 ---
.../app/components/modal-member-tier.js | 185 -----
.../app/components/posts/debug.hbs | 8 +-
apps/ember-admin/app/controllers/member.js | 352 ---------
apps/ember-admin/app/router.js | 2 -
apps/ember-admin/app/routes/member.js | 152 ----
apps/ember-admin/app/routes/member/new.js | 6 -
apps/ember-admin/app/serializers/member.js | 4 +-
apps/ember-admin/app/services/feature.js | 1 -
apps/ember-admin/app/styles/app-dark.css | 4 -
.../app/styles/components/dropdowns.css | 36 -
.../app/styles/layouts/members.css | 21 -
apps/ember-admin/app/templates/member.hbs | 146 ----
.../app/utils/subscription-data.js | 225 ------
.../tests/acceptance/members/details-test.js | 621 ---------------
.../tests/unit/controllers/member-test.js | 40 -
.../tests/unit/services/state-bridge-test.js | 4 +-
.../unit/utils/subscription-data-test.js | 734 ------------------
.../admin/members/member-details-page.ts | 62 +-
e2e/tests/admin/members/member-detail.test.ts | 11 +-
ghost/core/core/shared/labs.js | 3 +-
46 files changed, 38 insertions(+), 4164 deletions(-)
delete mode 100644 apps/admin/src/member-detail-gate.test.tsx
delete mode 100644 apps/admin/src/member-detail-gate.tsx
delete mode 100644 apps/ember-admin/app/components/gh-member-label-input.hbs
delete mode 100644 apps/ember-admin/app/components/gh-member-label-input.js
delete mode 100644 apps/ember-admin/app/components/gh-member-settings-form.hbs
delete mode 100644 apps/ember-admin/app/components/gh-member-settings-form.js
delete mode 100644 apps/ember-admin/app/components/member/activity-feed-empty.hbs
delete mode 100644 apps/ember-admin/app/components/member/activity-feed.hbs
delete mode 100644 apps/ember-admin/app/components/member/activity-feed.js
delete mode 100644 apps/ember-admin/app/components/member/newsletter-preference.hbs
delete mode 100644 apps/ember-admin/app/components/member/newsletter-preference.js
delete mode 100644 apps/ember-admin/app/components/member/subscription-detail-box.hbs
delete mode 100644 apps/ember-admin/app/components/member/subscription-detail-box.js
delete mode 100644 apps/ember-admin/app/components/members/modals/delete-member.hbs
delete mode 100644 apps/ember-admin/app/components/members/modals/delete-member.js
delete mode 100644 apps/ember-admin/app/components/members/modals/disable-commenting.hbs
delete mode 100644 apps/ember-admin/app/components/members/modals/disable-commenting.js
delete mode 100644 apps/ember-admin/app/components/members/modals/logout-member.hbs
delete mode 100644 apps/ember-admin/app/components/members/modals/logout-member.js
delete mode 100644 apps/ember-admin/app/components/modal-member-tier.hbs
delete mode 100644 apps/ember-admin/app/components/modal-member-tier.js
delete mode 100644 apps/ember-admin/app/controllers/member.js
delete mode 100644 apps/ember-admin/app/routes/member.js
delete mode 100644 apps/ember-admin/app/routes/member/new.js
delete mode 100644 apps/ember-admin/app/templates/member.hbs
delete mode 100644 apps/ember-admin/app/utils/subscription-data.js
delete mode 100644 apps/ember-admin/tests/acceptance/members/details-test.js
delete mode 100644 apps/ember-admin/tests/unit/controllers/member-test.js
delete mode 100644 apps/ember-admin/tests/unit/utils/subscription-data-test.js
diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx
index 71819d23abb..720bd64aa47 100644
--- a/apps/admin/src/layout/app-sidebar/nav-content.tsx
+++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx
@@ -16,7 +16,7 @@ import { useIsActiveLink } from "./use-is-active-link";
import { useEmberRouting } from "@/ember-bridge";
import { useFeatureFlag } from "@tryghost/admin-x-framework/hooks";
-const LEGACY_MEMBERS_ACTIVE_ROUTES = ['member', 'member.new', 'members-activity'];
+const LEGACY_MEMBERS_ACTIVE_ROUTES = ['members-activity'];
function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) {
return (
diff --git a/apps/admin/src/member-detail-gate.test.tsx b/apps/admin/src/member-detail-gate.test.tsx
deleted file mode 100644
index e6f6aaf6028..00000000000
--- a/apps/admin/src/member-detail-gate.test.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import React from 'react';
-import {MemberDetailGate} from './member-detail-gate';
-import {beforeEach, describe, expect, it, vi} from 'vitest';
-import {render, screen, waitFor} from '@testing-library/react';
-
-const {mockUseBrowseConfig} = vi.hoisted(() => ({
- mockUseBrowseConfig: vi.fn()
-}));
-
-vi.mock('@tryghost/admin-x-framework/api/config', () => ({
- useBrowseConfig: mockUseBrowseConfig
-}));
-
-vi.mock('./ember-bridge', () => ({
- EmberFallback: () => React.createElement('div', {'data-testid': 'ember-fallback'}),
- useEmberFeatureFlag: (flag: string) => {
- const stateBridge = window.EmberBridge?.state;
- if (!stateBridge?.isFeatureEnabled) {
- return undefined;
- }
- return stateBridge.isFeatureEnabled(flag) ?? null;
- }
-}));
-
-vi.mock('./members/detail/member-detail', () => ({
- default: () => React.createElement('div', {'data-testid': 'react-member-detail'})
-}));
-
-const configResult = (overrides: Record) => ({
- data: undefined,
- isError: false,
- isLoading: false,
- ...overrides
-});
-
-const withLabs = (labs: Record) => configResult({data: {config: {labs}}});
-
-describe('MemberDetailGate', () => {
- beforeEach(() => {
- mockUseBrowseConfig.mockReset();
- });
-
- it('renders Ember while the flag is off', () => {
- mockUseBrowseConfig.mockReturnValue(withLabs({memberDetailsReact: false}));
-
- render();
-
- expect(screen.getByTestId('ember-fallback')).toBeInTheDocument();
- });
-
- it('renders React while the flag is on', async () => {
- mockUseBrowseConfig.mockReturnValue(withLabs({memberDetailsReact: true}));
-
- render();
-
- // The React screen is lazily imported, so it arrives a tick later.
- await waitFor(() => {
- expect(screen.getByTestId('react-member-detail')).toBeInTheDocument();
- });
- });
-
- it('renders Ember when the flag is absent from config', () => {
- mockUseBrowseConfig.mockReturnValue(withLabs({}));
-
- render();
-
- expect(screen.getByTestId('ember-fallback')).toBeInTheDocument();
- });
-
- it('renders Ember when the config query fails', () => {
- // A failed config read must not blank the screen — Ember owns this URL
- // by default and still serves it, so degrading to Ember keeps the
- // member detail working. Reporting is left to the framework's default
- // error handler on useBrowseConfig.
- mockUseBrowseConfig.mockReturnValue(configResult({isError: true, data: undefined}));
-
- render();
-
- expect(screen.getByTestId('ember-fallback')).toBeInTheDocument();
- });
-
- it('renders Ember when the config query resolves with no data', () => {
- mockUseBrowseConfig.mockReturnValue(configResult({data: undefined}));
-
- render();
-
- expect(screen.getByTestId('ember-fallback')).toBeInTheDocument();
- });
-
- it('renders nothing while config is loading', () => {
- // Deliberately not falling back to Ember here: doing so would un-hide
- // the Ember shell and flash the old screen on every cold load for
- // admins who have the flag on.
- mockUseBrowseConfig.mockReturnValue(configResult({isLoading: true}));
-
- const {container} = render();
-
- expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument();
- expect(screen.queryByTestId('react-member-detail')).not.toBeInTheDocument();
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/apps/admin/src/member-detail-gate.tsx b/apps/admin/src/member-detail-gate.tsx
deleted file mode 100644
index 7ef1efcd98a..00000000000
--- a/apps/admin/src/member-detail-gate.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { FlagGatedRoute } from "./flag-gated-route";
-import { lazy } from "react";
-
-/**
- * Serves `/members/:member_id` — covering both edit (`:member_id`) and create
- * (the `new` sentinel) — from the React member detail screen when the
- * `memberDetailsReact` Labs flag is on, and from Ember otherwise. The gating
- * semantics (loading, error, and flag branching) live in FlagGatedRoute.
- */
-const MemberDetailReact = lazy(() => import("./members/detail/member-detail"));
-
-export function MemberDetailGate() {
- return ;
-}
-
-export default MemberDetailGate;
diff --git a/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx b/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx
index 34cc4c5959d..a5d3516a430 100644
--- a/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx
+++ b/apps/admin/src/members/detail/member-detail-custom-fields.acceptance.test.tsx
@@ -3,7 +3,7 @@ import {page, userEvent} from 'vitest/browser';
import {fakeAdminEndpoint, fakeMembers, member, renderAdminApp, type Member} from '@test-utils/acceptance';
-const FLAGS = {labs: {memberDetailsReact: true, membersCustomFields: true}};
+const FLAGS = {labs: {membersCustomFields: true}};
const FIELDS = [
{key: 'job_title', name: 'Job title', type: 'short_text', created_at: '2026-07-14T00:00:00.000Z', updated_at: null},
diff --git a/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx b/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx
index 5a29e2aaf8e..9f375d2b909 100644
--- a/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx
+++ b/apps/admin/src/members/detail/member-detail-leave-guard.acceptance.test.tsx
@@ -3,8 +3,6 @@ import {page} from 'vitest/browser';
import {fakeAdminEndpoint, fakeMembers, member, renderAdminApp, type Member} from '@test-utils/acceptance';
-const FLAGS = {labs: {memberDetailsReact: true}};
-
function fakeMemberDetailWorld(m: Member) {
fakeMembers([m]);
fakeAdminEndpoint('GET', new RegExp(`^/members/${m.id}/`), {members: [m]});
@@ -28,7 +26,7 @@ describe('Member detail leave guard', () => {
it('guards leaving via the breadcrumb (react-router link) with unsaved edits', async () => {
const m = member({name: 'Ada Lovelace'});
fakeMemberDetailWorld(m);
- await renderAdminApp(`/members/${m.id}`, FLAGS);
+ await renderAdminApp(`/members/${m.id}`);
await page.getByLabelText('Name').fill('Ada B');
await page.getByTestId('member-detail').getByRole('link', {name: 'Members'}).click();
@@ -39,7 +37,7 @@ describe('Member detail leave guard', () => {
it('guards leaving via the sidebar (native hash anchor) with unsaved edits', async () => {
const m = member({name: 'Ada Lovelace'});
fakeMemberDetailWorld(m);
- await renderAdminApp(`/members/${m.id}`, FLAGS);
+ await renderAdminApp(`/members/${m.id}`);
await page.getByLabelText('Name').fill('Ada B');
await page.getByRole('link', {name: 'Members'}).first().click();
@@ -50,7 +48,7 @@ describe('Member detail leave guard', () => {
it('keeps editing on cancel and completes the navigation on Leave', async () => {
const m = member({name: 'Ada Lovelace'});
fakeMemberDetailWorld(m);
- await renderAdminApp(`/members/${m.id}`, FLAGS);
+ await renderAdminApp(`/members/${m.id}`);
await page.getByLabelText('Name').fill('Ada B');
await page.getByRole('link', {name: 'Members'}).first().click();
diff --git a/apps/admin/src/members/detail/member-detail.tsx b/apps/admin/src/members/detail/member-detail.tsx
index 0178b12074c..305e33d8a95 100644
--- a/apps/admin/src/members/detail/member-detail.tsx
+++ b/apps/admin/src/members/detail/member-detail.tsx
@@ -284,9 +284,9 @@ const MemberDetailPage: React.FC = ({paidMembersEnabled,
const blocker = useBlocker(({currentLocation, nextLocation}) => !bypassGuardRef.current && hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname);
// Native `` navigations (the sidebar, links into Ember
// routes) never reach the react-router blocker above — see the hook.
- // Ember's own guard (`trailing-hash.js`) can't cover this screen either:
- // with `memberDetailsReact` on, the Ember member route aborts and never
- // registers into the `unsaved-changes` service.
+ // Ember's own guard (`trailing-hash.js`) can't cover this screen either,
+ // since the screen has no Ember route to register into the
+ // `unsaved-changes` service.
const anchorGuard = useHashLinkNavigationGuard(hasUnsavedChanges);
const isBlocked = blocker.state === 'blocked' || anchorGuard.isBlocked;
diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx
index 38c398414b6..a4f9874189f 100644
--- a/apps/admin/src/routes.tsx
+++ b/apps/admin/src/routes.tsx
@@ -13,7 +13,6 @@ import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge";
import type { RouteHandle } from "./ember-bridge";
import HomeRedirect from "./home-redirect";
import { EmberListWithGiftLinks } from "./gift-link-modal-host";
-import { MemberDetailGate } from "./member-detail-gate";
import { TagDetailGate } from "./tag-detail-gate";
import { OnboardingRedirect } from "./onboarding/onboarding-redirect";
import { type AccessRouteHandle, RouteAccessGuard } from "./route-access-guard";
@@ -64,13 +63,8 @@ const membersRoute: RouteObject = {
// Covers both edit (`:member_id`) and create (the sentinel `new`)
// — real member ids are 24-char hex ObjectIds, so they can't
// collide with the literal "new".
- //
- // MemberDetailGate serves Ember or React depending on the
- // `memberDetailsReact` Labs flag; the parent route's
- // emberFallbackHandle covers both, since ForceUpgradeGuard checks
- // every match rather than just the leaf.
path: ":member_id",
- Component: MemberDetailGate
+ lazy: lazyComponent(() => import("./members/detail/member-detail"))
}
]
};
diff --git a/apps/ember-admin/app/components/gh-member-details-activity.hbs b/apps/ember-admin/app/components/gh-member-details-activity.hbs
index f5faa1aac37..3d314e5e279 100644
--- a/apps/ember-admin/app/components/gh-member-details-activity.hbs
+++ b/apps/ember-admin/app/components/gh-member-details-activity.hbs
@@ -28,7 +28,7 @@
{{/if}}
- {{tier.name}}
- {{#if (eq sub.status "canceled")}}
- Canceled
- {{else if sub.cancel_at_period_end}}
- Canceled
- {{else if sub.compExpiry}}
- Active
- {{else if sub.giftExpiry}}
- Active
- {{else if sub.trialUntil}}
- Active
- {{else}}
- Active
- {{/if}}
- {{#if (gt tier.subscriptions.length 1)}}
- {{tier.subscriptions.length}} subscriptions
- {{/if}}
-
-
- {{sub.priceLabel}}
- {{sub.validityDetails}}
-
-
-
- {{#if sub.isGift}}
- {{! Gift subscriptions have no action menu }}
- {{else if sub.isComplimentary}}
-
-
-
- {{svg-jar "dotdotdot"}}
- Subscription menu
-
-
-
-