diff --git a/.changeset/weak-suns-enjoy.md b/.changeset/weak-suns-enjoy.md
new file mode 100644
index 00000000000..70ed7ecceaa
--- /dev/null
+++ b/.changeset/weak-suns-enjoy.md
@@ -0,0 +1,5 @@
+---
+"@tryghost/koenig-lexical": minor
+---
+
+Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin
diff --git a/.dockerignore b/.dockerignore
index f6afbac9afe..a5df51d18d8 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -32,10 +32,11 @@ compose.yml
.codex
.cursor
-# NOTE: core/built/admin is intentionally NOT ignored — the production image's
-# `full` stage COPYs it from the build context, where CI injects the admin build
-# artifact. The `core` stage copies from the deploy stage (which never builds
-# admin), so a stray local admin build cannot leak into the core image.
+# NOTE: core/built/admin and core/built/embed-renderer are intentionally NOT
+# ignored — the production image's `full` stage COPYs them from the build
+# context, where CI injects the admin build artifact. The `core` stage copies
+# from the deploy stage (which never builds either), so stray local builds cannot
+# leak into the core image.
# Ignore local config files (.json and .jsonc)
ghost/core/config.local.json*
diff --git a/.github/embed-renderer/404.html b/.github/embed-renderer/404.html
new file mode 100644
index 00000000000..c3d8bb00cb1
--- /dev/null
+++ b/.github/embed-renderer/404.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+Not found
+
+
+
Not found. This domain serves Ghost's embed preview renderer and nothing else.
+
+
diff --git a/.github/embed-renderer/README.md b/.github/embed-renderer/README.md
new file mode 100644
index 00000000000..ebc18aa32b4
--- /dev/null
+++ b/.github/embed-renderer/README.md
@@ -0,0 +1,36 @@
+# Embed renderer hosting
+
+The editor previews embed card html in
+[`koenig/koenig-lexical/public/embed-renderer/`](../../koenig/koenig-lexical/public/embed-renderer/),
+loaded from a domain that serves nothing else. Embed scripts run with that
+domain's origin, so it must never share one with Ghost Admin, a Ghost site, or
+anything holding cookies. Sites point at it with `security.embedPreviewUrl`.
+
+Self-hosted Ghost previews embeds from `public.ghostembeds.com`, deployed from
+this repository.
+
+## Deploying
+
+CI deploys on pushes to `main` that touch the renderer. By hand:
+
+```bash
+.github/embed-renderer/build.sh /tmp/embed-renderer
+netlify deploy --prod --dir=/tmp/embed-renderer --no-build
+```
+
+The Netlify site has no linked repository and asset post-processing off, so the
+renderer's inline script isn't rewritten. DNS: the host as a CNAME, an empty
+apex, and no mail (no MX, SPF `-all`, DMARC `p=reject`).
+
+## Adding a version
+
+The renderer is versioned by its message protocol, not by Ghost release. Add
+`v.html` alongside the existing files and keep every older version: editors
+request the version they were built against, so each deploy ships all of them.
+
+## Rules for this domain
+
+- serve nothing but the renderer files; every other path 404s
+- never set cookies, serve Ghost content, or add branding
+- never list it in `security.txt`, OAuth redirects, CORS allowlists or a CSP
+ `script-src`: it runs arbitrary third-party code by design
diff --git a/.github/embed-renderer/_headers b/.github/embed-renderer/_headers
new file mode 100644
index 00000000000..caedde9343d
--- /dev/null
+++ b/.github/embed-renderer/_headers
@@ -0,0 +1,6 @@
+/*
+ Content-Security-Policy: frame-ancestors https:
+ Referrer-Policy: strict-origin-when-cross-origin
+ X-Content-Type-Options: nosniff
+ Strict-Transport-Security: max-age=31536000; includeSubDomains
+ Cache-Control: public, max-age=3600
diff --git a/.github/embed-renderer/build.sh b/.github/embed-renderer/build.sh
new file mode 100755
index 00000000000..b42192e0b89
--- /dev/null
+++ b/.github/embed-renderer/build.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+#
+# Assembles the upload directory for public.ghostembeds.com.
+#
+# ./build.sh [output-directory] (default: ./deploy)
+#
+# It holds every renderer version, the _headers next to this script, and a 404
+# page. Upload the directory to Netlify, or deploy it with:
+#
+# netlify deploy --prod --dir= --no-build
+#
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$here/../.." && pwd)"
+renderer_dir="$repo_root/koenig/koenig-lexical/public/embed-renderer"
+out_dir="${1:-$PWD/deploy}"
+
+if [ ! -d "$renderer_dir" ]; then
+ echo "renderer source missing: $renderer_dir" >&2
+ exit 1
+fi
+
+rm -rf "$out_dir"
+mkdir -p "$out_dir"
+
+cp "$renderer_dir"/v*.html "$out_dir/"
+cp "$here/404.html" "$out_dir/"
+cp "$here/_headers" "$out_dir/"
+
+echo "$out_dir"
+for file in "$out_dir"/*; do
+ printf ' %s %s\n' "$(shasum -a 256 "$file" | cut -c1-16)" "$(basename "$file")"
+done
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index a6fa357cab4..fe70e83ca4a 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -271,6 +271,15 @@
allowedVersions: '<9',
},
+ // Admin's Cmd-K search must match Ember's results, and Ember ships
+ // FlexSearch 0.7; 0.8 changes how titles are tokenized and ranked.
+ {
+ description: 'Cap the catalog flexsearch at 0.7 (matches Ember admin search)',
+ matchDepTypes: ['pnpm.catalog'],
+ matchPackageNames: ['flexsearch'],
+ allowedVersions: '<0.8',
+ },
+
// 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
@@ -283,7 +292,7 @@
// Node.js runtime declared in `engines`, not a dependency — so a hard
// version cap is the only lever. Raise it when we bump the Node engine.
{
- description: 'Cap @types/node at the default Node major (devEngines: 22.23.1)',
+ description: 'Cap @types/node at the default Node major (devEngines: 22.23.3)',
matchPackageNames: ['@types/node'],
allowedVersions: '<23',
},
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f29f7043d8a..1847befbbb2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,7 +15,7 @@ on:
env:
FORCE_COLOR: 1
HEAD_COMMIT: ${{ github.sha }}
- NODE_VERSION: 22.23.1
+ NODE_VERSION: 22.23.3
# Disable v8-compile-cache to prevent intermittent V8 deserializer crashes
# when multiple parallel Nx workers race to read/write shared bytecode cache
# files. The cache lives in /tmp and is discarded after each run anyway,
@@ -228,7 +228,7 @@ jobs:
# test:unit's Nx cache is keyed on `node -v` (nx.json) so each leg runs.
id: node_matrix
run: |
- echo 'matrix=["22.23.1", "24.20.0"]' >> $GITHUB_OUTPUT
+ echo "matrix=[\"${NODE_VERSION}\", \"24.20.0\"]" >> $GITHUB_OUTPUT
- name: Start Nx Cloud CI run
run: pnpm nx start-ci-run
@@ -1255,19 +1255,20 @@ jobs:
echo "::error::IS_SHIPPING is set but VITE_SENTRY_AUTH_TOKEN is empty — Koenig sourcemaps would not reach Sentry"
exit 1
fi
- # Builds apps/admin/dist AND ghost/core/core/built/admin (asset-delivery).
+ # Builds apps/admin/dist, ghost/core/core/built/admin (asset-delivery),
+ # and ghost/core/core/built/embed-renderer (separate-origin previews).
pnpm nx run @tryghost/admin:build
- # The built admin (ghost/core/core/built/admin) is consumed by both job_pack
- # (packed into the Ghost-CLI archive) and job_docker (COPYed into the full
- # image). Ship it as a tarball to preserve file modes and speed transfer.
+ # The built admin and embed renderer are consumed by both job_pack (packed
+ # into the Ghost-CLI archive) and job_docker (COPYed into the full image).
+ # Ship them as a tarball to preserve file modes and speed transfer.
#
# --exclude '*.map': admin sourcemaps (~60MB) are uploaded to Sentry during
# the build (IS_SHIPPING), not shipped in the image. ghost/core's `files`
# field strips them from the Ghost-CLI archive (!core/built/**/*.map), so
# excluding them here matches that for the Docker image too.
- name: Pack admin build
- run: tar --exclude='*.map' -czf admin-build.tar.gz -C ghost/core/core/built admin
+ run: tar --exclude='*.map' -czf admin-build.tar.gz -C ghost/core/core/built admin embed-renderer
- name: Upload admin build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -1346,7 +1347,8 @@ jobs:
name: admin-build
- name: Extract admin build
- # The archive includes core/built/admin via ghost/core's files allowlist.
+ # The archive includes core/built/admin and core/built/embed-renderer via
+ # ghost/core's files allowlist.
run: |
mkdir -p ghost/core/core/built
tar -xzf admin-build.tar.gz -C ghost/core/core/built
@@ -1575,9 +1577,9 @@ jobs:
path: ${{ runner.temp }}/admin-artifact
- name: Extract admin build into context
- # The full stage COPYs ghost/core/core/built/admin from the context; the
- # deploy stage excludes core/built entirely, so admin never leaks into core
- # and adding it is the only context change between the core and full builds.
+ # The full stage COPYs the admin and embed renderer from the context; the
+ # deploy stage excludes core/built entirely, so neither leaks into core and
+ # adding them is the only context change between the core and full builds.
run: |
mkdir -p ghost/core/core/built
tar -xzf "${RUNNER_TEMP}/admin-artifact/admin-build.tar.gz" -C ghost/core/core/built
@@ -1587,8 +1589,8 @@ jobs:
env:
BUILDKIT_PROGRESS: plain
with:
- # Same repo-root context as core (admin now present at
- # ghost/core/core/built/admin, excluded by the deploy stage) so the
+ # Same repo-root context as core (admin and embed renderer now present
+ # under ghost/core/core/built, excluded by the deploy stage) so the
# deploy/install/build layers cache-hit from the core build above.
context: .
file: Dockerfile.production
diff --git a/.github/workflows/e2e-runner-image.yml b/.github/workflows/e2e-runner-image.yml
index 4c8eea28117..f794ea09148 100644
--- a/.github/workflows/e2e-runner-image.yml
+++ b/.github/workflows/e2e-runner-image.yml
@@ -25,7 +25,7 @@ on:
- 'package.json'
env:
- NODE_VERSION: 22.23.1
+ NODE_VERSION: 22.23.3
permissions:
contents: read
diff --git a/.github/workflows/embed-renderer.yml b/.github/workflows/embed-renderer.yml
new file mode 100644
index 00000000000..bb09a28e694
--- /dev/null
+++ b/.github/workflows/embed-renderer.yml
@@ -0,0 +1,59 @@
+name: Embed renderer
+
+# Deploys the embed renderer to public.ghostembeds.com, which serves
+# self-hosted Ghost. See .github/embed-renderer/README.md.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'koenig/koenig-lexical/public/embed-renderer/**'
+ - '.github/embed-renderer/**'
+ - '.github/workflows/embed-renderer.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ NODE_VERSION: 22.23.3
+
+concurrency:
+ group: embed-renderer
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ name: Deploy public renderer
+ runs-on: ubuntu-latest
+ if: github.repository == 'TryGhost/Ghost'
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - uses: ./.github/actions/setup-node-pnpm
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ install: 'false'
+ store-cache: 'false'
+
+ # every version ships on every deploy: a Netlify deploy replaces the site,
+ # and editors request the renderer version they were built against
+ - name: Build upload directory
+ run: .github/embed-renderer/build.sh "$RUNNER_TEMP/embed-renderer"
+
+ # --ignore-scripts: a static --no-build deploy needs none of netlify-cli's
+ # dependency build scripts, which pnpm otherwise refuses to skip.
+ # Runs outside the checkout: from the repo root netlify-cli sees the pnpm
+ # workspace and refuses to deploy until one of its packages is picked
+ - name: Deploy to Netlify
+ working-directory: ${{ runner.temp }}
+ env:
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ NETLIFY_SITE_ID: ${{ secrets.NETLIFY_EMBEDS_PUBLIC_SITE_ID }}
+ run: |
+ pnpm --ignore-scripts --package=netlify-cli@27.8.0 dlx netlify deploy \
+ --prod \
+ --no-build \
+ --dir="$RUNNER_TEMP/embed-renderer" \
+ --message "${GITHUB_SHA:0:7} via ${GITHUB_WORKFLOW}"
diff --git a/.github/workflows/koenig-demo.yml b/.github/workflows/koenig-demo.yml
index 71ca1d442f1..d69c050fa8e 100644
--- a/.github/workflows/koenig-demo.yml
+++ b/.github/workflows/koenig-demo.yml
@@ -19,7 +19,7 @@ concurrency:
cancel-in-progress: true
env:
- NODE_VERSION: 22.23.1
+ NODE_VERSION: 22.23.3
jobs:
deploy:
diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
index b228371ccd8..b7a84938802 100644
--- a/.github/workflows/publish-packages.yml
+++ b/.github/workflows/publish-packages.yml
@@ -40,7 +40,7 @@ concurrency:
cancel-in-progress: false
env:
- NODE_VERSION: 22.23.1
+ NODE_VERSION: 22.23.3
jobs:
publish:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0bbf7914053..f09b3bd4844 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,7 +26,7 @@ on:
env:
FORCE_COLOR: 1
- NODE_VERSION: 22.23.1
+ NODE_VERSION: 22.23.3
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
diff --git a/.node-version b/.node-version
index f9e7451e771..de889f8c2e7 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-22.23.1
+22.23.3
diff --git a/.nvmrc b/.nvmrc
index f9e7451e771..de889f8c2e7 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22.23.1
+22.23.3
diff --git a/.pnpmfile.mjs b/.pnpmfile.mjs
index 511646e7b4e..2f24b8a7fa6 100644
--- a/.pnpmfile.mjs
+++ b/.pnpmfile.mjs
@@ -121,6 +121,11 @@ function readPackage(pkg) {
*/
async function updateConfig(config) {
const { packages, versioning = {} } = config;
+ // `pnpm dlx` runs this hook without the workspace package list
+ if (!packages) {
+ return config;
+ }
+
const ignoredPackages = new Set(versioning.ignore ?? []);
// step 1: enumerate all workspace packages with glob
diff --git a/Dockerfile.production b/Dockerfile.production
index 743b3c4ef9d..4beca238985 100644
--- a/Dockerfile.production
+++ b/Dockerfile.production
@@ -11,8 +11,8 @@
# Build context: the monorepo repo root. The deploy stage runs `pnpm deploy`
# against the workspace to produce a self-contained app dir (source + resolved
# node_modules), so this image no longer depends on the `pnpm pack` output that
-# Ghost-CLI uses (scripts/pack.mjs). The `full` stage's admin build is injected
-# into the context by CI at core/built/admin.
+# Ghost-CLI uses (scripts/pack.mjs). The `full` stage's admin and embed renderer
+# builds are injected into the context by CI under core/built.
#
# Ownership model: application code (node_modules, server source, admin) is owned
# by nobody:nogroup so the runtime user (ghost, uid 1000) can read but not modify
@@ -22,7 +22,7 @@
# duplicated node_modules (~600MB) and the admin build (~80MB) and inflated every
# image pull.
-ARG NODE_VERSION=22.23.1
+ARG NODE_VERSION=22.23.3
# ---- deploy: build closure + self-contained app dir (build-only, not shipped) ----
FROM node:$NODE_VERSION-bookworm-slim AS deploy
@@ -35,15 +35,15 @@ RUN corepack enable
# `ghost...` filter. .dockerignore prunes node_modules/build/dist/.git/.nx so the
# build starts from a clean tree and rebuilds outputs in-container.
#
-# --exclude core/built: for the `full` target CI injects the admin build into the
-# context at ghost/core/core/built/admin. Excluding the whole built dir (not just
-# built/admin — the built dir itself is absent on a fresh checkout, so excluding
-# only its child would still leave a differing directory entry) keeps this COPY
-# layer's hash identical between the core and full builds. That lets `full` reuse
-# `core`'s cached deploy/install/build layers instead of re-running the whole
-# closure build, and keeps admin out of the deploy output so `core` stays
-# admin-free. The server's own core/built output is regenerated in-container by the
-# build steps below, so nothing is lost by excluding the host copy.
+# --exclude core/built: for the `full` target CI injects the admin and embed
+# renderer builds under ghost/core/core/built. Excluding the whole built dir (the
+# directory itself is absent on a fresh checkout, so excluding only its children
+# would still leave a differing directory entry) keeps this COPY layer's hash
+# identical between the core and full builds. That lets `full` reuse `core`'s
+# cached deploy/install/build layers instead of re-running the whole closure
+# build, and keeps both artifacts out of the deploy output so `core` stays
+# admin-free. The server's own core/built output is regenerated in-container by
+# the build steps below, so nothing is lost by excluding the host copy.
COPY --exclude=ghost/core/core/built . .
# Install only ghost and its dependency subgraph (`ghost...`), not the whole
@@ -66,8 +66,9 @@ RUN pnpm --filter-prod "ghost^..." -r run build && \
# Produce a self-contained deploy dir: ghost/core's `files` set + a fully resolved,
# production-only node_modules (--prod drops devDeps, keeps optional better-sqlite3).
-# Admin is never built here, so core/built/admin is absent — the core image stays
-# admin-free and the full stage injects admin from the build context.
+# Admin is never built here, so core/built/admin and core/built/embed-renderer are
+# absent — the core image stays admin-free and the full stage injects both from
+# the build context.
#
# inject-workspace-packages: hard-copy the workspace deps (kg-*/adapters/i18n) into
# node_modules instead of linking them back to the workspace. The deploy dir is
@@ -133,13 +134,14 @@ EXPOSE 2368
CMD ["node", "index.js"]
-# ---- Full: core + admin ----
+# ---- Full: core + admin + embed renderer ----
FROM core AS full
# COPY --chown sets ownership in the copy layer; a post-hoc `chown -R` would
-# duplicate the ~80MB admin build into a second layer. Admin is injected into the
-# build context by CI (downloaded from the admin build job) at
-# ghost/core/core/built/admin — the deploy stage excludes that path, so this is the
-# only stage that carries admin. Local `full` builds must populate that path first
-# (e.g. `pnpm nx run @tryghost/admin:build`).
+# duplicate the ~80MB admin build into a second layer. Admin and the embed
+# renderer are injected into the build context by CI (downloaded from the admin
+# build job) under ghost/core/core/built — the deploy stage excludes that path,
+# so this is the only stage that carries them. Local `full` builds must populate
+# both paths first (e.g. `pnpm nx run @tryghost/admin:build`).
COPY --chown=nobody:nogroup ghost/core/core/built/admin core/built/admin
+COPY --chown=nobody:nogroup ghost/core/core/built/embed-renderer core/built/embed-renderer
diff --git a/apps/admin-x-framework/src/api/config.ts b/apps/admin-x-framework/src/api/config.ts
index 210743aac12..25135a8ca26 100644
--- a/apps/admin-x-framework/src/api/config.ts
+++ b/apps/admin-x-framework/src/api/config.ts
@@ -58,6 +58,11 @@ export type Config = {
max?: number;
error?: string;
};
+ emails?: {
+ maxPeriodic?: number;
+ disabled?: boolean;
+ error?: string;
+ };
customThemes?: {
allowlist?: string[];
error?: string;
@@ -87,6 +92,9 @@ export type Config = {
upgradeUrl?: string; // Destination for the banner's upgrade button
};
};
+ subscription?: {
+ start?: string; // ISO date that anchors monthly periodic limits
+ };
billing?: {
enabled?: boolean;
url?: string;
@@ -135,6 +143,8 @@ export type Config = {
};
security?: {
staffDeviceVerification?: boolean;
+ // directory serving the Koenig embed renderer on a separate origin
+ embedPreviewUrl?: string;
};
featurebase?: {
enabled?: boolean;
diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts
index 48ad4dafd32..416a3f4db9d 100644
--- a/apps/admin-x-framework/src/api/pages.ts
+++ b/apps/admin-x-framework/src/api/pages.ts
@@ -14,6 +14,7 @@ import {
buildPostReadParams,
serializePostPayload,
} from './post-contract';
+import { tagsDataType } from './tags';
import type {
CreateContentData,
EditContentData,
@@ -115,13 +116,14 @@ export interface EditPagePayload {
sessionExpiryRedirect?: boolean;
}
+// A tag sent without an id is created by the save itself, so tag lists go stale too.
export const useAddPage = createMutation({
method: 'POST',
path: () => '/pages/',
searchParams: ({ options }) => buildPageWriteParams(options),
body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
- invalidateQueries: { dataType },
+ invalidateQueries: { dataType: [dataType, tagsDataType] },
});
export const useEditPage = createMutation({
@@ -130,7 +132,7 @@ export const useEditPage = createMutation({
searchParams: ({ options }) => buildPageWriteParams(options),
body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
- invalidateQueries: { dataType },
+ invalidateQueries: { dataType: [dataType, tagsDataType] },
});
export interface DeletePagePayload {
diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts
index 8251627ce09..ae9b86dce64 100644
--- a/apps/admin-x-framework/src/api/posts.ts
+++ b/apps/admin-x-framework/src/api/posts.ts
@@ -14,6 +14,7 @@ import {
buildPostWriteParams,
serializePostPayload,
} from './post-contract';
+import { tagsDataType } from './tags';
import type {
CreateContentData,
EditContentData,
@@ -132,13 +133,14 @@ export interface EditPostPayload {
sessionExpiryRedirect?: boolean;
}
+// A tag sent without an id is created by the save itself, so tag lists go stale too.
export const useAddPost = createMutation({
method: 'POST',
path: () => '/posts/',
searchParams: ({ options }) => buildPostWriteParams(options),
body: ({ post }) => ({ posts: [serializePostPayload(post)] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
- invalidateQueries: { dataType },
+ invalidateQueries: { dataType: [dataType, tagsDataType] },
});
export const useEditPost = createMutation({
@@ -147,7 +149,7 @@ export const useEditPost = createMutation({
searchParams: ({ options }) => buildPostWriteParams(options),
body: ({ post }) => ({ posts: [serializePostPayload(post)] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
- invalidateQueries: { dataType },
+ invalidateQueries: { dataType: [dataType, tagsDataType] },
});
export interface DeletePostPayload {
diff --git a/apps/admin-x-framework/src/api/tags.ts b/apps/admin-x-framework/src/api/tags.ts
index 3541dce207c..35a109c0719 100644
--- a/apps/admin-x-framework/src/api/tags.ts
+++ b/apps/admin-x-framework/src/api/tags.ts
@@ -39,6 +39,8 @@ export interface TagsResponseType {
const dataType = 'TagsResponseType';
+export const tagsDataType = dataType;
+
const useBrowseTagsQuery = createInfiniteQuery({
dataType,
path: '/tags/',
diff --git a/apps/admin-x-framework/src/hooks/use-limiter.ts b/apps/admin-x-framework/src/hooks/use-limiter.ts
index 55d070307f0..c2d1122c801 100644
--- a/apps/admin-x-framework/src/hooks/use-limiter.ts
+++ b/apps/admin-x-framework/src/hooks/use-limiter.ts
@@ -63,7 +63,23 @@ export const useLimiter = (): Limiter => {
return noOpLimiter;
}
- const limits = { ...config.hostSettings.limits } as Record;
+ // A subscription without a start can't anchor a period, so it's treated as absent
+ const subscriptionStart = config.hostSettings.subscription?.start;
+ const subscription = subscriptionStart
+ ? { startDate: subscriptionStart, interval: 'month' as const }
+ : undefined;
+
+ // Periodic limits need a subscription to build, and registration stops at the first
+ // limit that throws, so without one they're skipped to keep the rest working
+ const limits = Object.fromEntries(
+ Object.entries(config.hostSettings.limits).filter(([name, limit]) => {
+ if (!subscription && limit && Object.prototype.hasOwnProperty.call(limit, 'maxPeriodic')) {
+ console.warn(`Skipping ${name} limit: periodic limits need hostSettings.subscription`); // eslint-disable-line no-console
+ return false;
+ }
+ return true;
+ }),
+ ) as Record;
const limiter = new LimitService();
if (limits.staff) {
@@ -99,6 +115,7 @@ export const useLimiter = (): Limiter => {
limiter.loadLimits({
limits,
+ subscription,
helpLink,
errors: {
HostLimitError,
diff --git a/apps/admin-x-framework/src/utils/api/update-queries.ts b/apps/admin-x-framework/src/utils/api/update-queries.ts
index 78622fe0045..558971c715b 100644
--- a/apps/admin-x-framework/src/utils/api/update-queries.ts
+++ b/apps/admin-x-framework/src/utils/api/update-queries.ts
@@ -1,5 +1,11 @@
import { InfiniteData } from '@tanstack/react-query';
+// A plain Pages response is `{pages: Page[]}`, so `pages` can't identify InfiniteData; `pageParams` can.
+const isInfiniteData = (data: unknown): data is InfiniteData =>
+ typeof data === 'object' &&
+ data !== null &&
+ Array.isArray((data as { pageParams?: unknown }).pageParams);
+
export const insertToQueryCache = (
field: string,
recordsToInsert?: (response: ResponseData) => unknown[],
@@ -13,8 +19,8 @@ export const insertToQueryCache = (
recordsToInsert ||
((response: ResponseData) => (response as Record)[field]);
- if (typeof currentData === 'object' && 'pages' in currentData) {
- const { pages } = currentData as InfiniteData;
+ if (isInfiniteData(currentData)) {
+ const { pages } = currentData;
const lastPage = pages[pages.length - 1];
return {
...currentData,
@@ -54,8 +60,8 @@ export const updateQueryCache = (
const updated = getRecords(newData);
- if (typeof currentData === 'object' && 'pages' in currentData) {
- const { pages } = currentData as InfiniteData;
+ if (isInfiniteData(currentData)) {
+ const { pages } = currentData;
return {
...currentData,
pages: pages.map((page) => ({
@@ -87,8 +93,8 @@ export const deleteFromQueryCache = (
const deletedIds = idsFromPayload?.(payload) || [payload as string];
- if (typeof currentData === 'object' && 'pages' in currentData) {
- const { pages } = currentData as InfiniteData;
+ if (isInfiniteData(currentData)) {
+ const { pages } = currentData;
return {
...currentData,
pages: pages.map((page) => ({
diff --git a/apps/admin-x-framework/test/unit/api/pages.test.tsx b/apps/admin-x-framework/test/unit/api/pages.test.tsx
index ec907df2cf8..cfb461d6b16 100644
--- a/apps/admin-x-framework/test/unit/api/pages.test.tsx
+++ b/apps/admin-x-framework/test/unit/api/pages.test.tsx
@@ -1,7 +1,8 @@
import { act, waitFor } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
-import { renderHookWithProviders } from '../../../src/test/test-utils';
+import { describe, expect, it, vi } from 'vitest';
+import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils';
import { useAddPage, useEditPage, useEditorPage, usePage } from '../../../src/api/pages';
+import { tagsDataType } from '../../../src/api/tags';
import { withMockFetch } from '../../utils/mock-fetch';
// The Ember editor's exact include list — page writes re-request it too
@@ -133,4 +134,33 @@ describe('pages api', () => {
});
});
});
+
+ it('invalidates tag queries after a create or an edit, either of which can create a tag', async () => {
+ const queryClient = createTestQueryClient();
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+ await withMockFetch({}, async () => {
+ const { result } = renderHookWithProviders(
+ () => ({ add: useAddPage(), edit: useEditPage() }),
+ {
+ queryClient,
+ },
+ );
+
+ await act(async () => {
+ await result.current.add.mutateAsync({
+ page: { title: '(Untitled)', tags: [{ name: 'New' }] },
+ });
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] });
+
+ invalidateSpy.mockClear();
+ await act(async () => {
+ await result.current.edit.mutateAsync({
+ page: { id: 'page-1', tags: [{ name: 'New' }], updated_at: '2026-01-01T00:00:00.000Z' },
+ });
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] });
+ });
+ });
});
diff --git a/apps/admin-x-framework/test/unit/api/posts.test.tsx b/apps/admin-x-framework/test/unit/api/posts.test.tsx
index d32dae1e46f..49583cbc3c9 100644
--- a/apps/admin-x-framework/test/unit/api/posts.test.tsx
+++ b/apps/admin-x-framework/test/unit/api/posts.test.tsx
@@ -8,6 +8,7 @@ import {
useImportContentCSV,
usePost,
} from '../../../src/api/posts';
+import { tagsDataType } from '../../../src/api/tags';
import { withMockFetch } from '../../utils/mock-fetch';
// The Ember editor's exact include list — writes must re-request everything
@@ -306,6 +307,35 @@ describe('posts api', () => {
});
});
+ it('invalidates tag queries after a create or an edit, either of which can create a tag', async () => {
+ const queryClient = createTestQueryClient();
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+ await withMockFetch({}, async () => {
+ const { result } = renderHookWithProviders(
+ () => ({ add: useAddPost(), edit: useEditPost() }),
+ {
+ queryClient,
+ },
+ );
+
+ await act(async () => {
+ await result.current.add.mutateAsync({
+ post: { title: '(Untitled)', tags: [{ name: 'New' }] },
+ });
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] });
+
+ invalidateSpy.mockClear();
+ await act(async () => {
+ await result.current.edit.mutateAsync({
+ post: { id: 'post-1', tags: [{ name: 'New' }], updated_at: '2026-01-01T00:00:00.000Z' },
+ });
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] });
+ });
+ });
+
it('requests a mobiledoc conversion with convert_to_lexical', async () => {
await withMockFetch({}, async (mock) => {
const { result } = renderHookWithProviders(() => useEditPost());
diff --git a/apps/admin-x-framework/test/unit/hooks/use-limiter.test.ts b/apps/admin-x-framework/test/unit/hooks/use-limiter.test.ts
new file mode 100644
index 00000000000..73d0e0075a9
--- /dev/null
+++ b/apps/admin-x-framework/test/unit/hooks/use-limiter.test.ts
@@ -0,0 +1,56 @@
+import { renderHook } from '@testing-library/react';
+import { useLimiter } from '../../../src/hooks/use-limiter';
+
+vi.mock('../../../src/api/config', () => ({ useBrowseConfig: vi.fn() }));
+vi.mock('../../../src/api/users', () => ({
+ useBrowseUsers: () => ({ data: { users: [] }, isLoading: false }),
+}));
+vi.mock('../../../src/api/invites', () => ({
+ useBrowseInvites: () => ({ data: { invites: [] }, isLoading: false }),
+}));
+vi.mock('../../../src/api/roles', () => ({
+ useBrowseRoles: () => ({ data: { roles: [] }, isLoading: false }),
+}));
+vi.mock('../../../src/api/members', () => ({ useBrowseMembers: () => ({ refetch: vi.fn() }) }));
+vi.mock('../../../src/api/newsletters', () => ({
+ useBrowseNewsletters: () => ({ refetch: vi.fn() }),
+}));
+
+import { useBrowseConfig } from '../../../src/api/config';
+
+const withHostSettings = (hostSettings: unknown) => {
+ vi.mocked(useBrowseConfig).mockReturnValue({
+ data: { config: { hostSettings } },
+ } as ReturnType);
+};
+
+describe('useLimiter', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('loads periodic limits when the host sets a subscription start', () => {
+ withHostSettings({
+ subscription: { start: '2026-09-01T00:00:00.000Z' },
+ limits: { emails: { maxPeriodic: 300 }, customIntegrations: { disabled: true } },
+ });
+
+ const { result } = renderHook(() => useLimiter());
+
+ expect(result.current.isLimited('emails')).toBe(true);
+ expect(result.current.isLimited('customIntegrations')).toBe(true);
+ });
+
+ it('skips periodic limits without a subscription and keeps the rest', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ withHostSettings({
+ limits: { emails: { maxPeriodic: 300 }, customIntegrations: { disabled: true } },
+ });
+
+ const { result } = renderHook(() => useLimiter());
+
+ expect(result.current.isLimited('emails')).toBe(false);
+ expect(result.current.isLimited('customIntegrations')).toBe(true);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Skipping emails limit'));
+ });
+});
diff --git a/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts b/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts
index 12b0b27e5fd..1b1b076e43f 100644
--- a/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts
+++ b/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts
@@ -29,12 +29,32 @@ describe('cache update functions', () => {
const currentData = {
pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2' }] }],
+ pageParams: [undefined, 2],
};
const result = insertToQueryCache('posts')(newData, currentData);
expect(result).toEqual({
pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2' }, { id: '3' }] }],
+ pageParams: [undefined, 2],
+ });
+ });
+
+ it('appends to a non-paginated pages response', () => {
+ const newData = {
+ pages: [{ id: '2' }],
+ };
+
+ const currentData = {
+ pages: [{ id: '1' }],
+ meta: { pagination: { total: 1 } },
+ };
+
+ const result = insertToQueryCache('pages')(newData, currentData);
+
+ expect(result).toEqual({
+ pages: [{ id: '1' }, { id: '2' }],
+ meta: { pagination: { total: 1 } },
});
});
});
@@ -63,12 +83,86 @@ describe('cache update functions', () => {
const currentData = {
pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2', title: 'Old Title' }] }],
+ pageParams: [undefined, 2],
};
const result = updateQueryCache('posts')(newData, currentData);
expect(result).toEqual({
pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2', title: 'New Title' }] }],
+ pageParams: [undefined, 2],
+ });
+ });
+
+ it('updates a non-paginated users response', () => {
+ const newData = {
+ users: [{ id: '2', name: 'New Name' }],
+ };
+
+ const currentData = {
+ users: [
+ { id: '1', name: 'Other' },
+ { id: '2', name: 'Old Name' },
+ ],
+ meta: { pagination: { total: 2 } },
+ };
+
+ const result = updateQueryCache('users')(newData, currentData);
+
+ expect(result).toEqual({
+ users: [
+ { id: '1', name: 'Other' },
+ { id: '2', name: 'New Name' },
+ ],
+ meta: { pagination: { total: 2 } },
+ });
+ });
+
+ it('updates a non-paginated pages response', () => {
+ const newData = {
+ pages: [{ id: '2', title: 'New Title' }],
+ };
+
+ const currentData = {
+ pages: [
+ { id: '1', title: 'About' },
+ { id: '2', title: 'Old Title' },
+ ],
+ meta: { pagination: { total: 2 } },
+ };
+
+ const result = updateQueryCache('pages')(newData, currentData);
+
+ expect(result).toEqual({
+ pages: [
+ { id: '1', title: 'About' },
+ { id: '2', title: 'New Title' },
+ ],
+ meta: { pagination: { total: 2 } },
+ });
+ });
+
+ it('updates nested records in paginated pages queries', () => {
+ const newData = {
+ pages: [{ id: '2', title: 'New Title' }],
+ };
+
+ const currentData = {
+ pages: [
+ { pages: [{ id: '1', title: 'About' }] },
+ { pages: [{ id: '2', title: 'Old Title' }] },
+ ],
+ pageParams: [undefined, 2],
+ };
+
+ const result = updateQueryCache('pages')(newData, currentData);
+
+ expect(result).toEqual({
+ pages: [
+ { pages: [{ id: '1', title: 'About' }] },
+ { pages: [{ id: '2', title: 'New Title' }] },
+ ],
+ pageParams: [undefined, 2],
});
});
});
@@ -89,12 +183,62 @@ describe('cache update functions', () => {
it('deletes nested records in paginated queries', () => {
const currentData = {
pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2' }] }],
+ pageParams: [undefined, 2],
};
const result = deleteFromQueryCache('posts')(null, currentData, '2');
expect(result).toEqual({
pages: [{ posts: [{ id: '1' }] }, { posts: [] }],
+ pageParams: [undefined, 2],
+ });
+ });
+
+ it('deletes from a non-paginated users response', () => {
+ const currentData = {
+ users: [{ id: '1' }, { id: '2' }],
+ meta: { pagination: { total: 2 } },
+ };
+
+ const result = deleteFromQueryCache('users')(null, currentData, '2');
+
+ expect(result).toEqual({
+ users: [{ id: '1' }],
+ meta: { pagination: { total: 2 } },
+ });
+ });
+
+ it('deletes from a non-paginated pages response', () => {
+ const currentData = {
+ pages: [
+ { id: '1', title: 'About' },
+ { id: '2', title: 'Contact' },
+ ],
+ meta: { pagination: { total: 2 } },
+ };
+
+ const result = deleteFromQueryCache('pages')(null, currentData, '2');
+
+ expect(result).toEqual({
+ pages: [{ id: '1', title: 'About' }],
+ meta: { pagination: { total: 2 } },
+ });
+ });
+
+ it('deletes nested records in paginated pages queries', () => {
+ const currentData = {
+ pages: [
+ { pages: [{ id: '1', title: 'About' }] },
+ { pages: [{ id: '2', title: 'Contact' }] },
+ ],
+ pageParams: [undefined, 2],
+ };
+
+ const result = deleteFromQueryCache('pages')(null, currentData, '2');
+
+ expect(result).toEqual({
+ pages: [{ pages: [{ id: '1', title: 'About' }] }, { pages: [] }],
+ pageParams: [undefined, 2],
});
});
});
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 3159d52840f..e47466b21f7 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -52,6 +52,7 @@
"clsx": "catalog:",
"dequal": "catalog:",
"dompurify": "catalog:",
+ "flexsearch": "catalog:",
"i18n-iso-countries": "7.14.0",
"jszip": "3.10.1",
"lucide-react": "catalog:",
@@ -155,7 +156,8 @@
],
"outputs": [
"{projectRoot}/dist",
- "{workspaceRoot}/ghost/core/core/built/admin"
+ "{workspaceRoot}/ghost/core/core/built/admin",
+ "{workspaceRoot}/ghost/core/core/built/embed-renderer"
],
"dependsOn": [
"^build",
diff --git a/apps/admin/src/app-root.tsx b/apps/admin/src/app-root.tsx
index d14bf9f1622..c64458b4e47 100644
--- a/apps/admin/src/app-root.tsx
+++ b/apps/admin/src/app-root.tsx
@@ -18,8 +18,7 @@ function ThemedAdminApp() {
const { resolvedTheme } = useThemeContext();
const { pathname } = useLocation();
const isEmberOwnedRoute = useIsEmberOwnedRoute(pathname);
- const isAdmin7 =
- useFeatureFlag('admin7Pill') && !/^\/editor(?:\/|$)/.test(pathname) && !isEmberOwnedRoute;
+ const isAdmin7 = useFeatureFlag('admin7Pill') && !isEmberOwnedRoute;
return (
= ({ value, placeholder, className
const { fetchAutocompleteLinks, searchLinks } = useEmailLinkSuggestions();
const fetchEmbed = useKoenigFetchEmbed();
const klipyConfig = config?.klipy?.apiKey ? config.klipy : null;
+ const embedPreviewUrl = config?.security?.embedPreviewUrl || undefined;
const [transistorEnabled] = getSettingValues(settings, ['transistor']);
const cardConfig = useMemo(
@@ -129,6 +130,7 @@ const EmailEditor: React.FC = ({ value, placeholder, className
unsplash: unsplashConfig,
pinturaConfig,
klipy: klipyConfig,
+ embedPreviewUrl,
fetchEmbed,
fetchAutocompleteLinks,
searchLinks,
@@ -141,6 +143,7 @@ const EmailEditor: React.FC = ({ value, placeholder, className
unsplashConfig,
pinturaConfig,
klipyConfig,
+ embedPreviewUrl,
fetchEmbed,
fetchAutocompleteLinks,
searchLinks,
diff --git a/apps/admin/src/editor/card-config.test.ts b/apps/admin/src/editor/card-config.test.ts
index 46bedde77d6..e2492c486cb 100644
--- a/apps/admin/src/editor/card-config.test.ts
+++ b/apps/admin/src/editor/card-config.test.ts
@@ -185,6 +185,16 @@ describe('buildPostCardConfig', () => {
expect(cardConfig.klipy).toEqual(klipy);
});
+ it('passes the embed preview url through from the security config', () => {
+ const embedPreviewUrl = 'https://embeds.example.net/';
+
+ expect(buildPostCardConfig(sources(), ports).embedPreviewUrl).toBeUndefined();
+ expect(
+ buildPostCardConfig(sources({ config: { ...config, security: { embedPreviewUrl } } }), ports)
+ .embedPreviewUrl,
+ ).toBe(embedPreviewUrl);
+ });
+
it('hides labels from contributors', () => {
const cardConfig = buildPostCardConfig(sources({ currentUser: contributor }), ports);
diff --git a/apps/admin/src/editor/card-config.ts b/apps/admin/src/editor/card-config.ts
index 2b3a79fd113..c0d319ded53 100644
--- a/apps/admin/src/editor/card-config.ts
+++ b/apps/admin/src/editor/card-config.ts
@@ -60,6 +60,7 @@ export interface PostCardConfig extends PostCardConfigPorts {
unsplash: Record | null;
klipy: NonNullable | null;
pinturaConfig: { jsUrl: string; cssUrl: string } | null;
+ embedPreviewUrl: string | undefined;
renderLabels: boolean;
feature: { transistor: boolean; paywallImprovements: boolean };
deprecated: { headerV1: boolean };
@@ -117,6 +118,7 @@ export function buildPostCardConfig(
unsplash: getSettingValue(settings, 'unsplash') ? sources.unsplashHeaders : null,
klipy: config.klipy?.apiKey ? config.klipy : null,
pinturaConfig: sources.pinturaConfig,
+ embedPreviewUrl: config.security?.embedPreviewUrl || undefined,
fetchAutocompleteLinks: ports.fetchAutocompleteLinks,
fetchEmbed: ports.fetchEmbed,
fetchLabels: ports.fetchLabels,
diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx
index 2e71d15d5a4..f2352c79034 100644
--- a/apps/admin/src/editor/editor-header-actions.tsx
+++ b/apps/admin/src/editor/editor-header-actions.tsx
@@ -1,5 +1,7 @@
import { useCallback, useState } from 'react';
import { Button } from '@tryghost/shade/components';
+import { useShade } from '@tryghost/shade/app';
+import { PageHeader } from '@tryghost/shade/patterns';
import { Inline, Text } from '@tryghost/shade/primitives';
import { getSettingValue } from '@tryghost/admin-x-framework/api/settings';
import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks';
@@ -62,6 +64,7 @@ export function EditorHeaderActions({
siteUrl,
tkCount,
}: EditorHeaderActionsProps) {
+ const { isAdmin7 } = useShade();
const { persistedId, publishTime, title } = session;
const record = session.loadedRecord;
const [previewOpen, setPreviewOpen] = useState(false);
@@ -118,13 +121,17 @@ export function EditorHeaderActions({
return (
{isDraft ? (
-
+
) : null}
{isContributor ? (
<>
-