diff --git a/.agents/bin/docker b/.agents/bin/docker new file mode 100755 index 00000000000..1d64b5e1e69 --- /dev/null +++ b/.agents/bin/docker @@ -0,0 +1,2 @@ +#!/bin/sh +exec sudo --preserve-env=PUBLIC_URL /usr/bin/docker "$@" diff --git a/.agents/resume b/.agents/resume new file mode 100755 index 00000000000..788ed7c48da --- /dev/null +++ b/.agents/resume @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! systemctl is-active --quiet amp-svc-docker.service || ! systemctl is-active --quiet amp-svc-ghost.service; then + amp orb services ensure +fi diff --git a/.agents/setup b/.agents/setup new file mode 100755 index 00000000000..f00662f1510 --- /dev/null +++ b/.agents/setup @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +node_version="$(cat .node-version)" +node_dir="$HOME/.local/node-v${node_version}-linux-x64" + +if [[ ! -x "$node_dir/bin/node" ]]; then + archive="/tmp/node-v${node_version}-linux-x64.tar.xz" + curl -fsSLo "$archive" "https://nodejs.org/dist/v${node_version}/node-v${node_version}-linux-x64.tar.xz" + mkdir -p "$HOME/.local" + tar -xJf "$archive" -C "$HOME/.local" + rm -f "$archive" +fi + +export PATH="$node_dir/bin:$PATH" +corepack enable --install-directory "$node_dir/bin" +pnpm setup + +profile_marker="# Ghost orb Node.js" +profile_export="export PATH=\"$node_dir/bin:\$PATH\"" +if grep -Fqx "$profile_marker" "$HOME/.bash_profile" 2>/dev/null; then + sed -i "/^${profile_marker}$/ { n; c\\ +$profile_export + }" "$HOME/.bash_profile" +else + cat >> "$HOME/.bash_profile" </dev/null 2>&1; then + sudo install -m 0755 -d /etc/apt/keyrings + sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + sudo chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md index 23dbc9b4730..c9716f87489 100644 --- a/.agents/skills/commit/SKILL.md +++ b/.agents/skills/commit/SKILL.md @@ -14,48 +14,16 @@ Use this skill whenever the user asks you to create a git commit for the current - `git diff` - `git log -5 --oneline` 2. Only stage files relevant to the requested change. Do not include unrelated untracked files, generated files, or likely-local artifacts. -3. Always follow Ghost's commit conventions (see below) for commit messages -4. Run `git status --short` after committing and confirm the result. +3. Read and follow `.github/CONTRIBUTING.md#commit-messages`. It is the + source of truth for Ghost's commit conventions. +4. For publishable packages, check whether a release intent is required. A + package `README.md` is published and requires a release; repository-only + Markdown such as `AGENTS.md`, `CLAUDE.md`, changelogs, and package-local + `docs/` does not. +5. Run `git status --short` after committing and confirm the result. ## Important - Do not push to remote unless the user explicitly asks - Keep commits focused and avoid bundling unrelated changes - If there are no relevant changes, do not create an empty commit - If hooks fail, fix the issue and create a new commit. Never bypass hooks. - -## Commit message format - -We have a handful of simple standards for commit messages which help us to generate readable changelogs. Please follow this wherever possible and mention the associated issue number. - -- **1st line:** Max 80 character summary - - Written in past tense e.g. “Fixed the thing” not “Fixes the thing” - - Start with one of: Fixed, Changed, Updated, Improved, Added, Removed, Reverted, Moved, Released, Bumped, Cleaned -- **2nd line:** [Always blank] -- **3rd line:** `ref `, `fixes `, `closes ` or blank -- **4th line:** Why this change was made - the code includes the what, the commit message should describe the context of why - why this, why now, why not something else? - -If your change is **user-facing** please prepend the first line of your commit with **an emoji**. - -Because emoji commits are the release notes, it's important that anything that gets an emoji is a user-facing change that's significant and relevant for end-users to see. - -The first line of an emoji commit message should be from the perspective of the user. For example, 🐛 Fixed a race condition in the members service is technical and tells the user nothing, but 🐛 Fixed a bug causing active members to lose access to paid content tells the user reading the release notes “oh yeah, they fixed that bug I kept hitting.” - -### Main emojis we are using: - -- ✨ Feature -- 🎨 Improvement / change -- 🐛 Bug Fix -- 🌐 i18n (translation) submissions -- 💡 Anything else flagged to users or whoever is writing release notes - -### Example - -``` -✨ Added config flag for disabling page analytics - -ref https://linear.app/tryghost/issue/ENG-1234/ - -- analytics are brand new under development, therefore they need to be behind a flag -- not using the developerExperiments flag as that is already in wide use and we aren't ready to deploy this anywhere yet -- using the term `pageAnalytics` as this was discussed as best reflecting what this does -``` 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/create-database-migration/SKILL.md b/.agents/skills/create-database-migration/SKILL.md index 30aa8c204e9..daf809e653c 100644 --- a/.agents/skills/create-database-migration/SKILL.md +++ b/.agents/skills/create-database-migration/SKILL.md @@ -5,6 +5,11 @@ description: Create a database migration to add a table, add columns to an exist # Create Database Migration +Read the canonical human guidance in +[`docs/practices/database-migrations.md`](../../../docs/practices/database-migrations.md) +before making a migration. This skill is the executable checklist that +accompanies it. + ## Instructions 1. Create a new, empty migration file: `cd ghost/core && pnpm migrate:create `. IMPORTANT: do not create the migration file manually; always use this script to create the initial empty migration file. The slug must be kebab-case (e.g. `add-column-to-posts`). @@ -12,13 +17,15 @@ description: Create a database migration to add a table, add columns to an exist 3. Update the migration file with the changes you want to make in the database, following the existing patterns in the codebase. Where appropriate, prefer to use the utility functions in `ghost/core/core/server/data/migrations/utils/*`. 4. Update the schema definition file in `ghost/core/core/server/data/schema/schema.js`, and make sure it aligns with the latest changes from the migration. 5. Test the migration manually: `cd ghost/core && pnpm knex-migrator migrate --v {version directory} --force` -6. If adding or dropping a table, update `ghost/core/core/server/data/exporter/table-lists.js` as appropriate. -7. If adding or dropping a table, also add or remove the table name from the expected tables list in `ghost/core/test/integration/exporter/exporter.test.js`. This test has a hardcoded alphabetically-sorted array of all database tables — it runs in CI integration tests (not unit tests) and will fail if the new table is missing. -8. Run the schema integrity test, and update the hash: `cd ghost/core && pnpm test:single test/unit/server/data/schema/integrity.test.js` -9. Run unit tests in Ghost core, and iterate until they pass: `cd ghost/core && pnpm test:unit` +6. Roll the migration back to test `down()`: `cd ghost/core && pnpm knex-migrator rollback --v {previous version} --force`, then migrate forward again. +7. Run the migration integration test, which covers initialization, rollback, forward migration, and idempotency: `cd ghost/core && pnpm test:single test/integration/migrations/migration.test.js`. Migrations must pass the database-backed suites against both MySQL and SQLite. +8. If adding or dropping a table, update `ghost/core/core/server/data/exporter/table-lists.js` as appropriate. The consistency assertion in `ghost/core/test/unit/server/data/exporter/index.test.js` checks that every schema table is classified in the exporter lists. +9. Run the focused exporter unit test when the table lists change: `cd ghost/core && pnpm test:single test/unit/server/data/exporter/index.test.js`. +10. Run the schema integrity test, and update the hash: `cd ghost/core && pnpm test:single test/unit/server/data/schema/integrity.test.js` +11. Run unit tests in Ghost core, and iterate until they pass: `cd ghost/core && pnpm test:unit` ## Examples See [examples.md](examples.md) for example migrations. ## Rules -See [rules.md](rules.md) for rules that should always be followed when creating database migrations. \ No newline at end of file +See [rules.md](rules.md) for rules that should always be followed when creating database migrations. diff --git a/.agents/skills/migrate-internal-package/SKILL.md b/.agents/skills/migrate-internal-package/SKILL.md index 6b0b1d3018c..ca161ef3953 100644 --- a/.agents/skills/migrate-internal-package/SKILL.md +++ b/.agents/skills/migrate-internal-package/SKILL.md @@ -19,6 +19,28 @@ Explain every cross-repository or administrative action before it happens. PR, deprecating npm versions, or force-pushing. - Never ask for, display, or store credentials or OTPs. +## Work in isolated checkouts + +Never manipulate history in a checkout containing unrelated work. Fetch both +repositories, confirm the source checkout is clean, and create a dedicated +Ghost worktree from the freshly fetched `origin/main`. + +Run history-changing commands individually or in a fail-fast shell. A failed +`git worktree add` must not be followed by `git subtree add` in whichever +checkout happens to be current. Before choosing a branch or path, list existing +worktrees and matching local and remote branches. Use a migration-specific slug, +for example `codex/import--from-`, rather than a generic name. + +If the destination branch or path already exists, stop and inspect its +cleanliness, base, divergence, source split and attached worktree. Do not mutate, +delete or silently reuse it. Present the evidence and ask the user whether to +resume, preserve and supersede, or remove it when more than one choice is +reasonable. A previous attempt can contain valid unmerged history even when its +remote branch is gone. + +Before importing, record and compare the destination `HEAD` and `origin/main`; +they must match. Recheck the first parent immediately after the subtree commit. + ## Confirm this workflow applies Before changing either repository, record the source repository, its default @@ -36,6 +58,22 @@ Publication or download counts alone do not prove that a package must remain a supported public API. If supported external consumers still need new releases, stop: this internal-only workflow is the wrong publishing model. +For the public-consumer audit, check npm metadata, first-party repositories and +documentation, plus GitHub code search for package dependencies and runtime +imports. Classify results as first-party consumers, independent integrations, +Ghost forks, or deployed Ghost installation snapshots. Forks and installation +snapshots show historical presence only. Require a recent update, current +deployment, active dependency or another freshness signal before treating them +as evidence of continued installation needs; even then, they do not establish +an independently supported API. Record the evidence and confidence behind the +ownership decision; stop and ask if current support expectations remain +unclear. + +Run registry-only `npm view` commands from a neutral temporary directory. A +repository's `devEngines` policy can reject the host Node version before npm +contacts the registry, which is unrelated to the package metadata audit. Record +that failure separately if using a neutral directory does not resolve it. + ## Produce these work products in order 1. A green Ghost import PR with reachable source history. @@ -63,14 +101,48 @@ 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; - retain runtime behavior for the later modernization PR. +Before editing package metadata, map each source `workspace:*` dependency to +its destination state: + +- use `workspace:*` when the dependency already exists in Ghost; +- use `catalog:` when Ghost's catalog version satisfies the imported package; +- add a named migration catalog with the exact published version when changing + the shared catalog would broaden the migration or alter runtime behavior; +- stop if a required dependency is unpublished or exists only in the source + workspace. + +Document temporary named-catalog entries for the modernization follow-up. Never +inline dependency versions; Ghost's strict catalog policy still applies. Do not +import additional packages implicitly. + +If the package is legacy JavaScript or CommonJS, read +[`references/legacy-integration.md`](references/legacy-integration.md) +completely before creating integration commits. + Verify that the subtree commit has two parents and that representative file history crosses into the source repository before opening the PR. +Before opening the PR, also verify the source split is reachable from the branch +tip, the consumer resolves the workspace package through its production import +path, package lint and tests pass through Nx, relevant consumer tests pass, the +full build passes, and the Ghost archive contains the internal package. Record +the exact commit IDs and commands in the handoff. + +For a pilot or first use, include a structured gap report in the handoff: + +- `Observed`: the exact failure or ambiguity and the command/state that exposed it; +- `Worked around`: the safe action taken, without hiding the original gap; +- `Skill change`: the concrete instruction, preflight or script improvement; +- `Tooling change`: anything that cannot be solved within this repository; +- `Confidence`: high, medium or low, with unresolved evidence called out. + ## 2. Merge the import without rewriting history This is the exceptional PR. It must use GitHub's **Create a merge commit** diff --git a/.agents/skills/migrate-internal-package/references/history-and-merge.md b/.agents/skills/migrate-internal-package/references/history-and-merge.md index 9bdcdbd9b34..0e836b3e362 100644 --- a/.agents/skills/migrate-internal-package/references/history-and-merge.md +++ b/.agents/skills/migrate-internal-package/references/history-and-merge.md @@ -4,23 +4,49 @@ Read this reference before creating the Ghost import PR. ## Create package-only source history -Work from an up-to-date clone of the source repository. Use its actual default +Work from an up-to-date, clean, full clone of the source repository. Shallow and +partial clones can complete `git subtree split` while still failing later when +Ghost fetches the split, because the local source cannot serve promised objects. +Reject them before splitting: + +```bash +set -euo pipefail + +test "$(git rev-parse --is-shallow-repository)" = "false" +test -z "$(git config --local --get extensions.partialClone || true)" +``` + +If either check fails, create a fresh full clone without `--depth`, +`--filter` or sparse/partial clone options. Use the source's actual default branch and a temporary branch name that cannot be confused with a product -branch. For example, when the default branch is `main`: +branch. + +`git subtree split` may inspect thousands of commits and run for several +minutes. Use `--quiet` in agent or CI-style runners so its progress stream does +not flood or interrupt the runner. Run it on its own, keep the process alive +until it returns an exit status, and do not mistake an output timeout for +completion. For example, when the default branch is `main`: ```bash +set -euo pipefail + git fetch origin git switch --detach origin/main +test -z "$(git status --porcelain)" git subtree split \ + --quiet \ --prefix= \ - -b migrate--history + -b migrate--history \ + origin/main ``` -Record the split tip and inspect the resulting package-only history: +Confirm the branch was actually created, then record the split tip and inspect +the resulting package-only history: ```bash -git rev-parse migrate--history -git log --oneline --reverse migrate--history +git show-ref --verify refs/heads/migrate--history +source_split_tip=$(git rev-parse migrate--history) +git log --oneline --reverse "$source_split_tip" ``` `git subtree split` retains the relevant commit authorship and chronology while @@ -28,8 +54,33 @@ excluding unrelated source-repository paths. ## Attach the history to Ghost -From a branch based on freshly fetched Ghost `origin/main`, fetch the local -source split and add it without `--squash`: +Before creating the worktree, inspect collisions rather than discovering them +halfway through the import: + +```bash +git fetch --prune origin +git worktree list --porcelain +git branch --list 'codex/import-*' +git branch --remotes --list 'origin/codex/import-*' +``` + +If a match exists, record its worktree, cleanliness, base/divergence, imported +split and remote state. Do not delete or overwrite it without an explicit user +decision. Otherwise create or enter a dedicated Ghost worktree, then verify its +branch, cleanliness, base and empty destination before attaching history: + +```bash +set -euo pipefail + +test "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" +test "$(git branch --show-current)" = "codex/import--from-" +test -z "$(git status --porcelain)" +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +test ! -e "packages/" +``` + +Fetch the local source split and add it without `--squash`. Run these as +separate checked operations and stop immediately if either fails: ```bash git fetch /absolute/path/to/source-repository migrate--history @@ -49,13 +100,40 @@ git-subtree-split: Verify the topology before adding integration commits: ```bash -git show --no-patch --format='%H%nparents: %P%n%B' HEAD +set -euo pipefail + +source_split_tip="" +subtree_commit=$(git rev-parse HEAD) +ghost_parent=$(git rev-parse HEAD^1) +imported_parent=$(git rev-parse HEAD^2) +source_path="path/to/representative-file" +destination_path="packages//$source_path" + +test "$ghost_parent" = "$(git rev-parse origin/main)" +test "$imported_parent" = "$source_split_tip" +git merge-base --is-ancestor "$source_split_tip" HEAD + +source_history=$(git log --full-history --format=%H "$source_split_tip" -- "$source_path") +destination_history=$(git log --full-history --format=%H -- "$destination_path") +test -n "$source_history" +test -n "$destination_history" +test "$(git rev-parse "$source_split_tip:$source_path")" = \ + "$(git rev-parse "$subtree_commit:$destination_path")" + +git show --no-patch --format='%H%nparents: %P%n%B' "$subtree_commit" git log --graph --oneline --decorate --all --max-count=40 -git log --follow -- packages//path/to/representative-file +git log --full-history --oneline -- "$destination_path" +git log --full-history --oneline "$source_split_tip" -- "$source_path" ``` -The subtree commit must have two parents, and its second parent must equal the -recorded split tip. +The subtree commit must have two parents: its first parent must equal the +recorded Ghost base and its second parent must equal the recorded split tip. +Checking the prefixed destination path with `--full-history` and the unprefixed +split path separately is more reliable around merge boundaries than relying on +ordinary path history or `--follow`, either of which may show only the subtree +merge. Requiring non-empty histories and equal blob IDs makes a missing or +mismatched representative file stop the import before integration edits obscure +the source state. The Admin API schema migration from TryGhost/SDK is a known-good example: diff --git a/.agents/skills/migrate-internal-package/references/legacy-integration.md b/.agents/skills/migrate-internal-package/references/legacy-integration.md new file mode 100644 index 00000000000..bc29be43e92 --- /dev/null +++ b/.agents/skills/migrate-internal-package/references/legacy-integration.md @@ -0,0 +1,107 @@ +# Integrate a legacy JavaScript package + +Read this reference when the imported package is JavaScript or CommonJS. The +import PR establishes ownership and a green workspace package; it does not +modernize the implementation. + +## Keep the integration narrow + +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:*`; +- translate source-workspace dependencies deliberately; +- adapt lint and test configuration to Ghost's shared tools. + +Do not rename source files, change module format, introduce TypeScript, redesign +exports or clean up implementation details. Use the +`convert-internal-package-to-typescript` skill for that later work. + +## ESLint configuration + +Use the shared Node library factory in CommonJS mode and declare +`@internal/cfg-eslint` plus `eslint` as package dev dependencies: + +```js +import {nodeLibConfig} from '@internal/cfg-eslint'; + +export default nodeLibConfig({ + typescript: false, + commonjs: true, + legacyLocalFilenames: true, + srcGlobs: ['index.js', 'lib/**/*.js'], + testGlobs: ['test/**/*.js'] +}); +``` + +Prefer fixing trivial configuration drift. When a Ghost rule conflicts with a +test that intentionally exercises legacy behavior, use a narrow, documented +`extraTestRules` exception instead of changing runtime semantics. Rules must be +`'error'` or `'off'`, never warnings. + +## Vitest configuration + +The shared default targets TypeScript, so override its globs for JavaScript. +Preserve the source repository's coverage thresholds where practical: + +```ts +import {createVitestConfig} from '@internal/cfg-vitest'; + +export default createVitestConfig({ + test: { + globals: true, + include: ['test/**/*.test.js'], + coverage: { + include: ['lib/**/*.js'], + thresholds: { + lines: 90, + functions: 90, + branches: 80, + statements: 90 + } + } + } +}); +``` + +Declare `@internal/cfg-vitest`, `@vitest/coverage-v8` and `vitest` in the +package's dev dependencies. Match the actual source and test layout rather than +copying these example globs blindly. + +## Dependency mapping + +Source `workspace:*` ranges cannot be copied blindly when their packages are +not also Ghost workspaces. For each dependency: + +1. Prefer `workspace:*` for an existing Ghost package. +2. Prefer Ghost's catalog when it provides a compatible version. +3. Add the source release's exact published version to a named migration + catalog when changing the shared catalog would affect unrelated consumers or + alter behavior. Reference it with `catalog:`; never inline the version. +4. Stop if no published or destination-workspace dependency can satisfy it. + +Record temporary named-catalog entries in the PR and reassess them during +modernization. + +## Verification + +Run the package through its Nx surface and exercise a real consumer: + +```bash +pnpm nx run @tryghost/:lint +pnpm nx run @tryghost/:test +pnpm build +``` + +Also verify: + +- the package resolves through the consumer's production `require()` or import; +- relevant consumer tests pass; +- `pnpm archive` succeeds from `ghost/core`; +- the resulting Ghost archive contains the package component. + +Generated archives are verification artifacts, not files to commit. diff --git a/.agents/skills/shade-component-decision/SKILL.md b/.agents/skills/shade-component-decision/SKILL.md index 49cbeb2b172..fb5b70283d5 100644 --- a/.agents/skills/shade-component-decision/SKILL.md +++ b/.agents/skills/shade-component-decision/SKILL.md @@ -70,4 +70,5 @@ Each layer can use anything **below** it. The reverse is forbidden. ## Source of truth -Full rules: `apps/shade/AGENTS.md`. Human-facing: Storybook → Overview / Layers. +Storybook → Overview / Layers owns the layer model and promotion rules. +Overview / Contributing owns the implementation requirements. diff --git a/.agents/skills/shade-dropdown-surface-contract/SKILL.md b/.agents/skills/shade-dropdown-surface-contract/SKILL.md index d2008e1827d..5567d3fe628 100644 --- a/.agents/skills/shade-dropdown-surface-contract/SKILL.md +++ b/.agents/skills/shade-dropdown-surface-contract/SKILL.md @@ -72,4 +72,5 @@ Hover/active/selected state tokens (`--interactive-hover`, `--button-hover`, `-- ## Source of truth -`apps/shade/AGENTS.md` (Tokens & dark mode → Dropdown surface contract). Storybook → Tokens / Tokens Guide. +The `DropdownMenu`, `Select`, and `Popover` component files define the current +surface contract. Storybook → Tokens / Tokens Guide explains the token model. diff --git a/.agents/skills/shade-new-component/SKILL.md b/.agents/skills/shade-new-component/SKILL.md index 3f8d1be58bb..196e9ead511 100644 --- a/.agents/skills/shade-new-component/SKILL.md +++ b/.agents/skills/shade-new-component/SKILL.md @@ -114,4 +114,4 @@ See `shade-tokens-not-hex` and `shade-no-dark-variants`. ## Source of truth -`apps/shade/AGENTS.md`. Human docs: Storybook → Overview / Contributing. +Storybook → Overview / Contributing and the component's stories. diff --git a/.agents/skills/shade-page-templates/SKILL.md b/.agents/skills/shade-page-templates/SKILL.md index 1416a307d99..1a79a0b7559 100644 --- a/.agents/skills/shade-page-templates/SKILL.md +++ b/.agents/skills/shade-page-templates/SKILL.md @@ -94,4 +94,5 @@ If you're tempted to force a non-list shape into `ListPage`, stop and check whet ## Source of truth -`apps/shade/AGENTS.md`. Human docs: Storybook → Page Templates / Page Types. +Storybook → Page Templates / Page Types and the `ListPage` and `PageHeader` +stories. diff --git a/.agents/skills/shade-shadcn-install/SKILL.md b/.agents/skills/shade-shadcn-install/SKILL.md index 3fb440fcc48..9d5e91e8bc8 100644 --- a/.agents/skills/shade-shadcn-install/SKILL.md +++ b/.agents/skills/shade-shadcn-install/SKILL.md @@ -58,4 +58,5 @@ Raw ShadCN output is not Shade-quality yet. Do all of these: ## Source of truth -`apps/shade/AGENTS.md`, Storybook → Overview / Contributing. +Storybook → Overview / Contributing. This skill adds the agent-specific safety +steps for running the destructive ShadCN CLI. diff --git a/.amp/services.yaml b/.amp/services.yaml new file mode 100644 index 00000000000..43ae16ac2eb --- /dev/null +++ b/.amp/services.yaml @@ -0,0 +1,24 @@ +services: + docker: + command: sudo dockerd --group root + + ghost: + command: | + export PATH="$PWD/.agents/bin:$HOME/.local/node-v$(cat .node-version)-linux-x64/bin:$PATH" + attempts=0 + until docker info >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 60 ]; then + echo "Docker did not become ready after 60 seconds" >&2 + exit 1 + fi + sleep 1 + done + export DEV_COMPOSE_FILES="-f compose.dev.orb.yaml" + export GHOST_URL="$PUBLIC_URL" + unset PORT + exec pnpm dev + port: 2370 + portal: + title: Ghost development + description: Full source environment with Admin HMR, backend reloads, MySQL, Redis, and Mailpit. diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md deleted file mode 100644 index a9f1b26a831..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.2.1 - -### Patch Changes - -- Fixed the test suite type-check against the typed @tryghost/logging error signature. diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md new file mode 100644 index 00000000000..fbff870d83f --- /dev/null +++ b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md @@ -0,0 +1,7 @@ +## 0.2.2 + +### Patch Changes + +- Update framework dependencies + +- Updated logging requests to avoid process crashes on redirects. diff --git a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md new file mode 100644 index 00000000000..a7e11d25ee4 --- /dev/null +++ b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md @@ -0,0 +1,5 @@ +## 0.1.2 + +### Patch Changes + +- Update framework dependencies diff --git a/.changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md b/.changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md new file mode 100644 index 00000000000..f5e66f47b75 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md @@ -0,0 +1,5 @@ +## 5.2.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md b/.changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md new file mode 100644 index 00000000000..7aa113e7681 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md @@ -0,0 +1,7 @@ +## 4.3.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/changelogs/@tryghost!kg-converters@1.2.5.md b/.changeset/changelogs/@tryghost!kg-converters@1.2.5.md new file mode 100644 index 00000000000..c4c2d4e03af --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-converters@1.2.5.md @@ -0,0 +1,5 @@ +## 1.2.5 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md b/.changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md new file mode 100644 index 00000000000..0bd9ffe2541 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md @@ -0,0 +1,7 @@ +## 10.3.5 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md new file mode 100644 index 00000000000..25df9398a6f --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md @@ -0,0 +1,11 @@ +## 2.2.0 + +### Minor Changes + +- Update jsdom to 30 & node engines to match jsdom's + +### Patch Changes + +- Removed the unused emailCustomization and emailCustomizationAlpha feature options. + +- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md b/.changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md new file mode 100644 index 00000000000..f1a05dc1521 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md @@ -0,0 +1,8 @@ +## 1.3.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies: + - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md b/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md new file mode 100644 index 00000000000..c80a8b4cc25 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md @@ -0,0 +1,12 @@ +## 1.4.0 + +### Minor Changes + +- Update jsdom to 30 & node engines to match jsdom's + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies: + - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md b/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md new file mode 100644 index 00000000000..bdd78e70a09 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md @@ -0,0 +1,12 @@ +## 1.5.0 + +### Minor Changes + +- Update jsdom to 30 & node engines to match jsdom's + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies: + - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md b/.changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md new file mode 100644 index 00000000000..8a331ce1fb6 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md @@ -0,0 +1,7 @@ +## 7.2.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies diff --git a/.changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md b/.changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md new file mode 100644 index 00000000000..b9fe6f38f34 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md @@ -0,0 +1,11 @@ +## 0.4.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies + +- Update jsdom to 30 & node engines to match jsdom's + +- Updated dependencies diff --git a/.changeset/changelogs/@tryghost!kg-utils@1.1.4.md b/.changeset/changelogs/@tryghost!kg-utils@1.1.4.md new file mode 100644 index 00000000000..c14982b63d4 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-utils@1.1.4.md @@ -0,0 +1,7 @@ +## 1.1.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md deleted file mode 100644 index c45d98a4d85..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md +++ /dev/null @@ -1,5 +0,0 @@ -## 1.9.1 - -### Patch Changes - -- Fixed contrast text colors for light backgrounds diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md new file mode 100644 index 00000000000..43c4597d652 --- /dev/null +++ b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md @@ -0,0 +1,17 @@ +## 1.9.2 + +### Patch Changes + +- Updated the test commands in the README + +- Updated dependencies + +- Updated Koenig Lexical testing documentation. + +- Added a package description for npm + +- Updated dependencies + +- Updated dependencies + +- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/clean-flags-retire.md b/.changeset/clean-flags-retire.md new file mode 100644 index 00000000000..8af68dbcdd8 --- /dev/null +++ b/.changeset/clean-flags-retire.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-default-nodes": patch +--- + +Removed the unused emailCustomization and emailCustomizationAlpha feature options. diff --git a/.changeset/common-squids-argue.md b/.changeset/common-squids-argue.md new file mode 100644 index 00000000000..538ca0cd0a7 --- /dev/null +++ b/.changeset/common-squids-argue.md @@ -0,0 +1,15 @@ +--- +"@tryghost/kg-utils": patch +"@tryghost/kg-unsplash-selector": patch +"@tryghost/kg-markdown-html-renderer": patch +"@tryghost/kg-lexical-html-renderer": patch +"@tryghost/kg-html-to-lexical": patch +"@tryghost/kg-default-transforms": patch +"@tryghost/kg-default-nodes": patch +"@tryghost/kg-default-cards": patch +"@tryghost/kg-converters": patch +"@tryghost/kg-clean-basic-html": patch +"@tryghost/kg-card-factory": patch +--- + +Documented the package API and corrected the development instructions in the README diff --git a/.changeset/four-rings-relate.md b/.changeset/four-rings-relate.md new file mode 100644 index 00000000000..f2796b8087f --- /dev/null +++ b/.changeset/four-rings-relate.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-utils": patch +"@tryghost/kg-markdown-html-renderer": patch +--- + +Updated dependencies diff --git a/.changeset/funky-bikes-chew.md b/.changeset/funky-bikes-chew.md new file mode 100644 index 00000000000..844f52d6738 --- /dev/null +++ b/.changeset/funky-bikes-chew.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Updated the test commands in the README diff --git a/.changeset/funky-rice-shine.md b/.changeset/funky-rice-shine.md new file mode 100644 index 00000000000..2f762631c3a --- /dev/null +++ b/.changeset/funky-rice-shine.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Updated dependencies diff --git a/.changeset/ledger.yaml b/.changeset/ledger.yaml index bf4eb15a2e8..2c16f4b0153 100644 --- a/.changeset/ledger.yaml +++ b/.changeset/ledger.yaml @@ -16,19 +16,46 @@ dir: packages/adapters/scheduling-base intents: - typed-logging-args +"@tryghost/adapter-base-scheduling@0.2.2": + dir: packages/adapters/scheduling-base + intents: + - open-shirts-arrive + - young-spiders-request "@tryghost/adapter-base-sso@0.1.1": dir: packages/adapters/sso-base intents: - major-areas-fail - plain-singers-cheat +"@tryghost/adapter-base-sso@0.1.2": + dir: packages/adapters/sso-base + intents: + - open-shirts-arrive +"@tryghost/kg-card-factory@5.2.4": + dir: koenig/kg-card-factory + intents: + - common-squids-argue +"@tryghost/kg-clean-basic-html@4.3.4": + dir: koenig/kg-clean-basic-html + intents: + - common-squids-argue + - six-parents-shout "@tryghost/kg-converters@1.2.4": dir: koenig/kg-converters intents: - tidy-types-check +"@tryghost/kg-converters@1.2.5": + dir: koenig/kg-converters + intents: + - common-squids-argue "@tryghost/kg-default-cards@10.3.4": dir: koenig/kg-default-cards intents: - spacy-poodles-hunt +"@tryghost/kg-default-cards@10.3.5": + dir: koenig/kg-default-cards + intents: + - common-squids-argue + - six-parents-shout "@tryghost/kg-default-nodes@2.1.4": dir: koenig/kg-default-nodes intents: @@ -38,6 +65,43 @@ intents: - spacy-poodles-hunt - warm-hotels-make +"@tryghost/kg-default-nodes@2.2.0": + dir: koenig/kg-default-nodes + intents: + - clean-flags-retire + - common-squids-argue + - six-parents-shout +"@tryghost/kg-default-transforms@1.3.4": + dir: koenig/kg-default-transforms + intents: + - common-squids-argue +"@tryghost/kg-html-to-lexical@1.4.0": + dir: koenig/kg-html-to-lexical + intents: + - common-squids-argue + - six-parents-shout +"@tryghost/kg-lexical-html-renderer@1.5.0": + dir: koenig/kg-lexical-html-renderer + intents: + - common-squids-argue + - six-parents-shout +"@tryghost/kg-markdown-html-renderer@7.2.4": + dir: koenig/kg-markdown-html-renderer + intents: + - common-squids-argue + - four-rings-relate +"@tryghost/kg-unsplash-selector@0.4.4": + dir: koenig/kg-unsplash-selector + intents: + - common-squids-argue + - petite-schools-accept + - six-parents-shout + - swift-guests-grin +"@tryghost/kg-utils@1.1.4": + dir: koenig/kg-utils + intents: + - common-squids-argue + - four-rings-relate "@tryghost/koenig-lexical@1.9.0": dir: koenig/koenig-lexical intents: @@ -46,6 +110,16 @@ dir: koenig/koenig-lexical intents: - warm-hotels-make +"@tryghost/koenig-lexical@1.9.2": + dir: koenig/koenig-lexical + intents: + - funky-bikes-chew + - funky-rice-shine + - loose-pans-argue + - olive-regions-stop + - petite-forks-lick + - petite-schools-accept + - six-parents-shout ghost-storage-base@3.0.0: dir: packages/adapters/storage-base intents: diff --git a/.changeset/loose-pans-argue.md b/.changeset/loose-pans-argue.md new file mode 100644 index 00000000000..2da772de325 --- /dev/null +++ b/.changeset/loose-pans-argue.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Updated Koenig Lexical testing documentation. diff --git a/.changeset/olive-regions-stop.md b/.changeset/olive-regions-stop.md new file mode 100644 index 00000000000..286b0090c18 --- /dev/null +++ b/.changeset/olive-regions-stop.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Added a package description for npm diff --git a/.changeset/open-shirts-arrive.md b/.changeset/open-shirts-arrive.md new file mode 100644 index 00000000000..bb7ecca6cad --- /dev/null +++ b/.changeset/open-shirts-arrive.md @@ -0,0 +1,6 @@ +--- +"@tryghost/adapter-base-scheduling": patch +"@tryghost/adapter-base-sso": patch +--- + +Update framework dependencies diff --git a/.changeset/petite-forks-lick.md b/.changeset/petite-forks-lick.md new file mode 100644 index 00000000000..2f762631c3a --- /dev/null +++ b/.changeset/petite-forks-lick.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Updated dependencies diff --git a/.changeset/petite-schools-accept.md b/.changeset/petite-schools-accept.md new file mode 100644 index 00000000000..d9c67c82823 --- /dev/null +++ b/.changeset/petite-schools-accept.md @@ -0,0 +1,6 @@ +--- +"@tryghost/koenig-lexical": patch +"@tryghost/kg-unsplash-selector": patch +--- + +Updated dependencies diff --git a/.changeset/six-parents-shout.md b/.changeset/six-parents-shout.md new file mode 100644 index 00000000000..6c070752718 --- /dev/null +++ b/.changeset/six-parents-shout.md @@ -0,0 +1,11 @@ +--- +"@tryghost/kg-default-nodes": minor +"@tryghost/kg-html-to-lexical": minor +"@tryghost/kg-lexical-html-renderer": minor +"@tryghost/kg-clean-basic-html": patch +"@tryghost/kg-default-cards": patch +"@tryghost/kg-unsplash-selector": patch +"@tryghost/koenig-lexical": patch +--- + +Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/swift-guests-grin.md b/.changeset/swift-guests-grin.md new file mode 100644 index 00000000000..f26419f3739 --- /dev/null +++ b/.changeset/swift-guests-grin.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-unsplash-selector": patch +--- + +Updated dependencies diff --git a/.changeset/typed-logging-args.md b/.changeset/typed-logging-args.md deleted file mode 100644 index 8e3c5ec1423..00000000000 --- a/.changeset/typed-logging-args.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch ---- - -Fixed the test suite type-check against the typed @tryghost/logging error signature. diff --git a/.changeset/young-spiders-request.md b/.changeset/young-spiders-request.md new file mode 100644 index 00000000000..22c87141a74 --- /dev/null +++ b/.changeset/young-spiders-request.md @@ -0,0 +1,5 @@ +--- +"@tryghost/adapter-base-scheduling": patch +--- + +Updated logging requests to avoid process crashes on redirects. diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3bb38413bf2..9f5c78e7340 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,11 +1,212 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json reviews: + profile: quiet + request_changes_workflow: false + review_details: true + review_status: true + review_progress: true high_level_summary: false collapse_walkthrough: false changed_files_summary: false sequence_diagrams: false estimate_code_review_effort: false poem: false + auto_review: + enabled: true + drafts: false + ignore_usernames: + - app/tryghost-renovate + - dependabot[bot] + path_filters: + - "!**/dist/**" + - "!**/build/**" + - "!**/built/**" + - "!**/umd/**" + - "!**/coverage/**" pre_merge_checks: docstrings: mode: "off" + custom_checks: + - name: "Type-safe boundaries" + mode: "warning" + instructions: | + Fail only if the PR: + - consumes boundary data (HTTP input, external API/SDK responses, env/config, + DB/filesystem reads, queue/webhook/event payloads) without validating it + first — Zod by default, another format only where an external contract + requires it; or + - introduces `any`, unchecked `as`, `@ts-nocheck`, or `@ts-ignore` to bypass + typing boundary data; or + - hand-writes a type duplicating a shape a Zod schema describes (use z.infer). + Never fail for: internal function/module calls (no runtime validation needed), + pre-existing JS files touched incidentally, tests, scripts, or config files. + - name: "New files are TypeScript" + mode: "error" + instructions: | + Fail if the PR adds a new .js/.jsx/.cjs/.mjs source file, unless it is: a DB + migration (ghost/core/core/server/data/migrations/), under apps/ember-admin/, + a tool/config file, under scripts/ or docker/, or generated/vendored code. + Modifying pre-existing JS files never fails this check. + path_instructions: + - path: "**/*" + instructions: | + Prioritise concrete correctness, security, data-integrity, compatibility, + and regression risks. Explain the failure mode and point to the affected + code. Do not report formatting, naming, import ordering, type errors, or + other findings already owned by configured static tools or failing GitHub + checks. Do not request speculative abstractions, broad refactors, generic + documentation, or tests unrelated to changed behaviour. Treat nearby + AGENTS.md files and mapped codebase documentation as authoritative; do not + enforce proposals, plans, or historical guidance as current policy. + - path: "**/*.{ts,tsx,mts,cts}" + instructions: | + Review lens: "where does this data become trusted?" + - Boundary data (HTTP input, external API/SDK responses, env/config, + DB/filesystem reads, queue/webhook/event payloads) is `unknown` until + validated — Zod by default. + - Infer boundary types via z.infer/z.input; flag handwritten duplicates. + - Flag `any`, unchecked `as` on boundary data, `@ts-nocheck`, and unexplained + `@ts-ignore`/`@ts-expect-error`. + - Validated data stays trusted: don't request Zod on internal calls, and flag + redundant re-validation. + - ghost/core golden path: schema.ts owns Zod schemas + inferred types, with + codec/serializer modules at the edges (see core/server/services/gift-links). + - Looser typing in tests is fine unless it hides a real defect. + - path: "**/*.{js,jsx,cjs,mjs}" + instructions: | + New source files must be TypeScript: flag new JS files as a required change + unless exempt (DB migrations, apps/ember-admin/, tool/config files, scripts/, + docker/, generated code). + Never request conversion of pre-existing JS files. If the PR substantially + reworks one (rewritten logic or significant new functions — not renames or + small fixes), you may leave ONE optional, non-blocking note for the whole PR + that those files are cheap TS-conversion candidates; skip minor changes and + exempt areas. + If the PR adds or changes a runtime boundary (parsing HTTP input, JSON, config, + external responses), suggest validating it — ideally with TS + Zod. + - path: "ghost/core/core/server/api/**" + instructions: | + Review API contract semantics: authentication and permissions, validation at + untrusted boundaries, writable-field allowlists, accidental response-data + exposure, stable error codes/statuses, pagination/filter consistency, cache + invalidation, and compatibility with existing clients. Require tests only for + changed behaviour or a credible regression path. Do not repeat endpoint + complexity, filenames, typing, or other ESLint/schema failures. + - path: "ghost/core/core/server/services/**" + instructions: | + Review new or changed service boundaries for explicit dependency ownership, + deterministic/idempotent initialisation, boot ordering, transaction and event + semantics, cache coherence, and restart/multi-instance safety. New standalone + services default to TypeScript; extending an existing JavaScript service is an + accepted exception. Do not enforce unapproved repository, ORM, or dependency- + injection proposals as current architecture. + - path: "ghost/core/core/server/data/{migrations,schema}/**" + instructions: | + Review migration safety beyond lint: schema and migration parity, existing-data + shape and volume, deploy/rollback compatibility, transaction and locking risk, + idempotency, export/integrity updates, and preservation of constraints/defaults. + Do not duplicate migration filename, loop, schema-field, or integrity-check CI. + - path: "packages/**" + instructions: | + Review package boundaries and production consumption: minimal explicit exports, + declared runtime dependencies, source-condition versus built-output parity, + copied runtime assets, ESM/NodeNext compatibility, and consumer-facing release + impact. Respect ghostPackage migration/exempt metadata and public/browser/test- + only exceptions. Do not repeat fields enforced by lint:packages or changeset CI. + - path: "apps/{admin,activitypub,admin-x-framework,shade}/**/*.{ts,tsx}" + instructions: | + Review Admin UI for existing Shade reuse, correct component layer, semantic + tokens, accessible interaction states, and whole-sentence translations. New UI + that depends on backend settings, endpoints, or config must feature-detect old + backend support and cover the not-yet-deployed backend case. Do not apply these + rules to independent public UMD apps. Do not repeat ESLint/Tailwind findings. + - path: "apps/{portal,comments-ui,signup-form,sodo-search,announcement-bar}/**/*.{js,jsx,ts,tsx}" + instructions: | + These are independent public UMD/CDN surfaces, not embedded Shade apps. Review + backwards-compatible browser behaviour, bundle/runtime assumptions, accessible + recovery states, namespace-correct whole-sentence translations, and safe + handling of server-provided data. Do not request Shade adoption or Admin-only + Tailwind conventions. + - path: "e2e/tests/**/*.ts" + instructions: | + Review semantic E2E quality that static checks miss: test the user-visible + integration at the lowest useful layer; prefer web-first assertions and + semantic locators; keep reusable interactions in page objects and assertions in + tests; avoid hard waits and networkidle; use factories and preserve isolation. + Per-file environment reuse is the default, so request per-test isolation only + for state-heavy cases that genuinely need it. A direct semantic locator is fine + for a small one-off assertion. Do not repeat Playwright ESLint or CI failures. + - path: "e2e/helpers/**/*.ts" + instructions: | + Review fixture/page-object lifecycle, concurrency, reset timing, reusable + readiness guards, and stable public locators. Page objects may use necessary + structural selectors for iframe/editor/theme internals but must not contain + business assertions. Preserve the documented per-file/per-test isolation model. + - path: "**/*{.,-}{test,spec}.{js,jsx,ts,tsx}" + instructions: | + Review whether tests prove changed behaviour, meaningful error/edge paths, and + externally observable contracts without coupling to implementation details. + Prefer the lowest useful test layer. Do not demand broad E2E coverage for + isolated logic or repeat test-run failures already visible in GitHub checks. + - path: "docs/**/*.md" + instructions: | + Check technical claims, paths, commands, and declared authority/status against + the current repository. Flag contradictions and stale instructions with a + concrete source of truth. Do not demand generic tutorial expansion or enforce + proposal language on production code. + tools: + eslint: + enabled: true + oxc: + enabled: true + stylelint: + enabled: true + emberTemplateLint: + enabled: true + actionlint: + enabled: true + zizmor: + enabled: true + yamllint: + enabled: true + shellcheck: + enabled: true + opengrep: + enabled: true + gitleaks: + enabled: true + trufflehog: + enabled: true + osvScanner: + enabled: true + github-checks: + enabled: true + +knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - files: "docs/practices/api-design.md" + applyTo: "ghost/core/core/server/api/**,packages/admin-api-schema/**" + - files: "docs/practices/database-migrations.md" + applyTo: "ghost/core/core/server/data/migrations/**,ghost/core/core/server/data/schema/**" + - files: "docs/practices/error-handling.md" + applyTo: "ghost/core/core/server/**,apps/**/*.{js,jsx,ts,tsx}" + - files: "docs/practices/internationalization.md" + applyTo: "apps/**/*.{js,jsx,ts,tsx},packages/i18n/**" + - files: "docs/contributing/testing.md" + applyTo: "**/{test,tests}/**,**/*{.,-}{test,spec}.{js,jsx,ts,tsx}" + - files: "docs/codebase/monorepo-structure.md" + applyTo: "package.json,pnpm-workspace.yaml,nx.json,apps/**,packages/**,ghost/core/**,koenig/**" + - files: "docs/codebase/configuration.md" + applyTo: "ghost/core/core/shared/config/**,ghost/core/config*.json*" + - files: "docs/codebase/internal-caching.md" + applyTo: "ghost/core/core/server/adapters/cache/**,ghost/core/core/server/adapters/lib/redis/**,ghost/core/core/server/**/*cache*.{js,ts},ghost/core/core/shared/config/**,packages/adapters/cache-base/**" + - files: "docs/codebase/jobs.md" + applyTo: "ghost/core/core/server/services/**" + - files: "packages/README.md" + applyTo: "packages/**" + - files: "apps/shade/AGENTS.md" + applyTo: "apps/admin/**,apps/activitypub/**,apps/admin-x-framework/**,apps/shade/**" + - files: "e2e/README.md,e2e/AGENTS.md" + applyTo: "e2e/**" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 558a85019a8..01af2f04490 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,62 +6,79 @@ For **help**, **support**, **questions** and **ideas** please use **[our forum]( ## Where to Start -If you're a developer looking to contribute, but you're not sure where to begin: Check out the [good first issue](https://github.com/TryGhost/Ghost/labels/good%20first%20issue) label on Github, which contains small piece of work that have been specifically flagged as being friendly to new contributors. +The [codebase documentation](../docs/README.md) explains how to set up the +monorepo and find your way around it. Start with the +[development setup guide](../docs/contributing/development-setup.md), then use +the [contribution workflow](../docs/contributing/workflow.md) when you are ready +to make a change. -After that, if you're looking for something a little more challenging to sink your teeth into, there's a broader [help wanted](https://github.com/TryGhost/Ghost/labels/help%20wanted) label encompassing issues which need some love. +If you're not sure what to work on, start with +[good first issues](https://github.com/TryGhost/Ghost/labels/good%20first%20issue) +or browse the broader +[help wanted](https://github.com/TryGhost/Ghost/labels/help%20wanted) list. -If you've got an idea for a new feature, please start by suggesting it in the [forum](https://forum.ghost.org), as adding new features to Ghost first requires generating consensus around a design and spec. +Discuss new features and substantial product or architectural changes in the +[forum](https://forum.ghost.org) before implementing them. +## Commit Messages -## Working on Ghost Core +We have a handful of simple standards for commit messages which keep the main +branch readable and generate useful release notes. They matter most for pull +request titles and squash commits; follow them for intermediate commits where +practical. -If you're going to work on Ghost core you'll need to go through a slightly more involved install and setup process than the usual Ghost CLI version. +```text + -First you'll need to fork [Ghost](https://github.com/tryghost/ghost) to your personal Github account, and then follow the detailed [install from source](https://ghost.org/docs/install/source/) setup guide. + + +``` -### Branching Guide - -`main` on the main repository always contains the latest changes. This means that it is WIP for the next minor version and should NOT be considered stable. Stable versions are tagged using [semantic versioning](http://semver.org/). - -On your local repository, you should always work on a branch to make keeping up-to-date and submitting pull requests easier, but in most cases you should submit your pull requests to `main`. Where necessary, for example if multiple people are contributing on a large feature, or if a feature requires a database change, we make use of feature branches. - - -### Commit Messages - -We have a handful of simple standards for commit messages which help us to generate readable changelogs. Please follow this wherever possible and mention the associated issue number. +- Start the summary with `Fixed`, `Changed`, `Updated`, `Improved`, `Added`, + `Removed`, `Reverted`, `Moved`, `Released`, `Bumped`, or `Cleaned`. +- Keep the second line blank. +- When an issue exists, use a supported relationship followed by its URL, such + as `ref `, `fixes `, or `closes `. Use + `no ref` when it is useful to state explicitly that there is no issue, or + leave this line blank. +- Explain the context in the body: why this change, why now, and why this + approach. The diff already describes what changed. -- **1st line:** Max 80 character summary - - Written in past tense e.g. “Fixed the thing” not “Fixes the thing” - - Start with one of: Fixed, Changed, Updated, Improved, Added, Removed, Reverted, Moved, Released, Bumped, Cleaned -- **2nd line:** [Always blank] -- **3rd line:** `ref `, `fixes `, `closes ` or blank -- **4th line:** Why this change was made - the code includes the what, the commit message should describe the context of why - why this, why now, why not something else? +The local hook warns about most deviations without blocking the commit. It +does require the common invalid forms `refs ...` and `ref: ...` to be corrected +to a supported relationship such as `ref ...`. -If your change is **user-facing** please prepend the first line of your commit with **an emoji key**. If the commit is for an alpha feature, no emoji is needed. We are following [gitmoji](https://gitmoji.carloscuesta.me/). +### Release-note emojis -**Main emojis we are using:** +A leading release-note emoji opts the squash commit into generated release +notes. Add one only for a significant change that is relevant to users, and +write the summary from their perspective. Alpha or experimental work does not +need an emoji until it becomes user-facing. - ✨ Feature -- 🎨 Improvement / change -- 🐛 Bug Fix -- 🌐 i18n (translation) submissions [[See Translating Ghost docs for more detail](https://www.notion.so/5af2858289b44f9194f73f8a1e17af59?pvs=25#bef8c9988e294a4b9a6dd624136de36f)] -- 💡 Anything else flagged to users or whoever is writing release notes +- 🎨 Improvement or change +- 🐛 Bug fix +- 💡 Other noteworthy user-facing change -Good commit message examples: [new feature](https://github.com/TryGhost/Ghost/commit/61db6defde3b10a4022c86efac29cf15ae60983f), [bug fix](https://github.com/TryGhost/Ghost/commit/6ef835bb5879421ae9133541ebf8c4e560a4a90e) and [translation](https://github.com/TryGhost/Ghost/commit/83904c1611ae7ab3257b3b7d55f03e50cead62d7). +Use 🌐 for [translation submissions](../docs/contributing/translating-ghost.md). +Translation commits are not selected for generated release notes by that emoji +alone. -**Bumping @tryghost dependencies** - -When bumping `@tryghost/*` dependencies, the first line should follow the above format and say what has changed, not say what has been bumped. - -There is no need to include what modules have changed in the commit message, as this is _very_ clear from the contents of the commit. The commit should focus on surfacing the underlying changes from the dependencies - what actually changed as a result of this dependency bump? +Good final commit examples include a [new feature](https://github.com/TryGhost/Ghost/commit/61db6defde3b10a4022c86efac29cf15ae60983f), +a [bug fix](https://github.com/TryGhost/Ghost/commit/6ef835bb5879421ae9133541ebf8c4e560a4a90e), +and a [translation](https://github.com/TryGhost/Ghost/commit/83904c1611ae7ab3257b3b7d55f03e50cead62d7). -[Good example](https://github.com/TryGhost/Ghost/commit/95751a0e5fb719bb5bca74cb97fb5f29b225094f) +**Bumping @tryghost dependencies** +When bumping `@tryghost/*` dependencies, describe the user-visible result rather +than which packages were bumped. The diff already shows the package changes; +the message should explain what changed because of them. See this +[good example](https://github.com/TryGhost/Ghost/commit/95751a0e5fb719bb5bca74cb97fb5f29b225094f). -### Changesets +## Changesets -Ghost publishes several workspace packages to npm — the `@tryghost/*` editor and adapter packages under `koenig/` and `packages/`. When your change touches one of these publishable packages, add a **changeset** so it gets a version bump and a changelog entry: +Ghost publishes several workspace packages to npm — the `@tryghost/*` editor and adapter packages under `koenig/` and `packages/`. When your change affects one of these publishable packages, including by changing a catalog entry it consumes, add a **changeset** so it gets a version bump and a changelog entry: ```bash pnpm change @@ -73,18 +90,22 @@ This records which packages changed and the bump type (patch / minor / major); t pnpm change --bump none ``` -CI enforces this — the **Check app version bump** job fails a pull request that modifies a publishable package without a covering changeset. The pre-commit hook prints a non-blocking reminder locally, and `pnpm change status` shows what's currently pending. +A package `README.md` is published with the package and requires a release. +Repository-only Markdown such as `AGENTS.md`, `CLAUDE.md`, changelogs, and +package-local `docs/` does not. +CI enforces this — the **Check app version bump** job fails a pull request that affects a publishable package without a covering changeset. The pre-commit hook prints a non-blocking reminder locally, and `pnpm change status` shows what's currently pending. -### Submitting Pull Requests +For more detail, see the [contribution workflow](../docs/contributing/workflow.md). -We aim to merge any straightforward, well-understood bug fixes or improvements immediately, as long as they pass our tests (run `pnpm test` to check locally). We generally don’t merge new features and larger changes without prior discussion with the core product team for tech/design specification. +## Submitting Pull Requests -Please provide plenty of context and reasoning around your changes, to help us merge quickly. Closing an already open issue is our preferred workflow. If your PR gets out of date, we may ask you to rebase as you are more familiar with your changes than we will be. +We aim to merge any straightforward, well-understood bug fixes or improvements immediately, as long as they pass our tests (run `pnpm check` to ensure everything works). We generally don’t merge new features and larger changes without prior discussion with the core product team for tech/design specification. -### Sharing feedback on Documentation +Please provide plenty of context and reasoning around your changes, to help us merge quickly. Closing an already open issue is our preferred workflow. If your PR gets out of date, we may ask you to rebase as you are more familiar with your changes than we will be. -While the Docs are no longer Open Source, we welcome revisions and ideas on the forum! Please create a Post with your questions or suggestions in the [Contributing to Ghost Category](https://forum.ghost.org/c/contributing/27). Thank you for helping us keep the Docs relevant and up-to-date. +For branch, validation, and pull request details, follow the +[contribution workflow](../docs/contributing/workflow.md). --- diff --git a/.github/actions/load-docker-image/action.yml b/.github/actions/load-docker-image/action.yml index d9c07a078ca..c1961aa6bdf 100644 --- a/.github/actions/load-docker-image/action.yml +++ b/.github/actions/load-docker-image/action.yml @@ -34,7 +34,7 @@ runs: - name: Log in to GitHub Container Registry if: inputs.use-artifact == 'false' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/actions/report-boot-benchmark/action.yml b/.github/actions/report-boot-benchmark/action.yml index 9ce2fa4229b..16d43f3189f 100644 --- a/.github/actions/report-boot-benchmark/action.yml +++ b/.github/actions/report-boot-benchmark/action.yml @@ -148,7 +148,7 @@ runs: # means summary-only, so callers can use this action without publishing. - name: Publish to benchmark series if: github.event_name != 'pull_request' && inputs.github-token != '' - uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7 + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 with: name: ${{ inputs.series }} tool: 'customSmallerIsBetter' diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml index e1c4cb48aec..bc2d87c37b0 100644 --- a/.github/actions/setup-playwright/action.yml +++ b/.github/actions/setup-playwright/action.yml @@ -26,7 +26,7 @@ runs: - name: Check if Playwright browser is cached id: playwright-cache - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-Playwright-${{ steps.playwright-version.outputs.version }} diff --git a/.github/hooks/commit-msg.bash b/.github/hooks/commit-msg.bash index 332e66d2219..89feb5510c8 100755 --- a/.github/hooks/commit-msg.bash +++ b/.github/hooks/commit-msg.bash @@ -79,17 +79,18 @@ if [ -z "$body" ]; then echo -e "The body should explain: why this, why now, why not something else?" fi -# Check for emoji in user-facing changes -if [[ "$subject" =~ ^[^[:space:]]*[[:space:]] ]]; then - first_word="${subject%% *}" - # Emoji are multi-byte (non-ASCII), so detect them by stripping every ASCII - # byte and seeing if anything is left. The previous check tested the first word - # against [[:punct:]], which never matches an emoji — so the warning fired on - # *every* correctly-prefixed commit (🐛, ✨, …) and passed on plain ASCII. - if [[ -z "$(printf '%s' "$first_word" | LC_ALL=C tr -d '\000-\177')" ]]; then - echo -e "${yellow}Warning: User-facing changes should start with an emoji${no_color}" - echo -e "Common emojis: ✨ (Feature), 🎨 (Improvement), 🐛 (Bug Fix), 🌐 (i18n), 💡 (User-facing)" - fi +# Give concise release-note guidance. Whether a change is user-facing requires +# human judgment, so these notices are informative rather than enforcement. +first_word="${subject%% *}" +contributor_emojis="✨ 🎨 🐛 🌐 💡" + +if [[ "$first_word" =~ ^(✨|🎨|🐛|🌐|💡|🔒)$ ]]; then + echo -e "${yellow}Notice: Keep the emoji only for a significant user-facing PR title or squash commit.${no_color}" +elif [[ -n "$(printf '%s' "$first_word" | LC_ALL=C tr -d '\000-\177')" ]]; then + echo -e "${yellow}Warning: Unsupported leading emoji. Use one of: ${contributor_emojis}${no_color}" + echo -e "Emoji selection only matters for user-facing PR titles and squash commits." +else + echo -e "${yellow}Notice: If this is a significant user-facing PR title or squash commit, add one of: ${contributor_emojis}${no_color}" fi # Check for past tense verbs in subject diff --git a/.github/renovate.json5 b/.github/renovate.json5 index dab71f2c093..efb3cb1d336 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -4,7 +4,10 @@ // Track `# renovate:`-annotated `ARG/ENV *_VERSION` pins in Dockerfiles, // for tool versions that no native manager sees (e.g. the Tinybird CLI // installed via `uv tool install` in docker/tb-cli/Dockerfile). - "customManagers:dockerfileVersions" + "customManagers:dockerfileVersions", + // Same, for `# renovate:`-annotated `env:` version pins in workflows + // (the Tinybird CLI in .github/workflows/tinybird.yml). + "customManagers:githubActionsVersions" ], // Keep Renovate's own concurrency guardrails in place. The shared preset // extends :disableRateLimiting (prConcurrentLimit: 0, prHourlyLimit: 0), @@ -42,13 +45,17 @@ // We have to disable platform based automerge (forcing renovate to do it manually) // as otherwise renovate wont follow our schedule "platformAutomerge": false, - // Keep every open Renovate branch current, including updates that require - // human review. The shared preset uses `rebaseWhen: automerging`, which - // Renovate resolves to `never` whenever a package rule disables automerge. - // Those PRs then conflict with main indefinitely and occupy the open-PR cap - // until somebody manually requests a rebase. Runner scheduling below still - // limits when the resulting branch updates and CI runs can happen. - "rebaseWhen": "behind-base-branch", + // Branch protection does not require up-to-date branches (the required + // status checks ruleset has strict: false), so a green-but-behind branch + // is still mergeable. Rebasing on every `main` commit therefore buys + // nothing and costs a force-push plus a full CI re-run per branch, which + // is what stopped branches from ever being green *and* current inside an + // automerge window. "conflicted" rebases only when a branch genuinely + // conflicts — in practice whenever another Renovate PR lands a + // pnpm-lock.yaml change, which is exactly when it's needed. It also keeps + // needs:review PRs current, unlike the preset's "automerging", which + // Renovate resolves to "never" wherever a package rule disables automerge. + "rebaseWhen": "conflicted", // Soak every dependency update for 72 hours before opening a PR. This guards // against compromised publishes (malicious version yanked within a few hours) // and against unstable releases that get hotfixed shortly after publish. @@ -82,8 +89,6 @@ // self-hosted workflow that means a CI-storm of force-pushes across all // open Renovate PRs during the workday. Setting `updateNotScheduled: // false` keeps existing branch maintenance inside the same windows. - // Existing branches can still be maintained outside the normal creation - // schedule when the workflow switches Renovate into cap-reached mode. "updateNotScheduled": false, "schedule": [ // Run all weekend @@ -91,15 +96,17 @@ // Run on weekday evenings "* 23 * * 1-5", // Run on early weekday mornings (previous day 23:00 is already - // covered by the evening/weekend blocks above) - "* 0-4 * * 1-6" + // covered by the evening/weekend blocks above). Extends to 05:59 to + // absorb GitHub Actions scheduler drift — delayed ticks were landing + // outside the window and doing nothing. + "* 0-5 * * 1-6" ], "automergeSchedule": [ // Allow automerge all weekend "* * * * 0,6", - // Allow automerge overnight on weekday evenings (11pm-4:59am UTC) + // Allow automerge overnight on weekday evenings (11pm-5:59am UTC) "* 23 * * 1-5", - "* 0-4 * * 1-6" + "* 0-5 * * 1-6" ], // Vulnerability alerts normally bypass Renovate's PR concurrency, hourly // PR, and schedule limits. Let them keep doing that so security fixes can @@ -108,70 +115,34 @@ "vulnerabilityAlerts": { "schedule": ["at any time"], "minimumReleaseAge": "3 days", - "rebaseWhen": "behind-base-branch", + // Same reasoning as the global `rebaseWhen` above: branches don't have + // to be current to merge, so rebasing a security PR on every `main` + // commit only delays the fix behind another CI cycle. + "rebaseWhen": "conflicted", "dependencyDashboardApproval": false, "labels": ["dependencies", "security"] }, "ignoreDeps": [ // https://github.com/TryGhost/Ghost/commit/2b9e494dfcb95c40f596ccf54ec3151c25d53601 - // `got` 10.x has a Node 10 bug that makes it pretty much unusable for now - "got", - // https://github.com/TryGhost/Ghost/commit/2b9e494dfcb95c40f596ccf54ec3151c25d53601 - // `intl-messageformat` 6.0.0 introduced a breaking change in terms of - // escaping that would be pretty difficult to fix for now + // Theme i18n ({{t}} helper) is on intl-messageformat 5.x. 6.0.0 changed + // message escaping from `\` to ICU apostrophe quoting, which changes the + // output of existing theme locale strings; needs a deliberate migration. "intl-messageformat", - // https://github.com/TryGhost/Ghost/commit/b2fa84c7ff9bf8e21b0791f268f57e92759a87b1 - // no reason given - "moment", - // https://github.com/TryGhost/Ghost/pull/10672 - // https://github.com/TryGhost/Ghost/issues/10870 - "moment-timezone", - // https://github.com/TryGhost/Admin/pull/1111/files - // Ignored because of a mobiledoc-kit issue but that's now in koenig, can probably be cleaned up - "simple-dom", - // https://github.com/TryGhost/Admin/pull/1111/files - // https://github.com/TryGhost/Ghost/pull/10672 - // These have been ignored since forever - "ember-drag-drop", - "normalize.css", - "validator", - - // https://github.com/TryGhost/Ghost/commit/7ebf2891b7470a1c2ffeddefb2fe5e7a57319df3 - // Changed how modules are loaded, caused a weird error during render - "@embroider/macros", - // https://github.com/TryGhost/Ghost/commit/a10ad3767f60ed2c8e56feb49e7bf83d9618b2ab - // Caused linespacing issues in the editor, but now it's used in different places - // So not sure if it's relevant - soon we will finish switching to react-codemirror + // codemirror 6 is a rewrite of the 5.x API. @tryghost/kg-simplemde (the + // Koenig markdown card) is built on 5.x and is not being ported. Also + // covers the `codemirror@<5.58.2` pnpm override key. "codemirror", - // https://github.com/TryGhost/Ghost/commit/3236891b80988924fbbdb625d30cb64a7bf2afd1 - // ember-cli-code-coverage@2.0.0 broke our code coverage - "ember-cli-code-coverage", - // https://github.com/TryGhost/Ghost/commit/1382e34e42a513c201cb957b7f843369a2ce1b63 - // ember-cli-terser@4.0.2 has a regression that breaks our sourcemaps - "ember-cli-terser", - - // https://linear.app/ghost/issue/PLA-56 - // Deprecated (per-method lodash packages are EOL); last real release is - // 4.5.0 (2019). The flagged "4.18.0 [SECURITY]" bump does not exist, so - // Renovate can't remediate it. It's transitive-only — we never call - // `_.template` ourselves — and only reachable via frozen Ember build - // tooling (broccoli-templater, sourcemap-validator) plus old @tryghost/* - // packages, so it can't be removed from the tree here. Ignore it to stop - // the unactionable dashboard noise. - "lodash.template", - // https://linear.app/ghost/issue/PLA-78 // https://github.com/TryGhost/Ghost/pull/28180 - // knex 2.5.x breaks migrations: 2.5.0's password-masking mutates the - // shared `config.get('database')` object, which we hand to BOTH knex() - // and knex-migrator. With core ahead of knex-migrator's pinned knex - // (2.4.2), the password is stripped before knex-migrator connects → - // "Access denied (using password: NO)". 2.x is EOL; the real move is - // knex 3.x, gated on publishing a knex-3 knex-migrator - // (TryGhost/knex-migrator#86, unreleased), bumped in lockstep with core. - // Ignore until we do that coordinated upgrade. + // ghost/core is pinned to knex 2.4.2 and knex-migrator's knex is pinned + // back to the same version via the `knex-migrator>knex` pnpm override + // (knex-migrator 5.4.x bundles knex 3.x). Migrations are built with + // core's query builder and executed by knex-migrator's, so the two must + // move together; 2.5.x also strips the password from the shared + // `config.get('database')` object before knex-migrator connects. Ignore + // until core moves to knex 3.x in one coordinated upgrade. "knex", // Same PLA-78 pin, but Renovate treats the pnpm override key // `knex-migrator>knex` as its own dependency name, so the plain "knex" @@ -303,6 +274,20 @@ "allowedVersions": "<3.14" }, + // Stay on the MySQL 8 line — that's what Ghost supports and what CI and + // local dev run against. Capping (rather than disabling) keeps 8.x + // patches and digest re-pins flowing. + { + "description": "Cap MySQL at the 8.x line", + "matchDatasources": [ + "docker" + ], + "matchPackageNames": [ + "mysql" + ], + "allowedVersions": "<9" + }, + // Keep `@types/*` aligned with the runtime major they describe. Type defs // for a different major than what actually runs are silently wrong at best // (e.g. @types/express 5 vs Express 4) and build-breaking at worst diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 2fb66651971..2d687d29d0e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -40,7 +40,7 @@ How we write GitHub Actions workflows safely. Follow these when adding or editin - **Pin every third-party action to a full commit SHA**, with the version as a trailing comment: ```yaml - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 ``` A tag or branch ref can be re-pointed at malicious code; a SHA cannot. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 808b2d63110..29141787ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: pull-requests: read steps: - name: Checkout current commit - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ env.HEAD_COMMIT }} fetch-depth: 0 @@ -78,12 +78,30 @@ jobs: echo "GITHUB_EVENT_NAME: ${{ github.event_name }}" echo "GITHUB_CONTEXT: ${{ toJson(github.event) }}" + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + env: + FORCE_COLOR: 0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + # Replaced nrwl/nx-set-shas, which verified each candidate commit over the + # API and hid the errors — see scripts/nx-set-shas.js. - name: Set SHAs for Nx Commands if: env.IS_TAG != 'true' - uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 - with: - main-branch-name: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} - error-on-no-successful-workflow: ${{ env.IS_MAIN == 'true' && github.repository == 'TryGhost/Ghost' }} + env: + GITHUB_TOKEN: ${{ github.token }} + BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} + # Canonical main is the one branch where too narrow a base means + # untested commits land, so there a lookup that comes up empty fails + # the run rather than falling back to the previous commit. + ON_MISSING: ${{ (env.IS_MAIN == 'true' && github.repository == 'TryGhost/Ghost') && 'error' || 'previous-commit' }} + run: node scripts/nx-set-shas.js --branch "$BRANCH" --head "$HEAD_COMMIT" --on-missing "$ON_MISSING" - name: Check user org membership id: check_user_org_membership @@ -137,14 +155,28 @@ jobs: - 'scripts/**' docs: - '**/*.md' + - '**/*.mdx' - '.agents/**' - '.claude/**' - '.github/workflows/ci.yml' - 'package.json' - 'scripts/check-agent-skill-links.js' - 'scripts/test/check-agent-skill-links.test.js' + - 'scripts/check-agent-guidance.js' + - 'scripts/test/check-agent-guidance.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 + - '!.github/CODEOWNERS' - 'ghost/**' - '!ghost/core/core/server/data/tinybird/**' # Unit tests + vitest config are exercised only by job_unit-tests; @@ -160,6 +192,11 @@ jobs: - '!koenig/kg-unsplash-selector/**' - '!koenig/kg-simplemde/**' - '!koenig/*/test/**' + # Documentation does not affect Ghost runtime behaviour, even + # when it lives inside a project root. Keep this after every + # positive pattern so micromatch cannot add docs files back. + - '!**/*.md' + - '!**/*.mdx' unit-test-globals: - 'vitest.config.mjs' core-unit-test-globals: @@ -167,6 +204,7 @@ jobs: - 'ghost/core/test/utils/vitest-*.ts' any-code: - '!**/*.md' + - '!**/*.mdx' - '!.devcontainer/**' - '!.vscode/**' - *renovate_only @@ -178,6 +216,7 @@ jobs: # added here as their conventions are confirmed. e2e: - '!**/*.md' + - '!**/*.mdx' - '!.devcontainer/**' - '!.vscode/**' - '!ghost/core/test/**' @@ -196,24 +235,24 @@ jobs: run: | echo 'matrix=["22.23.1"]' >> $GITHUB_OUTPUT - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - env: - FORCE_COLOR: 0 - with: - node-version: ${{ env.NODE_VERSION }} - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Start Nx Cloud CI run run: pnpm nx start-ci-run - name: Determine Affected Projects id: affected run: | + # Nx treats README files as project inputs. Avoid populating code-test + # matrices when every changed file is documentation. + if [[ "${{ env.IS_TAG }}" != 'true' && "${{ steps.changed.outputs.any-code }}" != 'true' ]]; then + echo 'affected_projects=[]' >> "$GITHUB_OUTPUT" + echo 'affected_projects_str=' >> "$GITHUB_OUTPUT" + echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT" + echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT" + echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT" + echo 'publish_public_apps_matrix=[]' >> "$GITHUB_OUTPUT" + exit 0 + fi + # if the ci files have changed or we're in a tag, ensure we don't just look at affected # projects and we run the necessary jobs on all projects AFFECTED_ARG="--affected" @@ -269,6 +308,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 @@ -298,7 +338,7 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Checkout PR head commit - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -306,8 +346,8 @@ jobs: - name: Fetch main branch run: git fetch --no-tags origin main - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -341,7 +381,7 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Checkout PR head commit - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -361,11 +401,11 @@ jobs: if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.affected_projects_str != '' name: Lint steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 1000 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -375,7 +415,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ghost/**/.eslintcache key: eslint-cache @@ -389,7 +429,7 @@ jobs: - name: Lint boundaries run: pnpm nx run ghost-monorepo:lint:boundaries - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -402,14 +442,37 @@ jobs: needs: [job_setup] if: needs.job_setup.outputs.changed_docs == 'true' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Lint documentation guidance + run: | + node --test scripts/test/check-agent-guidance.test.js + node scripts/check-agent-skill-links.js + node scripts/check-agent-guidance.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} - - name: Lint agent skills - run: node scripts/check-agent-skill-links.js + - 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 @@ -419,9 +482,9 @@ jobs: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_i18n_apps == 'true' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -445,9 +508,9 @@ jobs: CI: true COVERAGE: ${{ needs.job_setup.outputs.coverage_enabled }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -470,7 +533,7 @@ jobs: name: admin-coverage path: apps/*/coverage/cobertura-coverage.xml - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -489,13 +552,13 @@ jobs: if: (needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_development == 'true') || needs.job_setup.outputs.has_perf_tests_label == 'true' name: Performance tests steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Boot activates the default theme, which is a submodule submodules: true - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -513,6 +576,9 @@ jobs: - name: Build TS code run: pnpm nx run-many -t build:tsc + - name: Build assets + run: pnpm --filter ghost run build:assets + - name: Run hyperfine on boot working-directory: ghost/core run: hyperfine --show-output --warmup 3 'GHOST_CI_SHUTDOWN_AFTER_BOOT=1 node index.js' --export-json boot-perf.json @@ -534,7 +600,8 @@ jobs: name: Performance tests (production image) runs-on: blacksmith-2vcpu-ubuntu-2404 needs: [job_setup, job_docker] - # Registry path only: the core image is pushed to GHCR, never saved as an artifact. + # Registry path only: benchmarks are a canonical-repo series, so this pulls the + # core image from GHCR rather than loading the artifact-path tarball. if: | needs.job_docker.result == 'success' && needs.job_docker.outputs.use-artifact == 'false' && @@ -544,7 +611,7 @@ jobs: packages: read steps: # Only for .github/actions/load-docker-image; nothing is built from source here. - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false @@ -626,11 +693,11 @@ jobs: node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} name: Unit tests (Node ${{ matrix.node }}) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 1000 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -687,7 +754,7 @@ jobs: exit 1 fi - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -716,7 +783,7 @@ jobs: --health-timeout=5s --health-retries=60 redis: - image: redis:7.4@sha256:a8f08480e1f88f2647fed492d1178c06abb0d0c1fbf02c682a61e2f483fb3954 + image: redis:7.4@sha256:e9b2e45ecd47fbb69b877cf8d045d5cccaaaed52524b6e098b4abe8212994f73 ports: - 6379:6379 options: >- @@ -744,9 +811,9 @@ jobs: COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled == 'true' && matrix.env.DB == 'better-sqlite3' }} name: Acceptance tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -836,7 +903,7 @@ jobs: ghost/*/coverage-e2e/cobertura-coverage.xml ghost/*/coverage-integration/cobertura-coverage.xml - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -877,11 +944,11 @@ jobs: NODE_ENV: ${{ matrix.env.NODE_ENV }} name: Legacy tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: true - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -926,7 +993,7 @@ jobs: exit 1 fi - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -945,9 +1012,9 @@ jobs: env: CI: true steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -1012,7 +1079,7 @@ jobs: path: ${{ steps.app_name.outputs.root }}/playwright-report retention-days: 30 - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -1025,7 +1092,7 @@ jobs: if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' runs-on: ubuntu-latest steps: - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -1083,7 +1150,7 @@ jobs: run: | [ -f ~/.ghost/logs/*.log ] && cat ~/.ghost/logs/*.log - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -1098,12 +1165,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -1165,7 +1232,7 @@ jobs: retention-days: 7 if-no-files-found: error - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -1184,15 +1251,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false # Themes (content/themes/casper|source) are submodules packed into the # archive — without them the tarball ships empty theme dirs. submodules: true - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -1248,7 +1315,7 @@ jobs: retention-days: 7 if-no-files-found: error - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -1271,7 +1338,7 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false # The deploy stage packs content/themes/casper|source (submodules). @@ -1336,7 +1403,7 @@ jobs: - name: Log in to GitHub Container Registry if: steps.strategy.outputs.should-push == 'true' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -1399,6 +1466,29 @@ jobs: cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-core-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # Uploaded here, before the full image is even built: on the artifact path + # consumers (Ghost-Moya CD) need the core image — server only, no admin — + # and would otherwise have to fall back to `docker-image-production`. + - name: Save core image as artifact + if: steps.strategy.outputs.use-artifact == 'true' + run: | + IMAGE_TAG=$(echo "${{ steps.meta-core.outputs.tags }}" | head -n1) + echo "Saving image: $IMAGE_TAG" + # Written outside the repo root: a stray tarball there would change the + # `.` context between the core and full builds and bust the deploy-stage + # COPY cache (same reason the admin artifact lands in RUNNER_TEMP below). + docker save "$IMAGE_TAG" | gzip > "${RUNNER_TEMP}/docker-image-core.tar.gz" + ls -lh "${RUNNER_TEMP}/docker-image-core.tar.gz" + + - name: Upload core image artifact + if: steps.strategy.outputs.use-artifact == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: docker-image-core + path: ${{ runner.temp }}/docker-image-core.tar.gz + retention-days: 1 + if-no-files-found: error + # Synchronise with job_build_admin only now, after core is built: the full # image is core + admin. No `needs` edge, so the two jobs run concurrently # and core builds during the wait. @@ -1663,11 +1753,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: @@ -1771,7 +1861,7 @@ jobs: shardTotal: 2 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Docker Registry Mirrors uses: ./.github/actions/setup-docker-registry-mirrors @@ -1807,8 +1897,8 @@ jobs: image-tags: ${{ needs.job_docker.outputs.image-e2e-tags }} artifact-name: docker-image-e2e - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -1862,7 +1952,7 @@ jobs: path: e2e/test-results retention-days: 7 - - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -1882,10 +1972,10 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -1968,7 +2058,7 @@ jobs: if: always() && needs.job_setup.outputs.coverage_enabled == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Restore Admin coverage if: contains(needs.job_admin-tests.result, 'success') @@ -1983,7 +2073,7 @@ jobs: - name: Upload Admin test coverage if: contains(needs.job_admin-tests.result, 'success') - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: flags: admin-tests files: apps/ember-admin/coverage/cobertura-coverage.xml @@ -2002,7 +2092,7 @@ jobs: - name: Upload E2E test coverage if: contains(needs.job_acceptance-tests.result, 'success') - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: flags: e2e-tests files: ghost/core/coverage-e2e/cobertura-coverage.xml,ghost/core/coverage-integration/cobertura-coverage.xml @@ -2017,6 +2107,7 @@ jobs: job_migration_integrity_check, job_lint, job_lint_docs, + job_lint_packages, job_i18n, job_build_admin, job_pack, @@ -2077,11 +2168,11 @@ jobs: include: ${{ fromJSON(needs.job_setup.outputs.publish_public_apps_matrix) }} steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -2214,7 +2305,7 @@ jobs: name: ghost-npm-tarball - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} package-manager-cache: false @@ -2271,7 +2362,7 @@ jobs: env: GH_TOKEN: ${{ secrets.CANARY_DOCKER_BUILD }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 8b89425542f..c13c8fd1948 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -19,8 +19,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install gh-aw extension - uses: github/gh-aw/actions/setup-cli@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 + uses: github/gh-aw/actions/setup-cli@53843da968225dc56e1590978a7ed6407a8438ac # v0.85.4 with: version: v0.49.3 diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index f1bc90b6797..cb579a27566 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -21,14 +21,14 @@ jobs: if: github.repository == 'TryGhost/Ghost' runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 if: inputs.base-ref == 'latest' with: ref: main fetch-depth: 0 submodules: true - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 if: inputs.base-ref != 'latest' with: ref: ${{ inputs.base-ref }} diff --git a/.github/workflows/devcontainer-build.yml b/.github/workflows/devcontainer-build.yml index c0c4f1a1e59..279ded2e340 100644 --- a/.github/workflows/devcontainer-build.yml +++ b/.github/workflows/devcontainer-build.yml @@ -41,7 +41,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 @@ -50,7 +50,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Login to GHCR - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/e2e-runner-image.yml b/.github/workflows/e2e-runner-image.yml index f5a9de403eb..4c8eea28117 100644 --- a/.github/workflows/e2e-runner-image.yml +++ b/.github/workflows/e2e-runner-image.yml @@ -44,7 +44,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false @@ -93,7 +93,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Login to GHCR - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/i18n-review-checks.yml b/.github/workflows/i18n-review-checks.yml index 069486d5854..1dc04fd09cf 100644 --- a/.github/workflows/i18n-review-checks.yml +++ b/.github/workflows/i18n-review-checks.yml @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-slim timeout-minutes: 5 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false sparse-checkout: | @@ -47,7 +47,7 @@ jobs: # Must match translation-review.yml, where this tool actually runs. - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '24' diff --git a/.github/workflows/koenig-demo.yml b/.github/workflows/koenig-demo.yml index c29e06b51c1..517581c4569 100644 --- a/.github/workflows/koenig-demo.yml +++ b/.github/workflows/koenig-demo.yml @@ -32,11 +32,11 @@ jobs: VITE_KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }} steps: - name: Checkout repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml index 6535bb8f958..47263cbecc6 100644 --- a/.github/workflows/label-actions.yml +++ b/.github/workflows/label-actions.yml @@ -18,4 +18,4 @@ jobs: runs-on: ubuntu-slim if: github.repository_owner == 'TryGhost' steps: - - uses: tryghost/actions/actions/label-actions@e7a401946f91165a6426290705f501a377ec1533 # main + - uses: tryghost/actions/actions/label-actions@12da0671df2e249a65c467340262e6c4251d9565 # main diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml index b0413e0a1b3..fceacbfcdf3 100644 --- a/.github/workflows/migration-review.yml +++ b/.github/workflows/migration-review.yml @@ -44,7 +44,6 @@ jobs: - [ ] Uses the correct utils - [ ] Contains a minimal changeset - [ ] Does not mix DDL/DML operations - - [ ] Tested in MySQL and SQLite ### Schema changes diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index fd504cc32cb..f73e8191db8 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -60,15 +60,15 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Publishing uses OIDC + the public registry; no git operations need # the token, so don't leave it in .git/config for later steps. persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ env.NODE_VERSION }} package-manager-cache: false diff --git a/.github/workflows/publish-tb-cli.yml b/.github/workflows/publish-tb-cli.yml index 6fd55d987d1..00ffe4aa1e8 100644 --- a/.github/workflows/publish-tb-cli.yml +++ b/.github/workflows/publish-tb-cli.yml @@ -21,13 +21,13 @@ jobs: cancel-in-progress: true steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Login to GHCR - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8742fc4b41d..4b45f6f6d6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ run-name: "Release — ${{ inputs.bump-type || 'auto' }} from ${{ inputs.branch on: schedule: - - cron: '0 15 * * 5' # Friday 3pm UTC + - cron: '0 15 * * 2' # Tuesday 3pm UTC workflow_dispatch: inputs: branch: @@ -51,7 +51,7 @@ jobs: with: ssh-private-key: ${{ secrets.DEPLOY_KEY }} - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Deploy key (via ssh-agent) is used for git push — it bypasses # branch protection and triggers downstream workflows (unlike GITHUB_TOKEN) @@ -63,8 +63,8 @@ jobs: # Ghost only and can't authenticate against Casper/Source over SSH - run: git submodule update --init - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 00e8b088e36..cede723baec 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -23,8 +23,11 @@ on: # duration, giving each merge a fresh rebase + green CI window. # # Times are UTC; matches renovate.json5's `schedule` block. - # Weekday early-morning window (Mon-Sat 00:00-04:59 UTC) - - cron: '17 0-4 * * 1-6' + # Weekday early-morning window (Mon-Sat 00:00-05:59 UTC). Last tick is + # 03:47 rather than the end of the window: Actions has been delivering + # these up to ~70min late, so a later cron lands outside the window and + # wastes the freshest-CI opportunity of the night. + - cron: '47 0-3 * * 1-6' # Weekday evening window (Mon-Fri 23:00-23:59 UTC) - cron: '17 23 * * 1-5' # Weekend - every 2h is plenty; no automerge urgency, just batch creation @@ -38,6 +41,12 @@ on: required: false default: false type: boolean + logLevel: + description: 'Renovate log level for this manual run' + required: false + default: 'info' + type: choice + options: [info, debug, trace] concurrency: group: renovate @@ -67,7 +76,7 @@ jobs: repositories: Ghost - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Enforce a live cap on open Renovate PRs while still letting Renovate # maintain and automerge the PRs that already exist. @@ -81,9 +90,13 @@ jobs: # # Below the cap, restrict PR creation to the number of slots left. At or # above the cap, or when RENOVATE_MAINTENANCE_ONLY=true, force dashboard - # approval for new branches/PRs while allowing existing PR branches to - # keep updating outside the creation schedule. That gives us "rebase and - # merge the 10, but create no more." + # approval for new branches/PRs. Existing PRs keep updating and merging + # on the normal schedule. That gives us "rebase and merge the 10, but + # create no more." + # + # Deliberately does NOT force `updateNotScheduled: true` — that overrides + # renovate.json5's `false` and puts a full-fleet force-push in the middle + # of the workday, which is exactly what #28207 removed. - name: Configure Renovate PR cap env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -98,19 +111,24 @@ jobs: PR_CAP=10 fi + # Exclude `needs:review` PRs: Renovate cannot merge them itself, so + # counting them lets a handful of parked updates hold the cap shut + # forever — the cap is only released by Renovate merging something. open_count=$(gh pr list \ --repo "${{ github.repository }}" \ --author "app/tryghost-renovate" \ --state open \ - --json number --jq 'length') + --limit 100 \ + --json number,labels \ + --jq '[.[] | select(any(.labels[].name; . == "needs:review") | not)] | length') - echo "Renovate has $open_count open PRs (cap: $PR_CAP)" + echo "Renovate has $open_count open PRs it can merge itself (cap: $PR_CAP)" if [ "$MAINTENANCE_ONLY" = "true" ]; then - force='{"dependencyDashboardApproval":true,"prCreation":"approval","updateNotScheduled":true,"vulnerabilityAlerts":{"dependencyDashboardApproval":false}}' + force='{"dependencyDashboardApproval":true,"prCreation":"approval","vulnerabilityAlerts":{"dependencyDashboardApproval":false}}' echo "::notice::RENOVATE_MAINTENANCE_ONLY=true. Running in maintenance-only mode: existing PRs may update/automerge, new PRs require dashboard approval." elif [ "$open_count" -ge "$PR_CAP" ]; then - force='{"dependencyDashboardApproval":true,"prCreation":"approval","updateNotScheduled":true,"vulnerabilityAlerts":{"dependencyDashboardApproval":false}}' + force='{"dependencyDashboardApproval":true,"prCreation":"approval","vulnerabilityAlerts":{"dependencyDashboardApproval":false}}' echo "::notice::Renovate is at or above the open PR cap. Running in maintenance-only mode: existing PRs may update/automerge, new PRs require dashboard approval." else remaining=$((PR_CAP - open_count)) @@ -126,11 +144,11 @@ jobs: echo "RENOVATE_FORCE=$force" >> "$GITHUB_ENV" - name: Self-hosted Renovate - uses: renovatebot/github-action@dd5302ec17783b2fc721b19ae7209b57b1587765 # v46.1.17 + uses: renovatebot/github-action@e09d604f8f803bb527bd8321ed5be06c460b8682 # v46.2.2 with: token: ${{ steps.app-token.outputs.token }} env: - LOG_LEVEL: debug + LOG_LEVEL: ${{ inputs.logLevel || 'info' }} RENOVATE_REPOSITORY_CACHE: enabled RENOVATE_REPOSITORIES: TryGhost/Ghost # Ghost is already onboarded via Mend; don't open an onboarding PR. diff --git a/.github/workflows/stale-i18n.yml b/.github/workflows/stale-i18n.yml index 11b546de44b..beebd9f6655 100644 --- a/.github/workflows/stale-i18n.yml +++ b/.github/workflows/stale-i18n.yml @@ -12,7 +12,7 @@ jobs: if: github.repository_owner == 'TryGhost' runs-on: ubuntu-slim steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11 with: stale-pr-message: | Thanks for contributing to Ghost's i18n :) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 127a808041b..a2181e4e889 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: if: github.repository_owner == 'TryGhost' runs-on: ubuntu-slim steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11 with: stale-issue-message: | Our bot has automatically marked this issue as stale because there has not been any activity here in some time. diff --git a/.github/workflows/sync-fork.yml b/.github/workflows/sync-fork.yml index 0889a74e74c..7a8df37ec22 100644 --- a/.github/workflows/sync-fork.yml +++ b/.github/workflows/sync-fork.yml @@ -70,7 +70,7 @@ jobs: exit 1 fi - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: main # Full history — a shallow clone can't compute a merge base against diff --git a/.github/workflows/tinybird.yml b/.github/workflows/tinybird.yml index ea297e13509..6e3c414e49b 100644 --- a/.github/workflows/tinybird.yml +++ b/.github/workflows/tinybird.yml @@ -30,7 +30,7 @@ jobs: outputs: tinybird: ${{ steps.changed.outputs.tinybird }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 @@ -58,13 +58,13 @@ jobs: working-directory: ghost/core/core/server/data/tinybird services: tinybird: - image: tinybirdco/tinybird-local:latest@sha256:942cb8a703806bedc39a5ca77bc9cd36dd813ddad1556d46022ce9492f5acdb8 + image: tinybirdco/tinybird-local:latest@sha256:cf9de0516700eea366161181e74cae943f1a7310e6f2cc4f5762cb1777ed3fb3 ports: - 7181:7181 outputs: datafiles_changed: ${{ steps.datafiles.outputs.tinybird }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 @@ -78,8 +78,21 @@ jobs: - 'ghost/core/core/server/data/tinybird/**' - '!ghost/core/core/server/data/tinybird/**/*.md' + # tinybird.co/install.sh always installs the latest CLI, so this job + # silently tracked upstream. 4.6.14 ingests a nested JSON object into a + # String column as ClickHouse Tuple syntax instead of raw JSON, so every + # JSONExtractString(payload, ...) in pipes/mv_hits.pipe returns '' and all + # 29 endpoint tests read back empty. Pin until that's fixed upstream; keep + # in lockstep with docker/tb-cli/Dockerfile. - name: Install Tinybird CLI - run: curl -fsSL https://tinybird.co/install.sh | sh + env: + # renovate: datasource=pypi depName=tinybird versioning=pep440 + TINYBIRD_VERSION: 4.6.13 + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + uv tool install "tinybird@${TINYBIRD_VERSION}" --python 3.11 --force + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build project run: tb build @@ -120,7 +133,7 @@ jobs: needs: [tests] if: github.repository == 'TryGhost/Ghost' && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.tests.outputs.datafiles_changed == 'true' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Trigger and watch traffic analytics infra Tinybird workflow env: diff --git a/.github/workflows/translation-review.yml b/.github/workflows/translation-review.yml index 6686f7e6694..046da2b8ec7 100644 --- a/.github/workflows/translation-review.yml +++ b/.github/workflows/translation-review.yml @@ -64,7 +64,7 @@ jobs: ) steps: - name: Checkout main (trusted ref — never the PR head) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false ref: main @@ -77,7 +77,7 @@ jobs: # Tracks current LTS rather than the repo's NODE_VERSION: i18n-review is # outside the workspace and shares no runtime with Ghost. - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '24' diff --git a/.gitignore b/.gitignore index e063404e12f..ecc648125a0 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ test/functional/*.png /ghost/core/core/frontend/public/member-attribution.min.js /ghost/core/core/frontend/public/ghost-stats.min.js /ghost/core/core/frontend/public/private.min.js +/ghost/core/core/frontend/public/cards.manifest.json # Caddyfile - for local development with ssl + caddy Caddyfile !docker/dev-gateway/Caddyfile @@ -198,6 +199,9 @@ yalc.lock # useful for keeping local plans etc /ai +# Amp generates thread-specific portal state at runtime +/.amp/portals/ + # direnv environment loader files .envrc diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs index 589d161526a..b0a1c3f0e17 100644 --- a/.lintstagedrc.cjs +++ b/.lintstagedrc.cjs @@ -91,6 +91,23 @@ function buildBoundaryCommand(files) { return `pnpm exec depcruise --config .dependency-cruiser.cjs -- ${shellQuote(relativeFiles)}`; } +function buildMarkdownCommands(files) { + const relativeFiles = files + .map(file => normalize(path.relative(ROOT, file))) + .filter(file => !file.startsWith('.changeset/')) + .filter(file => !file.split('/').some(part => part === 'fixture' || part === 'fixtures')); + + if (relativeFiles.length === 0) { + return []; + } + + const quotedFiles = shellQuote(relativeFiles); + return [ + `pnpm exec markdownlint-cli2 --config .markdownlint-cli2.jsonc ${quotedFiles}`, + `pnpm exec remark --use remark-validate-links --frail --quiet --no-stdout ${quotedFiles}` + ]; +} + module.exports = { '*.{js,ts,tsx,jsx,cjs}': (files) => { const groups = new Map(); @@ -109,5 +126,12 @@ module.exports = { 'ghost/core/core/{server,shared,frontend}/**/*.{js,ts}': (files) => buildBoundaryCommand(files), 'apps/{shade,admin-x-framework,activitypub,portal,comments-ui,signup-form,sodo-search,announcement-bar,admin-toolbar}/src/**/*.{js,ts,tsx,jsx}': (files) => - buildBoundaryCommand(files) + buildBoundaryCommand(files), + '**/*.md': buildMarkdownCommands, + '{**/AGENTS.md,scripts/check-agent-guidance.js}': () => + 'pnpm lint:agent-guidance', + '{.agents/skills/**,.claude/skills/**,scripts/check-agent-skill-links.js}': () => + 'pnpm lint:agent-skills', + '{package.json,pnpm-workspace.yaml,packages/**/package.json,packages/_template/**,scripts/check-internal-packages.js,scripts/create-package.js,scripts/lib/constants.js,scripts/lib/package-template.js}': () => + 'pnpm lint:packages' }; diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000000..9dbbb45b295 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,16 @@ +{ + "config": { + "default": false, + "MD011": true, + "MD018": true, + "MD019": true, + "MD020": true, + "MD021": true, + "MD037": true, + "MD038": true, + "MD039": true, + "MD051": true, + "MD052": true, + "MD056": true + } +} diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000..97ed230dc1e --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,39 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "arrowParens": "avoid", + "bracketSpacing": false, + "ignorePatterns": [ + "**/build/**", + "**/built/**", + "**/coverage/**", + "**/dist/**", + "**/*.min.*", + "**/*.hbs", + "**/*.html", + "**/test/**/fixtures/**", + "**/__snapshots__/**", + "apps/ember-admin/**", + "koenig/kg-default-nodes/**", + "koenig/kg-lexical-html-renderer/**", + "koenig/kg-simplemde/debug/**", + "koenig/koenig-lexical/**" + ], + "jsdoc": { + "addDefaultToDescription": false, + "bracketSpacing": false + }, + "jsxSingleQuote": true, + "printWidth": 120, + "singleQuote": true, + "sortPackageJson": false, + "tabWidth": 4, + "trailingComma": "none", + "overrides": [ + { + "files": ["*.yml", "*.yaml"], + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/.pnpmfile.mjs b/.pnpmfile.mjs index 2c5e2257fff..9b50b6d8539 100644 --- a/.pnpmfile.mjs +++ b/.pnpmfile.mjs @@ -2,10 +2,10 @@ import {glob, readFile} from 'node:fs/promises'; // Global pnpm hooks for the Ghost monorepo. // -// Only `beforePacking` is defined. It runs during `pnpm pack` / `pnpm publish` -// and mutates the package.json written *into the tarball* — the on-disk -// manifest is never touched, and dependency resolution / the shared lockfile -// are unaffected (no `readPackage`/`afterAllResolved` hook here). +// `beforePacking` runs during `pnpm pack` / `pnpm publish` and mutates the +// package.json written *into the tarball* — the on-disk manifest is never +// touched. `readPackage` runs during resolution, so it *does* feed the shared +// lockfile. // // Applied to every packed/published package: // - drop `nx` — Nx target config, meaningless to consumers @@ -65,6 +65,41 @@ function beforePacking(pkg) { return pkg; } +function readPackage(pkg) { + // consolidate declares 48 template engines as optional peers. pnpm links any + // that another workspace package happens to satisfy, so react, react-dom and + // @babel/core rode into ghost's production deploy closure via + // nodemailer-mailgun-transport — the only thing that pulls consolidate in, and + // it never renders through it. packageExtensions can only add, so dropping the + // peers outright needs this hook. + if (pkg.name === 'consolidate') { + delete pkg.peerDependencies; + delete pkg.peerDependenciesMeta; + } + + // knex declares sqlite3 as an optional peer dep, and we don't use it/don't + // want to install it in production, so we'll remove it from the knex peer + // deps + if (pkg.name === 'knex') { + delete pkg.peerDependencies?.sqlite3; + delete pkg.peerDependenciesMeta?.sqlite3; + } + + // these deps pull in typescript as an optional peer dep, which ends up + // being included in Ghost's production image because of the way pnpm hoists + // optional peers. We don't want to ship ts in the prod image so we delete + // it from the manifest + // + // NOTE: auto-install-peers: false doesn't solve the problem here unfortunately, + // and it causes more issues with other deps + if (['viem', 'ox', 'abitype'].includes(pkg.name)) { + delete pkg.peerDependencies?.typescript; + delete pkg.peerDependenciesMeta?.typescript; + } + + return pkg; +} + /** * Dynamic config update function to automatically exclude "private" packages * from pnpm's changelog detection. We can't remove the version fields @@ -105,4 +140,4 @@ async function updateConfig(config) { return config; } -export const hooks = {beforePacking, updateConfig}; +export const hooks = {beforePacking, readPackage, updateConfig}; diff --git a/.secretlintrc.json b/.secretlintrc.json index e2e75f8b831..6bd9780e163 100644 --- a/.secretlintrc.json +++ b/.secretlintrc.json @@ -46,7 +46,8 @@ "/mynewfancypasswordwhichisnotallowed/", "/scriptTag\\.dataset\\.key/", "/69010382388f9de5869ad6e558/", - "/process\\.env\\./" + "/process\\.env\\./", + "/this\\._settingsCache\\.get/" ] }, { diff --git a/AGENTS.md b/AGENTS.md index faf348dc65e..002fae02d6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,402 +1,66 @@ # AGENTS.md -This file provides guidance to AI Agents when working with code in this repository. - -## Package Manager - -**Always use `pnpm` for all commands.** This repository uses pnpm workspaces, not npm. - -Shared dependency versions are pinned in `pnpm-workspace.yaml` under `catalog:` and referenced as `"pkg": "catalog:"` (or `catalog:` for named catalogs). `catalogMode` is `strict`, so `pnpm add` routes new deps into the catalog automatically — don't inline the version. - -## Monorepo Structure - -Ghost is a pnpm + Nx monorepo with four workspace groups: - -### ghost/* - Core Ghost packages -- **ghost/core** - Main Ghost application (Node.js/Express backend) - - Core server: `ghost/core/core/server/` - - Frontend rendering: `ghost/core/core/frontend/` - -### apps/* - React-based UI applications -Two categories of apps: - -**Admin Apps** (embedded in Ghost Admin): -- `ember-admin` - Ember.js admin client (legacy, being migrated to React) -- `admin` - The consolidated React admin shell, organized by domain (`src/{analytics,members,posts,tags,comments,automations,settings,...}`) -- `activitypub` - ActivityPub integration (route-composed into `admin`) -- Built with Vite + React + `@tanstack/react-query` - -**Public Apps** (served to site visitors): -- `portal`, `comments-ui`, `signup-form`, `sodo-search`, `announcement-bar` -- Built as UMD bundles, loaded via CDN in site themes - -**Foundation Libraries**: -- `admin-x-framework` - Shared API hooks, routing, utilities -- `admin-x-design-system` - Legacy design system (being phased out) -- `shade` - New design system (shadcn/ui + Radix UI + react-hook-form + zod) - -### koenig/* - Ghost editor (Koenig) packages -Merged from the former TryGhost/Koenig repo with full git history: - -- **koenig-lexical** - The Lexical-based rich text editor UI. Bundled into - Ghost Admin at build time (`apps/ember-admin` copies its UMD build into admin - assets; `apps/admin` imports it directly) -- **kg-*** - Editor support packages: server-side renderers and converters - consumed by `ghost/core` (kg-default-nodes, kg-lexical-html-renderer, - kg-html-to-lexical, ...) plus frontend helpers (kg-unsplash-selector) - -All Koenig packages resolve via `workspace:` — nothing in dev, CI, or the -release archive installs them from npm. They are published to npm for -external consumers only, automatically as part of the Ghost release lane -(see `publish_koenig_packages` in ci.yml). - -**Zero-build dev via the `source` export condition.** The `kg-*` libraries -consumed by `ghost/core` declare a `source` condition in their `package.json` -`exports` that points at the raw `src/*.ts`, listed *before* -`types`/`import`/`require`: - -```jsonc -".": { - "source": "./src/index.ts", // dev/test: read raw TS - "types": "./build/esm/index.d.ts", - "import": "./build/esm/index.js", - "require": "./build/cjs/index.js" // prod/published: compiled JS -} -``` - -`ghost/core`'s dev runner (`nodemon.json`: `node --conditions=source --import=tsx`) -and its Vitest configs (`resolve.conditions: ['source', 'node']` + -`--import tsx --conditions=source`) activate this condition, so a source change -in a `kg-*` package is picked up with **no `tsc` rebuild**. Production and the -published npm tarball run plain `node`, which ignores `source` and uses -`build/` — and `src/` is excluded from each package's `files` array, so it is -never shipped. The separate ESM and CommonJS outputs are part of Koenig's public -package contract; new internal packages use the ESM-only shape documented below. - -### packages/* - Shared workspace libraries -Backend and shared libraries. Internal packages are consumed via `workspace:*`; -selected adapter bases also have supported public releases: - -Read [`packages/README.md`](packages/README.md) before creating or modernizing an -internal package. It is the canonical lifetime contract; `packages/_template` -is its scaffold. - -- **i18n** - Centralized internationalization for all apps -- **parse-email-address** - Email address parsing -- **adapters/** - Adapter base classes (`adapter-base-*`: scheduling, storage, - SSO, redirects, route settings) -- **custom-field-types**, **testing** - Shared field-type definitions and test - helpers -- **_template** - Scaffold for new packages; excluded from the workspace - -### e2e/ - End-to-end tests -- Playwright-based E2E tests with Docker container isolation -- See `e2e/CLAUDE.md` for detailed testing guidance - -## Common Commands - -### Development -```bash -corepack enable pnpm # Enable corepack to use the correct pnpm version -pnpm run setup # First-time setup (installs deps + submodules + builds workspace packages) -pnpm dev # Start development (Docker backend + host frontend dev servers) -``` - -> **Fresh worktree / first run — run `pnpm setup` before anything else.** It installs deps and syncs submodules. `pnpm fix` does a clean reinstall if anything misbehaves after a branch switch. - -### Building -```bash -pnpm build # Build all packages (Nx handles dependencies) -pnpm build:clean # Clean build artifacts and rebuild -``` - -### Testing -```bash -# Unit tests (from root) -pnpm test:unit # Run all unit tests in all packages -pnpm test:watch # Watch mode — unified Vitest watcher (ghost/core + all apps) - -# Ghost core tests (from ghost/core/) -cd ghost/core -pnpm test:unit # Unit tests only (Vitest, run once) -pnpm test:watch # Watch mode — ghost/core unit tests only -pnpm test:integration # Integration tests -pnpm test:e2e # Server-side e2e suites (webhooks/server/frontend/api) — not browser -pnpm test:all # All test types - -# These run on sqlite with no extra services. The Redis/MinIO/S3 adapter suites -# probe for their service and auto-skip when it's down (run `pnpm dev:storage` -# etc. to exercise them); they always run in CI, which starts the services. - -# E2E browser tests (from root) -pnpm test:e2e # Run e2e/ Playwright tests - -# Running a single test -cd ghost/core -pnpm test:single test/unit/path/to/test.test.js # routes test/unit/* → unit config, test/* → DB config - -# Watch a single DB-backed file (integration/e2e) — the default test:watch only -# covers unit tests, so point it at the DB config explicitly: -pnpm exec vitest -c vitest.config.db.ts test/integration/path/to/test.test.js - -# Ember Admin tests (from the repository root) -pnpm nx run ghost-admin:test - -# Run one Ember Admin test file. Paths are relative to apps/ember-admin. -# The explicit `1` supplies the numeric value required by the test script's -# trailing `--parallel` option before additional Ember Exam arguments. -pnpm nx run ghost-admin:test -- 1 --file-path=tests/acceptance/editor/publish-flow-test.js -``` - -> **Always run Ember Admin tests through Nx.** Running `ember test` or -> `ember exam` directly from `apps/ember-admin` skips the dependency build -> graph and commonly fails in fresh worktrees with missing outputs such as -> `koenig-lexical.umd.js`, `@tryghost/admin-x-framework/hooks`, or -> `@tryghost/kg-converters`. For focused runs, use Ember Exam's `--file-path` -> as shown above rather than appending `--filter` to the package script. - -### Linting -```bash -pnpm lint # Lint all packages -cd ghost/core && pnpm lint # Lint Ghost core (server, shared, frontend, tests) -cd apps/ember-admin && pnpm lint # Lint Ember admin -``` - -### Database -```bash -pnpm knex-migrator migrate # Run database migrations -pnpm reset:data # Reset database with test data (1000 members, 100 posts) (requires pnpm dev running) -pnpm reset:data:empty # Reset database with no data (requires pnpm dev running) -``` - -### Docker -```bash -pnpm docker:build # Build Docker images -pnpm docker:clean # Stop containers, remove volumes and local images -pnpm docker:down # Stop containers -``` - -### How `pnpm dev` works - -The `pnpm dev` command uses a **hybrid Docker + host development** setup: - -**What runs in Docker:** -- Ghost Core backend (with hot-reload via mounted source) -- MySQL, Redis, Mailpit -- Caddy gateway/reverse proxy - -**What runs on host by default:** -- Admin, legacy Ember admin, Portal, and foundation library dev watchers -- Optional public UMD app watchers can be added when needed - -**Setup:** -```bash -# Start Ghost backend, Admin, Portal, and Docker services -pnpm dev - -# Add optional public apps (comments-ui, sodo-search, signup-form, admin-toolbar) -pnpm dev:public - -# Develop the Koenig editor against Ghost Admin (adds a koenig-lexical rebuild -# watcher + preview server; Admin loads the editor from your local build) -pnpm dev:lexical - -# With optional services (uses Docker Compose file composition) -pnpm dev:analytics # Include Tinybird analytics -pnpm dev:storage # Include MinIO S3-compatible object storage -pnpm dev:stripe # Include Stripe webhook forwarding -pnpm dev:full # Include analytics, storage, Stripe, and public app watchers - -# Everything available -pnpm dev:all # -``` - -**Accessing Services:** -- Ghost: `http://localhost:2368` (database: `ghost_dev`) -- Mailpit UI: `http://localhost:8025` (email testing) -- MySQL: `localhost:3306` -- Redis: `localhost:6379` -- Tinybird: `http://localhost:7181` (when analytics enabled) -- MinIO Console: `http://localhost:9001` (when storage enabled) -- MinIO S3 API: `http://localhost:9000` (when storage enabled) - -## Architecture Patterns - -### Admin Apps Integration (Micro-Frontend) - -**Build Process:** -1. Admin-x React apps build to `apps/*/dist` using Vite -2. `apps/ember-admin/lib/asset-delivery` copies them to `ghost/core/core/built/admin/assets/*` -3. Ghost admin serves from `/ghost/assets/{app-name}/{app-name}.js` - -**Runtime Loading:** -- Ember admin uses `AdminXComponent` to dynamically import React apps -- React components wrapped in Suspense with error boundaries -- Apps receive config via `additionalProps()` method - -### Public Apps Integration - -- Built as UMD bundles to `apps/*/umd/*.min.js` -- Loaded via `"); await modal.getByRole("tab", {name: "Site footer"}).click(); await modal.getByTestId("footer-code").getByRole("textbox").fill(""); @@ -52,12 +69,12 @@ describe("Advanced settings", () => { await expect.poll(() => api.requests.length).toBe(1); }); - it("resets authentication when the feature is enabled", async () => { + it("resets authentication after confirmation", async () => { fakeSettingsScreens(); // Success intentionally navigates to /ghost/ after locking users; // the isolated E2E danger-zone test covers that successful mutation. const api = fakeAdminEndpoint("POST", "/authentication/reset/", {errors: [{message: "stop after request"}]}, {status: 400}); - await renderAdminApp("/settings/advanced", {labs: {dangerZoneResetAuth: true}}); + await renderAdminApp("/settings/advanced"); await settingsScreen.section("dangerzone").getByRole("button", {name: "Reset all authentication"}).click(); await settingsScreen.confirmationModal().getByRole("button", {name: "Reset all authentication"}).click(); @@ -128,6 +145,63 @@ describe("Advanced settings", () => { } }); + it("imports content from the universal importer and confirms the import is queued", async () => { + fakeSettingsScreens(); + const importApi = fakeAdminEndpoint("POST", "/db/", {}); + await renderAdminApp("/settings/migration"); + + await settingsScreen.section("migrationtools").getByRole("button", {name: "Universal import"}).click(); + const modal = page.getByTestId("universal-import-modal"); + const input = modal.element().querySelector("#import-file"); + if (!input) { + throw new Error("import file input was not rendered"); + } + await page.elementLocator(input).upload(new File(["{}"], "content.json", {type: "application/json"})); + + await expect.poll(() => importApi.requests.length).toBe(1); + await expect(modal).toHaveCount(0); + await expect.element(settingsScreen.confirmationModal()).toHaveTextContent("Import in progress"); + await settingsScreen.confirmationModal().getByRole("button", {name: "Got it"}).click(); + await expect(settingsScreen.confirmationModal()).toHaveCount(0); + }); + + it("closes the universal importer without importing when cancelled", async () => { + fakeSettingsScreens(); + const importApi = fakeAdminEndpoint("POST", "/db/", {}); + await renderAdminApp("/settings/migration"); + + await settingsScreen.section("migrationtools").getByRole("button", {name: "Universal import"}).click(); + const modal = page.getByTestId("universal-import-modal"); + await expect.element(modal).toBeVisible(); + await modal.getByRole("button", {name: "Cancel"}).click(); + + await expect(modal).toHaveCount(0); + expect(importApi.requests).toHaveLength(0); + }); + + it("enables the automations beta only after confirmation", async () => { + fakeSettingsScreens(); + const settingsApi = fakeEditSettings(); + await renderAdminApp("/settings/labs"); + + const section = settingsScreen.section("labs"); + await section.getByRole("button", {name: "Open"}).click(); + await section.getByRole("tab", {name: "Beta features"}).click(); + const toggle = section.getByRole("switch", {name: "Automations (beta)"}); + + await toggle.click(); + const confirmation = page.getByTestId("feature-toggle-confirmation-modal"); + await expect.element(confirmation).toBeVisible(); + await confirmation.getByRole("button", {name: "Cancel"}).click(); + await expect(confirmation).toHaveCount(0); + expect(settingsApi.requests).toHaveLength(0); + + await toggle.click(); + await confirmation.getByRole("button", {name: "Enable"}).click(); + await expect(confirmation).toHaveCount(0); + await expect.poll(() => settingsApi.lastRequest?.settings.find(setting => setting.key === "labs")?.value).toContain('"automations":true'); + }); + it("downloads the content and settings export", async () => { fakeSettingsScreens(); await renderAdminApp("/settings/migration"); diff --git a/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx b/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx index 85bf96bb74a..dd267b53afd 100644 --- a/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx +++ b/apps/admin/src/settings/advanced/integrations.acceptance.test.tsx @@ -45,10 +45,14 @@ function apiKey(secret: string) { }; } -function limitedConfig() { +const customIntegrationsLimit = {customIntegrations: {disabled: true, error: "Your plan does not support custom integrations"}}; + +function limitedConfig(upgradeUrl?: string) { const response = configResponse(); response.config.labs = {...response.config.labs, transistor: true}; - response.config.hostSettings = {limits: {customIntegrations: {disabled: true, error: "Your plan does not support custom integrations"}}}; + response.config.hostSettings = upgradeUrl ? + {limits: customIntegrationsLimit, billing: {upgradeUrl}} : + {limits: customIntegrationsLimit}; return response; } @@ -122,7 +126,8 @@ describe("Advanced integrations", () => { await modal.getByRole("button", {name: "Save"}).click(); await expect.element(section).toHaveTextContent(/Test description/); expect(editApi.requests).toHaveLength(1); - await modal.getByRole("button", {name: "Close"}).click(); + // The modal closes itself once the saved state resets — clicking Close races that. + await expect.element(modal).not.toBeInTheDocument(); await section.getByText("My integration").hover(); await section.getByRole("button", {name: "Delete"}).click(); @@ -278,7 +283,16 @@ describe("Advanced integrations", () => { } await section.getByTestId("zapier-integration").getByRole("button", {name: "Upgrade"}).click(); - expect(JSON.parse(document.body.dataset.externalNavigate!)).toMatchObject({route: "pro"}); + expect(JSON.parse(document.body.dataset.externalNavigate!)).toMatchObject({route: "/pro"}); + }); + + it("sends the upgrade CTA to a host's own billing app when one is configured", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings/integrations", {boot: {browseConfig: {response: limitedConfig("#/pro/billing/plans")}}}); + + const section = settingsScreen.section("integrations"); + await section.getByTestId("zapier-integration").getByRole("button", {name: "Upgrade"}).click(); + expect(JSON.parse(document.body.dataset.externalNavigate!)).toMatchObject({route: "/pro/billing/plans"}); }); it("saves FirstPromoter configuration and warns before discarding later changes", async () => { diff --git a/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx b/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx new file mode 100644 index 00000000000..ef475a2c17e --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools-export.acceptance.test.tsx @@ -0,0 +1,62 @@ +import {describe, expect, it} from "vitest"; +import {page} from "vitest/browser"; + +import {configResponse, fakeSettingsScreens, renderAdminApp} from "@test-utils/acceptance"; +import {settingsScreen} from "@/settings/settings.screen"; + +async function openExportTab() { + const section = settingsScreen.section("migrationtools"); + await section.getByRole("tab", {name: "Export"}).click(); + return section; +} + +describe("Migration tools export", () => { + it("keeps the individual export buttons without the selfServeArchives flag", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings/advanced"); + + const section = await openExportTab(); + await expect.element(section.getByRole("button", {name: "Content & settings"})).toBeVisible(); + await expect.element(section.getByRole("button", {name: "Post analytics"})).toBeVisible(); + await expect.element(section.getByRole("button", {name: "Export data"})).not.toBeInTheDocument(); + }); + + it("offers the sync export dialog without media when no archive host is configured", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings/advanced", {labs: {selfServeArchives: true}}); + + const section = await openExportTab(); + await section.getByRole("button", {name: "Export data"}).click(); + + const dialog = page.getByRole("dialog"); + await expect.element(dialog.getByText("downloaded as a single zip", {exact: false})).toBeVisible(); + await expect.element(dialog.getByText("Members", {exact: true})).toBeVisible(); + await expect.element(dialog.getByText("Media files", {exact: true})).not.toBeInTheDocument(); + + await dialog.getByRole("button", {name: "Export", exact: true}).click(); + await expect.element(dialog.getByText("Preparing your export", {exact: false})).toBeVisible(); + }); + + it("offers media and email delivery when an archive host is configured", async () => { + fakeSettingsScreens(); + const config = configResponse({labs: {selfServeArchives: true}}); + (config.config as {hostSettings?: object}).hostSettings = { + ...(config.config as {hostSettings?: object}).hostSettings, + export: {generate_archive_url: "https://archives.example.com/generate"}, + }; + await renderAdminApp("/settings/advanced", { + labs: {selfServeArchives: true}, + boot: {browseConfig: {response: config}}, + }); + + const section = await openExportTab(); + await section.getByRole("button", {name: "Export data"}).click(); + + const dialog = page.getByRole("dialog"); + await expect.element(dialog.getByText("sent to you by email", {exact: false})).toBeVisible(); + await expect.element(dialog.getByText("Media files", {exact: true})).toBeVisible(); + + await dialog.getByRole("button", {name: "Export", exact: true}).click(); + await expect.element(dialog.getByText("Exporting data", {exact: false})).toBeVisible(); + }); +}); diff --git a/apps/admin/src/settings/app/app.tsx b/apps/admin/src/settings/app/app.tsx index c560ce3737c..992ac686b99 100644 --- a/apps/admin/src/settings/app/app.tsx +++ b/apps/admin/src/settings/app/app.tsx @@ -1,6 +1,8 @@ import MainContent from './main-content'; import NiceModal from '@ebay/nice-modal-react'; import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider'; +import {ConfirmationProvider} from './components/providers/confirmation-provider'; +import {DialogPortalProvider} from './components/providers/dialog-portal'; import {Outlet, useLocation} from '@tryghost/admin-x-framework'; import {useEffect} from 'react'; import {useScrollSectionContext} from './hooks/use-scroll-section'; @@ -27,11 +29,15 @@ export function App({upgradeStatus}: AppProps) { return (
- - - - - + + + + + + + + +
); diff --git a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx b/apps/admin/src/settings/app/components/confirmation-modal.test.tsx index 0dcb8d2f5cd..39472b55f48 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx +++ b/apps/admin/src/settings/app/components/confirmation-modal.test.tsx @@ -1,18 +1,19 @@ -import ConfirmationModal, {type ConfirmationModalProps} from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {type ConfirmationModalProps} from '@/settings/app/components/confirmation-modal'; +import {useEffect} from 'react'; +import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('ConfirmationModal', () => { - afterEach(() => { - void NiceModal.remove(ConfirmationModal); - }); - const showModal = (props: ConfirmationModalProps) => { - render(); - - act(() => { - void NiceModal.show(ConfirmationModal, props); - }); + const Trigger = () => { + const {confirm} = useConfirmation(); + useEffect(() => { + confirm(props); + + }, [confirm]); + return null; + }; + render(); }; it('renders the supplied content and confirms without closing implicitly', async () => { diff --git a/apps/admin/src/settings/app/components/confirmation-modal.tsx b/apps/admin/src/settings/app/components/confirmation-modal.tsx index 3e408c86b08..892ea1b62dc 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.tsx +++ b/apps/admin/src/settings/app/components/confirmation-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import { AlertDialog, @@ -31,7 +30,14 @@ export interface ConfirmationModalProps { testId?: string; } -export const ConfirmationModalContent: React.FC = ({ +export type ConfirmationHostProps = { + visible?: boolean; + onRemove: () => void; +}; + +export const ConfirmationModalContent: React.FC = ({ + visible = true, + onRemove, title = 'Are you sure?', prompt, cancelLabel = 'Cancel', @@ -45,7 +51,6 @@ export const ConfirmationModalContent: React.FC = ({ stickyFooter = false, testId = 'confirmation-modal' }) => { - const modal = useModal(); const [taskState, setTaskState] = useState<'running' | ''>(''); const isRunning = taskState === 'running'; const runningLabel = okRunningLabel || okLabel; @@ -58,7 +63,7 @@ export const ConfirmationModalContent: React.FC = ({ if (onCancel) { onCancel(); } else { - modal.remove(); + onRemove(); } }; @@ -66,7 +71,7 @@ export const ConfirmationModalContent: React.FC = ({ setTaskState('running'); try { - await onOk?.(modal); + await onOk?.({remove: onRemove}); } catch (error) { // eslint-disable-next-line no-console console.error('Unhandled Promise Rejection. Make sure you catch errors in your onOk handler.', error); @@ -94,7 +99,7 @@ export const ConfirmationModalContent: React.FC = ({ const footer = customFooter === undefined ? defaultFooter : customFooter; return ( - !open && handleCancel()}> + !open && handleCancel()}> = ({ ); }; - -export default NiceModal.create(ConfirmationModalContent); diff --git a/apps/admin/src/settings/app/components/limit-modal.test.tsx b/apps/admin/src/settings/app/components/limit-modal.test.tsx index 9a18d0ca831..fc3e6058be1 100644 --- a/apps/admin/src/settings/app/components/limit-modal.test.tsx +++ b/apps/admin/src/settings/app/components/limit-modal.test.tsx @@ -1,18 +1,19 @@ -import LimitModal, {type LimitModalProps} from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {type LimitModalProps} from '@/settings/app/components/limit-modal'; +import {useEffect} from 'react'; +import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('LimitModal', () => { - afterEach(() => { - void NiceModal.remove(LimitModal); - }); - const showModal = (props: LimitModalProps) => { - render(); - - act(() => { - void NiceModal.show(LimitModal, props); - }); + const Trigger = () => { + const {showLimit} = useConfirmation(); + useEffect(() => { + showLimit(props); + + }, [showLimit]); + return null; + }; + render(); }; it('preserves the upgrade defaults and renders HTML prompts', async () => { diff --git a/apps/admin/src/settings/app/components/limit-modal.tsx b/apps/admin/src/settings/app/components/limit-modal.tsx index 20f5b4bd033..2f9a0b91c05 100644 --- a/apps/admin/src/settings/app/components/limit-modal.tsx +++ b/apps/admin/src/settings/app/components/limit-modal.tsx @@ -1,7 +1,6 @@ -import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; -import {ConfirmationModalContent} from './confirmation-modal'; +import {type ConfirmationHostProps, ConfirmationModalContent} from './confirmation-modal'; export interface LimitModalProps { title?: string; @@ -13,7 +12,9 @@ export interface LimitModalProps { }) => void | Promise; } -export const LimitModalContent: React.FC = ({ +export const LimitModalContent: React.FC = ({ + visible = true, + onRemove, title = 'Upgrade your plan', prompt, okLabel = 'Upgrade', @@ -31,9 +32,9 @@ export const LimitModalContent: React.FC = ({ prompt={
{promptContent}
} testId='limit-modal' title={title} + visible={visible} onOk={onOk} + onRemove={onRemove} /> ); }; - -export default NiceModal.create(LimitModalContent); diff --git a/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx b/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx new file mode 100644 index 00000000000..b22fb12d597 --- /dev/null +++ b/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx @@ -0,0 +1,56 @@ +import React, {createContext, useCallback, useContext, useRef, useState} from 'react'; +import {type ConfirmationModalProps, ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; +import {type LimitModalProps, LimitModalContent} from '@/settings/app/components/limit-modal'; + +export type ConfirmationHandle = {remove: () => void}; + +type ConfirmationRequest = + | {id: number; kind: 'confirm'; props: ConfirmationModalProps} + | {id: number; kind: 'limit'; props: LimitModalProps}; + +type ConfirmationContextType = { + confirm: (props: ConfirmationModalProps) => ConfirmationHandle; + showLimit: (props: LimitModalProps) => ConfirmationHandle; +}; + +const ConfirmationContext = createContext(null); + +export function useConfirmation(): ConfirmationContextType { + const context = useContext(ConfirmationContext); + if (!context) { + throw new Error('useConfirmation must be used inside ConfirmationProvider'); + } + return context; +} + +export const ConfirmationProvider: React.FC<{children: React.ReactNode}> = ({children}) => { + const [requests, setRequests] = useState([]); + const nextId = useRef(0); + + const show = useCallback((request: Omit): ConfirmationHandle => { + nextId.current += 1; + const id = nextId.current; + // One request per kind, matching NiceModal's per-component keying: a + // second show replaces the first instead of stacking (StrictMode + // double-effects depend on this). + setRequests(current => [...current.filter(r => r.kind !== request.kind), {...request, id} as ConfirmationRequest]); + return {remove: () => setRequests(current => current.filter(r => r.id !== id))}; + }, []); + + const confirm = useCallback((props: ConfirmationModalProps) => show({kind: 'confirm', props}), [show]); + const showLimit = useCallback((props: LimitModalProps) => show({kind: 'limit', props}), [show]); + + const contextValue = React.useMemo(() => ({confirm, showLimit}), [confirm, showLimit]); + + return ( + + {children} + {requests.map((request) => { + const remove = () => setRequests(current => current.filter(r => r.id !== request.id)); + return request.kind === 'confirm' ? + : + ; + })} + + ); +}; diff --git a/apps/admin/src/settings/app/components/providers/dialog-portal.tsx b/apps/admin/src/settings/app/components/providers/dialog-portal.tsx new file mode 100644 index 00000000000..1d37ff3f800 --- /dev/null +++ b/apps/admin/src/settings/app/components/providers/dialog-portal.tsx @@ -0,0 +1,27 @@ +import React, {createContext, useContext, useState} from 'react'; +import {createPortal} from 'react-dom'; + +const DialogPortalContext = createContext(null); + +// Settings groups and the fixed content wrapper open stacking contexts, so an in-tree +// SettingsModal paints below the settings chrome; the host sits beside the layout instead. +export const DialogPortalProvider: React.FC<{children: React.ReactNode}> = ({children}) => { + const [host, setHost] = useState(null); + + return ( + + {children} +
+ + ); +}; + +export const DialogPortal: React.FC<{children: React.ReactNode}> = ({children}) => { + const host = useContext(DialogPortalContext); + + if (!host) { + return null; + } + + return createPortal(children, host); +}; diff --git a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx b/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx index cec819ea4cf..2c48683ec8f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx @@ -1,22 +1,24 @@ import CodeModal from './code/code-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import {Button} from '@tryghost/shade/components'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => { + const [isCodeModalOpen, setIsCodeModalOpen] = useState(false); + return ( { - NiceModal.show(CodeModal); - }}>Open} + customButtons={} description="Add custom code to your publication" keywords={keywords} navid='code-injection' testId='code-injection' title="Code injection" - /> + > + {isCodeModalOpen && setIsCodeModalOpen(false)} />} + ); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx index fe4d41d2e7b..c2e49fadc62 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx @@ -1,5 +1,4 @@ import CodeEditor from '@/settings/app/components/code-editor'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import useSettingGroup from '@/settings/app/hooks/use-setting-group'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; @@ -10,19 +9,15 @@ import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useSaveButton} from '@/settings/app/hooks/use-save-button'; interface CodeModalProps { - hint?: React.ReactNode; - value?: string; - onChange: (value: string) => void; - afterClose?: () => void + onClose: () => void; } -const CodeModal: React.FC = ({afterClose}) => { +const CodeModal: React.FC = ({onClose}) => { const { localSettings, handleSave, updateSetting } = useSettingGroup(); - const modal = useModal(); const [headerContent, footerContent] = getSettingValues(localSettings, ['codeinjection_head', 'codeinjection_foot']); @@ -63,22 +58,19 @@ const CodeModal: React.FC = ({afterClose}) => { }); return } height='full' size='full' testId='modal-code-injection' + onClose={onClose} >
Code injection - +
@@ -98,4 +90,4 @@ const CodeModal: React.FC = ({afterClose}) => { ; }; -export default NiceModal.create(CodeModal); +export default CodeModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx b/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx index d8c79689ccb..28ab18ff0c0 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx @@ -1,5 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import trackEvent from '@/settings/app/utils/analytics'; @@ -8,8 +6,8 @@ import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; import {useRemoveAllGiftLinks} from '@tryghost/admin-x-framework/api/gift-links'; @@ -22,10 +20,8 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: removeAllGiftLinks} = useRemoveAllGiftLinks(); const client = useQueryClient(); const handleError = useHandleError(); - const {config} = useGlobalData(); const {totalUsers} = useStaffUsers(); - - const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth); + const {confirm} = useConfirmation(); const resetAuthStaffSentence = totalUsers === 1 ? 'You will be signed out and must reset your password before signing back in.' @@ -34,7 +30,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { : 'All staff users, including you, will be signed out and must reset their password before signing back in.'; const handleDeleteAllContent = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Would you really like to delete all content from your blog?', prompt: 'This is permanent! No backups, no restores, no magic undo button. We warned you, k?', okVariant: 'destructive', @@ -53,7 +49,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }; const handleResetAuth = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reset all authentication?', prompt: ( <> @@ -85,7 +81,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }; const handleRemoveAllGiftLinks = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reset all gift links?', prompt: 'This immediately invalidates every active gift link across your site. Anyone holding one will lose access. New gift links can still be created afterwards.', okLabel: 'Reset all gift links', @@ -121,15 +117,13 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { - {resetAuthEnabled && ( - - -
Reset all authentication
-
Rotate every API key, sign out every staff user, and require a password reset. Use after a suspected credential compromise.
-
- -
- )} + + +
Reset all authentication
+
Rotate every API key, sign out every staff user, and require a password reset. Use after a suspected credential compromise.
+
+ +
Reset all gift links
diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx index b1553e17bb4..ebc50fb01ff 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx @@ -1,7 +1,5 @@ import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import IntegrationsSettingsImg from '@/settings/app/assets/images/integrations-settings.png'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; @@ -11,9 +9,12 @@ import {LucideIcon} from '@tryghost/shade/utils'; import {Plug} from 'lucide-react'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {DEFAULT_UPGRADE_ROUTE} from '@tryghost/admin-x-framework/api/config'; +import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; interface IntegrationItemProps { @@ -24,6 +25,7 @@ interface IntegrationItemProps { onDelete?: () => void; active?: boolean; disabled?: boolean; + upgradeRoute?: string; testId?: string; custom?: boolean; } @@ -46,6 +48,7 @@ const IntegrationItem: React.FC = ({ onDelete, active, disabled, + upgradeRoute = DEFAULT_UPGRADE_ROUTE, testId, custom = false }) => { @@ -56,7 +59,7 @@ const IntegrationItem: React.FC = ({ e?.stopPropagation(); if (disabled) { - updateRoute({route: 'pro', isExternal: true}); + updateRoute({route: upgradeRoute, isExternal: true}); } else { action(); } @@ -94,6 +97,7 @@ const IntegrationItem: React.FC = ({ const BuiltInIntegrations: React.FC = () => { const {config} = useGlobalData(); + const upgradeRoute = useUpgradeRoute(); const {updateRoute} = useSettingsNavigation(); const openModal = (modal: string) => { @@ -191,6 +195,7 @@ const BuiltInIntegrations: React.FC = () => { icon={item.icon} testId={item.testId} title={item.title} + upgradeRoute={upgradeRoute} /> ))} @@ -201,6 +206,7 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio const {updateRoute} = useSettingsNavigation(); const {mutateAsync: deleteIntegration} = useDeleteIntegration(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); if (integrations.length) { return ( @@ -220,7 +226,7 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio title={integration.name} custom onDelete={() => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure?', prompt: 'Deleting this integration will remove all webhooks and api keys associated with it.', okVariant: 'destructive', diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx index c7b382c58da..d5245bfda35 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx @@ -1,34 +1,36 @@ -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import {useEffect, useState} from 'react'; import {Field, FieldError, FieldGroup, FieldLabel, Input} from '@tryghost/shade/components'; import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useCreateIntegration} from '@tryghost/admin-x-framework/api/integrations'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; function AddIntegrationModal() { const {updateRoute} = useSettingsNavigation(); + const upgradeRoute = useUpgradeRoute(); const [name, setName] = useState(''); const [errors, setErrors] = useState({name: ''}); const {mutateAsync: createIntegration} = useCreateIntegration(); const limiter = useLimiter(); const handleError = useHandleError(); + const {showLimit} = useConfirmation(); useEffect(() => { if (limiter?.isLimited('customIntegrations')) { limiter.errorIfWouldGoOverLimit('customIntegrations').catch((error) => { if (error instanceof HostLimitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: error.message || `Your current plan doesn't support more custom integrations.`, - onOk: () => updateRoute({route: '/pro', isExternal: true}) + onOk: () => updateRoute({route: upgradeRoute, isExternal: true}) }); updateRoute('integrations'); } }); } - }, [limiter, updateRoute]); + }, [limiter, showLimit, updateRoute, upgradeRoute]); return = ({in const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const {mutateAsync: uploadImage} = useUploadImage(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {formState, updateForm, handleSave, saveState, errors, clearError, okProps} = useForm({ initialState: integration, @@ -64,7 +64,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in const name = apiKey.type === 'content' ? 'Content' : 'Admin'; - NiceModal.show(ConfirmationModal, { + confirm({ title: `Regenerate ${name} API Key`, prompt: `You can regenerate ${name} API Key any time, but any scripts or applications using it will need to be updated.`, okLabel: `Regenerate ${name} API Key`, diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx index a9bfc9c2fdc..aa77cacf6bd 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx @@ -1,14 +1,13 @@ import APIKeys from './api-keys'; import BookmarkThumb from '@/settings/app/assets/images/integrations/ghost-transistor.png'; import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import IntegrationHeader from './integration-header'; -import NiceModal from '@ebay/nice-modal-react'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSet, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useEffect, useState} from 'react'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -23,6 +22,7 @@ function TransistorModal() { const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [regenerated, setRegenerated] = useState(false); const builtInApiIntegrationsDisabled = config.hostSettings?.limits?.customIntegrations?.disabled; @@ -50,7 +50,7 @@ function TransistorModal() { setRegenerated(false); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Regenerate Admin API Key', prompt: 'You will need to update the API key in your Transistor account settings after regenerating.', okLabel: 'Regenerate Admin API Key', diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx index 1d05d89ebf3..456d46ed62b 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import validator from 'validator'; import webhookEventOptions from './webhook-event-options'; @@ -10,11 +9,11 @@ import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface WebhookModalProps { webhook?: Webhook; integrationId: string; + onClose: () => void; } -const WebhookModal: React.FC = ({webhook, integrationId}) => { +const WebhookModal: React.FC = ({webhook, integrationId, onClose}) => { const eventErrorId = React.useId(); - const modal = useModal(); const {mutateAsync: createWebhook} = useCreateWebhook(); const {mutateAsync: editWebhook} = useEditWebhook(); const handleError = useHandleError(); @@ -59,9 +58,10 @@ const WebhookModal: React.FC = ({webhook, integrationId}) => testId='webhook-modal' title='Add webhook' formSheet + onClose={onClose} onOk={async () => { if (await handleSave()) { - modal.remove(); + onClose(); } }} > @@ -109,4 +109,4 @@ const WebhookModal: React.FC = ({webhook, integrationId}) => ; }; -export default NiceModal.create(WebhookModal); +export default WebhookModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx index 1f3e4ad29af..977bb1a35e8 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx @@ -1,4 +1,5 @@ import WebhooksTable from '@/settings/app/components/settings/advanced/integrations/webhooks-table'; +import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {render, screen} from '@testing-library/react'; @@ -17,7 +18,7 @@ describe('WebhooksTable', () => { webhooks: [] } as unknown as Integration; - const {container} = render(); + const {container} = render(); expect(screen.getByRole('heading', {name: 'No webhooks'})).toBeInTheDocument(); expect(screen.getByText('Add a webhook to send Ghost events to another service.')).toBeInTheDocument(); @@ -40,7 +41,7 @@ describe('WebhooksTable', () => { }] } as unknown as Integration; - const {container} = render(); + const {container} = render(); const table = screen.getByRole('table'); const addButton = screen.getByRole('button', {name: 'Add webhook'}); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx index 21253318ae4..e1554823ec4 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx @@ -1,28 +1,38 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import WebhookModal from './webhook-modal'; +import {useState} from 'react'; import {Button, EmptyIndicator, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {getWebhookEventLabel} from './webhook-event-options'; import {toast} from 'sonner'; -import {useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {type Webhook, useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { const {mutateAsync: deleteWebhook} = useDeleteWebhook(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const webhooks = integration.webhooks || []; + const [editingWebhook, setEditingWebhook] = useState(null); + const [isAddingWebhook, setIsAddingWebhook] = useState(false); const showAddWebhookModal = () => { - NiceModal.show(WebhookModal, { - integrationId: integration.id - }); + setIsAddingWebhook(true); + }; + + const closeWebhookModal = () => { + setIsAddingWebhook(false); + setEditingWebhook(null); }; + const webhookModal = isAddingWebhook + ? + : editingWebhook && ; + const handleDelete = (id: string) => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure?', prompt: 'Deleting this webhook may prevent the integration from functioning.', okVariant: 'destructive', @@ -56,6 +66,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { + {webhookModal} ); } @@ -71,12 +82,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { {webhooks.map(webhook => ( - { - NiceModal.show(WebhookModal, { - webhook, - integrationId: integration.id - }); - }}> + setEditingWebhook(webhook)}>
{webhook.name}
@@ -120,6 +126,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { + {webhookModal} ); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx index 7da7fbe9c10..6e32adfaf4a 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx @@ -1,14 +1,13 @@ import APIKeys from './api-keys'; import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import IntegrationHeader from './integration-header'; -import NiceModal from '@ebay/nice-modal-react'; import ZapierLogo from '@/settings/app/assets/images/zapier-logo.svg'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useEffect, useState} from 'react'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -31,6 +30,7 @@ function ZapierModal() { const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [regenerated, setRegenerated] = useState(false); const zapierDisabled = config.hostSettings?.limits?.customIntegrations?.disabled; @@ -50,7 +50,7 @@ function ZapierModal() { setRegenerated(false); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Regenerate Admin API Key', prompt: 'You will need to locate the Ghost App within your Zapier account and click on "Reconnect" to enter the new Admin API Key.', okLabel: 'Regenerate Admin API Key', diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx index a915fbb8108..30f79243fe9 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx @@ -1,6 +1,5 @@ import FeatureToggle from './feature-toggle'; import LabItem from './lab-item'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import YamlFileEditorModal from './yaml-file-editor-modal'; import {ActionList, Button, Dropzone} from '@tryghost/shade/components'; @@ -11,6 +10,7 @@ import {getSettingValue} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; const IS_AUTOMATIONS_BETA_ACTIVE = true; @@ -23,115 +23,126 @@ const BetaFeatures: React.FC = () => { const [routesUploading, setRoutesUploading] = useState(false); const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); const isAutomationsEnabled = !!labs.automations; + const [openEditor, setOpenEditor] = useState<'redirects' | 'routes' | null>(null); - const openRedirectsEditor = () => { - NiceModal.show(YamlFileEditorModal, { - title: 'Redirects', - hint: <>Configure redirects for old or moved content. See the docs for the file format., - testId: 'modal-redirects-editor', - downloadPath: '/redirects/download/', - uploadFilename: 'redirects.yaml', - successMessage: 'Redirects updated', - onUpload: (file: File) => uploadRedirects(file) - }); - }; - - const openRoutesEditor = () => { - NiceModal.show(YamlFileEditorModal, { - title: 'Routes', - hint: <>Configure dynamic routing by editing the routes.yaml file. See the docs for the file format., - testId: 'modal-routes-editor', - downloadPath: '/settings/routes/yaml/', - uploadFilename: 'routes.yaml', - successMessage: 'Routes updated', - onUpload: (file: File) => uploadRoutes(file) - }); - }; + const openRedirectsEditor = () => setOpenEditor('redirects'); + const openRoutesEditor = () => setOpenEditor('routes'); + const closeEditor = () => setOpenEditor(null); return ( - - {IS_AUTOMATIONS_BETA_ACTIVE ? ( - } - detail={<>Build automated email flows for your members, and get early access to new automation features as they ship. Learn more →} - title='Automations (beta)' /> - ) : null} - } - detail={<>Allows newly-assigned editors to manage members and comments in addition to regular roles.} - title='Enhanced Editor role (beta)' /> - } - detail={<>Adds the excerpt input below the post title in the editor} - title='Show post excerpt inline' /> - } - detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. Learn more →} - title='Additional payment methods' /> - - - { - try { - setRedirectsUploading(true); - await uploadRedirects(file); - toast.success('Redirects uploaded'); - } catch (e) { - handleError(e); - } finally { - setRedirectsUploading(false); - } + <> + + {IS_AUTOMATIONS_BETA_ACTIVE ? ( + - {redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} - - - - - } - detail={<>Configure redirects for old or moved content,
more info in the docs} - testId='redirects' - title='Redirects' /> - - - { - try { - setRoutesUploading(true); - await uploadRoutes(file); - toast.success('Routes uploaded'); - } catch (e) { - handleError(e); - } finally { - setRoutesUploading(false); - } - }} - > - {routesUploading ? 'Uploading ...' : 'Upload routes file'} - - - - - } - detail='Configure dynamic routing by modifying the routes.yaml file' - testId='routes' - title='Routes' /> -
+ disabled={isAutomationsEnabled} + flag="automations" + label='Automations (beta)' />} + detail={<>Build automated email flows for your members, and get early access to new automation features as they ship. Learn more →} + title='Automations (beta)' /> + ) : null} + } + detail={<>Allows newly-assigned editors to manage members and comments in addition to regular roles.} + title='Enhanced Editor role (beta)' /> + } + detail={<>Adds the excerpt input below the post title in the editor} + title='Show post excerpt inline' /> + } + detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. Learn more →} + title='Additional payment methods' /> + + + { + try { + setRedirectsUploading(true); + await uploadRedirects(file); + toast.success('Redirects uploaded'); + } catch (e) { + handleError(e); + } finally { + setRedirectsUploading(false); + } + }} + > + {redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} + + + + + } + detail={<>Configure redirects for old or moved content,
more info in the docs} + testId='redirects' + title='Redirects' /> + + + { + try { + setRoutesUploading(true); + await uploadRoutes(file); + toast.success('Routes uploaded'); + } catch (e) { + handleError(e); + } finally { + setRoutesUploading(false); + } + }} + > + {routesUploading ? 'Uploading ...' : 'Upload routes file'} + + + + + } + detail='Configure dynamic routing by modifying the routes.yaml file' + testId='routes' + title='Routes' /> + + {openEditor === 'redirects' && ( + + Configure redirects for old or moved content. See the docs for the file format.} + successMessage='Redirects updated' + testId='modal-redirects-editor' + title='Redirects' + uploadFilename='redirects.yaml' + onClose={closeEditor} + onUpload={(file: File) => uploadRedirects(file)} + /> + + )} + {openEditor === 'routes' && ( + + Configure dynamic routing by editing the routes.yaml file. See the docs for the file format.} + successMessage='Routes updated' + testId='modal-routes-editor' + title='Routes' + uploadFilename='routes.yaml' + onClose={closeEditor} + onUpload={(file: File) => uploadRoutes(file)} + /> + + )} + ); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx index d6a73a2b3da..9586f79c080 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx @@ -1,5 +1,4 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import trackEvent from '@/settings/app/utils/analytics'; import {type ConfigResponseType, configDataType} from '@tryghost/admin-x-framework/api/config'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -8,6 +7,7 @@ import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/ import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; type ConfirmationProps = { title: string; @@ -25,21 +25,18 @@ type FeatureToggleProps = { type FeatureToggleConfirmationModalProps = ConfirmationProps & { onConfirm: () => Promise; + onClose: () => void; }; -const FeatureToggleConfirmationModal = NiceModal.create(({ +const FeatureToggleConfirmationModal: React.FC = ({ title, prompt, okLabel, okRunningLabel = 'Enabling...', - onConfirm + onConfirm, + onClose }) => { - const modal = useModal(); - const [isRunning, setIsRunning] = React.useState(false); - - const handleCancel = () => { - modal.remove(); - }; + const [isRunning, setIsRunning] = useState(false); const handleConfirm = async () => { setIsRunning(true); @@ -47,7 +44,7 @@ const FeatureToggleConfirmationModal = NiceModal.create
{prompt}
); -}); +}; const FeatureToggle: React.FC = ({label, flag, disabled, confirmation}) => { const {settings} = useGlobalData(); @@ -78,6 +76,7 @@ const FeatureToggle: React.FC = ({label, flag, disabled, con const client = useQueryClient(); const handleError = useHandleError(); const isEnabled = !!labs[flag]; + const [isConfirming, setIsConfirming] = useState(false); const saveFeatureValue = async (newValue: boolean) => { try { @@ -102,18 +101,25 @@ const FeatureToggle: React.FC = ({label, flag, disabled, con } }; - return { - - if (confirmation && newValue) { - NiceModal.show(FeatureToggleConfirmationModal, { - ...confirmation, - onConfirm: () => saveFeatureValue(newValue) - }); - return; - } + return <> + { + if (confirmation && newValue) { + setIsConfirming(true); + return; + } - await saveFeatureValue(newValue); - }} />; + await saveFeatureValue(newValue); + }} /> + {confirmation && isConfirming && ( + + setIsConfirming(false)} + onConfirm={() => saveFeatureValue(true)} + /> + + )} + ; }; export default FeatureToggle; 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 126fb555377..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 @@ -23,10 +23,6 @@ const features: Feature[] = [{ title: 'Stripe Automatic Tax (private beta)', description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe', flag: 'stripeAutomaticTax' -}, { - title: 'Email customization (internal beta)', - description: 'Newsletter customization settings that have been released to Ghost\'s own production sites', - flag: 'emailCustomization' }, { title: 'Import Member Tier', description: 'Enables tier to be specified when importing members', @@ -39,10 +35,6 @@ const features: Feature[] = [{ title: 'Admin UI Refresh', description: 'Enable Admin UI refresh (exploration)', flag: 'adminUIRefresh' -}, { - title: 'Explore', - description: 'Enables keeping in touch with the new Explore API', - flag: 'explore' }, { title: 'Tags X', description: 'Enables the new Tags UI', @@ -55,26 +47,14 @@ const features: Feature[] = [{ title: 'Updated theme translation (beta)', description: 'Enable theme translation using i18next instead of the old translation package.', flag: 'themeTranslation' -}, { - title: 'Featurebase Feedback', - description: 'Display a Feedback menu item in the admin sidebar. Requires the new admin experience.', - flag: 'featurebaseFeedback' }, { title: 'Picture Element', description: 'Use the HTML picture element to serve modern image formats (AVIF, WebP) with automatic fallbacks', flag: 'pictureImageFormats' -}, { - title: 'Smarter Counts', - description: 'Use optimized COUNT queries for API pagination when safe', - flag: 'smarterCounts' }, { 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.', @@ -91,6 +71,14 @@ const features: Feature[] = [{ title: 'Gift subscription customization', description: 'Enables fixed-duration gift subscription purchases before publisher configuration is available', flag: 'giftSubCustomization' +}, { + title: 'Self-serve archives', + description: 'Replaces the individual export buttons with a single "Export data" flow for downloading a full site archive', + flag: 'selfServeArchives' +}, { + title: 'Machine payments', + description: 'Let AI agents pay for access to paid-members markdown (.md) URLs via Stripe Machine Payments Protocol', + flag: 'machinePayments' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx index 9f8542fb9e9..58bb3a92832 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx @@ -1,5 +1,4 @@ import CodeEditor from '@/settings/app/components/code-editor'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useState} from 'react'; import {APIError, JSONError} from '@tryghost/admin-x-framework/errors'; import {Button} from '@tryghost/shade/components'; @@ -17,7 +16,7 @@ export interface YamlFileEditorModalProps { uploadFilename: string; successMessage: string; onUpload: (file: File) => Promise; - afterClose?: () => void; + onClose: () => void; } const extractErrorMessage = (error: unknown): string => { @@ -40,9 +39,8 @@ const YamlFileEditorModal: React.FC = ({ uploadFilename, successMessage, onUpload, - afterClose + onClose }) => { - const modal = useModal(); const handleError = useHandleError(); const [content, setContent] = useState(''); @@ -96,11 +94,6 @@ const YamlFileEditorModal: React.FC = ({ }; }, [downloadPath, uploadFilename]); - const closeModal = () => { - modal.remove(); - afterClose?.(); - }; - const handleSave = async () => { if (isSaving || isLoading || loadError) { return; @@ -115,7 +108,7 @@ const YamlFileEditorModal: React.FC = ({ toast.success(successMessage); - closeModal(); + onClose(); } catch (error) { setSaveError(extractErrorMessage(error)); handleError(error, {withToast: false}); @@ -143,19 +136,19 @@ const YamlFileEditorModal: React.FC = ({ return ( } height='full' size='full' testId={testId} + onClose={onClose} >
{title} - +
@@ -184,4 +177,4 @@ const YamlFileEditorModal: React.FC = ({ ); }; -export default NiceModal.create(YamlFileEditorModal); +export default YamlFileEditorModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx new file mode 100644 index 00000000000..17286d65129 --- /dev/null +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx @@ -0,0 +1,205 @@ +import React, {useEffect, useRef, useState} from 'react'; +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + LoadingIndicator +} from '@tryghost/shade/components'; +import {LucideIcon} from '@tryghost/shade/utils'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; + +export type ExportMode = 'sync' | 'async'; + +type ExportComponentKey = 'content' | 'members' | 'analytics' | 'themes' | 'routes' | 'media'; + +type ExportComponent = { + key: ExportComponentKey; + label: string; + description: string; + defaultChecked: boolean; + asyncOnly?: boolean; +}; + +const EXPORT_COMPONENTS: ExportComponent[] = [ + {key: 'content', label: 'Content & settings', description: 'Posts, pages, tags, tiers and settings (JSON)', defaultChecked: true}, + {key: 'members', label: 'Members', description: 'All members with labels and subscription status (CSV)', defaultChecked: true}, + {key: 'analytics', label: 'Post analytics', description: 'Sends, opens, clicks and conversions per post (CSV)', defaultChecked: true}, + {key: 'themes', label: 'Themes', description: 'All installed themes, including custom code', defaultChecked: true}, + {key: 'routes', label: 'Redirects & routes', description: 'routes.yaml and redirects configuration', defaultChecked: true}, + {key: 'media', label: 'Media files', description: 'All images, video and audio files. May significantly increase export size and duration', defaultChecked: false, asyncOnly: true} +]; + +type ExportPhase = 'select' | 'confirmed' | 'preparing' | 'done'; + +const ExportAllModal: React.FC<{open: boolean; onOpenChange: (open: boolean) => void; mode: ExportMode}> = ({open, onOpenChange, mode}) => { + const {data: currentUser} = useCurrentUser(); + const [phase, setPhase] = useState('select'); + const [selected, setSelected] = useState>(() => { + const initial = {} as Record; + EXPORT_COMPONENTS.forEach((component) => { + initial[component.key] = component.defaultChecked; + }); + return initial; + }); + const mockTimerRef = useRef>(); + const resetTimerRef = useRef>(); + + const email = currentUser?.email; + const visibleComponents = EXPORT_COMPONENTS.filter(component => mode === 'async' || !component.asyncOnly); + const noneSelected = visibleComponents.every(component => !selected[component.key]); + + const handleOpenChange = (next: boolean) => { + onOpenChange(next); + if (next) { + clearTimeout(resetTimerRef.current); + setPhase('select'); + return; + } + clearTimeout(mockTimerRef.current); + clearTimeout(resetTimerRef.current); + // Reset for the next open, after the close animation + resetTimerRef.current = setTimeout(() => setPhase('select'), 200); + }; + + // Static UX/UI mockup, nothing is wired to a backend + const startExport = () => { + if (mode === 'async') { + setPhase('confirmed'); + return; + } + setPhase('preparing'); + mockTimerRef.current = setTimeout(() => { + triggerMockDownload(); + setPhase('done'); + }, 10000); + }; + + const triggerMockDownload = () => { + const emptyZip = new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]); + const url = URL.createObjectURL(new Blob([emptyZip], {type: 'application/zip'})); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'ghost-export.zip'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }; + + useEffect(() => { + return () => { + clearTimeout(mockTimerRef.current); + clearTimeout(resetTimerRef.current); + }; + }, []); + + return ( + + + {phase === 'select' && ( + <> + + Export data + + {mode === 'async' + ? 'Choose what to include. Your export will be prepared in the background and a download link sent to you by email.' + : <> + Your export will be downloaded as a single zip file.{' '} + Images, videos and files are not included.{' '} + Learn more → + } + + +
+ {visibleComponents.map(component => ( + + ))} +
+ + + + + + )} + + {phase === 'confirmed' && ( + <> + + + Exporting data… + + + + A link to download your data will be sent to your email {email} once + the export is complete. The link will be valid for 7 days. You can now close this window. + + + + + + )} + + {phase === 'preparing' && ( + <> + + + Preparing your export… + + + + Your download will start automatically when it’s ready. Keep this window open. + + + + + + )} + + {phase === 'done' && ( + <> + + + Export downloaded + + + + Your export has been downloaded as a zip file. + + + + + + )} +
+
+ ); +}; + +export default ExportAllModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx index 1b5ed1726a0..f487eb9dd42 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx @@ -1,14 +1,23 @@ +import ExportAllModal, {type ExportMode} from './export-all-modal'; import React from 'react'; +import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {Button, LoadingIndicator} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {downloadAllContent} from '@tryghost/admin-x-framework/api/db'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const MigrationToolsExport: React.FC = () => { const [isExportingPosts, setIsExportingPosts] = React.useState(false); + const [exportAllOpen, setExportAllOpen] = React.useState(false); const handleError = useHandleError(); + const hasSelfServeArchives = useFeatureFlag('selfServeArchives'); + const {data: configData} = useBrowseConfig(); + const hostSettings = configData?.config?.hostSettings as {export?: {generate_archive_url?: string}} | undefined; + const mode: ExportMode = hostSettings?.export?.generate_archive_url ? 'async' : 'sync'; + const exportPosts = async () => { if (isExportingPosts) { return; @@ -25,6 +34,19 @@ const MigrationToolsExport: React.FC = () => { } }; + if (hasSelfServeArchives) { + return ( + <> +
+ +
+ + + ); + } + return (
diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx index 8febf9c3b9e..83566954d5d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx @@ -1,16 +1,17 @@ import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import UniversalImportModal from './universal-import-modal'; import {Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; const MigrationToolsImport: React.FC = () => { const {updateRoute} = useSettingsNavigation(); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); const handleImportContent = () => { - NiceModal.show(UniversalImportModal); + setIsImportModalOpen(true); }; const importers = [ @@ -31,6 +32,7 @@ const MigrationToolsImport: React.FC = () => { {importer.title} ))} + {isImportModalOpen && setIsImportModalOpen(false)} />}
); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx new file mode 100644 index 00000000000..78825bf3f8c --- /dev/null +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -0,0 +1,139 @@ +import {useState} from 'react'; +import UniversalImportModal from '@/settings/app/components/settings/advanced/migration-tools/universal-import-modal'; +import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; + +const {mockImportContent, mockImportContentCSV, mockUseFeatureFlag, mockHandleError} = vi.hoisted(() => ({ + mockImportContent: vi.fn(), + mockImportContentCSV: vi.fn(), + mockUseFeatureFlag: vi.fn(), + mockHandleError: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework/api/db', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/api/db'); + return {...actual, useImportContent: () => ({mutateAsync: mockImportContent})}; +}); + +vi.mock('@tryghost/admin-x-framework/api/posts', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/api/posts'); + return {...actual, useImportContentCSV: () => ({mutateAsync: mockImportContentCSV})}; +}); + +vi.mock('@tryghost/admin-x-framework/hooks', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/hooks'); + return {...actual, useHandleError: () => mockHandleError}; +}); + +vi.mock('@/settings/app/hooks/use-feature-flag', () => ({ + default: (flag: string) => mockUseFeatureFlag(flag) +})); + +describe('UniversalImportModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockImportContent.mockResolvedValue({}); + mockImportContentCSV.mockResolvedValue({}); + }); + + const showModal = () => { + const ModalHarness = () => { + const [isOpen, setIsOpen] = useState(true); + + return isOpen ? setIsOpen(false)} /> : null; + }; + + render(); + }; + + const fileInput = async () => await screen.findByTestId('import-file'); + + const description = async () => await screen.findByTestId('import-file-description'); + + const dropFile = async (file: File) => { + const input = await fileInput(); + + // act() flushes react-dropzone's async file processing, so a + // "not called" assertion afterwards can't pass vacuously + await act(async () => { + fireEvent.change(input, {target: {files: [file]}}); + }); + }; + + it('reads the csvContentImporter flag', async () => { + mockUseFeatureFlag.mockReturnValue(false); + showModal(); + + await fileInput(); + expect(mockUseFeatureFlag).toHaveBeenCalledWith('csvContentImporter'); + }); + + it('sends JSON files to the db import when csvContentImporter is disabled', async () => { + mockUseFeatureFlag.mockReturnValue(false); + showModal(); + + expect(await description()).toHaveTextContent(/Select any JSON or zip file/); + + const file = new File(['{}'], 'export.json', {type: 'application/json'}); + await dropFile(file); + + await waitFor(() => expect(mockImportContent).toHaveBeenCalledWith(file)); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + expect(await screen.findByTestId('confirmation-modal')).toHaveTextContent('Import in progress'); + }); + + it('rejects CSV files when csvContentImporter is disabled', async () => { + mockUseFeatureFlag.mockReturnValue(false); + showModal(); + + const input = await fileInput(); + expect(input).toHaveAttribute('accept', expect.not.stringContaining('.csv')); + + await dropFile(new File(['title\nHello'], 'posts.csv', {type: 'text/csv'})); + + expect(mockImportContent).not.toHaveBeenCalled(); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + expect(screen.getByTestId('universal-import-modal')).toBeInTheDocument(); + }); + + it('sends CSV files to the posts upload endpoint when csvContentImporter is enabled', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + expect(await description()).toHaveTextContent(/Select any JSON, zip or CSV file/); + + const input = await fileInput(); + expect(input).toHaveAttribute('accept', expect.stringContaining('.csv')); + + const file = new File(['title\nHello'], 'posts.csv', {type: 'text/csv'}); + await dropFile(file); + + await waitFor(() => expect(mockImportContentCSV).toHaveBeenCalledWith(file)); + expect(mockImportContent).not.toHaveBeenCalled(); + expect(await screen.findByTestId('confirmation-modal')).toHaveTextContent('Import in progress'); + }); + + it('still sends JSON files to the db import when csvContentImporter is enabled', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + const file = new File(['{}'], 'export.json', {type: 'application/json'}); + await dropFile(file); + + await waitFor(() => expect(mockImportContent).toHaveBeenCalledWith(file)); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + }); + + it('surfaces an error and keeps the modal open when the import fails', async () => { + const error = new Error('Import failed'); + mockUseFeatureFlag.mockReturnValue(true); + mockImportContentCSV.mockRejectedValue(error); + showModal(); + + await dropFile(new File(['title\nHello'], 'posts.csv', {type: 'text/csv'})); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + expect(screen.getByTestId('universal-import-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmation-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx index 6798ef96292..acb85bbbca0 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,18 +1,25 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; +import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {Button, Dropzone} from '@tryghost/shade/components'; import {ExternalLink} from 'lucide-react'; import {Inline} from '@tryghost/shade/primitives'; import {SettingsModal} from '@tryghost/shade/patterns'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useImportContent} from '@tryghost/admin-x-framework/api/db'; +import {useImportContentCSV} from '@tryghost/admin-x-framework/api/posts'; -const UniversalImportModal: React.FC = () => { - const modal = useModal(); +const UniversalImportModal: React.FC<{onClose: () => void}> = ({onClose}) => { const {mutateAsync: importContent} = useImportContent(); + const {mutateAsync: importContentCSV} = useImportContentCSV(); + const csvContentImporter = useFeatureFlag('csvContentImporter'); const [uploading, setUploading] = useState(false); const handleError = useHandleError(); + const {confirm} = useConfirmation(); + + const acceptedTypes: React.ComponentProps['accept'] = csvContentImporter + ? {'application/json': ['.json'], 'application/zip': ['.zip'], 'text/csv': ['.csv']} + : {'application/json': ['.json'], 'application/zip': ['.zip']}; return ( { Learn more
+ {installedModal && setInstalledModal(null)} />}
); }; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx index 805ca7e8979..3f7de204e3e 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx @@ -1,8 +1,5 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; @@ -12,8 +9,10 @@ import {type Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyThe import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; interface ThemeActionProps { theme: Theme; @@ -59,7 +58,10 @@ const ThemeActions: React.FC = ({ const {refreshActiveThemeData} = useCustomFonts(); const handleError = useHandleError(); const {route, updateRoute} = useSettingsNavigation(); + const upgradeRoute = useUpgradeRoute(); const {checkThemeLimitError} = useCheckThemeLimitError(); + const {confirm, showLimit} = useConfirmation(); + const [activationErrors, setActivationErrors] = useState(null); const handleActivate = async () => { try { @@ -73,19 +75,8 @@ const ThemeActions: React.FC = ({ } else { handleError(e); } - const title = 'Theme not activated'; - const prompt = <>This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.; - if (fatalErrors) { - NiceModal.show(InvalidThemeModal, { - title, - prompt, - fatalErrors, - onRetry: async (modal) => { - modal?.remove(); - handleActivate(); - } - }); + setActivationErrors(fatalErrors); } } }; @@ -96,7 +87,7 @@ const ThemeActions: React.FC = ({ }; const handleDelete = async () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure you want to delete this?', prompt: ( <> @@ -131,9 +122,9 @@ const ThemeActions: React.FC = ({ const limitError = await checkThemeLimitError('.'); if (limitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: limitError, - onOk: () => updateRoute({route: '/pro', isExternal: true}) + onOk: () => updateRoute({route: upgradeRoute, isExternal: true}) }); return; } @@ -172,6 +163,18 @@ const ThemeActions: React.FC = ({ )} + {activationErrors && ( + This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.} + title='Theme not activated' + onClose={() => setActivationErrors(null)} + onRetry={async () => { + setActivationErrors(null); + handleActivate(); + }} + /> + )}
); }; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx index 7838b262849..1cd54100588 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx @@ -1,29 +1,31 @@ -import NiceModal from '@ebay/nice-modal-react'; import React, {type ReactNode} from 'react'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {formatNumber} from '@tryghost/shade/utils'; export type {FatalErrors} from './theme-validation-details'; -const InvalidThemeModal: React.FC<{ +export type InvalidThemeModalProps = { title: string - prompt: ReactNode + prompt?: ReactNode fatalErrors?: FatalErrors; validationDetailsDefaultOpen?: boolean; onRetry?: (modal?: { remove: () => void; }) => void | Promise; -}> = ({title, prompt, fatalErrors, validationDetailsDefaultOpen, onRetry}) => { +}; + +const InvalidThemeModal: React.FC void}> = ({title, prompt, fatalErrors, validationDetailsDefaultOpen, onRetry, onClose}) => { const {data: configData} = useBrowseConfig(); const defaultOpen = validationDetailsDefaultOpen ?? configData?.config?.environment === 'development'; const {blockingProblems, secondaryProblems, stringErrors} = getIssuesFromFatalErrors(fatalErrors); const blockingIssueCount = blockingProblems.length + stringErrors.length; - const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.; + const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${formatNumber(blockingIssueCount)} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.; return
@@ -47,7 +49,8 @@ const InvalidThemeModal: React.FC<{ stickyFooter={true} title={title} onOk={onRetry} + onRemove={onClose} />; }; -export default NiceModal.create(InvalidThemeModal); +export default InvalidThemeModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx index 3e21887985b..50ba523125e 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx @@ -1,12 +1,11 @@ import CodeMirror, {EditorView} from '@uiw/react-codemirror'; import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import ThemeEditorConfirmModal from './theme-editor-confirm-modal'; import ThemeEditorInputModal from './theme-editor-input-modal'; import ThemeEditorToolbar from './theme-editor-toolbar'; import ThemeFileTree from './theme-file-tree'; -import ThemeInstalledModal from './theme-installed-modal'; +import ThemeInstalledModal, {type ThemeInstalledModalProps} from './theme-installed-modal'; import {TextWrap, Undo2} from 'lucide-react'; import { cloneThemeFiles, @@ -27,6 +26,7 @@ import {toast} from 'sonner'; import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; +import {formatNumber} from '@tryghost/shade/utils'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import type {SelectedNode} from './theme-file-tree'; import type {ThemeEditorConfirmModalProps} from './theme-editor-confirm-modal'; @@ -185,6 +185,16 @@ type UploadSizeLimitError = { }; }; +type ThemeEditorDialogRequest = { + type: 'confirmation'; + props: ThemeEditorConfirmModalProps; + resolve: (result: boolean) => void; +} | { + type: 'input'; + props: ThemeEditorInputModalProps; + resolve: (result: string | null) => void; +}; + const UPLOAD_SIZE_LIMIT_TITLES: Record = { COMPRESSED_TOO_LARGE: 'Theme too large to upload', ENTRY_TOO_LARGE: 'File too large', @@ -249,6 +259,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const [isSaving, setIsSaving] = useState(false); const [loadError, setLoadError] = useState(null); const [isTextWrapEnabled, setIsTextWrapEnabled] = useState(false); + const [dialogRequest, setDialogRequest] = useState(null); + const dialogRequestRef = useRef(null); + const [saveErrors, setSaveErrors] = useState(null); + const [installedModal, setInstalledModal] = useState(null); const [editorExtensions, setEditorExtensions] = useState | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited>>>([]); useEffect(() => { @@ -369,44 +383,46 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { }; }, [isTextWrapEnabled, selectedFile]); - const requestConfirmation = async ({ - title, - prompt, - cancelLabel, - okLabel, - okVariant - }: ThemeEditorConfirmModalProps) => { - const confirmed = await NiceModal.show(ThemeEditorConfirmModal, { - title, - prompt, - cancelLabel, - okLabel, - okVariant - }) as boolean | undefined; - - return Boolean(confirmed); + const cancelPendingDialogRequest = useCallback(() => { + const request = dialogRequestRef.current; + + if (!request) { + return; + } + + dialogRequestRef.current = null; + + if (request.type === 'confirmation') { + request.resolve(false); + } else { + request.resolve(null); + } + }, []); + + const requestConfirmation = (props: ThemeEditorConfirmModalProps) => { + cancelPendingDialogRequest(); + + return new Promise((resolve) => { + const request: ThemeEditorDialogRequest = {type: 'confirmation', props, resolve}; + dialogRequestRef.current = request; + setDialogRequest(request); + }); }; - const requestInput = async ({ - title, - prompt, - fieldTitle, - initialValue, - placeholder, - cancelLabel, - okLabel - }: ThemeEditorInputModalProps) => { - return await NiceModal.show(ThemeEditorInputModal, { - title, - prompt, - fieldTitle, - initialValue, - placeholder, - cancelLabel, - okLabel - }) as string | null; + const requestInput = (props: ThemeEditorInputModalProps) => { + cancelPendingDialogRequest(); + + return new Promise((resolve) => { + const request: ThemeEditorDialogRequest = {type: 'input', props, resolve}; + dialogRequestRef.current = request; + setDialogRequest(request); + }); }; + useEffect(() => { + return () => cancelPendingDialogRequest(); + }, [cancelPendingDialogRequest]); + const closeEditor = async () => { if (changes.length > 0) { const shouldDiscard = await requestConfirmation({ @@ -430,10 +446,19 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { // each render — using a ref decouples that churn from the global listener. const handleSaveRef = useRef<() => void>(() => {}); + const hasOpenDialog = Boolean(dialogRequest || saveErrors || installedModal); + useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); + + if (hasOpenDialog) { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + void handleSaveRef.current(); return; } @@ -442,6 +467,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { return; } + if (hasOpenDialog) { + return; + } + event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); @@ -452,7 +481,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { return () => { window.removeEventListener('keydown', handleKeydown, true); }; - }, []); + }, [hasOpenDialog]); const ensurePathExpanded = (path: string) => { setExpandedDirectories((current) => { @@ -645,7 +674,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const confirmed = await requestConfirmation({ title: 'Delete folder', - prompt: <>Delete {matchingPaths.length} file{matchingPaths.length === 1 ? '' : 's'} from {selectedNode.path}?, + prompt: <>Delete {formatNumber(matchingPaths.length)} file{matchingPaths.length === 1 ? '' : 's'} from {selectedNode.path}?, okLabel: 'Delete', okVariant: 'destructive' }); @@ -728,7 +757,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const themeExists = themesData?.themes.some(theme => theme.name === nextThemeName) || false; const confirmMessage = isSaveAs ? `Save your edits as "${nextThemeName}"?` - : `Upload ${changes.length} changed file${changes.length === 1 ? '' : 's'} and replace "${previousThemeName}"?`; + : `Upload ${formatNumber(changes.length)} changed file${changes.length === 1 ? '' : 's'} and replace "${previousThemeName}"?`; const confirmedSave = await requestConfirmation({ title: isSaveAs ? 'Save theme as new copy' : 'Update theme', @@ -781,10 +810,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { if (!response.ok) { if (response.status === 422 && data?.errors) { - NiceModal.show(InvalidThemeModal, { - title: 'Theme not saved', - fatalErrors: data.errors as FatalErrors - }); + setSaveErrors(data.errors as FatalErrors); return; } @@ -812,7 +838,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { await queryClient.invalidateQueries({queryKey: ['ThemesResponseType']}); if (isSaveAs || uploadedTheme.errors?.length || uploadedTheme.warnings?.length) { - NiceModal.show(ThemeInstalledModal, { + setInstalledModal({ title: isSaveAs ? 'Theme saved' : 'Theme updated', prompt: <>{uploadedTheme.name} saved successfully., installedTheme: uploadedTheme @@ -838,7 +864,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const selectedFileStatus = selectedFile ? changesMap.get(selectedFile.path) : null; - return ( + return (<>
= ({themeName}) => {
- ); + {dialogRequest?.type === 'confirmation' && ( + { + if (dialogRequestRef.current !== dialogRequest) { + return; + } + + dialogRequestRef.current = null; + setDialogRequest(null); + dialogRequest.resolve(result); + }} + /> + )} + {dialogRequest?.type === 'input' && ( + { + if (dialogRequestRef.current !== dialogRequest) { + return; + } + + dialogRequestRef.current = null; + setDialogRequest(null); + dialogRequest.resolve(result); + }} + /> + )} + {saveErrors && setSaveErrors(null)} />} + {installedModal && setInstalledModal(null)} />} + ); }; export default ThemeCodeEditorModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx index 296457de574..3a01cc3e612 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import {SettingsModal} from '@tryghost/shade/patterns'; import type {ButtonProps} from '@tryghost/shade/components'; @@ -11,20 +10,14 @@ export type ThemeEditorConfirmModalProps = { okVariant?: ButtonProps['variant']; }; -const ThemeEditorConfirmModal = NiceModal.create(({ +const ThemeEditorConfirmModal: React.FC void}> = ({ title, prompt, cancelLabel = 'Cancel', okLabel = 'OK', - okVariant = 'default' + okVariant = 'default', + onResolve }) => { - const modal = useModal(); - - const closeWithResult = (result: boolean) => { - modal.resolve(result); - modal.remove(); - }; - return ( (( testId='theme-editor-confirm-modal' title={title} width={540} - onCancel={() => closeWithResult(false)} - onOk={() => closeWithResult(true)} + onCancel={() => onResolve(false)} + onClose={() => onResolve(false)} + onOk={() => onResolve(true)} >
{prompt}
); -}); +}; export default ThemeEditorConfirmModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx index f619909c957..a58c4d68ce3 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import {Field, FieldLabel, Input} from '@tryghost/shade/components'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -13,23 +12,18 @@ export type ThemeEditorInputModalProps = { okLabel?: string; }; -const ThemeEditorInputModal = NiceModal.create(({ +const ThemeEditorInputModal: React.FC void}> = ({ title, prompt, fieldTitle, initialValue, placeholder, cancelLabel = 'Cancel', - okLabel = 'Continue' + okLabel = 'Continue', + onResolve }) => { - const modal = useModal(); const [value, setValue] = useState(initialValue); - const closeWithResult = (result: string | null) => { - modal.resolve(result); - modal.remove(); - }; - return ( (({ testId='theme-editor-input-modal' title={title} width={540} - onCancel={() => closeWithResult(null)} - onOk={() => closeWithResult(value)} + onCancel={() => onResolve(null)} + onClose={() => onResolve(null)} + onOk={() => onResolve(value)} >
{prompt} @@ -51,6 +46,6 @@ const ThemeEditorInputModal = NiceModal.create(({
); -}); +}; export default ThemeEditorInputModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx index 7ab99b48e7a..715fe53a898 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal from '@ebay/nice-modal-react'; import React, {type ReactNode} from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; @@ -9,13 +8,15 @@ import {toast} from 'sonner'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -const ThemeInstalledModal: React.FC<{ +export type ThemeInstalledModalProps = { title: string prompt: ReactNode installedTheme: InstalledTheme; validationDetailsDefaultOpen?: boolean; onActivate?: () => void; -}> = ({title, installedTheme, validationDetailsDefaultOpen, onActivate}) => { +}; + +const ThemeInstalledModal: React.FC void}> = ({title, installedTheme, validationDetailsDefaultOpen, onActivate, onClose}) => { const {mutateAsync: activateTheme} = useActivateTheme(); const {refreshActiveThemeData} = useCustomFonts(); const handleError = useHandleError(); @@ -83,12 +84,14 @@ const ThemeInstalledModal: React.FC<{ toast.success('Theme activated', {description:
{updatedTheme.name} is now your active theme.
}); } catch (e) { handleError(e); + return; } } onActivate?.(); activateModal?.remove(); }} + onRemove={onClose} />; }; -export default NiceModal.create(ThemeInstalledModal); +export default ThemeInstalledModal; diff --git a/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx b/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx new file mode 100644 index 00000000000..204b1af725c --- /dev/null +++ b/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx @@ -0,0 +1,33 @@ +import {renderHook} from '@testing-library/react'; +import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; + +const mockConfig = vi.fn(); + +vi.mock('@/settings/app/components/providers/global-data-provider', () => ({ + useGlobalData: () => ({config: mockConfig()}) +})); + +describe('useUpgradeRoute', () => { + const routeFor = (billing?: Record) => { + mockConfig.mockReturnValue({hostSettings: billing ? {billing} : {}}); + return renderHook(() => useUpgradeRoute()).result.current; + }; + + it('sends people to Ghost(Pro) billing when the host has not configured anything', () => { + expect(routeFor()).toBe('/pro'); + expect(routeFor({})).toBe('/pro'); + }); + + // hostSettings holds an href, updateRoute takes a route + it('turns a hash href into a route', () => { + expect(routeFor({upgradeUrl: '#/pro/billing/plans'})).toBe('/pro/billing/plans'); + }); + + it('leaves an absolute billing URL alone', () => { + expect(routeFor({upgradeUrl: 'https://billing.example.com/upgrade'})).toBe('https://billing.example.com/upgrade'); + }); + + it('leaves a route without a hash alone', () => { + expect(routeFor({upgradeUrl: '/billing'})).toBe('/billing'); + }); +}); diff --git a/apps/admin/src/settings/app/hooks/use-upgrade-route.ts b/apps/admin/src/settings/app/hooks/use-upgrade-route.ts new file mode 100644 index 00000000000..25e76816f2d --- /dev/null +++ b/apps/admin/src/settings/app/hooks/use-upgrade-route.ts @@ -0,0 +1,8 @@ +import {upgradeRoute} from '@tryghost/admin-x-framework/api/config'; +import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; + +export function useUpgradeRoute() { + const {config} = useGlobalData(); + + return upgradeRoute(config); +} diff --git a/apps/admin/src/settings/general/seo-meta.acceptance.test.tsx b/apps/admin/src/settings/general/seo-meta.acceptance.test.tsx index 2c425bfc79e..7af3a970c83 100644 --- a/apps/admin/src/settings/general/seo-meta.acceptance.test.tsx +++ b/apps/admin/src/settings/general/seo-meta.acceptance.test.tsx @@ -12,12 +12,10 @@ function fakeImageUpload(url: string): void { } describe("SEO meta settings", () => { - it("toggles LLM structured data when the feature flag is on", async () => { + it("toggles LLM structured data", async () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); - await renderAdminApp("/settings", { - labs: { llmsTxt: true }, - }); + await renderAdminApp("/settings"); const section = settingsScreen.seoMeta(); const toggle = section.getByLabelText("Enable structured data for LLMs and AI search engines"); @@ -33,8 +31,7 @@ describe("SEO meta settings", () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); await renderAdminApp("/settings", { - labs: { llmsTxt: true }, - boot: { browseSettings: { response: settingsResponse({ labs: { llmsTxt: true }, settings: { llms_enabled: false } }) } }, + boot: { browseSettings: { response: settingsResponse({ settings: { llms_enabled: false } }) } }, }); const section = settingsScreen.seoMeta(); @@ -46,15 +43,6 @@ describe("SEO meta settings", () => { await expect(settingsApi).toHaveEditedSettings([{ key: "llms_enabled", value: true }]); }); - it("hides LLM structured data when the feature flag is off", async () => { - fakeSettingsScreens(); - await renderAdminApp("/settings"); - - const section = settingsScreen.seoMeta(); - await expect.element(section.getByLabelText("Meta title")).toBeVisible(); - await expect(section.getByLabelText("Enable structured data for LLMs and AI search engines")).toHaveCount(0); - }); - it("edits search metadata", async () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); diff --git a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx index ac1e2044275..a2eb7229d92 100644 --- a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it} from "vitest"; -import {page} from "vitest/browser"; +import {page, userEvent} from "vitest/browser"; import {configResponse, fakeAdminEndpoint, fakeSettingsScreens, renderAdminApp, settingsResponse} from "@test-utils/acceptance"; import {settingsScreen} from "@/settings/settings.screen"; @@ -14,7 +14,7 @@ const companyField = { }; const archivedField = { - key: "old-hobby", + key: "old_hobby", name: "Old hobby", type: "short_text", status: "archived", @@ -85,7 +85,7 @@ describe("Custom fields", () => { fakeSettingsScreens(); fakeCustomFields(); const createApi = fakeAdminEndpoint("POST", "/members/custom_fields/", { - members_custom_fields: [{...companyField, key: "job-title", name: "Job Title"}], + members_custom_fields: [{...companyField, key: "job_title", name: "Job Title"}], }); await renderAdminApp("/settings", {boot: customFieldsBoot()}); @@ -201,7 +201,7 @@ describe("Custom fields", () => { fakeSettingsScreens(); const manyFields = Array.from({length: 7}, (_, index) => ({ ...companyField, - key: `field-${index}`, + key: `field_${index}`, name: `Field ${index}`, })); fakeCustomFields(manyFields); @@ -225,7 +225,7 @@ describe("Custom fields", () => { fakeSettingsScreens(); const initialFields = Array.from({length: 6}, (_, index) => ({ ...companyField, - key: `field-${index}`, + key: `field_${index}`, name: `Field ${index}`, })); fakeCustomFieldsWithCreate(initialFields, {...companyField, key: "newest", name: "Newest"}); @@ -244,17 +244,205 @@ describe("Custom fields", () => { await expect(rows).toHaveCount(7); await expect.element(rows.last()).toHaveTextContent("Newest"); - // Wait for the modal to finish closing (Save holds it open ~500ms for - // its saving state). Ending the test earlier leaves that removal - // pending, and it would fire into the NEXT test's freshly-opened - // modal — NiceModal keys modals by component and dispatches globally. + // Save holds the modal open for its ~500ms saving state. Verify the + // async save has completed and the parent-controlled modal unmounts. await expect(modal).toHaveCount(0); }); + it("reorders a field by dragging it, sending the whole list in its new order", async () => { + fakeSettingsScreens(); + const shirtField = {...companyField, key: "shirt_size", name: "Shirt size"}; + const nicknameField = {...companyField, key: "nickname", name: "Nickname"}; + let currentFields = [companyField, shirtField, nicknameField]; + const browseApi = fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => ({members_custom_fields: currentFields})); + const reorderApi = fakeAdminEndpoint("PUT", "/members/custom_fields/", (request) => { + const order = (request.body as {members_custom_fields: {key: string}[]}).members_custom_fields; + currentFields = order.map(({key}) => currentFields.find(field => field.key === key)!); + return {members_custom_fields: currentFields}; + }); + await renderAdminApp("/settings", {boot: customFieldsBoot()}); + + const rows = settingsScreen.customFields().getByTestId("custom-field-list-item"); + await expect(rows).toHaveCount(3); + const browsesBeforeDrag = browseApi.requests.length; + + // A real pointer drag of the handle onto the first row, which is what dnd-kit + // listens for. Note this cannot be driven from the keyboard: the sortable list + // does not wire dnd-kit's sortable coordinate getter, so arrow keys move a + // lifted item by a flat 25px and it never reaches the next row. + const handle = settingsScreen.customFields().getByLabelText("Reorder Nickname"); + await expect.element(handle).toBeVisible(); + await userEvent.dragAndDrop(handle, rows.first()); + + // The whole list goes up, in the order the drag left it, keys only — order is a + // property of the list, so a field never carries a rank. Nickname was dropped on + // the first row, so it takes that place and the rest shuffle down. + await expect.poll(() => reorderApi.lastRequest?.body).toEqual({ + members_custom_fields: [{key: "nickname"}, {key: "company"}, {key: "shirt_size"}], + }); + + // The row stays where it was dropped rather than snapping back and jumping when + // the response lands. + await expect.element(rows.first()).toHaveTextContent("Nickname"); + + // And the response settles it, so the list is never re-read. A reorder only + // succeeds when it named exactly the fields the site has, so its response says + // everything a fetch would and the round-trip is not repeated. + expect(browseApi.requests.length).toBe(browsesBeforeDrag); + }); + + it("leaves the dragged field where it was dropped while the request is in flight", async () => { + fakeSettingsScreens(); + const nicknameField = {...companyField, key: "nickname", name: "Nickname"}; + let currentFields = [companyField, nicknameField]; + fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => ({members_custom_fields: currentFields})); + + // The PUT is held open so the assertion below lands while the request is still + // outstanding. Without the local move, the list would revert to the server's + // order the moment the drag ends and the row would snap back under the cursor. + let releasePut: () => void = () => {}; + const putHeld = new Promise((resolve) => { + releasePut = resolve; + }); + fakeAdminEndpoint("PUT", "/members/custom_fields/", async (request) => { + await putHeld; + const order = (request.body as {members_custom_fields: {key: string}[]}).members_custom_fields; + currentFields = order.map(({key}) => currentFields.find(field => field.key === key)!); + return {members_custom_fields: currentFields}; + }); + await renderAdminApp("/settings", {boot: customFieldsBoot()}); + + const rows = settingsScreen.customFields().getByTestId("custom-field-list-item"); + await expect(rows).toHaveCount(2); + + await userEvent.dragAndDrop( + settingsScreen.customFields().getByLabelText("Reorder Nickname"), + rows.first() + ); + + await expect.element(rows.first()).toHaveTextContent("Nickname"); + + // Let the request finish so it isn't left hanging. Whether the row survives the + // settled response is the drag test's job — repeating the assertion here would + // resolve against this same pre-release state and prove nothing. + releasePut(); + }); + + it("puts the list back and says why when the order is refused", async () => { + fakeSettingsScreens(); + const nicknameField = {...companyField, key: "nickname", name: "Nickname"}; + // What the server has, and what this screen does not know about yet: a third + // field a colleague added since the page loaded. + const serverFields = [companyField, nicknameField, {...companyField, key: "added_elsewhere", name: "Added elsewhere"}]; + let browses = 0; + fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => { + browses += 1; + // The first load predates the colleague's field; a refetch sees it. + return {members_custom_fields: browses === 1 ? [companyField, nicknameField] : serverFields}; + }); + fakeAdminEndpoint("PUT", "/members/custom_fields/", { + errors: [{ + type: "ValidationError", + message: "The order must name every custom field.", + context: "\"added_elsewhere\" is missing from the order. Reload and try again.", + }], + }, {status: 422}); + await renderAdminApp("/settings", {boot: customFieldsBoot()}); + + const rows = settingsScreen.customFields().getByTestId("custom-field-list-item"); + await expect(rows).toHaveCount(2); + + await userEvent.dragAndDrop( + settingsScreen.customFields().getByLabelText("Reorder Nickname"), + rows.first() + ); + + // The server's own words reach the publisher, not a generic failure: they name + // the field and say what to do about it. And the list goes back to the order the + // server holds rather than keeping an arrangement that was refused. + await expect.element(page.getByText(/is missing from the order/)).toBeVisible(); + await expect.element(rows.first()).toHaveTextContent("Company"); + }); + + it("sends the archived fields too, keeping their places in the order", async () => { + fakeSettingsScreens(); + const shirtField = {...companyField, key: "shirt_size", name: "Shirt size"}; + // Archived between the two active fields, which is the arrangement that catches a + // move applied to the visible tab instead of the whole list. + let currentFields = [companyField, archivedField, shirtField]; + fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => ({members_custom_fields: currentFields})); + const reorderApi = fakeAdminEndpoint("PUT", "/members/custom_fields/", (request) => { + const order = (request.body as {members_custom_fields: {key: string}[]}).members_custom_fields; + currentFields = order.map(({key}) => currentFields.find(field => field.key === key)!); + return {members_custom_fields: currentFields}; + }); + await renderAdminApp("/settings", {boot: customFieldsBoot()}); + + const rows = settingsScreen.customFields().getByTestId("custom-field-list-item"); + await expect(rows).toHaveCount(2); + + await userEvent.dragAndDrop( + settingsScreen.customFields().getByLabelText("Reorder Shirt size"), + rows.first() + ); + + // The archived field is named even though it was never on screen: an order states + // the whole list, and the API refuses one that leaves a field out. + await expect.poll(() => reorderApi.lastRequest?.body).toEqual({ + members_custom_fields: [{key: "shirt_size"}, {key: "company"}, {key: "old_hobby"}], + }); + }); + + + it("reorders from a collapsed list without disturbing the fields behind Show all", async () => { + fakeSettingsScreens(); + let currentFields = Array.from({length: 7}, (_, index) => ({ + ...companyField, + key: `field_${index}`, + name: `Field ${index}`, + })); + fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => ({members_custom_fields: currentFields})); + const reorderApi = fakeAdminEndpoint("PUT", "/members/custom_fields/", (request) => { + const order = (request.body as {members_custom_fields: {key: string}[]}).members_custom_fields; + currentFields = order.map(({key}) => currentFields.find(field => field.key === key)!); + return {members_custom_fields: currentFields}; + }); + await renderAdminApp("/settings", {boot: customFieldsBoot()}); + + const rows = settingsScreen.customFields().getByTestId("custom-field-list-item"); + await expect(rows).toHaveCount(5); + + await userEvent.dragAndDrop( + settingsScreen.customFields().getByLabelText("Reorder Field 2"), + rows.first() + ); + + // The two fields the publisher cannot see are still named, and still last. + await expect.poll(() => reorderApi.lastRequest?.body).toEqual({ + members_custom_fields: [ + {key: "field_2"}, {key: "field_0"}, {key: "field_1"}, {key: "field_3"}, + {key: "field_4"}, {key: "field_5"}, {key: "field_6"}, + ], + }); + }); + + it("does not offer dragging on the archived tab", async () => { + fakeSettingsScreens(); + fakeCustomFields([companyField, archivedField]); + await renderAdminApp("/settings", {boot: customFieldsBoot()}); + + // An archived field holds its place in the order, but there is nowhere to see + // it, so there is nothing to drag it through. + await settingsScreen.customFields().getByRole("tab", {name: "Archived"}).click(); + + await expect(settingsScreen.customFields().getByTestId("custom-field-list-item")).toHaveCount(1); + await expect(settingsScreen.customFields().getByLabelText(/^Reorder /)).toHaveCount(0); + }); + it("permanently deletes an archived field from the header menu, after a heavy warning", async () => { fakeSettingsScreens(); const customFieldsApi = fakeCustomFields([companyField, archivedField]); - const deleteApi = fakeAdminEndpoint("DELETE", "/members/custom_fields/old-hobby/", {}); + const deleteApi = fakeAdminEndpoint("DELETE", "/members/custom_fields/old_hobby/", {}); await renderAdminApp("/settings", {boot: customFieldsBoot()}); await settingsScreen.customFields().getByRole("tab", {name: "Archived"}).click(); @@ -302,7 +490,7 @@ describe("Custom fields", () => { it("reactivates an archived field after confirmation, as a status edit", async () => { fakeSettingsScreens(); const customFieldsApi = fakeCustomFields([companyField, archivedField]); - const editApi = fakeAdminEndpoint("PUT", "/members/custom_fields/old-hobby/", { + const editApi = fakeAdminEndpoint("PUT", "/members/custom_fields/old_hobby/", { members_custom_fields: [{...archivedField, status: "active"}], }); await renderAdminApp("/settings", {boot: customFieldsBoot()}); diff --git a/apps/admin/src/settings/membership/tiers.acceptance.test.tsx b/apps/admin/src/settings/membership/tiers.acceptance.test.tsx index b91adcad088..723380acb1c 100644 --- a/apps/admin/src/settings/membership/tiers.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/tiers.acceptance.test.tsx @@ -21,14 +21,24 @@ const supporterTier = tier({ benefits: ["Simple benefit"], }); -function stripeSettings() { - return settingsResponse({settings: { - stripe_connect_display_name: "Dummy", - stripe_connect_livemode: false, - stripe_connect_account_id: "acct_123", - stripe_connect_publishable_key: "pk_test_123", - stripe_connect_secret_key: "sk_test_123", - }}); +function stripeSettings(overrides: Parameters[0] = {}) { + return settingsResponse({ + ...overrides, + settings: { + stripe_connect_display_name: "Dummy", + stripe_connect_livemode: false, + stripe_connect_account_id: "acct_123", + stripe_connect_publishable_key: "pk_test_123", + stripe_connect_secret_key: "sk_test_123", + ...overrides.settings, + }, + }); +} + +function withoutSettings(keys: string[]) { + const response = stripeSettings({labs: {machinePayments: true}}); + response.settings = response.settings.filter(({key}) => !keys.includes(key)); + return response; } function stripeLimitConfig() { @@ -258,6 +268,30 @@ describe("Tier settings", () => { await expect.element(settingsScreen.stripeModal()).toBeVisible(); }); + it("shows agent payment controls when the lab is on and the backend has deployed them", async () => { + fakeSettingsScreens(); + fakeTiers([freeTier, supporterTier]); + await renderAdminApp("/settings", { + labs: {machinePayments: true}, + boot: {browseSettings: {response: stripeSettings({labs: {machinePayments: true}})}}, + }); + + await expect.element(settingsScreen.tiers().getByText("Accept payments from AI agents")).toBeVisible(); + await expect.element(settingsScreen.tiers().getByTestId("machine-payments-toggle")).toBeVisible(); + }); + + it("hides agent payment controls when the backend has not deployed them", async () => { + fakeSettingsScreens(); + fakeTiers([freeTier, supporterTier]); + await renderAdminApp("/settings", { + labs: {machinePayments: true}, + boot: {browseSettings: {response: withoutSettings(["machine_payments_enabled"])}}, + }); + + await expect(settingsScreen.tiers().getByText("Accept payments from AI agents")).toHaveCount(0); + await expect(settingsScreen.tiers().getByTestId("machine-payments-toggle")).toHaveCount(0); + }); + it("blocks direct access to Stripe connection when the plan limit applies", async () => { fakeSettingsScreens(); fakeTiers([freeTier, supporterTier]); diff --git a/apps/admin/src/settings/site/theme.acceptance.test.tsx b/apps/admin/src/settings/site/theme.acceptance.test.tsx index 2858508bd12..cd6ff991d1c 100644 --- a/apps/admin/src/settings/site/theme.acceptance.test.tsx +++ b/apps/admin/src/settings/site/theme.acceptance.test.tsx @@ -145,6 +145,70 @@ describe("Theme settings", () => { expect(uploadApi.requests).toHaveLength(1); }); + it("keeps the installed-theme dialog open when activation fails", async () => { + fakeThemeWorld(); + const uploaded = theme({ name: "mytheme" }); + fakeAdminEndpoint("POST", "/themes/upload/", { themes: [uploaded] }); + const activateApi = fakeAdminEndpoint("PUT", "/themes/mytheme/activate/", { + errors: [{ message: "Theme activation failed" }], + }, { status: 422 }); + const buffer = await archiveBuffer(); + await renderAdminApp("/settings/design/change-theme"); + + await settingsScreen.themeModal().getByRole("button", { name: "Upload theme" }).click(); + await uploadThemeFile(new File([buffer], "mytheme.zip", { type: "application/zip" })); + + const installedModal = settingsScreen.confirmationModal(); + await installedModal.getByRole("button", { name: "Activate theme" }).click(); + + await expect.element(installedModal).toBeVisible(); + await expect.element(settingsScreen.errorToast()).toHaveTextContent("Theme activation failed"); + await expect.poll(currentRoute).toBe("/settings/design/change-theme"); + expect(activateApi.requests).toHaveLength(1); + }); + + it("reports blocking upload errors and retries the upload from the error dialog", async () => { + fakeThemeWorld(); + const uploadApi = fakeAdminEndpoint("POST", "/themes/upload/", { + errors: [{ message: "Theme is not compatible or contains errors.", details: "Missing index.hbs" }], + }, { status: 422 }); + const buffer = await archiveBuffer(); + await renderAdminApp("/settings/design/change-theme"); + + await settingsScreen.themeModal().getByRole("button", { name: "Upload theme" }).click(); + await uploadThemeFile(new File([buffer], "theme.zip", { type: "application/zip" })); + + const errorModal = settingsScreen.confirmationModal(); + await expect.element(errorModal).toHaveTextContent("Theme not uploaded"); + await expect.element(errorModal).toHaveTextContent("Missing index.hbs"); + expect(uploadApi.requests).toHaveLength(1); + + await errorModal.getByRole("button", { name: "Retry" }).click(); + await expect.element(page.getByText("Click to select or drag & drop zip file", { exact: true })).toBeVisible(); + await expect(page.getByText("Theme not uploaded")).toHaveCount(0); + }); + + it("reports blocking activation errors for an installed theme", async () => { + fakeThemeWorld(); + const activateApi = fakeAdminEndpoint("PUT", "/themes/casper/activate/", { + errors: [{ message: "Theme is not compatible or contains errors.", details: "Missing post.hbs" }], + }, { status: 422 }); + await renderAdminApp("/settings/design/change-theme"); + + const modal = settingsScreen.themeModal(); + await modal.getByRole("tab", { name: "Installed" }).click(); + await installedTheme("casper").getByRole("button", { name: "Activate" }).click(); + + const errorModal = settingsScreen.confirmationModal(); + await expect.element(errorModal).toHaveTextContent("Theme not activated"); + await expect.element(errorModal).toHaveTextContent("Missing post.hbs"); + expect(activateApi.requests).toHaveLength(1); + + await errorModal.getByRole("button", { name: "Close" }).click(); + await expect(settingsScreen.confirmationModal()).toHaveCount(0); + await expect.element(modal).toBeVisible(); + }); + it("prevents uploading an archive over a built-in theme", async () => { fakeThemeWorld(); const uploadApi = fakeAdminEndpoint("POST", "/themes/upload/", { themes: [theme({ name: "source" })] }); @@ -190,6 +254,9 @@ describe("Theme settings", () => { const editor = await editorTextbox(); await editor.fill('{"name":"edition","version":"1.0.0"}\n'); window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true })); + await expect.element(settingsScreen.themeEditorConfirmModal()).toBeVisible(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true })); + await expect(settingsScreen.themeEditorConfirmModal()).toHaveCount(1); await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Replace theme" }).click(); await expect.element(settingsScreen.successToast()).toHaveTextContent(/Theme saved/i); @@ -218,6 +285,28 @@ describe("Theme settings", () => { await expect.element(settingsScreen.errorToast()).toHaveTextContent(/1\.0 MB/); }); + it("keeps the code editor open and reports blocking validation errors on save", async () => { + fakeThemeWorld(); + await fakeThemeDownload("edition"); + fakeAdminEndpoint("POST", "/themes/upload/", { + errors: [{ message: "Theme is not compatible or contains errors.", details: "Missing default.hbs" }], + }, { status: 422 }); + await renderAdminApp("/settings/theme/edit/edition"); + + const editor = await editorTextbox(); + await editor.fill('{"name":"edition","version":"1.0.0"}\n'); + await settingsScreen.themeCodeEditorModal().getByRole("button", { name: "Save" }).click(); + await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Replace theme" }).click(); + + const errorModal = settingsScreen.confirmationModal(); + await expect.element(errorModal).toHaveTextContent("Theme not saved"); + await expect.element(errorModal).toHaveTextContent("Missing default.hbs"); + await expect(errorModal.getByRole("button", { name: "Retry" })).toHaveCount(0); + await userEvent.keyboard("{Escape}"); + await expect(settingsScreen.confirmationModal()).toHaveCount(0); + await expect.element(settingsScreen.themeCodeEditorModal()).toBeVisible(); + }); + it("requires built-in themes to be saved under a valid new name", async () => { fakeThemeWorld(); await fakeThemeDownload("casper"); @@ -346,7 +435,8 @@ describe("Theme settings", () => { await editor.fill('{"name":"edition","version":"1.0.0"}\n'); await settingsScreen.themeCodeEditorModal().getByRole("button", { name: "Close" }).click(); await expect.element(settingsScreen.themeEditorConfirmModal()).toHaveTextContent(/unsaved theme changes/i); - await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Cancel" }).click(); + await userEvent.keyboard("{Escape}"); + await expect(settingsScreen.themeEditorConfirmModal()).toHaveCount(0); await expect.element(settingsScreen.themeCodeEditorModal()).toBeVisible(); await settingsScreen.themeCodeEditorModal().getByRole("button", { name: "Close" }).click(); await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Discard changes" }).click(); diff --git a/apps/admin/src/shared/filters/filter-codec-roundtrip.test.ts b/apps/admin/src/shared/filters/filter-codec-roundtrip.test.ts new file mode 100644 index 00000000000..c7e454dbc9b --- /dev/null +++ b/apps/admin/src/shared/filters/filter-codec-roundtrip.test.ts @@ -0,0 +1,148 @@ +import nql from '@tryghost/nql-lang'; +import {dateCodec, numberCodec, scalarCodec, setCodec, textCodec} from './filter-codecs'; +import {describe, expect, it} from 'vitest'; +import type {CodecContext, FilterCodec, FilterPredicate} from './filter-types'; + +// What a saved segment actually relies on: a predicate the publisher built in the UI +// is serialized to NQL, stored, and read back the next time the page loads. Every +// codec must survive that trip for every operator it advertises, whatever the value +// holds — the per-codec tests above assert one direction at a time against hand-written +// NQL, which is how the anchor readers in this engine and in member-filter-query.ts +// drifted apart without a test noticing. + +function context(key: string, timezone = 'UTC'): CodecContext { + return {key, pattern: key, params: {}, timezone}; +} + +function roundTrip(codec: FilterCodec, predicate: Omit, ctx: CodecContext) { + const clauses = codec.serialize({id: 'x', ...predicate}, ctx); + + if (!clauses) { + throw new Error(`serialize returned null for ${predicate.operator}`); + } + + const node = nql.parse(clauses.join('+'), {preserveRelativeDates: true}); + + return codec.parse(node, ctx); +} + +// Values chosen for what they do to the regex the text codec builds: `$` and `^` are +// the anchors the parse side reads operators from, so a value containing one is the +// case where escaping and anchoring have to be told apart. +const TEXT_VALUES = [ + 'Ghost', + 'two words', + '5$', + '$5', + '^caret', + 'a.b', + "it's", + 'back\\slash', + '-leading-hyphen', + 'trailing$' +]; + +const TEXT_OPERATORS = [ + 'is', + 'contains', + 'does-not-contain', + 'starts-with', + 'does-not-start-with', + 'ends-with', + 'does-not-end-with' +]; + +describe('codec round trips', () => { + describe('textCodec', () => { + const ctx = context('email'); + const codec = textCodec(); + + for (const operator of TEXT_OPERATORS) { + it.each(TEXT_VALUES)(`round-trips ${operator} %j`, (value) => { + expect(roundTrip(codec, {field: 'email', operator, values: [value]}, ctx)).toEqual({ + field: 'email', + operator, + values: [value] + }); + }); + } + }); + + describe('scalarCodec', () => { + const ctx = context('status'); + const codec = scalarCodec(); + + for (const operator of ['is', 'is-not']) { + it.each(['paid', 'two words', '-leading', "it's", 'a.b'])(`round-trips ${operator} %j`, (value) => { + expect(roundTrip(codec, {field: 'status', operator, values: [value]}, ctx)).toEqual({ + field: 'status', + operator, + values: [value] + }); + }); + } + }); + + describe('setCodec', () => { + const ctx = context('label'); + const codec = setCodec(); + + for (const operator of ['is-any', 'is-not-any']) { + it.each([ + [['vip']], + [['vip', 'founder']], + [['two words', 'a.b']] + ])(`round-trips ${operator} %j`, (values) => { + const parsed = roundTrip(codec, {field: 'label', operator, values}, ctx); + + expect(parsed?.operator).toBe(operator); + expect(parsed?.values).toEqual([...values].sort((left, right) => left.localeCompare(right))); + }); + } + }); + + describe('numberCodec', () => { + const ctx = context('email_count'); + const codec = numberCodec(); + + for (const operator of ['is', 'is-greater', 'is-or-greater', 'is-less', 'is-or-less']) { + it.each([0, 1, 42])(`round-trips ${operator} %j`, (value) => { + expect(roundTrip(codec, {field: 'email_count', operator, values: [value]}, ctx)).toEqual({ + field: 'email_count', + operator, + values: [value] + }); + }); + } + }); + + describe('dateCodec', () => { + const codec = dateCodec(); + + for (const timezone of ['UTC', 'Europe/Berlin', 'America/Los_Angeles']) { + for (const operator of ['is-less', 'is-or-less', 'is-greater', 'is-or-greater']) { + it(`round-trips ${operator} in ${timezone}`, () => { + const ctx = context('created_at', timezone); + + expect(roundTrip(codec, {field: 'created_at', operator, values: ['2026-08-11']}, ctx)).toEqual({ + field: 'created_at', + operator, + values: ['2026-08-11'] + }); + }); + } + + for (const operator of ['in-the-last', 'in-the-next']) { + it(`round-trips ${operator} in ${timezone}`, () => { + const ctx = context('created_at', timezone); + + expect(roundTrip(codec, {field: 'created_at', operator, values: [30]}, ctx)).toEqual({ + field: 'created_at', + operator, + values: [30] + }); + }); + } + } + }); +}); diff --git a/apps/admin/src/shared/filters/filter-codecs.ts b/apps/admin/src/shared/filters/filter-codecs.ts index 6cd4900bbcd..60f8498e82b 100644 --- a/apps/admin/src/shared/filters/filter-codecs.ts +++ b/apps/admin/src/shared/filters/filter-codecs.ts @@ -83,38 +83,53 @@ function serializeScalarValue(value: unknown, config?: CodecConfig): string { return String(value); } -function extractRegexOperator(pattern: RegExp, negated = false): string { - const source = pattern.source; - const startsWith = source.startsWith('^'); - const endsWith = source.endsWith('$'); - - if (startsWith && endsWith) { - return negated ? 'does-not-contain' : 'contains'; +// A trailing `$` anchors the regex only when it isn't itself escaped: a value holding a +// literal `$` (contains `5$`) reaches here as the source `5\$`, which still ends in `$`. +// An odd run of backslashes before it means it is escaped, so it is part of the value. +// A literal `^` is always escaped to `\^`, so a leading `^` needs no such check. +function hasEndAnchor(source: string): boolean { + if (!source.endsWith('$')) { + return false; } - if (startsWith) { - return negated ? 'does-not-start-with' : 'starts-with'; - } + let backslashes = 0; - if (endsWith) { - return negated ? 'does-not-end-with' : 'ends-with'; + for (let index = source.length - 2; index >= 0 && source[index] === '\\'; index -= 1) { + backslashes += 1; } - return negated ? 'does-not-contain' : 'contains'; + return backslashes % 2 === 0; } -function normalizeRegexValue(pattern: RegExp): string { - let source = pattern.source; +// Which anchors a regex carries, and the value left once they are removed. Read together +// rather than one at a time: the operator and the value are two answers to the same +// question, and deciding the anchors twice is how a value could keep a `$` the operator +// had already consumed. +function decomposeRegex(pattern: RegExp): {anchorStart: boolean; anchorEnd: boolean; value: string} { + const source = pattern.source; + const anchorStart = source.startsWith('^'); + const anchorEnd = hasEndAnchor(source); + const body = source.slice(anchorStart ? 1 : 0, anchorEnd ? -1 : undefined); - if (source.startsWith('^')) { - source = source.slice(1); + return { + anchorStart, + anchorEnd, + value: body.replace(/\\([\\.^$|?*+()[\]{}/-])/g, '$1') + }; +} + +// Anchors read back into the operator that would have produced them. Both anchors is not +// an operator this codec emits, so it falls back to the unanchored reading. +function anchorsToOperator(anchorStart: boolean, anchorEnd: boolean, negated: boolean): string { + if (anchorStart && !anchorEnd) { + return negated ? 'does-not-start-with' : 'starts-with'; } - if (source.endsWith('$')) { - source = source.slice(0, -1); + if (anchorEnd && !anchorStart) { + return negated ? 'does-not-end-with' : 'ends-with'; } - return source.replace(/\\([\\.^$|?*+()[\]{}/-])/g, '$1'); + return negated ? 'does-not-contain' : 'contains'; } export function scalarCodec(config?: CodecConfig): FilterCodec { @@ -179,18 +194,22 @@ export function textCodec(config?: CodecConfig): FilterCodec { } if (comparator.operator === '$regex' && comparator.value instanceof RegExp) { + const {anchorStart, anchorEnd, value} = decomposeRegex(comparator.value); + return { field: ctx.key, - operator: extractRegexOperator(comparator.value), - values: [normalizeRegexValue(comparator.value)] + operator: anchorsToOperator(anchorStart, anchorEnd, false), + values: [value] }; } if (comparator.operator === '$not' && comparator.value instanceof RegExp) { + const {anchorStart, anchorEnd, value} = decomposeRegex(comparator.value); + return { field: ctx.key, - operator: extractRegexOperator(comparator.value, true), - values: [normalizeRegexValue(comparator.value)] + operator: anchorsToOperator(anchorStart, anchorEnd, true), + values: [value] }; } diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-icon.tsx b/apps/admin/src/shared/member-custom-fields/custom-field-icon.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-icon.tsx rename to apps/admin/src/shared/member-custom-fields/custom-field-icon.tsx diff --git a/apps/admin/src/shared/member-custom-fields/custom-field-type-option.tsx b/apps/admin/src/shared/member-custom-fields/custom-field-type-option.tsx new file mode 100644 index 00000000000..6b725848a83 --- /dev/null +++ b/apps/admin/src/shared/member-custom-fields/custom-field-type-option.tsx @@ -0,0 +1,21 @@ +import CustomFieldIcon from './custom-field-icon'; +import {userTypeForFieldType} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; + +/** + * A field type as it appears in a picker: its icon and its name. + * + * Shared rather than owned by Settings so that wherever a publisher is offered the field + * types, they read the same, and so a type's icon is decided in one place. + */ +export function CustomFieldTypeOption({type}: {type: MemberCustomField['type']}) { + return ( + + {/* Fixed width so labels line up in a column whatever shape the icon is. */} + + + + {userTypeForFieldType(type).label} + + ); +} diff --git a/apps/admin/src/tags/detail/tag-code-injection-accordion.tsx b/apps/admin/src/tags/detail/tag-code-injection-accordion.tsx new file mode 100644 index 00000000000..d6875dd521f --- /dev/null +++ b/apps/admin/src/tags/detail/tag-code-injection-accordion.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {Accordion, AccordionContent, AccordionItem, AccordionTrigger, Card, CodeEditor} from '@tryghost/shade/components'; +import {Stack} from '@tryghost/shade/primitives'; + +interface TagCodeInjectionAccordionProps { + disabled?: boolean; + headerValue: string; + footerValue: string; + onHeaderChange: (value: string) => void; + onFooterChange: (value: string) => void; +} + +const htmlExtensions = [() => import('@codemirror/lang-html').then(module => module.html())]; + +const TagCodeInjectionAccordion: React.FC = ({disabled, headerValue, footerValue, onHeaderChange, onFooterChange}) => { + const hasCodeInjection = Boolean(headerValue.trim() || footerValue.trim()); + + return ( + + + + + + Code injection + Add styles/scripts to the header and footer. + + + + + Tag header {'{{ghost_head}}'}} + value={headerValue} + onChange={onHeaderChange} + /> + Tag footer {'{{ghost_foot}}'}} + value={footerValue} + onChange={onFooterChange} + /> + + + + + + ); +}; + +export default TagCodeInjectionAccordion; diff --git a/apps/admin/src/tags/detail/tag-color-field.tsx b/apps/admin/src/tags/detail/tag-color-field.tsx index 975ce45be42..2885e5ceb1a 100644 --- a/apps/admin/src/tags/detail/tag-color-field.tsx +++ b/apps/admin/src/tags/detail/tag-color-field.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import {InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, Label} from '@tryghost/shade/components'; +import {InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, Label, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; +import {ColorPicker, ColorPickerTrigger} from '@tryghost/shade/patterns'; +import {Stack} from '@tryghost/shade/primitives'; interface TagColorFieldProps { value: string; @@ -13,7 +15,7 @@ const HEX_COLOR_REGEX = /#[0-9A-Fa-f]{6}$/; /** * The tag accent colour control, arranged like Ember's `.input-color`: one - * bordered control with the swatch (the native colour-picker trigger) on the + * bordered control with the colour-picker trigger on the * left, a static `#` prefix, and the hex text input. Ports `tag-form.js` * `updateAccentColor` — immediate normalization keeps the form draft in sync * before keyboard saves, with the same error copy for a malformed hex value. @@ -21,6 +23,7 @@ const HEX_COLOR_REGEX = /#[0-9A-Fa-f]{6}$/; const TagColorField: React.FC = ({value, disabled, errorId, onChange, onError}) => { const [text, setText] = React.useState(value.replace(/^#/, '')); const lastValueRef = React.useRef(value); + const allowPickerChanges = React.useRef(false); // Adopt external changes (initial load, a background refetch) without // clobbering in-progress typing on unrelated re-renders. @@ -60,50 +63,58 @@ const TagColorField: React.FC = ({value, disabled, errorId, }; return ( -
- - - -
e.stopPropagation()} - > - { - setText(e.target.value.replace(/^#/, '')); - applyColor(e.target.value); - }} - /> -
- # -
- applyColor(e.target.value)} - onChange={(e) => { - setText(e.target.value); - applyColor(e.target.value); - }} - /> -
-
+ allowPickerChanges.current = false}> + + + + + + + + # + + applyColor(e.target.value)} + onChange={(e) => { + setText(e.target.value); + applyColor(e.target.value); + }} + /> + + + +
allowPickerChanges.current = true} + onKeyDownCapture={() => allowPickerChanges.current = true} + onPointerDownCapture={() => allowPickerChanges.current = true} + > + { + if (allowPickerChanges.current) { + setText(color.replace(/^#/, '')); + applyColor(color); + } + }} + /> +
+
+
); }; diff --git a/apps/admin/src/tags/detail/tag-detail-form.tsx b/apps/admin/src/tags/detail/tag-detail-form.tsx index 41cbb533628..a2f619458dc 100644 --- a/apps/admin/src/tags/detail/tag-detail-form.tsx +++ b/apps/admin/src/tags/detail/tag-detail-form.tsx @@ -1,7 +1,9 @@ import React from 'react'; import TagColorField from './tag-color-field'; +import TagCodeInjectionAccordion from './tag-code-injection-accordion'; import TagImageField from './tag-image-field'; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger, Card, CardContent, FieldError, Input, Label, Textarea} from '@tryghost/shade/components'; +import {Card, CardContent, FieldError, Input, Label, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components'; +import {Grid, Inline, Stack} from '@tryghost/shade/primitives'; import {DESCRIPTION_MAX_LENGTH, FACEBOOK_DESCRIPTION_RECOMMENDED_LENGTH, FACEBOOK_TITLE_RECOMMENDED_LENGTH, META_DESCRIPTION_RECOMMENDED_LENGTH, META_TITLE_RECOMMENDED_LENGTH, X_DESCRIPTION_RECOMMENDED_LENGTH, X_TITLE_RECOMMENDED_LENGTH, charLength, getBlogDomain, getSeoDescription, getSeoTitle, getSeoUrl, getSlugUrlPreview, validateTagField} from './tag-detail-edit'; import {FacebookCardPreview, SeoPreview, XCardPreview} from './tag-detail-previews'; import {cn, formatNumber} from '@tryghost/shade/utils'; @@ -20,27 +22,17 @@ interface TagDetailFormProps { } const errorId = (field: TagFieldName) => `tag-${field}-error`; - /** Ember's `gh-count-down-characters`: the used count, red once past the limit. */ const UsedCharacters: React.FC<{value: string; limit: number; prefix: 'Maximum' | 'Recommended'}> = ({value, limit, prefix}) => { const used = charLength(value); return (

- {prefix}: {formatNumber(limit)} characters. You’ve used{' '} + {prefix}: {formatNumber(limit)} characters. You’ve used{' '} limit ? 'text-destructive' : 'text-state-success')}>{formatNumber(used)}

); }; -const SectionTrigger: React.FC<{title: string; description: string}> = ({title, description}) => ( - - - {title} - {description} - - -); - const TagDetailForm: React.FC = ({draft, errors, blogUrl, disabled, onChange, onFieldError, onImageBusyChange, onImageUploadPendingChange}) => { const {data: settingsData} = useBrowseSettings({}); const siteTitle = getSettingValue(settingsData?.settings ?? [], 'title') ?? ''; @@ -59,15 +51,16 @@ const TagDetailForm: React.FC = ({draft, errors, blogUrl, di const blogDomain = getBlogDomain(blogUrl); return ( -
- {/* Card 1 mirrors Ember's main form block; card 2 groups the - collapsible sections — the member detail screen's card idiom. */} - - -
-
-
-
+ + {/* The main form and advanced settings collapse into one column + below the medium breakpoint. */} + + + + + + + = ({draft, errors, blogUrl, di onBlur={() => validateOnBlur('name')} onChange={e => onChange({name: e.target.value})} /> -
+ = ({draft, errors, blogUrl, di onChange={accentColor => onChange({accentColor})} onError={message => onFieldError('accentColor', message)} /> -
-
+ + {errors.name} {errors.accentColor}

Start with # to create internal tags.{' '} Learn more

-
- -
- - validateOnBlur('slug')} - onChange={e => onChange({slug: e.target.value})} - /> -

{getSlugUrlPreview(draft.slug, blogUrl)}

- {errors.slug} -
- -
- -