{{content}} From de23f8a1f3d707d6ae0c2137e19f8f33089dcee2 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 2 Sep 2026 13:12:09 +0100 Subject: [PATCH 04/15] Added `improveSendingUI` labs flag (#30455) ref https://linear.app/ghost/issue/BER-3920/ - adds labs flag in preparation for gating UI changes during development --- apps/admin/src/settings/advanced/labs/private-features.tsx | 5 +++++ ghost/core/core/shared/labs.js | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/admin/src/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx index cdaf6c15dc3..6028fa4a853 100644 --- a/apps/admin/src/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -60,6 +60,11 @@ const features: Feature[] = [ 'Enables {uniqueid} variable in emails for unique image URLs to bypass ESP image caching', flag: 'emailUniqueid', }, + { + title: 'Improve sending UI', + description: 'Enables improvements to email sending and delivery status for large email sends', + flag: 'improveSendingUI', + }, { title: 'Updated theme translation (beta)', description: 'Enable theme translation using i18next instead of the old translation package.', diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 62f5d63151e..691f1685e21 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -49,6 +49,7 @@ const PRIVATE_FEATURES = [ 'admin7PageChrome', 'tagsX', 'emailUniqueid', + 'improveSendingUI', 'themeTranslation', 'pictureImageFormats', 'getHelperDeduplication', From c46f039a91fd6ec35dfbcf6340a5825abfe71bb1 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 2 Sep 2026 07:56:07 -0500 Subject: [PATCH 05/15] Added editor e2e coverage for session expiry and scheduled-post editing (#30447) no ref The editor's data-loss-critical flows had no browser e2e coverage. These two specs pin the current behavior of the Ember editor so it cannot regress silently. --- .../admin/posts/post/post-editor-page.ts | 38 ++++++ .../admin/posts/editor-session-expiry.test.ts | 86 ++++++++++++ .../posts/scheduled-post-editing.test.ts | 126 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 e2e/tests/admin/posts/editor-session-expiry.test.ts create mode 100644 e2e/tests/admin/posts/scheduled-post-editing.test.ts diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts index 9d9b95ca7c6..a6ac4cf1abe 100644 --- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts +++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts @@ -28,6 +28,25 @@ class SettingsMenu extends BasePage { } } +class ReAuthenticateModal extends BasePage { + readonly modal: Locator; + readonly passwordInput: Locator; + readonly signInButton: Locator; + + constructor(page: Page) { + super(page); + + this.modal = page.locator('[data-test-modal="re-authenticate"]'); + this.passwordInput = this.modal.getByLabel('Your password'); + this.signInButton = this.modal.getByRole('button', { name: /Sign in/ }); + } + + async signIn(password: string): Promise { + await this.passwordInput.fill(password); + await this.signInButton.click(); + } +} + class PublishFlow extends BasePage { readonly publishButton: Locator; readonly publishTypeSetting: Locator; @@ -144,6 +163,7 @@ export class PostEditorPage extends AdminPage { readonly backButton: Locator; readonly settingsMenu: SettingsMenu; + readonly reauthenticateModal: ReAuthenticateModal; constructor(page: Page) { super(page); @@ -164,6 +184,20 @@ export class PostEditorPage extends AdminPage { this.backButton = page.locator('[data-test-breadcrumb]'); this.settingsMenu = new SettingsMenu(page); + this.reauthenticateModal = new ReAuthenticateModal(page); + } + + /** + * The id of the post currently open in the editor. Waits for the URL to + * carry an id first: a new draft only gets one after its first save. + */ + async getPostId(): Promise { + await this.page.waitForURL(/#\/editor\/post\/[0-9a-f]{24}/); + const match = this.page.url().match(/#\/editor\/post\/([0-9a-f]{24})/); + if (!match) { + throw new Error(`No post id in editor URL: ${this.page.url()}`); + } + return match[1]; } async gotoPost(postId: string): Promise { @@ -201,6 +235,10 @@ export class PostEditorPage extends AdminPage { async appendToBody(text: string): Promise { await this.lexicalEditor.click(); + // The click can land the caret mid-content; select all and collapse the + // selection so the text is genuinely appended at the end + await this.page.keyboard.press('ControlOrMeta+a'); + await this.page.keyboard.press('ArrowRight'); await this.page.keyboard.type(text); } diff --git a/e2e/tests/admin/posts/editor-session-expiry.test.ts b/e2e/tests/admin/posts/editor-session-expiry.test.ts new file mode 100644 index 00000000000..f16672f639c --- /dev/null +++ b/e2e/tests/admin/posts/editor-session-expiry.test.ts @@ -0,0 +1,86 @@ +import { PostEditorPage, PostsPage } from '@/admin-pages'; +import { expect, test } from '@/helpers/playwright'; + +test.describe('Ghost Admin - Editor session expiry', () => { + test('save recovers through re-authentication after the session expires', async ({ + page, + ghostAccountOwner, + }) => { + // Draft creation, expiry, re-authentication and a second autosave do not + // fit the default local budget + test.setTimeout(60000); + + const postData = { + title: `session-expiry-${Date.now()}`, + body: 'Written before the session expired.', + }; + + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft(postData); + // The draft autosave assigns the post an id and moves the URL onto it + const postId = await editor.getPostId(); + + // Typing the draft can leave content unsaved with no autosave pending + // (performs during the in-flight create are dropped) or with a debounced + // autosave still to come. Flush deterministically before expiring the + // session - type a marker and wait for the save that carries it - so the + // 401 comes from the post-expiry typing below, not a stale autosave + const flushMarker = 'Flushed before expiry.'; + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes(`/ghost/api/admin/posts/${postId}/`) && + response.status() === 200 && + (response.request().postData() ?? '').includes(flushMarker), + ), + editor.appendToBody(` ${flushMarker}`), + ]); + + // Expire the session server-side while the editor stays open + const logoutResponse = await page.request.delete('/ghost/api/admin/session/'); + expect(logoutResponse.ok()).toBeTruthy(); + + // Typing more triggers the draft autosave, which now fails authentication + await editor.appendToBody(' Written after the session expired.'); + + // The editor must not redirect away and lose content; it keeps the post + // on screen and asks for the password instead + await expect(editor.reauthenticateModal.modal).toBeVisible({ timeout: 15000 }); + expect(page.url()).toContain(`/editor/post/${postId}`); + await expect(editor.lexicalEditor).toContainText('Written after the session expired.'); + + // Re-authenticating closes the modal and restores the session + await editor.reauthenticateModal.signIn(ghostAccountOwner.password); + await expect(editor.reauthenticateModal.modal).toBeHidden(); + + // Saving works again: the next edit autosaves successfully + const [saveResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes(`/ghost/api/admin/posts/${postId}/`) && + response.status() === 200, + ), + editor.appendToBody(' Written after re-authenticating.'), + ]); + expect(saveResponse.status()).toBe(200); + + // Nothing typed across the expiry was lost + const postResponse = await page.request.get( + `/ghost/api/admin/posts/${postId}/?formats=lexical`, + ); + expect(postResponse.status()).toBe(200); + const { + posts: [post], + } = await postResponse.json(); + expect(post.status).toBe('draft'); + expect(post.lexical).toContain('Written before the session expired.'); + expect(post.lexical).toContain('Written after the session expired.'); + expect(post.lexical).toContain('Written after re-authenticating.'); + }); +}); diff --git a/e2e/tests/admin/posts/scheduled-post-editing.test.ts b/e2e/tests/admin/posts/scheduled-post-editing.test.ts new file mode 100644 index 00000000000..48a48df8b9b --- /dev/null +++ b/e2e/tests/admin/posts/scheduled-post-editing.test.ts @@ -0,0 +1,126 @@ +import { Page } from '@playwright/test'; +import { PostEditorPage, PostsPage } from '@/admin-pages'; +import { createPostFactory } from '@/data-factory'; +import { expect, test } from '@/helpers/playwright'; + +// Server-side: config times.cannotScheduleAPostBeforeInMinutes (default 2), +// enforced only when published_at changes. +const MIN_SCHEDULE_LEAD_MS = 2 * 60 * 1000; + +async function getPost(page: Page, postId: string) { + const response = await page.request.get(`/ghost/api/admin/posts/${postId}/?formats=lexical`); + expect(response.status()).toBe(200); + const { posts } = await response.json(); + return posts[0]; +} + +function waitForPostSave(page: Page, postId: string) { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes(`/ghost/api/admin/posts/${postId}/`) && + response.status() === 200, + ); +} + +test.describe('Ghost Admin - Scheduled post editing', () => { + test('keeps the schedule and publish time when content is edited', async ({ page }) => { + // Scheduling via the publish flow plus a reopen of the post does not fit + // the default local budget + test.setTimeout(60000); + + // ~25h out: always beyond the minimum lead time, and always on a + // different calendar day than the picker default (now + 10 minutes) so + // filling the date input observably changes the schedule summary + const target = new Date(Date.now() + 25 * 60 * 60 * 1000); + const isoTarget = target.toISOString(); + const scheduleDate = isoTarget.slice(0, 10); + const scheduleTime = isoTarget.slice(11, 16); + + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page); + await editor.createDraft({ + title: `scheduled-edit-${Date.now()}`, + body: 'Scheduled post body.', + }); + // The draft autosave assigns the post an id and moves the URL onto it + const postId = await editor.getPostId(); + + await editor.publishFlow.open(); + await editor.publishFlow.schedule({ date: scheduleDate, time: scheduleTime }); + await Promise.all([waitForPostSave(page, postId), editor.publishFlow.confirm()]); + + // Completing the publish flow navigates to the posts list with a success + // modal, so editing means closing it and reopening the post + await editor.publishFlow.close(); + await editor.gotoPost(postId); + + await expect(editor.postStatus.first()).toContainText('Scheduled'); + + // A schedule set through the picker round-trips without validation + // errors: the editor zeroes milliseconds before saving (the API only + // stores whole seconds), so re-saves send back an identical published_at + // and cannot trip date-changed validation. Seconds are not zeroed - they + // are inherited from the moment scheduling was toggled - so pin the + // picked minute and the zeroed milliseconds only + const scheduled = await getPost(page, postId); + expect(scheduled.status).toBe('scheduled'); + expect(scheduled.published_at).toMatch( + new RegExp(`^${scheduleDate}T${scheduleTime}:\\d{2}\\.000Z$`), + ); + + await editor.appendToBody(' Edited while scheduled.'); + await Promise.all([waitForPostSave(page, postId), editor.publishSaveButton.click()]); + + await expect(editor.postStatus.first()).toContainText('Scheduled'); + const updated = await getPost(page, postId); + expect(updated.status).toBe('scheduled'); + expect(updated.published_at).toBe(scheduled.published_at); + expect(updated.lexical).toContain('Scheduled post body.'); + expect(updated.lexical).toContain('Edited while scheduled.'); + }); + + test('saves edits and unschedules close to the publish time', async ({ page }) => { + // Waiting into the minimum-lead window takes real clock time + test.setTimeout(120000); + + const postFactory = createPostFactory(page.request); + // Far enough out to pass creation validation, close enough that the wait + // into the minimum-lead window stays short. Whole seconds only: the API + // stores second precision + const publishAt = new Date(Math.ceil(Date.now() / 1000) * 1000 + 150 * 1000); + const post = await postFactory.create({ + title: `near-publish-edit-${Date.now()}`, + status: 'scheduled', + published_at: publishAt, + }); + + const editor = new PostEditorPage(page); + await editor.gotoPost(post.id); + await expect(editor.postStatus.first()).toContainText('Scheduled'); + + // Enter the window in which a *changed* publish time would be rejected + await expect + .poll(() => Date.now(), { timeout: 60000 }) + .toBeGreaterThan(publishAt.getTime() - MIN_SCHEDULE_LEAD_MS); + + await editor.appendToBody(' Last minute edit.'); + // Re-saving with an unchanged publish time succeeds inside the window: + // the minimum lead is enforced only when published_at changes + await Promise.all([waitForPostSave(page, post.id), editor.publishSaveButton.click()]); + + const updated = await getPost(page, post.id); + expect(updated.status).toBe('scheduled'); + expect(updated.published_at).toBe(publishAt.toISOString()); + expect(updated.lexical).toContain('Last minute edit.'); + + // Unscheduling still works this close to the publish time + await editor.revertToDraft(); + await expect(editor.postStatus.first()).toContainText('Draft'); + const reverted = await getPost(page, post.id); + expect(reverted.status).toBe('draft'); + }); +}); From 8357a85ad5ff13c03e2e212c8a0a5842a28a385a Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 2 Sep 2026 07:59:47 -0500 Subject: [PATCH 06/15] Added email preview and retry hooks to the framework data layer (#30445) no ref Adds email-preview and email-retry support to the `admin-x-framework` data layer so the React publish flow can render newsletter previews, send test emails, and retry failed sends. --- .../src/api/content-types.ts | 4 +- .../src/api/email-previews.ts | 67 +++++++++++ apps/admin-x-framework/src/api/emails.ts | 21 ++++ apps/admin-x-framework/src/api/posts.ts | 2 + .../test/unit/api/email-previews.test.tsx | 110 ++++++++++++++++++ .../test/unit/api/emails.test.tsx | 59 ++++++++++ .../providers/post-analytics-context.ts | 5 - 7 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 apps/admin-x-framework/src/api/email-previews.ts create mode 100644 apps/admin-x-framework/src/api/emails.ts create mode 100644 apps/admin-x-framework/test/unit/api/email-previews.test.tsx create mode 100644 apps/admin-x-framework/test/unit/api/emails.test.tsx diff --git a/apps/admin-x-framework/src/api/content-types.ts b/apps/admin-x-framework/src/api/content-types.ts index 64c206100aa..c405246393f 100644 --- a/apps/admin-x-framework/src/api/content-types.ts +++ b/apps/admin-x-framework/src/api/content-types.ts @@ -3,9 +3,11 @@ type Override = Omit & Changes; export type Email = { + id?: string; opened_count: number; email_count: number; - status?: string; + status?: 'pending' | 'submitting' | 'submitted' | 'failed'; + error?: string | null; track_opens?: boolean; track_clicks?: boolean; }; diff --git a/apps/admin-x-framework/src/api/email-previews.ts b/apps/admin-x-framework/src/api/email-previews.ts new file mode 100644 index 00000000000..1ef05f2e58c --- /dev/null +++ b/apps/admin-x-framework/src/api/email-previews.ts @@ -0,0 +1,67 @@ +import { createMutation, createQueryWithId } from '../utils/api/hooks'; + +export type EmailPreview = { + html: string; + plaintext: string; + subject: string; +}; + +export interface EmailPreviewResponseType { + email_previews: EmailPreview[]; +} + +export interface EmailPreviewParams { + memberStatus?: 'free' | 'paid'; + /** Tier slug - narrows the paid audience to a single tier */ + memberTier?: string; + /** Newsletter slug - the server falls back to the post's, then the default newsletter */ + newsletter?: string; +} + +const dataType = 'EmailPreviewResponseType'; + +const useEmailPreviewQuery = createQueryWithId({ + dataType, + path: (id) => `/email_previews/posts/${id}/`, +}); + +export const useEmailPreview = ( + id: string, + options: EmailPreviewParams & Parameters[1] = {}, +) => { + const { memberStatus, memberTier, newsletter, searchParams, ...query } = options; + + const params: Record = { ...searchParams }; + if (memberStatus) { + params.member_status = memberStatus; + } + if (memberTier) { + params.member_tier = memberTier; + } + if (newsletter) { + params.newsletter = newsletter; + } + + return useEmailPreviewQuery(id, { ...query, searchParams: params }); +}; + +export interface SendTestEmailPayload { + postId: string; + /** The server accepts exactly one recipient per request */ + emails: string[]; + memberStatus?: 'free' | 'paid'; + memberTier?: string; + newsletter?: string; +} + +/** Sends a test email for a post. Responds 204 with no body. */ +export const useSendTestEmail = createMutation({ + method: 'POST', + path: ({ postId }) => `/email_previews/posts/${postId}/`, + body: ({ emails, memberStatus, memberTier, newsletter }) => ({ + emails, + ...(newsletter && { newsletter }), + ...(memberStatus && { member_status: memberStatus }), + ...(memberTier && { member_tier: memberTier }), + }), +}); diff --git a/apps/admin-x-framework/src/api/emails.ts b/apps/admin-x-framework/src/api/emails.ts new file mode 100644 index 00000000000..3292b868a24 --- /dev/null +++ b/apps/admin-x-framework/src/api/emails.ts @@ -0,0 +1,21 @@ +import { createMutation } from '../utils/api/hooks'; +import { postsDataType } from './posts'; +import type { Email } from './content-types'; + +export interface EmailsResponseType { + emails: Email[]; +} + +/** + * Retry a failed email send. + * + * The framework has no email queries - the email consumers see is the copy + * embedded on the post (the editor read contract includes `email`), so a + * successful retry invalidates post queries to refresh that embedded copy. + */ +export const useRetryEmail = createMutation({ + method: 'PUT', + path: (id) => `/emails/${id}/retry/`, + body: () => ({}), + invalidateQueries: { dataType: postsDataType }, +}); diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 86c7702b54d..65e8a345db7 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -53,6 +53,8 @@ export interface PostResponseType { const dataType = 'PostsResponseType'; +export const postsDataType = dataType; + export const useBrowsePosts = createQuery({ dataType, path: '/posts/', diff --git a/apps/admin-x-framework/test/unit/api/email-previews.test.tsx b/apps/admin-x-framework/test/unit/api/email-previews.test.tsx new file mode 100644 index 00000000000..81ce5b8a29a --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/email-previews.test.tsx @@ -0,0 +1,110 @@ +import { act, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { useEmailPreview, useSendTestEmail } from '../../../src/api/email-previews'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const previewCall = (mock: any) => + mock.calls.find(([input]: [unknown]) => String(input).includes('/email_previews/')); + +const requestBody = (mock: any) => JSON.parse(previewCall(mock)[1].body as string); + +describe('email previews api', () => { + it('reads an email preview with the full audience and newsletter params', async () => { + await withMockFetch( + { + json: { + email_previews: [{ html: '

Hi

', plaintext: 'Hi', subject: 'Hello' }], + // the permissions gate fetches the current user through the same mock + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEmailPreview('post-1', { + memberStatus: 'paid', + memberTier: 'gold', + newsletter: 'weekly', + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(previewCall(mock)[0] as string); + expect(url.pathname).toBe('/ghost/api/admin/email_previews/posts/post-1/'); + expect(Object.fromEntries(url.searchParams.entries())).toEqual({ + member_status: 'paid', + member_tier: 'gold', + newsletter: 'weekly', + }); + expect(result.current.data?.email_previews[0].subject).toBe('Hello'); + }, + ); + }); + + it('omits audience params that are not provided', async () => { + await withMockFetch( + { + json: { + email_previews: [{ html: '

Hi

', plaintext: 'Hi', subject: 'Hello' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEmailPreview('post-1', { memberStatus: 'free' }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(previewCall(mock)[0] as string); + expect(Object.fromEntries(url.searchParams.entries())).toEqual({ + member_status: 'free', + }); + }, + ); + }); + + it('sends a test email with the audience and newsletter in the body', async () => { + await withMockFetch({ status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useSendTestEmail()); + + await act(async () => { + await result.current.mutateAsync({ + postId: 'post-1', + emails: ['test@example.com'], + memberStatus: 'paid', + memberTier: 'gold', + newsletter: 'weekly', + }); + }); + + const [url, options] = previewCall(mock); + expect(new URL(url as string).pathname).toBe('/ghost/api/admin/email_previews/posts/post-1/'); + expect(options.method).toBe('POST'); + expect(requestBody(mock)).toEqual({ + emails: ['test@example.com'], + newsletter: 'weekly', + member_status: 'paid', + member_tier: 'gold', + }); + }); + }); + + it('sends a test email with only the recipient when no audience is given', async () => { + await withMockFetch({ status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useSendTestEmail()); + + await act(async () => { + await result.current.mutateAsync({ + postId: 'post-1', + emails: ['test@example.com'], + }); + }); + + expect(requestBody(mock)).toEqual({ emails: ['test@example.com'] }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/emails.test.tsx b/apps/admin-x-framework/test/unit/api/emails.test.tsx new file mode 100644 index 00000000000..17be24205ad --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/emails.test.tsx @@ -0,0 +1,59 @@ +import { act } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; +import { useRetryEmail } from '../../../src/api/emails'; +import { postsDataType } from '../../../src/api/posts'; +import { withMockFetch } from '../../utils/mock-fetch'; + +describe('emails api', () => { + it('retries a failed email via the retry endpoint', async () => { + await withMockFetch( + { + json: { emails: [{ id: 'email-1', status: 'pending', email_count: 10, opened_count: 0 }] }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => useRetryEmail()); + + let response; + await act(async () => { + response = await result.current.mutateAsync('email-1'); + }); + + const [url, options] = mock.calls[0]; + expect(new URL(url as string).pathname).toBe('/ghost/api/admin/emails/email-1/retry/'); + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body as string)).toEqual({}); + expect(response).toEqual({ + emails: [{ id: 'email-1', status: 'pending', email_count: 10, opened_count: 0 }], + }); + }, + ); + }); + + it('invalidates post queries so the embedded email refreshes', async () => { + const queryClient = createTestQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const onInvalidate = vi.fn(); + + await withMockFetch( + { + json: { emails: [{ id: 'email-1', status: 'pending', email_count: 10, opened_count: 0 }] }, + headers: { 'content-type': 'application/json' }, + }, + async () => { + const { result } = renderHookWithProviders(() => useRetryEmail(), { + queryClient, + frameworkProps: { onInvalidate }, + }); + + await act(async () => { + await result.current.mutateAsync('email-1'); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [postsDataType] }); + expect(onInvalidate).toHaveBeenCalledWith(postsDataType); + }, + ); + }); +}); diff --git a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts index 2d0d82178d5..bc4c3ff6f87 100644 --- a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts +++ b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts @@ -10,11 +10,6 @@ export interface Post extends PostBase { authors?: { name?: string; }[]; - email?: { - opened_count: number; - email_count: number; - status?: string; - } | null; newsletter?: { feedback_enabled?: boolean; } | null; From 934980c94f5a64dc67aaa4f4cdaf13325aa9ca26 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 2 Sep 2026 08:00:17 -0500 Subject: [PATCH 07/15] Added recipient-filter utilities and a members-count hook to the framework (#30446) no ref Added wiring for email recipient filters and the members count handling for the publish flows. --- apps/admin-x-framework/src/api/members.ts | 127 +++++++++++++ apps/admin-x-framework/src/index.ts | 13 ++ .../src/utils/recipient-filter.ts | 158 ++++++++++++++++ .../test/unit/api/members.test.tsx | 175 +++++++++++++++++ .../test/unit/utils/recipient-filter.test.ts | 179 ++++++++++++++++++ 5 files changed, 652 insertions(+) create mode 100644 apps/admin-x-framework/src/utils/recipient-filter.ts create mode 100644 apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index f0a00ba2799..3781b7781a1 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -11,6 +11,7 @@ import { apiUrl } from '../utils/api/fetch-api'; import type { FieldValue } from '@tryghost/custom-field-types'; import { useCurrentUser } from './current-user'; import { canManageMembers } from './users'; +import { FREE_SEGMENT, PAID_SEGMENT } from '../utils/recipient-filter'; export type MemberLabel = { id: string; @@ -187,6 +188,132 @@ export function useMemberCount() { return data?.meta?.pagination.total; } +// ----------------------------------------------------------------------------- +// Filtered member counts (email recipients) +// ----------------------------------------------------------------------------- + +// The Ember members-count-cache's TTL; the framework default staleTime (5min) +// is too stale for publish-flow recipient counts. +const MEMBERS_COUNT_STALE_TIME = 60 * 1000; + +const useBrowseMembersCountQuery = createQuery({ + dataType, + path: membersPath, +}); + +export interface MembersCountResult { + /** `null` while loading and for roles that cannot browse members. */ + count: number | null; + isLoading: boolean; +} + +/** + * Number of members matching a filter, consolidated from the Ember + * `members-count-cache` service + `members-count-fetcher` resource: a browse + * request with `limit=1` reading `meta.pagination.total`, cached per-filter + * for 60 seconds. As in Ember, roles that cannot manage members get + * `count: null` without a request, a nullish filter counts as 0 without a + * request, and request errors resolve to 0 with no error toast. While the + * current user is still loading the result is `{count: null, isLoading: true}` + * so callers can tell it apart from a role that cannot browse members. + */ +export function useMembersCount(filter: string | null | undefined): MembersCountResult { + const { data: currentUser } = useCurrentUser(); + const canFetch = Boolean(currentUser && canManageMembers(currentUser)); + const enabled = canFetch && filter !== null && filter !== undefined; + + const result = useBrowseMembersCountQuery({ + // order/page pin the same cheap, stable request shape the Ember cache used + searchParams: { filter: filter ?? '', order: 'id', limit: '1', page: '1' }, + staleTime: MEMBERS_COUNT_STALE_TIME, + enabled, + defaultErrorHandler: false, + }); + + if (currentUser === undefined) { + return { count: null, isLoading: true }; + } + + if (!enabled || result.isError) { + return { count: canFetch ? 0 : null, isLoading: false }; + } + + return { + count: result.data?.meta?.pagination.total ?? null, + isLoading: result.isLoading, + }; +} + +// gh-pluralize combined toLocaleString with ember-inflector; every noun used +// below pluralizes regularly so a trailing "s" matches its output. +function pluralizedCount(count: number, noun: string): string { + return `${count.toLocaleString()} ${count === 1 ? noun : `${noun}s`}`; +} + +export interface MembersCountStringOptions { + /** + * The recipient count, usually from `useMembersCount`: a number renders + * numeric copy; `null` (role cannot browse members) and `undefined` (not + * fetched) render the descriptive fallback copy instead. + */ + count?: number | null; + /** With `hasMultipleNewsletters`, switches copy to "subscribers of ". */ + newsletter?: { name: string; recipientFilter: string }; + hasMultipleNewsletters?: boolean; +} + +/** + * Human-readable copy for a recipient count, ported from the Ember + * `members-count-cache#countString` (which fetched the count itself; this + * pure port takes it from `useMembersCount`, which matches its semantics). + */ +export function membersCountString( + filter: string = '', + { count, newsletter, hasMultipleNewsletters = false }: MembersCountStringOptions = {}, +): string { + const nounSingular = newsletter && hasMultipleNewsletters ? 'subscriber' : 'member'; + const nounPlural = `${nounSingular}s`; + const suffix = newsletter && hasMultipleNewsletters ? ` of ${newsletter.name}` : ''; + + // Strips the newsletter scope composed by getFullRecipientFilter to get back + // the user-selected segment; a plain comma split like the Ember original. + const basicFilter = newsletter + ? filter.replace(newsletter.recipientFilter, '').replace(/^\+\((.*)\)$/, '$1') + : filter; + const filterParts = basicFilter.split(','); + const isFree = filterParts.length === 1 && filterParts[0] === FREE_SEGMENT; + const isPaid = filterParts.length === 1 && filterParts[0] === PAID_SEGMENT; + const isAll = + !filter || (filterParts.includes(FREE_SEGMENT) && filterParts.includes(PAID_SEGMENT)); + + // Ember reserved this copy for plain Editors and fetched a count for every + // other role on the spot; a pure function can't fetch, so an absent count — + // whatever the role — gets the descriptive copy rather than a bogus "0". + if (count === undefined || count === null) { + if (isFree) { + return `all free ${nounPlural}${suffix}`; + } + if (isPaid) { + return `all paid ${nounPlural}${suffix}`; + } + if (isAll) { + return `all ${nounPlural}${suffix}`; + } + + return 'a custom members segment'; + } + + if (isFree) { + return pluralizedCount(count, `free ${nounSingular}`) + suffix; + } + + if (isPaid) { + return pluralizedCount(count, `paid ${nounSingular}`) + suffix; + } + + return pluralizedCount(count, nounSingular) + suffix; +} + export type NewMember = { email: string; name?: string | null; diff --git a/apps/admin-x-framework/src/index.ts b/apps/admin-x-framework/src/index.ts index f43720c41a1..1cca4d09469 100644 --- a/apps/admin-x-framework/src/index.ts +++ b/apps/admin-x-framework/src/index.ts @@ -53,6 +53,19 @@ export { } from './utils/post-helpers'; export { focusKoenigEditorOnBottomClick } from './utils/focus-koenig-editor-on-bottom-click'; +// Recipient filter utilities +export { + EVERYONE_RECIPIENT_FILTER, + FREE_SEGMENT, + PAID_SEGMENT, + buildRecipientFilter, + getFullRecipientFilter, + getNewsletterRecipientFilter, + getRecipientType, + parseRecipientFilter, +} from './utils/recipient-filter'; +export type { RecipientFilterSegments, RecipientType } from './utils/recipient-filter'; + // Source utilities export { getFaviconDomain, diff --git a/apps/admin-x-framework/src/utils/recipient-filter.ts b/apps/admin-x-framework/src/utils/recipient-filter.ts new file mode 100644 index 00000000000..93c3eea40bb --- /dev/null +++ b/apps/admin-x-framework/src/utils/recipient-filter.ts @@ -0,0 +1,158 @@ +// Email recipient-filter string handling, consolidated from the Ember admin's +// four copies of the same comma-split logic: `utils/publish-options.js`, +// `components/gh-members-recipient-select.js`, +// `components/editor/modals/publish-flow.js` and +// `services/members-count-cache.js`. Behavior (including quirks) is preserved +// so Ember and React screens classify and rebuild filters identically. + +export const FREE_SEGMENT = 'status:free'; +export const PAID_SEGMENT = 'status:-free'; + +/** + * The canonical "everyone" spelling. Selecting both the free and paid + * checkboxes produces this filter, and the server treats it as all members. + */ +export const EVERYONE_RECIPIENT_FILTER = `${FREE_SEGMENT},${PAID_SEGMENT}`; + +const BASE_SEGMENTS: string[] = [FREE_SEGMENT, PAID_SEGMENT]; + +export interface RecipientFilterSegments { + /** Exact `status:free` present among the base segments (the "free members" checkbox). */ + free: boolean; + /** Exact `status:-free` present among the base segments (the "paid members" checkbox). */ + paid: boolean; + /** + * Raw base segments in first-occurrence order. Kept verbatim (untrimmed) + * because a padded segment like `" status:free"` is classified as a base + * segment but does not check the free checkbox, and survives a rebuild as-is. + */ + base: string[]; + /** + * Raw non-blank segments that are not base segments — `label:`, + * `tier:` and any other custom NQL segments — in first-occurrence order. + */ + specific: string[]; +} + +/** + * Splits a recipient filter into base (free/paid) and specific (label/tier) + * segments by comma, the way `gh-members-recipient-select` does. This is a + * plain comma split, not an NQL parse: a specific segment whose value contains + * a comma is split apart the same way the Ember component splits it. + */ +export function parseRecipientFilter(filter: string | null | undefined): RecipientFilterSegments { + const items = (filter || '').split(','); + const base: string[] = []; + const specific: string[] = []; + + for (const item of items) { + if (BASE_SEGMENTS.includes(item.trim())) { + if (!base.includes(item)) { + base.push(item); + } + } else if (item.trim() !== '') { + if (!specific.includes(item)) { + specific.push(item); + } + } + } + + return { + free: base.includes(FREE_SEGMENT), + paid: base.includes(PAID_SEGMENT), + base, + specific, + }; +} + +/** + * Rebuilds a recipient filter string from parsed segments, mirroring + * `gh-members-recipient-select#updateFilter`: base segments first, then + * specific segments, deduplicated, joined by comma. Returns `null` for an + * empty selection (the "no recipients" state). + * + * `paidAvailable: false` drops the paid segment, matching the component's + * behavior when Stripe is not connected. + */ +export function buildRecipientFilter( + segments: Pick, + { paidAvailable = true }: { paidAvailable?: boolean } = {}, +): string | null { + const selected = new Set([...segments.base, ...segments.specific]); + + if (!paidAvailable) { + selected.delete(PAID_SEGMENT); + } + + return Array.from(selected).join(',') || null; +} + +export type RecipientType = 'none' | 'all' | 'free' | 'paid' | 'specific'; + +/** + * Classifies a recipient filter the way the publish flow does + * (`editor/modals/publish-flow.js#recipientType`). + * + * The "all" case is a substring check, not a segment check, so any filter + * containing both `status:free` and `status:-free` anywhere classifies as + * "all" even alongside other segments — e.g. `label:x,status:free,status:-free`. + */ +export function getRecipientType(filter: string | null | undefined): RecipientType { + if (!filter) { + return 'none'; + } + + if (filter === FREE_SEGMENT) { + return 'free'; + } + + if (filter === PAID_SEGMENT) { + return 'paid'; + } + + if (filter.includes(FREE_SEGMENT) && filter.includes(PAID_SEGMENT)) { + return 'all'; + } + + return 'specific'; +} + +/** + * Derives the filter that scopes members to a newsletter's audience + * (`models/newsletter.js#recipientFilter`): actively subscribed to the + * newsletter, email not disabled, and paid-only when the newsletter's + * visibility is `paid`. + */ +export function getNewsletterRecipientFilter({ + slug, + visibility, +}: { + slug: string; + visibility?: string; +}): string { + const filter = [`newsletters.slug:${slug}`, 'email_disabled:0']; + + if (visibility === 'paid') { + filter.push(PAID_SEGMENT); + } + + return filter.join('+'); +} + +/** + * Composes the full filter sent to the email service + * (`utils/publish-options.js#fullRecipientFilter`): the newsletter's audience + * filter, optionally AND-ed with the selected recipient filter. + */ +export function getFullRecipientFilter( + newsletterRecipientFilter: string, + recipientFilter: string | null | undefined, +): string { + let filter = newsletterRecipientFilter; + + if (recipientFilter) { + filter += `+(${recipientFilter})`; + } + + return filter; +} diff --git a/apps/admin-x-framework/test/unit/api/members.test.tsx b/apps/admin-x-framework/test/unit/api/members.test.tsx index 944bf38158b..de5ebaf3a9b 100644 --- a/apps/admin-x-framework/test/unit/api/members.test.tsx +++ b/apps/admin-x-framework/test/unit/api/members.test.tsx @@ -16,7 +16,9 @@ import { useMemberActivityFeed, useMemberCount, useMemberLogout, + useMembersCount, useRemoveMemberEmailSuppression, + membersCountString, } from '../../../src/api/members'; import type { MemberActivityEvent, @@ -311,6 +313,179 @@ describe('members api', () => { }); }); + describe('useMembersCount', () => { + it('fetches the count for a filter via a limit=1 browse request', async () => { + const queryClient = createQueryClientWithCurrentUser([ + { id: 'role-1', name: 'Administrator' }, + ]); + + await withMockFetch( + { + json: membersResponse(4289), + }, + async (mockFetch) => { + const { result } = renderHookWithProviders( + () => useMembersCount('status:free,label:vip'), + { queryClient }, + ); + + await waitFor(() => { + expect(result.current.count).toBe(4289); + }); + + expect(result.current.isLoading).toBe(false); + + const url = new URL(mockFetch.calls[0][0].toString()); + expect(url.pathname).toBe('/ghost/api/admin/members/'); + expect(url.searchParams.get('filter')).toBe('status:free,label:vip'); + expect(url.searchParams.get('order')).toBe('id'); + expect(url.searchParams.get('limit')).toBe('1'); + expect(url.searchParams.get('page')).toBe('1'); + }, + ); + }); + + it('reports loading while the current user is unresolved', () => { + const queryClient = createTestQueryClient(); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(() => new Promise(() => {})) as typeof globalThis.fetch; + + try { + const { result } = renderHookWithProviders(() => useMembersCount('status:free'), { + queryClient, + }); + + expect(result.current).toEqual({ count: null, isLoading: true }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('returns a null count without fetching for roles that cannot manage members', async () => { + const queryClient = createQueryClientWithCurrentUser([{ id: 'role-1', name: 'Editor' }]); + + await withMockFetch({}, async (mockFetch) => { + const { result } = renderHookWithProviders(() => useMembersCount('status:free'), { + queryClient, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current).toEqual({ count: null, isLoading: false }); + expect(mockFetch.calls).toHaveLength(0); + }); + }); + + it('counts a nullish filter as 0 without fetching', async () => { + const queryClient = createQueryClientWithCurrentUser([ + { id: 'role-1', name: 'Administrator' }, + ]); + + await withMockFetch({}, async (mockFetch) => { + const { result } = renderHookWithProviders(() => useMembersCount(null), { queryClient }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current).toEqual({ count: 0, isLoading: false }); + expect(mockFetch.calls).toHaveLength(0); + }); + }); + + it('resolves request errors to a count of 0', async () => { + const queryClient = createQueryClientWithCurrentUser([ + { id: 'role-1', name: 'Administrator' }, + ]); + + await withMockFetch( + { json: { errors: [{ message: 'Nope' }] }, status: 500, ok: false }, + async () => { + const { result } = renderHookWithProviders(() => useMembersCount('status:free'), { + queryClient, + }); + + await waitFor(() => { + expect(result.current).toEqual({ count: 0, isLoading: false }); + }); + }, + ); + }); + }); + + describe('membersCountString', () => { + it('pluralizes counts per recipient type', () => { + expect(membersCountString('status:free', { count: 1 })).toBe('1 free member'); + expect(membersCountString('status:free', { count: 0 })).toBe('0 free members'); + expect(membersCountString('status:-free', { count: 5 })).toBe('5 paid members'); + expect(membersCountString('status:free,status:-free', { count: 2 })).toBe('2 members'); + expect(membersCountString('label:vip', { count: 3 })).toBe('3 members'); + expect(membersCountString('status:free,label:vip', { count: 3 })).toBe('3 members'); + }); + + it('formats large counts with the locale separator', () => { + expect(membersCountString('status:free,status:-free', { count: 12345 })).toBe( + `${(12345).toLocaleString()} members`, + ); + }); + + it('falls back to descriptive copy when no count was fetched', () => { + expect(membersCountString('status:free')).toBe('all free members'); + expect(membersCountString('status:-free')).toBe('all paid members'); + expect(membersCountString('status:free,status:-free')).toBe('all members'); + expect(membersCountString('')).toBe('all members'); + expect(membersCountString('label:vip')).toBe('a custom members segment'); + }); + + it('falls back to descriptive copy for a null count from useMembersCount', () => { + // null is useMembersCount's "role cannot browse members" value; it must + // never render as a zero count. + expect(membersCountString('status:free', { count: null })).toBe('all free members'); + expect(membersCountString('label:vip', { count: null })).toBe('a custom members segment'); + }); + + it('switches to subscriber copy and strips the newsletter scope when there are multiple newsletters', () => { + const newsletter = { + name: 'Weekly', + recipientFilter: 'newsletters.slug:weekly+email_disabled:0', + }; + const fullFilter = `${newsletter.recipientFilter}+(status:free)`; + + expect( + membersCountString(fullFilter, { + newsletter, + hasMultipleNewsletters: true, + }), + ).toBe('all free subscribers of Weekly'); + + expect( + membersCountString(fullFilter, { + count: 9, + newsletter, + hasMultipleNewsletters: true, + }), + ).toBe('9 free subscribers of Weekly'); + }); + + it('keeps member copy for a single newsletter', () => { + const newsletter = { + name: 'Weekly', + recipientFilter: 'newsletters.slug:weekly+email_disabled:0', + }; + const fullFilter = `${newsletter.recipientFilter}+(status:free)`; + + expect( + membersCountString(fullFilter, { + count: 9, + newsletter, + hasMultipleNewsletters: false, + }), + ).toBe('9 free members'); + }); + }); + it('syncs the sidebar member count from an unfiltered members list query', async () => { const queryClient = createQueryClientWithCurrentUser(); const memberDetailKey = [ diff --git a/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts b/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts new file mode 100644 index 00000000000..9c43f62e8e3 --- /dev/null +++ b/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { + EVERYONE_RECIPIENT_FILTER, + FREE_SEGMENT, + PAID_SEGMENT, + buildRecipientFilter, + getFullRecipientFilter, + getNewsletterRecipientFilter, + getRecipientType, + parseRecipientFilter, +} from '../../../src/utils/recipient-filter'; + +describe('recipient-filter', () => { + describe('parseRecipientFilter', () => { + it('returns empty segments for null, undefined and empty filters', () => { + for (const filter of [null, undefined, '']) { + expect(parseRecipientFilter(filter)).toEqual({ + free: false, + paid: false, + base: [], + specific: [], + }); + } + }); + + it('parses the base segments into checkbox state', () => { + expect(parseRecipientFilter(FREE_SEGMENT)).toEqual({ + free: true, + paid: false, + base: [FREE_SEGMENT], + specific: [], + }); + + expect(parseRecipientFilter(PAID_SEGMENT)).toEqual({ + free: false, + paid: true, + base: [PAID_SEGMENT], + specific: [], + }); + + expect(parseRecipientFilter(EVERYONE_RECIPIENT_FILTER)).toEqual({ + free: true, + paid: true, + base: [FREE_SEGMENT, PAID_SEGMENT], + specific: [], + }); + }); + + it('splits base and specific segments preserving order', () => { + expect( + parseRecipientFilter('label:vip,status:free,tier:gold,status:-free,label:beta'), + ).toEqual({ + free: true, + paid: true, + base: [FREE_SEGMENT, PAID_SEGMENT], + specific: ['label:vip', 'tier:gold', 'label:beta'], + }); + }); + + it('drops blank segments and deduplicates', () => { + expect(parseRecipientFilter('label:vip,, ,status:free,label:vip,status:free')).toEqual({ + free: true, + paid: false, + base: [FREE_SEGMENT], + specific: ['label:vip'], + }); + }); + + it('classifies padded base segments as base without checking the checkbox, like Ember', () => { + // gh-members-recipient-select trims for base classification but tests + // checkbox state against the raw segment. + expect(parseRecipientFilter(' status:free,label:vip')).toEqual({ + free: false, + paid: false, + base: [' status:free'], + specific: ['label:vip'], + }); + }); + }); + + describe('buildRecipientFilter', () => { + it('round-trips every recipient type', () => { + const filters = [ + FREE_SEGMENT, + PAID_SEGMENT, + EVERYONE_RECIPIENT_FILTER, + 'label:vip', + 'tier:gold', + 'status:free,label:vip,tier:gold', + 'status:free,status:-free,label:vip,label:beta,tier:gold,tier:silver', + ' status:free,label:vip', + ]; + + for (const filter of filters) { + expect(buildRecipientFilter(parseRecipientFilter(filter))).toBe(filter); + } + }); + + it('rebuilds with base segments first, like the Ember select', () => { + expect(buildRecipientFilter(parseRecipientFilter('label:vip,status:free'))).toBe( + 'status:free,label:vip', + ); + }); + + it('returns null for an empty selection', () => { + expect(buildRecipientFilter({ base: [], specific: [] })).toBeNull(); + expect(buildRecipientFilter(parseRecipientFilter(null))).toBeNull(); + }); + + it('drops the paid segment when paid is unavailable', () => { + expect( + buildRecipientFilter(parseRecipientFilter(EVERYONE_RECIPIENT_FILTER), { + paidAvailable: false, + }), + ).toBe(FREE_SEGMENT); + + expect( + buildRecipientFilter(parseRecipientFilter(PAID_SEGMENT), { paidAvailable: false }), + ).toBeNull(); + }); + }); + + describe('getRecipientType', () => { + it('classifies each filter shape', () => { + expect(getRecipientType(null)).toBe('none'); + expect(getRecipientType(undefined)).toBe('none'); + expect(getRecipientType('')).toBe('none'); + expect(getRecipientType(FREE_SEGMENT)).toBe('free'); + expect(getRecipientType(PAID_SEGMENT)).toBe('paid'); + expect(getRecipientType(EVERYONE_RECIPIENT_FILTER)).toBe('all'); + expect(getRecipientType('label:vip')).toBe('specific'); + expect(getRecipientType('label:vip,tier:gold')).toBe('specific'); + expect(getRecipientType('status:free,label:vip')).toBe('specific'); + expect(getRecipientType('status:-free,tier:gold')).toBe('specific'); + }); + + it('classifies both base segments as all even alongside specific segments', () => { + // Substring semantics from the Ember publish flow. + expect(getRecipientType('status:free,status:-free,label:vip')).toBe('all'); + expect(getRecipientType('label:vip,status:free,status:-free')).toBe('all'); + }); + }); + + describe('getNewsletterRecipientFilter', () => { + it('scopes to newsletter subscribers with email enabled', () => { + expect(getNewsletterRecipientFilter({ slug: 'weekly' })).toBe( + 'newsletters.slug:weekly+email_disabled:0', + ); + }); + + it('adds the paid segment for paid-visibility newsletters', () => { + expect(getNewsletterRecipientFilter({ slug: 'weekly', visibility: 'paid' })).toBe( + 'newsletters.slug:weekly+email_disabled:0+status:-free', + ); + expect(getNewsletterRecipientFilter({ slug: 'weekly', visibility: 'members' })).toBe( + 'newsletters.slug:weekly+email_disabled:0', + ); + }); + }); + + describe('getFullRecipientFilter', () => { + const newsletterFilter = 'newsletters.slug:weekly+email_disabled:0'; + + it('returns the newsletter filter alone when there is no recipient filter', () => { + expect(getFullRecipientFilter(newsletterFilter, null)).toBe(newsletterFilter); + expect(getFullRecipientFilter(newsletterFilter, undefined)).toBe(newsletterFilter); + expect(getFullRecipientFilter(newsletterFilter, '')).toBe(newsletterFilter); + }); + + it('ANDs the recipient filter onto the newsletter filter', () => { + expect(getFullRecipientFilter(newsletterFilter, EVERYONE_RECIPIENT_FILTER)).toBe( + 'newsletters.slug:weekly+email_disabled:0+(status:free,status:-free)', + ); + expect(getFullRecipientFilter(newsletterFilter, 'label:vip,tier:gold')).toBe( + 'newsletters.slug:weekly+email_disabled:0+(label:vip,tier:gold)', + ); + }); + }); +}); From d9ad12bc08639f7ea4dafb73bcb2a4fa88df1cef Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 2 Sep 2026 08:17:24 +0000 Subject: [PATCH 08/15] Removed pre-start job buffering from the in-memory jobs backend ref https://linear.app/ghost/issue/HKG-2001 Boot starts the jobs service before the web app is mounted and before any recurring job is scheduled, so nothing can legitimately enqueue or schedule before start() - the buffering path (a paused constructor queue resumed on start) only existed defensively. A pre-start enqueue or recurring schedule now throws so a boot-ordering regression surfaces immediately, while an enqueue after shutdown() remains a silently dropped benign race. A recurring tick that cannot enqueue is caught and logged rather than escaping the timer callback as an uncaught exception. This also simplifies the ground for routing job types onto separate queues. --- .../adapters/jobs/InMemoryJobsBackend.ts | 50 +++++++++++-------- .../jobs/in-memory-jobs-backend.test.ts | 30 ++++++----- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts b/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts index 035381d0193..00ce59a75e2 100644 --- a/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts +++ b/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts @@ -42,6 +42,11 @@ interface RecurringTimer { clear(): void; } +// Work only flows between start() and shutdown(): boot starts the service +// before the web app is mounted or any recurring job is scheduled, so an +// enqueue or recurring registration before start() is a boot-ordering bug and +// throws, while an enqueue after shutdown() is a benign shutdown race and is +// dropped. export default class InMemoryJobsBackend extends JobsBackendBase { private _processor: JobProcessor | null; private _stopped: boolean; @@ -59,53 +64,58 @@ export default class InMemoryJobsBackend extends JobsBackendBase { } private _createQueue(): queueAsPromised { - const queue = fastq.promise( - (envelope: JobEnvelope) => this._deliver(envelope), - this._concurrency, - ); - // Paused until start() attaches a processor; enqueue buffers meanwhile. - queue.pause(); - return queue; + return fastq.promise((envelope: JobEnvelope) => this._deliver(envelope), this._concurrency); } start({ processor }: JobsStartOptions): void { this._processor = processor; this._stopped = false; - this._queue.resume(); } enqueue(envelope: JobEnvelope): void { if (this._stopped) { return; } + if (!this._processor) { + throw new errors.IncorrectUsageError({ + message: `Cannot enqueue job "${envelope.type}" before the jobs backend is started.`, + }); + } this._queue.push(envelope); } private async _deliver(envelope: JobEnvelope): Promise { - const processor = this._processor; - if (!processor) { - throw new errors.IncorrectUsageError({ - message: - 'InMemoryJobsBackend attempted a delivery with no processor attached - the backend lifecycle is broken.', - }); - } try { - await processor(envelope); + await this._processor!(envelope); } catch (err) { logging.error(`Job "${envelope.type}" delivery failed`, err); } } scheduleRecurring(envelope: JobEnvelope, { cron }: RecurringSchedule): void { + if (this._stopped) { + return; + } + if (!this._processor) { + throw new errors.IncorrectUsageError({ + message: `Cannot schedule recurring job "${envelope.type}" before the jobs backend is started.`, + }); + } // First schedule per type wins; a re-registration must not disturb a // schedule that is already running (parity with a durable backend). - if (this._stopped || this._recurring.has(envelope.type)) { + if (this._recurring.has(envelope.type)) { return; } const parsed = later.parse.cron(cron, hasSeconds(cron)); const timer = later.setInterval(() => { - this.enqueue(envelope); + // A throw inside a later timer callback would be an uncaughtException; + // a recurring tick must never take the process down. + try { + this.enqueue(envelope); + } catch (err) { + logging.error(`Recurring job "${envelope.type}" tick failed to enqueue`, err); + } }, parsed); this._recurring.set(envelope.type, timer); } @@ -132,8 +142,8 @@ export default class InMemoryJobsBackend extends JobsBackendBase { await Promise.race([queue.drained(), delay(timeoutMs)]); } - // Fresh (paused) queue so a re-boot never inherits this lifecycle's - // abandoned in-flight deliveries against its concurrency limit. + // Fresh queue so a re-boot never inherits this lifecycle's abandoned + // in-flight deliveries against its concurrency limit. this._queue = this._createQueue(); this._processor = null; } diff --git a/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts b/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts index 119359a9879..5fb497dde96 100644 --- a/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts +++ b/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts @@ -14,20 +14,26 @@ describe('InMemoryJobsBackend', function () { assert.deepEqual(backend.requiredFns, ['start', 'enqueue', 'scheduleRecurring', 'shutdown']); }); - it('buffers envelopes enqueued before start, then delivers on start', async function () { - const received: JobEnvelope[] = []; + // Boot starts the jobs service before the web app is mounted or any + // recurring job is scheduled, so a pre-start enqueue or schedule is a + // boot-ordering bug. + it('throws on an enqueue before start instead of silently losing the job', function () { const backend = new InMemoryJobsBackend(); + assert.throws( + () => backend.enqueue({ type: 'early', payload: '{}' }), + /before the jobs backend is started/, + ); + }); - backend.enqueue({ type: 'buffered', payload: '{"n":1}' }); - - backend.start({ - processor: async (env) => { - received.push(env); - }, - }); - await backend.shutdown({ timeoutMs: 1000 }); - - assert.deepEqual(received, [{ type: 'buffered', payload: '{"n":1}' }]); + // A silently accepted pre-start schedule would throw from enqueue inside + // the timer callback later - an uncaughtException - so it must fail at the + // registration site instead. + it('throws on a recurring schedule before start instead of arming a delayed crash', function () { + const backend = new InMemoryJobsBackend(); + assert.throws( + () => backend.scheduleRecurring({ type: 'early', payload: '{}' }, { cron: '0 0 3 * * *' }), + /before the jobs backend is started/, + ); }); it('caps concurrent deliveries at the default concurrency', async function () { From ace943ee873686f9471bba12bf7a57d591dc0875 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 2 Sep 2026 09:05:15 +0000 Subject: [PATCH 09/15] Added queue routing to the class-based jobs service ref https://linear.app/ghost/issue/HKG-2001 Job types with different execution characteristics - slow, untrusted, flood-prone vs. fast, internal, scheduled - could not be isolated from each other: every type shared the in-memory backend's single queue, so any high-volume type could occupy all of the shared workers and starve the rest. The old Bree-based system isolated such work by running an entire second job manager; the class-based service had no equivalent. Handlers can now declare a queue and concurrency together where they are registered (jobsService.handle(Job, handler, {queue, concurrency})), keeping execution policy next to the handler and out of both the job classes and the dispatch sites; a handler that declares nothing runs on the shared default lane, whose name is reserved. The queue is routing metadata resolved at dispatch time - delivery always routes by job type, never by queue, so envelopes stay valid across deploys that move a type between queues. Declared queues are handed to the backend at start() as desired state: the in-memory backend runs one fastq lane per queue and enforces concurrency per process, and a declaration a backend cannot satisfy fails loudly at start() rather than being silently dropped. --- docs/codebase/jobs.md | 22 ++- .../adapters/jobs/InMemoryJobsBackend.ts | 72 ++++++--- .../services/jobs-service/jobs-service.ts | 71 ++++++++- .../jobs/in-memory-jobs-backend.test.ts | 103 +++++++++++++ .../jobs-service/jobs-service.test.ts | 140 +++++++++++++++++- packages/adapters/jobs-base/README.md | 24 ++- packages/adapters/jobs-base/src/base.ts | 23 ++- .../jobs-base/src/contract-test-suite.ts | 34 +++++ 8 files changed, 452 insertions(+), 37 deletions(-) diff --git a/docs/codebase/jobs.md b/docs/codebase/jobs.md index e1d82afe87c..ebe558960bf 100644 --- a/docs/codebase/jobs.md +++ b/docs/codebase/jobs.md @@ -14,10 +14,14 @@ in-process and share the main process's initialized services. ## Adding a job -Jobs are registered through the service in -`ghost/core/core/server/services/jobs/`. The service is a wrapper around -`@tryghost/job-manager` and provides Ghost's logging, configuration, models, and -events. +Class-based jobs are registered through +`jobsService.handle(JobClass, handler)` in +`ghost/core/core/server/services/jobs-service/register-job-handlers.ts`, which +boot wires up before starting the service; the queue options below are part of +this API. Legacy jobs are registered through the service in +`ghost/core/core/server/services/jobs/`, a wrapper around +`@tryghost/job-manager` that provides Ghost's logging, configuration, models, +and events. Existing examples include: @@ -33,6 +37,16 @@ from `ghost/core/core/boot.js`. Keep the wrapper's `init()` idempotent, but let boot own service construction and worker setup rather than initializing on the first request. +## Queues + +Handlers registered through the class-based service can declare a queue and a +concurrency limit together alongside the handler +(`jobsService.handle(Job, handler, {queue: 'webmentions', concurrency: 3})`), +which isolates slow or flood-prone job types from the shared workers; with no +declaration the job type runs on the shared default lane. The queue only +affects which workers run the job and how many run at once - delivery always +routes by job type. + ## Testing Tests for the legacy jobs wrapper live in diff --git a/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts b/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts index 00ce59a75e2..c72655a1098 100644 --- a/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts +++ b/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts @@ -5,6 +5,7 @@ import { JobsBackendBase } from '@tryghost/adapter-base-jobs'; import type { JobProcessor, JobEnvelope, + JobRouting, JobsStartOptions, RecurringSchedule, JobsShutdownOptions, @@ -15,6 +16,9 @@ const logging = require('@tryghost/logging'); const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10000; const DEFAULT_CONCURRENCY = 3; +// Envelopes with no routing share this lane; routing to "default" by name is +// the same lane, not a collision. +const DEFAULT_QUEUE = 'default'; function hasSeconds(cron: string): boolean { return cron.trim().split(/\s+/).length >= 6; @@ -50,29 +54,46 @@ interface RecurringTimer { export default class InMemoryJobsBackend extends JobsBackendBase { private _processor: JobProcessor | null; private _stopped: boolean; - private _concurrency: number; - private _queue: queueAsPromised; + private _defaultConcurrency: number; + private _queues: Map>; private _recurring: Map; constructor(config: { concurrency?: unknown } = {}) { super(); this._processor = null; this._stopped = false; - this._concurrency = resolveConcurrency(config.concurrency); - this._queue = this._createQueue(); + this._defaultConcurrency = resolveConcurrency(config.concurrency); + this._queues = new Map(); this._recurring = new Map(); } - private _createQueue(): queueAsPromised { - return fastq.promise((envelope: JobEnvelope) => this._deliver(envelope), this._concurrency); + private _makeQueue(concurrency: number): queueAsPromised { + return fastq.promise((envelope: JobEnvelope) => this._deliver(envelope), concurrency); } - start({ processor }: JobsStartOptions): void { + start({ processor, queues }: JobsStartOptions): void { + const declared = new Map(); + for (const [name, { concurrency }] of Object.entries(queues ?? {})) { + // A declaration the backend cannot satisfy must fail loudly here, never + // silently fall back (see the jobs-base README). + if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1)) { + throw new errors.IncorrectUsageError({ + message: `Invalid concurrency for declared queue "${name}": ${JSON.stringify(concurrency)}. Expected a positive integer.`, + }); + } + declared.set(name, concurrency ?? this._defaultConcurrency); + } + + // State only changes once every declaration is valid, so a rejected + // start() never leaves a partially started backend that accepts work. this._processor = processor; this._stopped = false; + for (const [name, concurrency] of declared) { + this._queues.set(name, this._makeQueue(concurrency)); + } } - enqueue(envelope: JobEnvelope): void { + enqueue(envelope: JobEnvelope, routing?: JobRouting): void { if (this._stopped) { return; } @@ -81,7 +102,15 @@ export default class InMemoryJobsBackend extends JobsBackendBase { message: `Cannot enqueue job "${envelope.type}" before the jobs backend is started.`, }); } - this._queue.push(envelope); + const name = routing?.queue ?? DEFAULT_QUEUE; + let queue = this._queues.get(name); + if (!queue) { + // Lanes are created lazily: the default lane, and any queue name no + // handler declared, each get their own lane at the default concurrency. + queue = this._makeQueue(this._defaultConcurrency); + this._queues.set(name, queue); + } + queue.push(envelope); } private async _deliver(envelope: JobEnvelope): Promise { @@ -92,7 +121,11 @@ export default class InMemoryJobsBackend extends JobsBackendBase { } } - scheduleRecurring(envelope: JobEnvelope, { cron }: RecurringSchedule): void { + scheduleRecurring( + envelope: JobEnvelope, + { cron }: RecurringSchedule, + routing?: JobRouting, + ): void { if (this._stopped) { return; } @@ -112,7 +145,7 @@ export default class InMemoryJobsBackend extends JobsBackendBase { // A throw inside a later timer callback would be an uncaughtException; // a recurring tick must never take the process down. try { - this.enqueue(envelope); + this.enqueue(envelope, routing); } catch (err) { logging.error(`Recurring job "${envelope.type}" tick failed to enqueue`, err); } @@ -136,15 +169,18 @@ export default class InMemoryJobsBackend extends JobsBackendBase { this._clearRecurring(type); } - const queue = this._queue; - queue.kill(); - if (!queue.idle()) { - await Promise.race([queue.drained(), delay(timeoutMs)]); + const queues = [...this._queues.values()]; + for (const queue of queues) { + queue.kill(); + } + const draining = queues.filter((queue) => !queue.idle()); + if (draining.length > 0) { + await Promise.race([Promise.all(draining.map((queue) => queue.drained())), delay(timeoutMs)]); } - // Fresh queue so a re-boot never inherits this lifecycle's abandoned - // in-flight deliveries against its concurrency limit. - this._queue = this._createQueue(); + // Discard the queues so a re-boot never inherits this lifecycle's + // abandoned in-flight deliveries against its concurrency limits. + this._queues = new Map(); this._processor = null; } } diff --git a/ghost/core/core/server/services/jobs-service/jobs-service.ts b/ghost/core/core/server/services/jobs-service/jobs-service.ts index 6126037f90d..bd8ce9ba7e1 100644 --- a/ghost/core/core/server/services/jobs-service/jobs-service.ts +++ b/ghost/core/core/server/services/jobs-service/jobs-service.ts @@ -3,7 +3,9 @@ import cronValidate from 'cron-validate'; import type { JobsBackendBase, JobEnvelope, + JobRouting, JobsShutdownOptions, + QueueDeclaration, RecurringSchedule, } from '@tryghost/adapter-base-jobs'; import { Job, JobConstructor, JobHandler } from './job'; @@ -25,11 +27,28 @@ export interface JobsServiceOptions { type Deliverer = (payload: string) => Promise | void; +// Execution policy for a job type, declared where its handler is registered: +// either no options (the type runs on the backend's shared default lane) or a +// queue name and concurrency together. The queue is routing metadata only - +// delivery always routes by job type - and concurrency is a queue-level +// declaration the backend enforces as strictly as it can (per process +// in-memory, globally where a durable backend supports it). A tunable value +// can be resolved from config at the registration site before declaring it +// here. +export interface JobHandlingOptions { + /** Named lane; types declaring the same name and concurrency share it. */ + queue: string; + /** Max concurrent deliveries for the queue. */ + concurrency: number; +} + export class JobsService { readonly #backend: JobsBackendBase; readonly #logging: JobsLogger; readonly #sentry?: JobsErrorReporter; readonly #registry = new Map(); + readonly #queueByType = new Map(); + readonly #queues = new Map(); constructor({ backend, logging, sentry }: JobsServiceOptions) { this.#backend = backend; @@ -37,7 +56,11 @@ export class JobsService { this.#sentry = sentry; } - handle(JobClass: JobConstructor, handler: JobHandler): void { + handle( + JobClass: JobConstructor, + handler: JobHandler, + options?: JobHandlingOptions, + ): void { const type = JobClass.type; if (typeof type !== 'string' || type.length === 0) { throw new errors.IncorrectUsageError({ @@ -49,16 +72,57 @@ export class JobsService { message: `A handler for job type "${type}" is already registered.`, }); } + this.#declareQueue(type, options); this.#registry.set(type, (payload) => handler(new JobClass(JSON.parse(payload)))); } + #declareQueue(type: string, options?: JobHandlingOptions): void { + if (!options) { + return; + } + const { queue, concurrency } = options; + if (typeof queue !== 'string' || queue.length === 0) { + throw new errors.IncorrectUsageError({ + message: `Invalid queue for job type "${type}": ${JSON.stringify(queue)}. Expected a non-empty string.`, + }); + } + // "default" is the backend's shared lane for types that declare no queue; + // declaring it would silently re-size that lane for every unrouted type. + if (queue === 'default') { + throw new errors.IncorrectUsageError({ + message: `Queue name "default" is reserved for the shared lane; job type "${type}" must omit options to use it.`, + }); + } + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new errors.IncorrectUsageError({ + message: `Invalid concurrency for job type "${type}": ${JSON.stringify(concurrency)}. Expected a positive integer.`, + }); + } + + const existing = this.#queues.get(queue); + if (existing && existing.concurrency !== concurrency) { + throw new errors.IncorrectUsageError({ + message: `Conflicting concurrency for queue "${queue}": ${existing.concurrency} is already declared, job type "${type}" declares ${concurrency}.`, + }); + } + this.#queues.set(queue, { concurrency }); + this.#queueByType.set(type, queue); + } + + #routingFor(type: string): JobRouting | undefined { + const queue = this.#queueByType.get(type); + return queue === undefined ? undefined : { queue }; + } + async dispatch(job: Job): Promise { - await this.#backend.enqueue(this.#buildEnvelope(job)); + const envelope = this.#buildEnvelope(job); + await this.#backend.enqueue(envelope, this.#routingFor(envelope.type)); } async scheduleRecurring(job: Job, schedule: RecurringSchedule): Promise { this.#assertValidCron(schedule.cron); - await this.#backend.scheduleRecurring(this.#buildEnvelope(job), schedule); + const envelope = this.#buildEnvelope(job); + await this.#backend.scheduleRecurring(envelope, schedule, this.#routingFor(envelope.type)); } // later.parse.cron does not strictly validate: it silently coerces a @@ -80,6 +144,7 @@ export class JobsService { async start(): Promise { await this.#backend.start({ processor: (envelope) => this.#process(envelope), + queues: Object.fromEntries(this.#queues), }); } diff --git a/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts b/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts index 5fb497dde96..694ce643c0a 100644 --- a/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts +++ b/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts @@ -36,6 +36,33 @@ describe('InMemoryJobsBackend', function () { ); }); + it('rejects an invalid declared queue concurrency at start instead of silently ignoring it', function () { + const backend = new InMemoryJobsBackend(); + const processor = async () => {}; + assert.throws( + () => backend.start({ processor, queues: { slow: { concurrency: 0 } } }), + /declared queue "slow"/, + ); + assert.throws( + () => backend.start({ processor, queues: { slow: { concurrency: NaN } } }), + /declared queue "slow"/, + ); + }); + + it('stays un-started when start rejects a declaration, so no work is accepted', function () { + const backend = new InMemoryJobsBackend(); + assert.throws(() => + backend.start({ + processor: async () => {}, + queues: { ok: { concurrency: 1 }, bad: { concurrency: 0 } }, + }), + ); + assert.throws( + () => backend.enqueue({ type: 'early', payload: '{}' }), + /before the jobs backend is started/, + ); + }); + it('caps concurrent deliveries at the default concurrency', async function () { let running = 0; let maxConcurrent = 0; @@ -229,6 +256,82 @@ describe('InMemoryJobsBackend', function () { assert.deepEqual(received, ['fresh'], 'a re-boot starts new work despite prior hung handlers'); }); + describe('queue routing', function () { + // Tracks per-lane concurrency by tagging each envelope with its lane. + function makeLaneTracker() { + const running = new Map(); + const max = new Map(); + const release: Array<() => void> = []; + const processor = async (env: JobEnvelope) => { + const lane = JSON.parse(env.payload).lane as string; + running.set(lane, (running.get(lane) ?? 0) + 1); + max.set(lane, Math.max(max.get(lane) ?? 0, running.get(lane)!)); + await new Promise((resolve) => { + release.push(resolve); + }); + running.set(lane, running.get(lane)! - 1); + }; + return { max, release, processor }; + } + + async function drainAndShutdown( + backend: InMemoryJobsBackend, + release: Array<() => void>, + ): Promise { + release.forEach((resolve) => resolve()); + const releaser = setInterval(() => release.forEach((resolve) => resolve()), 5); + await backend.shutdown({ timeoutMs: 2000 }); + clearInterval(releaser); + } + + it('runs a declared queue at its own concurrency without occupying the default lane', async function () { + const { max, release, processor } = makeLaneTracker(); + const backend = new InMemoryJobsBackend(); + + backend.start({ processor, queues: { webmentions: { concurrency: 1 } } }); + + for (let i = 0; i < 4; i = i + 1) { + backend.enqueue( + { type: 'webmention', payload: '{"lane":"webmentions"}' }, + { queue: 'webmentions' }, + ); + backend.enqueue({ type: 'work', payload: '{"lane":"default"}' }); + } + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + assert.equal(max.get('webmentions'), 1, 'the declared queue runs serially'); + assert.equal(max.get('default'), 3, 'a busy declared queue does not occupy default workers'); + + await drainAndShutdown(backend, release); + }); + + it('drains in-flight work on every lane at shutdown', async function () { + const completed: string[] = []; + const backend = new InMemoryJobsBackend(); + + backend.start({ + processor: async (env) => { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + completed.push(env.type); + }, + queues: { webmentions: { concurrency: 1 } }, + }); + + backend.enqueue({ type: 'webmention', payload: '{}' }, { queue: 'webmentions' }); + backend.enqueue({ type: 'work', payload: '{}' }); + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + await backend.shutdown({ timeoutMs: 2000 }); + + assert.deepEqual(completed.sort(), ['webmention', 'work']); + }); + }); + describe('recurring schedules', function () { let clock: { tickAsync(ms: number): Promise; restore(): void } | undefined; diff --git a/ghost/core/test/unit/server/services/jobs-service/jobs-service.test.ts b/ghost/core/test/unit/server/services/jobs-service/jobs-service.test.ts index 55221fdfd3e..75bd8d877f5 100644 --- a/ghost/core/test/unit/server/services/jobs-service/jobs-service.test.ts +++ b/ghost/core/test/unit/server/services/jobs-service/jobs-service.test.ts @@ -3,6 +3,7 @@ import { describe, it, beforeEach } from 'vitest'; import type { JobsBackendBase, JobEnvelope, + JobRouting, JobsStartOptions, JobProcessor, RecurringSchedule, @@ -12,26 +13,33 @@ import { JobsService, JobsLogger, JobsErrorReporter, + JobHandlingOptions, } from '../../../../../core/server/services/jobs-service/jobs-service'; import { Job } from '../../../../../core/server/services/jobs-service/job'; class FakeBackend implements JobsBackendBase { readonly requiredFns = ['start', 'enqueue', 'scheduleRecurring', 'shutdown'] as const; processor: JobProcessor | null = null; - enqueued: JobEnvelope[] = []; - recurring: { envelope: JobEnvelope; schedule: RecurringSchedule }[] = []; + startOptions: JobsStartOptions | null = null; + enqueued: { envelope: JobEnvelope; routing?: JobRouting }[] = []; + recurring: { envelope: JobEnvelope; schedule: RecurringSchedule; routing?: JobRouting }[] = []; shutdownCalls: (JobsShutdownOptions | undefined)[] = []; start(options: JobsStartOptions): void { this.processor = options.processor; + this.startOptions = options; } - enqueue(envelope: JobEnvelope): void { - this.enqueued.push(envelope); + enqueue(envelope: JobEnvelope, routing?: JobRouting): void { + this.enqueued.push({ envelope, routing }); } - scheduleRecurring(envelope: JobEnvelope, schedule: RecurringSchedule): void { - this.recurring.push({ envelope, schedule }); + scheduleRecurring( + envelope: JobEnvelope, + schedule: RecurringSchedule, + routing?: JobRouting, + ): void { + this.recurring.push({ envelope, schedule, routing }); } shutdown(options?: JobsShutdownOptions): void { @@ -40,7 +48,7 @@ class FakeBackend implements JobsBackendBase { async deliver(index = 0): Promise { assert.ok(this.processor, 'processor must be wired via start()'); - await this.processor!(this.enqueued[index]!); + await this.processor!(this.enqueued[index]!.envelope); } } @@ -106,6 +114,71 @@ describe('JobsService', function () { service.handle(GreetJob, async () => {}); assert.throws(() => service.handle(GreetJob, async () => {}), /already registered/); }); + + it('rejects an invalid concurrency at registration', function () { + const service = makeService(); + assert.throws( + () => service.handle(GreetJob, async () => {}, { queue: 'slow', concurrency: 0 }), + /Invalid concurrency/, + ); + assert.throws( + () => service.handle(GreetJob, async () => {}, { queue: 'slow', concurrency: 1.5 }), + /Invalid concurrency/, + ); + assert.throws( + () => + service.handle(GreetJob, async () => {}, { + queue: 'slow', + } as unknown as JobHandlingOptions), + /Invalid concurrency/, + ); + }); + + it('rejects an invalid or missing queue name at registration', function () { + const service = makeService(); + assert.throws( + () => service.handle(GreetJob, async () => {}, { queue: '', concurrency: 1 }), + /Invalid queue/, + ); + assert.throws( + () => + service.handle(GreetJob, async () => {}, { + concurrency: 1, + } as unknown as JobHandlingOptions), + /Invalid queue/, + ); + }); + + it('reserves the "default" queue name for the shared lane', function () { + const service = makeService(); + assert.throws( + () => service.handle(GreetJob, async () => {}, { queue: 'default', concurrency: 1 }), + /reserved for the shared lane/, + ); + }); + + it('rejects conflicting concurrency declarations for one queue', function () { + const service = makeService(); + class OtherJob extends Job { + static type = 'other'; + } + service.handle(GreetJob, async () => {}, { queue: 'slow', concurrency: 1 }); + assert.throws( + () => service.handle(OtherJob, async () => {}, { queue: 'slow', concurrency: 2 }), + /Conflicting concurrency for queue "slow"/, + ); + }); + + it('lets a second type join a queue by declaring the same concurrency', function () { + const service = makeService(); + class OtherJob extends Job { + static type = 'other'; + } + service.handle(GreetJob, async () => {}, { queue: 'slow', concurrency: 1 }); + assert.doesNotThrow(() => + service.handle(OtherJob, async () => {}, { queue: 'slow', concurrency: 1 }), + ); + }); }); describe('dispatch', function () { @@ -114,7 +187,7 @@ describe('JobsService', function () { await service.dispatch(new GreetJob({ name: 'Ada' })); assert.equal(backend.enqueued.length, 1); - const envelope = backend.enqueued[0]!; + const envelope = backend.enqueued[0]!.envelope; assert.equal(envelope.type, 'greet'); assert.equal(typeof envelope.payload, 'string'); assert.deepEqual(JSON.parse(envelope.payload), { name: 'Ada' }); @@ -144,6 +217,57 @@ describe('JobsService', function () { }); }); + describe('queue routing', function () { + it('routes a dispatched job to its handler-declared queue', async function () { + const service = makeService(); + service.handle(GreetJob, async () => {}, { queue: 'greetings', concurrency: 2 }); + + await service.dispatch(new GreetJob({ name: 'Ada' })); + + assert.deepEqual(backend.enqueued[0]!.routing, { queue: 'greetings' }); + }); + + it('routing stays out of the envelope: no extra envelope fields from queue config', async function () { + const service = makeService(); + service.handle(GreetJob, async () => {}, { queue: 'greetings', concurrency: 2 }); + + await service.dispatch(new GreetJob({ name: 'Ada' })); + + assert.deepEqual(Object.keys(backend.enqueued[0]!.envelope).sort(), ['payload', 'type']); + }); + + it('dispatches with no routing when the type declares no queue', async function () { + const service = makeService(); + service.handle(GreetJob, async () => {}); + + await service.dispatch(new GreetJob({ name: 'Ada' })); + + assert.equal(backend.enqueued[0]!.routing, undefined); + }); + + it('hands declared queues to the backend on start', async function () { + const service = makeService(); + class OtherJob extends Job { + static type = 'other'; + } + service.handle(GreetJob, async () => {}, { queue: 'webmentions', concurrency: 1 }); + service.handle(OtherJob, async () => {}, { queue: 'webmentions', concurrency: 1 }); + + await service.start(); + + assert.deepEqual(backend.startOptions!.queues, { webmentions: { concurrency: 1 } }); + }); + + it('routes recurring schedules through the same queue mapping', async function () { + const service = makeService(); + service.handle(GreetJob, async () => {}, { queue: 'greetings', concurrency: 2 }); + + await service.scheduleRecurring(new GreetJob({ name: 'cron' }), { cron: '0 0 3 * * *' }); + + assert.deepEqual(backend.recurring[0]!.routing, { queue: 'greetings' }); + }); + }); + describe('delivery error handling', function () { it('captures handler errors with job context and rethrows so the backend sees a failed delivery', async function () { const { sentry, captured } = makeSentry(); diff --git a/packages/adapters/jobs-base/README.md b/packages/adapters/jobs-base/README.md index 052c37dd837..e59a8217150 100644 --- a/packages/adapters/jobs-base/README.md +++ b/packages/adapters/jobs-base/README.md @@ -12,11 +12,29 @@ in-memory one with no call-site change. A backend extends `JobsBackendBase` and implements four methods: -- `start({processor})` - wire the single delivery callback and begin accepting work. -- `enqueue(envelope)` - accept an envelope for delivery. Resolves on **acceptance**, not completion. -- `scheduleRecurring(envelope, {cron})` - register the recurring schedule for the envelope's type. The first registration per type wins; a later call for an already-scheduled type is ignored. +- `start({processor, queues})` - wire the single delivery callback and begin accepting work. `queues` is the desired state declared by registered handlers (`{name: {concurrency}}`). +- `enqueue(envelope, {queue})` - accept an envelope for delivery. Resolves on **acceptance**, not completion. +- `scheduleRecurring(envelope, {cron}, {queue})` - register the recurring schedule for the envelope's type. The first registration per type wins; a later call for an already-scheduled type is ignored. - `shutdown({timeoutMs})` - stop accepting work and drain in-flight work within a bounded time. +### Queues + +A queue is a routing/QoS lane, and routing metadata only - **delivery always +routes by the envelope's `type`, never by queue**, so a job is processable +whichever queue it arrives on and a deploy can move a type between queues while +older work is still in flight. Renaming or removing a queue is therefore a +parallel change: keep consuming the old name until it has drained, then drop it. + +The declared queues are desired state, not a command. A backend enforces a +queue's `concurrency` as strictly as it can - per process for the in-memory +backend, globally where a durable backend supports it. Weaker enforcement is +acceptable; silently ignoring a declaration is not: a backend that cannot +satisfy a declared queue's constraints - whatever that means for its +implementation - must fail loudly at `start()`. An envelope routed to a queue +no handler declared must still be delivered, never dropped. The queue name +`default` names the shared lane for envelopes with no routing and cannot be +declared. + ### Delivery outcome The backend delivers an envelope by calling `processor(envelope)` and awaiting the diff --git a/packages/adapters/jobs-base/src/base.ts b/packages/adapters/jobs-base/src/base.ts index c33dd8a501c..b73e3cbcd92 100644 --- a/packages/adapters/jobs-base/src/base.ts +++ b/packages/adapters/jobs-base/src/base.ts @@ -3,10 +3,30 @@ export interface JobEnvelope { payload: string; } +// Routing is metadata about where a job runs, never what runs it: delivery +// must always be keyed on the envelope's type, so a job is processable +// whichever queue it arrives on (deploys can move types between queues while +// older envelopes are still in flight). +export interface JobRouting { + queue?: string; +} + +// A queue declared in code (via handler registration) is desired state: the +// backend enforces its concurrency as strictly as it can - per process for an +// in-memory backend, globally where a durable backend supports it. Weaker +// enforcement is acceptable; silently ignoring a declaration is not. +export interface QueueDeclaration { + concurrency?: number; +} + export type JobProcessor = (envelope: JobEnvelope) => Promise; export interface JobsStartOptions { processor: JobProcessor; + // Queues declared by registered handlers. A backend that cannot satisfy a + // declared queue's constraints - whatever that means for its implementation - + // must fail loudly here rather than silently dropping the declaration. + queues?: Record; } export interface RecurringSchedule { @@ -29,11 +49,12 @@ export abstract class JobsBackendBase { abstract start(options: JobsStartOptions): void | Promise; - abstract enqueue(envelope: JobEnvelope): void | Promise; + abstract enqueue(envelope: JobEnvelope, routing?: JobRouting): void | Promise; abstract scheduleRecurring( envelope: JobEnvelope, schedule: RecurringSchedule, + routing?: JobRouting, ): void | Promise; abstract shutdown(options?: JobsShutdownOptions): void | Promise; diff --git a/packages/adapters/jobs-base/src/contract-test-suite.ts b/packages/adapters/jobs-base/src/contract-test-suite.ts index c0156876885..4590245c880 100644 --- a/packages/adapters/jobs-base/src/contract-test-suite.ts +++ b/packages/adapters/jobs-base/src/contract-test-suite.ts @@ -110,6 +110,40 @@ export function runJobsBackendContractTests( assert.equal(raced, 'shutdown'); }); + // Queue routing is metadata: a backend may isolate queues or run one + // lane, but a routed envelope must always be delivered, and unknown + // routing must never lose work. + it('delivers an envelope enqueued with queue routing', async function () { + const received: JobEnvelope[] = []; + const backend = makeBackend(); + await backend.start({ + processor: async (env) => { + received.push(env); + }, + queues: { isolated: { concurrency: 1 } }, + }); + + await backend.enqueue(envelope, { queue: 'isolated' }); + await backend.shutdown({ timeoutMs: 1000 }); + + assert.deepEqual(received, [envelope]); + }); + + it('delivers an envelope routed to a queue no handler declared', async function () { + const received: JobEnvelope[] = []; + const backend = makeBackend(); + await backend.start({ + processor: async (env) => { + received.push(env); + }, + }); + + await backend.enqueue(envelope, { queue: 'undeclared' }); + await backend.shutdown({ timeoutMs: 1000 }); + + assert.deepEqual(received, [envelope]); + }); + it('tolerates start after a prior shutdown', async function () { const received: JobEnvelope[] = []; const backend = makeBackend(); From 710a8450b0b4f23ed5c88137eccf66988d3fc4f4 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 2 Sep 2026 09:05:41 +0000 Subject: [PATCH 10/15] Added a dedicated queue for webmention processing ref https://linear.app/ghost/issue/HKG-2001 Webmention processing fetches external pages and is triggered by unauthenticated requests, so a flood of webmentions could occupy all of the in-memory backend's shared workers and starve other background jobs. Routing it onto its own webmentions queue restores the isolation the old dedicated mentions job manager provided before the job moved to the class-based service; the concurrency matches that legacy queue's inline fastq concurrency of 3. --- docs/codebase/jobs.md | 3 ++- .../jobs-service/register-job-handlers.ts | 19 ++++++++++++++++--- .../register-job-handlers.test.ts | 17 +++++++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/codebase/jobs.md b/docs/codebase/jobs.md index ebe558960bf..11abbe440f6 100644 --- a/docs/codebase/jobs.md +++ b/docs/codebase/jobs.md @@ -45,7 +45,8 @@ concurrency limit together alongside the handler which isolates slow or flood-prone job types from the shared workers; with no declaration the job type runs on the shared default lane. The queue only affects which workers run the job and how many run at once - delivery always -routes by job type. +routes by job type. Webmention processing runs on its own `webmentions` queue +this way. ## Testing diff --git a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts index 19640814866..3968120a54f 100644 --- a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts +++ b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts @@ -1,4 +1,5 @@ import { JobsService } from './jobs-service'; +import type { JobHandlingOptions } from './jobs-service'; import type { GiftService } from '../gifts/gift-service'; import CleanTokensJob from '../members/jobs/clean-tokens-job'; import CleanExpiredCompedJob from '../members/jobs/clean-expired-comped-job'; @@ -13,6 +14,14 @@ import ProcessWebmentionJob from '../mentions/process-webmention-job'; const updateCheck = require('../update-check'); +// Webmention processing fetches external pages and is triggered by +// unauthenticated requests, so webmention jobs run in their own lane where a +// flood cannot occupy the shared workers. The concurrency matches the old +// dedicated mentions job queue. Every webmention job type must register with +// this shared declaration so none can declare the queue with a different +// concurrency. +const WEBMENTIONS_QUEUE: JobHandlingOptions = { queue: 'webmentions', concurrency: 3 }; + interface RegisterJobHandlersDependencies { jobsService: JobsService; memberJobs: { @@ -55,7 +64,11 @@ export default function registerJobHandlers({ await updateCheck({ rethrowErrors: true }); }); - jobsService.handle(ProcessWebmentionJob, async (job) => { - await mentionsController.processWebmention(job); - }); + jobsService.handle( + ProcessWebmentionJob, + async (job) => { + await mentionsController.processWebmention(job); + }, + WEBMENTIONS_QUEUE, + ); } diff --git a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts index b0d734fefbb..3864779a4b9 100644 --- a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts +++ b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts @@ -20,12 +20,16 @@ describe('register-job-handlers', function () { // Handlers are looked up by their job type rather than registration order, // so adding a handler does not silently shift which one a test exercises. - function handlerFor(type: string) { + function registrationFor(type: string) { const call = jobsService.handle .getCalls() .find((c) => (c.args[0] as { type?: string }).type === type); assert.ok(call, `a handler is registered for ${type}`); - return call!.args[1] as (job: unknown) => Promise; + return call!; + } + + function handlerFor(type: string) { + return registrationFor(type).args[1] as (job: unknown) => Promise; } beforeEach(function () { @@ -135,4 +139,13 @@ describe('register-job-handlers', function () { assert.ok(mentionsController.processWebmention.calledOnceWithExactly(job)); }); + + // Guards the webmention isolation itself: dropping the options object in a + // refactor would silently move webmentions back onto the shared queue while + // every handler-behavior test still passes. + it('registers process-webmention on the dedicated webmentions queue', function () { + const registration = registrationFor('process-webmention'); + + assert.deepEqual(registration.args[2], { queue: 'webmentions', concurrency: 3 }); + }); }); From 37b21b29afd82edd4ba9b4f529463cabc6a570d0 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 2 Sep 2026 10:23:25 -0400 Subject: [PATCH 11/15] Updated framework deps to latest (#30463) no ref - fixes node 24 compatability issues in tryghost/request --- .changeset/weak-moose-brake.md | 15 + ghost/core/package.json | 20 +- pnpm-lock.yaml | 1152 +++++++++++++++++++++----------- pnpm-workspace.yaml | 18 +- 4 files changed, 782 insertions(+), 423 deletions(-) create mode 100644 .changeset/weak-moose-brake.md diff --git a/.changeset/weak-moose-brake.md b/.changeset/weak-moose-brake.md new file mode 100644 index 00000000000..cc733e77cb3 --- /dev/null +++ b/.changeset/weak-moose-brake.md @@ -0,0 +1,15 @@ +--- +"ghost-storage-base": none +"@tryghost/adapter-base-cache": none +"@tryghost/adapter-base-scheduling": none +"@tryghost/adapter-base-sso": none +"@tryghost/kg-card-factory": none +"@tryghost/kg-clean-basic-html": none +"@tryghost/kg-converters": none +"@tryghost/kg-default-cards": none +"@tryghost/kg-default-nodes": none +"@tryghost/kg-markdown-html-renderer": none +"@tryghost/kg-utils": none +--- + +Update dependencies diff --git a/ghost/core/package.json b/ghost/core/package.json index e77811acecb..4ea6affd684 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -92,17 +92,17 @@ "@tryghost/adapter-base-sso": "workspace:*", "@tryghost/admin-api-schema": "workspace:*", "@tryghost/api-framework": "catalog:", - "@tryghost/bookshelf-plugins": "2.3.8", + "@tryghost/bookshelf-plugins": "2.3.12", "@tryghost/brute-knex": "catalog:", "@tryghost/checkout": "workspace:*", "@tryghost/color-utils": "catalog:", "@tryghost/config-url-helpers": "1.0.27", "@tryghost/custom-field-types": "workspace:*", "@tryghost/custom-fonts": "catalog:", - "@tryghost/database-info": "2.3.2", + "@tryghost/database-info": "2.3.12", "@tryghost/debug": "catalog:", "@tryghost/domain-events": "catalog:", - "@tryghost/email-mock-receiver": "2.1.0", + "@tryghost/email-mock-receiver": "2.3.12", "@tryghost/errors": "catalog:", "@tryghost/helpers": "catalog:", "@tryghost/html-to-plaintext": "1.0.11", @@ -124,24 +124,24 @@ "@tryghost/mongo-utils": "0.6.5", "@tryghost/mw-error-handler": "1.0.13", "@tryghost/mw-vhost": "1.0.6", - "@tryghost/nodemailer": "2.3.1", + "@tryghost/nodemailer": "2.3.12", "@tryghost/nql": "catalog:", "@tryghost/nql-lang": "catalog:", "@tryghost/nql-string": "workspace:*", "@tryghost/parse-email-address": "workspace:*", - "@tryghost/pretty-cli": "3.3.2", + "@tryghost/pretty-cli": "3.3.12", "@tryghost/prometheus-metrics": "1.0.8", "@tryghost/referrer-parser": "0.1.21", "@tryghost/request": "catalog:", - "@tryghost/root-utils": "2.3.2", + "@tryghost/root-utils": "2.3.12", "@tryghost/security": "1.0.6", "@tryghost/social-urls": "0.1.63", "@tryghost/string": "catalog:", "@tryghost/tpl": "catalog:", "@tryghost/url-utils": "5.2.6", "@tryghost/validator": "catalog:", - "@tryghost/version": "2.3.2", - "@tryghost/zip": "3.5.1", + "@tryghost/version": "2.3.12", + "@tryghost/zip": "3.5.11", "@x402/core": "catalog:", "@x402/evm": "catalog:", "@x402/hono": "catalog:", @@ -252,8 +252,8 @@ "devDependencies": { "@eslint/js": "catalog:", "@internal/cfg-eslint": "workspace:*", - "@tryghost/express-test": "2.1.0", - "@tryghost/webhook-mock-receiver": "2.1.0", + "@tryghost/express-test": "2.3.12", + "@tryghost/webhook-mock-receiver": "2.3.12", "@types/bookshelf": "1.2.9", "@types/common-tags": "1.8.4", "@types/express": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c2e92539c7..eb830aaa62a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,8 +252,8 @@ catalogs: specifier: 14.3.1 version: 14.3.1 '@tryghost/api-framework': - specifier: 3.3.9 - version: 3.3.9 + specifier: 3.3.12 + version: 3.3.12 '@tryghost/brute-knex': specifier: 3.2.2 version: 3.2.2 @@ -264,11 +264,11 @@ catalogs: specifier: 1.0.11 version: 1.0.11 '@tryghost/debug': - specifier: 2.3.9 - version: 2.3.9 + specifier: 2.3.12 + version: 2.3.12 '@tryghost/domain-events': - specifier: 3.3.10 - version: 3.3.10 + specifier: 3.3.13 + version: 3.3.13 '@tryghost/helpers': specifier: 1.1.106 version: 1.1.106 @@ -276,8 +276,8 @@ catalogs: specifier: 1.5.6 version: 1.5.6 '@tryghost/metrics': - specifier: 3.5.0 - version: 3.5.0 + specifier: 3.5.3 + version: 3.5.3 '@tryghost/mg-clean-html': specifier: 0.12.6 version: 0.12.6 @@ -285,8 +285,8 @@ catalogs: specifier: 0.11.2 version: 0.11.2 '@tryghost/request': - specifier: 4.0.3 - version: 4.0.3 + specifier: 4.0.5 + version: 4.0.5 '@tryghost/string': specifier: 0.3.5 version: 0.3.5 @@ -294,11 +294,11 @@ catalogs: specifier: 1.0.0 version: 1.0.0 '@tryghost/tpl': - specifier: 2.3.9 - version: 2.3.9 + specifier: 2.3.12 + version: 2.3.12 '@tryghost/validator': - specifier: 3.2.10 - version: 3.2.10 + specifier: 3.2.12 + version: 3.2.12 '@types/express': specifier: 4.17.25 version: 4.17.25 @@ -629,8 +629,8 @@ catalogs: overrides: cron-validate: 1.4.5 knex-migrator>knex: 2.4.2 - '@tryghost/errors': 3.3.9 - '@tryghost/logging': 5.4.0 + '@tryghost/errors': 3.3.12 + '@tryghost/logging': 5.4.3 '@tryghost/nql': 0.13.4 '@tryghost/nql-lang': 0.7.0 jackspeak: 4.2.3 @@ -1278,7 +1278,7 @@ importers: version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) react: specifier: catalog:react17 version: 17.0.2 @@ -1769,7 +1769,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) devDependencies: '@doist/react-interpolate': specifier: 2.2.4 @@ -2047,7 +2047,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) react: specifier: 'catalog:' version: 18.3.1 @@ -2117,7 +2117,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) '@tryghost/i18n': specifier: workspace:* version: link:../../packages/i18n @@ -2264,10 +2264,10 @@ importers: version: link:../packages/custom-field-types '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) '@tryghost/logging': - specifier: 5.4.0 - version: 5.4.0(supports-color@10.2.2) + specifier: 5.4.3 + version: 5.4.3(supports-color@10.2.2) '@tryghost/test-data': specifier: workspace:* version: link:../packages/testing/test-data @@ -2369,10 +2369,10 @@ importers: version: link:../../packages/admin-api-schema '@tryghost/api-framework': specifier: 'catalog:' - version: 3.3.9(supports-color@10.2.2) + version: 3.3.12(supports-color@10.2.2) '@tryghost/bookshelf-plugins': - specifier: 2.3.8 - version: 2.3.8(supports-color@10.2.2) + specifier: 2.3.12 + version: 2.3.12(supports-color@10.2.2) '@tryghost/brute-knex': specifier: 'catalog:' version: 3.2.2(better-sqlite3@12.11.1)(express@4.22.2(supports-color@10.2.2))(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2) @@ -2392,20 +2392,20 @@ importers: specifier: 'catalog:' version: 1.0.11 '@tryghost/database-info': - specifier: 2.3.2 - version: 2.3.2 + specifier: 2.3.12 + version: 2.3.12 '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) '@tryghost/domain-events': specifier: 'catalog:' - version: 3.3.10(supports-color@10.2.2) + version: 3.3.13(supports-color@10.2.2) '@tryghost/email-mock-receiver': - specifier: 2.1.0 - version: 2.1.0 + specifier: 2.3.12 + version: 2.3.12 '@tryghost/errors': - specifier: 3.3.9 - version: 3.3.9 + specifier: 3.3.12 + version: 3.3.12 '@tryghost/helpers': specifier: 'catalog:' version: 1.1.106 @@ -2446,11 +2446,11 @@ importers: specifier: 'catalog:' version: 1.5.6 '@tryghost/logging': - specifier: 5.4.0 - version: 5.4.0(supports-color@10.2.2) + specifier: 5.4.3 + version: 5.4.3(supports-color@10.2.2) '@tryghost/metrics': specifier: 'catalog:' - version: 3.5.0(supports-color@10.2.2) + version: 3.5.3(supports-color@10.2.2) '@tryghost/mg-clean-html': specifier: 'catalog:' version: 0.12.6(encoding@0.1.13) @@ -2467,8 +2467,8 @@ importers: specifier: 1.0.6 version: 1.0.6 '@tryghost/nodemailer': - specifier: 2.3.1 - version: 2.3.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + specifier: 2.3.12 + version: 2.3.12(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) '@tryghost/nql': specifier: 0.13.4 version: 0.13.4(supports-color@10.2.2) @@ -2482,8 +2482,8 @@ importers: specifier: workspace:* version: link:../../packages/parse-email-address '@tryghost/pretty-cli': - specifier: 3.3.2 - version: 3.3.2 + specifier: 3.3.12 + version: 3.3.12 '@tryghost/prometheus-metrics': specifier: 1.0.8 version: 1.0.8(supports-color@10.2.2) @@ -2492,10 +2492,10 @@ importers: version: 0.1.21 '@tryghost/request': specifier: 'catalog:' - version: 4.0.3 + version: 4.0.5 '@tryghost/root-utils': - specifier: 2.3.2 - version: 2.3.2 + specifier: 2.3.12 + version: 2.3.12 '@tryghost/security': specifier: 1.0.6 version: 1.0.6 @@ -2507,19 +2507,19 @@ importers: version: 0.3.5 '@tryghost/tpl': specifier: 'catalog:' - version: 2.3.9 + version: 2.3.12 '@tryghost/url-utils': specifier: 5.2.6 version: 5.2.6 '@tryghost/validator': specifier: 'catalog:' - version: 3.2.10 + version: 3.2.12 '@tryghost/version': - specifier: 2.3.2 - version: 2.3.2 + specifier: 2.3.12 + version: 2.3.12 '@tryghost/zip': - specifier: 3.5.1 - version: 3.5.1(supports-color@10.2.2) + specifier: 3.5.11 + version: 3.5.11(supports-color@10.2.2) '@x402/core': specifier: 'catalog:' version: 2.12.0 @@ -2846,11 +2846,11 @@ importers: specifier: workspace:* version: link:../../configs/eslint '@tryghost/express-test': - specifier: 2.1.0 - version: 2.1.0(express@4.22.2(supports-color@10.2.2))(supports-color@10.2.2) + specifier: 2.3.12 + version: 2.3.12(express@4.22.2(supports-color@10.2.2))(supports-color@10.2.2) '@tryghost/webhook-mock-receiver': - specifier: 2.1.0 - version: 2.1.0 + specifier: 2.3.12 + version: 2.3.12 '@types/bookshelf': specifier: 1.2.9 version: 1.2.9(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2) @@ -4001,8 +4001,8 @@ importers: packages/adapters/scheduling-base: dependencies: '@tryghost/logging': - specifier: 5.4.0 - version: 5.4.0(supports-color@10.2.2) + specifier: 5.4.3 + version: 5.4.3(supports-color@10.2.2) devDependencies: '@internal/cfg-eslint': specifier: 'workspace:' @@ -4041,8 +4041,8 @@ importers: packages/adapters/sso-base: dependencies: '@tryghost/errors': - specifier: 3.3.9 - version: 3.3.9 + specifier: 3.3.12 + version: 3.3.12 devDependencies: '@internal/cfg-eslint': specifier: workspace:* @@ -4115,8 +4115,8 @@ importers: packages/admin-api-schema: dependencies: '@tryghost/errors': - specifier: 3.3.9 - version: 3.3.9 + specifier: 3.3.12 + version: 3.3.12 ajv: specifier: 'catalog:' version: 8.20.0 @@ -4224,7 +4224,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.9(supports-color@10.2.2) + version: 2.3.12(supports-color@10.2.2) i18next: specifier: 23.16.8 version: 23.16.8 @@ -4411,19 +4411,6 @@ packages: resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} engines: {node: ^22.13.0 || >=24.0.0} - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.12': resolution: {integrity: sha512-RgNDWfhNRIlNEzePIRrYTNi/6q+wwRMMapojn8YVzw4ZcJRa/gxVMtUbeZARR1gmopuv6oIhMbY7J66qIQ0ynw==} engines: {node: '>=20.0.0'} @@ -4432,46 +4419,82 @@ packages: resolution: {integrity: sha512-di9U/7Po7qlVYb2dq58ULsbBAE1pBIk53rux+50LQCvH1X+/l1Ys+BIk/QLBtdaK1nADk0xRNEBbA1QWVnMccw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sesv2@3.1073.0': - resolution: {integrity: sha512-+9OG/NMj/5OFL0ygVFEbh/CF1ENdGLSuYR3upvYJ4entG3dcAwBzgxRl81EAXt7yI0nK6IqBy7H6QuNoAs3Aew==} + '@aws-sdk/client-sesv2@3.1121.0': + resolution: {integrity: sha512-Vk7tHo7TPBlR5MmvpfR4XXNL5uld/5uWHNBceUArtQrzhwNaxt/5ANp45JETZfLH9VDoaX3bTmoHeSMOWc0Ldg==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.974.27': resolution: {integrity: sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.977.9': + resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.53': resolution: {integrity: sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.70': + resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.55': resolution: {integrity: sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.72': + resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.60': resolution: {integrity: sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.15': + resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.59': resolution: {integrity: sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.77': + resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.62': resolution: {integrity: sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.81': + resolution: {integrity: sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.53': resolution: {integrity: sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.70': + resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.59': resolution: {integrity: sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.14': + resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.59': resolution: {integrity: sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.76': + resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.58': resolution: {integrity: sha512-6uaWRRYJGhOqc9EoTSbLDf9nI/doSAb5vAwGshs5/Hlv5Ce25b246lBkbRd/77fLAi+uMI1a70mJzVyLyCEufQ==} engines: {node: '>=20.0.0'} @@ -4480,26 +4503,42 @@ packages: resolution: {integrity: sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.44': + resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.38': resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1079.0': resolution: {integrity: sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1116.0': + resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.15': resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.8': - resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} engines: {node: '>=20.0.0'} '@aws-sdk/xml-builder@3.972.33': resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.3.0': resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} @@ -6481,6 +6520,10 @@ packages: resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6493,12 +6536,16 @@ packages: resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@29.7.0': resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/expect@30.3.0': - resolution: {integrity: sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==} + '@jest/expect@30.4.1': + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/fake-timers@29.7.0': @@ -6517,6 +6564,10 @@ packages: resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/reporters@29.7.0': resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6534,10 +6585,18 @@ packages: resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/snapshot-utils@30.3.0': resolution: {integrity: sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/snapshot-utils@30.4.1': + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/source-map@29.6.3': resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6558,6 +6617,10 @@ packages: resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@30.4.1': + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@29.6.3': resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6566,6 +6629,10 @@ packages: resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} peerDependencies: @@ -8851,17 +8918,29 @@ packages: resolution: {integrity: sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==} engines: {node: '>=18.0.0'} + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.5': resolution: {integrity: sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.2': resolution: {integrity: sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.11.3': + resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==} + engines: {node: '>=18.0.0'} '@smithy/node-http-handler@4.9.2': resolution: {integrity: sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==} @@ -8871,17 +8950,17 @@ packages: resolution: {integrity: sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.15.1': resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -9432,48 +9511,48 @@ packages: resolution: {integrity: sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==} engines: {node: '>= 10'} - '@tryghost/api-framework@3.3.9': - resolution: {integrity: sha512-G05CO9tbwQnLWGlpC7c0tANQlgdQbJUK9l/j0o4ykfqz6d0W/uZ7avvniU2TKFO24D1pA+POZskZhigqMTCzDA==} + '@tryghost/api-framework@3.3.12': + resolution: {integrity: sha512-L5Q0ZOyYPlZijdo88TTlgpWWhcwvDzfg7ZJz1uzpQRPhzE2Mjo99dQyx+5JGfYhnpnR2mwfcrQStvF34FEMLkg==} - '@tryghost/bookshelf-collision@2.3.7': - resolution: {integrity: sha512-CIrIW1BhzyBaybKTKF7Ly6jcnx/AOzCmH1rrLlLhxUjW/5tRZt7qHfXgH7r/SbYbzR8+CVuljq1jGA4vqjM9SA==} + '@tryghost/bookshelf-collision@2.3.12': + resolution: {integrity: sha512-QawYGMPRdxaaYIauiFT4u7/lTI2KB+vl+cHHd8phrQuErJSgIyXCulpJcD6kSGAX9MD4W8IU74YZtwIso0VgMw==} - '@tryghost/bookshelf-custom-query@2.3.7': - resolution: {integrity: sha512-i9P24QcI4SadAkFfIwRr5RfK8c0EFOhRlGka0gkXecAOOsnRLJEK36/+f+7eOfVbhdOZq2drOg6zVBAkBjv4HQ==} + '@tryghost/bookshelf-custom-query@2.3.12': + resolution: {integrity: sha512-9hIdVz+fj+9w6wWrC7/zs/UVD04bT/4SVCeyn6R/HqM0BGdGDELTeRL5BtVMyZQ4YIJgK7eg3/WkUEv1o0z7vg==} - '@tryghost/bookshelf-eager-load@2.3.7': - resolution: {integrity: sha512-k3eB8Xe665PpZxx+t/ycIh0rqQt9XhV1BtnqCyNWRzgIvnTEcrIx0vQqxUQMOOAEtMoZ0s5rFAqGVzDo1H4KjQ==} + '@tryghost/bookshelf-eager-load@2.3.12': + resolution: {integrity: sha512-va68hgNz08GcEpG3mt8fyIWNx0I83HLk4OxUYvwRxacf5y52A33xQTQ3pDWWxEFd3XcbytOjU9xlpctAu9G83Q==} - '@tryghost/bookshelf-filter@2.3.7': - resolution: {integrity: sha512-lR51ryZO4NCNvzVcWv6b4CHDpp41XhflbltwdvJZdXgEej5dIf9kYSZ8km7Mrw8CEbZrGqoPUdiAdCxTgeKUZg==} + '@tryghost/bookshelf-filter@2.3.12': + resolution: {integrity: sha512-IraUXBhsPJfNf3lAr8pYCo4AFAjNVYAQlGVyGFZtdIMdg03rWoWcRAbHle7ozP58hBMwG6jg5h/Qt6dHZLi7Ag==} - '@tryghost/bookshelf-has-posts@2.4.7': - resolution: {integrity: sha512-uWdsQKosBvKhMZnKLArBmK791qH13o6oNnTMcpM9dDRBNWDW3IJBW3XYnZUTJmjDg9HnJ7t5zm7wZxOnKwwP9Q==} + '@tryghost/bookshelf-has-posts@2.4.12': + resolution: {integrity: sha512-ZwN0/kjMx/lrhJJcER7B80Wnz1qJpqbBLSn9VLtoygbNg4xufVTTrb0iBqHTCu2vqHVTQuMXj8sZxTOwlSyHfA==} - '@tryghost/bookshelf-include-count@2.3.7': - resolution: {integrity: sha512-opncKLBLPtc4/lLgMx+fIhXdjDX34V+4a2OYp259eDsKyFOnrm4H1vYdyxy/VIw8d/WnRy7KpdYA9xlJgZhDsQ==} + '@tryghost/bookshelf-include-count@2.3.12': + resolution: {integrity: sha512-PsnwMetXAPEt5ASWfLFU+j+ST9cBZ42WRoaeRCFVCQwHvWrn/LF4womQNQGwH6+byk9agC9JpjSniUGAkkXFZA==} - '@tryghost/bookshelf-order@2.3.7': - resolution: {integrity: sha512-pX4+8ne1W0oo5Kw98gvvgWL7RgzeywywnydnvGe6B48QuSt0NheFqKQ30g9VKZiCanEI7vkyKs9NZkoRTbY9Sw==} + '@tryghost/bookshelf-order@2.3.12': + resolution: {integrity: sha512-cWd+N1y7nkb1CuPBO+h7I70mkwDFskyseTjJX9oq9erVcz+fJzkHwf0Bb4fn4FUuFXy+75VSwArz5aa/nvClHg==} - '@tryghost/bookshelf-pagination@2.4.1': - resolution: {integrity: sha512-oEFuybdPkEhpadvOBUXM83BgVKyKaiFpdALOUqFHR4fk1xHA4vWgOE75ajntXcDUBUmmTSRW8k/4XZDofFWTxA==} + '@tryghost/bookshelf-pagination@2.4.6': + resolution: {integrity: sha512-K2x5D7P9ipf9QxNmyyVSMdJExD3BlBL4FZ98NmdlxqW6bxSXmAl4Avi8xAw7AWq3G3JKtODvCHZrRZIpwW3qHg==} - '@tryghost/bookshelf-plugins@2.3.8': - resolution: {integrity: sha512-CSEMOYJtBqXOxE1JccvYcktPDiC1xAXrYQAYSGGoUPXj/5zIRgU0Lr9z5WfmX4AmxEnpTPpFQl1O/9EoDrsxXA==} + '@tryghost/bookshelf-plugins@2.3.12': + resolution: {integrity: sha512-MmkYPwfGj6kjkl7IrzNPrpMoyi8A8LF4mqA1/1pANwuXi4kSCQ2TtoFnFvxI7eg0njwCmnM0c+jjO8orXD7gKg==} - '@tryghost/bookshelf-search@2.3.7': - resolution: {integrity: sha512-MAy/aLA72BGIJ7lYoBkCooJMpXGcnCRWI1+QwPDtszNdC4lawDOgBANyEmBzUFH+YveCkK0RAzDPCV4UO0xyPA==} + '@tryghost/bookshelf-search@2.3.12': + resolution: {integrity: sha512-DrAnqHX3re3tI7mKEDeF3xXZBWHQOYHygVGMYQAEdgzfDR3O+aA9hxBkepDQAZxrTg9nwqJvCVzhe0QAkYFMqQ==} - '@tryghost/bookshelf-transaction-events@2.3.7': - resolution: {integrity: sha512-m9SP1f999C+pO0HU6wzEVhGAIijXWcv4A4EFooHwTqyYszJTXxSwyzyw3u2dXMaMLHLKO9+iFotlaOBPLZN65g==} + '@tryghost/bookshelf-transaction-events@2.3.12': + resolution: {integrity: sha512-7Fegy8GZplUP4Q2n7LYodZgUSVORGTm9D2nnHSwaPvqvev0Vk4OOqdsaN4y6/d5mcl+bcKXwJbWvawyRV1LSsg==} '@tryghost/brute-knex@3.2.2': resolution: {integrity: sha512-wdhqZV5klysUO+0ZthzxkaWiSxqKKoJm7rkqby3llt5FHZTU5yiaJ55yraio9knm5MHqfDd+IFspNTKEjAnsIw==} engines: {node: '>=20.20.0'} - '@tryghost/bunyan-rotating-filestream@0.0.15': - resolution: {integrity: sha512-slSrnLFWWDoNsPsyXDyf5+mG2gULzQb//Ntp60NDEWu40yq1OVEJsbd8Jl+1F34cKQyoFBD1pm+J6srH+qOr8Q==} + '@tryghost/bunyan-rotating-filestream@0.0.18': + resolution: {integrity: sha512-aAq68VoZaMJMjYXEkHcFQTmfWjUV4msYvEhvTciNEJ8gkcti8eUANr3bGTtHmccVDPlxfAadc7v4gleIPxJ4UQ==} '@tryghost/color-utils@0.2.20': resolution: {integrity: sha512-0KLCQDX7TbJGKNihs+OB1+3hEvhdP4YXOKpdbqJlUkvEFdY+Gl1FlI4WL8wnn0v6oc1aOjvrKmva5BUT0pWtIg==} @@ -9490,8 +9569,8 @@ packages: '@tryghost/database-info@0.3.35': resolution: {integrity: sha512-S9OapApwzdh3GS0d3m+KgwH7IhZII6b9Aw5I89HA5VJRPeo6HdaDXiZzSDNcFaUzpx1FFBR0kDu7G+IRPD0eFA==} - '@tryghost/database-info@2.3.2': - resolution: {integrity: sha512-v8DFbv2IrGfCd45akvbjKzZ26tTAqaX4FE7sGYv0aFrj98mEfwedjCd9sc6tjJxOXGNWplAZDAeZo5dcwkbxDQ==} + '@tryghost/database-info@2.3.12': + resolution: {integrity: sha512-DEk3RH0EitMoIOLjnoM1oafjFE0Wc9pOqBns7HYVUCwVmDziVfj+N/Ca77+EeGUMey0BfCiY7hhg5uGbIRVx6A==} '@tryghost/debug@0.1.40': resolution: {integrity: sha512-r8ecoTeoickPsn/59tkrouCNHhUEy79MNhJdPNkhx/Q3Oevw+SQBjnVYa5mHaxTaXryM4nNguKM5fbxPGPBo3Q==} @@ -9499,30 +9578,27 @@ packages: '@tryghost/debug@2.3.1': resolution: {integrity: sha512-m35yRGwmmmvHWzs42qJnf0duCvi5Yd456bPa436Gcldv/rxK5YMPehtGDrwjHstJ4K2If0oguXsdHJf3tjgAjw==} - '@tryghost/debug@2.3.7': - resolution: {integrity: sha512-qS8QrBLNdDTu1DBgx/RwnNeF/7abDvT1iRZ+bDJ95TIj6gKcHbQN2gQs0rLhZLsnxQs2aC4arb0QjHRy8JyazA==} - - '@tryghost/debug@2.3.9': - resolution: {integrity: sha512-nWwKuyJIzCzBFbTMh2bcGAocqSeMbjPifb56AqqZybt0KErbuqrw+RfYox92UXTCQR/TLMhPmBq9ccHD0OG33w==} + '@tryghost/debug@2.3.12': + resolution: {integrity: sha512-x7gtwoO4fmG7LbP3W/RP4C6n4BpBdyf6wpsasIG91E+xtEkTd7krWIXWwcBg2Q+SwUF78O4qFfOejZDtMjH10w==} - '@tryghost/domain-events@3.3.10': - resolution: {integrity: sha512-Kh23N3SOCNpp0ozkgAmOt6qMKMwHjXPeXOvAhHAsA8ybaO0t+uEH1KJUn5xjCwptTu9JjgJ5YD3aiqovB8gdxg==} + '@tryghost/domain-events@3.3.13': + resolution: {integrity: sha512-7/JGHHkogFfmYo7tTc0K+wZzaU5p5OOCliuDMF6D+2A0CWWBb3wTO/HleAr3lKOFYpUsAPJ3CqGzzSI+rjWsVw==} - '@tryghost/elasticsearch@5.4.6': - resolution: {integrity: sha512-7bE2Nm4XfXhlOiVSxVV4FrKyHMxbd/NxNHnowe1vXyKzQp6ExWVNdrgPFnUUYoUyT1THz20HMY7SB0bUje1ZLg==} + '@tryghost/elasticsearch@5.4.9': + resolution: {integrity: sha512-lK51E9rn/ROEY1JQxd1cuy+ZlFLc99b0bcspx3ow3g8khG9te060jm4jqqIqlH/yeaClkih6wlopjSVJhdiWwQ==} - '@tryghost/email-mock-receiver@2.1.0': - resolution: {integrity: sha512-bgEVM5eRnN53rnqWJ8ZkjuGCzMwwt5Ch29TTXRp3qSGTJruUW44DtN/mjB36DDti0hq4unJV9+D1qIIKxdbong==} + '@tryghost/email-mock-receiver@2.3.12': + resolution: {integrity: sha512-9ucMMPTPAfsqaMgki+CtjVkqZ3V04Dkm+zYw/vSIf0NUoYxXsgsnXJPyix90jWp4BMuXmtLJbX74MoEZ+c9ZRg==} '@tryghost/ember-promise-modals@2.0.1': resolution: {integrity: sha512-py/Fi3jbr+UiUped74m2VRhNEJQrFSgPEhQu/FSHkPjUPWruIokwJoO4TDedklGcMJ+0RKr3YiigcuMFxXjf7A==} engines: {node: 12.* || >= 14.*} - '@tryghost/errors@3.3.9': - resolution: {integrity: sha512-hcrIXQoWZV0Th8DwndC8bA5BcCcgd7Zw0anAYv6ys/SsKlDMmpzZplECC3pEL8HKI/Cz05/TXkQJiBNrTK11/Q==} + '@tryghost/errors@3.3.12': + resolution: {integrity: sha512-fjZp4K2u7ic5zDJ1+i+wru2+9URcbqEmYDzS6PZsiXr3z/gI1QSkwGvpVdoBHnyDEWCorR5KTqXts8Fg9H9L4Q==} - '@tryghost/express-test@2.1.0': - resolution: {integrity: sha512-gY2AeFzBLvDtDCdc5rE54crDeEC9TzVboViUTmP1Vwbt35BA3O177Ox2j6vb/LQ9HCQ1tp3DOa0hbNYZFaaJAQ==} + '@tryghost/express-test@2.3.12': + resolution: {integrity: sha512-zNf2BUvaEYXk1dm7/vi8gfXV2U7deMdq3XsNbLsIC6Y6WVnb+vCs735Lqrt4R8uwf12VzNkI0VWEbFIgNMYF1g==} peerDependencies: express: ^4.0.0 || ^5.0.0 @@ -9535,14 +9611,14 @@ packages: '@tryghost/http-cache-utils@0.1.25': resolution: {integrity: sha512-OJAD1ESvV+F81w6IOTKCX0JLGIiJqNn87HVFMskpUC3IUGpqcDP1pCM79d72khxLWy5oRQDYu8xIqicHJ9ST1Q==} - '@tryghost/http-stream@2.3.10': - resolution: {integrity: sha512-qvod+MltdcQhcfii1B+VpNsJD9pi/1QYqdLckH0Z5DnFD3euDgB7cys+dr/2XS0TgxQc9Gj7cHZDl0OOtKe2RQ==} + '@tryghost/http-stream@2.3.13': + resolution: {integrity: sha512-FEvSnVQeWkp8oGDO3Ak60B/zazuJj0MTvw87Co3/1rGamYYtuCRAU8/VB8eP1GIT2jyYmFmw6VJ8NzCZpZmdgw==} '@tryghost/image-transform@1.4.17': resolution: {integrity: sha512-+WipAbQ6gTOB9hbhmTDHW7tzaB1lPIRszoN+z1qV+vlI+cR/4qO70Ux+HkSYT9gt4WpxRZJZCBCk3+x4Nm0LhQ==} - '@tryghost/jest-snapshot@2.1.0': - resolution: {integrity: sha512-oOi91KgOVnHzMgFTM6yEBA2jjpVBx0tbE1HJYmmoQ6UYt0rB7giUg1fMFcEvbfXu2DAng1mYKQysrf8EJfHslQ==} + '@tryghost/jest-snapshot@2.3.12': + resolution: {integrity: sha512-PyjtM7O5tlfafhQbr2Axjmao8DGmoLxvEmvsPKafUWQIj5NX1DjDboAq1iOxlnIjBIVDZUxig1UX//EjAZF8fw==} '@tryghost/job-manager@1.0.9': resolution: {integrity: sha512-Wlwr1R8oeU6HLB7RB1g6VxAH6VEZIaXTVV2GdvCbipK+TA0MIo6YOQUgrIky25RpyRqKXR0UuDIfXDB4+S+GgQ==} @@ -9561,11 +9637,11 @@ packages: '@tryghost/limit-service@1.5.6': resolution: {integrity: sha512-wR+Hm2v5k2FjOUhLhfPZ0p0acCfaTQoSEhuw96uw8c15CLZtm/HKsLnxA5d3AXwYmjRo5gkYskP8UYxrCKNcoQ==} - '@tryghost/logging@5.4.0': - resolution: {integrity: sha512-H0jWmJBJ0EEwBkq+SErWt4N/LQwwdYCfQcUT3a5RZbF5bcZTRcWnGfSw5aPBIphra9BaOHCiCYeQbyg5wcJkBw==} + '@tryghost/logging@5.4.3': + resolution: {integrity: sha512-VXJvLI1F5EwZ+4aD+TBqC0nvs40VSc+oSMBvp+ng4hJ/DaN5W0AQXMouQ3zu+6OT1VZKvs7FPTfS1ILgN2GCsg==} - '@tryghost/metrics@3.5.0': - resolution: {integrity: sha512-fBIKCodafcG23Eq5rjhmixI+CSzVZ555jNqj7tdDeCXtfVA9ws3X3H3dNo7jgxgZs2SbC7ElYbhBSj4dHX8RKw==} + '@tryghost/metrics@3.5.3': + resolution: {integrity: sha512-1RXDXzsoOjlabcyDHxQWcxnDhJSAcmY4Sy0LpdAj4xmdi6CNgZhYW+/kmm3aDXwqEOVUJlFWhOvrwlDxbxGE0Q==} '@tryghost/mg-clean-html@0.12.6': resolution: {integrity: sha512-ped/cNQdKvcX4F0B8Z5j0VnLuFjyHthElxT4Gny2yWtPHczvFsbVQXlynuHPpQcM3ZLE6o9lemm7Bin1uG/whQ==} @@ -9588,8 +9664,8 @@ packages: '@tryghost/mw-vhost@1.0.6': resolution: {integrity: sha512-FKoSdBw5+fZNbzC58LD3ZLbMEj2UI5+4XOnEau7xINuYBP/c4wN0TXHuJT8y6H1wnYvO4Qu9L+PwHxe1yHBA+Q==} - '@tryghost/nodemailer@2.3.1': - resolution: {integrity: sha512-sIek+jnYoHWRbsvXgjA+v1sBiiXS7Gh75iW2fl/jo+ED1H7B0i+svSMTfKwLpDRlGrUTMP2/swvr8EBb59kC4w==} + '@tryghost/nodemailer@2.3.12': + resolution: {integrity: sha512-uEbAazB4v6gQmXp+wrVyaLyiTKatlWR3nc2l5rBd3FRb2z/6ncIllWCMrKsWLHxRcr9sazgmP4LQrmgimS3CNQ==} '@tryghost/nql-lang@0.7.0': resolution: {integrity: sha512-S3P66JRL7kiKhYR7VnYC5S5xnkKqS+A5aW60nCGi/lraAhk/G5+xmPisztQiJWbHxM1odIagt2GpDMbKCDXT2w==} @@ -9600,11 +9676,11 @@ packages: '@tryghost/pretty-cli@3.3.1': resolution: {integrity: sha512-P7/GffeBG0grjmFxMWEeVFONL6HGt1mWUZvI5G36mhlxC/AXSWCSS6+qiQU91ZfenTwBrP6LZRC9Pyt0gqfzLg==} - '@tryghost/pretty-cli@3.3.2': - resolution: {integrity: sha512-JOWoEwrdF+flxZBWq+ikww5BRvU95jfUzUnx25pkrSNJEdTYWzJj3bsTGt7LravWCN41M9y4+XvYwEp6cfawpg==} + '@tryghost/pretty-cli@3.3.12': + resolution: {integrity: sha512-0NKZZXJfhnBzIkNORdGYVmFds8O/N8OZWvt14CM3twJp5CejGY5/0tktNjMDDXIXFVhzudHjjMkrEywU/bp1tg==} - '@tryghost/pretty-stream@2.3.9': - resolution: {integrity: sha512-5bq3xDBmLm1auQzsuoIxxwimuQFSnTia+rV9kQ6HmId4GRCbZrqcQOx7y9MVe2kwAUkdHqQrkSZkxoNebCiaRQ==} + '@tryghost/pretty-stream@2.3.12': + resolution: {integrity: sha512-NegQcNCOCpAoYD7GkdM0NJljYfzVb23Ifaph8cMfUa2PEvJYD8cwPB3xXZspGECRZTVyTYixe6MDxx58tNmyLQ==} '@tryghost/prometheus-metrics@1.0.8': resolution: {integrity: sha512-E1LRRwLgiWIN/P20uHgpTK+JVYYQ3+BarKCBszB6qmK5IBANJvQHI3LQV0wDWefVwFyl5FI6AqxLpOOpquvahA==} @@ -9612,18 +9688,16 @@ packages: '@tryghost/promise@0.3.20': resolution: {integrity: sha512-afHCBoS72XabqRi7GmHdVAWC/UMsVPGlxnL/epNVp0c/C7tEn6+MgHABXNAW4wViZFhuOda3k7fk3i2K0FAZQg==} - '@tryghost/promise@2.3.9': - resolution: {integrity: sha512-O/d4kTvvHrD/T9Czfhl057uPCJVQulfcQl/XsFHrWOhXewZUc6rsmqTFo6zz9Iv5NRye+SicsUya7BSJFSgKCQ==} + '@tryghost/promise@2.3.12': + resolution: {integrity: sha512-BrTvKB0o+2GW9Yjf0c4s0HSja/Xz9CxZSKYoJ+u9h/2+OG99KqxYVQDzUs1HVfI4aT8QkE0dflUBePi1zY1olA==} '@tryghost/referrer-parser@0.1.21': resolution: {integrity: sha512-Uz7bj8IadLah1j9GgieGQ0VGytdOG6mIlYdbpNu1r8/o9pjR2IS4bt88tvwpGSPNb47P9NrGntGn+Le1W/90jg==} engines: {node: '>=16.0.0'} - '@tryghost/request@4.0.2': - resolution: {integrity: sha512-43mgzdOSd09u7Ek9nIjgk1MIkYteZ5FmiS4UYc+H82G8ci5RnVvkW2qplaNYK8Rpih36KxxjNIb7SglX6IL+kg==} - - '@tryghost/request@4.0.3': - resolution: {integrity: sha512-vcKSJua/W9zjUQrH7jw/16t8dB6UbEbwCFMiFFxMY0uXcn2IbYEJuxmHOg0aMhqpOc11lAk714rDi3K+RNoweQ==} + '@tryghost/request@4.0.5': + resolution: {integrity: sha512-1I4cmpcu86cfq6R+4pfFxfqa5nkIj+vbprkIEYlvLvhYQj7B2BYaI88VV3A3jqfQc3dh7x/OYeF2udaVrhKpUw==} + engines: {node: ^22.12.0 || >=24.0.0} '@tryghost/root-utils@0.3.38': resolution: {integrity: sha512-ARn8wC6qv867lCr7BZ+IS8S/88K5go0j6HgQFhP27rKje4b40PsxH/P3rO4Ez2NzF/Do8ywFrTWHoLCSsCazXQ==} @@ -9631,17 +9705,8 @@ packages: '@tryghost/root-utils@2.3.1': resolution: {integrity: sha512-1g0HskTTDJzn3CzcEWhd4aKtcmPm3IztD7cyfQQiG4QX+6gfpPOkYk7Y5gDOwTKAYtfor0nc8npSY90vvIj93A==} - '@tryghost/root-utils@2.3.10': - resolution: {integrity: sha512-QqOj7YQxG91ovizRFKUn5f1AHZn5pdkgxcz8Qbg2M9QRJvACRVF9Pxst/yGoWmjdWRchpaDbfl7QipTOT/jH/Q==} - - '@tryghost/root-utils@2.3.2': - resolution: {integrity: sha512-tUyMS4xJUceE62JjZIjWAXCeoVlbNHWF2dIy6xJkaYgeHw4oZ5YoKMu9Mzqa7TPHIapU+BxwdWV0HvMj9dZGlA==} - - '@tryghost/root-utils@2.3.7': - resolution: {integrity: sha512-8HR0W95it+s+ERkRI3syWHDLyYcinBFexpVlmLBhMMf8yEcL7r6tio6H3sUAj09L/YE8Kk5uGhvjAZAT3TU77Q==} - - '@tryghost/root-utils@2.3.9': - resolution: {integrity: sha512-jhnWp6PVubyPoc3BDJSeQ7rX7kC73X7Asp8gv8mCosae9/ciBST95SND/Kblh8FI5yGwuENN8b6Z2w+YBl13FA==} + '@tryghost/root-utils@2.3.12': + resolution: {integrity: sha512-CyMKL8updcL41ZhNODJj21uSVq9npGDU5INnf22AGePi3kCVSDcOlPlJ4R+0bqE7KzDlTwp3wcn+ZmFX4EKiqQ==} '@tryghost/security@1.0.6': resolution: {integrity: sha512-h4FiUK4ndHezlXeuRzfwTZrX/a8ZdCS/FRqmZWCHcMUiBH6lewHv8eIjsPemN6e6Gn9jtsWZsKnuYM2mD0meSQ==} @@ -9665,17 +9730,8 @@ packages: '@tryghost/tpl@0.1.40': resolution: {integrity: sha512-8w94lbNxXptRCIS8pe/IHjzgVFLxljxXx8+UQLO8NRFKraoEXthkPjAphN2MXKp1UGtj5ldh4Ye4D82bUWceOw==} - '@tryghost/tpl@2.3.1': - resolution: {integrity: sha512-qqa2SvhnBVKFYN+G4hfj2cYZuZXINBDyJ9cTzNBtev/FRNUKniyGarPKAkyb6ZBtvp3Nqlmyvrqe+SFoqHcfrQ==} - - '@tryghost/tpl@2.3.10': - resolution: {integrity: sha512-jYUMBtCDc7Ok9m7VbdaL5znxhb4cWpRlnoCHZKTtxnP/D5Kt/WWwuV8fBRabqvdpPABkGlMm+momDAKMwst3lw==} - - '@tryghost/tpl@2.3.7': - resolution: {integrity: sha512-TIDQF9tj4MaQKrIxNB8CxpElShAwLfyByozggIBnuzIVkGudX07gy/o9oWGRYslRselrvdByf+NpYonQ9j3Y0Q==} - - '@tryghost/tpl@2.3.9': - resolution: {integrity: sha512-55bUKq9vG9YvJNtrJKptv2zZoLYXxGUPnX7q6mV6cuZEwxZaWY0uukcs2l3oCG9hollXj0pKJW4KdulcuYi20w==} + '@tryghost/tpl@2.3.12': + resolution: {integrity: sha512-4133W8SPCGd6xydaexFecLu/7poOyzgFEek+dYCZfeODldDGwu3y+a7L/veQX06ojZXGJeLUJ1irYzu/3fKbSw==} '@tryghost/url-utils@5.2.6': resolution: {integrity: sha512-3TQRcseZW/rq3UsSMP94n6WgKYZJqufreNdv9wJT5P4Fm5bNQ04rEHvL8i1AggXWa2kUPmwt2ed7KzNY6xx7zA==} @@ -9683,26 +9739,21 @@ packages: '@tryghost/url-utils@5.2.7': resolution: {integrity: sha512-enJ084BtVwsr+q+1wz2hy/jjooBfDLh4FS4g2o8n1ZAgGAly4mch6w7IpDX6MjRcy6an/S1i/Jo3tMsHa2igfw==} - '@tryghost/validator@3.2.10': - resolution: {integrity: sha512-mk4bexOTmO4xaGiTvHXwCQIH5okcR3XOA3zF2vSMu89lpwZ0QJPXEaooHZUdLLBGOwuigz0iLVyiNeC58OAyrg==} - - '@tryghost/version@2.3.10': - resolution: {integrity: sha512-4KHPqEZwhyixUFfDiNCoJ6nCjmLrX5VwqrPcAsNQs82W2chkaysvjZU9rXFNULpapRcYu0Y7xX/Sn2JJDlbriQ==} - - '@tryghost/version@2.3.2': - resolution: {integrity: sha512-tBW8gJKLJTUsrGdt/mM1k28Vy4DRTQF7tfZPUf9DQ0KkTgFc+5Z2WipGa50dOaicGysM15imOq7AK7IfXddCHw==} + '@tryghost/validator@3.2.12': + resolution: {integrity: sha512-3zuGfLCzcWK0DgF40vaWzlLFaPPUDd/+IriCHzrpJgGCb/mMbQh/QbEqouABneH5PSB/D6CWLVrqnm9+Kjpi9Q==} - '@tryghost/version@2.3.9': - resolution: {integrity: sha512-jRBIcJuU/ljnTmpGnWZfzUyj+KY/9NChAD+tlkqI9ZQoSn4/VZFnZn8zGb0cRI245wzIi2YTjZokO0RJutqvTg==} + '@tryghost/version@2.3.12': + resolution: {integrity: sha512-svCEBVRHCLdcjpOFJ6PhqlcwfqbEvkCv7d/hHopUbfIdl/Ipb+FhAhuw7C2O4tw+Tm+eugA/FwPKJeFop0TIsg==} - '@tryghost/webhook-mock-receiver@2.1.0': - resolution: {integrity: sha512-Cka5SW4igfgbGv33fFyEjkgt1HCA3lNhKCL88ThsDCw3/9ROxKQLcHMB+S7SNcewIDfsS5kI2INHtD+1Tbp/BA==} + '@tryghost/webhook-mock-receiver@2.3.12': + resolution: {integrity: sha512-pr237E7u+meWrqlBRW6vERCXNC8TXyysx4fc7vkkCX6Gfcq9euhusJliUc1YR30xf7r6KIsv+sE9bZDfeCxm2g==} + engines: {node: ^22.12.0 || >=24.0.0} '@tryghost/zip@3.5.0': resolution: {integrity: sha512-igHHPyBasmo+MWM+l8qtWWX/cdHlAQps5HbCfESi4zGPlkTzuScHfmFAnuXsCx+MZKP5LozgnQHOVIf6jkaU8A==} - '@tryghost/zip@3.5.1': - resolution: {integrity: sha512-tj+yu8c0OjvSJY6ivtIsOxMzma7pksGcf4cCN0zdvzZdLD0ImuQTkEjqUD4v8rlQ0pL/jjh4W7bKW9Gmjun9vg==} + '@tryghost/zip@3.5.11': + resolution: {integrity: sha512-eTINMqgZk8XA3cG0jczLlcllM6dUPN6eukxkqy1PBHPMkg2IHZu1wnEFUrwc7YFCmF3Y7XZf4JxxJIKlPIS9bA==} '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -12156,6 +12207,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -14603,6 +14658,10 @@ packages: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -14988,10 +15047,6 @@ packages: resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==} engines: {node: '>= 6'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -16462,6 +16517,10 @@ packages: resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@29.7.0: resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16486,6 +16545,10 @@ packages: resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-leak-detector@29.7.0: resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16498,6 +16561,10 @@ packages: resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16506,6 +16573,10 @@ packages: resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@29.7.0: resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16514,6 +16585,10 @@ packages: resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-pnp-resolver@1.2.3: resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} engines: {node: '>=6'} @@ -16531,6 +16606,10 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-resolve-dependencies@29.7.0: resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16555,6 +16634,10 @@ packages: resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16563,6 +16646,10 @@ packages: resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@29.7.0: resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -16583,6 +16670,10 @@ packages: resolution: {integrity: sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest@29.7.0: resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -18454,8 +18545,8 @@ packages: resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==} engines: {node: '>=6.0.0'} - nodemailer@9.0.1: - resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} + nodemailer@9.0.6: + resolution: {integrity: sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==} engines: {node: '>=6.0.0'} nodemon@3.1.14: @@ -19838,6 +19929,10 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} @@ -20231,6 +20326,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -23217,32 +23315,6 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.15 - '@aws-sdk/util-locate-window': 3.965.8 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.15 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.973.15 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.12': dependencies: '@aws-sdk/core': 3.974.27 @@ -23265,18 +23337,16 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/client-sesv2@3.1073.0': + '@aws-sdk/client-sesv2@3.1121.0': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.27 - '@aws-sdk/credential-provider-node': 3.972.62 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.0 - '@smithy/fetch-http-handler': 5.6.2 - '@smithy/node-http-handler': 4.9.2 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-node': 3.972.81 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 '@aws-sdk/core@3.974.27': @@ -23290,6 +23360,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.977.9': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.53': dependencies: '@aws-sdk/core': 3.974.27 @@ -23298,6 +23379,14 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.55': dependencies: '@aws-sdk/core': 3.974.27 @@ -23308,6 +23397,16 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.60': dependencies: '@aws-sdk/core': 3.974.27 @@ -23324,6 +23423,22 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.15': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-login': 3.972.77 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.59': dependencies: '@aws-sdk/core': 3.974.27 @@ -23333,6 +23448,15 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.77': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.62': dependencies: '@aws-sdk/credential-provider-env': 3.972.53 @@ -23347,6 +23471,20 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.81': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-ini': 3.973.15 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.53': dependencies: '@aws-sdk/core': 3.974.27 @@ -23355,6 +23493,14 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.59': dependencies: '@aws-sdk/core': 3.974.27 @@ -23365,6 +23511,16 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/token-providers': 3.1116.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.59': dependencies: '@aws-sdk/core': 3.974.27 @@ -23374,6 +23530,15 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.58': dependencies: '@aws-sdk/core': 3.974.27 @@ -23394,6 +23559,17 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.44': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.38': dependencies: '@aws-sdk/types': 3.973.15 @@ -23401,6 +23577,13 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1079.0': dependencies: '@aws-sdk/core': 3.974.27 @@ -23410,13 +23593,23 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1116.0': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/types@3.973.15': dependencies: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.8': + '@aws-sdk/types@3.974.5': dependencies: + '@smithy/types': 4.17.2 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.33': @@ -23424,6 +23617,11 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.3.0': {} '@azu/format-text@1.0.2': {} @@ -25805,7 +26003,10 @@ snapshots: '@jest/diff-sequences@30.0.1': {} - '@jest/diff-sequences@30.3.0': {} + '@jest/diff-sequences@30.3.0': + optional: true + + '@jest/diff-sequences@30.4.0': {} '@jest/environment@29.7.0': dependencies: @@ -25822,6 +26023,11 @@ snapshots: '@jest/expect-utils@30.3.0': dependencies: '@jest/get-type': 30.1.0 + optional: true + + '@jest/expect-utils@30.4.1': + dependencies: + '@jest/get-type': 30.1.0 '@jest/expect@29.7.0(supports-color@10.2.2)': dependencies: @@ -25831,10 +26037,10 @@ snapshots: - supports-color optional: true - '@jest/expect@30.3.0(supports-color@10.2.2)': + '@jest/expect@30.4.1(supports-color@10.2.2)': dependencies: - expect: 30.3.0 - jest-snapshot: 30.3.0(supports-color@10.2.2) + expect: 30.4.1 + jest-snapshot: 30.4.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -25864,6 +26070,12 @@ snapshots: dependencies: '@types/node': 26.0.0 jest-regex-util: 30.0.1 + optional: true + + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 26.0.0 + jest-regex-util: 30.4.0 '@jest/reporters@29.7.0(node-notifier@10.0.1)(supports-color@10.2.2)': dependencies: @@ -25904,6 +26116,11 @@ snapshots: '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.49 + optional: true + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.49 '@jest/snapshot-utils@30.3.0': dependencies: @@ -25911,6 +26128,14 @@ snapshots: chalk: 4.1.2 graceful-fs: 4.2.11 natural-compare: 1.4.0 + optional: true + + '@jest/snapshot-utils@30.4.1': + dependencies: + '@jest/types': 30.4.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 '@jest/source-map@29.6.3': dependencies: @@ -25974,6 +26199,26 @@ snapshots: write-file-atomic: 5.0.1 transitivePeerDependencies: - supports-color + optional: true + + '@jest/transform@30.4.1(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1(supports-color@10.2.2) + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color '@jest/types@29.6.3': dependencies: @@ -25993,6 +26238,17 @@ snapshots: '@types/node': 26.0.0 '@types/yargs': 17.0.35 chalk: 4.1.2 + optional: true + + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.0.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@typescript/typescript6@6.0.2)(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: @@ -28262,20 +28518,39 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.5': dependencies: '@smithy/core': 3.29.0 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.2': dependencies: '@smithy/core': 3.29.0 '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': + '@smithy/fetch-http-handler@5.7.2': dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.11.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 '@smithy/node-http-handler@4.9.2': @@ -28290,18 +28565,18 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/types@4.15.1': + '@smithy/signature-v4@5.7.3': dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/util-buffer-from@2.2.0': + '@smithy/types@4.15.1': dependencies: - '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@2.3.0': + '@smithy/types@4.17.2': dependencies: - '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 '@socket.io/component-emitter@3.1.2': {} @@ -29024,83 +29299,83 @@ snapshots: '@tootallnate/once@3.0.1': {} - '@tryghost/api-framework@3.3.9(supports-color@10.2.2)': + '@tryghost/api-framework@3.3.12(supports-color@10.2.2)': dependencies: - '@tryghost/debug': 2.3.9(supports-color@10.2.2) - '@tryghost/errors': 3.3.9 - '@tryghost/promise': 2.3.9 - '@tryghost/tpl': 2.3.9 - '@tryghost/validator': 3.2.10 + '@tryghost/debug': 2.3.12(supports-color@10.2.2) + '@tryghost/errors': 3.3.12 + '@tryghost/promise': 2.3.12 + '@tryghost/tpl': 2.3.12 + '@tryghost/validator': 3.2.12 lodash: 4.18.1 transitivePeerDependencies: - supports-color - '@tryghost/bookshelf-collision@2.3.7': + '@tryghost/bookshelf-collision@2.3.12': dependencies: - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 lodash: 4.18.1 moment-timezone: 0.5.45 - '@tryghost/bookshelf-custom-query@2.3.7': {} + '@tryghost/bookshelf-custom-query@2.3.12': {} - '@tryghost/bookshelf-eager-load@2.3.7(supports-color@10.2.2)': + '@tryghost/bookshelf-eager-load@2.3.12(supports-color@10.2.2)': dependencies: - '@tryghost/debug': 2.3.7(supports-color@10.2.2) + '@tryghost/debug': 2.3.12(supports-color@10.2.2) lodash: 4.18.1 transitivePeerDependencies: - supports-color - '@tryghost/bookshelf-filter@2.3.7(supports-color@10.2.2)': + '@tryghost/bookshelf-filter@2.3.12(supports-color@10.2.2)': dependencies: - '@tryghost/debug': 2.3.7(supports-color@10.2.2) - '@tryghost/errors': 3.3.9 + '@tryghost/debug': 2.3.12(supports-color@10.2.2) + '@tryghost/errors': 3.3.12 '@tryghost/nql': 0.13.4(supports-color@10.2.2) - '@tryghost/tpl': 2.3.7 + '@tryghost/tpl': 2.3.12 transitivePeerDependencies: - supports-color - '@tryghost/bookshelf-has-posts@2.4.7(supports-color@10.2.2)': + '@tryghost/bookshelf-has-posts@2.4.12(supports-color@10.2.2)': dependencies: - '@tryghost/debug': 2.3.7(supports-color@10.2.2) + '@tryghost/debug': 2.3.12(supports-color@10.2.2) lodash: 4.18.1 transitivePeerDependencies: - supports-color - '@tryghost/bookshelf-include-count@2.3.7(supports-color@10.2.2)': + '@tryghost/bookshelf-include-count@2.3.12(supports-color@10.2.2)': dependencies: - '@tryghost/debug': 2.3.7(supports-color@10.2.2) + '@tryghost/debug': 2.3.12(supports-color@10.2.2) lodash: 4.18.1 transitivePeerDependencies: - supports-color - '@tryghost/bookshelf-order@2.3.7': + '@tryghost/bookshelf-order@2.3.12': dependencies: lodash: 4.18.1 - '@tryghost/bookshelf-pagination@2.4.1': + '@tryghost/bookshelf-pagination@2.4.6': dependencies: - '@tryghost/errors': 3.3.9 - '@tryghost/tpl': 2.3.7 + '@tryghost/errors': 3.3.12 + '@tryghost/tpl': 2.3.12 lodash: 4.18.1 - '@tryghost/bookshelf-plugins@2.3.8(supports-color@10.2.2)': + '@tryghost/bookshelf-plugins@2.3.12(supports-color@10.2.2)': dependencies: - '@tryghost/bookshelf-collision': 2.3.7 - '@tryghost/bookshelf-custom-query': 2.3.7 - '@tryghost/bookshelf-eager-load': 2.3.7(supports-color@10.2.2) - '@tryghost/bookshelf-filter': 2.3.7(supports-color@10.2.2) - '@tryghost/bookshelf-has-posts': 2.4.7(supports-color@10.2.2) - '@tryghost/bookshelf-include-count': 2.3.7(supports-color@10.2.2) - '@tryghost/bookshelf-order': 2.3.7 - '@tryghost/bookshelf-pagination': 2.4.1 - '@tryghost/bookshelf-search': 2.3.7 - '@tryghost/bookshelf-transaction-events': 2.3.7 + '@tryghost/bookshelf-collision': 2.3.12 + '@tryghost/bookshelf-custom-query': 2.3.12 + '@tryghost/bookshelf-eager-load': 2.3.12(supports-color@10.2.2) + '@tryghost/bookshelf-filter': 2.3.12(supports-color@10.2.2) + '@tryghost/bookshelf-has-posts': 2.4.12(supports-color@10.2.2) + '@tryghost/bookshelf-include-count': 2.3.12(supports-color@10.2.2) + '@tryghost/bookshelf-order': 2.3.12 + '@tryghost/bookshelf-pagination': 2.4.6 + '@tryghost/bookshelf-search': 2.3.12 + '@tryghost/bookshelf-transaction-events': 2.3.12 transitivePeerDependencies: - supports-color - '@tryghost/bookshelf-search@2.3.7': {} + '@tryghost/bookshelf-search@2.3.12': {} - '@tryghost/bookshelf-transaction-events@2.3.7': {} + '@tryghost/bookshelf-transaction-events@2.3.12': {} '@tryghost/brute-knex@3.2.2(better-sqlite3@12.11.1)(express@4.22.2(supports-color@10.2.2))(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2)': dependencies: @@ -29116,7 +29391,7 @@ snapshots: - supports-color - tedious - '@tryghost/bunyan-rotating-filestream@0.0.15': + '@tryghost/bunyan-rotating-filestream@0.0.18': dependencies: long-timeout: 0.1.1 @@ -29136,7 +29411,7 @@ snapshots: '@tryghost/database-info@0.3.35': {} - '@tryghost/database-info@2.3.2': {} + '@tryghost/database-info@2.3.12': {} '@tryghost/debug@0.1.40(supports-color@10.2.2)': dependencies: @@ -29152,37 +29427,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@tryghost/debug@2.3.7(supports-color@10.2.2)': + '@tryghost/debug@2.3.12(supports-color@10.2.2)': dependencies: - '@tryghost/root-utils': 2.3.7 + '@tryghost/root-utils': 2.3.12 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@tryghost/debug@2.3.9(supports-color@10.2.2)': + '@tryghost/domain-events@3.3.13(supports-color@10.2.2)': dependencies: - '@tryghost/root-utils': 2.3.9 - debug: 4.4.3(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - - '@tryghost/domain-events@3.3.10(supports-color@10.2.2)': - dependencies: - '@tryghost/logging': 5.4.0(supports-color@10.2.2) + '@tryghost/logging': 5.4.3(supports-color@10.2.2) transitivePeerDependencies: - '@75lb/nature' - supports-color - '@tryghost/elasticsearch@5.4.6(supports-color@10.2.2)': + '@tryghost/elasticsearch@5.4.9(supports-color@10.2.2)': dependencies: '@elastic/elasticsearch': 8.19.2(supports-color@10.2.2) - '@tryghost/debug': 2.3.9(supports-color@10.2.2) + '@tryghost/debug': 2.3.12(supports-color@10.2.2) split2: 4.2.0 transitivePeerDependencies: - '@75lb/nature' - supports-color - '@tryghost/email-mock-receiver@2.1.0': {} + '@tryghost/email-mock-receiver@2.3.12': {} '@tryghost/ember-promise-modals@2.0.1(ember-source@3.24.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(postcss@8.5.26)(supports-color@10.2.2)': dependencies: @@ -29205,14 +29473,14 @@ snapshots: - webpack-cli - webpack-command - '@tryghost/errors@3.3.9': {} + '@tryghost/errors@3.3.12': {} - '@tryghost/express-test@2.1.0(express@4.22.2(supports-color@10.2.2))(supports-color@10.2.2)': + '@tryghost/express-test@2.3.12(express@4.22.2(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@tryghost/jest-snapshot': 2.1.0(supports-color@10.2.2) + '@tryghost/jest-snapshot': 2.3.12(supports-color@10.2.2) cookiejar: 2.1.4 express: 4.22.2(supports-color@10.2.2) - form-data: 4.0.5 + form-data: 4.0.6 mime-types: 3.0.2 transitivePeerDependencies: - supports-color @@ -29228,34 +29496,34 @@ snapshots: '@tryghost/http-cache-utils@0.1.25': {} - '@tryghost/http-stream@2.3.10': + '@tryghost/http-stream@2.3.13': dependencies: - '@tryghost/errors': 3.3.9 - '@tryghost/request': 4.0.2 + '@tryghost/errors': 3.3.12 + '@tryghost/request': 4.0.5 '@tryghost/image-transform@1.4.17(@types/node@22.20.1)': dependencies: - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 fs-extra: 11.3.6 optionalDependencies: sharp: 0.35.3(@types/node@22.20.1) transitivePeerDependencies: - '@types/node' - '@tryghost/jest-snapshot@2.1.0(supports-color@10.2.2)': + '@tryghost/jest-snapshot@2.3.12(supports-color@10.2.2)': dependencies: - '@jest/expect': 30.3.0(supports-color@10.2.2) - '@jest/expect-utils': 30.3.0 - '@tryghost/errors': 3.3.9 - jest-snapshot: 30.3.0(supports-color@10.2.2) + '@jest/expect': 30.4.1(supports-color@10.2.2) + '@jest/expect-utils': 30.4.1 + '@tryghost/errors': 3.3.12 + jest-snapshot: 30.4.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@tryghost/job-manager@1.0.9(supports-color@10.2.2)': dependencies: '@breejs/later': 4.2.0 - '@tryghost/errors': 3.3.9 - '@tryghost/logging': 5.4.0(supports-color@10.2.2) + '@tryghost/errors': 3.3.12 + '@tryghost/logging': 5.4.3(supports-color@10.2.2) bree: 6.5.0(supports-color@10.2.2) cron-validate: 1.4.5 fastq: 1.20.1 @@ -29295,17 +29563,17 @@ snapshots: '@tryghost/limit-service@1.5.6': dependencies: - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 lodash: 4.18.1 luxon: 3.7.2 - '@tryghost/logging@5.4.0(supports-color@10.2.2)': + '@tryghost/logging@5.4.3(supports-color@10.2.2)': dependencies: - '@tryghost/bunyan-rotating-filestream': 0.0.15 - '@tryghost/elasticsearch': 5.4.6(supports-color@10.2.2) - '@tryghost/http-stream': 2.3.10 - '@tryghost/pretty-stream': 2.3.9 - '@tryghost/root-utils': 2.3.9 + '@tryghost/bunyan-rotating-filestream': 0.0.18 + '@tryghost/elasticsearch': 5.4.9(supports-color@10.2.2) + '@tryghost/http-stream': 2.3.13 + '@tryghost/pretty-stream': 2.3.12 + '@tryghost/root-utils': 2.3.12 bunyan: 1.8.15 fs-extra: 11.4.0 gelf-stream: 1.1.1 @@ -29315,11 +29583,11 @@ snapshots: - '@75lb/nature' - supports-color - '@tryghost/metrics@3.5.0(supports-color@10.2.2)': + '@tryghost/metrics@3.5.3(supports-color@10.2.2)': dependencies: - '@tryghost/elasticsearch': 5.4.6(supports-color@10.2.2) - '@tryghost/pretty-stream': 2.3.9 - '@tryghost/root-utils': 2.3.9 + '@tryghost/elasticsearch': 5.4.9(supports-color@10.2.2) + '@tryghost/pretty-stream': 2.3.12 + '@tryghost/root-utils': 2.3.12 json-stringify-safe: 5.0.1 transitivePeerDependencies: - '@75lb/nature' @@ -29361,7 +29629,7 @@ snapshots: '@tryghost/mw-error-handler@1.0.13(supports-color@10.2.2)': dependencies: '@tryghost/debug': 0.1.40(supports-color@10.2.2) - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 '@tryghost/http-cache-utils': 0.1.25 '@tryghost/tpl': 0.1.40 lodash: 4.18.1 @@ -29371,12 +29639,12 @@ snapshots: '@tryghost/mw-vhost@1.0.6': {} - '@tryghost/nodemailer@2.3.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': + '@tryghost/nodemailer@2.3.12(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@aws-sdk/client-sesv2': 3.1073.0 - '@tryghost/errors': 3.3.9 - '@tryghost/tpl': 2.3.1 - nodemailer: 9.0.1 + '@aws-sdk/client-sesv2': 3.1121.0 + '@tryghost/errors': 3.3.12 + '@tryghost/tpl': 2.3.12 + nodemailer: 9.0.6 nodemailer-mailgun-transport: 2.1.5(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) nodemailer-stub-transport: 1.1.0 transitivePeerDependencies: @@ -29402,12 +29670,12 @@ snapshots: chalk: 5.6.2 sywac: 1.3.0 - '@tryghost/pretty-cli@3.3.2': + '@tryghost/pretty-cli@3.3.12': dependencies: - chalk: 5.6.2 + chalk: 6.0.0 sywac: 1.3.0 - '@tryghost/pretty-stream@2.3.9': + '@tryghost/pretty-stream@2.3.12': dependencies: date-format: 4.0.14 lodash: 4.18.1 @@ -29415,7 +29683,7 @@ snapshots: '@tryghost/prometheus-metrics@1.0.8(supports-color@10.2.2)': dependencies: - '@tryghost/logging': 5.4.0(supports-color@10.2.2) + '@tryghost/logging': 5.4.3(supports-color@10.2.2) express: 4.22.1(supports-color@10.2.2) prom-client: 15.1.3 stoppable: 1.1.0 @@ -29425,24 +29693,15 @@ snapshots: '@tryghost/promise@0.3.20': {} - '@tryghost/promise@2.3.9': {} + '@tryghost/promise@2.3.12': {} '@tryghost/referrer-parser@0.1.21': {} - '@tryghost/request@4.0.2': - dependencies: - '@tryghost/errors': 3.3.9 - '@tryghost/validator': 3.2.10 - '@tryghost/version': 2.3.9 - cacheable-lookup: 7.0.0 - got: 15.1.0 - lodash: 4.18.1 - - '@tryghost/request@4.0.3': + '@tryghost/request@4.0.5': dependencies: - '@tryghost/errors': 3.3.9 - '@tryghost/validator': 3.2.10 - '@tryghost/version': 2.3.10 + '@tryghost/errors': 3.3.12 + '@tryghost/validator': 3.2.12 + '@tryghost/version': 2.3.12 cacheable-lookup: 7.0.0 got: 15.1.0 lodash: 4.18.1 @@ -29457,22 +29716,7 @@ snapshots: caller: 1.1.0 find-root: 1.1.0 - '@tryghost/root-utils@2.3.10': - dependencies: - caller: 1.1.0 - find-root: 1.1.0 - - '@tryghost/root-utils@2.3.2': - dependencies: - caller: 1.1.0 - find-root: 1.1.0 - - '@tryghost/root-utils@2.3.7': - dependencies: - caller: 1.1.0 - find-root: 1.1.0 - - '@tryghost/root-utils@2.3.9': + '@tryghost/root-utils@2.3.12': dependencies: caller: 1.1.0 find-root: 1.1.0 @@ -29485,7 +29729,7 @@ snapshots: '@tryghost/server@3.1.1(supports-color@10.2.2)': dependencies: '@tryghost/debug': 2.3.1(supports-color@10.2.2) - '@tryghost/logging': 5.4.0(supports-color@10.2.2) + '@tryghost/logging': 5.4.3(supports-color@10.2.2) transitivePeerDependencies: - '@75lb/nature' - supports-color @@ -29506,13 +29750,7 @@ snapshots: dependencies: lodash.template: 4.18.1 - '@tryghost/tpl@2.3.1': {} - - '@tryghost/tpl@2.3.10': {} - - '@tryghost/tpl@2.3.7': {} - - '@tryghost/tpl@2.3.9': {} + '@tryghost/tpl@2.3.12': {} '@tryghost/url-utils@5.2.6': dependencies: @@ -29534,36 +29772,26 @@ snapshots: remark-footnotes: 1.0.0 unist-util-visit: 2.0.3 - '@tryghost/validator@3.2.10': + '@tryghost/validator@3.2.12': dependencies: - '@tryghost/errors': 3.3.9 - '@tryghost/tpl': 2.3.10 + '@tryghost/errors': 3.3.12 + '@tryghost/tpl': 2.3.12 lodash: 4.18.1 moment-timezone: 0.5.45 validator: 13.15.35 - '@tryghost/version@2.3.10': + '@tryghost/version@2.3.12': dependencies: - '@tryghost/root-utils': 2.3.10 + '@tryghost/root-utils': 2.3.12 semver: 7.8.5 - '@tryghost/version@2.3.2': - dependencies: - '@tryghost/root-utils': 2.3.2 - semver: 7.8.5 - - '@tryghost/version@2.3.9': - dependencies: - '@tryghost/root-utils': 2.3.9 - semver: 7.8.5 - - '@tryghost/webhook-mock-receiver@2.1.0': + '@tryghost/webhook-mock-receiver@2.3.12': dependencies: p-wait-for: 6.0.0 '@tryghost/zip@3.5.0(supports-color@10.2.2)': dependencies: - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 archiver: 8.0.0 extract-zip: 2.0.1(supports-color@10.2.2) transitivePeerDependencies: @@ -29572,9 +29800,9 @@ snapshots: - react-native-b4a - supports-color - '@tryghost/zip@3.5.1(supports-color@10.2.2)': + '@tryghost/zip@3.5.11(supports-color@10.2.2)': dependencies: - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 archiver: 8.0.0 extract-zip: 2.0.1(supports-color@10.2.2) transitivePeerDependencies: @@ -32351,7 +32579,7 @@ snapshots: bookshelf-relations@2.8.0(bookshelf@1.2.0(knex@2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2)))(supports-color@10.2.2): dependencies: '@tryghost/debug': 0.1.40(supports-color@10.2.2) - '@tryghost/errors': 3.3.9 + '@tryghost/errors': 3.3.12 bluebird: 3.7.2 bookshelf: 1.2.0(knex@2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2)) lodash: 4.18.1 @@ -33324,6 +33552,8 @@ snapshots: chalk@5.6.2: {} + chalk@6.0.0: {} + char-regex@1.0.2: optional: true @@ -37206,6 +37436,16 @@ snapshots: jest-message-util: 30.3.0 jest-mock: 30.3.0 jest-util: 30.3.0 + optional: true + + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 exponential-backoff@3.1.3: {} @@ -37774,14 +38014,6 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -38313,8 +38545,8 @@ snapshots: '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@10.2.2) '@tryghost/config': 2.3.1 '@tryghost/debug': 2.3.1(supports-color@10.2.2) - '@tryghost/errors': 3.3.9 - '@tryghost/logging': 5.4.0(supports-color@10.2.2) + '@tryghost/errors': 3.3.12 + '@tryghost/logging': 5.4.3(supports-color@10.2.2) '@tryghost/nql': 0.13.4(supports-color@10.2.2) '@tryghost/pretty-cli': 3.3.1 '@tryghost/server': 3.1.1(supports-color@10.2.2) @@ -39567,6 +39799,14 @@ snapshots: '@jest/get-type': 30.1.0 chalk: 4.1.2 pretty-format: 30.3.0 + optional: true + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 jest-docblock@29.7.0: dependencies: @@ -39625,6 +39865,22 @@ snapshots: walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 + optional: true + + jest-haste-map@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 26.0.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + jest-worker: 30.4.1 + picomatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 jest-leak-detector@29.7.0: dependencies: @@ -39645,6 +39901,14 @@ snapshots: chalk: 4.1.2 jest-diff: 30.3.0 pretty-format: 30.3.0 + optional: true + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 jest-message-util@29.7.0: dependencies: @@ -39669,6 +39933,20 @@ snapshots: pretty-format: 30.3.0 slash: 3.0.0 stack-utils: 2.0.6 + optional: true + + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.5 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 jest-mock@29.7.0: dependencies: @@ -39682,6 +39960,13 @@ snapshots: '@jest/types': 30.3.0 '@types/node': 26.0.0 jest-util: 30.3.0 + optional: true + + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 26.0.0 + jest-util: 30.4.1 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): optionalDependencies: @@ -39691,7 +39976,10 @@ snapshots: jest-regex-util@29.6.3: optional: true - jest-regex-util@30.0.1: {} + jest-regex-util@30.0.1: + optional: true + + jest-regex-util@30.4.0: {} jest-resolve-dependencies@29.7.0(supports-color@10.2.2): dependencies: @@ -39820,6 +40108,33 @@ snapshots: synckit: 0.11.13 transitivePeerDependencies: - supports-color + optional: true + + jest-snapshot@30.4.1(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/types': 7.29.7 + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.4.1 + '@jest/transform': 30.4.1(supports-color@10.2.2) + '@jest/types': 30.4.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) + chalk: 4.1.2 + expect: 30.4.1 + graceful-fs: 4.2.11 + jest-diff: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + pretty-format: 30.4.1 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color jest-util@29.7.0: dependencies: @@ -39838,6 +40153,16 @@ snapshots: ci-info: 4.4.0 graceful-fs: 4.2.11 picomatch: 4.0.5 + optional: true + + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 26.0.0 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.5 jest-validate@29.7.0: dependencies: @@ -39882,6 +40207,15 @@ snapshots: jest-util: 30.3.0 merge-stream: 2.0.0 supports-color: 10.2.2 + optional: true + + jest-worker@30.4.1: + dependencies: + '@types/node': 26.0.0 + '@ungap/structured-clone': 1.3.1 + jest-util: 30.4.1 + merge-stream: 2.0.0 + supports-color: 10.2.2 jest@29.7.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(node-notifier@10.0.1)(supports-color@10.2.2): dependencies: @@ -40190,8 +40524,8 @@ snapshots: knex-migrator@5.4.1(@types/node@22.20.1)(bluebird@3.7.2)(supports-color@10.2.2): dependencies: '@tryghost/database-info': 0.3.35 - '@tryghost/errors': 3.3.9 - '@tryghost/logging': 5.4.0(supports-color@10.2.2) + '@tryghost/errors': 3.3.12 + '@tryghost/logging': 5.4.3(supports-color@10.2.2) '@tryghost/promise': 0.3.20 commander: 5.1.0 compare-ver: 2.0.2 @@ -42189,7 +42523,7 @@ snapshots: nodemailer@8.0.11: {} - nodemailer@9.0.1: {} + nodemailer@9.0.6: {} nodemon@3.1.14: dependencies: @@ -43857,6 +44191,14 @@ snapshots: '@jest/schemas': 30.0.5 ansi-styles: 5.2.0 react-is: 18.3.1 + optional: true + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.8 pretty-hrtime@1.0.3: {} @@ -44317,6 +44659,8 @@ snapshots: react-is@18.3.1: {} + react-is@19.2.8: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c120e7254b2..eb41f64c980 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -79,25 +79,25 @@ catalog: '@tanstack/react-virtual': 3.14.9 '@testing-library/jest-dom': 6.9.1 '@testing-library/react': 14.3.1 - '@tryghost/api-framework': 3.3.9 + '@tryghost/api-framework': 3.3.12 '@tryghost/brute-knex': 3.2.2 '@tryghost/color-utils': 0.2.20 '@tryghost/custom-fonts': 1.0.11 - '@tryghost/debug': 2.3.9 - '@tryghost/domain-events': 3.3.10 - '@tryghost/errors': 3.3.9 + '@tryghost/debug': 2.3.12 + '@tryghost/domain-events': 3.3.13 + '@tryghost/errors': 3.3.12 '@tryghost/helpers': 1.1.106 '@tryghost/limit-service': 1.5.6 - '@tryghost/logging': 5.4.0 + '@tryghost/logging': 5.4.3 '@tryghost/mg-clean-html': 0.12.6 - '@tryghost/metrics': 3.5.0 + '@tryghost/metrics': 3.5.3 '@tryghost/mongo-knex': 0.11.2 '@tryghost/nql': 0.13.4 '@tryghost/nql-lang': 0.7.0 - '@tryghost/request': 4.0.3 + '@tryghost/request': 4.0.5 '@tryghost/string': 0.3.5 '@tryghost/timezone-data': 1.0.0 - '@tryghost/tpl': 2.3.9 + '@tryghost/tpl': 2.3.12 '@types/express': 4.17.25 '@types/html-minifier': ^4.0.6 '@types/lodash': 4.17.25 @@ -223,7 +223,7 @@ catalog: cron-validate: 1.4.5 '@types/stoppable': 1.1.3 '@types/express-brute': 1.0.6 - '@tryghost/validator': 3.2.10 + '@tryghost/validator': 3.2.12 catalogs: react17: From 624177b1d2c6e5e3a3d4cdc1e75ea480f12373b6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 2 Sep 2026 09:48:55 -0500 Subject: [PATCH 12/15] Auto-formatted Tinybird "hits" materialized view (#30448) no ref This change should have no user impact. All I did was run this: ```sh tb fmt ghost/core/core/server/data/tinybird/pipes/mv_hits.pipe ``` (I didn't choose to format other files because, annoyingly, `tb fmt` removes comments, which I wasn't ready to do.) --- .../server/data/tinybird/pipes/mv_hits.pipe | 136 +++++++++++------- 1 file changed, 86 insertions(+), 50 deletions(-) diff --git a/ghost/core/core/server/data/tinybird/pipes/mv_hits.pipe b/ghost/core/core/server/data/tinybird/pipes/mv_hits.pipe index fa910faa90b..717a6732e8f 100644 --- a/ghost/core/core/server/data/tinybird/pipes/mv_hits.pipe +++ b/ghost/core/core/server/data/tinybird/pipes/mv_hits.pipe @@ -2,19 +2,23 @@ TOKEN "axis" READ NODE mv_hits_0 SQL > - - SELECT timestamp, + SELECT + timestamp, inserted_at, -- payload.meta.received_timestamp may be missing or null - -- Nullable fields incur performance penalty in clickhouse, so we default to zero (unix epoch) instead of null. - parseDateTime64BestEffortOrZero(JSONExtractString(payload, 'meta', 'received_timestamp'), 3) as received_at, + -- Nullable fields incur performance penalty in clickhouse, so we default to zero (unix epoch) + -- instead of null. + parseDateTime64BestEffortOrZero( + JSONExtractString(payload, 'meta', 'received_timestamp'), 3 + ) as received_at, action, version, coalesce(session_id, '0') as session_id, JSONExtractString(payload, 'locale') as locale, JSONExtractString(payload, 'location') as location, case - when JSONExtractString(payload, 'referrerSource') = '' then JSONExtractString(payload, 'meta', 'referrerSource') + when JSONExtractString(payload, 'referrerSource') = '' + then JSONExtractString(payload, 'meta', 'referrerSource') else JSONExtractString(payload, 'referrerSource') end as referrer, JSONExtractString(payload, 'pathname') as pathname, @@ -35,11 +39,8 @@ SQL > FROM analytics_events where action = 'page_hit' - - NODE mv_hits_1 SQL > - SELECT site_uuid, timestamp, @@ -47,8 +48,10 @@ SQL > inserted_at, case -- set to -1 if received_at is the unix epoch, or if received_at is after inserted_at - when toUnixTimestamp(received_at) = 0 then -1 - when received_at > inserted_at then -1 + when toUnixTimestamp(received_at) = 0 + then -1 + when received_at > inserted_at + then -1 else date_diff('millisecond', received_at, inserted_at) end as ingestion_latency_ms, action, @@ -61,58 +64,91 @@ SQL > gift_link, location, case - when referrer = '' then '' + when referrer = '' + then '' -- Social Media Consolidation - when referrer IN ('Facebook', 'www.facebook.com', 'l.facebook.com', 'lm.facebook.com', 'm.facebook.com', 'facebook') then 'Facebook' - when referrer IN ('Twitter', 'x.com', 'com.twitter.android') then 'Twitter' - when referrer IN ('go.bsky.app', 'bsky', 'bsky.app') then 'Bluesky' - when referrer IN ('Instagram', 'www.instagram.com') then 'Instagram' - when referrer IN ('LinkedIn', 'LINKEDIN_COMPANY') then 'LinkedIn' - when referrer IN ('l.threads.com') then 'Threads' - + when + referrer IN ( + 'Facebook', + 'www.facebook.com', + 'l.facebook.com', + 'lm.facebook.com', + 'm.facebook.com', + 'facebook' + ) + then 'Facebook' + when referrer IN ('Twitter', 'x.com', 'com.twitter.android') + then 'Twitter' + when referrer IN ('go.bsky.app', 'bsky', 'bsky.app') + then 'Bluesky' + when referrer IN ('Instagram', 'www.instagram.com') + then 'Instagram' + when referrer IN ('LinkedIn', 'LINKEDIN_COMPANY') + then 'LinkedIn' + when referrer IN ('l.threads.com') + then 'Threads' -- Reddit Ecosystem - when referrer IN ('www.reddit.com', 'out.reddit.com', 'old.reddit.com', 'com.reddit.frontpage') then 'Reddit' - + when + referrer + IN ('www.reddit.com', 'out.reddit.com', 'old.reddit.com', 'com.reddit.frontpage') + then 'Reddit' -- Search Engines (keep distinctions) - when referrer IN ('search.brave.com') then 'Brave Search' - when referrer IN ('www.ecosia.org') then 'Ecosia' - + when referrer IN ('search.brave.com') + then 'Brave Search' + when referrer IN ('www.ecosia.org') + then 'Ecosia' -- Email Services - when referrer IN ('Gmail', 'com.google.android.gm', 'mail.google.com') then 'Gmail' - when referrer IN ('Outlook.com') then 'Outlook' - when referrer IN ('Yahoo!', 'www.yahoo.com', 'Yahoo! Mail', 'r.search.yahoo.com') then 'Yahoo!' - when referrer IN ('AOL Mail') then 'AOL Mail' - + when referrer IN ('Gmail', 'com.google.android.gm', 'mail.google.com') + then 'Gmail' + when referrer IN ('Outlook.com') + then 'Outlook' + when referrer IN ('Yahoo!', 'www.yahoo.com', 'Yahoo! Mail', 'r.search.yahoo.com') + then 'Yahoo!' + when referrer IN ('AOL Mail') + then 'AOL Mail' -- Content Platforms - when referrer IN ('flipboard', 'flipboard.com', 'flipboard.app') then 'Flipboard' - when referrer IN ('substack', 'substack.com') then 'Substack' - when referrer IN ('Ghost.org', 'ghost.org') then 'Ghost' - when referrer IN ('buffer') then 'Buffer' - when referrer IN ('Taboola') then 'Taboola' - when referrer IN ('AppNexus') then 'AppNexus' - + when referrer IN ('flipboard', 'flipboard.com', 'flipboard.app') + then 'Flipboard' + when referrer IN ('substack', 'substack.com') + then 'Substack' + when referrer IN ('Ghost.org', 'ghost.org') + then 'Ghost' + when referrer IN ('buffer') + then 'Buffer' + when referrer IN ('Taboola') + then 'Taboola' + when referrer IN ('AppNexus') + then 'AppNexus' -- Wikipedia - when referrer IN ('en.wikipedia.org', 'en.m.wikipedia.org') then 'Wikipedia' - + when referrer IN ('en.wikipedia.org', 'en.m.wikipedia.org') + then 'Wikipedia' -- Mastodon Network - when referrer IN ('mastodon.social', 'mastodon.online', 'org.joinmastodon.android', 'phanpy.social', 'dev.phanpy.social') then 'Mastodon' - + when + referrer IN ( + 'mastodon.social', + 'mastodon.online', + 'org.joinmastodon.android', + 'phanpy.social', + 'dev.phanpy.social' + ) + then 'Mastodon' -- News Aggregators - when referrer IN ('www.memeorandum.com', 'memeorandum.com') then 'Memeorandum' - when referrer IN ('ground.news') then 'Ground News' - when referrer IN ('apple.news') then 'Apple News' - when referrer IN ('www.smartnews.com') then 'SmartNews' - + when referrer IN ('www.memeorandum.com', 'memeorandum.com') + then 'Memeorandum' + when referrer IN ('ground.news') + then 'Ground News' + when referrer IN ('apple.news') + then 'Apple News' + when referrer IN ('www.smartnews.com') + then 'SmartNews' -- Keep other sources as-is - when domainWithoutWWW(referrer) != '' then domainWithoutWWW(referrer) + when domainWithoutWWW(referrer) != '' + then domainWithoutWWW(referrer) else referrer end as source, pathname, href, - case - when device = '' then 'unknown' - else device - end as device, + case when device = '' then 'unknown' else device end as device, case when match(user_agent, 'windows') then 'windows' @@ -146,5 +182,5 @@ SQL > utm_content FROM mv_hits_0 -TYPE materialized +TYPE MATERIALIZED DATASOURCE _mv_hits From 564a78aa5b725863437c6e45fb1767972b05b486 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 2 Sep 2026 09:50:57 -0500 Subject: [PATCH 13/15] Added a style.css export to koenig-lexical and loaded it in the React admin (#30462) no ref `@tryghost/koenig-lexical` ships two bundles. The UMD build (`koenig-lexical.umd.js`) inlines the editor stylesheet and appends it to `` when it evaluates. The ESM build (`koenig-lexical.js`) extracts it to `dist/style.css` and leaves loading to the consumer, but the package `exports` map only exposed `"."`, so ESM consumers had no supported way to reach the stylesheet. The React admin imports Koenig through the ESM entry. --- .changeset/koenig-style-export.md | 5 +++++ .../membership/member-welcome-emails.acceptance.test.tsx | 8 ++++++++ apps/admin/src/utils/fetch-koenig-lexical.ts | 9 +++++++-- koenig/koenig-lexical/package.json | 3 ++- 4 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .changeset/koenig-style-export.md diff --git a/.changeset/koenig-style-export.md b/.changeset/koenig-style-export.md new file mode 100644 index 00000000000..5d3f57e0b04 --- /dev/null +++ b/.changeset/koenig-style-export.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": minor +--- + +Added a `./style.css` package export so ESM consumers can load the editor stylesheet, which only the UMD bundle injects on its own diff --git a/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx b/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx index 909679f3b93..6a059ec02d3 100644 --- a/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx @@ -188,6 +188,14 @@ function pasteText(content: string) { } describe('Member welcome emails', () => { + it('loads the Koenig stylesheet with the editor', async () => { + const modal = await openWelcomeEmailModal(); + const editor = modal.element().querySelector('.koenig-lexical'); + expect(editor).not.toBeNull(); + + await expect.poll(() => getComputedStyle(editor!).getPropertyValue('--black')).not.toBe(''); + }); + it('previews the unsaved draft only after Preview is selected', async () => { fakeSettingsScreens(); fakeDefaultNewsletter(); diff --git a/apps/admin/src/utils/fetch-koenig-lexical.ts b/apps/admin/src/utils/fetch-koenig-lexical.ts index 69ed404ac4e..877b5e54bfd 100644 --- a/apps/admin/src/utils/fetch-koenig-lexical.ts +++ b/apps/admin/src/utils/fetch-koenig-lexical.ts @@ -5,10 +5,15 @@ * This ensures React is properly deduped by Vite's bundler, avoiding the * "Invalid hook call" errors that occur when the UMD bundle (which includes * its own bundled React) is loaded alongside Vite's React. + * + * Only the UMD bundle injects Koenig's stylesheet; the ESM bundle leaves it + * to the consumer, so it is loaded here alongside the module. */ export async function fetchKoenigLexical(): Promise { - // Import the ESM module directly - Vite will handle React deduplication // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const koenig = await import('@tryghost/koenig-lexical'); + const [koenig] = await Promise.all([ + import('@tryghost/koenig-lexical'), + import('@tryghost/koenig-lexical/style.css'), + ]); return koenig; } diff --git a/koenig/koenig-lexical/package.json b/koenig/koenig-lexical/package.json index c6494332a7a..239d9917b07 100644 --- a/koenig/koenig-lexical/package.json +++ b/koenig/koenig-lexical/package.json @@ -21,7 +21,8 @@ ".": { "import": "./dist/koenig-lexical.js", "require": "./dist/koenig-lexical.umd.js" - } + }, + "./style.css": "./dist/style.css" }, "scripts": { "dev": "concurrently \"vite --host --force\" \"pnpm build --watch --emptyOutDir=false\" \"pnpm preview -l silent --host\" \"pnpm --filter @tryghost/kg-default-nodes dev\" \"pnpm --filter @tryghost/kg-default-transforms dev\"", From d9eb4ac2d1b0b018f9636f8ae111dd95d77ac767 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 2 Sep 2026 07:58:57 -0700 Subject: [PATCH 14/15] Added typechecking as a first-class CI check (#30322) no refs ## Context Many TypeScript workspaces already expose a `test:types` target, but CI currently selects only affected `test:unit` targets. That leaves standalone typechecks unprotected and makes their dependency on built workspace packages implicit. The [recent Admin-X typecheck regression fixed in #30291](https://github.com/TryGhost/Ghost/pull/30291) demonstrated that these failures can land on `main` while unit-test CI remains green. ## Summary - Added a root `pnpm test:types` command and a cached Nx `test:types` target that builds dependency projects first. - Added an affected-project `Typecheck` job with its own required CI signal and main-branch failure notification. - Included typechecking in the public-app publishing prerequisites while allowing the job to be skipped when no typed project is affected. - Removed Shade typechecking from `test:unit` so unit tests and typechecks are reported independently. - Documented how to run repository-wide and project-specific typechecks. - Separated `ghost/core`'s `lint:types` script into `test:types` (for typechecks) and `lint:code` (for linting .ts files) --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++++++-- apps/shade/README.md | 2 +- apps/shade/package.json | 2 +- docs/contributing/testing.md | 4 ++++ ghost/core/package.json | 14 ++++++------- nx.json | 4 ++++ package.json | 1 + 7 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d00c9a93251..fba4ca3f909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -241,6 +241,7 @@ jobs: if [[ "${{ env.IS_TAG }}" != 'true' && "${{ steps.changed.outputs.any-code }}" != 'true' ]]; then echo 'affected_projects=[]' >> "$GITHUB_OUTPUT" echo 'affected_projects_str=' >> "$GITHUB_OUTPUT" + echo 'typecheck_projects_str=' >> "$GITHUB_OUTPUT" echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT" echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT" echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT" @@ -261,6 +262,9 @@ jobs: AFFECTED_PROJECTS_STR=$(pnpm nx show projects ${AFFECTED_ARG} --sep=, | tr -d '\n') echo "affected_projects_str=$AFFECTED_PROJECTS_STR" >> "$GITHUB_OUTPUT" + TYPECHECK_PROJECTS_STR=$(pnpm -s nx show projects ${AFFECTED_ARG} --withTarget test:types --sep=, | tr -d '\n') + echo "typecheck_projects_str=$TYPECHECK_PROJECTS_STR" >> "$GITHUB_OUTPUT" + UNIT_TEST_AFFECTED_ARG="$AFFECTED_ARG" if [[ "${{ steps.changed.outputs.unit-test-globals }}" == 'true' ]]; then UNIT_TEST_AFFECTED_ARG="" @@ -296,6 +300,7 @@ jobs: outputs: affected_projects: ${{ steps.affected.outputs.affected_projects }} affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }} + typecheck_projects_str: ${{ steps.affected.outputs.typecheck_projects_str }} unit_test_projects_str: ${{ steps.affected.outputs.unit_test_projects_str }} affected_playwright_projects: ${{ steps.affected.outputs.affected_playwright_projects }} publish_public_apps_matrix: ${{ steps.affected.outputs.publish_public_apps_matrix }} @@ -748,6 +753,33 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + job_typecheck: + runs-on: ubuntu-latest + needs: [job_setup] + if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.typecheck_projects_str != '' + name: Typecheck + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1000 + - uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + trust-lockfile: 'true' + + - name: Typecheck projects + run: pnpm nx run-many -t test:types -p "${{ needs.job_setup.outputs.typecheck_projects_str }}" + env: + FORCE_COLOR: 0 + NX_SKIP_LOG_GROUPING: true + + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + status: ${{ job.status }} + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + job_acceptance-tests: # Private copies of this repo get 2-core runners, where vitest's DB suite # falls to a single worker; the public repo's runner is already 4-core. @@ -2305,6 +2337,7 @@ jobs: job_docker, job_ghost-cli, job_admin-tests, + job_typecheck, job_unit-tests, job_acceptance-tests, job_legacy-tests, @@ -2328,7 +2361,7 @@ jobs: # Runs only on push-to-main — never on pull_request — so the `id-token: write` # permission is never exposed to PR-controlled code (ref: ONC-1677). publish_public_apps: - needs: [job_setup, job_lint, job_unit-tests, job_build_e2e_public_apps] + needs: [job_setup, job_lint, job_typecheck, job_unit-tests, job_build_e2e_public_apps] name: Publish ${{ matrix.package_name }} runs-on: ubuntu-latest # Serialize per-app publishes so two quick main merges can't both compute the @@ -2339,11 +2372,13 @@ jobs: group: publish-public-app-${{ matrix.package_name }} cancel-in-progress: false if: | - github.event_name != 'pull_request' + always() + && github.event_name != 'pull_request' && github.repository == 'TryGhost/Ghost' && needs.job_setup.outputs.is_main == 'true' && needs.job_setup.result == 'success' && needs.job_lint.result == 'success' + && (needs.job_typecheck.result == 'success' || needs.job_typecheck.result == 'skipped') && needs.job_unit-tests.result == 'success' && needs.job_build_e2e_public_apps.result == 'success' && needs.job_setup.outputs.publish_public_apps_matrix != '[]' diff --git a/apps/shade/README.md b/apps/shade/README.md index 646a4b15cb8..9c044b64d78 100644 --- a/apps/shade/README.md +++ b/apps/shade/README.md @@ -73,7 +73,7 @@ Local docs with Storybook: ## Test - `pnpm test` — type-checks and runs Vitest with coverage -- `pnpm test:unit` — type-checks and runs Vitest +- `pnpm test:unit` — runs Vitest - `pnpm test:types` — TypeScript only - `pnpm lint` — ESLint for `src/` and `test/` diff --git a/apps/shade/package.json b/apps/shade/package.json index 71ba474f9d3..bc0556f485e 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -67,7 +67,7 @@ "dev": "vite build --watch", "build": "tsc -p tsconfig.declaration.json && tsc-alias -p tsconfig.declaration.json && vite build", "test": "pnpm test:types && vitest run --coverage", - "test:unit": "pnpm test:types && vitest run", + "test:unit": "vitest run", "test:types": "tsc --noEmit", "lint:code": "eslint src/ scripts/ --cache", "lint": "pnpm run '/^lint:/'", diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 7a2b6d141ca..ccb1aa5dd74 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -55,10 +55,14 @@ Nx can run a target for one workspace from the repository root: ```bash pnpm nx test +pnpm nx test:types pnpm nx test:unit pnpm nx test:acceptance ``` +Run all package typechecks with `pnpm test:types`. CI runs this as a dedicated +affected-project task, separately from unit tests. + Check the workspace's `package.json` or list its Nx targets when you are unsure which targets it provides: diff --git a/ghost/core/package.json b/ghost/core/package.json index 4ea6affd684..73fca460717 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -57,7 +57,7 @@ "test": "pnpm test:unit", "test:watch": "vitest --reporter=default", "test:single": "f() { case \"$1\" in test/unit/*) vitest run \"$1\" ;; test/*) vitest run -c vitest.config.db.ts \"$1\" ;; *) vitest run \"$1\" || vitest run -c vitest.config.db.ts \"$1\" ;; esac; }; f", - "test:all": "pnpm test:unit && pnpm test:integration && pnpm test:e2e && pnpm lint", + "test:all": "pnpm test:unit && pnpm test:integration && pnpm test:e2e && pnpm test:types && pnpm lint", "test:debug": "DEBUG=ghost:test* pnpm test", "test:unit": "vitest run", "test:integration": "vitest run -c vitest.config.db.ts --project integration", @@ -68,13 +68,13 @@ "test:int:slow": "pnpm test:integration", "test:e2e:slow": "vitest run -c vitest.config.db.ts --project e2e --project e2e-api --reporter=verbose", "test:leg:slow": "vitest run -c vitest.config.db.ts --project legacy --reporter=verbose", - "lint:server": "eslint 'core/server/**/*.js' 'core/*.js' '*.js' --cache", - "lint:shared": "eslint 'core/shared/**/*.js' --cache", - "lint:frontend": "eslint 'core/frontend/**/*.js' --cache", - "lint:test": "eslint 'test/**/*.js' --cache", + "test:types": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json", + "lint:server": "eslint 'core/server/**/*.{js,ts}' 'core/*.{js,ts}' '*.{js,ts}' 'bin/**/*.{js,ts}' 'scripts/**/*.{js,ts}' 'types/**/*.ts' --cache", + "lint:shared": "eslint 'core/shared/**/*.{js,ts}' --cache", + "lint:frontend": "eslint 'core/frontend/**/*.{js,ts}' --cache", + "lint:test": "eslint 'test/**/*.{js,ts}' --cache", "lint:code": "pnpm run '/^lint:(server|shared|frontend)$/'", - "lint:types": "eslint '**/*.ts' --cache && tsc --noEmit && tsc --noEmit -p test/tsconfig.json", - "lint": "pnpm run '/^lint:(server|shared|frontend|test|types)$/'" + "lint": "pnpm run '/^lint:(server|shared|frontend|test)$/'" }, "dependencies": { "@aws-sdk/client-s3": "3.1079.0", diff --git a/nx.json b/nx.json index 3cbf4c61317..3a4b4acd8ca 100644 --- a/nx.json +++ b/nx.json @@ -42,6 +42,10 @@ "dependsOn": ["build"], "inputs": ["default", "^default", { "runtime": "node -v" }] }, + "test:types": { + "cache": true, + "dependsOn": ["^build"] + }, "test:ci:*": { "cache": true, "inputs": ["default", "^default"] diff --git a/package.json b/package.json index 340cbd8558d..87280488ace 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "format:check": "oxfmt --check", "check": "pnpm format:check && pnpm lint && pnpm test", "test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin", + "test:types": "pnpm nx run-many -t test:types", "test:unit": "pnpm nx run-many -t test:unit", "test:watch": "vitest", "test:e2e": "pnpm --filter @tryghost/e2e test", From ae04c61de1c9fc8169808fc101910d22f596e63a Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 2 Sep 2026 10:12:22 -0500 Subject: [PATCH 15/15] Added the Koenig post editor mount behind the editorReact flag (#30459) no ref The `editorReact` Labs flag currently serves a placeholder on `/editor/*`. This PR turns it into a real post editor: it reads the post or page, renders it in Koenig with the full post `cardConfig`, and keeps edits in memory. **Nothing is saved yet** - the only write is the mobiledoc-to-lexical conversion a legacy post needs before it can open; saving lands separately with the save engine. With the flag off, Ember serves the editor exactly as before. --- apps/admin/src/editor/card-config.test.ts | 196 +++++++++++ apps/admin/src/editor/card-config.ts | 136 ++++++++ apps/admin/src/editor/editor-screen.tsx | 282 +++++++++++++-- .../src/editor/editor.acceptance.test.tsx | 48 +-- apps/admin/src/editor/editor.screen.ts | 36 ++ apps/admin/src/editor/koenig-post-editor.tsx | 122 +++++++ .../admin/src/editor/link-suggestions.test.ts | 220 ++++++++++++ apps/admin/src/editor/link-suggestions.ts | 259 ++++++++++++++ .../editor/post-editor.acceptance.test.tsx | 304 +++++++++++++++++ apps/admin/src/editor/post-editor.tsx | 321 ++++++++++++++++++ apps/admin/src/editor/tk.test.ts | 22 ++ apps/admin/src/editor/tk.ts | 30 ++ apps/admin/src/editor/use-post-card-config.ts | 125 +++++++ .../src/editor/use-post-link-suggestions.ts | 150 ++++++++ apps/admin/src/editor/use-post-snippets.tsx | 161 +++++++++ .../layout/editor-sidebar.acceptance.test.tsx | 16 +- .../src/settings/components/koenig-loader.ts | 8 +- apps/admin/test-utils/acceptance/index.ts | 1 + apps/admin/test-utils/acceptance/resources.ts | 7 + .../testing/test-data/src/selectors/editor.ts | 18 + 20 files changed, 2413 insertions(+), 49 deletions(-) create mode 100644 apps/admin/src/editor/card-config.test.ts create mode 100644 apps/admin/src/editor/card-config.ts create mode 100644 apps/admin/src/editor/editor.screen.ts create mode 100644 apps/admin/src/editor/koenig-post-editor.tsx create mode 100644 apps/admin/src/editor/link-suggestions.test.ts create mode 100644 apps/admin/src/editor/link-suggestions.ts create mode 100644 apps/admin/src/editor/post-editor.acceptance.test.tsx create mode 100644 apps/admin/src/editor/post-editor.tsx create mode 100644 apps/admin/src/editor/tk.test.ts create mode 100644 apps/admin/src/editor/tk.ts create mode 100644 apps/admin/src/editor/use-post-card-config.ts create mode 100644 apps/admin/src/editor/use-post-link-suggestions.ts create mode 100644 apps/admin/src/editor/use-post-snippets.tsx create mode 100644 packages/testing/test-data/src/selectors/editor.ts diff --git a/apps/admin/src/editor/card-config.test.ts b/apps/admin/src/editor/card-config.test.ts new file mode 100644 index 00000000000..03f6af18683 --- /dev/null +++ b/apps/admin/src/editor/card-config.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '@tryghost/admin-x-framework/api/config'; +import type { Setting } from '@tryghost/admin-x-framework/api/settings'; +import type { SiteData } from '@tryghost/admin-x-framework/api/site'; +import { + type PostCardConfigPorts, + type PostCardConfigSources, + buildCardConfigPost, + buildPostCardConfig, + getCardVisibilitySettings, +} from './card-config'; + +const settingsFrom = (values: Record): Setting[] => + Object.entries(values).map(([key, value]) => ({ key, value })); + +const baseSettings = { + title: 'Test Site', + description: 'Thoughts, stories and ideas.', + unsplash: true, + transistor: false, + members_signup_access: 'all', + stripe_connect_publishable_key: null, + stripe_connect_secret_key: null, + stripe_secret_key: null, + stripe_publishable_key: null, +}; + +const config = { stripeDirect: false, klipy: { apiKey: null }, labs: {} } as Config; +const site = { url: 'https://example.com/blog', site_uuid: 'site-uuid' } as SiteData; +const owner = { roles: [{ name: 'Owner' }] } as const; +const contributor = { roles: [{ name: 'Contributor' }] } as const; +const unsplashHeaders = { Authorization: 'Client-ID test', 'X-Unsplash-Cache': true }; + +const ports: PostCardConfigPorts = { + fetchEmbed: vi.fn(), + fetchAutocompleteLinks: vi.fn(), + searchLinks: vi.fn(), + fetchLabels: vi.fn(), + createSnippet: vi.fn(), + deleteSnippet: vi.fn(), +}; + +const sources = (overrides: Partial = {}): PostCardConfigSources => ({ + settings: settingsFrom(baseSettings), + config, + site, + currentUser: owner, + unsplashHeaders, + pinturaConfig: null, + post: buildCardConfigPost({ displayName: 'post', visibility: 'members' }, 'public'), + snippets: [], + ...overrides, +}); + +describe('buildCardConfigPost', () => { + it('returns undefined without a post', () => { + expect(buildCardConfigPost(undefined, 'public')).toBeUndefined(); + }); + + it('narrows the post to the fields cards read', () => { + expect( + buildCardConfigPost( + { displayName: 'page', showTitleAndFeatureImage: false, visibility: 'paid' }, + 'public', + ), + ).toEqual({ + displayName: 'page', + isPage: true, + showTitleAndFeatureImage: false, + visibility: 'paid', + }); + }); + + it('falls back to the site default visibility for an unsaved post', () => { + expect(buildCardConfigPost({ displayName: 'post' }, 'members')).toEqual({ + displayName: 'post', + isPage: false, + showTitleAndFeatureImage: true, + visibility: 'members', + }); + }); +}); + +describe('getCardVisibilitySettings', () => { + it('restricts pages to web-only visibility', () => { + expect(getCardVisibilitySettings({ isPage: true, displayName: 'page' })).toBe('web only'); + expect(getCardVisibilitySettings({ isPage: false, displayName: 'post' })).toBe('web and email'); + expect(getCardVisibilitySettings(undefined)).toBe('web and email'); + }); +}); + +describe('buildPostCardConfig', () => { + it('assembles the Ember editor card config', () => { + const cardConfig = buildPostCardConfig(sources(), ports); + + expect(cardConfig).toMatchObject({ + unsplash: unsplashHeaders, + klipy: null, + pinturaConfig: null, + renderLabels: true, + feature: { transistor: false, paywallImprovements: false }, + deprecated: { headerV1: true }, + membersEnabled: true, + siteTitle: 'Test Site', + siteDescription: 'Thoughts, stories and ideas.', + siteUrl: 'https://example.com/blog/', + siteUuid: 'site-uuid', + stripeEnabled: false, + visibilitySettings: 'web and email', + post: { displayName: 'post', isPage: false, visibility: 'members' }, + snippets: [], + }); + expect(cardConfig.fetchEmbed).toBe(ports.fetchEmbed); + expect(cardConfig.fetchAutocompleteLinks).toBe(ports.fetchAutocompleteLinks); + expect(cardConfig.searchLinks).toBe(ports.searchLinks); + expect(cardConfig.fetchLabels).toBe(ports.fetchLabels); + expect(cardConfig.createSnippet).toBe(ports.createSnippet); + expect(cardConfig.deleteSnippet).toBe(ports.deleteSnippet); + }); + + it('drops Unsplash when the integration is off', () => { + const cardConfig = buildPostCardConfig( + sources({ settings: settingsFrom({ ...baseSettings, unsplash: false }) }), + ports, + ); + + expect(cardConfig.unsplash).toBeNull(); + }); + + it('passes Klipy through only when an API key is configured', () => { + const klipy = { apiKey: 'key', contentFilter: 'off' }; + const cardConfig = buildPostCardConfig(sources({ config: { ...config, klipy } }), ports); + + expect(cardConfig.klipy).toEqual(klipy); + }); + + it('hides labels from contributors', () => { + const cardConfig = buildPostCardConfig(sources({ currentUser: contributor }), ports); + + expect(cardConfig.renderLabels).toBe(false); + }); + + it('reads feature flags from settings and labs', () => { + const cardConfig = buildPostCardConfig( + sources({ + settings: settingsFrom({ ...baseSettings, transistor: true }), + config: { ...config, labs: { paywallImprovements: true } }, + }), + ports, + ); + + expect(cardConfig.feature).toEqual({ transistor: true, paywallImprovements: true }); + }); + + it('treats invite-only member signup as members disabled', () => { + const cardConfig = buildPostCardConfig( + sources({ settings: settingsFrom({ ...baseSettings, members_signup_access: 'invite' }) }), + ports, + ); + + expect(cardConfig.membersEnabled).toBe(false); + }); + + it('reports Stripe as enabled from connect keys', () => { + const cardConfig = buildPostCardConfig( + sources({ + settings: settingsFrom({ + ...baseSettings, + stripe_connect_publishable_key: 'pk', + stripe_connect_secret_key: 'sk', + }), + }), + ports, + ); + + expect(cardConfig.stripeEnabled).toBe(true); + }); + + it('limits pages to web-only card visibility', () => { + const cardConfig = buildPostCardConfig( + sources({ post: buildCardConfigPost({ displayName: 'page' }, 'public') }), + ports, + ); + + expect(cardConfig.visibilitySettings).toBe('web only'); + }); + + it('passes pintura and snippets through untouched', () => { + const pinturaConfig = { jsUrl: 'https://cdn/pintura.js', cssUrl: 'https://cdn/pintura.css' }; + const snippets = [{ id: '1', name: 'Sign-off', value: '{"root":{}}' }]; + const cardConfig = buildPostCardConfig(sources({ pinturaConfig, snippets }), ports); + + expect(cardConfig.pinturaConfig).toBe(pinturaConfig); + expect(cardConfig.snippets).toBe(snippets); + }); +}); diff --git a/apps/admin/src/editor/card-config.ts b/apps/admin/src/editor/card-config.ts new file mode 100644 index 00000000000..852f7850c3a --- /dev/null +++ b/apps/admin/src/editor/card-config.ts @@ -0,0 +1,136 @@ +import { type Config } from '@tryghost/admin-x-framework/api/config'; +import { + type Setting, + checkStripeEnabled, + getSettingValue, +} from '@tryghost/admin-x-framework/api/settings'; +import { type SiteData, getHomepageUrl } from '@tryghost/admin-x-framework/api/site'; +import { isContributorUser } from '@tryghost/admin-x-framework/api/users'; +import type { AutocompleteLink, LinkSearchGroup } from './link-suggestions'; + +export type PostType = 'post' | 'page'; + +export interface CardConfigPost { + displayName: PostType; + isPage: boolean; + showTitleAndFeatureImage: boolean; + visibility: string; +} + +export interface CardConfigPostSource { + displayName: PostType; + showTitleAndFeatureImage?: boolean; + visibility?: string | null; +} + +export interface CardConfigSnippet { + id: string; + name: string; + value: string; +} + +export interface CardConfigSnippetInput { + name: string; + value: string; +} + +export interface PostCardConfigSources { + settings: Setting[]; + config: Config; + site: SiteData; + currentUser: Parameters[0]; + unsplashHeaders: Record; + pinturaConfig: { jsUrl: string; cssUrl: string } | null; + post: CardConfigPost | undefined; + snippets: CardConfigSnippet[]; +} + +export interface PostCardConfigPorts { + fetchEmbed: (url: string, options: { type?: string }) => Promise; + fetchAutocompleteLinks: () => Promise; + searchLinks: (term?: string) => Promise; + fetchLabels: () => Promise; + createSnippet?: (snippet: CardConfigSnippetInput) => void; + deleteSnippet?: (snippet: { name: string }) => void; +} + +export type CardVisibilitySettings = 'web only' | 'web and email'; + +export interface PostCardConfig extends PostCardConfigPorts { + unsplash: Record | null; + klipy: NonNullable | null; + pinturaConfig: { jsUrl: string; cssUrl: string } | null; + renderLabels: boolean; + feature: { transistor: boolean; paywallImprovements: boolean }; + deprecated: { headerV1: boolean }; + membersEnabled: boolean; + siteTitle: string; + siteDescription: string; + siteUrl: string; + siteUuid: string; + stripeEnabled: boolean; + post: CardConfigPost | undefined; + snippets: CardConfigSnippet[]; + visibilitySettings: CardVisibilitySettings; +} + +// An unsaved post has no visibility until the first save applies the site +// default, so it is resolved here to keep `visibility` present for cards. +export function buildCardConfigPost( + post: CardConfigPostSource | undefined, + defaultContentVisibility: string, +): CardConfigPost | undefined { + if (!post) { + return undefined; + } + + return { + displayName: post.displayName, + isPage: post.displayName === 'page', + showTitleAndFeatureImage: post.showTitleAndFeatureImage ?? true, + visibility: post.visibility || defaultContentVisibility, + }; +} + +export function getCardVisibilitySettings( + post: Pick | undefined, +): CardVisibilitySettings { + const isPage = post?.isPage || post?.displayName === 'page'; + return isPage ? 'web only' : 'web and email'; +} + +export function buildPostCardConfig( + sources: PostCardConfigSources, + ports: PostCardConfigPorts, +): PostCardConfig { + const { settings, config, site, currentUser } = sources; + + return { + unsplash: getSettingValue(settings, 'unsplash') ? sources.unsplashHeaders : null, + klipy: config.klipy?.apiKey ? config.klipy : null, + pinturaConfig: sources.pinturaConfig, + fetchAutocompleteLinks: ports.fetchAutocompleteLinks, + fetchEmbed: ports.fetchEmbed, + fetchLabels: ports.fetchLabels, + renderLabels: !isContributorUser(currentUser), + feature: { + transistor: getSettingValue(settings, 'transistor') === true, + paywallImprovements: config.labs?.paywallImprovements === true, + }, + deprecated: { + headerV1: true, + }, + membersEnabled: getSettingValue(settings, 'members_signup_access') === 'all', + searchLinks: ports.searchLinks, + siteTitle: getSettingValue(settings, 'title') ?? '', + siteDescription: getSettingValue(settings, 'description') ?? '', + siteUrl: getHomepageUrl(site), + siteUuid: site.site_uuid, + stripeEnabled: checkStripeEnabled(settings, config), + post: sources.post, + snippets: sources.snippets, + createSnippet: ports.createSnippet, + deleteSnippet: ports.deleteSnippet, + visibilitySettings: getCardVisibilitySettings(sources.post), + }; +} diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 34571c94ef6..cf1f5436ef3 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -1,33 +1,263 @@ +import { useCallback, useEffect, useState } from 'react'; import { AdminLink } from '@/shared/admin-link'; -import { useParams } from '@tryghost/admin-x-framework'; -import { Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; - -/** - * Placeholder for the React editor, served behind the `editorReact` Labs - * flag. It only proves the EditorGate cutover seam end to end; the editor - * itself still lives in Ember while the flag is off. - */ -export default function EditorScreen() { - const editorPath = useParams()['*']; - const isPage = editorPath?.split('/')[0] === 'page'; - const listPath = isPage ? '/pages' : '/posts'; - const listLabel = isPage ? 'Back to pages' : 'Back to posts'; +import { NotFound } from '@/shared/not-found'; +import { Navigate, useNavigate, useParams } from '@tryghost/admin-x-framework'; +import { Button, LoadingIndicator } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { APIError } from '@tryghost/admin-x-framework/errors'; +import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { + type PageEditorRecord, + useEditPage, + useEditorPage, +} from '@tryghost/admin-x-framework/api/pages'; +import { + type PostEditorRecord, + useEditPost, + useEditorPost, +} from '@tryghost/admin-x-framework/api/posts'; +import { + type User, + isAdminUser, + isAuthorOrContributor, + isContributorUser, + isEditorUser, + isOwnerUser, +} from '@tryghost/admin-x-framework/api/users'; +import type { CardConfigPostSource, PostType } from './card-config'; +import { PostEditor } from './post-editor'; +import { usePostCardConfig } from './use-post-card-config'; +import { usePostSnippets } from './use-post-snippets'; + +type EditorRecord = PostEditorRecord | PageEditorRecord; + +function EditorLoading() { + return ( + + + + ); +} + +function EditorLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + {message} + + + ); +} + +function EditorHeader({ postType }: { postType: PostType }) { + const listLabel = postType === 'page' ? 'Pages' : 'Posts'; return ( - - - The React editor is under construction. Turn off the “React editor” flag in Labs to use the - editor. - - + + ); +} + +function EditorSurface({ postType, record }: { postType: PostType; record?: EditorRecord }) { + const { data: currentUser } = useCurrentUser(); + const showExcerpt = useFeatureFlag('editorExcerpt'); + const [title, setTitle] = useState(() => + record?.title === '(Untitled)' ? '' : (record?.title ?? ''), + ); + const [excerpt, setExcerpt] = useState(() => record?.custom_excerpt ?? ''); + + const canManageSnippets = + !!currentUser && + (isOwnerUser(currentUser) || isAdminUser(currentUser) || isEditorUser(currentUser)); + const { snippets, createSnippet, deleteSnippet, snippetDialog } = usePostSnippets({ + canManage: canManageSnippets, + }); + + const [cardConfigPost] = useState(() => ({ + displayName: postType, + showTitleAndFeatureImage: + record && 'show_title_and_feature_image' in record + ? record.show_title_and_feature_image + : undefined, + visibility: record?.visibility, + })); + const cardConfig = usePostCardConfig({ + post: cardConfigPost, + snippets, + createSnippet, + deleteSnippet, + }); + + if (!cardConfig) { + return ; + } + + return ( + + +
+ +
+ {snippetDialog}
); } + +// The API returns posts the user cannot edit, so authorship is checked here +function shouldReturnToList(user: User, record: EditorRecord): boolean { + const isAuthored = record.authors?.some((author) => author.id === user.id) ?? false; + + if (isAuthorOrContributor(user) && !isAuthored) { + return true; + } + + return isContributorUser(user) && record.status !== 'draft'; +} + +interface ConversionState { + id: string; + record?: EditorRecord; + error?: unknown; +} + +// Mobiledoc content is converted server-side before the editor opens it +function useLexicalConversion(postType: PostType) { + const { mutateAsync: editPost } = useEditPost(); + const { mutateAsync: editPage } = useEditPage(); + const [state, setState] = useState(null); + + const convert = useCallback( + async (source: EditorRecord) => { + const payload = { id: source.id, updated_at: source.updated_at }; + const options = { convertToLexical: true }; + setState({ id: source.id }); + + try { + const record: EditorRecord | undefined = + postType === 'page' + ? (await editPage({ page: payload, options })).pages[0] + : (await editPost({ post: payload, options })).posts[0]; + setState(record ? { id: source.id, record } : { id: source.id, error: true }); + } catch (error) { + setState({ id: source.id, error }); + } + }, + [editPage, editPost, postType], + ); + + return { state, convert }; +} + +function ExistingPostEditor({ postType, id }: { postType: PostType; id: string }) { + const navigate = useNavigate(); + const { data: currentUser } = useCurrentUser(); + const postQuery = useEditorPost(id, { + enabled: postType === 'post', + defaultErrorHandler: false, + }); + const pageQuery = useEditorPage(id, { + enabled: postType === 'page', + defaultErrorHandler: false, + }); + const query = postType === 'page' ? pageQuery : postQuery; + const loaded: EditorRecord | undefined = + postType === 'page' ? pageQuery.data?.pages[0] : postQuery.data?.posts[0]; + const { state: conversion, convert } = useLexicalConversion(postType); + const listPath = postType === 'page' ? '/pages' : '/posts'; + + const returnToList = !!currentUser && !!loaded && shouldReturnToList(currentUser, loaded); + useEffect(() => { + if (returnToList) { + navigate(listPath, { replace: true }); + } + }, [returnToList, navigate, listPath]); + + const needsConversion = !!currentUser && !!loaded?.mobiledoc && !loaded.lexical && !returnToList; + useEffect(() => { + if (needsConversion && loaded && conversion?.id !== loaded.id) { + void convert(loaded); + } + }, [needsConversion, loaded, conversion?.id, convert]); + + const notFound = query.error instanceof APIError && query.error.response?.status === 404; + if (notFound) { + return ; + } + + if (query.error) { + return ( + void query.refetch()} + /> + ); + } + + if (query.isPending || !currentUser || returnToList) { + return ; + } + + if (!loaded) { + return ; + } + + let record = loaded; + if (needsConversion) { + const converted = conversion?.id === loaded.id ? conversion : undefined; + + if (converted?.error) { + return ( + void convert(loaded)} + /> + ); + } + + if (!converted?.record) { + return ; + } + + record = converted.record; + } + + return ; +} + +export default function EditorScreen() { + const editorPath = useParams()['*'] ?? ''; + const [typeSegment, id, ...rest] = editorPath.split('/').filter(Boolean); + + if (!typeSegment) { + return ; + } + + if ((typeSegment !== 'post' && typeSegment !== 'page') || rest.length > 0) { + return ; + } + + if (id) { + return ; + } + + return ; +} diff --git a/apps/admin/src/editor/editor.acceptance.test.tsx b/apps/admin/src/editor/editor.acceptance.test.tsx index b2c6cefa0a3..04b392fc7ea 100644 --- a/apps/admin/src/editor/editor.acceptance.test.tsx +++ b/apps/admin/src/editor/editor.acceptance.test.tsx @@ -1,58 +1,68 @@ import { describe, expect, it } from 'vitest'; -import { page } from 'vitest/browser'; -import { renderAdminApp } from '@test-utils/acceptance'; +import { + fakeAdminEndpoint, + fakePosts, + fakeSnippets, + post, + renderAdminApp, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; const FLAG_ON = { labs: { editorReact: true } }; const FLAG_OFF = { labs: { editorReact: false } }; /** * Proves the `editorReact` flag swap end-to-end in the real admin app: the - * React placeholder appears only when the flag is on, and the Ember side of - * the URL is delegated to otherwise. + * React editor appears only when the flag is on, and the Ember side of the + * URL is delegated to otherwise. * * There is no Ember app in this harness, so "Ember serves it" shows up as the - * React placeholder being absent rather than as an Ember editor being present - * — the Ember half of the handshake (the lexical-editor route aborting its + * React editor being absent rather than as an Ember editor being present — + * the Ember half of the handshake (the lexical-editor route aborting its * transition) is covered in * apps/ember-admin/tests/acceptance/editor-react-flag-test.js. */ describe('Editor flag', () => { - const placeholder = () => page.getByTestId('editor-react-placeholder'); + function fakeEditorWorld() { + fakeSnippets([]); + fakePosts([]); + fakeAdminEndpoint('GET', /^\/posts\/abc123\/\?/, { posts: [post({ id: 'abc123' })] }); + fakeAdminEndpoint('GET', /^\/pages\/abc123\/\?/, { pages: [post({ id: 'abc123' })] }); + } - it('renders the React placeholder when the flag is on', async () => { + it('renders the React editor when the flag is on', async () => { + fakeEditorWorld(); await renderAdminApp('/editor/post/abc123', FLAG_ON); - await expect.element(placeholder()).toBeVisible(); - await expect - .element(page.getByRole('link', { name: 'Back to posts' })) - .toHaveAttribute('href', '#/posts'); + await expect.element(editorScreen.root()).toBeVisible(); + await expect.element(editorScreen.backLink('post')).toHaveAttribute('href', '#/posts'); }); it('serves a new-post URL too', async () => { + fakeEditorWorld(); await renderAdminApp('/editor/post', FLAG_ON); - await expect.element(placeholder()).toBeVisible(); + await expect.element(editorScreen.root()).toBeVisible(); }); it('returns page editors to the pages list', async () => { + fakeEditorWorld(); await renderAdminApp('/editor/page/abc123', FLAG_ON); - await expect.element(placeholder()).toBeVisible(); - await expect - .element(page.getByRole('link', { name: 'Back to pages' })) - .toHaveAttribute('href', '#/pages'); + await expect.element(editorScreen.root()).toBeVisible(); + await expect.element(editorScreen.backLink('page')).toHaveAttribute('href', '#/pages'); }); it('defers to Ember when the flag is off', async () => { await renderAdminApp('/editor/post/abc123', FLAG_OFF); - await expect(placeholder()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); }); it('defers to Ember when the flag is absent entirely', async () => { await renderAdminApp('/editor/post/abc123'); - await expect(placeholder()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); }); }); diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts new file mode 100644 index 00000000000..28c0201c3cf --- /dev/null +++ b/apps/admin/src/editor/editor.screen.ts @@ -0,0 +1,36 @@ +import { page } from 'vitest/browser'; +import { + editorBody, + editorExcerptInput, + editorLoadError, + editorSecondaryInstance, + editorTitleInput, + editorWordCount, + pagesBackLink, + postEditor, + postsBackLink, + tkIndicator, +} from '@tryghost/test-data/selectors/editor'; + +/** Editor screen locators and gestures for acceptance specs; no assertions. */ +export const editorScreen = { + root: () => page.getByTestId(postEditor), + titleInput: () => page.getByTestId(editorTitleInput), + excerptInput: () => page.getByTestId(editorExcerptInput), + /** The primary Koenig content editable. */ + body: () => page.getByTestId(editorBody).getByRole('textbox'), + secondaryInstance: () => page.getByTestId(editorSecondaryInstance), + wordCount: () => page.getByTestId(editorWordCount), + loadError: () => page.getByTestId(editorLoadError), + notFound: () => page.getByRole('heading', { name: 'Page not found' }), + titleTkIndicator: () => page.getByTestId(tkIndicator), + backLink: (postType: 'post' | 'page') => + page.getByRole('link', { + name: postType === 'page' ? pagesBackLink : postsBackLink, + exact: true, + }), + /** Whether keyboard focus is inside the primary Koenig body. */ + bodyHasFocus: (): boolean => + document.querySelector(`[data-testid="${editorBody}"]`)?.contains(document.activeElement) ?? + false, +}; diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx new file mode 100644 index 00000000000..da8c0438d83 --- /dev/null +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -0,0 +1,122 @@ +import * as Sentry from '@sentry/react'; +import { Suspense, useCallback, useMemo } from 'react'; +import { LoadingIndicator } from '@tryghost/shade/components'; +import { koenigFileUploadTypes, useKoenigFileUpload } from '@tryghost/admin-x-framework/hooks'; +import ErrorBoundary from '@/settings/components/error-boundary'; +import { + type EditorResource, + type KoenigInstance, + loadKoenig, +} from '@/settings/components/koenig-loader'; +import type { PostCardConfig } from './card-config'; + +const fileUploader = { + useFileUpload: useKoenigFileUpload, + fileTypes: koenigFileUploadTypes, +}; + +const NOOP = () => {}; + +export interface KoenigPostEditorProps { + initialLexical: string | null; + placeholder: string; + cardConfig: PostCardConfig; + darkMode: boolean; + cursorDidExitAtTop?: () => void; + onChange?: (lexical: unknown) => void; + onSecondaryChange?: (lexical: unknown) => void; + registerAPI: (api: KoenigInstance | null) => void; + registerSecondaryAPI: (api: KoenigInstance | null) => void; + onWordCountChange: (count: number) => void; + onTkCountChange: (count: number) => void; +} + +interface KoenigInstanceMountProps extends KoenigPostEditorProps { + editor: EditorResource; + isSecondary: boolean; + onError: (error: unknown) => void; +} + +// The hidden secondary instance loads the same initial state: Koenig normalises +// documents on load, so its output is the baseline change detection compares against +function KoenigInstanceMount({ + editor, + isSecondary, + onError, + initialLexical, + placeholder, + cardConfig, + darkMode, + cursorDidExitAtTop, + onChange, + onSecondaryChange, + registerAPI, + registerSecondaryAPI, + onWordCountChange, + onTkCountChange, +}: KoenigInstanceMountProps) { + const { KoenigComposer, KoenigEditor, WordCountPlugin, TKCountPlugin } = editor.read(); + + return ( + + ); +} + +export function KoenigPostEditor(props: KoenigPostEditorProps) { + const editor = useMemo(() => loadKoenig(), []); + + const onError = useCallback((error: unknown) => { + // eslint-disable-next-line no-console + console.error(error); + + Sentry.captureException(error, { + tags: { lexical: true }, + contexts: { + koenig: { + version: window['@tryghost/koenig-lexical']?.version, + }, + }, + }); + // not rethrown: Lexical attempts to recover without losing user data + }, []); + + return ( +
+ + + +
+ } + > + + + + +