diff --git a/.changeset/easy-wasps-appear.md b/.changeset/easy-wasps-appear.md
new file mode 100644
index 00000000000..57ee67be712
--- /dev/null
+++ b/.changeset/easy-wasps-appear.md
@@ -0,0 +1,13 @@
+---
+"@tryghost/kg-utils": none
+"@tryghost/kg-unsplash-selector": none
+"@tryghost/kg-markdown-html-renderer": none
+"@tryghost/kg-html-to-lexical": none
+"@tryghost/kg-default-transforms": none
+"@tryghost/kg-default-cards": none
+"@tryghost/kg-converters": none
+"@tryghost/kg-clean-basic-html": none
+"@tryghost/kg-card-factory": none
+---
+
+Switched lint config to formatter-neutral rules; no runtime change
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index efb3cb1d336..2e1792a83e8 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -45,6 +45,13 @@
// We have to disable platform based automerge (forcing renovate to do it manually)
// as otherwise renovate wont follow our schedule
"platformAutomerge": false,
+ // Merge automerged PRs by rebasing the branch's own commits onto the base
+ // instead of squashing. Renovate branches are a single, concise commit
+ // (e.g. "chore(deps): update X to Y"), so a squash merge would replace that
+ // clean message with the PR title *and* the full PR body — release-note
+ // tables, changelogs, dependency dashboards — producing a giant commit
+ // message on main. Rebase preserves the branch commit message verbatim.
+ "automergeStrategy": "rebase",
// 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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 29141787ce3..f8733703013 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1039,15 +1039,30 @@ jobs:
# which depends on Arial's font metrics
# - playwright firefox deps: system media codecs so firefox can decode
# the H.264 fixtures in the video card tests
+ # msttcorefonts downloads the font files from SourceForge at install time;
+ # mirror-redirect roulette there can hang indefinitely. timeout-minutes is
+ # the backstop, and each network op is bounded + retried so a stuck fetch
+ # fails fast and re-rolls onto a different mirror instead of sitting.
- name: Install Koenig editor test dependencies (fonts + media codecs)
if: matrix.app == '@tryghost/koenig-lexical'
+ timeout-minutes: 10
env:
DEBIAN_FRONTEND: noninteractive
DEBCONF_NONINTERACTIVE_SEEN: "true"
run: |
sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections"
- sudo apt-get update -yq
- sudo apt-get install -yq msttcorefonts
+ sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30
+ installed=
+ for i in 1 2 3; do
+ if sudo timeout -k 5 120 apt-get install -yq \
+ -o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then
+ installed=1; break
+ fi
+ echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5
+ done
+ if [ -z "$installed" ]; then
+ echo "msttcorefonts failed after 3 attempts"; exit 1
+ fi
pnpm exec playwright install-deps firefox
- name: Run Playwright tests
@@ -1464,7 +1479,11 @@ jobs:
tags: ${{ steps.meta-core.outputs.tags }}
labels: ${{ steps.meta-core.outputs.labels }}
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') || '' }}
+ # PRs read cache-main but never write cache: the old cache-pr-N was
+ # write-only (cache-from is always cache-main) and mode=max cache export
+ # is the heaviest GHCR push in the run — skipping it on PRs cuts push
+ # volume with no rebuild cost. Only main/tag publish cache-main.
+ cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-core-name) || '' }}
# 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 —
@@ -1535,7 +1554,8 @@ jobs:
tags: ${{ steps.meta-full.outputs.tags }}
labels: ${{ steps.meta-full.outputs.labels }}
cache-from: type=registry,ref=${{ steps.strategy.outputs.image-full-name }}:cache-main
- cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-full-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }}
+ # See core image above: PRs skip cache-to; only main/tag publish cache-main.
+ cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-full-name) || '' }}
# The production image artifact is saved before the e2e steps below:
# Ghost-Moya CD discovers `docker-image-production` by name in this run
@@ -1711,7 +1731,8 @@ jobs:
tags: ${{ steps.meta-e2e.outputs.tags }}
labels: ${{ steps.meta-e2e.outputs.labels }}
cache-from: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-main', steps.strategy.outputs.image-e2e-name) || '' }}
- cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-e2e-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }}
+ # See core image above: PRs skip cache-to; only main/tag publish cache-main.
+ cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-e2e-name) || '' }}
- name: Save E2E image as artifact
if: steps.strategy.outputs.use-artifact == 'true'
diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts
index 0c541e9f32f..7fe442480d7 100644
--- a/apps/activitypub/src/api/activitypub.test.ts
+++ b/apps/activitypub/src/api/activitypub.test.ts
@@ -157,6 +157,153 @@ describe('ActivityPubAPI', function () {
});
});
+ describe('getPreferences', function () {
+ test('It returns the sensitive media display preference', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/v1/preferences': {
+ async assert(_resource, init) {
+ const headers = new Headers(init?.headers);
+ expect(init?.method).toEqual('GET');
+ expect(headers.get('Authorization')).toContain('fake-token');
+ },
+ response: JSONResponse({
+ showSensitiveMedia: true
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await expect(api.getPreferences()).resolves.toEqual({
+ showSensitiveMedia: true
+ });
+ });
+
+ test('It defaults showSensitiveMedia to false when it is missing', async function () {
+ const fakeFetch = Fetch({
+ 'https://activitypub.api/.ghost/activitypub/v1/preferences': {
+ response: JSONResponse({})
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await expect(api.getPreferences()).resolves.toEqual({
+ showSensitiveMedia: false
+ });
+ });
+ });
+
+ describe('updatePreferences', function () {
+ test('It saves the sensitive media display preference', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/v1/preferences': {
+ async assert(_resource, init) {
+ const headers = new Headers(init?.headers);
+ expect(init?.method).toEqual('PUT');
+ expect(headers.get('Authorization')).toContain('fake-token');
+ expect(headers.get('Content-Type')).toEqual('application/json');
+ expect(init?.body).toEqual(JSON.stringify({
+ showSensitiveMedia: true
+ }));
+ },
+ response: JSONResponse({
+ showSensitiveMedia: true
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({
+ showSensitiveMedia: true
+ });
+ });
+
+ test('It can disable the sensitive media display preference', async function () {
+ const fakeFetch = Fetch({
+ 'https://auth.api/': {
+ response: JSONResponse({
+ identities: [{
+ token: 'fake-token'
+ }]
+ })
+ },
+ 'https://activitypub.api/.ghost/activitypub/v1/preferences': {
+ async assert(_resource, init) {
+ expect(init?.method).toEqual('PUT');
+ expect(init?.body).toEqual(JSON.stringify({
+ showSensitiveMedia: false
+ }));
+ },
+ response: JSONResponse({
+ showSensitiveMedia: false
+ })
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await expect(api.updatePreferences({showSensitiveMedia: false})).resolves.toEqual({
+ showSensitiveMedia: false
+ });
+ });
+
+ test('It preserves the submitted preference when the update response has no body', async function () {
+ const fakeFetch = Fetch({
+ 'https://activitypub.api/.ghost/activitypub/v1/preferences': {
+ async assert(_resource, init) {
+ expect(init?.method).toEqual('PUT');
+ expect(init?.body).toEqual(JSON.stringify({
+ showSensitiveMedia: true
+ }));
+ },
+ response: new Response(null, {status: 204})
+ }
+ });
+ const api = new ActivityPubAPI(
+ new URL('https://activitypub.api'),
+ new URL('https://auth.api'),
+ 'index',
+ fakeFetch
+ );
+
+ await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({
+ showSensitiveMedia: true
+ });
+ });
+ });
+
describe('follow', function () {
test('It passes the token to the follow endpoint', async function () {
const fakeFetch = Fetch({
@@ -1356,7 +1503,22 @@ describe('ActivityPubAPI', function () {
notifications: [
{
id: 'https://example.com/notifications/abc123',
- type: 'like'
+ type: 'like',
+ post: {
+ id: 'https://example.com/posts/sensitive',
+ type: 'note',
+ title: null,
+ content: '
Sensitive post
',
+ url: 'https://example.com/posts/sensitive',
+ sensitive: true,
+ contentWarning: 'Sensitive topic',
+ likeCount: 0,
+ likedByMe: false,
+ repostCount: 0,
+ repostedByMe: false,
+ replyCount: 0,
+ attachments: []
+ }
},
{
id: 'https://example.com/notifications/def456',
@@ -1380,7 +1542,22 @@ describe('ActivityPubAPI', function () {
expect(actual.notifications).toEqual([
{
id: 'https://example.com/notifications/abc123',
- type: 'like'
+ type: 'like',
+ post: {
+ id: 'https://example.com/posts/sensitive',
+ type: 'note',
+ title: null,
+ content: 'Sensitive post
',
+ url: 'https://example.com/posts/sensitive',
+ sensitive: true,
+ contentWarning: 'Sensitive topic',
+ likeCount: 0,
+ likedByMe: false,
+ repostCount: 0,
+ repostedByMe: false,
+ replyCount: 0,
+ attachments: []
+ }
},
{
id: 'https://example.com/notifications/def456',
diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts
index 8f6cd6a23c4..ece9ed9a4d4 100644
--- a/apps/activitypub/src/api/activitypub.ts
+++ b/apps/activitypub/src/api/activitypub.ts
@@ -159,6 +159,8 @@ export interface Notification {
title: string | null;
content: string;
url: string;
+ sensitive?: boolean;
+ contentWarning?: string | null;
likeCount: number;
likedByMe: boolean;
repostCount: number;
@@ -177,6 +179,8 @@ export interface Notification {
title: string | null;
content: string;
url: string;
+ sensitive?: boolean;
+ contentWarning?: string | null;
},
createdAt: string;
}
@@ -206,6 +210,23 @@ export interface SocialWebDomain {
actorUrl: string;
}
+export interface Preferences {
+ showSensitiveMedia: boolean;
+}
+
+/**
+ * Fails closed: anything the server doesn't explicitly confirm leaves sensitive
+ * media hidden.
+ */
+function parsePreferences(json: object | null): Preferences {
+ return {
+ showSensitiveMedia:
+ json !== null &&
+ 'showSensitiveMedia' in json &&
+ json.showSensitiveMedia === true
+ };
+}
+
export const PostType = {
Note: 0,
Article: 1,
@@ -220,6 +241,8 @@ export interface Post {
title: string;
excerpt: string;
summary: string | null;
+ sensitive?: boolean;
+ contentWarning?: string | null;
content: string;
url: string;
featureImageUrl: string | null;
@@ -684,6 +707,26 @@ export class ActivityPubAPI {
return {count};
}
+ async getPreferences(): Promise {
+ const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl);
+
+ return parsePreferences(await this.fetchJSON(url));
+ }
+
+ async updatePreferences(preferences: Preferences): Promise {
+ const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl);
+ const json = await this.fetchJSON(url, 'PUT', preferences);
+
+ // A body-less success means the write was accepted but not echoed back,
+ // so the submitted value is what's now stored. Parsing null here would
+ // silently report the preference as disabled.
+ if (json === null) {
+ return preferences;
+ }
+
+ return parsePreferences(json);
+ }
+
async resetNotificationsCount() {
const url = new URL('.ghost/activitypub/v1/notifications/unread/reset', this.apiUrl);
diff --git a/apps/activitypub/src/components/feed/feed-item.tsx b/apps/activitypub/src/components/feed/feed-item.tsx
index 5aea3212cb2..e28f06ad5ea 100644
--- a/apps/activitypub/src/components/feed/feed-item.tsx
+++ b/apps/activitypub/src/components/feed/feed-item.tsx
@@ -2,7 +2,7 @@ import FeedItemMenu from './feed-item-menu';
import React, {useEffect, useRef, useState} from 'react';
import {ActivityPubAttachment, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Skeleton} from '@tryghost/shade/components';
-import {H4} from '@tryghost/shade/primitives';
+import {H4, Text} from '@tryghost/shade/primitives';
import {LucideIcon} from '@tryghost/shade/utils';
import {toast} from 'sonner';
@@ -19,8 +19,16 @@ import {openLinksInNewTab, sanitizeHtml, stripHtml} from '../../utils/content-fo
import {renderTimestamp} from '../../utils/render-timestamp';
import {useDeleteMutationForUser, useFollowMutationForUser, useUnfollowMutationForUser} from '../../hooks/use-activity-pub-queries';
import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path';
+import {useSensitiveMediaDisclosure} from '@src/hooks/use-sensitive-media-disclosure';
-export function getAttachment(object: ObjectProperties): ActivityPubAttachment | ActivityPubAttachment[] | null {
+/**
+ * The subset of an object the attachment helpers below actually read. Keeping it
+ * narrow lets callers that only have media (such as notification previews) pass
+ * what they have without inventing a whole ObjectProperties.
+ */
+export type AttachmentSource = Pick;
+
+export function getAttachment(object: AttachmentSource): ActivityPubAttachment | ActivityPubAttachment[] | null {
let attachment: ActivityPubAttachment | ActivityPubAttachment[] | undefined;
if (object.image) {
@@ -55,7 +63,7 @@ export function getAttachment(object: ObjectProperties): ActivityPubAttachment |
}
export function renderFeedAttachment(
- object: ObjectProperties,
+ object: AttachmentSource,
onImageClick?: (url: string) => void,
brokenImages?: Set,
onImageError?: (url: string) => void
@@ -178,6 +186,179 @@ export function renderFeedAttachment(
}
}
+export function SensitiveMediaOverlay({
+ className = '',
+ isLayered = false,
+ size = 'default',
+ showLabel = true,
+ onReveal
+}: {
+ className?: string;
+ isLayered?: boolean;
+ size?: 'default' | 'compact';
+ showLabel?: boolean;
+ onReveal: (event: React.MouseEvent) => void;
+}) {
+ const isCompact = size === 'compact';
+
+ return (
+ event.stopPropagation()}
+ >
+
+ {isCompact ? (
+
+
+ {showLabel && (
+ Sensitive media
+ )}
+
+
+ ) : (
+
+
+
+ Sensitive media
+ The following may contain sensitive material
+
+
+
+ )}
+
+ );
+}
+
+export function SensitiveMediaHideButton({
+ label = 'Hide media',
+ layout = 'overlay',
+ onHide
+}: {
+ label?: string;
+ layout?: 'overlay' | 'inline';
+ onHide: (event: React.MouseEvent) => void;
+}) {
+ return (
+
+ );
+}
+
+export function ContentWarningOverlay({
+ className = '',
+ isLayered = false,
+ label,
+ size = 'default',
+ onReveal
+}: {
+ className?: string;
+ isLayered?: boolean;
+ label: string;
+ size?: 'default' | 'compact';
+ onReveal: (event: React.MouseEvent) => void;
+}) {
+ const isCompact = size === 'compact';
+
+ return (
+ event.stopPropagation()}
+ >
+
+ {isCompact ? (
+
+
+
+ Content warning:
+ {label}
+
+
+
+ ) : (
+
+
+
+ Content warning:
+ {label}
+
+
+
+ )}
+
+ );
+}
+
function renderInboxAttachment(object: ObjectProperties, isLoading: boolean | undefined) {
const attachment = getAttachment(object);
@@ -293,6 +474,25 @@ const FeedItem: React.FC = ({
const deleteMutation = useDeleteMutationForUser('index');
const navigate = useNavigateWithBasePath();
+ const {
+ contentWarning,
+ shouldHideContentWarning,
+ shouldHideSensitiveMedia,
+ canHideSensitiveMedia,
+ isContentWarningRevealed,
+ showContentWarningOverlay,
+ contentWarningMinHeight,
+ contentWarningWrapperRef,
+ revealSensitiveMedia,
+ hideSensitiveMedia,
+ revealContentWarning
+ } = useSensitiveMediaDisclosure({
+ contentWarning: object?.contentWarning,
+ sensitive: object?.sensitive,
+ hasMedia: getAttachment(object) !== null,
+ resetKey: object?.id
+ });
+
const followMutation = useFollowMutationForUser(
'index',
() => {
@@ -381,6 +581,132 @@ const FeedItem: React.FC = ({
setBrokenImages(prev => new Set(prev).add(url));
};
+ const renderFeedMedia = (mediaClickHandler?: (url: string) => void) => {
+ if (shouldHideSensitiveMedia) {
+ const media = renderFeedAttachment(object, undefined, brokenImages, handleImageError);
+
+ if (!media) {
+ return ;
+ }
+
+ const mediaWrapperClassName = clsx(
+ 'relative mt-3 overflow-hidden rounded-md [&>.attachment-gallery]:mt-0 [&>img]:mt-0 [&>img]:block',
+ Array.isArray(getAttachment(object)) ? 'w-full' : 'w-fit max-w-full'
+ );
+
+ return (
+
+ {media}
+
+
+ );
+ }
+
+ const media = renderFeedAttachment(object, mediaClickHandler, brokenImages, handleImageError);
+
+ if (!media) {
+ return null;
+ }
+
+ if (canHideSensitiveMedia) {
+ const mediaWrapperClassName = clsx(
+ 'relative mt-3 [&>.attachment-gallery]:mt-0 [&>img]:mt-0 [&>img]:block',
+ Array.isArray(getAttachment(object)) ? 'w-full' : 'w-fit max-w-full'
+ );
+
+ return (
+
+ {media}
+
+
+ );
+ }
+
+ return media;
+ };
+
+ const renderContentWarningOverlay = (isLayered = false) => {
+ if (!contentWarning) {
+ return null;
+ }
+
+ return (
+
+ );
+ };
+
+ const renderNoteContent = (options?: {
+ contentClassName?: string;
+ mediaClickHandler?: (url: string) => void;
+ showName?: boolean;
+ }) => {
+ const {
+ contentClassName = 'ap-note-content break-anywhere line-clamp-[10] leading-[1.4285714286] tracking-[-0.006em] text-pretty text-gray-900 dark:text-gray-300 [&_p+p]:mt-3',
+ mediaClickHandler = openLightbox,
+ showName = false
+ } = options ?? {};
+
+ return (
+ <>
+ {showName && object.name && (
+ {object.name}
+ )}
+
+ {!isLoading ?
+
{
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === 'A' ||
+ target.closest('a')
+ ) {
+ e.stopPropagation();
+ }
+ }}
+ />
+ :
+
+ }
+
+ {isTruncated && (
+
+ )}
+ {renderFeedMedia(mediaClickHandler)}
+ >
+ );
+ };
+
+ const renderNoteContentWithWarning = (options?: {
+ contentClassName?: string;
+ mediaClickHandler?: (url: string) => void;
+ showName?: boolean;
+ }) => {
+ if (!contentWarning) {
+ return renderNoteContent(options);
+ }
+
+ return (
+
+ {isContentWarningRevealed && renderNoteContent(options)}
+ {showContentWarningOverlay && renderContentWarningOverlay(isContentWarningRevealed)}
+
+ );
+ };
+
let author = actor;
if (type === 'Announce') {
author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor;
@@ -487,37 +813,18 @@ const FeedItem: React.FC
= ({
- {(object.type === 'Article') ?
- {renderFeedAttachment(object, onClick, brokenImages, handleImageError)}
-
-
{object.name}
-
{object.preview?.content}
-
-
:
-
-
- {!isLoading ?
-
{
- const target = e.target as HTMLElement;
- if (
- target.tagName === 'A' ||
- target.closest('a')
- ) {
- e.stopPropagation();
- }
- }}
- />
- :
-
- }
+ {(object.type === 'Article') ? (
+ shouldHideContentWarning ? renderContentWarningOverlay() :
+
+ {renderFeedMedia(onClick)}
+
+
{object.name}
+
{object.preview?.content}
+
- {isTruncated && (
-
- )}
- {renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)}
+ ) :
+
+ {renderNoteContentWithWarning()}
}
@@ -577,9 +884,10 @@ const FeedItem: React.FC
= ({
>}
- {object.name &&
{object.name}
}
-
- {renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)}
+ {renderNoteContentWithWarning({
+ contentClassName: 'ap-note-content-large break-anywhere text-[1.6rem] tracking-[-0.011em] text-pretty text-gray-900 dark:text-gray-300 [&_p+p]:mt-3',
+ showName: true
+ })}
{showStats && = ({
- {(object.type === 'Article') && renderFeedAttachment(object, onClick, brokenImages, handleImageError)}
- {object.name &&
{object.name}
}
- {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
}
- {(object.type === 'Note') && renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)}
- {(object.type === 'Article') &&
}
+ {shouldHideContentWarning ? renderContentWarningOverlay() : <>
+ {(object.type === 'Article') && renderFeedMedia(onClick)}
+ {object.name &&
{object.name}
}
+ {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
}
+ {(object.type === 'Note') && renderFeedMedia(openLightbox)}
+ {(object.type === 'Article') &&
}
+ >}
{!isCompact &&
{showStats && = ({
-
- {isLoading ? : (object.name ? object.name : (
-
- ))}
-
-
- {!isLoading ?
-
- :
-
- }
-
-
- {!isLoading ? (object.content && `${getReadingTime(object.content)}`) : }
-
+ {shouldHideContentWarning ? renderContentWarningOverlay() : <>
+
+ {isLoading ? : (object.name ? object.name : (
+
+ ))}
+
+
+ {!isLoading ?
+
+ :
+
+ }
+
+
+ {!isLoading ? (object.content && `${getReadingTime(object.content)}`) : }
+
+ >}
{showStats && = ({
- {renderInboxAttachment(object, isLoading)}
+ {shouldHideContentWarning ? null : shouldHideSensitiveMedia ? (
+
+ ) : renderInboxAttachment(object, isLoading)}
)}
>
diff --git a/apps/activitypub/src/hooks/use-activity-pub-queries.ts b/apps/activitypub/src/hooks/use-activity-pub-queries.ts
index 6550d048dd0..cd7bf63102f 100644
--- a/apps/activitypub/src/hooks/use-activity-pub-queries.ts
+++ b/apps/activitypub/src/hooks/use-activity-pub-queries.ts
@@ -9,6 +9,7 @@ import {
type GetAccountFollowsResponse,
type Notification,
type Post,
+ type Preferences,
type ReplyChainResponse,
type SearchResults,
type SocialWebDomain,
@@ -95,6 +96,7 @@ const QUERY_KEYS = {
postsLikedByAccount: ['account_liked_posts'],
notifications: (handle: string) => ['notifications', handle],
notificationsCount: (handle: string) => ['notifications_count', handle],
+ preferences: ['preferences'],
blockedAccounts: (handle: string) => ['blocked_accounts', handle],
blockedDomains: (handle: string) => ['blocked_domains', handle],
topics: () => ['topics']
@@ -1784,6 +1786,61 @@ export function useFeedForUser(options: {enabled: boolean}) {
return {feedQuery, updateFeedActivity};
}
+export function usePreferencesForUser() {
+ return useQuery
({
+ queryKey: QUERY_KEYS.preferences,
+ // Preferences are owner/admin only, so a failure here is usually a 403
+ // that won't resolve by asking again. Callers fall back to hiding
+ // sensitive media.
+ retry: false,
+ async queryFn() {
+ const siteUrl = await getSiteUrl();
+ const api = createActivityPubAPI('index', siteUrl);
+
+ return api.getPreferences();
+ }
+ });
+}
+
+export function useUpdatePreferencesForUser({onError}: {onError?: () => void} = {}) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ scope: {id: 'preferences'},
+ async mutationFn(preferences: Preferences) {
+ const siteUrl = await getSiteUrl();
+ const api = createActivityPubAPI('index', siteUrl);
+
+ return api.updatePreferences(preferences);
+ },
+ async onMutate(preferences) {
+ await queryClient.cancelQueries({
+ queryKey: QUERY_KEYS.preferences
+ });
+
+ const previousPreferences = queryClient.getQueryData(QUERY_KEYS.preferences);
+
+ queryClient.setQueryData(QUERY_KEYS.preferences, preferences);
+
+ return {previousPreferences};
+ },
+ onError(_error, _preferences, context) {
+ if (context?.previousPreferences === undefined) {
+ // There was nothing to roll back to, so drop the optimistic
+ // value rather than leaving showSensitiveMedia: true cached.
+ queryClient.removeQueries({queryKey: QUERY_KEYS.preferences});
+ } else {
+ queryClient.setQueryData(QUERY_KEYS.preferences, context.previousPreferences);
+ }
+
+ onError?.();
+ },
+ onSuccess(preferences) {
+ queryClient.setQueryData(QUERY_KEYS.preferences, preferences);
+ }
+ });
+}
+
export function useInboxForUser(options: {enabled: boolean}) {
const queryKey = QUERY_KEYS.inbox;
const queryClient = useQueryClient();
diff --git a/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts b/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts
new file mode 100644
index 00000000000..c992016221e
--- /dev/null
+++ b/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts
@@ -0,0 +1,121 @@
+import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
+import {usePreferencesForUser} from '@hooks/use-activity-pub-queries';
+
+interface SensitiveMediaDisclosureOptions {
+ contentWarning?: string | null;
+ sensitive?: boolean;
+ /**
+ * Whether the post actually carries media worth concealing. Each surface
+ * detects this differently — the feed looks at attachments, the reader also
+ * considers the feature image and media embedded in the article body.
+ */
+ hasMedia: boolean;
+ /**
+ * Resets the disclosure state when it changes, so a revealed post never
+ * leaks its reveal into the next post rendered by the same component.
+ */
+ resetKey?: string;
+}
+
+interface SensitiveMediaDisclosure {
+ contentWarning: string | null;
+ shouldHideContentWarning: boolean;
+ shouldHideSensitiveMedia: boolean;
+ canHideSensitiveMedia: boolean;
+ isContentWarningRevealed: boolean;
+ showContentWarningOverlay: boolean;
+ contentWarningMinHeight: number | undefined;
+ contentWarningWrapperRef: React.RefObject;
+ revealSensitiveMedia: (event: React.MouseEvent) => void;
+ hideSensitiveMedia: (event: React.MouseEvent) => void;
+ revealContentWarning: (event: React.MouseEvent) => void;
+}
+
+/**
+ * Owns the reveal/hide state for sensitive media and content warnings.
+ *
+ * Both disclosures fail closed: until the preferences query resolves, and
+ * whenever it fails, sensitive media stays hidden.
+ *
+ * A content warning covers the whole post, so it also covers any media inside
+ * it. Revealing the warning therefore reveals the media too, rather than
+ * stacking a second warning on top of the first.
+ */
+export function useSensitiveMediaDisclosure({
+ contentWarning: rawContentWarning,
+ sensitive,
+ hasMedia,
+ resetKey
+}: SensitiveMediaDisclosureOptions): SensitiveMediaDisclosure {
+ const {data: preferences} = usePreferencesForUser();
+ const showSensitiveMediaByDefault = preferences?.showSensitiveMedia ?? false;
+
+ const [isSensitiveMediaRevealed, setIsSensitiveMediaRevealed] = useState(false);
+ const [isSensitiveMediaManuallyHidden, setIsSensitiveMediaManuallyHidden] = useState(false);
+ const [isContentWarningRevealed, setIsContentWarningRevealed] = useState(false);
+ const [showContentWarningOverlay, setShowContentWarningOverlay] = useState(true);
+ const [contentWarningMinHeight, setContentWarningMinHeight] = useState(undefined);
+ const contentWarningWrapperRef = useRef(null);
+
+ useEffect(() => {
+ setIsSensitiveMediaRevealed(false);
+ setIsSensitiveMediaManuallyHidden(false);
+ setIsContentWarningRevealed(false);
+ setShowContentWarningOverlay(true);
+ setContentWarningMinHeight(undefined);
+ }, [resetKey]);
+
+ useLayoutEffect(() => {
+ if (!isContentWarningRevealed || !showContentWarningOverlay) {
+ return;
+ }
+
+ // Content is mounted under the overlay; remove the overlay before paint
+ // so the browser never paints an empty intermediate frame.
+ setShowContentWarningOverlay(false);
+ setContentWarningMinHeight(undefined);
+ }, [isContentWarningRevealed, showContentWarningOverlay]);
+
+ const contentWarning = rawContentWarning?.trim() || null;
+ const hasContentWarning = contentWarning !== null;
+ const hasSensitiveMedia = sensitive === true && hasMedia;
+ const isSensitiveMediaGated = hasSensitiveMedia && !hasContentWarning && !showSensitiveMediaByDefault;
+ const shouldHideSensitiveMedia = isSensitiveMediaGated && (isSensitiveMediaManuallyHidden || !isSensitiveMediaRevealed);
+
+ const revealSensitiveMedia = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ setIsSensitiveMediaManuallyHidden(false);
+ setIsSensitiveMediaRevealed(true);
+ };
+
+ const hideSensitiveMedia = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ setIsSensitiveMediaManuallyHidden(true);
+ setIsSensitiveMediaRevealed(false);
+ };
+
+ const revealContentWarning = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ // Pin the collapsed height so the post doesn't jump while the revealed
+ // content mounts underneath the overlay.
+ const height = contentWarningWrapperRef.current?.offsetHeight;
+ if (height) {
+ setContentWarningMinHeight(height);
+ }
+ setIsContentWarningRevealed(true);
+ };
+
+ return {
+ contentWarning,
+ shouldHideContentWarning: hasContentWarning && !isContentWarningRevealed,
+ shouldHideSensitiveMedia,
+ canHideSensitiveMedia: isSensitiveMediaGated && !shouldHideSensitiveMedia,
+ isContentWarningRevealed,
+ showContentWarningOverlay,
+ contentWarningMinHeight,
+ contentWarningWrapperRef,
+ revealSensitiveMedia,
+ hideSensitiveMedia,
+ revealContentWarning
+ };
+}
diff --git a/apps/activitypub/src/utils/posts.ts b/apps/activitypub/src/utils/posts.ts
index 721aef0d030..8e039c4d85a 100644
--- a/apps/activitypub/src/utils/posts.ts
+++ b/apps/activitypub/src/utils/posts.ts
@@ -94,6 +94,8 @@ export function mapPostToActivity(post: Post): Activity {
name: post.title,
content: post.content,
summary: post.summary,
+ sensitive: post.sensitive,
+ contentWarning: post.contentWarning,
url: post.url,
attributedTo: actor,
image: post.featureImageUrl ?? '',
diff --git a/apps/activitypub/src/views/inbox/components/customizer.tsx b/apps/activitypub/src/views/inbox/components/customizer.tsx
index bb2e70daf27..c860c29d39c 100644
--- a/apps/activitypub/src/views/inbox/components/customizer.tsx
+++ b/apps/activitypub/src/views/inbox/components/customizer.tsx
@@ -161,7 +161,7 @@ const CustomizerView: React.FC = ({
}) => (
-
@@ -208,6 +208,7 @@ const CustomizerView: React.FC = ({
= ({
= ({
Aa
void;
}
+const SENSITIVE_MEDIA_HIDDEN_CLASS = 'gh-sensitive-media-hidden';
+
+// Shared by the detection helper and the iframe stylesheet so we never conceal
+// less than we detect
+const MEDIA_ELEMENT_SELECTOR = 'audio, canvas, embed, iframe, img, object, picture, source, svg, video';
+
const ArticleBody: React.FC<{
postUrl?: string;
heading: string;
@@ -40,6 +47,7 @@ const ArticleBody: React.FC<{
profile_image: string;
}>;
html: string;
+ hideMedia?: boolean;
backgroundColor: ColorOption;
fontSize: FontSize;
fontStyle: string;
@@ -54,6 +62,7 @@ const ArticleBody: React.FC<{
excerpt,
authors,
html,
+ hideMedia = false,
backgroundColor,
fontSize,
fontStyle,
@@ -65,6 +74,9 @@ const ArticleBody: React.FC<{
const iframeRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [iframeHeight, setIframeHeight] = useState('0px');
+ // Tracks which article is currently rendered in the iframe so srcdoc is only
+ // rewritten when the article itself changes
+ const renderedArticleRef = useRef(null);
const darkMode = (document.documentElement.classList.contains('dark') && backgroundColor === 'SYSTEM') || backgroundColor === 'DARK';
const cssContent = articleBodyStyles();
@@ -77,8 +89,23 @@ const ArticleBody: React.FC<{
return sanitizeArticleContent(openLinksInNewTab(transformedHtml));
}, [html, shouldEnforceVideoCardInlinePlayback]);
+ const updateSensitiveMediaVisibility = useCallback(() => {
+ const iframe = iframeRef.current;
+ const iframeDocument = iframe?.contentDocument;
+ if (!iframeDocument) {
+ return;
+ }
+
+ iframeDocument.documentElement.classList.toggle(SENSITIVE_MEDIA_HIDDEN_CLASS, hideMedia);
+
+ const iframeWindow = iframe.contentWindow as IframeWindow | null;
+ if (iframeWindow && typeof iframeWindow.resizeIframe === 'function') {
+ iframeWindow.resizeIframe();
+ }
+ }, [hideMedia]);
+
const htmlContent = `
-
+
${cssContent}
-
${htmlRef.current}
diff --git a/apps/admin/src/settings/app/components/settings/growth/embed-signup/embed-signup-sidebar.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/growth/embed-signup/embed-signup-sidebar.tsx
rename to apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx
diff --git a/apps/admin/src/settings/app/components/settings/growth/explore.tsx b/apps/admin/src/settings/growth/explore.tsx
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/growth/explore.tsx
rename to apps/admin/src/settings/growth/explore.tsx
index 7b158796ef3..687448e8028 100644
--- a/apps/admin/src/settings/app/components/settings/growth/explore.tsx
+++ b/apps/admin/src/settings/growth/explore.tsx
@@ -32,7 +32,7 @@ const Explore: React.FC<{ keywords: string[] }> = ({keywords}) => {
setMembersCount(count);
};
- fetchMemberCount();
+ void fetchMemberCount();
}, [fetchMembers]);
const [accentColor, icon] = getSettingValues(settings, ['accent_color', 'icon']);
@@ -65,7 +65,7 @@ const Explore: React.FC<{ keywords: string[] }> = ({keywords}) => {
aria-label='Ghost Explore'
checked={exploreEnabled}
data-testid='explore-toggle'
- onCheckedChange={checked => toggleSetting('explore_ping', checked)}
+ onCheckedChange={checked => void toggleSetting('explore_ping', checked)}
/>
}
description={`Promote your site across Ghost's website and publishing network`}
@@ -86,7 +86,7 @@ const Explore: React.FC<{ keywords: string[] }> = ({keywords}) => {
checked={Boolean(shareGrowthData)}
data-testid='explore-growth-toggle'
id='explore-growth-toggle'
- onCheckedChange={checked => toggleSetting('explore_ping_growth', checked)}
+ onCheckedChange={checked => void toggleSetting('explore_ping_growth', checked)}
/>
diff --git a/apps/admin/src/settings/app/components/settings/growth/explore/testimonials-modal.tsx b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/growth/explore/testimonials-modal.tsx
rename to apps/admin/src/settings/growth/explore/testimonials-modal.tsx
index 994b85f253b..59cee4d02ae 100644
--- a/apps/admin/src/settings/app/components/settings/growth/explore/testimonials-modal.tsx
+++ b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx
@@ -217,9 +217,7 @@ const TestimonialsModal = () => {
{
- await handleSave();
- }}
+ onClick={() => void handleSave()}
>
{saveState === 'saving' && }
Send testimonial
diff --git a/apps/admin/src/settings/app/components/settings/growth/growth-settings.tsx b/apps/admin/src/settings/growth/growth-settings.tsx
similarity index 74%
rename from apps/admin/src/settings/app/components/settings/growth/growth-settings.tsx
rename to apps/admin/src/settings/growth/growth-settings.tsx
index b2b33926a6d..2b68f8ecfbf 100644
--- a/apps/admin/src/settings/app/components/settings/growth/growth-settings.tsx
+++ b/apps/admin/src/settings/growth/growth-settings.tsx
@@ -6,16 +6,9 @@ import React from 'react';
import Recommendations from './recommendations';
import SearchableSection from '@/settings/app/components/searchable-section';
import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings';
+import {searchKeywords} from './search-keywords';
import {useGlobalData} from '@/settings/app/components/providers/global-data-provider';
-export const searchKeywords = {
- network: ['growth', 'network', 'activitypub', 'blog', 'fediverse', 'sharing'],
- explore: ['ghost explore', 'explore', 'growth', 'share', 'list', 'listing'],
- recommendations: ['growth', 'recommendations', 'recommend', 'blogroll'],
- embedSignupForm: ['growth', 'embeddable signup form', 'embeddable form', 'embeddable sign up form', 'embeddable sign up'],
- offers: ['growth', 'offers', 'discounts', 'coupons', 'promotions']
-};
-
const GrowthSettings: React.FC = () => {
const {config, settings} = useGlobalData();
const hasStripeEnabled = checkStripeEnabled(settings || [], config || {});
diff --git a/apps/admin/src/settings/app/components/settings/growth/network.tsx b/apps/admin/src/settings/growth/network.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/growth/network.tsx
rename to apps/admin/src/settings/growth/network.tsx
index e0b19172f6a..bfe77585731 100644
--- a/apps/admin/src/settings/app/components/settings/growth/network.tsx
+++ b/apps/admin/src/settings/growth/network.tsx
@@ -62,7 +62,7 @@ const Network: React.FC<{ keywords: string[] }> = ({keywords}) => {
aria-label='Network'
checked={isChecked}
disabled={isDisabled}
- onCheckedChange={toggleSocialWebSetting}
+ onCheckedChange={checked => void toggleSocialWebSetting(checked)}
/>
}
description='Distribute posts to the social web, so people can discover and follow your content across BlueSky, Threads, Mastodon, Flipboard, WordPress, and more.'
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers.tsx b/apps/admin/src/settings/growth/offers.tsx
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/growth/offers.tsx
rename to apps/admin/src/settings/growth/offers.tsx
index a521e85c4de..82d3b5ed8b1 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers.tsx
+++ b/apps/admin/src/settings/growth/offers.tsx
@@ -48,4 +48,4 @@ const Offers: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
-export default withErrorBoundary(Offers, 'Portal settings');
+export default withErrorBoundary(Offers, 'Offers');
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/add-offer-modal.tsx b/apps/admin/src/settings/growth/offers/add-offer-modal.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/growth/offers/add-offer-modal.tsx
rename to apps/admin/src/settings/growth/offers/add-offer-modal.tsx
index 08f78ec211c..73dd0f299d7 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/add-offer-modal.tsx
+++ b/apps/admin/src/settings/growth/offers/add-offer-modal.tsx
@@ -1,4 +1,4 @@
-import PortalFrame from '@/settings/app/components/settings/membership/portal/portal-frame';
+import PortalFrame from '@/settings/membership/portal/portal-frame';
import {Button, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea} from '@tryghost/shade/components';
import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks';
import {JSONError} from '@tryghost/admin-x-framework/errors';
@@ -293,7 +293,7 @@ const Sidebar: React.FC = ({tierOptions,
{
handleCodeInput(e);
}} onKeyDown={() => clearError('code')} />
- {errors.code ? {errors.code} : overrides.code.value !== '' && {homepageUrl}{overrides.code.value}
{isCopied ? 'Copied' : 'Copy'} }
+ {errors.code ? {errors.code} : overrides.code.value !== '' && {homepageUrl}{overrides.code.value}
void handleCopyClick()}>{isCopied ? 'Copied' : 'Copy'} }
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx
rename to apps/admin/src/settings/growth/offers/edit-offer-modal.tsx
index d71bc1bbd8a..0ba5e7c992a 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx
+++ b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx
@@ -1,4 +1,4 @@
-import PortalFrame from '@/settings/app/components/settings/membership/portal/portal-frame';
+import PortalFrame from '@/settings/membership/portal/portal-frame';
import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs';
import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Textarea} from '@tryghost/shade/components';
import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
@@ -55,7 +55,7 @@ const Sidebar: React.FC<{
setTimeout(() => setIsCopied(false), 2000);
};
- const confirmStatusChange = async () => {
+ const confirmStatusChange = () => {
if (offer?.status === 'active') {
confirm({
title: 'Archive offer',
@@ -139,7 +139,7 @@ const Sidebar: React.FC<{
Offer code
updateOffer({code: e.target.value})} onKeyDown={() => clearError('code')} />
- {offer?.code !== '' && {isCopied ? 'Copied!' : 'Copy link'}}
+ {offer?.code !== '' && void handleCopyClick()}>{isCopied ? 'Copied!' : 'Copy link'}}
{errors.code ? {errors.code} : offer?.code !== '' && {homepageUrl}{offer?.code}}
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx
rename to apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx
index 12ff2606b38..c593d544af0 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx
+++ b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx
@@ -1,4 +1,4 @@
-import PortalFrame from '@/settings/app/components/settings/membership/portal/portal-frame';
+import PortalFrame from '@/settings/membership/portal/portal-frame';
import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs';
import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks';
import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea} from '@tryghost/shade/components';
@@ -358,7 +358,7 @@ const RetentionOfferSidebar: React.FC<{
);
};
-const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => {
+const EditRetentionOfferModal: React.FC<{cadence: 'monthly' | 'yearly'}> = ({cadence}) => {
const {updateRoute} = useSettingsNavigation();
const {siteData} = useGlobalData();
const {data: {tiers = []} = {}} = useBrowseTiers();
@@ -367,7 +367,6 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => {
const {mutateAsync: editOffer} = useEditOffer();
const invalidateOffers = useInvalidateOffers();
const [href, setHref] = useState('');
- const cadence = id === 'monthly' ? 'monthly' : 'yearly' as const;
const breadcrumbTitle = cadence === 'monthly' ? 'Monthly retention' : 'Yearly retention';
const offerCadence = cadence === 'monthly' ? 'month' : 'year';
const activePaidTiers = getPaidActiveTiers(tiers || []);
@@ -540,7 +539,7 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => {
});
const activeRetentionOfferId = editableRetentionOffer?.id || 'none';
- const currentOfferKey = `${id}:${activeRetentionOfferId}`;
+ const currentOfferKey = `${cadence}:${activeRetentionOfferId}`;
useEffect(() => {
if (!hasFetchedOffers || isFetchingOffers || saveState === 'unsaved' || initializedOfferKey === currentOfferKey) {
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts b/apps/admin/src/settings/growth/offers/offer-helpers.test.ts
similarity index 96%
rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts
rename to apps/admin/src/settings/growth/offers/offer-helpers.test.ts
index cfc54c60f3d..ce631aabe02 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts
+++ b/apps/admin/src/settings/growth/offers/offer-helpers.test.ts
@@ -1,5 +1,5 @@
import * as assert from 'assert/strict';
-import {MAX_RETENTION_OFFER_NAME_LENGTH, generateRetentionOfferName} from '@/settings/app/components/settings/growth/offers/offer-helpers';
+import {MAX_RETENTION_OFFER_NAME_LENGTH, generateRetentionOfferName} from '@/settings/growth/offers/offer-helpers';
describe('generateRetentionOfferName', function () {
it('keeps the full 8-character hash when the preferred wording fits', function () {
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts b/apps/admin/src/settings/growth/offers/offer-helpers.ts
similarity index 53%
rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts
rename to apps/admin/src/settings/growth/offers/offer-helpers.ts
index fcfa651cdb8..f626814a868 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts
+++ b/apps/admin/src/settings/growth/offers/offer-helpers.ts
@@ -1,3 +1,6 @@
+import {type Tier} from '@tryghost/admin-x-framework/api/tiers';
+import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency';
+import {formatNumber} from '@tryghost/shade/utils';
import {getGhostPaths} from '@tryghost/admin-x-framework/helpers';
const MAX_RETENTION_OFFER_NAME_LENGTH = 40;
@@ -64,3 +67,53 @@ export const generateRetentionOfferName = (input: {amount: number; duration: str
export {
MAX_RETENTION_OFFER_NAME_LENGTH
};
+
+export type OfferType = 'percent' | 'fixed' | 'trial';
+
+export const getOfferCadence = (cadence: string): string => {
+ return cadence === 'month' ? 'monthly' : 'yearly';
+};
+
+export const getOfferDuration = (duration: string): string => {
+ return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever');
+};
+
+export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => {
+ let discountOffer = '';
+ const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0;
+ let updatedPrice = originalPrice;
+
+ const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2));
+ const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2});
+
+ let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice);
+
+ switch (type) {
+ case 'percent':
+ discountOffer = `${formatNumber(amount)}% off`;
+ updatedPrice = originalPrice - ((originalPrice * amount) / 100);
+ break;
+ case 'fixed':
+ discountOffer = `${formatPrice(amount)} ${currency} off`;
+ updatedPrice = originalPrice - amount;
+ break;
+ case 'trial':
+ discountOffer = `${formatNumber(amount)} days free`;
+ originalPriceWithCurrency = '';
+ break;
+ default:
+ break;
+ };
+
+ if (updatedPrice < 0) {
+ updatedPrice = 0;
+ }
+
+ const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice);
+
+ return {
+ discountOffer,
+ originalPriceWithCurrency,
+ updatedPriceWithCurrency
+ };
+};
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx b/apps/admin/src/settings/growth/offers/offer-success.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx
rename to apps/admin/src/settings/growth/offers/offer-success.tsx
index a44d9708ff0..886675f9fe7 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx
+++ b/apps/admin/src/settings/growth/offers/offer-success.tsx
@@ -95,7 +95,7 @@ const OfferSuccess: React.FC<{id: string}> = ({id}) => {
- {isCopied ? 'Copied!' : 'Copy link'}
+ void handleCopyClick()}>{isCopied ? 'Copied!' : 'Copy link'}
OR
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx
similarity index 65%
rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx
rename to apps/admin/src/settings/growth/offers/offers-container-modal.tsx
index a52a860888f..32d88808901 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx
+++ b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx
@@ -9,13 +9,24 @@ type OffersRouteHandlerProps = {
route: string;
};
+type RetentionCadence = 'monthly' | 'yearly';
+
+const RETENTION_ROUTE_PREFIX = 'offers/edit/retention/';
+
+const getRetentionCadence = (route: string): RetentionCadence | null => {
+ const suffix = route.slice(RETENTION_ROUTE_PREFIX.length).replace(/\/+$/, '');
+ return suffix === 'monthly' || suffix === 'yearly' ? suffix : null;
+};
+
const OffersRouteHandler: React.FC
= ({route}) => {
if (route === 'offers/new') {
return ;
- } else if (route.startsWith('offers/edit/retention/') && route.length > 'offers/edit/retention/'.length) {
- const retentionId = route.split('/').pop();
- return ;
- } else if (route === 'offers/edit/retention/' || route === 'offers/edit/retention') {
+ } else if (route === 'offers/edit/retention' || route.startsWith(RETENTION_ROUTE_PREFIX)) {
+ const retentionCadence = route.startsWith(RETENTION_ROUTE_PREFIX) ? getRetentionCadence(route) : null;
+ if (retentionCadence) {
+ return ;
+ }
+
return ;
} else if (route.startsWith('offers/edit/') && route.length > 'offers/edit/'.length) {
const offerId = route.split('/').pop();
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx b/apps/admin/src/settings/growth/offers/offers-index.tsx
similarity index 90%
rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx
rename to apps/admin/src/settings/growth/offers/offers-index.tsx
index 55521fe5de3..c05440910b2 100644
--- a/apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx
+++ b/apps/admin/src/settings/growth/offers/offers-index.tsx
@@ -5,62 +5,11 @@ import {type Offer, useBrowseOffers} from '@tryghost/admin-x-framework/api/offer
import {type RetentionOffer, getRetentionOffers} from './offers-retention';
import {SettingsModal} from '@tryghost/shade/patterns';
import {type Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers';
-import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl} from './offer-helpers';
-import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency';
+import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl, getOfferCadence, getOfferDiscount, getOfferDuration} from './offer-helpers';
import {toast} from 'sonner';
import {useOffersShowArchived, useSortingState} from '@/settings/app/components/providers/settings-app-provider';
import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation';
-export type OfferType = 'percent' | 'fixed' | 'trial';
-
-export const getOfferCadence = (cadence: string): string => {
- return cadence === 'month' ? 'monthly' : 'yearly';
-};
-
-export const getOfferDuration = (duration: string): string => {
- return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever');
-};
-
-export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => {
- let discountOffer = '';
- const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0;
- let updatedPrice = originalPrice;
-
- const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2));
- const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2});
-
- let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice);
-
- switch (type) {
- case 'percent':
- discountOffer = `${formatNumber(amount)}% off`;
- updatedPrice = originalPrice - ((originalPrice * amount) / 100);
- break;
- case 'fixed':
- discountOffer = `${formatPrice(amount)} ${currency} off`;
- updatedPrice = originalPrice - amount;
- break;
- case 'trial':
- discountOffer = `${formatNumber(amount)} days free`;
- originalPriceWithCurrency = '';
- break;
- default:
- break;
- };
-
- if (updatedPrice < 0) {
- updatedPrice = 0;
- }
-
- const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice);
-
- return {
- discountOffer,
- originalPriceWithCurrency,
- updatedPriceWithCurrency
- };
-};
-
const OffersFilterMenu: React.FC<{
showArchived: boolean;
setShowArchived: (show: boolean) => void;
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-retention.tsx b/apps/admin/src/settings/growth/offers/offers-retention.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-retention.tsx
rename to apps/admin/src/settings/growth/offers/offers-retention.tsx
diff --git a/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx b/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx
index f99937593bb..b3288141eb4 100644
--- a/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx
+++ b/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx
@@ -1,4 +1,4 @@
-import {describe, expect, it} from "vitest";
+import {describe, expect, it, vi} from "vitest";
import {browseResponse, currentRoute, fakeAdminEndpoint, fakeSettingsScreens, renderAdminApp} from "@test-utils/acceptance";
import {settingsScreen} from "@/settings/settings.screen";
@@ -33,7 +33,7 @@ const incomingRecommendations = [
excerpt: "Incoming recommendation 1 excerpt",
featured_image: "https://incoming1.com/image.jpg",
favicon: "https://incoming1.com/favicon.ico",
- url: "https://incoming1.com/?ref=ghost",
+ url: "https://incoming1.com/path?ref=ghost&source=email#featured",
recommending_back: false,
},
{
@@ -182,7 +182,40 @@ describe("Recommendations settings", () => {
await expect.element(rows.last()).toHaveTextContent("Incoming recommendation 2 title");
await expect.element(rows.last()).toHaveTextContent("Recommending");
+ const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+ await rows.first().getByText("Incoming recommendation 1 title").click();
+ expect(openSpy).toHaveBeenCalledWith(incomingRecommendations[0].url, "_blank", "noopener,noreferrer");
+ openSpy.mockRestore();
+
await rows.first().getByRole("button", {name: "Recommend back"}).click();
- await expect.poll(currentRoute).toBe("/settings/recommendations/add?url=https://incoming1.com/?ref=ghost");
+ await expect.poll(currentRoute).toBe(`/settings/recommendations/add?url=${encodeURIComponent(incomingRecommendations[0].url)}`);
+ });
+
+ it.each([
+ {scheme: "javascript", url: "javascript:alert(document.domain)"},
+ {scheme: "data", url: "data:text/html,"},
+ ])("blocks $scheme incoming recommendation URLs", async ({url}) => {
+ const unsafeRecommendation = {
+ ...incomingRecommendations[0],
+ id: `unsafe-${url}`,
+ title: "Unsafe incoming recommendation",
+ url,
+ };
+ fakeSettingsScreens();
+ fakeRecommendations();
+ fakeAdminEndpoint("GET", /^\/incoming_recommendations\/\?/, browseResponse("recommendations", [unsafeRecommendation], {limit: 5}));
+ await renderAdminApp("/settings/recommendations");
+
+ const section = settingsScreen.section("recommendations");
+ await section.getByRole("tab", {name: "Recommending you"}).click();
+ const row = section.getByTestId("incoming-recommendation-list-item");
+ const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+
+ await row.getByText("Unsafe incoming recommendation").click();
+ expect(openSpy).not.toHaveBeenCalled();
+
+ await row.getByRole("button", {name: "Recommend back"}).click();
+ await expect.poll(currentRoute).toBe("/settings/recommendations");
+ openSpy.mockRestore();
});
});
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations.tsx b/apps/admin/src/settings/growth/recommendations.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations.tsx
rename to apps/admin/src/settings/growth/recommendations.tsx
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx
similarity index 95%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx
rename to apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx
index 827068a6651..edb92496993 100644
--- a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx
+++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form';
+import RecommendationDescriptionForm from './recommendation-description-form';
+import {validateDescriptionForm} from './recommendation-validation';
import trackEvent from '@/settings/app/utils/analytics';
import {Button} from '@tryghost/shade/components';
import {type EditOrAddRecommendation, useAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations';
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx
rename to apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx
index 3917f0032c6..b0af9b4644c 100644
--- a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx
+++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx
@@ -139,13 +139,13 @@ const AddRecommendationModal: React.FC = () => {
React.useEffect(() => {
if (showLoadingView && !didInitialSubmit.current) {
didInitialSubmit.current = true;
- onOk();
+ void onOk();
}
}, [showLoadingView, onOk]);
useEffect(() => {
if (enterPressed) {
- onOk();
+ void onOk();
setEnterPressed(false); // Reset for future use
}
}, [formState]);
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx
similarity index 95%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx
rename to apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx
index 44b50f2c728..61c4a3d9c3e 100644
--- a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx
+++ b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form';
+import RecommendationDescriptionForm from './recommendation-description-form';
+import {validateDescriptionForm} from './recommendation-validation';
import {Button} from '@tryghost/shade/components';
import {type Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations';
import {SettingsModal} from '@tryghost/shade/patterns';
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx
similarity index 90%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx
rename to apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx
index fcd0653cb55..9d8cfe23df6 100644
--- a/apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx
+++ b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx
@@ -15,6 +15,15 @@ interface IncomingRecommendationListProps {
isLoading: boolean
}
+const getHttpRecommendationUrl = (url: string): string | null => {
+ try {
+ const protocol = new URL(url).protocol;
+ return protocol === 'http:' || protocol === 'https:' ? url : null;
+ } catch {
+ return null;
+ }
+};
+
const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingRecommendation, stats: ReferrerHistoryItem[]}> = ({incomingRecommendation, stats}) => {
const {updateRoute} = useSettingsNavigation();
@@ -36,11 +45,17 @@ const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingReco
}, [stats, incomingRecommendation.url]);
const recommendBack = () => {
- updateRoute({route: `recommendations/add?url=${incomingRecommendation.url}`});
+ const url = getHttpRecommendationUrl(incomingRecommendation.url);
+ if (url) {
+ updateRoute({route: `recommendations/add?url=${encodeURIComponent(url)}`});
+ }
};
const showDetails = () => {
- window.open(incomingRecommendation.url, '_blank');
+ const url = getHttpRecommendationUrl(incomingRecommendation.url);
+ if (url) {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
};
const freeMembersLabel = signups === 1 ? 'free member' : 'free members';
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx
similarity index 81%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx
rename to apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx
index d9b2ded5275..ad8db68c384 100644
--- a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx
+++ b/apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx
@@ -5,6 +5,7 @@ import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks';
import {Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Textarea} from '@tryghost/shade/components';
import {Text} from '@tryghost/shade/primitives';
import {formatNumber} from '@tryghost/shade/utils';
+import {validateDescriptionForm} from './recommendation-validation';
interface Props {
showURL?: boolean,
@@ -15,39 +16,6 @@ interface Props {
setErrors: (errors: ErrorMessages) => void
}
-export const validateDescriptionFormField = function (errors: ErrorMessages, field: 'title'|'description', value: string|null) {
- const cloned = {...errors};
- switch (field) {
- case 'title':
- if (!value) {
- cloned.title = 'Title is required';
- } else {
- delete cloned.title;
- }
- break;
- case 'description':
- if (value && value.length > 200) {
- cloned.description = 'Description cannot be longer than 200 characters';
- } else {
- delete cloned.description;
- }
- break;
- default: {
- // Will throw a compile error if we forget to add a case for a field
- const f: never = field;
- throw new Error(`Unknown field ${f}`);
- }
- }
- return cloned;
-};
-
-export const validateDescriptionForm = function (formState: EditOrAddRecommendation) {
- let newErrors: ErrorMessages = {};
- newErrors = validateDescriptionFormField(newErrors, 'title', formState.title);
- newErrors = validateDescriptionFormField(newErrors, 'description', formState.description);
- return newErrors;
-};
-
function RecommendationDescriptionForm({showURL, formState, updateForm, errors, clearError, setErrors}: Props) {
const [descriptionLength, setDescriptionLength] = React.useState(formState?.description?.length || 0);
const descriptionLengthColor = descriptionLength > 200 ? 'text-destructive' : 'text-foreground';
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-icon.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-icon.tsx
rename to apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx
diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx
rename to apps/admin/src/settings/growth/recommendations/recommendation-list.tsx
index 07e42d790b2..a595b982442 100644
--- a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx
+++ b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx
@@ -68,7 +68,7 @@ const RecommendationList: React.FC = ({recommendations,
const [editingRecommendation, setEditingRecommendation] = useState(null);
const copyRecommendationsUrl = () => {
- navigator.clipboard.writeText(recommendationsURL);
+ void navigator.clipboard.writeText(recommendationsURL);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts b/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts
new file mode 100644
index 00000000000..35815ae1115
--- /dev/null
+++ b/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts
@@ -0,0 +1,35 @@
+import {type EditOrAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations';
+import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks';
+
+export const validateDescriptionFormField = function (errors: ErrorMessages, field: 'title'|'description', value: string|null) {
+ const cloned = {...errors};
+ switch (field) {
+ case 'title':
+ if (!value) {
+ cloned.title = 'Title is required';
+ } else {
+ delete cloned.title;
+ }
+ break;
+ case 'description':
+ if (value && value.length > 200) {
+ cloned.description = 'Description cannot be longer than 200 characters';
+ } else {
+ delete cloned.description;
+ }
+ break;
+ default: {
+ // Will throw a compile error if we forget to add a case for a field
+ const f: never = field;
+ throw new Error(`Unknown field ${String(f)}`);
+ }
+ }
+ return cloned;
+};
+
+export const validateDescriptionForm = function (formState: EditOrAddRecommendation) {
+ let newErrors: ErrorMessages = {};
+ newErrors = validateDescriptionFormField(newErrors, 'title', formState.title);
+ newErrors = validateDescriptionFormField(newErrors, 'description', formState.description);
+ return newErrors;
+};
diff --git a/apps/admin/src/settings/growth/search-keywords.ts b/apps/admin/src/settings/growth/search-keywords.ts
new file mode 100644
index 00000000000..d626653e1ab
--- /dev/null
+++ b/apps/admin/src/settings/growth/search-keywords.ts
@@ -0,0 +1,7 @@
+export const searchKeywords = {
+ network: ['growth', 'network', 'activitypub', 'blog', 'fediverse', 'sharing'],
+ explore: ['ghost explore', 'explore', 'growth', 'share', 'list', 'listing'],
+ recommendations: ['growth', 'recommendations', 'recommend', 'blogroll'],
+ embedSignupForm: ['growth', 'embeddable signup form', 'embeddable form', 'embeddable sign up form', 'embeddable sign up'],
+ offers: ['growth', 'offers', 'discounts', 'coupons', 'promotions']
+};
diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts
new file mode 100644
index 00000000000..43629aeb308
--- /dev/null
+++ b/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts
@@ -0,0 +1,64 @@
+import {describe, expect, it} from 'vitest';
+import {parseTipsAndDonationsSettings} from './tips-and-donations-settings';
+
+describe('parseTipsAndDonationsSettings', () => {
+ it('uses safe defaults when an older backend omits donation settings', () => {
+ expect(parseTipsAndDonationsSettings({})).toEqual({
+ donations_currency: 'USD',
+ donations_suggested_amount: 500
+ });
+ });
+
+ it.each([
+ {amount: 725, description: 'a backend number'},
+ {amount: '725', description: 'a dirty local string'}
+ ])('accepts the suggested amount as $description', ({amount}) => {
+ expect(parseTipsAndDonationsSettings({
+ donations_currency: 'EUR',
+ donations_suggested_amount: amount
+ })).toEqual({
+ donations_currency: 'EUR',
+ donations_suggested_amount: 725
+ });
+ });
+
+ it.each([
+ {
+ settings: {donations_currency: 'ZZZ', donations_suggested_amount: 500},
+ description: 'an unsupported currency'
+ },
+ {
+ settings: {donations_currency: null, donations_suggested_amount: 500},
+ description: 'a null currency'
+ },
+ {
+ settings: {donations_currency: 'GBP', donations_suggested_amount: 'not-a-number'},
+ description: 'a malformed amount'
+ },
+ {
+ settings: {donations_currency: 'GBP', donations_suggested_amount: null},
+ description: 'a null amount'
+ }
+ ])('rejects $description instead of applying a missing-value default', ({settings}) => {
+ expect(() => parseTipsAndDonationsSettings(settings)).toThrow();
+ });
+
+ it.each([null, [], 'invalid'])('rejects a non-object settings payload', (settings) => {
+ expect(() => parseTipsAndDonationsSettings(settings)).toThrow();
+ });
+
+ it.each([
+ {amount: -1, description: 'a negative amount'},
+ {amount: 1.5, description: 'a fractional amount'},
+ {amount: NaN, description: 'a NaN amount'},
+ {amount: Infinity, description: 'a positive infinite amount'},
+ {amount: -Infinity, description: 'a negative infinite amount'},
+ {amount: Number.MAX_SAFE_INTEGER + 1, description: 'an unsafe integer amount'},
+ {amount: '9007199254740992', description: 'an overflowing digit-only amount'}
+ ])('rejects $description', ({amount}) => {
+ expect(() => parseTipsAndDonationsSettings({
+ donations_currency: 'USD',
+ donations_suggested_amount: amount
+ })).toThrow();
+ });
+});
diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.ts
new file mode 100644
index 00000000000..bf3a98b8378
--- /dev/null
+++ b/apps/admin/src/settings/growth/tips-and-donations-settings.ts
@@ -0,0 +1,35 @@
+import {currencySelectGroups} from '@/settings/app/utils/currency';
+import {z} from 'zod';
+
+const DEFAULT_DONATIONS_CURRENCY = 'USD';
+const DEFAULT_DONATIONS_SUGGESTED_AMOUNT = 500;
+
+const supportedCurrencies = new Set(
+ currencySelectGroups().flatMap(group => group.options.map(option => option.value))
+);
+
+const donationsCurrencySchema = z.string()
+ .refine(currency => supportedCurrencies.has(currency));
+
+const donationsSuggestedAmountNumberSchema = z.number()
+ .finite()
+ .int()
+ .nonnegative()
+ .max(Number.MAX_SAFE_INTEGER);
+
+const donationsSuggestedAmountSchema = z.union([
+ donationsSuggestedAmountNumberSchema,
+ z.string()
+ .regex(/^\d+$/)
+ .transform(Number)
+ .pipe(donationsSuggestedAmountNumberSchema)
+]);
+
+export const tipsAndDonationsSettingsSchema = z.object({
+ donations_currency: donationsCurrencySchema.default(DEFAULT_DONATIONS_CURRENCY),
+ donations_suggested_amount: donationsSuggestedAmountSchema.default(DEFAULT_DONATIONS_SUGGESTED_AMOUNT)
+});
+
+export type TipsAndDonationsSettings = z.infer;
+
+export const parseTipsAndDonationsSettings = (settings: unknown): TipsAndDonationsSettings => tipsAndDonationsSettingsSchema.parse(settings);
diff --git a/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx b/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx
index 0a2c75796d6..98b0c724450 100644
--- a/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx
+++ b/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx
@@ -4,12 +4,13 @@ import { page, userEvent } from "vitest/browser";
import { currentRoute, fakeEditSettings, fakeSettingsScreens, renderAdminApp, settingsResponse, type RenderAdminAppOptions } from "@test-utils/acceptance";
import { settingsScreen } from "@/settings/settings.screen";
-function withStripe(): RenderAdminAppOptions {
+function withStripe(settings: Record = {}): RenderAdminAppOptions {
return {
boot: {
browseSettings: {
response: settingsResponse({
settings: {
+ ...settings,
donations_enabled: true,
stripe_connect_publishable_key: "pk_test_123",
stripe_connect_secret_key: "sk_test_123",
@@ -54,7 +55,7 @@ describe("Tips and donations settings", () => {
const section = settingsScreen.tipsAndDonations();
await expect.element(section).toBeVisible();
await expect.element(settingsScreen.suggestedAmount()).toHaveValue("5");
- await expect.element(section.getByRole("combobox")).toBeVisible();
+ await expect.element(section.getByRole("combobox", {name: "Currency"})).toHaveTextContent("USD");
await expect.element(settingsScreen.donateUrl()).toHaveTextContent("http://test.com/#/portal/support");
await userEvent.hover(settingsScreen.donateUrl().element());
@@ -62,6 +63,28 @@ describe("Tips and donations settings", () => {
await expect.element(settingsScreen.copyShareableLink()).toBeVisible();
});
+ it.each([
+ {amount: 725, expected: "7.25", source: "a backend number"},
+ {amount: "825", expected: "8.25", source: "a dirty local string"},
+ ])("accepts a valid suggested amount from $source", async ({amount, expected}) => {
+ fakeSettingsScreens();
+ await renderAdminApp("/settings", withStripe({donations_suggested_amount: amount}));
+
+ await expect.element(settingsScreen.suggestedAmount()).toHaveValue(expected);
+ });
+
+ it("shows the section error boundary for malformed donation settings", async () => {
+ fakeSettingsScreens();
+ await renderAdminApp("/settings", withStripe({
+ donations_currency: "ZZZ",
+ donations_suggested_amount: "not-a-number",
+ }));
+
+ await expect.element(page.getByRole("alert")).toHaveTextContent("An error occurred loading Tips & donations. Please refresh and try again.");
+ await expect(settingsScreen.tipsAndDonations()).toHaveCount(0);
+ await expect(settingsScreen.suggestedAmount()).toHaveCount(0);
+ });
+
it("saves an updated suggested amount", async () => {
fakeSettingsScreens();
const settingsApi = fakeEditSettings();
diff --git a/apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx b/apps/admin/src/settings/growth/tips-and-donations.tsx
similarity index 94%
rename from apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx
rename to apps/admin/src/settings/growth/tips-and-donations.tsx
index 3bf14f7bf3b..c780ef7df6c 100644
--- a/apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx
+++ b/apps/admin/src/settings/growth/tips-and-donations.tsx
@@ -8,6 +8,7 @@ import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns
import {SettingGroupContent} from '@tryghost/shade/patterns';
import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
+import {parseTipsAndDonationsSettings} from './tips-and-donations-settings';
import {withErrorBoundary} from '@/settings/app/components/error-boundary';
// Stripe doesn't allow amounts over 10,000 as a preset amount
@@ -36,12 +37,18 @@ const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
}
});
- const [donationsCurrency = 'USD', donationsSuggestedAmount = '500'] = getSettingValues(
+ const [donationsCurrencySetting, donationsSuggestedAmountSetting] = getSettingValues(
localSettings,
['donations_currency', 'donations_suggested_amount']
);
+ const {
+ donations_currency: donationsCurrency,
+ donations_suggested_amount: suggestedAmountInCents
+ } = parseTipsAndDonationsSettings({
+ donations_currency: donationsCurrencySetting,
+ donations_suggested_amount: donationsSuggestedAmountSetting
+ });
- const suggestedAmountInCents = parseInt(donationsSuggestedAmount);
const suggestedAmountInput = useCurrencyInput(suggestedAmountInCents, cents => handleSettingChange('donations_suggested_amount', cents.toString()));
const donateUrl = `${siteData?.url.replace(/\/$/, '')}/#/portal/support`;
const currencyOptions = currencySelectGroups().flatMap(group => group.options.map(option => ({...option, metadata: {groupKey: group.key, groupLabel: group.label}})));
diff --git a/apps/admin/src/settings/app/components/settings/membership/access.tsx b/apps/admin/src/settings/membership/access.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/membership/access.tsx
rename to apps/admin/src/settings/membership/access.tsx
index 45f09427504..5d58e3c0372 100644
--- a/apps/admin/src/settings/app/components/settings/membership/access.tsx
+++ b/apps/admin/src/settings/membership/access.tsx
@@ -238,7 +238,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
data-testid='regenerate-access-code'
disabled={isRegenerating}
size='icon-xs'
- onClick={handleRegenerateAccessCode}
+ onClick={() => void handleRegenerateAccessCode()}
>
diff --git a/apps/admin/src/settings/app/components/settings/membership/analytics.tsx b/apps/admin/src/settings/membership/analytics.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/analytics.tsx
rename to apps/admin/src/settings/membership/analytics.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx b/apps/admin/src/settings/membership/custom-fields.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx
rename to apps/admin/src/settings/membership/custom-fields.tsx
index 8174778a7c1..336ceb3381c 100644
--- a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx
+++ b/apps/admin/src/settings/membership/custom-fields.tsx
@@ -212,7 +212,7 @@ const CustomFields: React.FC<{keywords: string[]}> = ({keywords}) => {
// The server's message says what to do; refetching is how the screen
// learns about the field it did not know about.
handleError(error);
- queryClient.invalidateQueries({queryKey: [memberCustomFieldsDataType]});
+ void queryClient.invalidateQueries({queryKey: [memberCustomFieldsDataType]});
})
// Dropped either way: on success the cache holds this order, on failure the
// server's is the one to show.
diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx b/apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx
rename to apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/gift-subscriptions.tsx b/apps/admin/src/settings/membership/gift-subscriptions.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/gift-subscriptions.tsx
rename to apps/admin/src/settings/membership/gift-subscriptions.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx b/apps/admin/src/settings/membership/member-emails.tsx
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails.tsx
rename to apps/admin/src/settings/membership/member-emails.tsx
index 0723fb64035..6bd30bd2231 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx
+++ b/apps/admin/src/settings/membership/member-emails.tsx
@@ -224,7 +224,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {
}
};
- verify();
+ void verify();
}, [confirm, handleError, verifyEmailToken, verifySenderUpdate]);
const handleToggle = async (emailType: 'free' | 'paid') => {
@@ -309,10 +309,10 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {
paidEmailForDisplay={paidEmailForDisplay}
paidWelcomeEmailEnabled={paidWelcomeEmailEnabled}
settings={settings}
- onFreeEdit={() => handleEditClick('free')}
- onFreeToggle={() => handleToggle('free')}
- onPaidEdit={() => handleEditClick('paid')}
- onPaidToggle={() => handleToggle('paid')}
+ onFreeEdit={() => void handleEditClick('free')}
+ onFreeToggle={() => void handleToggle('free')}
+ onPaidEdit={() => void handleEditClick('paid')}
+ onPaidToggle={() => void handleToggle('paid')}
/>
{editingEmail && (
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts b/apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts
rename to apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/design-payload.test.ts b/apps/admin/src/settings/membership/member-emails/design-payload.test.ts
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/design-payload.test.ts
rename to apps/admin/src/settings/membership/member-emails/design-payload.test.ts
index 4ad853fe125..bf548ee5908 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/design-payload.test.ts
+++ b/apps/admin/src/settings/membership/member-emails/design-payload.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types';
-import {buildAutomatedEmailDesignPayload, mapApiToDesignSettings} from '@/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal';
+import {buildAutomatedEmailDesignPayload, mapApiToDesignSettings} from '@/settings/membership/member-emails/design-payload';
describe('Welcome email design payload helpers', function () {
it('does not hydrate immutable api metadata into design settings', function () {
diff --git a/apps/admin/src/settings/membership/member-emails/design-payload.ts b/apps/admin/src/settings/membership/member-emails/design-payload.ts
new file mode 100644
index 00000000000..d3b57fca7c3
--- /dev/null
+++ b/apps/admin/src/settings/membership/member-emails/design-payload.ts
@@ -0,0 +1,75 @@
+import {type AutomatedEmailDesign, type EditAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design';
+import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/app/components/settings/email-design/types';
+
+export interface GeneralSettings {
+ senderName: string;
+ senderEmail: string;
+ replyToEmail: string;
+ headerImage: string;
+ showPublicationIcon: boolean;
+ showPublicationTitle: boolean;
+ showBadge: boolean;
+ emailFooter: string;
+}
+
+export interface WelcomeEmailCustomizeFormState {
+ designSettings: EmailDesignSettings;
+ generalSettings: GeneralSettings;
+}
+
+const WELCOME_EMAIL_DESIGN_FIELDS = new Set(Object.keys(DEFAULT_EMAIL_DESIGN));
+
+const isWelcomeEmailDesignField = (key: string) => WELCOME_EMAIL_DESIGN_FIELDS.has(key);
+
+/**
+ * Maps API response fields to the frontend GeneralSettings shape.
+ * Note: senderName, senderEmail and replyToEmail are not part of the design endpoint.
+ *
+ * @param {Pick} apiData - Subset of design fields used for general settings
+ * @param {GeneralSettings} defaults - Carries forward sender fields, which are not part of the design API
+ * @returns {GeneralSettings} General settings populated from the API response
+ */
+export function mapApiToGeneralSettings(
+ apiData: Pick,
+ defaults: GeneralSettings
+): GeneralSettings {
+ return {
+ senderName: defaults.senderName,
+ senderEmail: defaults.senderEmail,
+ replyToEmail: defaults.replyToEmail,
+ headerImage: apiData.header_image || '',
+ showPublicationIcon: apiData.show_header_icon,
+ showPublicationTitle: apiData.show_header_title,
+ showBadge: apiData.show_badge,
+ emailFooter: apiData.footer_content || ''
+ };
+}
+
+/**
+ * Maps API response fields to the frontend welcome-email design settings shape.
+ *
+ * @param {EmailDesignSettings} apiData - The persisted design fields from the API response
+ * @returns {EmailDesignSettings} Design settings populated from the API response
+ */
+export function mapApiToDesignSettings(
+ apiData: EmailDesignSettings
+): EmailDesignSettings {
+ return Object.fromEntries(
+ Object.entries(apiData).filter(([key]) => isWelcomeEmailDesignField(key))
+ ) as EmailDesignSettings;
+}
+
+export function buildAutomatedEmailDesignPayload(state: WelcomeEmailCustomizeFormState): EditAutomatedEmailDesign {
+ const persistedDesign = Object.fromEntries(
+ Object.entries(state.designSettings).filter(([key]) => isWelcomeEmailDesignField(key))
+ );
+
+ return {
+ ...persistedDesign,
+ header_image: state.generalSettings.headerImage || null,
+ show_header_icon: state.generalSettings.showPublicationIcon,
+ show_header_title: state.generalSettings.showPublicationTitle,
+ show_badge: state.generalSettings.showBadge,
+ footer_content: state.generalSettings.emailFooter || null
+ };
+}
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/member-email-editor.tsx b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/member-email-editor.tsx
rename to apps/admin/src/settings/membership/member-emails/member-email-editor.tsx
index 9326efee0dd..4b45ffcc048 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/member-email-editor.tsx
+++ b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx
@@ -85,8 +85,8 @@ const EmailEditorInner: React.FC<{
registerAPI: (API: KoenigInstance | null) => void;
onChange: (data: unknown) => void;
}> = ({editor, darkMode, cardConfig, initialEditorState, placeholder, className, registerAPI, onChange}) => {
- const koenig = editor.read();
- const EmailEditor = koenig.EmailEditor;
+ // loadKoenig resolves the untyped @tryghost/koenig-lexical bundle
+ const {EmailEditor} = editor.read() as {EmailEditor: React.ComponentType>};
return (
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/test-email-dropdown.tsx b/apps/admin/src/settings/membership/member-emails/test-email-dropdown.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/test-email-dropdown.tsx
rename to apps/admin/src/settings/membership/member-emails/test-email-dropdown.tsx
index b4007fac5f3..cfacc488e9c 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/test-email-dropdown.tsx
+++ b/apps/admin/src/settings/membership/member-emails/test-email-dropdown.tsx
@@ -97,7 +97,7 @@ const TestEmailDropdown: React.FC
= ({
className='w-full'
disabled={sendState === 'sending'}
type='button'
- onClick={handleSendTestEmail}
+ onClick={() => void handleSendTestEmail()}
>
{sendState === 'sent' ? 'Sent' : sendState === 'sending' ? 'Sending...' : 'Send'}
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.test.tsx b/apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.test.tsx
similarity index 91%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.test.tsx
rename to apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.test.tsx
index 10942b083c6..6075026a0b3 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.test.tsx
+++ b/apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.test.tsx
@@ -1,7 +1,7 @@
import * as assert from 'node:assert/strict';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {act, renderHook, waitFor} from '@testing-library/react';
-import {useWelcomeEmailPreview} from '@/settings/app/components/settings/membership/member-emails/use-welcome-email-preview';
+import {useWelcomeEmailPreview} from '@/settings/membership/member-emails/use-welcome-email-preview';
const validLexical = JSON.stringify({
root: {
@@ -41,7 +41,7 @@ const createDeferred = () => {
};
describe('useWelcomeEmailPreview', function () {
- it('returns invalid state and skips network for invalid drafts', async function () {
+ it('returns invalid state and skips network for invalid drafts', function () {
const previewWelcomeEmail = vi.fn();
const setErrors = vi.fn();
const {result} = renderHook(() => useWelcomeEmailPreview({
@@ -51,7 +51,7 @@ describe('useWelcomeEmailPreview', function () {
}));
act(() => {
- result.current.enterPreview({subject: ' ', lexical: ''});
+ void result.current.enterPreview({subject: ' ', lexical: ''});
});
assert.equal(previewWelcomeEmail.mock.calls.length, 0);
@@ -77,8 +77,8 @@ describe('useWelcomeEmailPreview', function () {
}));
act(() => {
- result.current.enterPreview({subject: 'First', lexical: validLexical});
- result.current.enterPreview({subject: 'Second', lexical: validLexical});
+ void result.current.enterPreview({subject: 'First', lexical: validLexical});
+ void result.current.enterPreview({subject: 'Second', lexical: validLexical});
});
await act(async () => {
@@ -138,7 +138,7 @@ describe('useWelcomeEmailPreview', function () {
}));
act(() => {
- result.current.enterPreview({subject: 'Welcome', lexical: validLexical});
+ void result.current.enterPreview({subject: 'Welcome', lexical: validLexical});
});
await waitFor(() => {
@@ -162,7 +162,7 @@ describe('useWelcomeEmailPreview', function () {
}));
act(() => {
- result.current.enterPreview({subject: 'Welcome', lexical: validLexical});
+ void result.current.enterPreview({subject: 'Welcome', lexical: validLexical});
});
await waitFor(() => {
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.ts b/apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.ts
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.ts
rename to apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.ts
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx
similarity index 87%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx
rename to apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx
index dac07627acb..8a8b9dbfe3b 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx
+++ b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx
@@ -5,7 +5,7 @@ import ShowBadgeField from '@/settings/app/components/settings/email-design/show
import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content';
import useFeatureFlag from '@/settings/app/hooks/use-feature-flag';
import validator from 'validator';
-import {type AutomatedEmailDesign, type EditAutomatedEmailDesign, useEditAutomatedEmailDesign, useReadAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design';
+import {useEditAutomatedEmailDesign, useReadAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design';
import {
BackgroundColorField,
BodyFontField,
@@ -25,6 +25,7 @@ import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/app/com
import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context';
import {Input, LoadingIndicator, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components';
import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailValues} from './default-welcome-email-values';
+import {type GeneralSettings, type WelcomeEmailCustomizeFormState, buildAutomatedEmailDesignPayload, mapApiToDesignSettings, mapApiToGeneralSettings} from './design-payload';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {toast} from 'sonner';
import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmailSenders} from '@tryghost/admin-x-framework/api/automated-emails';
@@ -33,26 +34,8 @@ import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useGlobalData} from '@/settings/app/components/providers/global-data-provider';
import {useWelcomeEmailSenderDetails} from '@/settings/app/hooks/use-welcome-email-sender-details';
-interface GeneralSettings {
- senderName: string;
- senderEmail: string;
- replyToEmail: string;
- headerImage: string;
- showPublicationIcon: boolean;
- showPublicationTitle: boolean;
- showBadge: boolean;
- emailFooter: string;
-}
-
-interface WelcomeEmailCustomizeFormState {
- designSettings: EmailDesignSettings;
- generalSettings: GeneralSettings;
-}
-
const SAVE_ERROR_TOAST_ID = 'welcome-email-design-save-error';
-const WELCOME_EMAIL_DESIGN_FIELDS = new Set(Object.keys(DEFAULT_EMAIL_DESIGN));
-const isWelcomeEmailDesignField = (key: string) => WELCOME_EMAIL_DESIGN_FIELDS.has(key);
interface GeneralTabProps {
generalSettings: GeneralSettings;
onGeneralChange: (updates: Partial) => void;
@@ -268,58 +251,6 @@ const Sidebar: React.FC = ({
);
-/**
- * Maps API response fields to the frontend GeneralSettings shape.
- * Note: senderName, senderEmail and replyToEmail are not part of the design endpoint.
- *
- * @param {Pick} apiData - Subset of design fields used for general settings
- * @param {GeneralSettings} defaults - Carries forward sender fields, which are not part of the design API
- * @returns {GeneralSettings} General settings populated from the API response
- */
-function mapApiToGeneralSettings(
- apiData: Pick,
- defaults: GeneralSettings
-): GeneralSettings {
- return {
- senderName: defaults.senderName,
- senderEmail: defaults.senderEmail,
- replyToEmail: defaults.replyToEmail,
- headerImage: apiData.header_image || '',
- showPublicationIcon: apiData.show_header_icon,
- showPublicationTitle: apiData.show_header_title,
- showBadge: apiData.show_badge,
- emailFooter: apiData.footer_content || ''
- };
-}
-
-/**
- * Maps API response fields to the frontend welcome-email design settings shape.
- *
- * @param {EmailDesignSettings} apiData - The persisted design fields from the API response
- * @returns {EmailDesignSettings} Design settings populated from the API response
- */
-export function mapApiToDesignSettings(
- apiData: EmailDesignSettings
-): EmailDesignSettings {
- return Object.fromEntries(
- Object.entries(apiData).filter(([key]) => isWelcomeEmailDesignField(key))
- ) as EmailDesignSettings;
-}
-
-export function buildAutomatedEmailDesignPayload(state: WelcomeEmailCustomizeFormState): EditAutomatedEmailDesign {
- const persistedDesign = Object.fromEntries(
- Object.entries(state.designSettings).filter(([key]) => isWelcomeEmailDesignField(key))
- );
-
- return {
- ...persistedDesign,
- header_image: state.generalSettings.headerImage || null,
- show_header_icon: state.generalSettings.showPublicationIcon,
- show_header_title: state.generalSettings.showPublicationTitle,
- show_badge: state.generalSettings.showBadge,
- footer_content: state.generalSettings.emailFooter || null
- };
-}
const ErrorState: React.FC<{message: string}> = ({message}) => (
@@ -565,7 +496,7 @@ const WelcomeEmailCustomizeModal: React.FC<{onClose: () => void}> = ({onClose})
title="Welcome emails"
open
onClose={onClose}
- onSave={() => handleSave({fakeWhenUnchanged: true})}
+ onSave={() => void handleSave({fakeWhenUnchanged: true})}
/>
);
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx
rename to apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx
index ac9c09155d5..aeb42dea28e 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx
+++ b/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx
@@ -154,7 +154,7 @@ const WelcomeEmailModal: React.FC
void}
const handleCMDS = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
- handleSaveRef.current({fakeWhenUnchanged: true});
+ void handleSaveRef.current({fakeWhenUnchanged: true});
}
};
window.addEventListener('keydown', handleCMDS);
@@ -168,7 +168,7 @@ const WelcomeEmailModal: React.FC void}
if (nextMode === 'preview') {
setPreviewSubjectOverride(null);
- enterPreview(formState);
+ void enterPreview(formState);
} else {
setShowTestDropdown(false);
setPreviewSubjectOverride(null);
@@ -235,7 +235,7 @@ const WelcomeEmailModal: React.FC void}
await handleSave({fakeWhenUnchanged: true})}
+ onClick={() => void handleSave({fakeWhenUnchanged: true})}
>
{saveButtonLabel}
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-preview-frame.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-preview-frame.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-preview-frame.tsx
rename to apps/admin/src/settings/membership/member-emails/welcome-email-preview-frame.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-validation.ts b/apps/admin/src/settings/membership/member-emails/welcome-email-validation.ts
similarity index 89%
rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-validation.ts
rename to apps/admin/src/settings/membership/member-emails/welcome-email-validation.ts
index 19ca3943c3b..be76cc67663 100644
--- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-validation.ts
+++ b/apps/admin/src/settings/membership/member-emails/welcome-email-validation.ts
@@ -9,7 +9,7 @@ const isEmptyLexical = (lexical: string | null | undefined): boolean => {
}
try {
- const parsed = JSON.parse(lexical);
+ const parsed = JSON.parse(lexical) as {root?: {children?: Array<{type?: string; children?: unknown[]}>}} | null;
const children = parsed?.root?.children;
// Empty if no children or only an empty paragraph
diff --git a/apps/admin/src/settings/app/components/settings/membership/membership-settings.tsx b/apps/admin/src/settings/membership/membership-settings.tsx
similarity index 68%
rename from apps/admin/src/settings/app/components/settings/membership/membership-settings.tsx
rename to apps/admin/src/settings/membership/membership-settings.tsx
index 22a1df67676..5ea66fe386a 100644
--- a/apps/admin/src/settings/app/components/settings/membership/membership-settings.tsx
+++ b/apps/admin/src/settings/membership/membership-settings.tsx
@@ -7,21 +7,12 @@ import React from 'react';
import SearchableSection from '@/settings/app/components/searchable-section';
import SpamFilters from '@/settings/app/components/settings/advanced/spam-filters';
import Tiers from './tiers';
-import TipsAndDonations from '@/settings/app/components/settings/growth/tips-and-donations';
+import TipsAndDonations from '@/settings/growth/tips-and-donations';
import useFeatureFlag from '@/settings/app/hooks/use-feature-flag';
import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
+import {searchKeywords} from './search-keywords';
import {useGlobalData} from '@/settings/app/components/providers/global-data-provider';
-export const searchKeywords = {
- access: ['membership', 'default', 'access', 'subscription', 'post', 'membership', 'comments', 'commenting', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email', 'password protection', 'lock site', 'private site', 'private site mode', 'make this site private'],
- tiers: ['membership', 'tiers', 'payment', 'paid', 'stripe'],
- portal: ['membership', 'portal', 'signup', 'sign up', 'signin', 'sign in', 'login', 'account', 'membership', 'support', 'email', 'address', 'support email address', 'support address'],
- giftSubscriptions: ['membership', 'gift', 'gifts', 'gift subscriptions', 'present', 'share', 'shareable link'],
- memberEmails: ['membership', 'signup', 'welcome email', 'welcome emails', 'email', 'new user', 'new member', 'account'],
- tips: ['membership', 'tips', 'donations', 'one time', 'payment'],
- customFields: ['membership', 'custom fields', 'fields', 'member fields', 'custom field']
-};
-
const MembershipSettings: React.FC = () => {
const {config, settings} = useGlobalData();
const [hasTipsAndDonations, paidMembersEnabled] = getSettingValues(settings, ['donations_enabled', 'paid_members_enabled']) as [boolean, boolean];
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal.tsx b/apps/admin/src/settings/membership/portal.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/portal.tsx
rename to apps/admin/src/settings/membership/portal.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/account-page.tsx b/apps/admin/src/settings/membership/portal/account-page.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/portal/account-page.tsx
rename to apps/admin/src/settings/membership/portal/account-page.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/look-and-feel.tsx b/apps/admin/src/settings/membership/portal/look-and-feel.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/membership/portal/look-and-feel.tsx
rename to apps/admin/src/settings/membership/portal/look-and-feel.tsx
index 4382ceefb7b..5baed289ce8 100644
--- a/apps/admin/src/settings/app/components/settings/membership/portal/look-and-feel.tsx
+++ b/apps/admin/src/settings/membership/portal/look-and-feel.tsx
@@ -132,7 +132,7 @@ const LookAndFeel: React.FC<{
) : (
- handleImageUpload(files[0])}>
+ void handleImageUpload(files[0])}>
)}
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-frame.tsx b/apps/admin/src/settings/membership/portal/portal-frame.tsx
similarity index 96%
rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-frame.tsx
rename to apps/admin/src/settings/membership/portal/portal-frame.tsx
index 7606a17fb56..35527361589 100644
--- a/apps/admin/src/settings/app/components/settings/membership/portal/portal-frame.tsx
+++ b/apps/admin/src/settings/membership/portal/portal-frame.tsx
@@ -33,7 +33,7 @@ const PortalFrame: React.FC = ({href, onDestroyed, selectedTab
const originURL = new URL(event.origin);
if (originURL.origin === new URL(href).origin) {
- if (event?.data?.type === 'portal-preview-ready') {
+ if ((event.data as {type?: string} | null)?.type === 'portal-preview-ready') {
makeVisible();
}
}
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-links.tsx b/apps/admin/src/settings/membership/portal/portal-links.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-links.tsx
rename to apps/admin/src/settings/membership/portal/portal-links.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx b/apps/admin/src/settings/membership/portal/portal-modal.tsx
similarity index 96%
rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx
rename to apps/admin/src/settings/membership/portal/portal-modal.tsx
index 0c2f988aa0a..d2e77b9f395 100644
--- a/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx
+++ b/apps/admin/src/settings/membership/portal/portal-modal.tsx
@@ -95,11 +95,10 @@ const PortalModal: React.FC = () => {
cancelLabel: '',
onOk: confirmModal => confirmModal?.remove()
});
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } catch (e: any) {
+ } catch (e) {
let prompt = 'There was an error verifying your email address. Please try again.';
- if (e?.message === 'Token expired') {
+ if (e instanceof Error && e.message === 'Token expired') {
prompt = 'Verification link has expired.';
}
confirm({
@@ -113,14 +112,14 @@ const PortalModal: React.FC = () => {
}
};
if (verifyEmail) {
- checkToken({token: verifyEmail});
+ void checkToken({token: verifyEmail});
}
}, [confirm, handleError, verifyEmail, verifyToken]);
- const {formState, setFormState, saveState, handleSave, updateForm, okProps} = useForm({
+ const {formState, setFormState, saveState, handleSave, updateForm, okProps} = useForm<{settings: Dirtyable[]; tiers: Dirtyable[]}>({
initialState: {
- settings: settings as Dirtyable[],
- tiers: allTiers as Dirtyable[] || []
+ settings,
+ tiers: allTiers || []
},
savingDelay: 500,
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-preview.tsx b/apps/admin/src/settings/membership/portal/portal-preview.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-preview.tsx
rename to apps/admin/src/settings/membership/portal/portal-preview.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/signup-options.tsx b/apps/admin/src/settings/membership/portal/signup-options.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/portal/signup-options.tsx
rename to apps/admin/src/settings/membership/portal/signup-options.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/transistor-settings.tsx b/apps/admin/src/settings/membership/portal/transistor-settings.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/portal/transistor-settings.tsx
rename to apps/admin/src/settings/membership/portal/transistor-settings.tsx
diff --git a/apps/admin/src/settings/membership/search-keywords.ts b/apps/admin/src/settings/membership/search-keywords.ts
new file mode 100644
index 00000000000..3bcb92ebb35
--- /dev/null
+++ b/apps/admin/src/settings/membership/search-keywords.ts
@@ -0,0 +1,9 @@
+export const searchKeywords = {
+ access: ['membership', 'default', 'access', 'subscription', 'post', 'membership', 'comments', 'commenting', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email', 'password protection', 'lock site', 'private site', 'private site mode', 'make this site private'],
+ tiers: ['membership', 'tiers', 'payment', 'paid', 'stripe'],
+ portal: ['membership', 'portal', 'signup', 'sign up', 'signin', 'sign in', 'login', 'account', 'membership', 'support', 'email', 'address', 'support email address', 'support address'],
+ giftSubscriptions: ['membership', 'gift', 'gifts', 'gift subscriptions', 'present', 'share', 'shareable link'],
+ memberEmails: ['membership', 'signup', 'welcome email', 'welcome emails', 'email', 'new user', 'new member', 'account'],
+ tips: ['membership', 'tips', 'donations', 'one time', 'payment'],
+ customFields: ['membership', 'custom fields', 'fields', 'member fields', 'custom field']
+};
diff --git a/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx b/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx
similarity index 98%
rename from apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx
rename to apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx
index f8dd8740182..7a581400968 100644
--- a/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx
+++ b/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx
@@ -144,7 +144,7 @@ const Connect: React.FC = () => {
{error && {error}}
- {submitEnabled && Save Stripe settings}
+ {submitEnabled && void onSubmit()}>Save Stripe settings}
);
};
@@ -187,7 +187,7 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => {
return (
-
+ void openDisconnectStripeModal()}>
Disconnect
@@ -248,7 +248,7 @@ const Direct: React.FC<{onClose: () => void}> = ({onClose}) => {
Publishable key updateSetting('stripe_publishable_key', e.target.value)} />
Secure key updateSetting('stripe_secret_key', e.target.value)} />
- Save Stripe settings
+ void onSubmit()}>Save Stripe settings
);
@@ -287,7 +287,7 @@ const StripeConnectModal: React.FC = () => {
}
};
- checkLimit();
+ void checkLimit();
}, [limiter, updateRoute, stripeEnabled, hasStripeConnectLimit, showLimit, upgradeRoute]);
const startFlow = () => {
diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers.tsx b/apps/admin/src/settings/membership/tiers.tsx
similarity index 97%
rename from apps/admin/src/settings/app/components/settings/membership/tiers.tsx
rename to apps/admin/src/settings/membership/tiers.tsx
index 570ef57437c..17ab32277da 100644
--- a/apps/admin/src/settings/app/components/settings/membership/tiers.tsx
+++ b/apps/admin/src/settings/membership/tiers.tsx
@@ -147,9 +147,9 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
}
const stripeButton = stripeEnabled ?
-
+ void openConnectModal()} />
:
- ;
+ void openConnectModal()}/>;
return (
= ({keywords}) => {
>
{stripeEnabled ?
-
+ void openConnectModal()} />
:
-
+ void openConnectModal()}/>
}
@@ -172,7 +172,7 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
{isEnd === false && fetchNextPage()}
+ onClick={() => void fetchNextPage()}
>
{`Load more (showing ${formatNumber(tiers?.length || 0)}/${formatNumber(meta?.pagination.total || 0)} tiers)`}
}
diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx b/apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx
similarity index 99%
rename from apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx
rename to apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx
index 0008ec9df37..8e3b8db54dc 100644
--- a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx
+++ b/apps/admin/src/settings/membership/tiers/tier-detail-modal.tsx
@@ -188,7 +188,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
cancelLabel: 'Cancel',
okVariant: tier.active ? 'destructive' : 'default',
onOk: (confirmModal) => {
- updateTier({...tier, active: !tier.active});
+ void updateTier({...tier, active: !tier.active});
confirmModal?.remove();
toast.success(`Tier ${tier.active ? 'archived' : 'reactivated'}`);
}
@@ -419,7 +419,7 @@ const TierDetailModal: React.FC = () => {
useEffect(() => {
if (tierId && !tier && !isEnd) {
- fetchNextPage();
+ void fetchNextPage();
}
}, [fetchNextPage, isEnd, tierId, tier]);
diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-preview.test.tsx b/apps/admin/src/settings/membership/tiers/tier-detail-preview.test.tsx
similarity index 90%
rename from apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-preview.test.tsx
rename to apps/admin/src/settings/membership/tiers/tier-detail-preview.test.tsx
index 935f6736721..bf775f99df9 100644
--- a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-preview.test.tsx
+++ b/apps/admin/src/settings/membership/tiers/tier-detail-preview.test.tsx
@@ -1,4 +1,4 @@
-import TierDetailPreview from '@/settings/app/components/settings/membership/tiers/tier-detail-preview';
+import TierDetailPreview from '@/settings/membership/tiers/tier-detail-preview';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-preview.tsx b/apps/admin/src/settings/membership/tiers/tier-detail-preview.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-preview.tsx
rename to apps/admin/src/settings/membership/tiers/tier-detail-preview.tsx
diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers/tiers-list.tsx b/apps/admin/src/settings/membership/tiers/tiers-list.tsx
similarity index 100%
rename from apps/admin/src/settings/app/components/settings/membership/tiers/tiers-list.tsx
rename to apps/admin/src/settings/membership/tiers/tiers-list.tsx
diff --git a/apps/admin/src/settings/offers.acceptance.test.tsx b/apps/admin/src/settings/offers.acceptance.test.tsx
index 2b7a0031b35..3028acbe59a 100644
--- a/apps/admin/src/settings/offers.acceptance.test.tsx
+++ b/apps/admin/src/settings/offers.acceptance.test.tsx
@@ -423,6 +423,14 @@ describe("Offers", () => {
await expect.element(yearlyModal.getByLabelText("Amount off")).toHaveValue(30);
});
+ it("falls back to the offers list for an invalid retention cadence", async () => {
+ retentionWorld([]);
+ await renderAdminApp("/settings/offers/edit/retention/not-a-cadence", withStripe());
+
+ await expect.element(offersScreen.listModal()).toBeVisible();
+ await expect(offersScreen.retentionModal()).toHaveCount(0);
+ });
+
it("shows validation errors for invalid retention values on save", async () => {
retentionWorld([retentionOffer({ id: "retention-month-active" })]);
await renderAdminApp("/settings/offers/edit/retention/monthly", withStripe());
diff --git a/apps/admin/src/settings/routes.tsx b/apps/admin/src/settings/routes.tsx
index 1c9742de852..b3b601cda26 100644
--- a/apps/admin/src/settings/routes.tsx
+++ b/apps/admin/src/settings/routes.tsx
@@ -18,15 +18,15 @@ export const settingsRouteChildren: RouteObject[] = [
{path: 'theme/edit/*', element: },
{path: 'navigation/edit', lazy: lazyComponent(() => import('./app/components/settings/site/navigation-modal'))},
{path: 'announcement-bar/edit', lazy: lazyComponent(() => import('./app/components/settings/site/announcement-bar-modal'))},
- {path: 'staff/invite', lazy: lazyComponent(() => import('./app/components/settings/general/invite-user-modal'))},
- {path: 'staff/:slug', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))},
- {path: 'staff/:slug/edit', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))},
- {path: 'staff/:slug/social-links', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))},
- {path: 'staff/:slug/email-notifications', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))},
- {path: 'portal/edit', lazy: lazyComponent(() => import('./app/components/settings/membership/portal/portal-modal'))},
- {path: 'tiers/add', lazy: lazyComponent(() => import('./app/components/settings/membership/tiers/tier-detail-modal'))},
- {path: 'tiers/:tierId', lazy: lazyComponent(() => import('./app/components/settings/membership/tiers/tier-detail-modal'))},
- {path: 'stripe-connect', lazy: lazyComponent(() => import('./app/components/settings/membership/stripe/stripe-connect-modal'))},
+ {path: 'staff/invite', lazy: lazyComponent(() => import('./general/invite-user-modal'))},
+ {path: 'staff/:slug', lazy: lazyComponent(() => import('./general/user-detail-modal'))},
+ {path: 'staff/:slug/edit', lazy: lazyComponent(() => import('./general/user-detail-modal'))},
+ {path: 'staff/:slug/social-links', lazy: lazyComponent(() => import('./general/user-detail-modal'))},
+ {path: 'staff/:slug/email-notifications', lazy: lazyComponent(() => import('./general/user-detail-modal'))},
+ {path: 'portal/edit', lazy: lazyComponent(() => import('./membership/portal/portal-modal'))},
+ {path: 'tiers/add', lazy: lazyComponent(() => import('./membership/tiers/tier-detail-modal'))},
+ {path: 'tiers/:tierId', lazy: lazyComponent(() => import('./membership/tiers/tier-detail-modal'))},
+ {path: 'stripe-connect', lazy: lazyComponent(() => import('./membership/stripe/stripe-connect-modal'))},
{path: 'newsletters/new', lazy: lazyComponent(() => import('./app/components/settings/email/newsletters/add-newsletter-modal'))},
{path: 'newsletters/:newsletterId', lazy: lazyComponent(() => import('./app/components/settings/email/newsletters/newsletter-detail-modal'))},
{path: 'history/view/:userId?', lazy: lazyComponent(() => import('./app/components/settings/advanced/history-modal'))},
@@ -39,20 +39,20 @@ export const settingsRouteChildren: RouteObject[] = [
{path: 'integrations/firstpromoter', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/first-promoter-modal'))},
{path: 'integrations/pintura', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/pintura-modal'))},
{path: 'integrations/:integrationId', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/custom-integration-modal'))},
- {path: 'recommendations/add', lazy: lazyComponent(() => import('./app/components/settings/growth/recommendations/add-recommendation-modal'))},
+ {path: 'recommendations/add', lazy: lazyComponent(() => import('./growth/recommendations/add-recommendation-modal'))},
// The edit flow opens from the recommendations list with the loaded record
// (never URL-driven); the legacy route only ever redirected back in effect.
{path: 'recommendations/edit', element: },
- {path: 'embed-signup-form/show', lazy: lazyComponent(() => import('./app/components/settings/growth/embed-signup/embed-signup-form-modal'))},
+ {path: 'embed-signup-form/show', lazy: lazyComponent(() => import('./growth/embed-signup/embed-signup-form-modal'))},
// The offers container owns list/add/edit/retention/success views and
// reads the path to pick between them.
- {path: 'offers/new', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))},
- {path: 'offers/edit', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))},
- {path: 'offers/edit/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))},
- {path: 'offers/edit/retention/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))},
- {path: 'offers/success/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))},
- {path: 'explore/testimonial', lazy: lazyComponent(() => import('./app/components/settings/growth/explore/testimonials-modal'))},
- {path: 'about', lazy: lazyComponent(() => import('./app/components/settings/general/about'))},
+ {path: 'offers/new', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))},
+ {path: 'offers/edit', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))},
+ {path: 'offers/edit/:offerId', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))},
+ {path: 'offers/edit/retention/:offerId', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))},
+ {path: 'offers/success/:offerId', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))},
+ {path: 'explore/testimonial', lazy: lazyComponent(() => import('./growth/explore/testimonials-modal'))},
+ {path: 'about', lazy: lazyComponent(() => import('./general/about'))},
// The lock-site setting was merged into the Access section.
{path: 'locksite', element: },
// Section anchors (/settings/) and unknown paths render the shell
diff --git a/apps/admin/src/vite-env.d.ts b/apps/admin/src/vite-env.d.ts
index c4b1a0c5da7..650bbe760f5 100644
--- a/apps/admin/src/vite-env.d.ts
+++ b/apps/admin/src/vite-env.d.ts
@@ -1,5 +1,9 @@
///
+interface ImportMetaEnv {
+ readonly GHOST_BUILD_VERSION?: string;
+}
+
declare module '@tryghost/limit-service'
declare module '@tryghost/nql'
declare module '@tryghost/string' {
diff --git a/apps/shade/package.json b/apps/shade/package.json
index 98aa4f16b86..b2cf27bb1b8 100644
--- a/apps/shade/package.json
+++ b/apps/shade/package.json
@@ -107,7 +107,6 @@
"@dnd-kit/core": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
- "@ebay/nice-modal-react": "catalog:",
"@hookform/resolvers": "5.4.0",
"@number-flow/react": "0.6.2",
"@radix-ui/react-accordion": "1.2.15",
diff --git a/apps/shade/src/components/patterns/settings-modal.stories.tsx b/apps/shade/src/components/patterns/settings-modal.stories.tsx
index 0c322108e01..81542e10575 100644
--- a/apps/shade/src/components/patterns/settings-modal.stories.tsx
+++ b/apps/shade/src/components/patterns/settings-modal.stories.tsx
@@ -1,16 +1,17 @@
-import NiceModal from '@ebay/nice-modal-react';
+import {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react-vite';
import {Button} from '@/components/ui/button';
import {Box} from '@/components/primitives/box';
import {SettingsModal, type SettingsModalProps} from '@/components/patterns/settings-modal';
-const SettingsModalStory = (props: SettingsModalProps) => {
- const StoryModal = NiceModal.create(() => );
+const SettingsModalStory = (props: Omit) => {
+ const [open, setOpen] = useState(false);
return (
- NiceModal.show(StoryModal)}>Open modal
+ setOpen(true)}>Open modal
+ {open && setOpen(false)} />}
);
};
@@ -22,15 +23,10 @@ const meta = {
parameters: {
docs: {
description: {
- component: 'Transitional compatibility shell for the existing settings NiceModal flows. New modal flows should use Shade Dialog primitives directly.'
+ component: 'Consumer-controlled shell for the legacy full-page settings dialogs. New modal flows should use Shade Dialog primitives directly.'
}
}
- },
- decorators: [Story => (
-
-
-
- )]
+ }
} satisfies Meta;
export default meta;
diff --git a/apps/shade/src/components/patterns/settings-modal.tsx b/apps/shade/src/components/patterns/settings-modal.tsx
index 74d78553fb0..6f213229a5f 100644
--- a/apps/shade/src/components/patterns/settings-modal.tsx
+++ b/apps/shade/src/components/patterns/settings-modal.tsx
@@ -1,4 +1,3 @@
-import {useModal} from '@ebay/nice-modal-react';
import {cva} from 'class-variance-authority';
import {X} from 'lucide-react';
import React, {forwardRef, useEffect, useState} from 'react';
@@ -14,8 +13,8 @@ import useGlobalDirtyState from '@/hooks/use-global-dirty-state';
import {cn} from '@/lib/utils';
/**
- * Compatibility shell for settings modals while the legacy NiceModal flows are
- * migrated to Shade's consumer-controlled Dialog primitives.
+ * Consumer-controlled settings modal shell. Legacy full-page settings dialogs
+ * still render through it; new modal flows should use Shade Dialog primitives.
*/
export type SettingsModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'bleed';
@@ -40,12 +39,12 @@ export interface SettingsModalProps {
footerClassName?: string;
header?: boolean;
padding?: boolean;
- onOk?: () => void;
+ /** May be async; the modal fires it without awaiting, so the handler owns its own error handling. */
+ onOk?: () => void | Promise;
onCancel?: () => void;
topRightContent?: 'close' | React.ReactNode;
hideXOnMobile?: boolean;
- /** Supersedes the NiceModal close path; without it the modal must be mounted through NiceModal. Keep its presence stable across renders — toggling defined/undefined remounts the modal subtree. */
- onClose?: () => void;
+ onClose: () => void;
afterClose?: () => void;
children?: React.ReactNode;
backDrop?: boolean;
@@ -124,9 +123,7 @@ const headerOffsets: Record = {
bleed: '-inset-x-10'
};
-type SettingsModalContentProps = Omit & {requestClose: () => void};
-
-const SettingsModalContent = forwardRef(({
+const SettingsModal = forwardRef(({
'aria-label': ariaLabel,
className,
size = 'md',
@@ -150,7 +147,7 @@ const SettingsModalContent = forwardRef(
onCancel,
topRightContent,
hideXOnMobile = false,
- requestClose,
+ onClose,
afterClose,
children,
backDrop = true,
@@ -173,7 +170,7 @@ const SettingsModalContent = forwardRef(
const removeModal = () => {
confirm(dirty, () => {
- requestClose();
+ onClose();
afterClose?.();
});
};
@@ -226,7 +223,7 @@ const SettingsModalContent = forwardRef(
const handleCMDS = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
- onOk();
+ void onOk();
}
};
@@ -295,7 +292,7 @@ const SettingsModalContent = forwardRef(
)}
{okLabel && (
-
+ void onOk?.()}>
{okLoading && }
{okLabel}
@@ -359,22 +356,6 @@ const SettingsModalContent = forwardRef(
);
});
-SettingsModalContent.displayName = 'SettingsModalContent';
-
-const NiceSettingsModal = forwardRef>((props, ref) => {
- const modal = useModal();
- return modal.remove()} />;
-});
-
-NiceSettingsModal.displayName = 'NiceSettingsModal';
-
-const SettingsModal = forwardRef(({onClose, ...props}, ref) => {
- if (onClose) {
- return ;
- }
- return ;
-});
-
SettingsModal.displayName = 'SettingsModal';
export {SettingsModal, settingsModalVariants};
diff --git a/apps/shade/test/unit/components/patterns/settings-modal.test.tsx b/apps/shade/test/unit/components/patterns/settings-modal.test.tsx
index 42cfbc834ce..2bd7a34dc37 100644
--- a/apps/shade/test/unit/components/patterns/settings-modal.test.tsx
+++ b/apps/shade/test/unit/components/patterns/settings-modal.test.tsx
@@ -1,15 +1,8 @@
-import NiceModal from '@ebay/nice-modal-react';
-import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {describe, expect, it, vi} from 'vitest';
import {SettingsModal, settingsModalVariants, type SettingsModalSize} from '@/components/patterns/settings-modal';
-const TestSettingsModal = NiceModal.create(() => (
- undefined}>
- Modal content
-
-));
-
describe('SettingsModal', () => {
it.each(['sm', 'md', 'lg', 'xl', 'full'])('uses the standard dialog radius for the %s size', (size) => {
expect(settingsModalVariants({size})).toContain('rounded-lg');
@@ -21,27 +14,23 @@ describe('SettingsModal', () => {
expect(settingsModalVariants({size})).not.toContain('rounded-lg');
});
- it('uses content-sized outline and primary actions by default', async () => {
- render();
-
- act(() => {
- void NiceModal.show(TestSettingsModal);
- });
+ it('uses content-sized outline and primary actions by default', () => {
+ render(
+ undefined} onOk={() => undefined}>
+ Modal content
+
+ );
- const cancelButton = await screen.findByRole('button', {name: 'Cancel'});
+ const cancelButton = screen.getByRole('button', {name: 'Cancel'});
const okButton = screen.getByRole('button', {name: 'OK'});
expect(cancelButton.className).toContain('border-control-border');
expect(cancelButton.className).toContain('bg-transparent');
expect(cancelButton.className).not.toContain('hover:bg-accent');
expect(okButton.className).not.toContain('min-w-20');
-
- act(() => {
- void NiceModal.remove(TestSettingsModal);
- });
});
- it('renders without a NiceModal context and closes through onClose', () => {
+ it('closes through onClose', () => {
const onClose = vi.fn();
render(
@@ -71,23 +60,4 @@ describe('SettingsModal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
-
- it('still closes through NiceModal when no onClose is passed', async () => {
- const BridgeModal = NiceModal.create(() => (
-
- Modal content
-
- ));
-
- render();
- act(() => {
- void NiceModal.show(BridgeModal);
- });
-
- fireEvent.click(await screen.findByTestId('close-modal'));
-
- await waitFor(() => {
- expect(screen.queryByTestId('close-modal')).toBeNull();
- });
- });
});
diff --git a/apps/shade/test/unit/components/ui/overlay-escape.test.tsx b/apps/shade/test/unit/components/ui/overlay-escape.test.tsx
index ad3c3d04776..71cc091f921 100644
--- a/apps/shade/test/unit/components/ui/overlay-escape.test.tsx
+++ b/apps/shade/test/unit/components/ui/overlay-escape.test.tsx
@@ -1,5 +1,4 @@
-import NiceModal from '@ebay/nice-modal-react';
-import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {useState} from 'react';
import {beforeAll, describe, expect, it, vi} from 'vitest';
@@ -55,17 +54,11 @@ describe('nested overlay Escape behavior', () => {
it.each(overlayCases)('closes an uncontrolled %s before its parent SettingsModal', async (_name, Overlay) => {
const onCancel = vi.fn();
const onOpenChange = vi.fn();
- const TestModal = NiceModal.create(() => (
-
+ render(
+
- ));
-
- render();
-
- act(() => {
- void NiceModal.show(TestModal);
- });
+ );
await screen.findByText(/Menu item|Option one|Popover content|Combobox content/);
fireEvent.keyDown(document, {key: 'Escape'});
@@ -80,10 +73,6 @@ describe('nested overlay Escape behavior', () => {
await waitFor(() => {
expect(onCancel).toHaveBeenCalledOnce();
});
-
- act(() => {
- void NiceModal.remove(TestModal);
- });
});
it('preserves controlled overlay state behavior', async () => {
@@ -102,16 +91,11 @@ describe('nested overlay Escape behavior', () => {
);
};
- const TestModal = NiceModal.create(() => (
-
+ render(
+
- ));
-
- render();
- act(() => {
- void NiceModal.show(TestModal);
- });
+ );
await screen.findByText('Controlled content');
fireEvent.keyDown(document, {key: 'Escape'});
@@ -126,8 +110,8 @@ describe('nested overlay Escape behavior', () => {
const onCancel = vi.fn();
const onOuterOpenChange = vi.fn();
const onInnerOpenChange = vi.fn();
- const TestModal = NiceModal.create(() => (
-
+ render(
+
Open outer
@@ -141,12 +125,7 @@ describe('nested overlay Escape behavior', () => {
- ));
-
- render();
- act(() => {
- void NiceModal.show(TestModal);
- });
+ );
await screen.findByText('Inner item');
fireEvent.keyDown(document, {key: 'Escape'});
@@ -179,16 +158,11 @@ describe('nested overlay Escape behavior', () => {
it('keeps the modal open when a later document listener handles Escape', async () => {
const onCancel = vi.fn();
- const TestModal = NiceModal.create(() => (
-
+ render(
+
Modal content
- ));
-
- render();
- act(() => {
- void NiceModal.show(TestModal);
- });
+ );
await screen.findByText('Modal content');
const handleNestedEscape = (event: KeyboardEvent) => {
diff --git a/apps/signup-form/test/e2e/attribution.test.ts b/apps/signup-form/test/e2e/attribution.test.ts
index cd30d46dc11..32144525726 100644
--- a/apps/signup-form/test/e2e/attribution.test.ts
+++ b/apps/signup-form/test/e2e/attribution.test.ts
@@ -2,7 +2,7 @@ import {expect} from '@playwright/test';
import {initialize} from '../utils/e2e';
import {test} from '@playwright/test';
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
+/* eslint-disable @typescript-eslint/no-explicit-any */
async function testHistory({page, embeddedOnUrl, path, urlHistory, sessionStorageHistory}: {page: any, embeddedOnUrl?: string, path: string, urlHistory: any[], sessionStorageHistory?: any[]}) {
const {frame, lastApiRequest} = await initialize({page, title: 'Sign up', embeddedOnUrl, path});
@@ -30,6 +30,7 @@ async function testHistory({page, embeddedOnUrl, path, urlHistory, sessionStorag
expect(lastApiRequest.body).toHaveProperty('email', 'jamie@example.com');
expect(lastApiRequest.body).toHaveProperty('urlHistory', urlHistory);
}
+/* eslint-enable @typescript-eslint/no-explicit-any */
test.describe('Attribution', async () => {
test('Sends the current path', async ({page}) => {
diff --git a/e2e/eslint.config.js b/e2e/eslint.config.js
index 56caeb96a78..4509bae4e6a 100644
--- a/e2e/eslint.config.js
+++ b/e2e/eslint.config.js
@@ -148,6 +148,11 @@ export default tseslint.config([
// Manually include rules from plugin:ghost/ts and plugin:ghost/ts-test
// These would normally come from the extends, but flat config requires explicit inclusion
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
// Sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
diff --git a/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js b/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js
index 5720b0109fb..c2532d8a4cc 100644
--- a/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js
+++ b/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js
@@ -7,7 +7,8 @@
const {SafeString, templates} = require('../../../../services/handlebars');
// We use the name input_password to match the helper for consistency:
-module.exports = function input_password(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function input_password(options) {
options = options || {};
options.hash = options.hash || {};
diff --git a/ghost/core/core/frontend/helpers/body_class.js b/ghost/core/core/frontend/helpers/body_class.js
index a734a49b62f..38d75b26de9 100644
--- a/ghost/core/core/frontend/helpers/body_class.js
+++ b/ghost/core/core/frontend/helpers/body_class.js
@@ -11,7 +11,8 @@ const {SafeString} = require('../services/handlebars');
*/
// We use the name body_class to match the helper for consistency
-module.exports = function body_class(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function body_class(options) {
let classes = [];
const context = options.data.root.context || [];
const obj = this.post || this.page;
diff --git a/ghost/core/core/frontend/helpers/cancel_link.js b/ghost/core/core/frontend/helpers/cancel_link.js
index b405005a219..a04612f1975 100644
--- a/ghost/core/core/frontend/helpers/cancel_link.js
+++ b/ghost/core/core/frontend/helpers/cancel_link.js
@@ -15,7 +15,8 @@ const messages = {
invalidData: 'The {{cancel_link}} helper was used outside of a subscription context. See https://ghost.org/docs/themes/members/#cancel-links.'
};
-function cancel_link(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+function cancel_link(options) {
let truncateOptions = (options || {}).hash || {};
if (this.id === undefined || this.cancel_at_period_end === undefined) {
diff --git a/ghost/core/core/frontend/helpers/color_to_rgba.js b/ghost/core/core/frontend/helpers/color_to_rgba.js
index f901be1d617..ea2331eb613 100644
--- a/ghost/core/core/frontend/helpers/color_to_rgba.js
+++ b/ghost/core/core/frontend/helpers/color_to_rgba.js
@@ -1,6 +1,7 @@
const {Color} = require('@tryghost/color-utils');
-module.exports = function color_to_rgba(color, alpha) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function color_to_rgba(color, alpha) {
const backgroundColor = (typeof color === 'string' && color.trim()) ? color.trim() : '#15171A';
const opacity = Number.isFinite(alpha) ? alpha : Number.parseFloat(alpha);
const normalizedOpacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 0.25;
diff --git a/ghost/core/core/frontend/helpers/content_api_key.js b/ghost/core/core/frontend/helpers/content_api_key.js
index 5101fa043f0..b4aa6e9e302 100644
--- a/ghost/core/core/frontend/helpers/content_api_key.js
+++ b/ghost/core/core/frontend/helpers/content_api_key.js
@@ -2,7 +2,8 @@ const {SafeString} = require('../services/handlebars');
const logging = require('@tryghost/logging');
const {getFrontendKey} = require('../services/proxy');
-module.exports = async function content_api_key() { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = async function content_api_key() {
try {
const frontendKey = await getFrontendKey();
diff --git a/ghost/core/core/frontend/helpers/content_api_url.js b/ghost/core/core/frontend/helpers/content_api_url.js
index 8cef0ab0da9..29c21e8aa8a 100644
--- a/ghost/core/core/frontend/helpers/content_api_url.js
+++ b/ghost/core/core/frontend/helpers/content_api_url.js
@@ -2,7 +2,8 @@ const {SafeString} = require('../services/handlebars');
const logging = require('@tryghost/logging');
const {urlUtils} = require('../services/proxy');
-module.exports = function content_api_url(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function content_api_url(options) {
let result;
const absoluteUrlRequested = getAbsoluteOption(options);
diff --git a/ghost/core/core/frontend/helpers/contrast_text_color.js b/ghost/core/core/frontend/helpers/contrast_text_color.js
index 1d1f5d5c93f..68b2013231d 100644
--- a/ghost/core/core/frontend/helpers/contrast_text_color.js
+++ b/ghost/core/core/frontend/helpers/contrast_text_color.js
@@ -1,6 +1,7 @@
const {textColorForBackgroundColor} = require('@tryghost/color-utils');
-module.exports = function contrast_text_color(color) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function contrast_text_color(color) {
const backgroundColor = (typeof color === 'string' && color.trim()) ? color.trim() : '#15171A';
try {
diff --git a/ghost/core/core/frontend/helpers/facebook_url.js b/ghost/core/core/frontend/helpers/facebook_url.js
index cc8b0bd8cf2..15910d08f73 100644
--- a/ghost/core/core/frontend/helpers/facebook_url.js
+++ b/ghost/core/core/frontend/helpers/facebook_url.js
@@ -9,7 +9,8 @@ const {localUtils} = require('../services/handlebars');
/**
* @deprecated Use {{social_url type="facebook"}} instead.
*/
-module.exports = function facebook_url(username, options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function facebook_url(username, options) {
if (!options) {
options = username;
username = localUtils.findKey('facebook', this, options.data.site);
diff --git a/ghost/core/core/frontend/helpers/ghost_foot.js b/ghost/core/core/frontend/helpers/ghost_foot.js
index a439574faf0..71aa9add617 100644
--- a/ghost/core/core/frontend/helpers/ghost_foot.js
+++ b/ghost/core/core/frontend/helpers/ghost_foot.js
@@ -9,7 +9,8 @@ const _ = require('lodash');
const createFrame = hbs.handlebars.createFrame;
// We use the name ghost_foot to match the helper for consistency:
-module.exports = function ghost_foot(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function ghost_foot(options) {
const foot = [];
const globalCodeinjection = settingsCache.get('codeinjection_foot');
diff --git a/ghost/core/core/frontend/helpers/ghost_head.js b/ghost/core/core/frontend/helpers/ghost_head.js
index 87053512384..41570ded9e3 100644
--- a/ghost/core/core/frontend/helpers/ghost_head.js
+++ b/ghost/core/core/frontend/helpers/ghost_head.js
@@ -320,7 +320,8 @@ function getTinybirdTrackerScript(dataRoot) {
* Also see how the root object gets created, https://github.com/wycats/handlebars.js/blob/v4.0.6/lib/handlebars/runtime.js#L259
*/
// We use the name ghost_head to match the helper for consistency:
-module.exports = async function ghost_head(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = async function ghost_head(options) {
debug('begin');
// if server error page do nothing
if (options.data.root.statusCode >= 500) {
diff --git a/ghost/core/core/frontend/helpers/has.js b/ghost/core/core/frontend/helpers/has.js
index 454b8425d05..39c5ed858e2 100644
--- a/ghost/core/core/frontend/helpers/has.js
+++ b/ghost/core/core/frontend/helpers/has.js
@@ -107,9 +107,11 @@ function evaluateStringMatch(expr, str, ci) {
* @param {Object} data - global params
*/
function evaluateList(type, expr, obj, data) {
- return expr.split(',').map(function (prop) {
+ const props = expr.split(',').map(function (prop) {
return prop.trim().toLocaleLowerCase();
- })[type](function (prop) {
+ });
+
+ return props[type](function (prop) {
if (prop.match(/^@/)) {
return _.has(data, prop.replace(/@/, '')) && !_.isEmpty(_.get(data, prop.replace(/@/, '')));
} else {
diff --git a/ghost/core/core/frontend/helpers/link_class.js b/ghost/core/core/frontend/helpers/link_class.js
index 5ac27cf1d9a..6a54d5884b3 100644
--- a/ghost/core/core/frontend/helpers/link_class.js
+++ b/ghost/core/core/frontend/helpers/link_class.js
@@ -12,7 +12,8 @@ const messages = {
forIsRequired: 'The {{link_class}} helper requires a for="" attribute.'
};
-module.exports = function link_class(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function link_class(options) {
options = options || {};
options.hash = options.hash || {};
options.data = options.data || {};
diff --git a/ghost/core/core/frontend/helpers/meta_description.js b/ghost/core/core/frontend/helpers/meta_description.js
index 0ab68ba4e26..175b7d3f936 100644
--- a/ghost/core/core/frontend/helpers/meta_description.js
+++ b/ghost/core/core/frontend/helpers/meta_description.js
@@ -6,7 +6,8 @@ const metaData = require('../meta');
const {getMetaDataDescription} = metaData;
// We use the name meta_description to match the helper for consistency:
-module.exports = function meta_description(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function meta_description(options) {
options = options || {};
return getMetaDataDescription(this, options.data.root) || '';
diff --git a/ghost/core/core/frontend/helpers/meta_title.js b/ghost/core/core/frontend/helpers/meta_title.js
index 65a75f368c5..61fd88f2de1 100644
--- a/ghost/core/core/frontend/helpers/meta_title.js
+++ b/ghost/core/core/frontend/helpers/meta_title.js
@@ -6,6 +6,7 @@ const metaData = require('../meta');
const {getMetaDataTitle} = metaData;
// We use the name meta_title to match the helper for consistency:
-module.exports = function meta_title(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function meta_title(options) {
return getMetaDataTitle(this, options.data.root, options);
};
diff --git a/ghost/core/core/frontend/helpers/page_url.js b/ghost/core/core/frontend/helpers/page_url.js
index d46126b5f07..38e2e2d4dc3 100644
--- a/ghost/core/core/frontend/helpers/page_url.js
+++ b/ghost/core/core/frontend/helpers/page_url.js
@@ -8,7 +8,8 @@ const metaData = require('../meta');
const getPaginatedUrl = metaData.getPaginatedUrl;
// We use the name page_url to match the helper for consistency:
-module.exports = function page_url(page, options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function page_url(page, options) {
if (!options) {
options = page;
page = 1;
diff --git a/ghost/core/core/frontend/helpers/post_class.js b/ghost/core/core/frontend/helpers/post_class.js
index dd2779c4ec2..ba0263b3070 100644
--- a/ghost/core/core/frontend/helpers/post_class.js
+++ b/ghost/core/core/frontend/helpers/post_class.js
@@ -5,7 +5,8 @@
const {SafeString} = require('../services/handlebars');
// We use the name post_class to match the helper for consistency:
-module.exports = function post_class() { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function post_class() {
let classes = ['post'];
const tags = this.post && this.post.tags ? this.post.tags : this.tags || [];
diff --git a/ghost/core/core/frontend/helpers/reading_time.js b/ghost/core/core/frontend/helpers/reading_time.js
index 6622161c64e..00c1216e92c 100644
--- a/ghost/core/core/frontend/helpers/reading_time.js
+++ b/ghost/core/core/frontend/helpers/reading_time.js
@@ -15,7 +15,8 @@ const {SafeString} = require('../services/handlebars');
const {readingTime: calculateAndFormatReadingTime} = require('@tryghost/helpers');
-module.exports = function reading_time(options) {// eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function reading_time(options) {
options = options || {};
options.hash = options.hash || {};
const possiblyPost = this;
diff --git a/ghost/core/core/frontend/helpers/social_accounts.js b/ghost/core/core/frontend/helpers/social_accounts.js
index 722dd8e5602..4d947a1e2d3 100644
--- a/ghost/core/core/frontend/helpers/social_accounts.js
+++ b/ghost/core/core/frontend/helpers/social_accounts.js
@@ -45,7 +45,8 @@ const SOCIAL_PLATFORMS = [
{type: 'instagram', name: 'Instagram'}
];
-module.exports = function social_accounts(source, options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function social_accounts(source, options) {
// {{#social_accounts}} with no positional arg: handlebars passes only options.
if (arguments.length < 2) {
throw new errors.IncorrectUsageError({
diff --git a/ghost/core/core/frontend/helpers/social_url.js b/ghost/core/core/frontend/helpers/social_url.js
index 5f19b97bf9b..6311189c9b3 100644
--- a/ghost/core/core/frontend/helpers/social_url.js
+++ b/ghost/core/core/frontend/helpers/social_url.js
@@ -6,7 +6,8 @@ const {socialUrls} = require('../services/proxy');
const {localUtils} = require('../services/handlebars');
// We use the name social_url to match the helper for consistency:
-module.exports = function social_url(options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function social_url(options) {
// Check for required hash option 'type'
if (!options || !options.hash || !options.hash.type) {
return null;
diff --git a/ghost/core/core/frontend/helpers/total_members.js b/ghost/core/core/frontend/helpers/total_members.js
index f8d1865f41d..5f461e4e28c 100644
--- a/ghost/core/core/frontend/helpers/total_members.js
+++ b/ghost/core/core/frontend/helpers/total_members.js
@@ -4,7 +4,8 @@
const {SafeString} = require('../services/handlebars');
const {memberCountRounding, getMemberStats} = require('../utils/member-count');
-module.exports = async function total_members () { //eslint-disable-line
+// eslint-disable-next-line
+module.exports = async function total_members () {
if (this.total) {
return new SafeString(memberCountRounding(this.total));
} else {
diff --git a/ghost/core/core/frontend/helpers/total_paid_members.js b/ghost/core/core/frontend/helpers/total_paid_members.js
index acfbf44aa61..d665da86269 100644
--- a/ghost/core/core/frontend/helpers/total_paid_members.js
+++ b/ghost/core/core/frontend/helpers/total_paid_members.js
@@ -3,7 +3,8 @@
const {SafeString} = require('../services/handlebars');
const {memberCountRounding, getMemberStats} = require('../utils/member-count');
-module.exports = async function total_paid_members () { //eslint-disable-line
+// eslint-disable-next-line
+module.exports = async function total_paid_members () {
if (this.paid) {
return new SafeString(memberCountRounding(this.paid));
} else {
diff --git a/ghost/core/core/frontend/helpers/twitter_url.js b/ghost/core/core/frontend/helpers/twitter_url.js
index 62622067529..6ee39958be1 100644
--- a/ghost/core/core/frontend/helpers/twitter_url.js
+++ b/ghost/core/core/frontend/helpers/twitter_url.js
@@ -9,7 +9,8 @@ const {localUtils} = require('../services/handlebars');
/**
* @deprecated Use {{social_url type="twitter"}} instead.
*/
-module.exports = function twitter_url(username, options) { // eslint-disable-line camelcase
+// eslint-disable-next-line camelcase
+module.exports = function twitter_url(username, options) {
if (!options) {
options = username;
username = localUtils.findKey('twitter', this, options.data.site);
diff --git a/ghost/core/core/server/api/endpoints/utils/permissions.js b/ghost/core/core/server/api/endpoints/utils/permissions.js
index 12e7223b299..8a75c975eae 100644
--- a/ghost/core/core/server/api/endpoints/utils/permissions.js
+++ b/ghost/core/core/server/api/endpoints/utils/permissions.js
@@ -41,7 +41,8 @@ const nonePublicAuth = async (apiConfig, frame) => {
}
try {
- const result = await permissions.canThis(frame.options.context)[apiConfig.method][singular](permissionIdentifier, unsafeAttrObject);
+ const permissionCheck = permissions.canThis(frame.options.context)[apiConfig.method][singular];
+ const result = await permissionCheck(permissionIdentifier, unsafeAttrObject);
/*
* Allow the permissions function to return a list of excluded attributes.
diff --git a/ghost/core/core/server/data/importer/handlers/json.js b/ghost/core/core/server/data/importer/handlers/json.js
index a2abbc964fc..181721198b4 100644
--- a/ghost/core/core/server/data/importer/handlers/json.js
+++ b/ghost/core/core/server/data/importer/handlers/json.js
@@ -17,7 +17,8 @@ JSONHandler = {
contentTypes: ['application/octet-stream', 'application/json'],
directories: [],
- loadFile: async function (files, startDir) { // eslint-disable-line no-unused-vars
+ // eslint-disable-next-line no-unused-vars
+ loadFile: async function (files, startDir) {
debug('loadFile', files);
// @TODO: Handle multiple JSON files
const filePath = files[0].path;
diff --git a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js
index 565294ce4b7..cabd035779a 100644
--- a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js
+++ b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js
@@ -22,7 +22,8 @@ module.exports = createTransactionalMigration(
logging.info(`Updating ${rows.length} Tiers with price and currency information`);
}
- for (const row of rows) { // eslint-disable-line no-restricted-syntax
+ // eslint-disable-next-line no-restricted-syntax
+ for (const row of rows) {
await knex('products').update(row).where('id', row.id);
}
},
diff --git a/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js
index a6a2c07eeec..193a675504e 100644
--- a/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js
+++ b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js
@@ -26,14 +26,16 @@ module.exports = createTransactionalMigration(
}
// Attach a unique id to each row
- for (const row of rows) { // eslint-disable-line no-restricted-syntax
+ // eslint-disable-next-line no-restricted-syntax
+ for (const row of rows) {
row.batch_id = ObjectId().toHexString();
}
// Create batches (insertBatch doesn't support the onConflict option)
const batches = _.chunk(rows, 1000);
- for (const batch of batches) { // eslint-disable-line no-restricted-syntax
+ // eslint-disable-next-line no-restricted-syntax
+ for (const batch of batches) {
// Update the members_created_events table using INSERT ON DUPLICATE KEY UPDATE trick
const response1 = await knex('members_created_events').insert(batch.map((r) => {
return {
diff --git a/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js b/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js
index a498d3150c5..1f6a5ff5c29 100644
--- a/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js
+++ b/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js
@@ -25,7 +25,8 @@ module.exports = createTransactionalMigration(
logging.info(`Updating ${rows.length} Tiers with price and currency information`);
}
- for (const row of rows) { // eslint-disable-line no-restricted-syntax
+ // eslint-disable-next-line no-restricted-syntax
+ for (const row of rows) {
await knex('products').update(row).where('id', row.id);
}
},
diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js
index 807eaa0e06e..650a5b4f022 100644
--- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js
+++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js
@@ -20,15 +20,16 @@ class MembersCreatedEventsImporter extends TableImporter {
}
generateSource() {
- let source = 'member';
if (luck(10)) {
- source = 'admin';
- } else if (luck(5)) {
- source = 'api';
- } else if (luck(5)) { // eslint-disable-line no-dupe-else-if
- source = 'import';
+ return 'admin';
}
- return source;
+ if (luck(5)) {
+ return 'api';
+ }
+ if (luck(5)) {
+ return 'import';
+ }
+ return 'member';
}
generate() {
diff --git a/ghost/core/core/server/models/index.js b/ghost/core/core/server/models/index.js
index ffa610b50ee..b6f11366fd8 100644
--- a/ghost/core/core/server/models/index.js
+++ b/ghost/core/core/server/models/index.js
@@ -61,7 +61,7 @@ const {Redirect} = require('./redirect');
const {setIsRoles, checkUserPermissionsForRole} = require('./role-utils');
const {Role, Roles} = require('./role');
const {Session, Sessions} = require('./session');
-const {Settings, getOrGenerateSiteUuid} = require('./settings');
+const {Settings} = require('./settings');
const {SingleUseToken, SingleUseTokens} = require('./single-use-token');
const {Snippet, Snippets} = require('./snippet');
const {StripeCustomerSubscription} = require('./stripe-customer-subscription');
@@ -166,7 +166,6 @@ exports.Roles = Roles;
exports.Session = Session;
exports.Sessions = Sessions;
exports.Settings = Settings;
-exports.getOrGenerateSiteUuid = getOrGenerateSiteUuid;
exports.SingleUseToken = SingleUseToken;
exports.SingleUseTokens = SingleUseTokens;
exports.Snippet = Snippet;
diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js
index 827785e040b..b8d60be3914 100644
--- a/ghost/core/core/server/models/settings.js
+++ b/ghost/core/core/server/models/settings.js
@@ -478,6 +478,5 @@ Settings = ghostBookshelf.Model.extend({
});
module.exports = {
- Settings: ghostBookshelf.model('Settings', Settings),
- getOrGenerateSiteUuid: getOrGenerateSiteUuid
+ Settings: ghostBookshelf.model('Settings', Settings)
};
diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts
index 2438bdb3699..e9016990e2c 100644
--- a/ghost/core/core/server/services/automations/automations-repository.ts
+++ b/ghost/core/core/server/services/automations/automations-repository.ts
@@ -69,6 +69,7 @@ export interface AutomationBrowseResult extends AutomationSummary {
stats: {
last_run_created_at: Date | null;
total_run_count: number;
+ in_progress_run_count: number;
};
}
diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts
index bb1e921e156..67b517bf32d 100644
--- a/ghost/core/core/server/services/automations/database-automations-repository.ts
+++ b/ghost/core/core/server/services/automations/database-automations-repository.ts
@@ -56,6 +56,7 @@ interface AutomationRow {
interface AutomationBrowseRow extends AutomationRow {
last_run_created_at: DatabaseDate | null;
total_run_count: string | number | null;
+ in_progress_run_count: string | number | null;
}
interface ActionRow {
@@ -1019,11 +1020,17 @@ async function loadAutomationBySlug(trx: Knex.Transaction, slug: string): Promis
}
async function loadAutomations(trx: Knex.Transaction): Promise {
+ const inProgressRuns = trx('automation_run_steps')
+ .distinct('automation_run_id')
+ .where('status', 'pending')
+ .as('in_progress_runs');
const runStats = trx('automation_runs')
- .select('automation_id')
- .max({last_run_created_at: 'created_at'})
+ .select('automation_runs.automation_id')
+ .max({last_run_created_at: 'automation_runs.created_at'})
.count({total_run_count: '*'})
- .groupBy('automation_id')
+ .count({in_progress_run_count: 'in_progress_runs.automation_run_id'})
+ .leftJoin(inProgressRuns, 'automation_runs.id', 'in_progress_runs.automation_run_id')
+ .groupBy('automation_runs.automation_id')
.as('run_stats');
return await trx('automations')
.select(
@@ -1034,7 +1041,8 @@ async function loadAutomations(trx: Knex.Transaction): Promise}
*/
exports.scheduleRecurringNewslettersJob = async (...args) => {
- if (!process.env.NODE_ENV.startsWith('test')) {
+ if (!process.env.NODE_ENV?.startsWith('test')) {
await emailAnalyticsJobScheduler.scheduleRecurringNewslettersJob(...args);
}
};
@@ -24,7 +24,7 @@ exports.scheduleRecurringNewslettersJob = async (...args) => {
* @returns {Promise}
*/
exports.scheduleRecurringAutomationsJob = async (...args) => {
- if (!process.env.NODE_ENV.startsWith('test')) {
+ if (!process.env.NODE_ENV?.startsWith('test')) {
await emailAnalyticsJobScheduler.scheduleRecurringAutomationsJob(...args);
}
};
diff --git a/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js b/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js
index d66e369935b..3fdd65c3457 100644
--- a/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js
+++ b/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js
@@ -63,7 +63,8 @@ class AbstractEmailSuppressionList {
* @param {string} email
* @returns {Promise}
*/
- async removeEmail(email) { // eslint-disable-line
+ // eslint-disable-next-line
+ async removeEmail(email) {
return Promise.reject();
}
@@ -71,7 +72,8 @@ class AbstractEmailSuppressionList {
* @param {string} email
* @returns {Promise}
*/
- async getSuppressionData(email) { // eslint-disable-line
+ // eslint-disable-next-line
+ async getSuppressionData(email) {
return Promise.reject();
}
diff --git a/ghost/core/core/server/services/member-welcome-emails/service.js b/ghost/core/core/server/services/member-welcome-emails/service.js
index 36905cb580f..1bed0258d16 100644
--- a/ghost/core/core/server/services/member-welcome-emails/service.js
+++ b/ghost/core/core/server/services/member-welcome-emails/service.js
@@ -420,10 +420,15 @@ class MemberWelcomeEmailService {
tags = [MEMBER_WELCOME_EMAIL_TAG];
sendEmail = this.#sendTransactionalEmail.bind(this);
break;
- case 'automation':
+ case 'automation': {
tags = [AUTOMATION_EMAIL_TAG];
+ const mailgunTagFromConfig = config.get('bulkEmail:mailgun:tag');
+ if (typeof mailgunTagFromConfig === 'string' && mailgunTagFromConfig.length > 0) {
+ tags.push(mailgunTagFromConfig);
+ }
sendEmail = this.#sendBulkEmail.bind(this);
break;
+ }
default: {
/** @type {never} */ const _exhaustive = emailType;
throw new errors.InternalServerError({
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap
index a50b434a605..98f350d0f25 100644
--- a/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap
+++ b/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap
@@ -9,6 +9,7 @@ Object {
"name": "Free member welcome flow",
"slug": "member-welcome-email-free",
"stats": Object {
+ "in_progress_run_count": 0,
"last_run_created_at": null,
"total_run_count": 0,
},
@@ -21,6 +22,7 @@ Object {
"name": "Paid member welcome flow",
"slug": "member-welcome-email-paid",
"stats": Object {
+ "in_progress_run_count": 0,
"last_run_created_at": null,
"total_run_count": 0,
},
@@ -45,7 +47,7 @@ exports[`Automations API browse returns automations sourced from the database 2:
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
- "content-length": "624",
+ "content-length": "676",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js
index ff77262d58f..3906b39ce6e 100644
--- a/ghost/core/test/e2e-api/admin/automations.test.js
+++ b/ghost/core/test/e2e-api/admin/automations.test.js
@@ -25,7 +25,8 @@ const matchAutomationSummary = () => ({
...matchAutomationBase(),
stats: {
last_run_created_at: null,
- total_run_count: 0
+ total_run_count: 0,
+ in_progress_run_count: 0
}
});
@@ -115,14 +116,39 @@ describe('Automations API', function () {
describe('browse', function () {
async function createAutomationRun(automationId, createdAt) {
+ const runId = ObjectId().toHexString();
await models.Base.knex('automation_runs').insert({
- id: ObjectId().toHexString(),
+ id: runId,
automation_id: automationId,
member_id: null,
member_email: 'member@example.com',
created_at: createdAt,
updated_at: createdAt
});
+ return runId;
+ }
+
+ async function createAutomationRunStep(automationId, runId, status) {
+ const revisionId = await models.Base.knex('automation_action_revisions')
+ .innerJoin('automation_actions', 'automation_actions.id', 'automation_action_revisions.action_id')
+ .where('automation_actions.automation_id', automationId)
+ .first('automation_action_revisions.id')
+ .then(revision => revision.id);
+ const now = new Date();
+ await models.Base.knex('automation_run_steps').insert({
+ id: ObjectId().toHexString(),
+ automation_run_id: runId,
+ automation_action_revision_id: revisionId,
+ ready_at: now,
+ step_attempts: 0,
+ started_at: null,
+ finished_at: null,
+ status,
+ locked_by: null,
+ locked_at: null,
+ created_at: now,
+ updated_at: now
+ });
}
async function deleteActionsForAutomationIds(automationIds) {
@@ -230,6 +256,31 @@ describe('Automations API', function () {
assert.equal(automation.stats.total_run_count, 2);
});
+ it('returns zero in progress run counts for automations without runs', async function () {
+ const {body} = await agent.get('automations').expectStatus(200);
+
+ assert.deepEqual(body.automations.map(automation => automation.stats.in_progress_run_count), [0, 0]);
+ });
+
+ it('returns the number of runs with pending steps', async function () {
+ const {body: beforeBody} = await agent.get('automations').expectStatus(200);
+ const automationId = beforeBody.automations[0].id;
+
+ const pendingRunId = await createAutomationRun(automationId, new Date('2026-01-01T00:00:00.000Z'));
+ await createAutomationRunStep(automationId, pendingRunId, 'finished');
+ await createAutomationRunStep(automationId, pendingRunId, 'pending');
+
+ const finishedRunId = await createAutomationRun(automationId, new Date('2026-01-02T00:00:00.000Z'));
+ await createAutomationRunStep(automationId, finishedRunId, 'finished');
+
+ await createAutomationRun(automationId, new Date('2026-01-03T00:00:00.000Z'));
+
+ const {body} = await agent.get('automations').expectStatus(200);
+ const automation = body.automations.find(candidate => candidate.id === automationId);
+
+ assert.equal(automation.stats.in_progress_run_count, 1);
+ });
+
it('upserts the default free and paid automations', async function () {
const existingAutomations = await models.Base.knex('automations')
.select('id')
diff --git a/ghost/core/test/integration/services/member-welcome-emails.test.js b/ghost/core/test/integration/services/member-welcome-emails.test.js
index 0d797f5abe3..4ad3cef199d 100644
--- a/ghost/core/test/integration/services/member-welcome-emails.test.js
+++ b/ghost/core/test/integration/services/member-welcome-emails.test.js
@@ -5,6 +5,7 @@ const testUtils = require('../../utils');
const {mockManager} = require('../../utils/e2e-framework');
const models = require('../../../core/server/models');
const db = require('../../../core/server/data/db');
+const config = require('../../../core/shared/config');
const MailgunClient = require('../../../core/server/services/lib/mailgun-client');
const mailService = require('../../../core/server/services/mail');
const settingsHelpers = require('../../../core/server/services/settings-helpers');
@@ -486,6 +487,17 @@ describe('Member Welcome Emails Integration', function () {
assert.deepEqual(sendCall.args[0].tags, ['automation-email']);
});
+ it('adds the configured bulk email Mailgun tag to automation emails', async function () {
+ sinon.stub(config, 'get').callThrough()
+ .withArgs('bulkEmail:mailgun:tag').returns('blog-123');
+
+ await sendAutomationEmail();
+
+ sinon.assert.calledOnce(MailgunClient.prototype.send);
+ const sendCall = MailgunClient.prototype.send.firstCall;
+ assert.deepEqual(sendCall.args[0].tags, ['automation-email', 'blog-123']);
+ });
+
it('passes the open tracking value through for automation emails', async function () {
await sendAutomationEmail({trackOpens: true});
diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts
index 0c6ceb2078a..e390b2f5bd8 100644
--- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts
+++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts
@@ -705,6 +705,57 @@ describe('automations repository', function () {
assert.equal(otherAutomation.stats.total_run_count, 1);
});
+ it('returns zero for "in progress run count" if the automation has no runs', async function () {
+ const result = await repo.browse();
+
+ assert(result.data.every(automation => automation.stats.in_progress_run_count === 0));
+ });
+
+ it('returns zero for "in progress run count" if none of the runs have pending steps', async function () {
+ const automationId = (await getAutomationBySlug('member-welcome-email-free')).id;
+ const {revision_id: revisionId} = await getActionByIndex(automationId, 0);
+ const run = await insertRun(automationId);
+ await insertStep(run.id, revisionId, {status: 'finished'});
+
+ const browseResult = await repo.browse();
+ const automation = browseResult.data.find(candidate => candidate.id === automationId);
+ assert(automation);
+
+ assert.equal(automation.stats.in_progress_run_count, 0);
+ });
+
+ it('returns the number of runs with pending steps for the automation', async function () {
+ const automationId = (await getAutomationBySlug('member-welcome-email-free')).id;
+ const otherAutomationId = (await getAutomationBySlug('member-welcome-email-paid')).id;
+ const {revision_id: revisionId} = await getActionByIndex(automationId, 0);
+ const {revision_id: otherRevisionId} = await getActionByIndex(otherAutomationId, 0);
+
+ const pendingRun = await insertRun(automationId);
+ await insertStep(pendingRun.id, revisionId, {status: 'finished'});
+ await insertStep(pendingRun.id, revisionId, {status: 'pending'});
+
+ const multiStepPendingRun = await insertRun(automationId);
+ await insertStep(multiStepPendingRun.id, revisionId, {status: 'pending'});
+ await insertStep(multiStepPendingRun.id, revisionId, {status: 'pending'});
+
+ const finishedRun = await insertRun(automationId);
+ await insertStep(finishedRun.id, revisionId, {status: 'finished'});
+
+ await insertRun(automationId);
+
+ const otherPendingRun = await insertRun(otherAutomationId);
+ await insertStep(otherPendingRun.id, otherRevisionId, {status: 'pending'});
+
+ const browseResult = await repo.browse();
+ const automation = browseResult.data.find(candidate => candidate.id === automationId);
+ const otherAutomation = browseResult.data.find(candidate => candidate.id === otherAutomationId);
+ assert(automation);
+ assert(otherAutomation);
+
+ assert.equal(automation.stats.in_progress_run_count, 2);
+ assert.equal(otherAutomation.stats.in_progress_run_count, 1);
+ });
+
it('creates missing default free and paid automations', async function () {
const automationIds = await knex('automations')
.whereIn('slug', ['member-welcome-email-free', 'member-welcome-email-paid'])
diff --git a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts
index 5014e4a525c..ec750735c9d 100644
--- a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts
+++ b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts
@@ -386,6 +386,21 @@ describe('EmailAnalyticsService', function () {
sinon.assert.notCalled(eventProcessor.aggregate);
});
+ it('returns 0 and clears metadata when a pending fetch is canceled', async function () {
+ await service.schedule({
+ begin: new Date(2023, 0, 1),
+ end: new Date(2023, 0, 2)
+ });
+ service.getStatus().scheduled.canceled = true;
+ setJobMetadataStub.resetHistory();
+
+ const result = await service.fetchScheduled();
+
+ assert.equal(result.eventCount, 0);
+ sinon.assert.calledOnceWithExactly(setJobMetadataStub, JOB_NAMES.scheduled, null);
+ sinon.assert.notCalled(eventProcessor.processBatch);
+ });
+
it('fetches events with correct parameters', async function () {
await service.schedule({
begin: new Date(2023, 0, 1),
@@ -400,6 +415,63 @@ describe('EmailAnalyticsService', function () {
assert.deepEqual(eventProcessor.processBatch.getCall(0).args[0], [1,2,3,4,5,6,7,8,9,10]);
});
+ it('resumes from the last processed event timestamp', async function () {
+ const begin = new Date(2023, 0, 1);
+ const lastEventTimestamp = new Date(2023, 0, 1, 12);
+ const end = new Date(2023, 0, 2);
+ const fetchEvents = sinon.stub().resolves();
+ service = createService({
+ queries: {
+ getJobData: sinon.stub().resolves({
+ finished_at: lastEventTimestamp,
+ started_at: null,
+ metadata: {
+ begin: begin.toISOString(),
+ end: end.toISOString()
+ }
+ }),
+ setJobTimestamp: setJobTimestampStub,
+ setJobStatus: setJobStatusStub,
+ setJobMetadata: setJobMetadataStub
+ },
+ fetchEvents
+ });
+ await service.restoreScheduled();
+
+ await service.fetchScheduled();
+
+ sinon.assert.calledOnceWithMatch(fetchEvents, {
+ begin: lastEventTimestamp,
+ end
+ });
+ });
+
+ it('cancels a fetch while it is processing', async function () {
+ service = createService({
+ queries: {
+ setJobTimestamp: setJobTimestampStub,
+ setJobStatus: setJobStatusStub,
+ setJobMetadata: setJobMetadataStub
+ },
+ fetchEvents: async ({batchHandler}: {batchHandler: BatchHandler}) => {
+ service.cancelScheduled();
+ await batchHandler([1]);
+ },
+ createEventProcessor: () => eventProcessor
+ });
+ await service.schedule({
+ begin: new Date(2023, 0, 1),
+ end: new Date(2023, 0, 2)
+ });
+ setJobMetadataStub.resetHistory();
+
+ const result = await service.fetchScheduled();
+
+ assert.equal(result.eventCount, 1);
+ sinon.assert.calledWith(setJobMetadataStub, JOB_NAMES.scheduled, null);
+ assert.equal(service.getStatus().scheduled.canceled, undefined);
+ });
+
it('bails when end date is before begin date', async function () {
await service.schedule({
begin: new Date(2023, 0, 2),
@@ -466,6 +538,19 @@ describe('EmailAnalyticsService', function () {
});
});
+ it('rejects scheduling while a fetch is running', async function () {
+ await service.schedule({
+ begin: new Date(2023, 0, 1),
+ end: new Date(2023, 0, 2)
+ });
+ service.getStatus().scheduled.running = true;
+
+ await assert.rejects(service.schedule({
+ begin: new Date(2023, 0, 3),
+ end: new Date(2023, 0, 4)
+ }), /Already fetching scheduled events/);
+ });
+
it('clears metadata when canceling a non-running schedule', async function () {
await service.schedule({
begin: new Date(2023, 0, 1),
@@ -563,6 +648,96 @@ describe('EmailAnalyticsService', function () {
await assert.rejects(service.fetchLatestOpenedEvents(), /final aggregation failed/);
});
+
+ it('supports processors without aggregation', async function () {
+ const eventProcessor = createStubEventProcessor();
+ eventProcessor.aggregate = undefined as never;
+ const service = createServiceWithEventProcessor(eventProcessor);
+
+ const result = await service.fetchLatestOpenedEvents();
+
+ assert.equal(result.eventCount, 1);
+ assert.equal(result.aggregationTimeMs, 0);
+ });
+
+ it('supports aggregation without timing details', async function () {
+ const eventProcessor = createStubEventProcessor();
+ eventProcessor.aggregate.resolves(undefined);
+ const service = createServiceWithEventProcessor(eventProcessor);
+
+ const result = await service.fetchLatestOpenedEvents();
+
+ assert.equal(result.eventCount, 1);
+ assert.equal(result.aggregationTimeMs, 0);
+ });
+
+ it('preserves new email and member IDs in the cumulative result', async function () {
+ const eventProcessor = createStubEventProcessor();
+ eventProcessor.processBatch.callsFake(async (_events, result) => {
+ result.emailIds.push('email-id');
+ result.memberIds.push('member-id');
+ });
+ const service = createServiceWithEventProcessor(eventProcessor);
+
+ const result = await service.fetchLatestOpenedEvents();
+
+ assert.deepEqual(result.result.emailIds, ['email-id']);
+ assert.deepEqual(result.result.memberIds, ['member-id']);
+ });
+
+ it('rejects when fetching events fails', async function () {
+ const error = new Error('fetch failed');
+ const service = createService({
+ queries: {
+ getLastEventTimestamp: sinon.stub().resolves(),
+ setJobTimestamp: sinon.stub().resolves(),
+ setJobStatus: sinon.stub().resolves()
+ },
+ fetchEvents: sinon.stub().rejects(error)
+ });
+
+ await assert.rejects(service.fetchLatestOpenedEvents(), error);
+ });
+
+ it('rejects when fetching events throws a non-Error value', async function () {
+ const service = createService({
+ queries: {
+ getLastEventTimestamp: sinon.stub().resolves(),
+ setJobTimestamp: sinon.stub().resolves(),
+ setJobStatus: sinon.stub().resolves()
+ },
+ fetchEvents: async () => {
+ throw 'fetch failed';
+ }
+ });
+
+ await assert.rejects(service.fetchLatestOpenedEvents(), error => error === 'fetch failed');
+ });
+
+ it('persists and advances the last processed event timestamp', async function () {
+ const lastEventTimestamp = new Date(Date.now() - 10_000);
+ const setJobTimestamp = sinon.stub().resolves();
+ const eventProcessor = createStubEventProcessor();
+ eventProcessor.processBatch.callsFake(async (_events, _result, fetchData) => {
+ fetchData.lastEventTimestamp = lastEventTimestamp;
+ });
+ const service = createService({
+ queries: {
+ getLastEventTimestamp: sinon.stub().resolves(),
+ setJobTimestamp,
+ setJobStatus: sinon.stub().resolves()
+ },
+ fetchEvents: async ({batchHandler}: {batchHandler: BatchHandler}) => {
+ await batchHandler([1]);
+ },
+ createEventProcessor: () => eventProcessor
+ });
+
+ await service.fetchLatestOpenedEvents({maxEvents: 2});
+
+ sinon.assert.calledWithExactly(setJobTimestamp, JOB_NAMES.latestOpened, 'finished', lastEventTimestamp);
+ assert.deepEqual(service.getStatus().latestOpened.lastEventTimestamp, new Date(lastEventTimestamp.getTime() + 1000));
+ });
});
describe('restoreScheduled', function () {
@@ -611,6 +786,18 @@ describe('EmailAnalyticsService', function () {
assert.equal(status.scheduled.schedule, undefined);
});
+ it('handles errors while restoring persisted data', async function () {
+ const service = createService({
+ queries: {
+ getJobData: sinon.stub().rejects(new Error('read failed'))
+ }
+ });
+
+ await service.restoreScheduled();
+
+ assert.equal(service.getStatus().scheduled.schedule, undefined);
+ });
+
it('does nothing when metadata is null', async function () {
const service = createService({
queries: {
@@ -694,6 +881,21 @@ describe('EmailAnalyticsService', function () {
await service.fetchMissing();
sinon.assert.calledOnce(fetchLatestSpy);
});
+
+ it('quits if the end is before the begin', async function () {
+ const fetchEvents = sinon.spy();
+ const service = createService({
+ queries: {
+ getLastJobRunTimestamp: sinon.stub().resolves(new Date(Date.now() + 1000))
+ },
+ fetchEvents
+ });
+
+ const result = await service.fetchMissing();
+
+ assert.equal(result.eventCount, 0);
+ sinon.assert.notCalled(fetchEvents);
+ });
});
});
});
diff --git a/koenig/kg-card-factory/eslint.config.mjs b/koenig/kg-card-factory/eslint.config.mjs
index 3b85fa8bbbe..2ec2e0bdf0f 100644
--- a/koenig/kg-card-factory/eslint.config.mjs
+++ b/koenig/kg-card-factory/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: {ghost: ghostPlugin},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error'
}
},
diff --git a/koenig/kg-clean-basic-html/eslint.config.mjs b/koenig/kg-clean-basic-html/eslint.config.mjs
index 25fe9a16bf5..4bd9bc12baa 100644
--- a/koenig/kg-clean-basic-html/eslint.config.mjs
+++ b/koenig/kg-clean-basic-html/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: { ghost: ghostPlugin },
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error',
},
},
diff --git a/koenig/kg-converters/eslint.config.mjs b/koenig/kg-converters/eslint.config.mjs
index 25fe9a16bf5..4bd9bc12baa 100644
--- a/koenig/kg-converters/eslint.config.mjs
+++ b/koenig/kg-converters/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: { ghost: ghostPlugin },
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error',
},
},
diff --git a/koenig/kg-default-cards/eslint.config.mjs b/koenig/kg-default-cards/eslint.config.mjs
index d75842e22c2..89f6111b128 100644
--- a/koenig/kg-default-cards/eslint.config.mjs
+++ b/koenig/kg-default-cards/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: {ghost: ghostPlugin},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error'
}
},
diff --git a/koenig/kg-default-transforms/eslint.config.mjs b/koenig/kg-default-transforms/eslint.config.mjs
index d75842e22c2..89f6111b128 100644
--- a/koenig/kg-default-transforms/eslint.config.mjs
+++ b/koenig/kg-default-transforms/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: {ghost: ghostPlugin},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error'
}
},
diff --git a/koenig/kg-html-to-lexical/eslint.config.mjs b/koenig/kg-html-to-lexical/eslint.config.mjs
index 3b85fa8bbbe..2ec2e0bdf0f 100644
--- a/koenig/kg-html-to-lexical/eslint.config.mjs
+++ b/koenig/kg-html-to-lexical/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: {ghost: ghostPlugin},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error'
}
},
diff --git a/koenig/kg-markdown-html-renderer/eslint.config.mjs b/koenig/kg-markdown-html-renderer/eslint.config.mjs
index 3b85fa8bbbe..2ec2e0bdf0f 100644
--- a/koenig/kg-markdown-html-renderer/eslint.config.mjs
+++ b/koenig/kg-markdown-html-renderer/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: {ghost: ghostPlugin},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error'
}
},
diff --git a/koenig/kg-unsplash-selector/eslint.config.js b/koenig/kg-unsplash-selector/eslint.config.js
index d27ac2ab7a0..c3b4c657225 100644
--- a/koenig/kg-unsplash-selector/eslint.config.js
+++ b/koenig/kg-unsplash-selector/eslint.config.js
@@ -38,6 +38,11 @@ export default defineConfig([
},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
// sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
diff --git a/koenig/kg-utils/eslint.config.mjs b/koenig/kg-utils/eslint.config.mjs
index 3b85fa8bbbe..2ec2e0bdf0f 100644
--- a/koenig/kg-utils/eslint.config.mjs
+++ b/koenig/kg-utils/eslint.config.mjs
@@ -17,6 +17,11 @@ export default defineConfig([
plugins: {ghost: ghostPlugin},
rules: {
...ghostPlugin.configs.ts.rules,
+ // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off
+ ...ghostPlugin.configs['ts-no-style'].rules,
+ camelcase: ghostPlugin.configs.ts.rules.camelcase,
+ curly: ghostPlugin.configs.ts.rules.curly,
+ 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'],
'@typescript-eslint/no-explicit-any': 'error'
}
},
diff --git a/package.json b/package.json
index 0ec025e3584..32a97968976 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,8 @@
"lint:boundaries": "depcruise ghost/core/core apps --config .dependency-cruiser.cjs",
"lint:packages": "node scripts/check-internal-packages.js",
"lint:docs": "pnpm lint:agent-skills && pnpm lint:agent-guidance && pnpm lint:markdown && pnpm lint:doc-links",
+ "format": "oxfmt",
+ "format:check": "oxfmt --check",
"check": "pnpm lint && pnpm test",
"test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin",
"test:unit": "pnpm nx run-many -t test:unit",
diff --git a/packages/i18n/test/i18n.lint.js b/packages/i18n/test/i18n.lint.js
index 7497668f3ae..25e37d630de 100644
--- a/packages/i18n/test/i18n.lint.js
+++ b/packages/i18n/test/i18n.lint.js
@@ -272,7 +272,8 @@ class LinterContext {
resultsToFilter.add(resultIndex);
}
- console.log(`Applied ${this._fixes.length} fixes`); // eslint-disable-line no-console
+ // eslint-disable-next-line no-console
+ console.log(`Applied ${this._fixes.length} fixes`);
for (const resultIndex of resultsToFilter) {
this.summary.results[resultIndex].messages = this.summary.results[resultIndex].messages.filter(Boolean);
@@ -310,17 +311,20 @@ async function exitWithSummary(summary) {
const formattedResults = await formatter.format(summary.results, {cwd: '', rulesMeta: {}});
if (formattedResults) {
- console.log(formattedResults); // eslint-disable-line no-console
+ // eslint-disable-next-line no-console
+ console.log(formattedResults);
}
if (summary.ignoreAllFlagProvidedWithoutFixFlag) {
- console.warn( // eslint-disable-line no-console
+ // eslint-disable-next-line no-console
+ console.warn(
'--unsafe-ignore-all was provided without --fix; errors were not ignored'
);
}
if (summary.errorCount > 0) {
- console.log( // eslint-disable-line no-console
+ // eslint-disable-next-line no-console
+ console.log(
`\nNote: Since JSON doesn't support comments, use test/i18n-ignore.json to waive messages.\n\n`
);
process.exit(1);
@@ -479,7 +483,8 @@ async function analyze() {
if (require.main === module) {
analyze().then(results => exitWithSummary(results)).catch((error) => {
- console.error(error); // eslint-disable-line no-console
+ // eslint-disable-next-line no-console
+ console.error(error);
process.exit(1);
});
}
diff --git a/packages/testing/test-data/src/selectors/offers.ts b/packages/testing/test-data/src/selectors/offers.ts
index 0b9d187e1bd..dcb53854bff 100644
--- a/packages/testing/test-data/src/selectors/offers.ts
+++ b/packages/testing/test-data/src/selectors/offers.ts
@@ -1,6 +1,6 @@
/**
* Offers screen selector strings, consumed by the admin screen helpers.
- * Source of truth: apps/admin/src/settings/app/components/settings/growth/offers.
+ * Source of truth: apps/admin/src/settings/growth/offers.
*/
export const offersSelectors = {
testIds: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2a1fd2f281d..27751ee72b9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,9 +57,6 @@ catalogs:
'@dnd-kit/utilities':
specifier: 3.2.2
version: 3.2.2
- '@ebay/nice-modal-react':
- specifier: 1.2.13
- version: 1.2.13
'@eslint/compat':
specifier: 2.1.0
version: 2.1.0
@@ -748,9 +745,6 @@ importers:
'@dnd-kit/sortable':
specifier: 'catalog:'
version: 7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
- '@ebay/nice-modal-react':
- specifier: 'catalog:'
- version: 1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/react':
specifier: 'catalog:'
version: 7.120.4(react@18.3.1)
@@ -990,9 +984,6 @@ importers:
apps/admin-x-framework:
dependencies:
- '@ebay/nice-modal-react':
- specifier: 'catalog:'
- version: 1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/react':
specifier: 'catalog:'
version: 7.120.4(react@18.3.1)
@@ -1724,9 +1715,6 @@ importers:
'@dnd-kit/utilities':
specifier: 'catalog:'
version: 3.2.2(react@18.3.1)
- '@ebay/nice-modal-react':
- specifier: 'catalog:'
- version: 1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@hookform/resolvers':
specifier: 5.4.0
version: 5.4.0(react-hook-form@7.80.0(react@18.3.1))
@@ -5282,12 +5270,6 @@ packages:
react: '>=17.0.2'
react-dom: '>=17.0.2'
- '@ebay/nice-modal-react@1.2.13':
- resolution: {integrity: sha512-jx8xIWe/Up4tpNuM02M+rbnLoxdngTGk3Y8LjJsLGXXcSoKd/+eZStZcAlIO/jwxyz/bhPZnpqPJZWAmhOofuA==}
- peerDependencies:
- react: '>16.8.0'
- react-dom: '>16.8.0'
-
'@elastic/elasticsearch@8.19.2':
resolution: {integrity: sha512-LMJCju/+AZkDlJArd/MYABWTDrHi4U7j3qGTKi1hYC7+67SaiYmYFItiueACQVrj2j3ECPMcguZ+7WrZeB+Z5g==}
engines: {node: '>=20'}
@@ -24237,11 +24219,6 @@ snapshots:
react: 17.0.2
react-dom: 17.0.2(react@17.0.2)
- '@ebay/nice-modal-react@1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
-
'@elastic/elasticsearch@8.19.2(supports-color@10.2.2)':
dependencies:
'@elastic/transport': 8.10.1(supports-color@10.2.2)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d2de342fea6..8b714738fe2 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -54,7 +54,6 @@ catalog:
'@codemirror/state': 6.7.1
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.6
- '@ebay/nice-modal-react': 1.2.13
'@eslint/compat': 2.1.0
'@eslint/js': 9.39.5
'@faker-js/faker': 10.5.0