From d27a8520931bf71d08f1ddeb4b3e545c7083f27b Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Tue, 18 Aug 2026 11:26:51 +0100 Subject: [PATCH 01/22] Added development URL testing guide (#30056) Add a guide for testing Ghost on physical devices and with non-default development URLs: HTTPS, subdirectory installs, and a separate Admin hostname. --- docker/dev-url-testing/Caddyfile | 16 +++ docker/dev-url-testing/compose.yaml | 4 + docs/README.md | 1 + docs/contributing/development-setup.md | 4 + docs/contributing/testing-development-urls.md | 133 ++++++++++++++++++ docs/contributing/testing.md | 4 + 6 files changed, 162 insertions(+) create mode 100644 docker/dev-url-testing/Caddyfile create mode 100644 docker/dev-url-testing/compose.yaml create mode 100644 docs/contributing/testing-development-urls.md diff --git a/docker/dev-url-testing/Caddyfile b/docker/dev-url-testing/Caddyfile new file mode 100644 index 00000000000..5dabf62f285 --- /dev/null +++ b/docker/dev-url-testing/Caddyfile @@ -0,0 +1,16 @@ +{ + local_certs + http_port 9080 +} + +https://site.localhost:8443 { + reverse_proxy http://localhost:2369 { + header_up Host site.localhost:8443 + } +} + +https://admin.localhost:8443 { + reverse_proxy http://localhost:2369 { + header_up Host admin.localhost:8443 + } +} diff --git a/docker/dev-url-testing/compose.yaml b/docker/dev-url-testing/compose.yaml new file mode 100644 index 00000000000..cf5315b78b2 --- /dev/null +++ b/docker/dev-url-testing/compose.yaml @@ -0,0 +1,4 @@ +services: + ghost-dev: + ports: + - "2369:2368" diff --git a/docs/README.md b/docs/README.md index 5d45f49e242..40544951774 100644 --- a/docs/README.md +++ b/docs/README.md @@ -96,6 +96,7 @@ Practice and contributor guides explain how to make and verify changes: - [Error handling](practices/error-handling.md) - [Internationalization](practices/internationalization.md) - [Performance testing](contributing/performance-testing.md) +- [Testing development URLs and devices](contributing/testing-development-urls.md) Reference guides provide tables and other information to look up while working on Ghost: diff --git a/docs/contributing/development-setup.md b/docs/contributing/development-setup.md index 7b5929d5985..ef9b5fefdad 100644 --- a/docs/contributing/development-setup.md +++ b/docs/contributing/development-setup.md @@ -118,6 +118,10 @@ environment and adds the listed tooling: Copy [`.env.example`](../../.env.example) to `.env` only when you need an optional integration. Never commit credentials or the local `.env` file. +To open Ghost on a phone or another computer, or to exercise HTTPS, +subdirectory, and separate-Admin URL behaviour, see +[Testing development URLs and devices](testing-development-urls.md). + ## Data and email After creating the local owner account, populate a development site with stable diff --git a/docs/contributing/testing-development-urls.md b/docs/contributing/testing-development-urls.md new file mode 100644 index 00000000000..aa24d3597b9 --- /dev/null +++ b/docs/contributing/testing-development-urls.md @@ -0,0 +1,133 @@ +# Testing development URLs and devices + +Ghost supports configurations that differ from the normal +`http://localhost:2368` setup. Test the configuration that matters to your +change rather than rebuilding the whole development environment for every URL +edge case. + +## Test on another device + +Use an HTTPS tunnel when you need to open the site or Admin on a phone, tablet, +or another computer. This avoids maintaining local DNS, assigning a static IP, +and installing a local certificate authority on every test device. + +With `pnpm dev` running, install and authenticate the +[ngrok agent](https://ngrok.com/docs/getting-started/), then run: + +```bash +ngrok http 2368 +``` + +Open the HTTPS forwarding URL on the other device. Ghost Admin is available at +`/ghost/` on the same URL. + +The development Caddy gateway trusts the forwarded protocol supplied by ngrok, +Tailscale Funnel, and similar HTTPS tunnel agents. Tunnel the gateway on port +`2368`, not an individual Admin or public-app development server, so requests +continue through the same routing used by the normal development environment. + +Tunnel URLs are normally temporary. The site can be inspected without changing +Ghost's configured URL, but links generated by Ghost will still use +`http://localhost:2368`. When the behaviour under test depends on absolute +URLs, create `ghost/core/config.local.json` with the forwarding URL and restart +the development environment: + +```json +{ + "url": "https://your-forwarding-domain.example" +} +``` + +Do not commit `config.local.json`. Remove the override when the test is +finished. + +Treat a public tunnel URL as temporary public access to the local site. Do not +use production data or credentials, and stop the tunnel after testing. + +## Test HTTPS, subdirectories, and a separate Admin URL + +Use the manual environment below when you need to inspect all three behaviours +in a browser. It runs a local TLS proxy in front of Ghost Core and uses +`.localhost` hostnames, which resolve to the loopback address without editing +`/etc/hosts` or running a DNS server. + +Install [Caddy](https://caddyserver.com/docs/install) if it is not already +available. Create `ghost/core/config.local.json`: + +```json +{ + "url": "https://site.localhost:8443/blog/", + "admin": { + "url": "https://admin.localhost:8443/blog/" + } +} +``` + +The site URL supplies the `/blog/` subdirectory. The separate Admin URL uses +the same subdirectory because Ghost's Admin and API routes remain underneath +the configured site path. + +Start the Docker services with the URL-testing Compose override: + +```bash +DEV_COMPOSE_FILES="-f docker/dev-url-testing/compose.yaml" \ + pnpm nx run ghost-monorepo:docker:up +``` + +The override exposes Ghost Core directly on port `2369`. This is intentional: +the extra Caddy process must connect directly to Ghost so Express can trust its +forwarded HTTPS header. + +In another terminal, start the local TLS proxy: + +```bash +caddy run --adapter caddyfile \ + --config docker/dev-url-testing/Caddyfile +``` + +Caddy may ask for the system password the first time so it can install its +local certificate authority. Open: + +- Site: `https://site.localhost:8443/blog/` +- Admin: `https://admin.localhost:8443/blog/ghost/` + +Check the behaviour affected by the change, including redirects, generated +links, API requests, cookies, and assets. A request to the site hostname's +`/blog/ghost/` path should redirect to the Admin hostname. + +This configuration serves the Admin assets available from Ghost Core. It does +not use the normal Admin Vite/HMR route because that development server's +startup probe assumes the default root URL. Use the automated tests below for +focused development, then use this manual environment for final browser +verification. + +Press `Ctrl+C` to stop Caddy. Stop the Docker services with the same override: + +```bash +DEV_COMPOSE_FILES="-f docker/dev-url-testing/compose.yaml" pnpm docker:down +``` + +Delete `ghost/core/config.local.json` before returning to `pnpm dev`; otherwise +Ghost will continue using the alternate URLs. The file is ignored by Git. + +## Add automated URL-configuration coverage + +Ghost Core keeps its advanced URL configuration coverage in +`ghost/core/test/e2e-frontend/advanced-url-config.test.js`. It currently covers +subdirectory routing and redirects to a separate Admin origin, and is the +default place to add HTTP-level coverage for behaviour that depends on: + +- an HTTPS site URL; +- a site installed in a subdirectory; or +- Admin and the site using different origins. + +Run the file from `ghost/core`: + +```bash +pnpm test:single test/e2e-frontend/advanced-url-config.test.js +``` + +Add a focused case there when the behaviour can be verified through Ghost's +HTTP responses, redirects, generated URLs, or routing. Tests elsewhere in +`ghost/core/test/` also set `url` and `admin:url` through the shared test config +helpers when a lower-level test is sufficient. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 39a489b58c6..60a0db1dec0 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -44,6 +44,10 @@ When a regression crosses several layers, prefer a focused test at the lowest layer that proves the fix. Add a broader acceptance or browser E2E test when the integration between layers is itself the behavior being protected. +For physical-device testing and URL configurations such as HTTPS, +subdirectories, or a separate Admin origin, see +[Testing development URLs and devices](testing-development-urls.md). + ## Run Focused Tests Nx can run a target for one workspace from the repository root: From 43c32755fec30787ff950ea8b5173783099a34d6 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 18 Aug 2026 11:55:16 +0100 Subject: [PATCH 02/22] Upload content CSVs to new endpoint from universal importer modal (#30024) ref https://linear.app/ghost/issue/MIG-1437/ - adds a testid attribute to the existing file upload dropbox description - adds test to ensure existing behaviour is not changed when the `csvContentImporter` flag is enabled - adds tests for when the `csvContentImporter` is enabled - adds a conditional for the file drop placeholder text --- apps/admin-x-framework/src/api/posts.ts | 11 ++ .../test/unit/api/db.test.tsx | 40 +++++ .../test/unit/api/posts.test.tsx | 48 ++++++ .../universal-import-modal.test.tsx | 141 ++++++++++++++++++ .../universal-import-modal.tsx | 21 ++- .../admin/__snapshots__/db.test.js.snap | 31 ++++ ghost/core/test/e2e-api/admin/db.test.js | 17 +++ 7 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 apps/admin-x-framework/test/unit/api/db.test.tsx create mode 100644 apps/admin-x-framework/test/unit/api/posts.test.tsx create mode 100644 apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 2f2f13fcbe7..96fad4d6592 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -80,6 +80,17 @@ export const useDeletePost = createMutation({ path: id => `/posts/${id}/` }); +export const useImportContentCSV = createMutation({ + method: 'POST', + retry: false, + path: () => '/posts/upload/', + body: (file) => { + const formData = new FormData(); + formData.append('postsfile', file); + return formData; + } +}); + // Search index endpoints for efficient search export const useSearchIndexPosts = createQuery({ dataType, diff --git a/apps/admin-x-framework/test/unit/api/db.test.tsx b/apps/admin-x-framework/test/unit/api/db.test.tsx new file mode 100644 index 00000000000..59bc01b2c66 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/db.test.tsx @@ -0,0 +1,40 @@ +import {act} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; +import {renderHookWithProviders} from '../../../src/test/test-utils'; +import {useImportContent} from '../../../src/api/db'; +import {withMockFetch} from '../../utils/mock-fetch'; + +describe('db api', () => { + it('imports JSON content via the db endpoint', async () => { + const file = new File(['{}'], 'export.json', {type: 'application/json'}); + + await withMockFetch({}, async (mock) => { + const {result} = renderHookWithProviders(() => useImportContent()); + + await act(async () => { + await result.current.mutateAsync(file); + }); + + expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/db/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(mock.calls[0][1].body).toBeInstanceOf(FormData); + expect(mock.calls[0][1].body.get('importfile')).toBe(file); + expect(mock.calls[0][1].headers).not.toHaveProperty('content-type'); + }); + }); + + it('imports zip content via the db endpoint', async () => { + const file = new File(['PK'], 'export.zip', {type: 'application/zip'}); + + await withMockFetch({}, async (mock) => { + const {result} = renderHookWithProviders(() => useImportContent()); + + await act(async () => { + await result.current.mutateAsync(file); + }); + + expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/db/'); + expect(mock.calls[0][1].body.get('importfile')).toBe(file); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/posts.test.tsx b/apps/admin-x-framework/test/unit/api/posts.test.tsx new file mode 100644 index 00000000000..abf6f07d0a7 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/posts.test.tsx @@ -0,0 +1,48 @@ +import {act} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; +import {createTestQueryClient, renderHookWithProviders} from '../../../src/test/test-utils'; +import {useImportContentCSV} from '../../../src/api/posts'; +import {withMockFetch} from '../../utils/mock-fetch'; + +describe('posts api', () => { + it('imports CSV content via the posts upload endpoint', async () => { + const file = new File(['title\nHello'], 'posts.csv', {type: 'text/csv'}); + + await withMockFetch({}, async (mock) => { + const {result} = renderHookWithProviders(() => useImportContentCSV()); + + await act(async () => { + await result.current.mutateAsync(file); + }); + + expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/posts/upload/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(mock.calls[0][1].body).toBeInstanceOf(FormData); + expect(mock.calls[0][1].body.get('postsfile')).toBe(file); + expect(mock.calls[0][1].headers).not.toHaveProperty('content-type'); + }); + }); + + it('does not retry content CSV import uploads after transient network failures', async () => { + const queryClient = createTestQueryClient(); + const file = new File(['title\nHello'], 'posts.csv', {type: 'text/csv'}); + const originalFetch = globalThis.fetch; + const mockFetch = vi.fn(() => Promise.reject(new TypeError('Network failed'))); + + vi.useFakeTimers(); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + try { + const {result} = renderHookWithProviders(() => useImportContentCSV(), {queryClient}); + const importPromise = result.current.mutateAsync(file).catch(error => error); + + await vi.advanceTimersByTimeAsync(600); + + expect(mockFetch).toHaveBeenCalledTimes(1); + await expect(importPromise).resolves.toBeInstanceOf(Error); + } finally { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); +}); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx new file mode 100644 index 00000000000..e3c09dbafb3 --- /dev/null +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -0,0 +1,141 @@ +import NiceModal from '@ebay/nice-modal-react'; +import UniversalImportModal from '@/settings/app/components/settings/advanced/migration-tools/universal-import-modal'; +import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; + +const {mockImportContent, mockImportContentCSV, mockUseFeatureFlag, mockHandleError} = vi.hoisted(() => ({ + mockImportContent: vi.fn(), + mockImportContentCSV: vi.fn(), + mockUseFeatureFlag: vi.fn(), + mockHandleError: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework/api/db', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/api/db'); + return {...actual, useImportContent: () => ({mutateAsync: mockImportContent})}; +}); + +vi.mock('@tryghost/admin-x-framework/api/posts', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/api/posts'); + return {...actual, useImportContentCSV: () => ({mutateAsync: mockImportContentCSV})}; +}); + +vi.mock('@tryghost/admin-x-framework/hooks', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/hooks'); + return {...actual, useHandleError: () => mockHandleError}; +}); + +vi.mock('@/settings/app/hooks/use-feature-flag', () => ({ + default: (flag: string) => mockUseFeatureFlag(flag) +})); + +describe('UniversalImportModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockImportContent.mockResolvedValue({}); + mockImportContentCSV.mockResolvedValue({}); + }); + + afterEach(() => { + void NiceModal.remove(UniversalImportModal); + }); + + const showModal = () => { + render(); + + act(() => { + void NiceModal.show(UniversalImportModal); + }); + }; + + const fileInput = async () => await screen.findByTestId('import-file'); + + const description = async () => await screen.findByTestId('import-file-description'); + + const dropFile = async (file: File) => { + const input = await fileInput(); + + // act() flushes react-dropzone's async file processing, so a + // "not called" assertion afterwards can't pass vacuously + await act(async () => { + fireEvent.change(input, {target: {files: [file]}}); + }); + }; + + it('reads the csvContentImporter flag', async () => { + mockUseFeatureFlag.mockReturnValue(false); + showModal(); + + await fileInput(); + expect(mockUseFeatureFlag).toHaveBeenCalledWith('csvContentImporter'); + }); + + it('sends JSON files to the db import when csvContentImporter is disabled', async () => { + mockUseFeatureFlag.mockReturnValue(false); + showModal(); + + expect(await description()).toHaveTextContent(/Select any JSON or zip file/); + + const file = new File(['{}'], 'export.json', {type: 'application/json'}); + await dropFile(file); + + await waitFor(() => expect(mockImportContent).toHaveBeenCalledWith(file)); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + expect(await screen.findByTestId('confirmation-modal')).toHaveTextContent('Import in progress'); + }); + + it('rejects CSV files when csvContentImporter is disabled', async () => { + mockUseFeatureFlag.mockReturnValue(false); + showModal(); + + const input = await fileInput(); + expect(input).toHaveAttribute('accept', expect.not.stringContaining('.csv')); + + await dropFile(new File(['title\nHello'], 'posts.csv', {type: 'text/csv'})); + + expect(mockImportContent).not.toHaveBeenCalled(); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + expect(screen.getByTestId('universal-import-modal')).toBeInTheDocument(); + }); + + it('sends CSV files to the posts upload endpoint when csvContentImporter is enabled', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + expect(await description()).toHaveTextContent(/Select any JSON, zip or CSV file/); + + const input = await fileInput(); + expect(input).toHaveAttribute('accept', expect.stringContaining('.csv')); + + const file = new File(['title\nHello'], 'posts.csv', {type: 'text/csv'}); + await dropFile(file); + + await waitFor(() => expect(mockImportContentCSV).toHaveBeenCalledWith(file)); + expect(mockImportContent).not.toHaveBeenCalled(); + expect(await screen.findByTestId('confirmation-modal')).toHaveTextContent('Import in progress'); + }); + + it('still sends JSON files to the db import when csvContentImporter is enabled', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + const file = new File(['{}'], 'export.json', {type: 'application/json'}); + await dropFile(file); + + await waitFor(() => expect(mockImportContent).toHaveBeenCalledWith(file)); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + }); + + it('surfaces an error and keeps the modal open when the import fails', async () => { + const error = new Error('Import failed'); + mockUseFeatureFlag.mockReturnValue(true); + mockImportContentCSV.mockRejectedValue(error); + showModal(); + + await dropFile(new File(['title\nHello'], 'posts.csv', {type: 'text/csv'})); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + expect(screen.getByTestId('universal-import-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmation-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx index 63aec562128..c43db66a783 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,5 +1,6 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; +import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {Button, Dropzone} from '@tryghost/shade/components'; import {ExternalLink} from 'lucide-react'; import {Inline} from '@tryghost/shade/primitives'; @@ -7,14 +8,21 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useImportContent} from '@tryghost/admin-x-framework/api/db'; +import {useImportContentCSV} from '@tryghost/admin-x-framework/api/posts'; const UniversalImportModal: React.FC = () => { const modal = useModal(); const {mutateAsync: importContent} = useImportContent(); + const {mutateAsync: importContentCSV} = useImportContentCSV(); + const csvContentImporter = useFeatureFlag('csvContentImporter'); const [uploading, setUploading] = useState(false); const handleError = useHandleError(); const {confirm} = useConfirmation(); + const acceptedTypes: React.ComponentProps['accept'] = csvContentImporter + ? {'application/json': ['.json'], 'application/zip': ['.zip'], 'text/csv': ['.csv']} + : {'application/json': ['.json'], 'application/zip': ['.zip']}; + return ( { >
{ setUploading(true); try { - await importContent(file); + if (csvContentImporter && file.name.toLowerCase().endsWith('.csv')) { + await importContentCSV(file); + } else { + await importContent(file); + } modal.remove(); confirm({ title: 'Import in progress', @@ -56,9 +69,9 @@ const UniversalImportModal: React.FC = () => { } }} > -
+
{uploading ? 'Uploading...' : <> - Select any JSON or zip file that contains
posts and settings + Select any {csvContentImporter ? 'JSON, zip or CSV' : 'JSON or zip'} file that contains
posts and settings }
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/db.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/db.test.js.snap index 47615c8ba54..577f1f6af7d 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/db.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/db.test.js.snap @@ -110,3 +110,34 @@ Object { "x-powered-by": "Express", } `; + +exports[`DB API Rejects CSV file uploads 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Unsupported file. Please try any of the following formats: .json,.zip", + "property": null, + "type": "UnsupportedMediaTypeError", + }, + ], +} +`; + +exports[`DB API Rejects CSV file uploads 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "267", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/db.test.js b/ghost/core/test/e2e-api/admin/db.test.js index 1599215444a..b10f985d443 100644 --- a/ghost/core/test/e2e-api/admin/db.test.js +++ b/ghost/core/test/e2e-api/admin/db.test.js @@ -131,4 +131,21 @@ describe('DB API', function () { }] }); }); + + // CSV content is imported via POST /posts/upload/, not the db backup endpoint + it('Rejects CSV file uploads', async function () { + await agent + .post('db/') + .attach('importfile', path.join(__dirname, '../../utils/fixtures/csv/valid-posts-import.csv')) + .expectStatus(415) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + errors: [{ + id: anyErrorId + }] + }); + }); }); From fe7771d1ebd84df74ad99bb8eed2cfe842022769 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Tue, 18 Aug 2026 11:31:31 +0100 Subject: [PATCH 03/22] Added API-level tests pinning every member filter operator Ghost Admin composes a filter from its own field catalogue and sends NQL, and nothing on either side can tell whether the server honours what was written: admin cannot see the SQL, and the query layer cannot see what admin meant. The only place the two are observable together is the browse endpoint, so these run the NQL admin emits against a real database and assert exactly which members come back. Derived fields are the reason this matters more than it looks. A column filter is its own evidence, but `subscribed` is rewritten to a newsletter relation before it reaches the model, `email_disabled` combines with it to mean something neither column says alone, and a label is a join. Those shapes are agreed between two codebases and pinned nowhere. This lands ahead of the filter engine refactor so the safety net exists before anything moves. --- .../admin/members-filter-matrix.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 ghost/core/test/e2e-api/admin/members-filter-matrix.test.ts diff --git a/ghost/core/test/e2e-api/admin/members-filter-matrix.test.ts b/ghost/core/test/e2e-api/admin/members-filter-matrix.test.ts new file mode 100644 index 00000000000..8cdd5011f76 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/members-filter-matrix.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; + +const {agentProvider, fixtureManager} = require('../../utils/e2e-framework'); +const models = require('../../../core/server/models'); + +// The NQL strings below are copied verbatim from what Ghost Admin emits. Do not tidy them into +// something that reads better — their exact shape is what is under test. +describe('Members filtering, every operator admin can emit', function () { + let agent: { + get: (_url: string) => any; + post: (_url: string) => any; + put: (_url: string) => any; + loginAsOwner: () => Promise; + }; + + let newsletterSlug: string; + + const ALICE = 'alice@example.com'; + const BOB = 'bob@example.com'; + const CAROL = 'carol@example.com'; + + async function browse(filter: string): Promise { + const {body} = await agent + .get(`members/?filter=${encodeURIComponent(filter)}&limit=all`) + .expectStatus(200); + return body.members.map((member: {email: string}) => member.email).sort(); + } + + /** + * Dates must be the `YYYY-MM-DD HH:mm:ss` UTC string Ghost stores. A JS Date reaches SQLite + * as epoch milliseconds and compares as a number against the filter's text, matching every row. + */ + async function setColumns(email: string, columns: Record) { + await models.Base.knex('members').where({email}).update(columns); + } + + beforeAll(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('users', 'newsletters'); + await agent.loginAsOwner(); + + const {body: newsletters} = await agent.get('newsletters/?limit=1').expectStatus(200); + newsletterSlug = newsletters.newsletters[0].slug; + + const newsletterId = newsletters.newsletters[0].id; + + async function createMember(member: Record) { + await agent.post('members/').body({members: [member]}).expectStatus(201); + } + + await createMember({email: ALICE, name: 'Alice Anderson', labels: [{name: 'vip'}], newsletters: [{id: newsletterId}]}); + await createMember({email: BOB, name: 'Bob Brown', labels: [{name: 'vip'}], newsletters: []}); + await createMember({email: CAROL, name: 'Carol Clark', newsletters: [{id: newsletterId}]}); + + await setColumns(ALICE, {status: 'free', email_count: 0, created_at: '2024-01-01 00:00:00'}); + await setColumns(BOB, {status: 'paid', email_count: 5, created_at: '2024-06-01 00:00:00'}); + await setColumns(CAROL, {status: 'comped', email_count: 10, created_at: '2025-01-01 00:00:00', email_disabled: true}); + }); + + afterAll(async function () { + await models.Base.knex('members_labels').del(); + await models.Base.knex('members_newsletters').del(); + await models.Base.knex('members').del(); + }); + + function matrix(cases: Array<{what: string, nql: string, expect: string[]}>) { + for (const {what, nql, expect} of cases) { + it(what, async function () { + assert.deepEqual(await browse(nql), [...expect].sort(), `filter: ${nql}`); + }); + } + } + + describe('native — text on a column', function () { + matrix([ + {what: 'name is', nql: "name:'Alice Anderson'", expect: [ALICE]}, + {what: 'name contains', nql: "name:~'Brown'", expect: [BOB]}, + {what: 'name does not contain', nql: "name:-~'Brown'", expect: [ALICE, CAROL]}, + {what: 'name starts with', nql: "name:~^'Carol'", expect: [CAROL]}, + {what: 'name ends with', nql: "name:~$'Clark'", expect: [CAROL]}, + {what: 'email contains', nql: "email:~'bob'", expect: [BOB]}, + {what: 'email is not', nql: "email:-'bob@example.com'", expect: [ALICE, CAROL]} + ]); + }); + + describe('native — a value from a fixed set', function () { + matrix([ + {what: 'status is', nql: 'status:paid', expect: [BOB]}, + {what: 'status is not', nql: 'status:-paid', expect: [ALICE, CAROL]} + ]); + }); + + describe('native — a number', function () { + matrix([ + {what: 'email count is', nql: 'email_count:5', expect: [BOB]}, + {what: 'email count is greater', nql: 'email_count:>5', expect: [CAROL]}, + {what: 'email count is less', nql: 'email_count:<5', expect: [ALICE]} + ]); + }); + + // Boundaries fall between members, never on one. SQLite compares the stored + // `2024-06-01 00:00:00` against the filter's `2024-06-01T00:00:00.000Z` as text, and a space + // sorts before `T`, so an exact tie tests storage format rather than the operator. + describe('native — a date', function () { + matrix([ + {what: 'created before', nql: "created_at:<'2024-03-01T00:00:00.000Z'", expect: [ALICE]}, + {what: 'created on or before', nql: "created_at:<='2024-09-01T23:59:59.999Z'", expect: [ALICE, BOB]}, + {what: 'created after', nql: "created_at:>'2024-09-01T00:00:00.000Z'", expect: [CAROL]}, + {what: 'created on or after', nql: "created_at:>='2024-03-01T00:00:00.000Z'", expect: [BOB, CAROL]} + ]); + }); + + describe('derived — a label, which is a join', function () { + matrix([ + {what: 'label is any of', nql: 'label:[vip]', expect: [ALICE, BOB]}, + {what: 'label is none of', nql: 'label:-[vip]', expect: [CAROL]} + ]); + }); + + describe('derived — subscribed, which is not a column at all', function () { + it('is subscribed means on a newsletter and still being sent to', async function () { + assert.deepEqual(await browse('(subscribed:true+email_disabled:0)'), [ALICE]); + }); + + it('is unsubscribed means off every newsletter, still sendable', async function () { + assert.deepEqual(await browse('(subscribed:false+email_disabled:0)'), [BOB]); + }); + + it('is email-disabled is about the flag alone', async function () { + assert.deepEqual(await browse('(email_disabled:1)'), [CAROL]); + }); + + it('is not subscribed is the negation admin writes, joined with a comma', async function () { + assert.deepEqual(await browse('(subscribed:false,email_disabled:1)'), [BOB, CAROL].sort()); + }); + }); + + describe('derived — one newsletter, by slug', function () { + it('subscribed to a named newsletter', async function () { + assert.deepEqual(await browse(`(newsletters.slug:${newsletterSlug}+email_disabled:0)`), [ALICE]); + }); + + it('unsubscribed from a named newsletter', async function () { + assert.deepEqual(await browse(`(newsletters.slug:-${newsletterSlug},email_disabled:1)`), [BOB, CAROL].sort()); + }); + }); + + // `^` and `$` are both characters a name can contain and the syntax for "starts with" and + // "ends with". Confusing the two returns members rather than erroring, so each pair below + // runs both readings and pins that they select different people. + describe('native — a text value containing the anchor characters', function () { + const DIGIT = 'digit@example.com'; + const DOLLAR = 'dollar@example.com'; + const CARET = 'caret@example.com'; + const DRAFT = 'draft@example.com'; + + beforeAll(async function () { + for (const [email, name] of [ + [DIGIT, 'Room 5'], + [DOLLAR, 'Ticket 5$'], + [CARET, '^Draft note'], + [DRAFT, 'Draft note'] + ]) { + await agent.post('members/').body({members: [{email, name}]}).expectStatus(201); + } + }); + + afterAll(async function () { + await models.Base.knex('members').whereIn('email', [DIGIT, DOLLAR, CARET, DRAFT]).del(); + }); + + it('contains a value ending in a dollar', async function () { + assert.deepEqual(await browse("name:~'5$'"), [DOLLAR]); + }); + + it('ends with the digit before it, which is a different question', async function () { + assert.deepEqual(await browse("name:~$'5'"), [DIGIT]); + }); + + it('ends with the dollar itself', async function () { + assert.deepEqual(await browse("name:~$'5$'"), [DOLLAR]); + }); + + it('contains a value starting with a caret', async function () { + assert.deepEqual(await browse("name:~'^Draft'"), [CARET]); + }); + + it('starts with the word after it, which is a different question', async function () { + assert.deepEqual(await browse("name:~^'Draft'"), [DRAFT]); + }); + + it('starts with the caret itself', async function () { + assert.deepEqual(await browse("name:~^'^Draft'"), [CARET]); + }); + }); + + describe('combinations, as a publisher builds them', function () { + matrix([ + {what: 'two natives', nql: "status:-paid+name:~'a'", expect: [ALICE, CAROL]}, + {what: 'a native and a derived', nql: 'label:[vip]+status:paid', expect: [BOB]}, + {what: 'a derived and a date', nql: "label:[vip]+created_at:<'2024-03-01T00:00:00.000Z'", expect: [ALICE]}, + {what: 'a filter matching nobody still answers', nql: "status:paid+name:~'Zeta'", expect: []} + ]); + + it('a subscription state alongside a native filter', async function () { + assert.deepEqual(await browse('(subscribed:true+email_disabled:0)+status:free'), [ALICE]); + }); + }); + + describe('canonical ordering, which saved views depend on', function () { + it('gives the same members whichever order the clauses are written in', async function () { + const one = await browse("status:-paid+name:~'a'"); + const other = await browse("name:~'a'+status:-paid"); + + assert.deepEqual(one, other); + }); + }); +}); From 22f52ddb12a7ff5a0ed207674fcbbaa0b5a6e6da Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 18 Aug 2026 05:44:44 -0700 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=8E=A8=20Improved=20automation=20ca?= =?UTF-8?q?nvas=20panning=20so=20the=20flow=20can't=20be=20lost=20off=20sc?= =?UTF-8?q?reen=20(#29962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/NY-1520 Automation workflows could be panned completely out of view, while graph or zoom changes could leave the viewport outside its updated bounds. Centralized viewport measurement, zoom configuration and constraint handling so workflows remain recoverable across canvas, graph and zoom changes. Updated the controls to match the supported 50–100% zoom range and disable actions at their limits. --- .../components/canvas/automation-canvas.tsx | 28 ++++- .../components/canvas/controls.tsx | 8 +- .../components/canvas/use-canvas-viewport.ts | 116 ++++++++++++++++++ apps/admin/src/automations/editor.test.tsx | 59 +++++++-- 4 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 apps/admin/src/automations/components/canvas/use-canvas-viewport.ts diff --git a/apps/admin/src/automations/components/canvas/automation-canvas.tsx b/apps/admin/src/automations/components/canvas/automation-canvas.tsx index 6875bab5cad..f3c2aad0369 100644 --- a/apps/admin/src/automations/components/canvas/automation-canvas.tsx +++ b/apps/admin/src/automations/components/canvas/automation-canvas.tsx @@ -19,6 +19,8 @@ import {isEmptyEmailLexical} from '@/automations/utils'; import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; import {useLocation, useNavigate, useSearchParams} from '@tryghost/admin-x-framework'; import type {EmailModalMode} from '@/automations/components/types'; +import {CANVAS_ZOOM_CONFIG, useCanvasViewport} from './use-canvas-viewport'; +import type {CanvasContentBounds} from './use-canvas-viewport'; const NODE_X = 0; const NODE_WIDTH = 256; @@ -31,6 +33,8 @@ const NODE_VISUAL_GAP_Y = 112; const REGULAR_NODE_HEIGHT = 68; const EMAIL_NODE_WITH_STATS_HEIGHT = 133; const INITIAL_VIEWPORT_Y = 40; +// Rendered height of the tail node (h-12) — used to derive the content's bottom edge for the pan bound. +const TAIL_NODE_HEIGHT = 48; const NODE_ENTER_ANIMATION_DURATION = 250; const DISABLED_REASON = 'Maximum steps added'; const DEFAULT_EDGE_STROKE = 'var(--xy-edge-stroke)'; @@ -169,7 +173,7 @@ type BuildGraphParams = { selectedStepId: string | null; } -const buildGraph = ({actionErrors, automation, automationAnalyticsEnabled, disabled, onDelete, onEditEmailBody, onPick, onPreviewEmail, onSelectStep, newStepId, selectedStepId}: BuildGraphParams): { nodes: AutomationFlowNode[]; edges: Edge[] } => { +const buildGraph = ({actionErrors, automation, automationAnalyticsEnabled, disabled, onDelete, onEditEmailBody, onPick, onPreviewEmail, onSelectStep, newStepId, selectedStepId}: BuildGraphParams): { nodes: AutomationFlowNode[]; edges: Edge[]; contentBounds: CanvasContentBounds } => { const ordered = getInitialActionOrder(automation); const baseNodeProps = { draggable: false, @@ -251,6 +255,14 @@ const buildGraph = ({actionErrors, automation, automationAnalyticsEnabled, disab draggable: false, connectable: false }); + // Content bounds in flow coordinates, derived from node positions so the pan bound keeps + // working if the graph ever grows wider (e.g. branching). + const xs = nodes.map(node => node.position.x); + const contentBounds: CanvasContentBounds = { + left: Math.min(...xs), + right: Math.max(...xs) + NODE_WIDTH, + bottom: cursorY + TAIL_NODE_HEIGHT + }; // Every connecting line between existing nodes gets a circular + on hover. The trailing edge into the // tail node intentionally has none — the rectangular tail button already covers that slot. @@ -284,7 +296,7 @@ const buildGraph = ({actionErrors, automation, automationAnalyticsEnabled, disab style: {stroke: DEFAULT_EDGE_STROKE} }); - return {nodes, edges}; + return {nodes, edges, contentBounds}; }; const getInitialViewport = (canvasWidth: number): { x: number; y: number; zoom: number } => ({ @@ -473,6 +485,11 @@ const AutomationCanvas: React.FC = ({ }); }, [actionErrors, automation, automationAnalyticsEnabled, handleContextMenuEditEmail, handleContextMenuPreviewEmail, handlePick, handleRequestDelete, newStepId, selectedStepId]); + const viewport = useCanvasViewport({ + contentBounds: graph?.contentBounds, + initialViewport: initialViewport.current + }); + const clearDetail = useCallback(() => { setSelectedStep(null); }, []); @@ -539,22 +556,27 @@ const AutomationCanvas: React.FC = ({ } return ( -
+
{ if (event.button !== 0) { return; diff --git a/apps/admin/src/automations/components/canvas/controls.tsx b/apps/admin/src/automations/components/canvas/controls.tsx index 3f4eba9f2d9..cbc4e5a2c5d 100644 --- a/apps/admin/src/automations/components/canvas/controls.tsx +++ b/apps/admin/src/automations/components/canvas/controls.tsx @@ -3,9 +3,9 @@ import React, {useState} from 'react'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger} from '@tryghost/shade/components'; import {Controls, useReactFlow, useViewport} from '@xyflow/react'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; +import {CANVAS_ZOOM_CONFIG} from './use-canvas-viewport'; const VIEWPORT_ANIMATION_DURATION = 180; -const ZOOM_PRESETS = [1.5, 1, 0.75, 0.5, 0.25]; export const AutomationCanvasControls: React.FC = () => { const [open, setOpen] = useState(false); @@ -36,6 +36,7 @@ export const AutomationCanvasControls: React.FC = () => { - {ZOOM_PRESETS.map((preset) => { + {CANVAS_ZOOM_CONFIG.presets.map((preset) => { const presetPercent = Math.round(preset * 100); const isSelected = Math.abs(zoom - preset) < 0.01; return ( @@ -79,6 +80,7 @@ export const AutomationCanvasControls: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts b/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts new file mode 100644 index 00000000000..3ea955f56cc --- /dev/null +++ b/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts @@ -0,0 +1,116 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {CoordinateExtent, Edge, Node, OnInit, OnMove, ReactFlowInstance, Viewport} from '@xyflow/react'; + +export type CanvasContentBounds = { + left: number; + right: number; + bottom: number; +}; + +export const CANVAS_ZOOM_CONFIG = { + minZoom: 0.5, + maxZoom: 1, + presets: [1, 0.75, 0.5] +} as const; + +// Keep the viewport center over the content. Each content edge can move as far as the center of +// the canvas, while short flows retain their initial top-anchored position. +const getPanTranslateExtent = ( + content: CanvasContentBounds, + size: {width: number; height: number}, + viewport: Pick +): CoordinateExtent => { + const halfWidth = size.width / (2 * viewport.zoom); + const halfHeight = size.height / (2 * viewport.zoom); + const initialVisibleBottom = (size.height - viewport.y) / viewport.zoom; + + return [ + [content.left - halfWidth, -halfHeight], + [content.right + halfWidth, Math.max(content.bottom + halfHeight, initialVisibleBottom)] + ]; +}; + +const constrainViewport = (viewport: Viewport, size: {width: number; height: number}, extent: CoordinateExtent): Viewport => ({ + x: Math.min(Math.max(viewport.x, size.width - (extent[1][0] * viewport.zoom)), -extent[0][0] * viewport.zoom), + y: Math.min(Math.max(viewport.y, size.height - (extent[1][1] * viewport.zoom)), -extent[0][1] * viewport.zoom), + zoom: viewport.zoom +}); + +export const useCanvasViewport = ({contentBounds, initialViewport}: { + contentBounds?: CanvasContentBounds; + initialViewport: Viewport; +}) => { + const [canvasSize, setCanvasSize] = useState({width: 0, height: 0}); + const [reactFlowInstance, setReactFlowInstance] = useState | null>(null); + const [zoom, setZoom] = useState(initialViewport.zoom); + const resizeObserverRef = useRef(null); + + const measureCanvas = useCallback((element: HTMLDivElement | null) => { + resizeObserverRef.current?.disconnect(); + + if (!element) { + resizeObserverRef.current = null; + return; + } + + const update = () => { + const nextSize = {width: element.clientWidth, height: element.clientHeight}; + setCanvasSize(currentSize => ( + currentSize.width === nextSize.width && currentSize.height === nextSize.height + ? currentSize + : nextSize + )); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + resizeObserverRef.current = observer; + }, []); + + const translateExtent = useMemo(() => ( + contentBounds && canvasSize.width && canvasSize.height + ? getPanTranslateExtent(contentBounds, canvasSize, {y: initialViewport.y, zoom}) + : undefined + ), [canvasSize, contentBounds, initialViewport.y, zoom]); + + const onInit = useCallback>((instance) => { + setReactFlowInstance(instance); + setZoom(instance.getViewport().zoom); + }, []); + + const onMove = useCallback((_, viewport) => { + setZoom(viewport.zoom); + }, []); + + const contentBottom = contentBounds?.bottom; + const contentLeft = contentBounds?.left; + const contentRight = contentBounds?.right; + + useEffect(() => { + if (!reactFlowInstance || !canvasSize.width || !canvasSize.height || contentBottom === undefined || contentLeft === undefined || contentRight === undefined) { + return; + } + + // React Flow applies a new translateExtent to future interactions but does not constrain + // the current transform. Clamp it explicitly after the canvas, graph, or zoom changes. + const currentViewport = reactFlowInstance.getViewport(); + const nextExtent = getPanTranslateExtent({ + bottom: contentBottom, + left: contentLeft, + right: contentRight + }, canvasSize, {y: initialViewport.y, zoom}); + const nextViewport = constrainViewport(currentViewport, canvasSize, nextExtent); + + if (nextViewport.x !== currentViewport.x || nextViewport.y !== currentViewport.y) { + void reactFlowInstance.setViewport(nextViewport); + } + }, [canvasSize.height, canvasSize.width, contentBottom, contentLeft, contentRight, initialViewport.y, reactFlowInstance, zoom]); + + return { + measureCanvas, + onInit, + onMove, + translateExtent + }; +}; diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 48bc3153b22..c07aaf5b1b6 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -123,6 +123,8 @@ const mockEditMutation = { }; const mockReactFlow = { fitView: vi.fn(), + getViewport: vi.fn(() => ({x: 0, y: 0, zoom: mockViewportZoom})), + setViewport: vi.fn(), zoomIn: vi.fn(), zoomOut: vi.fn(), zoomTo: vi.fn() @@ -168,6 +170,8 @@ type StubReactFlowProps = { edgeTypes?: Record>; onNodeClick?: (event: React.MouseEvent, node: StubNode) => void; onNodeDoubleClick?: (event: React.MouseEvent, node: StubNode) => void; + onInit?: (instance: typeof mockReactFlow) => void; + onMove?: (event: MouseEvent | TouchEvent | null, viewport: {x: number; y: number; zoom: number}) => void; onPaneClick?: (event: React.MouseEvent) => void; zoomOnDoubleClick?: boolean; }; @@ -178,8 +182,13 @@ vi.mock('@xyflow/react', async () => { const actual = await vi.importActual('@xyflow/react'); return { ...actual, - ReactFlow: ({nodes, edges, children, className, nodeTypes, edgeTypes, onNodeClick, onNodeDoubleClick, onPaneClick, zoomOnDoubleClick}: StubReactFlowProps) => ( -
+ ReactFlow: ({nodes, edges, children, className, nodeTypes, edgeTypes, onInit, onNodeClick, onNodeDoubleClick, onPaneClick, zoomOnDoubleClick}: StubReactFlowProps) => { + React.useEffect(() => { + onInit?.(mockReactFlow); + }, [onInit]); + + return ( +
{nodes.map((node) => { const nodeType = node.type ?? 'default'; const Custom = nodeTypes?.[nodeType]; @@ -223,8 +232,9 @@ vi.mock('@xyflow/react', async () => { })} {children} -
- ), +
+ ); + }, Background: () => null, Controls: ({children, className, showFitView, showInteractive, showZoom, style}: {children?: React.ReactNode; className?: string; showFitView?: boolean; showInteractive?: boolean; showZoom?: boolean; style?: React.CSSProperties}) => (
{ }); mockEditMutation.mutate.mockReset(); mockReactFlow.fitView.mockReset(); + mockReactFlow.getViewport.mockReset(); + mockReactFlow.getViewport.mockImplementation(() => ({x: 0, y: 0, zoom: mockViewportZoom})); + mockReactFlow.setViewport.mockReset(); mockReactFlow.zoomIn.mockReset(); mockReactFlow.zoomOut.mockReset(); mockReactFlow.zoomTo.mockReset(); @@ -847,10 +860,11 @@ describe('AutomationEditor', () => { expect(controls).toHaveClass('overflow-hidden', 'rounded-md'); expect(screen.getByRole('button', {name: 'Zoom out'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Zoom level 100%'})).toHaveTextContent('100%'); - expect(screen.getByRole('button', {name: 'Zoom in'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Zoom in'})).toBeDisabled(); }); it('animates viewport changes from the custom canvas controls', () => { + mockViewportZoom = 0.75; mockUseReadAutomation.mockReturnValue({ data: {automations: [automationDetail]}, isLoading: false, @@ -878,11 +892,11 @@ describe('AutomationEditor', () => { fireEvent.pointerDown(screen.getByRole('button', {name: 'Zoom level 75%'}), {button: 0, ctrlKey: false}); - expect(screen.getByRole('menuitem', {name: '150%'})).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', {name: '150%'})).not.toBeInTheDocument(); expect(screen.getByRole('menuitem', {name: '100%'})).toBeInTheDocument(); expect(screen.getByRole('menuitem', {name: '75%'})).toBeInTheDocument(); expect(screen.getByRole('menuitem', {name: '50%'})).toBeInTheDocument(); - expect(screen.getByRole('menuitem', {name: '25%'})).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', {name: '25%'})).not.toBeInTheDocument(); expect(screen.getByRole('menuitem', {name: 'Fit to view'})).toBeInTheDocument(); expect(screen.getByRole('menuitem', {name: '75%'}).querySelector('svg')).toBeInTheDocument(); }); @@ -897,9 +911,9 @@ describe('AutomationEditor', () => { renderEditor(); fireEvent.pointerDown(screen.getByRole('button', {name: 'Zoom level 100%'}), {button: 0, ctrlKey: false}); - fireEvent.click(screen.getByRole('menuitem', {name: '150%'})); + fireEvent.click(screen.getByRole('menuitem', {name: '75%'})); - expect(mockReactFlow.zoomTo).toHaveBeenCalledWith(1.5, {duration: 180}); + expect(mockReactFlow.zoomTo).toHaveBeenCalledWith(0.75, {duration: 180}); fireEvent.pointerDown(screen.getByRole('button', {name: 'Zoom level 100%'}), {button: 0, ctrlKey: false}); fireEvent.click(screen.getByRole('menuitem', {name: 'Fit to view'})); @@ -907,6 +921,33 @@ describe('AutomationEditor', () => { expect(mockReactFlow.fitView).toHaveBeenCalledWith({duration: 180}); }); + it('re-constrains the viewport when deleting a step shrinks the graph', async () => { + const clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000); + const clientHeightSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(800); + mockUseReadAutomation.mockReturnValue({ + data: {automations: [automationDetail]}, + isLoading: false, + isError: false + }); + + try { + renderEditor(); + await waitFor(() => expect(mockReactFlow.setViewport).toHaveBeenCalled()); + mockReactFlow.setViewport.mockClear(); + // This is the bottom-most valid viewport for the original two-step graph. Once one + // step is deleted, it falls outside the shorter graph's new translate extent. + mockReactFlow.getViewport.mockReturnValue({x: 372, y: -188, zoom: 1}); + + fireEvent.click(screen.getByRole('button', {name: 'Wait: 1 day'})); + fireEvent.click(within(screen.getByRole('complementary', {name: 'Step details'})).getByRole('button', {name: 'Delete step'})); + + await waitFor(() => expect(mockReactFlow.setViewport).toHaveBeenCalledWith({x: 372, y: -8, zoom: 1})); + } finally { + clientWidthSpy.mockRestore(); + clientHeightSpy.mockRestore(); + } + }); + it('opens a read-only sidebar for the trigger step', () => { mockUseReadAutomation.mockReturnValue({ data: {automations: [automationDetail]}, From 0c58752fbea20d9fc686c973c1dc859f6ddc1b24 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 08:23:56 -0500 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20.rpm=20and=20.deb?= =?UTF-8?q?=20files=20being=20rejected=20by=20the=20file=20upload=20card?= =?UTF-8?q?=20(#30061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/TryGhost/Ghost/issues/30040 The file upload allowlist introduced with the file type validation was derived from observed usage and did not include Linux package formats, so sites distributing software could no longer attach `.rpm` or `.deb` files while sibling archive formats like `.tar.gz` continued to work. --- ghost/core/core/shared/config/overrides.json | 2 ++ ghost/core/test/e2e-api/admin/files.test.js | 29 +++++++++++++++++++ .../unit/server/data/importer/index.test.js | 4 +-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ghost/core/core/shared/config/overrides.json b/ghost/core/core/shared/config/overrides.json index ff66881d88e..c7863bb01af 100644 --- a/ghost/core/core/shared/config/overrides.json +++ b/ghost/core/core/shared/config/overrides.json @@ -65,6 +65,7 @@ ".cr3", ".css", ".csv", + ".deb", ".dng", ".doc", ".docx", @@ -123,6 +124,7 @@ ".py", ".raf", ".rar", + ".rpm", ".rtf", ".rw2", ".skp", diff --git a/ghost/core/test/e2e-api/admin/files.test.js b/ghost/core/test/e2e-api/admin/files.test.js index 5928bd403c5..647ee8cec5d 100644 --- a/ghost/core/test/e2e-api/admin/files.test.js +++ b/ghost/core/test/e2e-api/admin/files.test.js @@ -141,6 +141,35 @@ describe('Files API', function () { } }); + it('Stores Linux package files with application/octet-stream', async function () { + const store = adapterManager.getAdapter('storage:files'); + const saveSpy = sinon.spy(store, 'save'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-test-')); + + try { + for (const filename of ['package.rpm', 'package.deb']) { + saveSpy.resetHistory(); + const tempFile = path.join(tmpDir, filename); + fs.writeFileSync(tempFile, 'fake package content'); + + const res = await request.post(localUtils.API.getApiQuery('files/upload')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .attach('file', tempFile) + .expect(201); + + files.push(new URL(res.body.files[0].url).pathname); + + assert.ok(saveSpy.calledOnce, 'save() should have been called once'); + const fileArg = saveSpy.firstCall.args[0]; + assert.equal(fileArg.type, 'application/octet-stream', `${filename} should be stored as application/octet-stream`); + } + } finally { + fs.removeSync(tmpDir); + } + }); + it('Stores HTML files with text/plain content type to prevent execution', async function () { const store = adapterManager.getAdapter('storage:files'); const saveSpy = sinon.spy(store, 'save'); diff --git a/ghost/core/test/unit/server/data/importer/index.test.js b/ghost/core/test/unit/server/data/importer/index.test.js index 11b9572ff25..5cc8ac105b7 100644 --- a/ghost/core/test/unit/server/data/importer/index.test.js +++ b/ghost/core/test/unit/server/data/importer/index.test.js @@ -52,7 +52,7 @@ describe('Importer', function () { it('gets the correct extensions', function () { assert(Array.isArray(ImportManager.getExtensions())); - assert.equal(ImportManager.getExtensions().length, 105); + assert.equal(ImportManager.getExtensions().length, 107); assert(ImportManager.getExtensions().includes('.csv')); assert(ImportManager.getExtensions().includes('.json')); assert(ImportManager.getExtensions().includes('.zip')); @@ -119,7 +119,7 @@ describe('Importer', function () { }); it('globs extensions correctly', function () { - const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.7z|.aac|.ai|.aif|.aiff|.apkg|.arw|.avif|.avi|.blend|.bmp|.bz2|.cr2|.cr3|.css|.csv|.dng|.doc|.docx|.eot|.epub|.eps|.fb2|.fbx|.flac|.geojson|.glb|.gltf|.gpx|.gz|.heic|.heif|.html|.ics|.ipynb|.js|.json|.jsonld|.key|.kml|.m4v|.md|.mid|.midi|.mkv|.mobi|.mov|.nef|.np3|.numbers|.obj|.odp|.ods|.odt|.opus|.orf|.otf|.pages|.paprikarecipes|.pdf|.ppt|.pptx|.psd|.py|.raf|.rar|.rtf|.rw2|.skp|.stl|.tar|.tgz|.tif|.tiff|.toml|.tsv|.ttf|.txt|.vcf|.woff|.woff2|.xcf|.xls|.xlsb|.xlsm|.xlsx|.xml|.yaml|.zip|.markdown)'; + const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.7z|.aac|.ai|.aif|.aiff|.apkg|.arw|.avif|.avi|.blend|.bmp|.bz2|.cr2|.cr3|.css|.csv|.deb|.dng|.doc|.docx|.eot|.epub|.eps|.fb2|.fbx|.flac|.geojson|.glb|.gltf|.gpx|.gz|.heic|.heif|.html|.ics|.ipynb|.js|.json|.jsonld|.key|.kml|.m4v|.md|.mid|.midi|.mkv|.mobi|.mov|.nef|.np3|.numbers|.obj|.odp|.ods|.odt|.opus|.orf|.otf|.pages|.paprikarecipes|.pdf|.ppt|.pptx|.psd|.py|.raf|.rar|.rpm|.rtf|.rw2|.skp|.stl|.tar|.tgz|.tif|.tiff|.toml|.tsv|.ttf|.txt|.vcf|.woff|.woff2|.xcf|.xls|.xlsb|.xlsm|.xlsx|.xml|.yaml|.zip|.markdown)'; assert.equal(ImportManager.getGlobPattern(ImportManager.getExtensions()), extGlob); assert.equal(ImportManager.getGlobPattern(ImportManager.getDirectories()), '+(images|content|media|files)'); assert.equal(ImportManager.getGlobPattern(JSONHandler.extensions), '+(.json)'); From 1eaff16ff6bc53805a3e249a8a68cdd4f5c8c1c1 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 08:54:59 -0500 Subject: [PATCH 06/22] TypeScriptified data generator date utils (#30039) no ref --- .../automation-action-revisions-importer.ts | 7 +++--- .../importers/automation-actions-importer.ts | 9 ++++---- .../automation-run-steps-importer.ts | 7 +++--- .../importers/automation-runs-importer.ts | 11 +++++----- .../seeders/importers/automations-importer.ts | 7 +++--- .../importers/comment-reports-importer.js | 8 +++---- .../seeders/importers/comments-importer.js | 10 ++++----- .../importers/email-batches-importer.js | 8 +++---- .../importers/email-recipients-importer.js | 22 +++++++++---------- .../data/seeders/importers/emails-importer.js | 12 +++++----- .../data/seeders/importers/labels-importer.js | 6 ++--- .../members-click-events-importer.js | 6 ++--- .../members-created-events-importer.js | 8 +++---- .../importers/members-feedback-importer.js | 12 +++++----- .../seeders/importers/members-importer.js | 8 +++---- .../members-login-events-importer.js | 6 ++--- .../importers/members-products-importer.js | 4 ++-- .../members-status-events-importer.js | 6 ++--- .../members-stripe-customers-importer.js | 4 ++-- ...stripe-customers-subscriptions-importer.js | 14 ++++++------ .../members-subscribe-events-importer.js | 4 ++-- ...rs-subscription-created-events-importer.js | 6 ++--- .../importers/offer-redemptions-importer.js | 10 ++++----- .../data/seeders/importers/offers-importer.js | 6 ++--- .../data/seeders/importers/posts-importer.js | 8 +++---- .../data/seeders/importers/table-importer.ts | 5 ++--- .../data/seeders/importers/tags-importer.js | 4 ++-- .../data/seeders/importers/users-importer.js | 4 ++-- .../importers/web-mentions-importer.js | 4 ++-- .../{database-date.js => database-date.ts} | 20 ++++++++--------- .../data/seeders/data-generator.test.js | 6 ++--- 31 files changed, 122 insertions(+), 130 deletions(-) rename ghost/core/core/server/data/seeders/utils/{database-date.js => database-date.ts} (54%) diff --git a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts index 31bc492df27..5025ed7f463 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts @@ -2,8 +2,7 @@ import {faker} from '@faker-js/faker'; import errors from '@tryghost/errors'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; -// @ts-expect-error This module currently lacks type definitions. -import dateToDatabaseString from '../utils/database-date'; +import * as databaseDate from '../utils/database-date'; import {DEFAULT_EMAIL_DESIGN_SETTING_SLUG} from '../../../services/member-welcome-emails/constants'; type AutomationAction = { @@ -67,13 +66,13 @@ export class AutomationActionRevisionsImporter extends TableImporter const requestedStepCount = clamp(Math.floor(stepsPerRun) + extraStep, 1, actionCount); const runStepCount = fullPathGeneratedForAutomation.has(run.automation_id) ? requestedStepCount : actionCount; fullPathGeneratedForAutomation.add(run.automation_id); - const runCreatedAt = dateToDatabaseString.parse(run.created_at); + const runCreatedAt = databaseDate.parse(run.created_at); const lastStepStatus = faker.helpers.arrayElement(['pending', 'finished', 'failed'] as const); for (let index = 0; index < runStepCount; index += 1) { const createdAt = new Date(runCreatedAt); createdAt.setHours(createdAt.getHours() + index); - const createdAtString = dateToDatabaseString(createdAt); + const createdAtString = databaseDate.dateToDatabaseString(createdAt); const status = index === runStepCount - 1 ? lastStepStatus : 'finished'; const isTerminal = status !== 'pending'; diff --git a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts index bccef290ae2..ea267402a4f 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts @@ -4,8 +4,7 @@ import assert from 'node:assert/strict'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; import {parseEmailAddress} from '@tryghost/parse-email-address'; -// @ts-expect-error This module currently lacks type definitions. -import dateToDatabaseString from '../utils/database-date'; +import * as databaseDate from '../utils/database-date'; type Automation = { id: string; @@ -74,8 +73,8 @@ export class AutomationRunsImporter extends TableImporter { status: faker.helpers.arrayElement(['active', 'inactive']), name, slug, - created_at: dateToDatabaseString(createdAt), - updated_at: dateToDatabaseString(createdAt) + created_at: databaseDate.dateToDatabaseString(createdAt), + updated_at: databaseDate.dateToDatabaseString(createdAt) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js index d4f493f10fe..d18a086d38a 100644 --- a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class CommentReportsImporter extends TableImporter { static table = 'comment_reports'; @@ -49,7 +49,7 @@ class CommentReportsImporter extends TableImporter { return null; } - const reportTime = dateToDatabaseString.randomBetween(this.model.created_at, new Date()); + const reportTime = databaseDate.randomBetween(this.model.created_at, new Date()); const reporter = this.possibleReporters[faker.number.int(this.possibleReporters.length - 1)]; @@ -57,8 +57,8 @@ class CommentReportsImporter extends TableImporter { id: this.fastFakeObjectId(), comment_id: this.model.id, member_id: reporter.id, - created_at: dateToDatabaseString(reportTime), - updated_at: dateToDatabaseString(reportTime) + created_at: databaseDate.dateToDatabaseString(reportTime), + updated_at: databaseDate.dateToDatabaseString(reportTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comments-importer.js b/ghost/core/core/server/data/seeders/importers/comments-importer.js index 302e50f30dc..7fdaacceef8 100644 --- a/ghost/core/core/server/data/seeders/importers/comments-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comments-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class CommentsImporter extends TableImporter { static table = 'comments'; @@ -24,7 +24,7 @@ class CommentsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const publishedAt = dateToDatabaseString.parse(model.published_at); + const publishedAt = databaseDate.parse(model.published_at); this.commentIds = []; // Store [id, parent_id, timestamp] tuples for reply-to-reply @@ -37,7 +37,7 @@ class CommentsImporter extends TableImporter { endTime: new Date() }).sort((a, b) => a.getTime() - b.getTime()); // Sort chronologically so replies always come after their targets - this.possibleMembers = this.members.filter(member => dateToDatabaseString.parse(member.created_at) < publishedAt); + this.possibleMembers = this.members.filter(member => databaseDate.parse(member.created_at) < publishedAt); } generate() { @@ -97,8 +97,8 @@ class CommentsImporter extends TableImporter { parent_id: parentId, in_reply_to_id: inReplyToId, status: 'published', - created_at: dateToDatabaseString(timestamp), - updated_at: dateToDatabaseString(timestamp), + created_at: databaseDate.dateToDatabaseString(timestamp), + updated_at: databaseDate.dateToDatabaseString(timestamp), html: `

${faker.lorem.sentence().replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)}

` }; diff --git a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js index 592c5629e8a..a5028710528 100644 --- a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class EmailBatchesImporter extends TableImporter { static table = 'email_batches'; @@ -21,8 +21,8 @@ class EmailBatchesImporter extends TableImporter { } generate() { - const emailSentDate = dateToDatabaseString.parse(this.model.created_at); - const latestUpdatedDate = dateToDatabaseString.parse(this.model.created_at); + const emailSentDate = databaseDate.parse(this.model.created_at); + const latestUpdatedDate = databaseDate.parse(this.model.created_at); latestUpdatedDate.setHours(latestUpdatedDate.getHours() + 1); return { @@ -31,7 +31,7 @@ class EmailBatchesImporter extends TableImporter { mailgun_message_id: `${new Date().toISOString().split('.')[0].replace(/[^0-9]/g, '')}.${faker.string.hexadecimal({length: 16, prefix: '', casing: 'lower'})}@m.example.com`, status: 'submitted', // TODO: introduce failures created_at: this.model.created_at, - updated_at: dateToDatabaseString(dateToDatabaseString.randomBetween(emailSentDate, latestUpdatedDate)) + updated_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(emailSentDate, latestUpdatedDate)) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js index bbb1673e4e7..6bbcd62febb 100644 --- a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); const debug = require('@tryghost/debug')('EmailRecipientsImporter'); const emailStatus = { @@ -111,7 +111,7 @@ class EmailRecipientsImporter extends TableImporter { if (!(memberSubscribeEvent.created_at instanceof Date)) { // SQLite fix - memberSubscribeEvent.created_at = dateToDatabaseString.parse(memberSubscribeEvent.created_at); + memberSubscribeEvent.created_at = databaseDate.parse(memberSubscribeEvent.created_at); } this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(memberSubscribeEvent.created_at.getTime()); } @@ -125,8 +125,8 @@ class EmailRecipientsImporter extends TableImporter { this.batchIndex = this.batch.index; // Shallow clone members list so we can shuffle and modify it - const earliestOpenTime = dateToDatabaseString.parse(this.batch.updated_at); - const latestOpenTime = dateToDatabaseString.parse(this.batch.updated_at); + const earliestOpenTime = databaseDate.parse(this.batch.updated_at); + const latestOpenTime = databaseDate.parse(this.batch.updated_at); latestOpenTime.setDate(latestOpenTime.getDate() + 14); // Get all members that were subscribed to this newsletter BEFORE the batch was sent @@ -164,7 +164,7 @@ class EmailRecipientsImporter extends TableImporter { } // The events are generated for a different time, so we need to move them to the batch time - timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + dateToDatabaseString.parse(this.batch.updated_at).getTime()); + timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + databaseDate.parse(this.batch.updated_at).getTime()); if (timestamp > new Date()) { timestamp = new Date(); @@ -187,9 +187,9 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = dateToDatabaseString.parse(this.batch.updated_at); + const startDate = databaseDate.parse(this.batch.updated_at); const endDate = timestamp; - deliveredTime = dateToDatabaseString.randomBetween(startDate, endDate); + deliveredTime = databaseDate.randomBetween(startDate, endDate); } return { @@ -198,10 +198,10 @@ class EmailRecipientsImporter extends TableImporter { email_id: this.model.id, batch_id: this.batch.id, member_id: member.id, - processed_at: dateToDatabaseString(this.batch.updated_at), - delivered_at: status === emailStatus.opened ? dateToDatabaseString(deliveredTime) : status === emailStatus.delivered ? dateToDatabaseString(timestamp) : null, - opened_at: status === emailStatus.opened ? dateToDatabaseString(timestamp) : null, - failed_at: status === emailStatus.failed ? dateToDatabaseString(timestamp) : null, + processed_at: databaseDate.dateToDatabaseString(this.batch.updated_at), + delivered_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(deliveredTime) : status === emailStatus.delivered ? databaseDate.dateToDatabaseString(timestamp) : null, + opened_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(timestamp) : null, + failed_at: status === emailStatus.failed ? databaseDate.dateToDatabaseString(timestamp) : null, member_uuid: member.uuid, member_email: member.email, member_name: member.name diff --git a/ghost/core/core/server/data/seeders/importers/emails-importer.js b/ghost/core/core/server/data/seeders/importers/emails-importer.js index 56c2d4d779c..47923c99085 100644 --- a/ghost/core/core/server/data/seeders/importers/emails-importer.js +++ b/ghost/core/core/server/data/seeders/importers/emails-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class EmailsImporter extends TableImporter { static table = 'emails'; @@ -42,7 +42,7 @@ class EmailsImporter extends TableImporter { : this.newsletters[1]; } - const publishedAt = dateToDatabaseString.parse(this.model.published_at); + const publishedAt = databaseDate.parse(this.model.published_at); const timestamp = luck(60) ? publishedAt : generateEvents({ @@ -55,7 +55,7 @@ class EmailsImporter extends TableImporter { const recipientCount = this.membersSubscribeEvents .filter(entry => entry.newsletter_id === newsletter.id) - .filter(entry => dateToDatabaseString.parse(entry.created_at) < timestamp).length; + .filter(entry => databaseDate.parse(entry.created_at) < timestamp).length; const deliveredCount = Math.ceil(recipientCount * faker.number.float({ max: 1, min: 0.9, @@ -87,10 +87,10 @@ class EmailsImporter extends TableImporter { track_opens: true, track_clicks: true, feedback_enabled: true, - submitted_at: dateToDatabaseString(timestamp), + submitted_at: databaseDate.dateToDatabaseString(timestamp), newsletter_id: newsletter.id, - created_at: dateToDatabaseString(timestamp), - updated_at: dateToDatabaseString(timestamp) + created_at: databaseDate.dateToDatabaseString(timestamp), + updated_at: databaseDate.dateToDatabaseString(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/labels-importer.js b/ghost/core/core/server/data/seeders/importers/labels-importer.js index f0dd07fef63..e8fef6bfdcb 100644 --- a/ghost/core/core/server/data/seeders/importers/labels-importer.js +++ b/ghost/core/core/server/data/seeders/importers/labels-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class LabelsImporter extends TableImporter { static table = 'labels'; @@ -30,8 +30,8 @@ class LabelsImporter extends TableImporter { id: this.fastFakeObjectId(), name: name, slug: `${slugify(name)}`, - created_at: dateToDatabaseString(blogStartDate), - updated_at: dateToDatabaseString(blogStartDate) + created_at: databaseDate.dateToDatabaseString(blogStartDate), + updated_at: databaseDate.dateToDatabaseString(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index 4ce60711afb..f9c80d9f0a7 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersClickEventsImporter extends TableImporter { static table = 'members_click_events'; @@ -50,7 +50,7 @@ class MembersClickEventsImporter extends TableImporter { } this.amount -= 1; - const openedAt = dateToDatabaseString.parse(this.model.opened_at); + const openedAt = databaseDate.parse(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); const clickTime = faker.date.between({from: openedAt.getTime(), to: laterOn.getTime()}); //added getTime here because it threw random errors @@ -61,7 +61,7 @@ class MembersClickEventsImporter extends TableImporter { min: 0, max: this.redirectList.length - 1 }))].id, - created_at: dateToDatabaseString(clickTime) + created_at: databaseDate.dateToDatabaseString(clickTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index bf3850fb94f..807eaa0e06e 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersCreatedEventsImporter extends TableImporter { static table = 'members_created_events'; @@ -47,8 +47,8 @@ class MembersCreatedEventsImporter extends TableImporter { }; if (source === 'member' && luck(30)) { - const memberCreatedAt = dateToDatabaseString.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && dateToDatabaseString.parse(p.published_at) < memberCreatedAt); + const memberCreatedAt = databaseDate.parse(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < memberCreatedAt); if (post) { attribution = { attribution_id: post.id, @@ -92,7 +92,7 @@ class MembersCreatedEventsImporter extends TableImporter { return { id: this.fastFakeObjectId(), - created_at: dateToDatabaseString(this.model.created_at), + created_at: databaseDate.dateToDatabaseString(this.model.created_at), member_id: this.model.id, source, ...attribution, diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index b413b3a92e3..3b43be3cc4d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersFeedbackImporter extends TableImporter { static table = 'members_feedback'; @@ -24,10 +24,10 @@ class MembersFeedbackImporter extends TableImporter { return null; } - const openedAt = dateToDatabaseString.parse(this.model.opened_at); - const laterOn = dateToDatabaseString.parse(this.model.opened_at); + const openedAt = databaseDate.parse(this.model.opened_at); + const laterOn = databaseDate.parse(this.model.opened_at); laterOn.setMinutes(laterOn.getMinutes() + 60); - const feedbackTime = dateToDatabaseString.randomBetween(openedAt, laterOn); + const feedbackTime = databaseDate.randomBetween(openedAt, laterOn); const postId = this.emails.find(email => email.id === this.model.email_id).post_id; return { @@ -35,8 +35,8 @@ class MembersFeedbackImporter extends TableImporter { score: luck(70) ? 1 : 0, member_id: this.model.member_id, post_id: postId, - created_at: dateToDatabaseString(feedbackTime), - updated_at: dateToDatabaseString(feedbackTime) + created_at: databaseDate.dateToDatabaseString(feedbackTime), + updated_at: databaseDate.dateToDatabaseString(feedbackTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-importer.js b/ghost/core/core/server/data/seeders/importers/members-importer.js index 566d2e78a46..11bfcf3dbe8 100644 --- a/ghost/core/core/server/data/seeders/importers/members-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-importer.js @@ -4,7 +4,7 @@ const {faker: americanFaker} = require('@faker-js/faker/locale/en_US'); const {blogStartDate: startTime} = require('../utils/blog-info'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); const debug = require('@tryghost/debug')('MembersImporter'); class MembersImporter extends TableImporter { @@ -88,9 +88,9 @@ class MembersImporter extends TableImporter { email_opened_count: 0, email_open_rate: null, // 40% of users logged in within a week, 60% sometime since registering - last_seen_at: luck(40) ? dateToDatabaseString(faker.date.recent({days: 7})) : dateToDatabaseString(faker.date.between({from: timestamp, to: new Date()})), - created_at: dateToDatabaseString(timestamp), - updated_at: dateToDatabaseString(timestamp) + last_seen_at: luck(40) ? databaseDate.dateToDatabaseString(faker.date.recent({days: 7})) : databaseDate.dateToDatabaseString(faker.date.between({from: timestamp, to: new Date()})), + created_at: databaseDate.dateToDatabaseString(timestamp), + updated_at: databaseDate.dateToDatabaseString(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js index d9a5b386645..3cc32b220b9 100644 --- a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersLoginEventsImporter extends TableImporter { static table = 'members_login_events'; @@ -34,7 +34,7 @@ class MembersLoginEventsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const memberCreatedAt = dateToDatabaseString.parse(model.created_at); + const memberCreatedAt = databaseDate.parse(model.created_at); const endDate = new Date(); const daysBetween = Math.ceil((endDate.valueOf() - memberCreatedAt.valueOf()) / (1000 * 60 * 60 * 24)); @@ -60,7 +60,7 @@ class MembersLoginEventsImporter extends TableImporter { } return { id: this.fastFakeObjectId(), - created_at: dateToDatabaseString(timestamp), + created_at: databaseDate.dateToDatabaseString(timestamp), member_id: this.model.id }; } diff --git a/ghost/core/core/server/data/seeders/importers/members-products-importer.js b/ghost/core/core/server/data/seeders/importers/members-products-importer.js index 65469d0aad8..893e2098be2 100644 --- a/ghost/core/core/server/data/seeders/importers/members-products-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-products-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersProductsImporter extends TableImporter { static table = 'members_products'; @@ -34,7 +34,7 @@ class MembersProductsImporter extends TableImporter { member_id: this.model.id, product_id: this.getProduct().id, sort_order: 0, - expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : dateToDatabaseString(faker.date.future())) + expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : databaseDate.dateToDatabaseString(faker.date.future())) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index 27e8e4d3d71..d18a602cb5b 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,5 @@ const {TableImporter} = require('./table-importer'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersStatusEventsImporter extends TableImporter { static table = 'members_status_events'; @@ -31,7 +31,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: null, to_status: 'free', - created_at: dateToDatabaseString(model.created_at) + created_at: databaseDate.dateToDatabaseString(model.created_at) }]; if (model.status !== 'free') { this.events.push({ @@ -39,7 +39,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: dateToDatabaseString(dateToDatabaseString.randomBetween(model.created_at, new Date())) + created_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js index 480786cb821..34203ad2d2a 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js @@ -1,6 +1,6 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersStripeCustomersImporter extends TableImporter { static table = 'members_stripe_customers'; @@ -35,7 +35,7 @@ class MembersStripeCustomersImporter extends TableImporter { // Only 30% of free members should have a stripe customer = have had a subscription in the past or tried to subscribe // The number should increase the older the member is - const daysSinceMemberCreated = Math.floor((new Date() - dateToDatabaseString.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); + const daysSinceMemberCreated = Math.floor((new Date() - databaseDate.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); const shouldHaveStripeCustomer = faker.number.int({min: 0, max: 100}) < Math.max(Math.min(daysSinceMemberCreated / 60, 15), 2); if (!shouldHaveStripeCustomer) { diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js index 375a5fa5464..766eb2f6993 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js @@ -1,6 +1,6 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); @@ -95,7 +95,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { (isMonthly ? price.interval === 'month' : price.interval === 'year'); }); const mrr = createValid ? (isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12)) : 0; - const memberCreatedAt = dateToDatabaseString.parse(member.created_at); + const memberCreatedAt = databaseDate.parse(member.created_at); const referenceEndDate = this.lastSubscriptionStart ?? new Date(); @@ -193,8 +193,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { validStatusses.push({ status: 'trialing', cancel_at_period_end: false, - trial_end_at: dateToDatabaseString(endDate), - trial_start_at: dateToDatabaseString(startDate) + trial_end_at: databaseDate.dateToDatabaseString(endDate), + trial_start_at: databaseDate.dateToDatabaseString(startDate) }); } @@ -236,8 +236,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { customer_id: customer.customer_id, subscription_id: `sub_${faker.string.alphanumeric(14)}`, stripe_price_id: stripePrice.stripe_price_id, - start_date: dateToDatabaseString(startDate), - created_at: dateToDatabaseString(startDate), + start_date: databaseDate.dateToDatabaseString(startDate), + created_at: databaseDate.dateToDatabaseString(startDate), mrr, plan_id: stripeProduct.stripe_product_id, plan_nickname: `${ghostProduct.name} - ${stripePrice.nickname}`, @@ -248,7 +248,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { // Defaults status: 'active', cancel_at_period_end: false, - current_period_end: dateToDatabaseString(endDate), + current_period_end: databaseDate.dateToDatabaseString(endDate), // Override ...status diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index fdf352cacaf..e05856e8d37 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersSubscribeEventsImporter extends TableImporter { static table = 'members_subscribe_events'; @@ -50,7 +50,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = dateToDatabaseString(dateToDatabaseString.randomBetween(this.model.created_at, new Date())); + const createdAt = databaseDate.dateToDatabaseString(databaseDate.randomBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js index 8583edb4230..0704cebb035 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class MembersSubscriptionCreatedEventsImporter extends TableImporter { static table = 'members_subscription_created_events'; @@ -48,8 +48,8 @@ class MembersSubscriptionCreatedEventsImporter extends TableImporter { }; if (luck(30)) { - const createdAt = dateToDatabaseString.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && dateToDatabaseString.parse(p.published_at) < createdAt); + const createdAt = databaseDate.parse(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < createdAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index c1dd84867c7..016a44bd882 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const errors = require('@tryghost/errors'); const {TableImporter} = require('./table-importer'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class OfferRedemptionsImporter extends TableImporter { static table = 'offer_redemptions'; @@ -53,7 +53,7 @@ class OfferRedemptionsImporter extends TableImporter { this.subscriptionPool.push({ memberId, subscriptionId: subscription.id, - subscriptionCreatedAt: dateToDatabaseString.parse(subscription.created_at), + subscriptionCreatedAt: databaseDate.parse(subscription.created_at), redemptionEndAt: this.getRedemptionEndDate(subscription.current_period_end), availableOffers: [...matchingOffers], lastRedeemedAt: null @@ -78,7 +78,7 @@ class OfferRedemptionsImporter extends TableImporter { getRedemptionEndDate(currentPeriodEnd) { const now = new Date(); - const endDate = currentPeriodEnd ? dateToDatabaseString.parse(currentPeriodEnd) : now; + const endDate = currentPeriodEnd ? databaseDate.parse(currentPeriodEnd) : now; return endDate > now ? now : endDate; } @@ -86,7 +86,7 @@ class OfferRedemptionsImporter extends TableImporter { getCreatedAt(subscriptionState, offer) { const candidateEarliest = new Date(Math.max( subscriptionState.subscriptionCreatedAt.valueOf(), - dateToDatabaseString.parse(offer.created_at).valueOf(), + databaseDate.parse(offer.created_at).valueOf(), subscriptionState.lastRedeemedAt ? subscriptionState.lastRedeemedAt.valueOf() + 1000 : 0 )); const earliest = new Date(Math.min( @@ -98,7 +98,7 @@ class OfferRedemptionsImporter extends TableImporter { subscriptionState.lastRedeemedAt = createdAt; - return dateToDatabaseString(createdAt); + return databaseDate.dateToDatabaseString(createdAt); } generate() { diff --git a/ghost/core/core/server/data/seeders/importers/offers-importer.js b/ghost/core/core/server/data/seeders/importers/offers-importer.js index bce624ca194..c547eeaf795 100644 --- a/ghost/core/core/server/data/seeders/importers/offers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offers-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); const offerTemplates = [{ name: 'Black Friday', @@ -107,8 +107,8 @@ class OffersImporter extends TableImporter { portal_title: name, portal_description: template.description, redemption_type: 'signup', - created_at: dateToDatabaseString(blogStartDate), - updated_at: dateToDatabaseString(blogStartDate) + created_at: databaseDate.dateToDatabaseString(blogStartDate), + updated_at: databaseDate.dateToDatabaseString(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/posts-importer.js b/ghost/core/core/server/data/seeders/importers/posts-importer.js index 80d2af45215..7757c3d2bba 100644 --- a/ghost/core/core/server/data/seeders/importers/posts-importer.js +++ b/ghost/core/core/server/data/seeders/importers/posts-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {luck} = require('../utils/random'); const {TableImporter} = require('./table-importer'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class PostsImporter extends TableImporter { static table = 'posts'; @@ -53,9 +53,9 @@ class PostsImporter extends TableImporter { return { id, - created_at: dateToDatabaseString(timestamp), - updated_at: dateToDatabaseString(timestamp), - published_at: status === 'published' ? dateToDatabaseString(timestamp) : status === 'scheduled' ? dateToDatabaseString(faker.date.soon({days: 5, refDate: timestamp})) : null, + created_at: databaseDate.dateToDatabaseString(timestamp), + updated_at: databaseDate.dateToDatabaseString(timestamp), + published_at: status === 'published' ? databaseDate.dateToDatabaseString(timestamp) : status === 'scheduled' ? databaseDate.dateToDatabaseString(faker.date.soon({days: 5, refDate: timestamp})) : null, uuid: faker.string.uuid(), comment_id: this.type === 'post' ? id : null, title: title, diff --git a/ghost/core/core/server/data/seeders/importers/table-importer.ts b/ghost/core/core/server/data/seeders/importers/table-importer.ts index 0b3fbe7b523..3389bf21bf2 100644 --- a/ghost/core/core/server/data/seeders/importers/table-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/table-importer.ts @@ -1,6 +1,5 @@ import debugFactory from '@tryghost/debug'; -// @ts-expect-error This module lacks type definitions. -import dateToDatabaseString from '../utils/database-date'; +import * as databaseDate from '../utils/database-date'; import path from 'node:path'; import fs from 'node:fs'; import papaparse from 'papaparse'; @@ -124,7 +123,7 @@ export abstract class TableImporter< if (typeof value === 'boolean') { mutableObj[key] = value ? 1 : 0; } else if (value instanceof Date) { - mutableObj[key] = dateToDatabaseString(value); + mutableObj[key] = databaseDate.dateToDatabaseString(value); } else if (value === null) { mutableObj[key] = '\\N'; } diff --git a/ghost/core/core/server/data/seeders/importers/tags-importer.js b/ghost/core/core/server/data/seeders/importers/tags-importer.js index 19d6effd920..6f1a2b62751 100644 --- a/ghost/core/core/server/data/seeders/importers/tags-importer.js +++ b/ghost/core/core/server/data/seeders/importers/tags-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {TableImporter} = require('./table-importer'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class TagsImporter extends TableImporter { static table = 'tags'; @@ -32,7 +32,7 @@ class TagsImporter extends TableImporter { name: name, slug: slugify(name), description: faker.lorem.sentence(), - created_at: dateToDatabaseString(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) + created_at: databaseDate.dateToDatabaseString(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/users-importer.js b/ghost/core/core/server/data/seeders/importers/users-importer.js index efec11fb770..1ae7d07e961 100644 --- a/ghost/core/core/server/data/seeders/importers/users-importer.js +++ b/ghost/core/core/server/data/seeders/importers/users-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const security = require('@tryghost/security'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class UsersImporter extends TableImporter { static table = 'users'; @@ -24,7 +24,7 @@ class UsersImporter extends TableImporter { password: await security.password.hash(faker.color.human()), email: faker.internet.email({firstName, lastName}), profile_image: faker.image.avatar(), - created_at: dateToDatabaseString(faker.date.between({from: new Date(2016, 0), to: new Date()})) + created_at: databaseDate.dateToDatabaseString(faker.date.between({from: new Date(2016, 0), to: new Date()})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js index 182724a4452..2e1793a9ed7 100644 --- a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const dateToDatabaseString = require('../utils/database-date'); +const databaseDate = require('../utils/database-date'); class WebMentionsImporter extends TableImporter { static table = 'mentions'; @@ -31,7 +31,7 @@ class WebMentionsImporter extends TableImporter { target: `${this.baseUrl}`, resource_id: null, resource_type: null, - created_at: dateToDatabaseString(faker.date.past()), + created_at: databaseDate.dateToDatabaseString(faker.date.past()), payload: JSON.stringify({}), deleted: 0, verified: 1 diff --git a/ghost/core/core/server/data/seeders/utils/database-date.js b/ghost/core/core/server/data/seeders/utils/database-date.ts similarity index 54% rename from ghost/core/core/server/data/seeders/utils/database-date.js rename to ghost/core/core/server/data/seeders/utils/database-date.ts index 1342e8a5ecc..39a2512e3c7 100644 --- a/ghost/core/core/server/data/seeders/utils/database-date.js +++ b/ghost/core/core/server/data/seeders/utils/database-date.ts @@ -1,16 +1,16 @@ -const {faker} = require('@faker-js/faker'); +import {faker} from '@faker-js/faker'; const databaseDatePattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/; -function dateToDatabaseString(date) { +export function dateToDatabaseString(date: Date | string): string { if (typeof date === 'string') { // SQLite fix when reusing other dates from the db return date; } - return date.toISOString().replace('Z','').replace('T', ' '); + return date.toISOString().replace('Z', '').replace('T', ' '); } -dateToDatabaseString.parse = function parseDatabaseDate(date) { +export function parse(date: Date | string | number): Date { if (date instanceof Date) { return new Date(date); } @@ -20,13 +20,11 @@ dateToDatabaseString.parse = function parseDatabaseDate(date) { } return new Date(date); -}; +} -dateToDatabaseString.randomBetween = function randomBetween(start, end) { - const earliest = dateToDatabaseString.parse(start); - const latest = dateToDatabaseString.parse(end); +export function randomBetween(start: Date | string | number, end: Date | string | number): Date { + const earliest = parse(start); + const latest = parse(end); return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; -}; - -module.exports = dateToDatabaseString; +} diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 0a8a2de21f9..4fbb051020a 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -11,7 +11,7 @@ const AutomationsImporter = importers.find(i => i.table === 'automations'); const AutomationRunStepsImporter = importers.find(i => i.table === 'automation_run_steps'); const generateEvents = require('../../../../../core/server/data/seeders/utils/event-generator'); -const dateToDatabaseString = require('../../../../../core/server/data/seeders/utils/database-date'); +const databaseDate = require('../../../../../core/server/data/seeders/utils/database-date'); const DataGenerator = require('../../../../../core/server/data/seeders/data-generator'); @@ -483,7 +483,7 @@ describe('Events Generator', function () { const originalTZ = process.env.TZ; try { process.env.TZ = 'America/New_York'; - const result = dateToDatabaseString.parse('2026-03-26 11:50:00.000'); + const result = databaseDate.parse('2026-03-26 11:50:00.000'); assert.equal(result.toISOString(), '2026-03-26T11:50:00.000Z'); } finally { if (originalTZ === undefined) { @@ -498,7 +498,7 @@ describe('Events Generator', function () { const startDate = new Date('2026-03-26T11:50:00.000Z'); const endDate = new Date('2026-03-26T10:00:00.000Z'); - assert.equal(dateToDatabaseString.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); + assert.equal(databaseDate.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); }); it('Generates a set of timestamps which meet the criteria', function () { From 7e9d564a50c7b8865f5a0ed47d4b35a70e0980ee Mon Sep 17 00:00:00 2001 From: tbraendle Date: Tue, 18 Aug 2026 15:58:06 +0200 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=8C=90=20Fixed=20grammar=20in=20Swi?= =?UTF-8?q?ss=20German=20(de-CH)=20email=20translations=20(#29733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref - `free`: kostenlos → kostenloses - `complimentary`: kostenlos → kostenloses (kept identical to `free`, mirroring `de` which uses one word for both) - `Subscription details`: Abo Details → Abo-Details (German orthography requires hyphenated compounds) - `This email address will not be used.`: E-Mail Adresse → E-Mail-Adresse (same hyphenation rule) --- packages/i18n/locales/de-CH/ghost.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/i18n/locales/de-CH/ghost.json b/packages/i18n/locales/de-CH/ghost.json index ec82355be44..c97131c5ef4 100644 --- a/packages/i18n/locales/de-CH/ghost.json +++ b/packages/i18n/locales/de-CH/ghost.json @@ -11,7 +11,7 @@ "Comment": "Kommentar", "Complete signup for {siteTitle}!": "Registrierung vervollständigen für die Seite «{siteTitle}».", "Complete your sign up to {siteTitle}!": "Vervollständigen Sie Ihre Registrierung für die Seite «{siteTitle}»!", - "complimentary": "kostenlos", + "complimentary": "kostenloses", "Confirm email address": "Bestätigen Sie Ihre E-Mail-Adresse.", "Confirm signup": "Bestätigen Sie Ihre Registrierung.", "Confirm your email address": "Bestätigen Sie Ihre E-Mail-Adresse.", @@ -22,7 +22,7 @@ "Email": "E-Mail", "For security verification, enter the code below to sign in to {siteTitle}:": "Zur Sicherheitsüberprüfung geben Sie bitte den folgenden Code ein, um sich bei {siteTitle} anzumelden:", "For your security, the link will expire in 24 hours time.": "Der Link läuft aus Sicherheitsgründen nach 24 Stunden ab.", - "free": "kostenlos", + "free": "kostenloses", "Gift subscription": "", "Here's how to keep your {tierName} membership.": "So behalten Sie Ihre {tierName}-Mitgliedschaft.", "Here's your code to login to {siteTitle}": "Hier ist Ihr Code, um sich bei {siteTitle} einzuloggen", @@ -60,7 +60,7 @@ "Sign in verification": "Login-Überprüfung", "Someone just replied to your comment": "Jemand hat gerade auf Ihren Kommentar geantwortet", "Someone just replied to your comment on {postTitle}.": "Jemand hat gerade auf Ihren Kommentar zu {postTitle} geantwortet", - "Subscription details": "Abo Details", + "Subscription details": "Abo-Details", "Support {siteTitle}": "", "Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:": "Mit dem untenstehenden Link schliessen Sie Ihre Registrierung bei «{siteTitle}» ab:", "Thank you for signing up to {siteTitle}!": "Danke für Ihre Registrierung bei unserer Seite «{siteTitle}»!", @@ -71,7 +71,7 @@ "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Vielen Dank für Ihre Unterstützung. Teilen Sie den untenstehenden Link mit allen, denen Sie eine {cadenceLabel} {tierName}-Mitgliedschaft bei {siteTitle} schenken möchten.", "Thanks for reading {siteTitle}.": "Vielen Dank, dass Sie {siteTitle} lesen.", "The link expires on {expiresAt} and can only be redeemed once.": "Der Link ist bis {expiresAt} gültig und kann nur einmal eingelöst werden.", - "This email address will not be used.": "Diese E-Mail Adresse wird nicht verwendet.", + "This email address will not be used.": "Diese E-Mail-Adresse wird nicht verwendet.", "This message was sent from {siteDomain} to {email}.": "Diese Nachricht wurde von {siteDomain} an {email} gesendet.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Um Ihr {tierName}-Abonnement weiterhin zu geniessen, wechseln Sie noch heute zu einem kostenpflichtigen Abo. Die restliche Laufzeit Ihres Geschenkabonnements wird Ihnen automatisch als kostenlose Verlängerung angerechnet.", "trialing": "Probeabo", From 3879bed474f57092d5dc8b0ae16930c41dfba810 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 09:24:02 -0500 Subject: [PATCH 08/22] Changed advanced settings dialogs to controlled components (#30014) no ref The code injection, webhook, labs YAML editor, feature-toggle confirmation and universal import dialogs were the last `NiceModal.create` dialogs under `settings/advanced`. They now take an `onClose` prop and are rendered by the component that opens them, using `SettingsModal`'s controlled `onClose` path that the routed settings dialogs already use. This removes five of the remaining NiceModal call sites in settings so the provider and dependency can be torn down once the theme and membership dialogs follow. --- .../advanced/advanced.acceptance.test.tsx | 74 ++++++ apps/admin/src/settings/app/app.tsx | 13 +- .../components/providers/dialog-portal.tsx | 27 +++ .../settings/advanced/code-injection.tsx | 14 +- .../settings/advanced/code/code-modal.tsx | 18 +- .../advanced/integrations/webhook-modal.tsx | 10 +- .../advanced/integrations/webhooks-table.tsx | 28 ++- .../settings/advanced/labs/beta-features.tsx | 223 +++++++++--------- .../settings/advanced/labs/feature-toggle.tsx | 54 +++-- .../advanced/labs/yaml-file-editor-modal.tsx | 19 +- .../migration-tools-import.tsx | 8 +- .../universal-import-modal.test.tsx | 16 +- .../universal-import-modal.tsx | 11 +- 13 files changed, 314 insertions(+), 201 deletions(-) create mode 100644 apps/admin/src/settings/app/components/providers/dialog-portal.tsx diff --git a/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx b/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx index 8b76df96a08..61f17ea8859 100644 --- a/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx +++ b/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx @@ -15,6 +15,21 @@ import { } from "@test-utils/acceptance"; import {settingsScreen} from "@/settings/settings.screen"; +// Settings groups and the content wrapper open stacking contexts; a dialog rendered +// inside them (rather than through the settings dialog portal) paints below the chrome. +function elementsPaintedOverModalBackdrop(): string[] { + const hits: string[] = []; + for (let x = 4; x < window.innerWidth; x += 24) { + for (let y = 4; y < window.innerHeight; y += 24) { + const element = document.elementFromPoint(x, y); + if (element && !element.closest("#modal-backdrop")) { + hits.push(`${x},${y}: <${element.tagName.toLowerCase()} id="${element.id}" data-testid="${element.getAttribute("data-testid") ?? ""}">`); + } + } + } + return hits; +} + function advancedSettings(overrides: Record) { return settingsResponse({settings: overrides}); } @@ -28,6 +43,8 @@ describe("Advanced settings", () => { const section = settingsScreen.section("code-injection"); await section.getByRole("button", {name: "Open"}).click(); const modal = settingsScreen.section("modal-code-injection"); + await expect.element(modal).toBeVisible(); + expect(elementsPaintedOverModalBackdrop()).toEqual([]); await modal.getByTestId("header-code").getByRole("textbox").fill(""); await modal.getByRole("tab", {name: "Site footer"}).click(); await modal.getByTestId("footer-code").getByRole("textbox").fill(""); @@ -128,6 +145,63 @@ describe("Advanced settings", () => { } }); + it("imports content from the universal importer and confirms the import is queued", async () => { + fakeSettingsScreens(); + const importApi = fakeAdminEndpoint("POST", "/db/", {}); + await renderAdminApp("/settings/migration"); + + await settingsScreen.section("migrationtools").getByRole("button", {name: "Universal import"}).click(); + const modal = page.getByTestId("universal-import-modal"); + const input = modal.element().querySelector("#import-file"); + if (!input) { + throw new Error("import file input was not rendered"); + } + await page.elementLocator(input).upload(new File(["{}"], "content.json", {type: "application/json"})); + + await expect.poll(() => importApi.requests.length).toBe(1); + await expect(modal).toHaveCount(0); + await expect.element(settingsScreen.confirmationModal()).toHaveTextContent("Import in progress"); + await settingsScreen.confirmationModal().getByRole("button", {name: "Got it"}).click(); + await expect(settingsScreen.confirmationModal()).toHaveCount(0); + }); + + it("closes the universal importer without importing when cancelled", async () => { + fakeSettingsScreens(); + const importApi = fakeAdminEndpoint("POST", "/db/", {}); + await renderAdminApp("/settings/migration"); + + await settingsScreen.section("migrationtools").getByRole("button", {name: "Universal import"}).click(); + const modal = page.getByTestId("universal-import-modal"); + await expect.element(modal).toBeVisible(); + await modal.getByRole("button", {name: "Cancel"}).click(); + + await expect(modal).toHaveCount(0); + expect(importApi.requests).toHaveLength(0); + }); + + it("enables the automations beta only after confirmation", async () => { + fakeSettingsScreens(); + const settingsApi = fakeEditSettings(); + await renderAdminApp("/settings/labs"); + + const section = settingsScreen.section("labs"); + await section.getByRole("button", {name: "Open"}).click(); + await section.getByRole("tab", {name: "Beta features"}).click(); + const toggle = section.getByRole("switch", {name: "Automations (beta)"}); + + await toggle.click(); + const confirmation = page.getByTestId("feature-toggle-confirmation-modal"); + await expect.element(confirmation).toBeVisible(); + await confirmation.getByRole("button", {name: "Cancel"}).click(); + await expect(confirmation).toHaveCount(0); + expect(settingsApi.requests).toHaveLength(0); + + await toggle.click(); + await confirmation.getByRole("button", {name: "Enable"}).click(); + await expect(confirmation).toHaveCount(0); + await expect.poll(() => settingsApi.lastRequest?.settings.find(setting => setting.key === "labs")?.value).toContain('"automations":true'); + }); + it("downloads the content and settings export", async () => { fakeSettingsScreens(); await renderAdminApp("/settings/migration"); diff --git a/apps/admin/src/settings/app/app.tsx b/apps/admin/src/settings/app/app.tsx index 5db40d992aa..992ac686b99 100644 --- a/apps/admin/src/settings/app/app.tsx +++ b/apps/admin/src/settings/app/app.tsx @@ -2,6 +2,7 @@ import MainContent from './main-content'; import NiceModal from '@ebay/nice-modal-react'; import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider'; import {ConfirmationProvider} from './components/providers/confirmation-provider'; +import {DialogPortalProvider} from './components/providers/dialog-portal'; import {Outlet, useLocation} from '@tryghost/admin-x-framework'; import {useEffect} from 'react'; import {useScrollSectionContext} from './hooks/use-scroll-section'; @@ -29,11 +30,13 @@ export function App({upgradeStatus}: AppProps) {
- - - - - + + + + + + +
diff --git a/apps/admin/src/settings/app/components/providers/dialog-portal.tsx b/apps/admin/src/settings/app/components/providers/dialog-portal.tsx new file mode 100644 index 00000000000..1d37ff3f800 --- /dev/null +++ b/apps/admin/src/settings/app/components/providers/dialog-portal.tsx @@ -0,0 +1,27 @@ +import React, {createContext, useContext, useState} from 'react'; +import {createPortal} from 'react-dom'; + +const DialogPortalContext = createContext(null); + +// Settings groups and the fixed content wrapper open stacking contexts, so an in-tree +// SettingsModal paints below the settings chrome; the host sits beside the layout instead. +export const DialogPortalProvider: React.FC<{children: React.ReactNode}> = ({children}) => { + const [host, setHost] = useState(null); + + return ( + + {children} +
+ + ); +}; + +export const DialogPortal: React.FC<{children: React.ReactNode}> = ({children}) => { + const host = useContext(DialogPortalContext); + + if (!host) { + return null; + } + + return createPortal(children, host); +}; diff --git a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx b/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx index cec819ea4cf..2c48683ec8f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx @@ -1,22 +1,24 @@ import CodeModal from './code/code-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import {Button} from '@tryghost/shade/components'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => { + const [isCodeModalOpen, setIsCodeModalOpen] = useState(false); + return ( { - NiceModal.show(CodeModal); - }}>Open} + customButtons={} description="Add custom code to your publication" keywords={keywords} navid='code-injection' testId='code-injection' title="Code injection" - /> + > + {isCodeModalOpen && setIsCodeModalOpen(false)} />} + ); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx index fe4d41d2e7b..c2e49fadc62 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx @@ -1,5 +1,4 @@ import CodeEditor from '@/settings/app/components/code-editor'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import useSettingGroup from '@/settings/app/hooks/use-setting-group'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; @@ -10,19 +9,15 @@ import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useSaveButton} from '@/settings/app/hooks/use-save-button'; interface CodeModalProps { - hint?: React.ReactNode; - value?: string; - onChange: (value: string) => void; - afterClose?: () => void + onClose: () => void; } -const CodeModal: React.FC = ({afterClose}) => { +const CodeModal: React.FC = ({onClose}) => { const { localSettings, handleSave, updateSetting } = useSettingGroup(); - const modal = useModal(); const [headerContent, footerContent] = getSettingValues(localSettings, ['codeinjection_head', 'codeinjection_foot']); @@ -63,22 +58,19 @@ const CodeModal: React.FC = ({afterClose}) => { }); return } height='full' size='full' testId='modal-code-injection' + onClose={onClose} >
Code injection - +
@@ -98,4 +90,4 @@ const CodeModal: React.FC = ({afterClose}) => { ; }; -export default NiceModal.create(CodeModal); +export default CodeModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx index 1d05d89ebf3..456d46ed62b 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import validator from 'validator'; import webhookEventOptions from './webhook-event-options'; @@ -10,11 +9,11 @@ import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface WebhookModalProps { webhook?: Webhook; integrationId: string; + onClose: () => void; } -const WebhookModal: React.FC = ({webhook, integrationId}) => { +const WebhookModal: React.FC = ({webhook, integrationId, onClose}) => { const eventErrorId = React.useId(); - const modal = useModal(); const {mutateAsync: createWebhook} = useCreateWebhook(); const {mutateAsync: editWebhook} = useEditWebhook(); const handleError = useHandleError(); @@ -59,9 +58,10 @@ const WebhookModal: React.FC = ({webhook, integrationId}) => testId='webhook-modal' title='Add webhook' formSheet + onClose={onClose} onOk={async () => { if (await handleSave()) { - modal.remove(); + onClose(); } }} > @@ -109,4 +109,4 @@ const WebhookModal: React.FC = ({webhook, integrationId}) => ; }; -export default NiceModal.create(WebhookModal); +export default WebhookModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx index a3d8717303a..e1554823ec4 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx @@ -1,5 +1,5 @@ -import NiceModal from '@ebay/nice-modal-react'; import WebhookModal from './webhook-modal'; +import {useState} from 'react'; import {Button, EmptyIndicator, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; @@ -7,7 +7,7 @@ import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {getWebhookEventLabel} from './webhook-event-options'; import {toast} from 'sonner'; import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; +import {type Webhook, useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { @@ -15,13 +15,22 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { const handleError = useHandleError(); const {confirm} = useConfirmation(); const webhooks = integration.webhooks || []; + const [editingWebhook, setEditingWebhook] = useState(null); + const [isAddingWebhook, setIsAddingWebhook] = useState(false); const showAddWebhookModal = () => { - NiceModal.show(WebhookModal, { - integrationId: integration.id - }); + setIsAddingWebhook(true); + }; + + const closeWebhookModal = () => { + setIsAddingWebhook(false); + setEditingWebhook(null); }; + const webhookModal = isAddingWebhook + ? + : editingWebhook && ; + const handleDelete = (id: string) => { confirm({ title: 'Are you sure?', @@ -57,6 +66,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { + {webhookModal} ); } @@ -72,12 +82,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { {webhooks.map(webhook => ( - { - NiceModal.show(WebhookModal, { - webhook, - integrationId: integration.id - }); - }}> + setEditingWebhook(webhook)}>
{webhook.name}
@@ -121,6 +126,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { + {webhookModal} ); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx index a915fbb8108..30f79243fe9 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx @@ -1,6 +1,5 @@ import FeatureToggle from './feature-toggle'; import LabItem from './lab-item'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import YamlFileEditorModal from './yaml-file-editor-modal'; import {ActionList, Button, Dropzone} from '@tryghost/shade/components'; @@ -11,6 +10,7 @@ import {getSettingValue} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; const IS_AUTOMATIONS_BETA_ACTIVE = true; @@ -23,115 +23,126 @@ const BetaFeatures: React.FC = () => { const [routesUploading, setRoutesUploading] = useState(false); const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); const isAutomationsEnabled = !!labs.automations; + const [openEditor, setOpenEditor] = useState<'redirects' | 'routes' | null>(null); - const openRedirectsEditor = () => { - NiceModal.show(YamlFileEditorModal, { - title: 'Redirects', - hint: <>Configure redirects for old or moved content. See the docs for the file format., - testId: 'modal-redirects-editor', - downloadPath: '/redirects/download/', - uploadFilename: 'redirects.yaml', - successMessage: 'Redirects updated', - onUpload: (file: File) => uploadRedirects(file) - }); - }; - - const openRoutesEditor = () => { - NiceModal.show(YamlFileEditorModal, { - title: 'Routes', - hint: <>Configure dynamic routing by editing the routes.yaml file. See the docs for the file format., - testId: 'modal-routes-editor', - downloadPath: '/settings/routes/yaml/', - uploadFilename: 'routes.yaml', - successMessage: 'Routes updated', - onUpload: (file: File) => uploadRoutes(file) - }); - }; + const openRedirectsEditor = () => setOpenEditor('redirects'); + const openRoutesEditor = () => setOpenEditor('routes'); + const closeEditor = () => setOpenEditor(null); return ( - - {IS_AUTOMATIONS_BETA_ACTIVE ? ( - } - detail={<>Build automated email flows for your members, and get early access to new automation features as they ship. Learn more →} - title='Automations (beta)' /> - ) : null} - } - detail={<>Allows newly-assigned editors to manage members and comments in addition to regular roles.} - title='Enhanced Editor role (beta)' /> - } - detail={<>Adds the excerpt input below the post title in the editor} - title='Show post excerpt inline' /> - } - detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. Learn more →} - title='Additional payment methods' /> - - - { - try { - setRedirectsUploading(true); - await uploadRedirects(file); - toast.success('Redirects uploaded'); - } catch (e) { - handleError(e); - } finally { - setRedirectsUploading(false); - } + <> + + {IS_AUTOMATIONS_BETA_ACTIVE ? ( + - {redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} - - - - - } - detail={<>Configure redirects for old or moved content,
more info in the docs} - testId='redirects' - title='Redirects' /> - - - { - try { - setRoutesUploading(true); - await uploadRoutes(file); - toast.success('Routes uploaded'); - } catch (e) { - handleError(e); - } finally { - setRoutesUploading(false); - } - }} - > - {routesUploading ? 'Uploading ...' : 'Upload routes file'} - - - - - } - detail='Configure dynamic routing by modifying the routes.yaml file' - testId='routes' - title='Routes' /> -
+ disabled={isAutomationsEnabled} + flag="automations" + label='Automations (beta)' />} + detail={<>Build automated email flows for your members, and get early access to new automation features as they ship. Learn more →} + title='Automations (beta)' /> + ) : null} + } + detail={<>Allows newly-assigned editors to manage members and comments in addition to regular roles.} + title='Enhanced Editor role (beta)' /> + } + detail={<>Adds the excerpt input below the post title in the editor} + title='Show post excerpt inline' /> + } + detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. Learn more →} + title='Additional payment methods' /> + + + { + try { + setRedirectsUploading(true); + await uploadRedirects(file); + toast.success('Redirects uploaded'); + } catch (e) { + handleError(e); + } finally { + setRedirectsUploading(false); + } + }} + > + {redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} + + + + + } + detail={<>Configure redirects for old or moved content,
more info in the docs} + testId='redirects' + title='Redirects' /> + + + { + try { + setRoutesUploading(true); + await uploadRoutes(file); + toast.success('Routes uploaded'); + } catch (e) { + handleError(e); + } finally { + setRoutesUploading(false); + } + }} + > + {routesUploading ? 'Uploading ...' : 'Upload routes file'} + + + + + } + detail='Configure dynamic routing by modifying the routes.yaml file' + testId='routes' + title='Routes' /> + + {openEditor === 'redirects' && ( + + Configure redirects for old or moved content. See the docs for the file format.} + successMessage='Redirects updated' + testId='modal-redirects-editor' + title='Redirects' + uploadFilename='redirects.yaml' + onClose={closeEditor} + onUpload={(file: File) => uploadRedirects(file)} + /> + + )} + {openEditor === 'routes' && ( + + Configure dynamic routing by editing the routes.yaml file. See the docs for the file format.} + successMessage='Routes updated' + testId='modal-routes-editor' + title='Routes' + uploadFilename='routes.yaml' + onClose={closeEditor} + onUpload={(file: File) => uploadRoutes(file)} + /> + + )} + ); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx index d6a73a2b3da..9586f79c080 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx @@ -1,5 +1,4 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import trackEvent from '@/settings/app/utils/analytics'; import {type ConfigResponseType, configDataType} from '@tryghost/admin-x-framework/api/config'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -8,6 +7,7 @@ import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/ import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; type ConfirmationProps = { title: string; @@ -25,21 +25,18 @@ type FeatureToggleProps = { type FeatureToggleConfirmationModalProps = ConfirmationProps & { onConfirm: () => Promise; + onClose: () => void; }; -const FeatureToggleConfirmationModal = NiceModal.create(({ +const FeatureToggleConfirmationModal: React.FC = ({ title, prompt, okLabel, okRunningLabel = 'Enabling...', - onConfirm + onConfirm, + onClose }) => { - const modal = useModal(); - const [isRunning, setIsRunning] = React.useState(false); - - const handleCancel = () => { - modal.remove(); - }; + const [isRunning, setIsRunning] = useState(false); const handleConfirm = async () => { setIsRunning(true); @@ -47,7 +44,7 @@ const FeatureToggleConfirmationModal = NiceModal.create
{prompt}
); -}); +}; const FeatureToggle: React.FC = ({label, flag, disabled, confirmation}) => { const {settings} = useGlobalData(); @@ -78,6 +76,7 @@ const FeatureToggle: React.FC = ({label, flag, disabled, con const client = useQueryClient(); const handleError = useHandleError(); const isEnabled = !!labs[flag]; + const [isConfirming, setIsConfirming] = useState(false); const saveFeatureValue = async (newValue: boolean) => { try { @@ -102,18 +101,25 @@ const FeatureToggle: React.FC = ({label, flag, disabled, con } }; - return { - - if (confirmation && newValue) { - NiceModal.show(FeatureToggleConfirmationModal, { - ...confirmation, - onConfirm: () => saveFeatureValue(newValue) - }); - return; - } + return <> + { + if (confirmation && newValue) { + setIsConfirming(true); + return; + } - await saveFeatureValue(newValue); - }} />; + await saveFeatureValue(newValue); + }} /> + {confirmation && isConfirming && ( + + setIsConfirming(false)} + onConfirm={() => saveFeatureValue(true)} + /> + + )} + ; }; export default FeatureToggle; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx index 9f8542fb9e9..58bb3a92832 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx @@ -1,5 +1,4 @@ import CodeEditor from '@/settings/app/components/code-editor'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useState} from 'react'; import {APIError, JSONError} from '@tryghost/admin-x-framework/errors'; import {Button} from '@tryghost/shade/components'; @@ -17,7 +16,7 @@ export interface YamlFileEditorModalProps { uploadFilename: string; successMessage: string; onUpload: (file: File) => Promise; - afterClose?: () => void; + onClose: () => void; } const extractErrorMessage = (error: unknown): string => { @@ -40,9 +39,8 @@ const YamlFileEditorModal: React.FC = ({ uploadFilename, successMessage, onUpload, - afterClose + onClose }) => { - const modal = useModal(); const handleError = useHandleError(); const [content, setContent] = useState(''); @@ -96,11 +94,6 @@ const YamlFileEditorModal: React.FC = ({ }; }, [downloadPath, uploadFilename]); - const closeModal = () => { - modal.remove(); - afterClose?.(); - }; - const handleSave = async () => { if (isSaving || isLoading || loadError) { return; @@ -115,7 +108,7 @@ const YamlFileEditorModal: React.FC = ({ toast.success(successMessage); - closeModal(); + onClose(); } catch (error) { setSaveError(extractErrorMessage(error)); handleError(error, {withToast: false}); @@ -143,19 +136,19 @@ const YamlFileEditorModal: React.FC = ({ return ( } height='full' size='full' testId={testId} + onClose={onClose} >
{title} - +
@@ -184,4 +177,4 @@ const YamlFileEditorModal: React.FC = ({ ); }; -export default NiceModal.create(YamlFileEditorModal); +export default YamlFileEditorModal; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx index 8febf9c3b9e..83566954d5d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx @@ -1,16 +1,17 @@ import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import UniversalImportModal from './universal-import-modal'; import {Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; const MigrationToolsImport: React.FC = () => { const {updateRoute} = useSettingsNavigation(); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); const handleImportContent = () => { - NiceModal.show(UniversalImportModal); + setIsImportModalOpen(true); }; const importers = [ @@ -31,6 +32,7 @@ const MigrationToolsImport: React.FC = () => { {importer.title} ))} + {isImportModalOpen && setIsImportModalOpen(false)} />}
); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx index e3c09dbafb3..78825bf3f8c 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -1,4 +1,4 @@ -import NiceModal from '@ebay/nice-modal-react'; +import {useState} from 'react'; import UniversalImportModal from '@/settings/app/components/settings/advanced/migration-tools/universal-import-modal'; import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; @@ -36,16 +36,14 @@ describe('UniversalImportModal', () => { mockImportContentCSV.mockResolvedValue({}); }); - afterEach(() => { - void NiceModal.remove(UniversalImportModal); - }); - const showModal = () => { - render(); + const ModalHarness = () => { + const [isOpen, setIsOpen] = useState(true); - act(() => { - void NiceModal.show(UniversalImportModal); - }); + return isOpen ? setIsOpen(false)} /> : null; + }; + + render(); }; const fileInput = async () => await screen.findByTestId('import-file'); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx index c43db66a783..acb85bbbca0 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {Button, Dropzone} from '@tryghost/shade/components'; @@ -10,8 +9,7 @@ import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useImportContent} from '@tryghost/admin-x-framework/api/db'; import {useImportContentCSV} from '@tryghost/admin-x-framework/api/posts'; -const UniversalImportModal: React.FC = () => { - const modal = useModal(); +const UniversalImportModal: React.FC<{onClose: () => void}> = ({onClose}) => { const {mutateAsync: importContent} = useImportContent(); const {mutateAsync: importContentCSV} = useImportContentCSV(); const csvContentImporter = useFeatureFlag('csvContentImporter'); @@ -32,13 +30,14 @@ const UniversalImportModal: React.FC = () => { Learn more
+ {installedModal && setInstalledModal(null)} />}
); }; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx index d2cc106175f..3f7de204e3e 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx @@ -1,6 +1,5 @@ import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import React from 'react'; +import React, {useState} from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; @@ -62,6 +61,7 @@ const ThemeActions: React.FC = ({ const upgradeRoute = useUpgradeRoute(); const {checkThemeLimitError} = useCheckThemeLimitError(); const {confirm, showLimit} = useConfirmation(); + const [activationErrors, setActivationErrors] = useState(null); const handleActivate = async () => { try { @@ -75,19 +75,8 @@ const ThemeActions: React.FC = ({ } else { handleError(e); } - const title = 'Theme not activated'; - const prompt = <>This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.; - if (fatalErrors) { - NiceModal.show(InvalidThemeModal, { - title, - prompt, - fatalErrors, - onRetry: async (modal) => { - modal?.remove(); - handleActivate(); - } - }); + setActivationErrors(fatalErrors); } } }; @@ -174,6 +163,18 @@ const ThemeActions: React.FC = ({ )} + {activationErrors && ( + This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.} + title='Theme not activated' + onClose={() => setActivationErrors(null)} + onRetry={async () => { + setActivationErrors(null); + handleActivate(); + }} + /> + )}
); }; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx index dfa07682af9..1cd54100588 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx @@ -1,31 +1,31 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {type ReactNode} from 'react'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {formatNumber} from '@tryghost/shade/utils'; export type {FatalErrors} from './theme-validation-details'; -const InvalidThemeModal: React.FC<{ +export type InvalidThemeModalProps = { title: string - prompt: ReactNode + prompt?: ReactNode fatalErrors?: FatalErrors; validationDetailsDefaultOpen?: boolean; onRetry?: (modal?: { remove: () => void; }) => void | Promise; -}> = ({title, prompt, fatalErrors, validationDetailsDefaultOpen, onRetry}) => { +}; + +const InvalidThemeModal: React.FC void}> = ({title, prompt, fatalErrors, validationDetailsDefaultOpen, onRetry, onClose}) => { const {data: configData} = useBrowseConfig(); const defaultOpen = validationDetailsDefaultOpen ?? configData?.config?.environment === 'development'; const {blockingProblems, secondaryProblems, stringErrors} = getIssuesFromFatalErrors(fatalErrors); const blockingIssueCount = blockingProblems.length + stringErrors.length; - const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.; - - const modal = useModal(); + const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${formatNumber(blockingIssueCount)} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.; return
@@ -48,10 +48,9 @@ const InvalidThemeModal: React.FC<{ } stickyFooter={true} title={title} - visible={modal.visible} onOk={onRetry} - onRemove={() => modal.remove()} + onRemove={onClose} />; }; -export default NiceModal.create(InvalidThemeModal); +export default InvalidThemeModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx index 3e21887985b..50ba523125e 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx @@ -1,12 +1,11 @@ import CodeMirror, {EditorView} from '@uiw/react-codemirror'; import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import ThemeEditorConfirmModal from './theme-editor-confirm-modal'; import ThemeEditorInputModal from './theme-editor-input-modal'; import ThemeEditorToolbar from './theme-editor-toolbar'; import ThemeFileTree from './theme-file-tree'; -import ThemeInstalledModal from './theme-installed-modal'; +import ThemeInstalledModal, {type ThemeInstalledModalProps} from './theme-installed-modal'; import {TextWrap, Undo2} from 'lucide-react'; import { cloneThemeFiles, @@ -27,6 +26,7 @@ import {toast} from 'sonner'; import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; +import {formatNumber} from '@tryghost/shade/utils'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import type {SelectedNode} from './theme-file-tree'; import type {ThemeEditorConfirmModalProps} from './theme-editor-confirm-modal'; @@ -185,6 +185,16 @@ type UploadSizeLimitError = { }; }; +type ThemeEditorDialogRequest = { + type: 'confirmation'; + props: ThemeEditorConfirmModalProps; + resolve: (result: boolean) => void; +} | { + type: 'input'; + props: ThemeEditorInputModalProps; + resolve: (result: string | null) => void; +}; + const UPLOAD_SIZE_LIMIT_TITLES: Record = { COMPRESSED_TOO_LARGE: 'Theme too large to upload', ENTRY_TOO_LARGE: 'File too large', @@ -249,6 +259,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const [isSaving, setIsSaving] = useState(false); const [loadError, setLoadError] = useState(null); const [isTextWrapEnabled, setIsTextWrapEnabled] = useState(false); + const [dialogRequest, setDialogRequest] = useState(null); + const dialogRequestRef = useRef(null); + const [saveErrors, setSaveErrors] = useState(null); + const [installedModal, setInstalledModal] = useState(null); const [editorExtensions, setEditorExtensions] = useState | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited>>>([]); useEffect(() => { @@ -369,44 +383,46 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { }; }, [isTextWrapEnabled, selectedFile]); - const requestConfirmation = async ({ - title, - prompt, - cancelLabel, - okLabel, - okVariant - }: ThemeEditorConfirmModalProps) => { - const confirmed = await NiceModal.show(ThemeEditorConfirmModal, { - title, - prompt, - cancelLabel, - okLabel, - okVariant - }) as boolean | undefined; - - return Boolean(confirmed); + const cancelPendingDialogRequest = useCallback(() => { + const request = dialogRequestRef.current; + + if (!request) { + return; + } + + dialogRequestRef.current = null; + + if (request.type === 'confirmation') { + request.resolve(false); + } else { + request.resolve(null); + } + }, []); + + const requestConfirmation = (props: ThemeEditorConfirmModalProps) => { + cancelPendingDialogRequest(); + + return new Promise((resolve) => { + const request: ThemeEditorDialogRequest = {type: 'confirmation', props, resolve}; + dialogRequestRef.current = request; + setDialogRequest(request); + }); }; - const requestInput = async ({ - title, - prompt, - fieldTitle, - initialValue, - placeholder, - cancelLabel, - okLabel - }: ThemeEditorInputModalProps) => { - return await NiceModal.show(ThemeEditorInputModal, { - title, - prompt, - fieldTitle, - initialValue, - placeholder, - cancelLabel, - okLabel - }) as string | null; + const requestInput = (props: ThemeEditorInputModalProps) => { + cancelPendingDialogRequest(); + + return new Promise((resolve) => { + const request: ThemeEditorDialogRequest = {type: 'input', props, resolve}; + dialogRequestRef.current = request; + setDialogRequest(request); + }); }; + useEffect(() => { + return () => cancelPendingDialogRequest(); + }, [cancelPendingDialogRequest]); + const closeEditor = async () => { if (changes.length > 0) { const shouldDiscard = await requestConfirmation({ @@ -430,10 +446,19 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { // each render — using a ref decouples that churn from the global listener. const handleSaveRef = useRef<() => void>(() => {}); + const hasOpenDialog = Boolean(dialogRequest || saveErrors || installedModal); + useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); + + if (hasOpenDialog) { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + void handleSaveRef.current(); return; } @@ -442,6 +467,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { return; } + if (hasOpenDialog) { + return; + } + event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); @@ -452,7 +481,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { return () => { window.removeEventListener('keydown', handleKeydown, true); }; - }, []); + }, [hasOpenDialog]); const ensurePathExpanded = (path: string) => { setExpandedDirectories((current) => { @@ -645,7 +674,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const confirmed = await requestConfirmation({ title: 'Delete folder', - prompt: <>Delete {matchingPaths.length} file{matchingPaths.length === 1 ? '' : 's'} from {selectedNode.path}?, + prompt: <>Delete {formatNumber(matchingPaths.length)} file{matchingPaths.length === 1 ? '' : 's'} from {selectedNode.path}?, okLabel: 'Delete', okVariant: 'destructive' }); @@ -728,7 +757,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const themeExists = themesData?.themes.some(theme => theme.name === nextThemeName) || false; const confirmMessage = isSaveAs ? `Save your edits as "${nextThemeName}"?` - : `Upload ${changes.length} changed file${changes.length === 1 ? '' : 's'} and replace "${previousThemeName}"?`; + : `Upload ${formatNumber(changes.length)} changed file${changes.length === 1 ? '' : 's'} and replace "${previousThemeName}"?`; const confirmedSave = await requestConfirmation({ title: isSaveAs ? 'Save theme as new copy' : 'Update theme', @@ -781,10 +810,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { if (!response.ok) { if (response.status === 422 && data?.errors) { - NiceModal.show(InvalidThemeModal, { - title: 'Theme not saved', - fatalErrors: data.errors as FatalErrors - }); + setSaveErrors(data.errors as FatalErrors); return; } @@ -812,7 +838,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { await queryClient.invalidateQueries({queryKey: ['ThemesResponseType']}); if (isSaveAs || uploadedTheme.errors?.length || uploadedTheme.warnings?.length) { - NiceModal.show(ThemeInstalledModal, { + setInstalledModal({ title: isSaveAs ? 'Theme saved' : 'Theme updated', prompt: <>{uploadedTheme.name} saved successfully., installedTheme: uploadedTheme @@ -838,7 +864,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const selectedFileStatus = selectedFile ? changesMap.get(selectedFile.path) : null; - return ( + return (<>
= ({themeName}) => {
- ); + {dialogRequest?.type === 'confirmation' && ( + { + if (dialogRequestRef.current !== dialogRequest) { + return; + } + + dialogRequestRef.current = null; + setDialogRequest(null); + dialogRequest.resolve(result); + }} + /> + )} + {dialogRequest?.type === 'input' && ( + { + if (dialogRequestRef.current !== dialogRequest) { + return; + } + + dialogRequestRef.current = null; + setDialogRequest(null); + dialogRequest.resolve(result); + }} + /> + )} + {saveErrors && setSaveErrors(null)} />} + {installedModal && setInstalledModal(null)} />} + ); }; export default ThemeCodeEditorModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx index 296457de574..3a01cc3e612 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import {SettingsModal} from '@tryghost/shade/patterns'; import type {ButtonProps} from '@tryghost/shade/components'; @@ -11,20 +10,14 @@ export type ThemeEditorConfirmModalProps = { okVariant?: ButtonProps['variant']; }; -const ThemeEditorConfirmModal = NiceModal.create(({ +const ThemeEditorConfirmModal: React.FC void}> = ({ title, prompt, cancelLabel = 'Cancel', okLabel = 'OK', - okVariant = 'default' + okVariant = 'default', + onResolve }) => { - const modal = useModal(); - - const closeWithResult = (result: boolean) => { - modal.resolve(result); - modal.remove(); - }; - return ( (( testId='theme-editor-confirm-modal' title={title} width={540} - onCancel={() => closeWithResult(false)} - onOk={() => closeWithResult(true)} + onCancel={() => onResolve(false)} + onClose={() => onResolve(false)} + onOk={() => onResolve(true)} >
{prompt}
); -}); +}; export default ThemeEditorConfirmModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx index f619909c957..a58c4d68ce3 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import {Field, FieldLabel, Input} from '@tryghost/shade/components'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -13,23 +12,18 @@ export type ThemeEditorInputModalProps = { okLabel?: string; }; -const ThemeEditorInputModal = NiceModal.create(({ +const ThemeEditorInputModal: React.FC void}> = ({ title, prompt, fieldTitle, initialValue, placeholder, cancelLabel = 'Cancel', - okLabel = 'Continue' + okLabel = 'Continue', + onResolve }) => { - const modal = useModal(); const [value, setValue] = useState(initialValue); - const closeWithResult = (result: string | null) => { - modal.resolve(result); - modal.remove(); - }; - return ( (({ testId='theme-editor-input-modal' title={title} width={540} - onCancel={() => closeWithResult(null)} - onOk={() => closeWithResult(value)} + onCancel={() => onResolve(null)} + onClose={() => onResolve(null)} + onOk={() => onResolve(value)} >
{prompt} @@ -51,6 +46,6 @@ const ThemeEditorInputModal = NiceModal.create(({
); -}); +}; export default ThemeEditorInputModal; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx index 353af778f05..715fe53a898 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {type ReactNode} from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; @@ -9,13 +8,15 @@ import {toast} from 'sonner'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -const ThemeInstalledModal: React.FC<{ +export type ThemeInstalledModalProps = { title: string prompt: ReactNode installedTheme: InstalledTheme; validationDetailsDefaultOpen?: boolean; onActivate?: () => void; -}> = ({title, installedTheme, validationDetailsDefaultOpen, onActivate}) => { +}; + +const ThemeInstalledModal: React.FC void}> = ({title, installedTheme, validationDetailsDefaultOpen, onActivate, onClose}) => { const {mutateAsync: activateTheme} = useActivateTheme(); const {refreshActiveThemeData} = useCustomFonts(); const handleError = useHandleError(); @@ -46,8 +47,6 @@ const ThemeInstalledModal: React.FC<{ ); - const modal = useModal(); - return } stickyFooter={true} title={modalTitle} - visible={modal.visible} onOk={async (activateModal) => { if (!installedTheme.active) { try { @@ -86,13 +84,14 @@ const ThemeInstalledModal: React.FC<{ toast.success('Theme activated', {description:
{updatedTheme.name} is now your active theme.
}); } catch (e) { handleError(e); + return; } } onActivate?.(); activateModal?.remove(); }} - onRemove={() => modal.remove()} + onRemove={onClose} />; }; -export default NiceModal.create(ThemeInstalledModal); +export default ThemeInstalledModal; diff --git a/apps/admin/src/settings/site/theme.acceptance.test.tsx b/apps/admin/src/settings/site/theme.acceptance.test.tsx index 2858508bd12..cd6ff991d1c 100644 --- a/apps/admin/src/settings/site/theme.acceptance.test.tsx +++ b/apps/admin/src/settings/site/theme.acceptance.test.tsx @@ -145,6 +145,70 @@ describe("Theme settings", () => { expect(uploadApi.requests).toHaveLength(1); }); + it("keeps the installed-theme dialog open when activation fails", async () => { + fakeThemeWorld(); + const uploaded = theme({ name: "mytheme" }); + fakeAdminEndpoint("POST", "/themes/upload/", { themes: [uploaded] }); + const activateApi = fakeAdminEndpoint("PUT", "/themes/mytheme/activate/", { + errors: [{ message: "Theme activation failed" }], + }, { status: 422 }); + const buffer = await archiveBuffer(); + await renderAdminApp("/settings/design/change-theme"); + + await settingsScreen.themeModal().getByRole("button", { name: "Upload theme" }).click(); + await uploadThemeFile(new File([buffer], "mytheme.zip", { type: "application/zip" })); + + const installedModal = settingsScreen.confirmationModal(); + await installedModal.getByRole("button", { name: "Activate theme" }).click(); + + await expect.element(installedModal).toBeVisible(); + await expect.element(settingsScreen.errorToast()).toHaveTextContent("Theme activation failed"); + await expect.poll(currentRoute).toBe("/settings/design/change-theme"); + expect(activateApi.requests).toHaveLength(1); + }); + + it("reports blocking upload errors and retries the upload from the error dialog", async () => { + fakeThemeWorld(); + const uploadApi = fakeAdminEndpoint("POST", "/themes/upload/", { + errors: [{ message: "Theme is not compatible or contains errors.", details: "Missing index.hbs" }], + }, { status: 422 }); + const buffer = await archiveBuffer(); + await renderAdminApp("/settings/design/change-theme"); + + await settingsScreen.themeModal().getByRole("button", { name: "Upload theme" }).click(); + await uploadThemeFile(new File([buffer], "theme.zip", { type: "application/zip" })); + + const errorModal = settingsScreen.confirmationModal(); + await expect.element(errorModal).toHaveTextContent("Theme not uploaded"); + await expect.element(errorModal).toHaveTextContent("Missing index.hbs"); + expect(uploadApi.requests).toHaveLength(1); + + await errorModal.getByRole("button", { name: "Retry" }).click(); + await expect.element(page.getByText("Click to select or drag & drop zip file", { exact: true })).toBeVisible(); + await expect(page.getByText("Theme not uploaded")).toHaveCount(0); + }); + + it("reports blocking activation errors for an installed theme", async () => { + fakeThemeWorld(); + const activateApi = fakeAdminEndpoint("PUT", "/themes/casper/activate/", { + errors: [{ message: "Theme is not compatible or contains errors.", details: "Missing post.hbs" }], + }, { status: 422 }); + await renderAdminApp("/settings/design/change-theme"); + + const modal = settingsScreen.themeModal(); + await modal.getByRole("tab", { name: "Installed" }).click(); + await installedTheme("casper").getByRole("button", { name: "Activate" }).click(); + + const errorModal = settingsScreen.confirmationModal(); + await expect.element(errorModal).toHaveTextContent("Theme not activated"); + await expect.element(errorModal).toHaveTextContent("Missing post.hbs"); + expect(activateApi.requests).toHaveLength(1); + + await errorModal.getByRole("button", { name: "Close" }).click(); + await expect(settingsScreen.confirmationModal()).toHaveCount(0); + await expect.element(modal).toBeVisible(); + }); + it("prevents uploading an archive over a built-in theme", async () => { fakeThemeWorld(); const uploadApi = fakeAdminEndpoint("POST", "/themes/upload/", { themes: [theme({ name: "source" })] }); @@ -190,6 +254,9 @@ describe("Theme settings", () => { const editor = await editorTextbox(); await editor.fill('{"name":"edition","version":"1.0.0"}\n'); window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true })); + await expect.element(settingsScreen.themeEditorConfirmModal()).toBeVisible(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true })); + await expect(settingsScreen.themeEditorConfirmModal()).toHaveCount(1); await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Replace theme" }).click(); await expect.element(settingsScreen.successToast()).toHaveTextContent(/Theme saved/i); @@ -218,6 +285,28 @@ describe("Theme settings", () => { await expect.element(settingsScreen.errorToast()).toHaveTextContent(/1\.0 MB/); }); + it("keeps the code editor open and reports blocking validation errors on save", async () => { + fakeThemeWorld(); + await fakeThemeDownload("edition"); + fakeAdminEndpoint("POST", "/themes/upload/", { + errors: [{ message: "Theme is not compatible or contains errors.", details: "Missing default.hbs" }], + }, { status: 422 }); + await renderAdminApp("/settings/theme/edit/edition"); + + const editor = await editorTextbox(); + await editor.fill('{"name":"edition","version":"1.0.0"}\n'); + await settingsScreen.themeCodeEditorModal().getByRole("button", { name: "Save" }).click(); + await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Replace theme" }).click(); + + const errorModal = settingsScreen.confirmationModal(); + await expect.element(errorModal).toHaveTextContent("Theme not saved"); + await expect.element(errorModal).toHaveTextContent("Missing default.hbs"); + await expect(errorModal.getByRole("button", { name: "Retry" })).toHaveCount(0); + await userEvent.keyboard("{Escape}"); + await expect(settingsScreen.confirmationModal()).toHaveCount(0); + await expect.element(settingsScreen.themeCodeEditorModal()).toBeVisible(); + }); + it("requires built-in themes to be saved under a valid new name", async () => { fakeThemeWorld(); await fakeThemeDownload("casper"); @@ -346,7 +435,8 @@ describe("Theme settings", () => { await editor.fill('{"name":"edition","version":"1.0.0"}\n'); await settingsScreen.themeCodeEditorModal().getByRole("button", { name: "Close" }).click(); await expect.element(settingsScreen.themeEditorConfirmModal()).toHaveTextContent(/unsaved theme changes/i); - await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Cancel" }).click(); + await userEvent.keyboard("{Escape}"); + await expect(settingsScreen.themeEditorConfirmModal()).toHaveCount(0); await expect.element(settingsScreen.themeCodeEditorModal()).toBeVisible(); await settingsScreen.themeCodeEditorModal().getByRole("button", { name: "Close" }).click(); await settingsScreen.themeEditorConfirmModal().getByRole("button", { name: "Discard changes" }).click(); From 9e65e0b4488a1ccced2a5aa3f9dfe79e7f8e926a Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Mon, 17 Aug 2026 08:22:30 +0000 Subject: [PATCH 10/22] Added @tryghost/adapter-base-jobs contract package ref https://linear.app/ghost/issue/HKG-1914 Ghost's background jobs are moving onto a class-based interface whose transport is swappable, and this package defines the seam every backend implements. A backend extends JobsBackendBase - a frozen start/enqueue/scheduleRecurring/ shutdown contract - and only ever sees the serialised {type, payload} envelope and a single delivery-processor callback, never a live job instance. Keeping the backend blind to job instances is what makes jobs serialisable end to end and lets a durable backend drop in for the in-memory one without touching a call site. The package also exports a backend-agnostic contract test suite, so the in-memory reference backend and any future durable backend are held to the same acceptance, delivery, drain and bounded-shutdown behaviour. It is a private workspace package, matching the other adapter-base-* packages. --- packages/adapters/jobs-base/README.md | 45 +++++++ packages/adapters/jobs-base/eslint.config.mjs | 9 ++ packages/adapters/jobs-base/package.json | 68 ++++++++++ packages/adapters/jobs-base/src/base.ts | 37 ++++++ .../jobs-base/src/contract-test-suite.ts | 120 ++++++++++++++++++ packages/adapters/jobs-base/src/index.ts | 1 + packages/adapters/jobs-base/test/base.test.ts | 19 +++ .../adapters/jobs-base/test/tsconfig.json | 11 ++ packages/adapters/jobs-base/tsconfig.json | 10 ++ packages/adapters/jobs-base/vitest.config.ts | 12 ++ 10 files changed, 332 insertions(+) create mode 100644 packages/adapters/jobs-base/README.md create mode 100644 packages/adapters/jobs-base/eslint.config.mjs create mode 100644 packages/adapters/jobs-base/package.json create mode 100644 packages/adapters/jobs-base/src/base.ts create mode 100644 packages/adapters/jobs-base/src/contract-test-suite.ts create mode 100644 packages/adapters/jobs-base/src/index.ts create mode 100644 packages/adapters/jobs-base/test/base.test.ts create mode 100644 packages/adapters/jobs-base/test/tsconfig.json create mode 100644 packages/adapters/jobs-base/tsconfig.json create mode 100644 packages/adapters/jobs-base/vitest.config.ts diff --git a/packages/adapters/jobs-base/README.md b/packages/adapters/jobs-base/README.md new file mode 100644 index 00000000000..052c37dd837 --- /dev/null +++ b/packages/adapters/jobs-base/README.md @@ -0,0 +1,45 @@ +# @tryghost/adapter-base-jobs + +Base class and contract for Ghost jobs backends. + +A jobs backend is the swappable transport behind Ghost's class-based jobs +interface. It only ever sees serialised `{type, payload}` envelopes and a single +delivery `processor` callback - it never touches a live job instance. That is +what keeps jobs serialisable end to end and lets a durable backend replace the +in-memory one with no call-site change. + +## Contract + +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. +- `shutdown({timeoutMs})` - stop accepting work and drain in-flight work within a bounded time. + +### Delivery outcome + +The backend delivers an envelope by calling `processor(envelope)` and awaiting the +returned promise. **The processor may reject: a rejected promise means a failed +delivery.** The backend decides what happens next - the in-memory reference +backend logs and drops it (parity with the legacy in-process queue, no +redelivery); a durable backend can redeliver. The processor never throws +synchronously, so a backend only needs to handle the rejected-promise case. + +`scheduleRecurring` is idempotent per type - the first schedule wins and +re-registration is a no-op, so a durable backend must not disturb an +already-running schedule. `cron` is a 5-field expression or 6 fields with a +leading seconds field. Delivery is at-most-once in memory but at-least-once on a +durable backend, so handlers must tolerate redelivery. + +## Shared contract test suite + +The package exports a backend-agnostic test suite so every backend runs the same +acceptance / delivery / drain / bounded-shutdown assertions: + +```ts +import {runJobsBackendContractTests} from '@tryghost/adapter-base-jobs/contract-test-suite'; + +// `describe`/`it` are injected so the suite depends on no specific test runner +runJobsBackendContractTests(() => new MyJobsBackend(), {describe, it}); +``` diff --git a/packages/adapters/jobs-base/eslint.config.mjs b/packages/adapters/jobs-base/eslint.config.mjs new file mode 100644 index 00000000000..dbe70fe8033 --- /dev/null +++ b/packages/adapters/jobs-base/eslint.config.mjs @@ -0,0 +1,9 @@ +import {nodeLibConfig} from '@internal/cfg-eslint'; + +export default nodeLibConfig({ + // The shared contract test suite is authored in src/ (it is a package export) + // but it uses vitest globals; treat it like test code for lint purposes. + extraTestRules: { + 'ghost/ghost-custom/no-native-error': 'off' + } +}); diff --git a/packages/adapters/jobs-base/package.json b/packages/adapters/jobs-base/package.json new file mode 100644 index 00000000000..d67d295c3ee --- /dev/null +++ b/packages/adapters/jobs-base/package.json @@ -0,0 +1,68 @@ +{ + "name": "@tryghost/adapter-base-jobs", + "version": "0.0.0", + "description": "Base class and contract for Ghost jobs backends.", + "private": true, + "ghostPackage": { + "goldenPath": "compliant" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/TryGhost/Ghost.git", + "directory": "packages/adapters/jobs-base" + }, + "author": "Ghost Foundation", + "license": "MIT", + "keywords": [ + "ghost", + "jobs", + "adapter" + ], + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./build/index.d.ts", + "default": "./build/index.js" + }, + "./contract-test-suite": { + "source": "./src/contract-test-suite.ts", + "types": "./build/contract-test-suite.d.ts", + "default": "./build/contract-test-suite.js" + } + }, + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "build": "tsc", + "test:unit": "NODE_ENV=testing vitest run --coverage", + "test": "pnpm run '/^test:/'", + "test:types": "tsc --noEmit -p test/tsconfig.json", + "lint:code": "eslint src/ --cache", + "lint:test": "eslint test/ --cache", + "lint": "pnpm run '/^lint:/'" + }, + "files": [ + "build" + ], + "devDependencies": { + "@internal/cfg-eslint": "workspace:*", + "@internal/cfg-typescript": "workspace:*", + "@internal/cfg-vitest": "workspace:*", + "@types/node": "catalog:", + "@typescript/native": "catalog:", + "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "nx": { + "targets": { + "build": { + "outputs": [ + "{projectRoot}/build" + ] + } + } + } +} diff --git a/packages/adapters/jobs-base/src/base.ts b/packages/adapters/jobs-base/src/base.ts new file mode 100644 index 00000000000..f90035d49d7 --- /dev/null +++ b/packages/adapters/jobs-base/src/base.ts @@ -0,0 +1,37 @@ +export interface JobEnvelope { + type: string; + payload: string; +} + +export type JobProcessor = (envelope: JobEnvelope) => Promise; + +export interface JobsStartOptions { + processor: JobProcessor; +} + +export interface RecurringSchedule { + cron: string; +} + +export interface JobsShutdownOptions { + timeoutMs?: number; +} + +export abstract class JobsBackendBase { + declare readonly requiredFns: readonly ['start', 'enqueue', 'scheduleRecurring', 'shutdown']; + + constructor() { + Object.defineProperty(this, 'requiredFns', { + value: Object.freeze(['start', 'enqueue', 'scheduleRecurring', 'shutdown']), + writable: false + }); + } + + abstract start(options: JobsStartOptions): void | Promise; + + abstract enqueue(envelope: JobEnvelope): void | Promise; + + abstract scheduleRecurring(envelope: JobEnvelope, schedule: RecurringSchedule): 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 new file mode 100644 index 00000000000..7e6ac752fbe --- /dev/null +++ b/packages/adapters/jobs-base/src/contract-test-suite.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import {JobsBackendBase, JobEnvelope} from './base.ts'; + +export type BackendFactory = () => JobsBackendBase; + +export interface JobsContractTestFramework { + describe(name: string, fn: () => void): void; + it(name: string, fn: () => void | Promise): void; +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return {promise, resolve}; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function runJobsBackendContractTests( + makeBackend: BackendFactory, + {describe, it}: JobsContractTestFramework +): void { + const envelope: JobEnvelope = {type: 'test-job', payload: '{"value":1}'}; + + describe('jobs backend contract', function () { + it('delivers an enqueued envelope to the processor', async function () { + const received: JobEnvelope[] = []; + const backend = makeBackend(); + await backend.start({processor: async (env) => { + received.push(env); + }}); + + await backend.enqueue(envelope); + await backend.shutdown({timeoutMs: 1000}); + + assert.deepEqual(received, [envelope]); + }); + + it('resolves enqueue on acceptance, not on completion', async function () { + let completed = false; + const release = deferred(); + const started = deferred(); + + const backend = makeBackend(); + await backend.start({processor: async () => { + started.resolve(); + await release.promise; + completed = true; + }}); + + await backend.enqueue(envelope); + + await started.promise; + assert.equal(completed, false); + + release.resolve(); + await backend.shutdown({timeoutMs: 1000}); + assert.equal(completed, true); + }); + + it('drains in-flight work on shutdown', async function () { + let completed = false; + const backend = makeBackend(); + await backend.start({processor: async () => { + await delay(30); + completed = true; + }}); + + await backend.enqueue(envelope); + await backend.shutdown({timeoutMs: 1000}); + + assert.equal(completed, true); + }); + + it('bounds shutdown when a handler hangs', async function () { + const backend = makeBackend(); + const started = deferred(); + await backend.start({processor: () => new Promise(() => { + started.resolve(); + })}); + + await backend.enqueue(envelope); + await started.promise; + + const raced = await Promise.race([ + Promise.resolve(backend.shutdown({timeoutMs: 30})).then(() => 'shutdown'), + delay(2000).then(() => 'hung') + ]); + + assert.equal(raced, 'shutdown'); + }); + + it('tolerates start after a prior shutdown', async function () { + const received: JobEnvelope[] = []; + const backend = makeBackend(); + + await backend.start({processor: async () => {}}); + await backend.shutdown({timeoutMs: 1000}); + + await backend.start({processor: async (env) => { + received.push(env); + }}); + await backend.enqueue(envelope); + await backend.shutdown({timeoutMs: 1000}); + + assert.deepEqual(received, [envelope]); + }); + }); +} diff --git a/packages/adapters/jobs-base/src/index.ts b/packages/adapters/jobs-base/src/index.ts new file mode 100644 index 00000000000..45beaac896e --- /dev/null +++ b/packages/adapters/jobs-base/src/index.ts @@ -0,0 +1 @@ +export * from './base.ts'; diff --git a/packages/adapters/jobs-base/test/base.test.ts b/packages/adapters/jobs-base/test/base.test.ts new file mode 100644 index 00000000000..1d8390f8588 --- /dev/null +++ b/packages/adapters/jobs-base/test/base.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'vitest'; + +import {JobsBackendBase, JobEnvelope, RecurringSchedule, JobsStartOptions} from '../src/base.ts'; + +class TestBackend extends JobsBackendBase { + start(_options: JobsStartOptions) {} + enqueue(_envelope: JobEnvelope) {} + scheduleRecurring(_envelope: JobEnvelope, _schedule: RecurringSchedule) {} + shutdown() {} +} + +describe('adapter-base-jobs', function () { + it('exposes the frozen requiredFns contract', function () { + const backend = new TestBackend(); + assert.deepEqual(backend.requiredFns, ['start', 'enqueue', 'scheduleRecurring', 'shutdown']); + assert.ok(Object.isFrozen(backend.requiredFns), 'requiredFns should be frozen'); + }); +}); diff --git a/packages/adapters/jobs-base/test/tsconfig.json b/packages/adapters/jobs-base/test/tsconfig.json new file mode 100644 index 00000000000..92d71849ad0 --- /dev/null +++ b/packages/adapters/jobs-base/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": [ + "../src/**/*", + "**/*" + ] +} diff --git a/packages/adapters/jobs-base/tsconfig.json b/packages/adapters/jobs-base/tsconfig.json new file mode 100644 index 00000000000..a010ea0e4c6 --- /dev/null +++ b/packages/adapters/jobs-base/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@internal/cfg-typescript/esm.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/adapters/jobs-base/vitest.config.ts b/packages/adapters/jobs-base/vitest.config.ts new file mode 100644 index 00000000000..342e26fa786 --- /dev/null +++ b/packages/adapters/jobs-base/vitest.config.ts @@ -0,0 +1,12 @@ +import {createVitestConfig} from '@internal/cfg-vitest'; + +// The shared contract test suite (contract-test-suite.ts) is a package export +// exercised by each *backend's* tests (e.g. the in-memory backend in ghost/core), +// not by this package's own unit tests - exclude it from this package's coverage. +export default createVitestConfig({ + test: { + coverage: { + exclude: ['src/contract-test-suite.ts'] + } + } +}); From 7d4c891a8c32b664e624e265933298696d04e8b5 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Mon, 17 Aug 2026 08:22:57 +0000 Subject: [PATCH 11/22] Added the in-memory jobs service and backend ref https://linear.app/ghost/issue/HKG-1914 This is the first working layer on top of the backend contract, and the point at which jobs can be registered and dispatched at all. The class-based jobs service owns everything the transport does not: the handler registry, the JSON envelope boundary, rehydration, cron validation and error reporting. InMemoryJobsBackend is the reference implementation - delivery runs through a bounded fastq queue whose concurrency comes from adapter config, shutdown drains in-flight work within a bounded time, and a recurring schedule is first-write-wins so a re-registration never disturbs one that is already running. A handler failure is reported and rethrown so the backend sees a rejected delivery as a failed one; the in-memory backend defensively logs and drops it, matching the legacy in-process queue with no redelivery yet. Because the backend is selected through adapter config, a durable backend is a drop-in replacement. Boot constructs the service, runs an initially-empty central registration step so there is a single place to wire handlers, then starts delivery. --- ghost/core/core/boot.js | 15 + .../adapters/jobs/InMemoryJobsBackend.ts | 136 ++++++++ .../services/adapter-manager/base-classes.ts | 4 +- .../server/services/jobs-service/index.ts | 35 ++ .../core/server/services/jobs-service/job.ts | 10 + .../services/jobs-service/jobs-service.ts | 120 +++++++ .../jobs-service/register-job-handlers.ts | 3 + ghost/core/core/shared/config/defaults.json | 6 + ghost/core/package.json | 7 + .../jobs/in-memory-jobs-backend.test.ts | 304 ++++++++++++++++++ .../services/jobs-service/index.test.js | 36 +++ .../jobs-service/jobs-service.test.ts | 200 ++++++++++++ pnpm-lock.yaml | 49 +++ pnpm-workspace.yaml | 4 + 14 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts create mode 100644 ghost/core/core/server/services/jobs-service/index.ts create mode 100644 ghost/core/core/server/services/jobs-service/job.ts create mode 100644 ghost/core/core/server/services/jobs-service/jobs-service.ts create mode 100644 ghost/core/core/server/services/jobs-service/register-job-handlers.ts create mode 100644 ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts create mode 100644 ghost/core/test/unit/server/services/jobs-service/index.test.js create mode 100644 ghost/core/test/unit/server/services/jobs-service/jobs-service.test.ts diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index b741f0392d5..20aab9b4f1c 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -160,6 +160,14 @@ async function initCore({ghostServer, config}) { await mentionsJobService.shutdown(); }); debug('End: Mentions Job Service'); + + debug('Begin: Jobs Service'); + const jobsService = require('./server/services/jobs-service'); + + ghostServer.registerCleanupTask(async () => { + await jobsService.shutdown({timeoutMs: config.get('server:shutdownTimeout')}); + }); + debug('End: Jobs Service'); } debug('End: initCore'); @@ -610,6 +618,13 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) } await initServices({ghostServer, config, prometheusClient}); + + debug('Begin: Register job handlers'); + const jobsService = require('./server/services/jobs-service'); + const service = jobsService.init(); + require('./server/services/jobs-service/register-job-handlers').default(); + await service.start(); + debug('End: Register job handlers'); debug('End: Load Ghost Services & Apps'); // Step 5 - Mount the full Ghost app onto the minimal root app & disable maintenance mode diff --git a/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts b/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts new file mode 100644 index 00000000000..59221071226 --- /dev/null +++ b/ghost/core/core/server/adapters/jobs/InMemoryJobsBackend.ts @@ -0,0 +1,136 @@ +import errors from '@tryghost/errors'; +import fastq from 'fastq'; +import type {queueAsPromised} from 'fastq'; +import {JobsBackendBase} from '@tryghost/adapter-base-jobs'; +import type { + JobProcessor, + JobEnvelope, + JobsStartOptions, + RecurringSchedule, + JobsShutdownOptions +} from '@tryghost/adapter-base-jobs'; + +const later = require('@breejs/later'); +const logging = require('@tryghost/logging'); + +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10000; +const DEFAULT_CONCURRENCY = 3; + +function hasSeconds(cron: string): boolean { + return cron.trim().split(/\s+/).length >= 6; +} + +function resolveConcurrency(value: unknown): number { + if (value === undefined || value === null) { + return DEFAULT_CONCURRENCY; + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + throw new errors.IncorrectUsageError({ + message: `Invalid jobs backend concurrency: ${JSON.stringify(value)}. Expected a positive integer.` + }); + } + return value; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +interface RecurringTimer { + clear(): void; +} + +export default class InMemoryJobsBackend extends JobsBackendBase { + private _processor: JobProcessor | null; + private _stopped: boolean; + private _concurrency: number; + private _queue: queueAsPromised; + private _recurring: Map; + + constructor(config: {concurrency?: unknown} = {}) { + super(); + this._processor = null; + this._stopped = false; + this._concurrency = resolveConcurrency(config.concurrency); + this._queue = this._createQueue(); + this._recurring = new Map(); + } + + 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; + } + + start({processor}: JobsStartOptions): void { + this._processor = processor; + this._stopped = false; + this._queue.resume(); + } + + enqueue(envelope: JobEnvelope): void { + if (this._stopped) { + return; + } + 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); + } catch (err) { + logging.error(`Job "${envelope.type}" delivery failed`, err); + } + } + + scheduleRecurring(envelope: JobEnvelope, {cron}: RecurringSchedule): void { + // 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)) { + return; + } + + const parsed = later.parse.cron(cron, hasSeconds(cron)); + const timer = later.setInterval(() => { + this.enqueue(envelope); + }, parsed); + this._recurring.set(envelope.type, timer); + } + + private _clearRecurring(type: string): void { + const timer = this._recurring.get(type); + if (timer) { + timer.clear(); + this._recurring.delete(type); + } + } + + async shutdown(options: JobsShutdownOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; + this._stopped = true; + + for (const type of [...this._recurring.keys()]) { + this._clearRecurring(type); + } + + const queue = this._queue; + queue.kill(); + if (!queue.idle()) { + 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. + this._queue = this._createQueue(); + this._processor = null; + } +} diff --git a/ghost/core/core/server/services/adapter-manager/base-classes.ts b/ghost/core/core/server/services/adapter-manager/base-classes.ts index bc649bcc351..77fb80ab6cd 100644 --- a/ghost/core/core/server/services/adapter-manager/base-classes.ts +++ b/ghost/core/core/server/services/adapter-manager/base-classes.ts @@ -4,6 +4,7 @@ import {SSOBase} from '@tryghost/adapter-base-sso'; import {CacheBase} from '@tryghost/adapter-base-cache'; import {RedirectsStoreBase} from '@tryghost/adapter-base-redirects'; import {RouteSettingsStoreBase} from '@tryghost/adapter-base-route-settings'; +import {JobsBackendBase} from '@tryghost/adapter-base-jobs'; import type {BaseClassMap} from './adapter-manager'; @@ -18,5 +19,6 @@ export const baseClasses = { sso: SSOBase, cache: CacheBase, redirects: RedirectsStoreBase, - 'route-settings': RouteSettingsStoreBase + 'route-settings': RouteSettingsStoreBase, + jobs: JobsBackendBase } satisfies BaseClassMap; diff --git a/ghost/core/core/server/services/jobs-service/index.ts b/ghost/core/core/server/services/jobs-service/index.ts new file mode 100644 index 00000000000..476c49dce2c --- /dev/null +++ b/ghost/core/core/server/services/jobs-service/index.ts @@ -0,0 +1,35 @@ +import errors from '@tryghost/errors'; +import {JobsService} from './jobs-service'; +import type {JobsShutdownOptions} from '@tryghost/adapter-base-jobs'; + +let instance: JobsService | undefined; + +export function init(): JobsService { + const adapterManager = require('../adapter-manager').default; + const logging = require('@tryghost/logging'); + const sentry = require('../../../shared/sentry'); + + instance = new JobsService({ + backend: adapterManager.getAdapter('jobs'), + logging, + sentry + }); + + return instance; +} + +export function getInstance(): JobsService { + if (!instance) { + throw new errors.IncorrectUsageError({ + message: 'Jobs service used before init(). Call init() from boot first.' + }); + } + return instance; +} + +export function shutdown(options?: JobsShutdownOptions): Promise { + if (!instance) { + return Promise.resolve(); + } + return instance.shutdown(options); +} diff --git a/ghost/core/core/server/services/jobs-service/job.ts b/ghost/core/core/server/services/jobs-service/job.ts new file mode 100644 index 00000000000..48356da9976 --- /dev/null +++ b/ghost/core/core/server/services/jobs-service/job.ts @@ -0,0 +1,10 @@ +export abstract class Job { +} + +export interface JobConstructor { + readonly type: string; + readonly name?: string; + new (data: D): T; +} + +export type JobHandler = (job: T) => Promise | void; diff --git a/ghost/core/core/server/services/jobs-service/jobs-service.ts b/ghost/core/core/server/services/jobs-service/jobs-service.ts new file mode 100644 index 00000000000..ab62943a066 --- /dev/null +++ b/ghost/core/core/server/services/jobs-service/jobs-service.ts @@ -0,0 +1,120 @@ +import errors from '@tryghost/errors'; +import cronValidate from 'cron-validate'; +import type { + JobsBackendBase, + JobEnvelope, + JobsShutdownOptions, + RecurringSchedule +} from '@tryghost/adapter-base-jobs'; +import {Job, JobConstructor, JobHandler} from './job'; + +export interface JobsLogger { + error(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; +} + +export interface JobsErrorReporter { + captureException(err: unknown, captureContext?: {tags?: Record}): void; +} + +export interface JobsServiceOptions { + backend: JobsBackendBase; + logging: JobsLogger; + sentry?: JobsErrorReporter; +} + +type Deliverer = (payload: string) => Promise | void; + +export class JobsService { + readonly #backend: JobsBackendBase; + readonly #logging: JobsLogger; + readonly #sentry?: JobsErrorReporter; + readonly #registry = new Map(); + + constructor({backend, logging, sentry}: JobsServiceOptions) { + this.#backend = backend; + this.#logging = logging; + this.#sentry = sentry; + } + + handle(JobClass: JobConstructor, handler: JobHandler): void { + const type = JobClass.type; + if (typeof type !== 'string' || type.length === 0) { + throw new errors.IncorrectUsageError({ + message: `Cannot register a job handler: ${JobClass.name ?? 'job class'} is missing a static "type" string.` + }); + } + if (this.#registry.has(type)) { + throw new errors.IncorrectUsageError({ + message: `A handler for job type "${type}" is already registered.` + }); + } + this.#registry.set(type, payload => handler(new JobClass(JSON.parse(payload)))); + } + + async dispatch(job: Job): Promise { + await this.#backend.enqueue(this.#buildEnvelope(job)); + } + + async scheduleRecurring(job: Job, schedule: RecurringSchedule): Promise { + this.#assertValidCron(schedule.cron); + await this.#backend.scheduleRecurring(this.#buildEnvelope(job), schedule); + } + + // later.parse.cron does not strictly validate: it silently coerces a + // malformed expression into a bogus schedule (garbage -> every minute, + // out-of-range -> clamped, an impossible date -> effectively never), so + // validate up front and fail loudly instead. + #assertValidCron(cron: string): void { + const result = cronValidate(cron, { + preset: 'default', // the seconds field is not supported in the default preset + override: {useSeconds: true} + }); + if (!result.isValid()) { + throw new errors.IncorrectUsageError({ + message: `Invalid cron expression: ${JSON.stringify(cron)}.` + }); + } + } + + async start(): Promise { + await this.#backend.start({ + processor: envelope => this.#process(envelope) + }); + } + + async shutdown(options?: JobsShutdownOptions): Promise { + await this.#backend.shutdown(options); + } + + #buildEnvelope(job: Job): JobEnvelope { + return {type: this.#typeOf(job), payload: JSON.stringify(job)}; + } + + #typeOf(job: Job): string { + const ctor: {type?: unknown; name?: string} = job.constructor; + const type = ctor.type; + if (typeof type !== 'string' || type.length === 0) { + throw new errors.IncorrectUsageError({ + message: `Cannot dispatch job: ${ctor.name ?? 'job'} is missing a static "type" string.` + }); + } + return type; + } + + async #process(envelope: JobEnvelope): Promise { + const deliver = this.#registry.get(envelope.type); + if (!deliver) { + this.#logging.error(`No handler registered for job type "${envelope.type}"; dropping delivery.`); + return; + } + + try { + await deliver(envelope.payload); + } catch (err) { + this.#sentry?.captureException(err, {tags: {job_type: envelope.type}}); + throw err; + } + } +} 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 new file mode 100644 index 00000000000..caee4066c11 --- /dev/null +++ b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts @@ -0,0 +1,3 @@ +export default function registerJobHandlers(): void { + // Job handlers are registered here as jobs migrate onto the class-based service. +} diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index b9e22a739ec..f4a91e3d861 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -56,6 +56,12 @@ "active": "FileStore", "FileStore": {}, "S3RouteSettingsStore": {} + }, + "jobs": { + "active": "InMemoryJobsBackend", + "InMemoryJobsBackend": { + "concurrency": 3 + } } }, "storage": { diff --git a/ghost/core/package.json b/ghost/core/package.json index 52cc6b58f5c..2b057be5420 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -81,12 +81,14 @@ }, "dependencies": { "@aws-sdk/client-s3": "3.1079.0", + "@breejs/later": "catalog:", "@extractus/oembed-extractor": "3.2.1", "@faker-js/faker": "catalog:", "@isaacs/ttlcache": "1.4.1", "@sentry/node": "7.120.4", "@slack/webhook": "7.1.0", "@tryghost/adapter-base-cache": "workspace:*", + "@tryghost/adapter-base-jobs": "workspace:*", "@tryghost/adapter-base-redirects": "workspace:*", "@tryghost/adapter-base-route-settings": "workspace:*", "@tryghost/adapter-base-scheduling": "workspace:*", @@ -158,6 +160,7 @@ "cookies": "0.9.1", "cors": "2.8.6", "countries-and-timezones": "3.9.0", + "cron-validate": "catalog:", "csso": "5.0.5", "dequal": "catalog:", "dompurify": "catalog:", @@ -171,6 +174,7 @@ "express-query-boolean": "2.0.0", "express-queue": "0.0.13", "express-session": "1.19.0", + "fastq": "catalog:", "file-type": "21.3.4", "form-data": "catalog:", "fs-extra": "catalog:", @@ -240,6 +244,9 @@ "xml": "1.0.1", "zod": "catalog:" }, + "overrides": { + "cron-validate": "1.4.5" + }, "optionalDependencies": { "better-sqlite3": "catalog:" }, 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 new file mode 100644 index 00000000000..2176ce0b3b4 --- /dev/null +++ b/ghost/core/test/unit/server/adapters/jobs/in-memory-jobs-backend.test.ts @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import type {JobEnvelope} from '@tryghost/adapter-base-jobs'; +import {runJobsBackendContractTests} from '@tryghost/adapter-base-jobs/contract-test-suite'; +import InMemoryJobsBackend from '../../../../../core/server/adapters/jobs/InMemoryJobsBackend'; + +const sinon = require('sinon'); +const logging = require('@tryghost/logging'); + +runJobsBackendContractTests(() => new InMemoryJobsBackend(), {describe, it}); + +describe('InMemoryJobsBackend', function () { + it('has the required contract methods', function () { + const backend = new InMemoryJobsBackend(); + assert.deepEqual(backend.requiredFns, ['start', 'enqueue', 'scheduleRecurring', 'shutdown']); + }); + + it('buffers envelopes enqueued before start, then delivers on start', async function () { + const received: JobEnvelope[] = []; + const backend = new InMemoryJobsBackend(); + + 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}'}]); + }); + + it('caps concurrent deliveries at the default concurrency', async function () { + let running = 0; + let maxConcurrent = 0; + const release: Array<() => void> = []; + const backend = new InMemoryJobsBackend(); + + backend.start({processor: async () => { + running = running + 1; + maxConcurrent = Math.max(maxConcurrent, running); + await new Promise((resolve) => { + release.push(resolve); + }); + running = running - 1; + }}); + + for (let i = 0; i < 8; i = i + 1) { + backend.enqueue({type: 'work', payload: `{"i":${i}}`}); + } + + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + assert.equal(maxConcurrent, 3, 'at most 3 deliveries run concurrently'); + + release.forEach(resolve => resolve()); + const drainRelease = setInterval(() => release.forEach(resolve => resolve()), 5); + await backend.shutdown({timeoutMs: 2000}); + clearInterval(drainRelease); + + assert.ok(maxConcurrent <= 3); + }); + + it('caps concurrent deliveries at the configured concurrency', async function () { + let running = 0; + let maxConcurrent = 0; + const release: Array<() => void> = []; + const backend = new InMemoryJobsBackend({concurrency: 2}); + + backend.start({processor: async () => { + running = running + 1; + maxConcurrent = Math.max(maxConcurrent, running); + await new Promise((resolve) => { + release.push(resolve); + }); + running = running - 1; + }}); + + for (let i = 0; i < 8; i = i + 1) { + backend.enqueue({type: 'work', payload: `{"i":${i}}`}); + } + + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + assert.equal(maxConcurrent, 2, 'at most the configured 2 deliveries run concurrently'); + + release.forEach(resolve => resolve()); + const drainRelease = setInterval(() => release.forEach(resolve => resolve()), 5); + await backend.shutdown({timeoutMs: 2000}); + clearInterval(drainRelease); + }); + + it('rejects an invalid concurrency config instead of silently stalling the pump', function () { + assert.throws(() => new InMemoryJobsBackend({concurrency: 0}), /concurrency/i); + assert.throws(() => new InMemoryJobsBackend({concurrency: -1}), /concurrency/i); + assert.throws(() => new InMemoryJobsBackend({concurrency: 1.5}), /concurrency/i); + assert.throws(() => new InMemoryJobsBackend({concurrency: NaN}), /concurrency/i); + assert.throws(() => new InMemoryJobsBackend({concurrency: '3'}), /concurrency/i); + }); + + it('falls back to the default when no concurrency is configured', function () { + assert.doesNotThrow(() => new InMemoryJobsBackend()); + assert.doesNotThrow(() => new InMemoryJobsBackend({})); + }); + + it('logs a rejected delivery with its job type and keeps delivering subsequent work', async function () { + const errorStub = sinon.stub(logging, 'error'); + try { + const delivered: string[] = []; + const backend = new InMemoryJobsBackend({concurrency: 1}); + + backend.start({processor: async (env) => { + delivered.push(env.type); + if (env.type === 'boom') { + throw new Error('delivery failed'); + } + }}); + + backend.enqueue({type: 'boom', payload: '{}'}); + backend.enqueue({type: 'next', payload: '{}'}); + + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + await backend.shutdown({timeoutMs: 1000}); + + assert.deepEqual(delivered, ['boom', 'next'], 'a rejected delivery does not stall the queue'); + assert.ok(errorStub.calledOnce, 'the rejected delivery is logged'); + assert.match(String(errorStub.firstCall.args[0]), /boom/); + } finally { + errorStub.restore(); + } + }); + + it('drops queued-but-unstarted deliveries on shutdown', async function () { + const received: string[] = []; + const backend = new InMemoryJobsBackend(); + const gate: Array<() => void> = []; + + backend.start({processor: async (env) => { + received.push(env.type); + await new Promise((resolve) => { + gate.push(resolve); + }); + }}); + + for (let i = 0; i < 6; i = i + 1) { + backend.enqueue({type: `job-${i}`, payload: '{}'}); + } + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + gate.forEach(resolve => resolve()); + const releaser = setInterval(() => gate.forEach(resolve => resolve()), 5); + await backend.shutdown({timeoutMs: 2000}); + clearInterval(releaser); + + assert.equal(received.length, 3, `only in-flight deliveries ran, got ${received.join(',')}`); + }); + + it('drops work enqueued after shutdown (no replay on the next start)', async function () { + const received: string[] = []; + const backend = new InMemoryJobsBackend(); + + backend.start({processor: async (env) => { + received.push(env.type); + }}); + await backend.shutdown({timeoutMs: 1000}); + + backend.enqueue({type: 'late', payload: '{}'}); + + backend.start({processor: async (env) => { + received.push(env.type); + }}); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + await backend.shutdown({timeoutMs: 1000}); + + assert.deepEqual(received, [], 'the post-shutdown enqueue was neither delivered nor replayed'); + }); + + it('releases abandoned in-flight slots after a timed-out drain so a re-boot can start work', async function () { + const backend = new InMemoryJobsBackend(); + + backend.start({processor: () => new Promise(() => {})}); + for (let i = 0; i < 3; i = i + 1) { + backend.enqueue({type: `hung-${i}`, payload: '{}'}); + } + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + await backend.shutdown({timeoutMs: 30}); + + const received: string[] = []; + backend.start({processor: async (env) => { + received.push(env.type); + }}); + backend.enqueue({type: 'fresh', payload: '{}'}); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + await backend.shutdown({timeoutMs: 1000}); + + assert.deepEqual(received, ['fresh'], 'a re-boot starts new work despite prior hung handlers'); + }); + + describe('recurring schedules', function () { + let clock: {tickAsync(ms: number): Promise; restore(): void} | undefined; + + afterEach(function () { + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + it('fires the processor on each cron tick', async function () { + clock = sinon.useFakeTimers({now: Date.UTC(2020, 0, 1)}); + const received: JobEnvelope[] = []; + const backend = new InMemoryJobsBackend(); + backend.start({processor: async (env) => { + received.push(env); + }}); + + backend.scheduleRecurring({type: 'tick', payload: '{}'}, {cron: '*/1 * * * * *'}); + await clock!.tickAsync(3000); + + assert.ok(received.length >= 2, `expected at least 2 ticks, got ${received.length}`); + assert.equal(received[0].type, 'tick'); + }); + + it('keeps the first schedule for a type and ignores re-registration', async function () { + clock = sinon.useFakeTimers({now: Date.UTC(2020, 0, 1)}); + const received: string[] = []; + const backend = new InMemoryJobsBackend(); + backend.start({processor: async (env) => { + received.push(env.payload); + }}); + + backend.scheduleRecurring({type: 'tick', payload: '"first"'}, {cron: '*/1 * * * * *'}); + backend.scheduleRecurring({type: 'tick', payload: '"second"'}, {cron: '*/1 * * * * *'}); + + await clock!.tickAsync(2000); + + assert.ok(received.length >= 1); + assert.ok(received.every(p => p === '"first"'), `a re-registration must not replace the running schedule, got ${received.join(',')}`); + }); + + it('schedules a day-of-week cron on the correct day (0 = Sunday)', async function () { + clock = sinon.useFakeTimers({now: Date.UTC(2026, 7, 17)}); + const fireDays: number[] = []; + const backend = new InMemoryJobsBackend(); + backend.start({processor: async () => { + fireDays.push(new Date().getUTCDay()); + }}); + + backend.scheduleRecurring({type: 'weekly', payload: '{}'}, {cron: '0 0 * * 0'}); + await clock!.tickAsync(14 * 24 * 60 * 60 * 1000); + await backend.shutdown({timeoutMs: 100}); + + assert.ok(fireDays.length >= 1, `expected at least one fire, got ${fireDays.length}`); + assert.ok(fireDays.every(day => day === 0), `weekly Sunday cron must only fire on Sundays, got ${fireDays.join(',')}`); + }); + + it('stops firing after shutdown', async function () { + clock = sinon.useFakeTimers({now: Date.UTC(2020, 0, 1)}); + const received: JobEnvelope[] = []; + const backend = new InMemoryJobsBackend(); + backend.start({processor: async (env) => { + received.push(env); + }}); + + backend.scheduleRecurring({type: 'tick', payload: '{}'}, {cron: '*/1 * * * * *'}); + await clock!.tickAsync(1500); + await backend.shutdown({timeoutMs: 100}); + const countAtShutdown = received.length; + + await clock!.tickAsync(3000); + assert.equal(received.length, countAtShutdown, 'no ticks should fire after shutdown'); + }); + + it('does not install a recurring schedule after shutdown (no reactivation on re-boot)', async function () { + clock = sinon.useFakeTimers({now: Date.UTC(2020, 0, 1)}); + const received: JobEnvelope[] = []; + const backend = new InMemoryJobsBackend(); + backend.start({processor: async (env) => { + received.push(env); + }}); + await backend.shutdown({timeoutMs: 100}); + + backend.scheduleRecurring({type: 'tick', payload: '{}'}, {cron: '*/1 * * * * *'}); + + backend.start({processor: async (env) => { + received.push(env); + }}); + await clock!.tickAsync(3000); + + assert.deepEqual(received, [], 'a schedule installed after shutdown must never fire, even after re-start'); + }); + }); +}); \ No newline at end of file diff --git a/ghost/core/test/unit/server/services/jobs-service/index.test.js b/ghost/core/test/unit/server/services/jobs-service/index.test.js new file mode 100644 index 00000000000..15c7b7afc36 --- /dev/null +++ b/ghost/core/test/unit/server/services/jobs-service/index.test.js @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); + +const jobsService = require('../../../../../core/server/services/jobs-service'); +const adapterManager = require('../../../../../core/server/services/adapter-manager').default; + +describe('jobs-service wrapper', function () { + afterEach(function () { + sinon.restore(); + }); + + // These two run before any init() in this file, so the module singleton is + // still undefined. + it('shutdown before init resolves without constructing a service', async function () { + await assert.doesNotReject(() => jobsService.shutdown()); + }); + + it('getInstance throws before init', function () { + assert.throws(() => jobsService.getInstance(), /used before init/); + }); + + it('init builds the service from the jobs adapter and getInstance returns it', function () { + const fakeBackend = { + requiredFns: ['start', 'enqueue', 'scheduleRecurring', 'shutdown'], + start() {}, + enqueue() {}, + scheduleRecurring() {}, + async shutdown() {} + }; + sinon.stub(adapterManager, 'getAdapter').withArgs('jobs').returns(fakeBackend); + + const service = jobsService.init(); + + assert.equal(jobsService.getInstance(), service, 'getInstance returns the instance built by init'); + }); +}); 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 new file mode 100644 index 00000000000..255ee77acbc --- /dev/null +++ b/ghost/core/test/unit/server/services/jobs-service/jobs-service.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import {describe, it, beforeEach} from 'vitest'; +import type { + JobsBackendBase, + JobEnvelope, + JobsStartOptions, + JobProcessor, + RecurringSchedule, + JobsShutdownOptions +} from '@tryghost/adapter-base-jobs'; +import {JobsService, JobsLogger, JobsErrorReporter} 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}[] = []; + shutdownCalls: (JobsShutdownOptions | undefined)[] = []; + + start(options: JobsStartOptions): void { + this.processor = options.processor; + } + + enqueue(envelope: JobEnvelope): void { + this.enqueued.push(envelope); + } + + scheduleRecurring(envelope: JobEnvelope, schedule: RecurringSchedule): void { + this.recurring.push({envelope, schedule}); + } + + shutdown(options?: JobsShutdownOptions): void { + this.shutdownCalls.push(options); + } + + async deliver(index = 0): Promise { + assert.ok(this.processor, 'processor must be wired via start()'); + await this.processor!(this.enqueued[index]!); + } +} + +function makeLogger() { + const calls = {error: [] as unknown[][], info: [] as unknown[][], warn: [] as unknown[][]}; + const logging: JobsLogger = { + error: (...args) => { + calls.error.push(args); + }, + info: (...args) => { + calls.info.push(args); + }, + warn: (...args) => { + calls.warn.push(args); + } + }; + return {logging, calls}; +} + +function makeSentry() { + const captured: {err: unknown; context?: unknown}[] = []; + const sentry: JobsErrorReporter = { + captureException: (err, context) => captured.push({err, context}) + }; + return {sentry, captured}; +} + +class GreetJob extends Job { + static type = 'greet'; + name: string; + constructor(data: {name: string}) { + super(); + this.name = data.name; + } +} + +describe('JobsService', function () { + let backend: FakeBackend; + let logger: ReturnType; + + beforeEach(function () { + backend = new FakeBackend(); + logger = makeLogger(); + }); + + function makeService(overrides: {sentry?: JobsErrorReporter} = {}) { + return new JobsService({ + backend, + logging: logger.logging, + sentry: overrides.sentry + }); + } + + describe('handle', function () { + it('throws when the job class has no static type', function () { + const service = makeService(); + class Untyped extends Job {} + assert.throws(() => service.handle(Untyped as never, async () => {}), /missing a static "type"/); + }); + + it('throws on a duplicate type registration', function () { + const service = makeService(); + service.handle(GreetJob, async () => {}); + assert.throws(() => service.handle(GreetJob, async () => {}), /already registered/); + }); + }); + + describe('dispatch', function () { + it('enqueues a {type, payload} envelope, not the live instance', async function () { + const service = makeService(); + await service.dispatch(new GreetJob({name: 'Ada'})); + + assert.equal(backend.enqueued.length, 1); + const envelope = backend.enqueued[0]!; + assert.equal(envelope.type, 'greet'); + assert.equal(typeof envelope.payload, 'string'); + assert.deepEqual(JSON.parse(envelope.payload), {name: 'Ada'}); + }); + + it('delivers a rehydrated instance to the handler, never the dispatched object', async function () { + const service = makeService(); + const dispatched = new GreetJob({name: 'Ada'}); + let received: unknown = null; + service.handle(GreetJob, async (job) => { + received = job; + }); + await service.start(); + + await service.dispatch(dispatched); + await backend.deliver(); + + assert.ok(received instanceof GreetJob, 'handler receives a real GreetJob instance'); + assert.notEqual(received, dispatched, 'handler must not receive the dispatched object'); + assert.equal(received.name, 'Ada'); + }); + + it('throws when dispatching a job with no static type', async function () { + const service = makeService(); + class Untyped extends Job {} + await assert.rejects(() => service.dispatch(new Untyped()), /missing a static "type"/); + }); + }); + + 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(); + const service = makeService({sentry}); + const boom = new Error('handler exploded'); + service.handle(GreetJob, async () => { + throw boom; + }); + await service.start(); + + await service.dispatch(new GreetJob({name: 'Ada'})); + await assert.rejects(() => backend.deliver(), /handler exploded/); + + assert.equal(captured.length, 1); + assert.equal(captured[0]!.err, boom); + assert.deepEqual(captured[0]!.context, {tags: {job_type: 'greet'}}); + assert.equal(logger.calls.error.length, 0, 'delivery failures are logged by the backend, not the service'); + }); + + it('logs and drops delivery for an unknown job type', async function () { + const service = makeService(); + await service.start(); + await service.dispatch(new GreetJob({name: 'Ada'})); + await backend.deliver(); + + assert.equal(logger.calls.error.length, 1); + assert.match(String(logger.calls.error[0]![0]), /No handler registered for job type "greet"/); + }); + }); + + describe('scheduleRecurring', function () { + it('hands the backend an envelope and schedule', async function () { + const service = makeService(); + await service.scheduleRecurring(new GreetJob({name: 'cron'}), {cron: '0 0 3 * * *'}); + + assert.equal(backend.recurring.length, 1); + assert.equal(backend.recurring[0]!.envelope.type, 'greet'); + assert.equal(backend.recurring[0]!.schedule.cron, '0 0 3 * * *'); + }); + + it('rejects an invalid cron before touching the backend', async function () { + const service = makeService(); + await assert.rejects( + () => service.scheduleRecurring(new GreetJob({name: 'cron'}), {cron: 'not-a-cron'}), + /Invalid cron expression/ + ); + assert.equal(backend.recurring.length, 0); + }); + }); + + describe('shutdown', function () { + it('delegates to the backend with the given options', async function () { + const service = makeService(); + await service.shutdown({timeoutMs: 42}); + assert.deepEqual(backend.shutdownCalls, [{timeoutMs: 42}]); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4a1b3ef5f0..bbaa23e7840 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@breejs/later': + specifier: 4.2.0 + version: 4.2.0 '@codemirror/autocomplete': specifier: 6.20.3 version: 6.20.3 @@ -318,6 +321,9 @@ catalogs: execa: specifier: 10.0.1 version: 10.0.1 + fastq: + specifier: ^1.20.1 + version: 1.20.1 form-data: specifier: 4.0.6 version: 4.0.6 @@ -496,6 +502,7 @@ catalogs: version: 3.4.19 overrides: + cron-validate: 1.4.5 knex-migrator>knex: 2.4.2 '@tryghost/errors': 3.3.8 '@tryghost/logging': 5.3.4 @@ -2190,6 +2197,9 @@ importers: '@aws-sdk/client-s3': specifier: 3.1079.0 version: 3.1079.0 + '@breejs/later': + specifier: 'catalog:' + version: 4.2.0 '@extractus/oembed-extractor': specifier: 3.2.1 version: 3.2.1(encoding@0.1.13) @@ -2208,6 +2218,9 @@ importers: '@tryghost/adapter-base-cache': specifier: workspace:* version: link:../../packages/adapters/cache-base + '@tryghost/adapter-base-jobs': + specifier: workspace:* + version: link:../../packages/adapters/jobs-base '@tryghost/adapter-base-redirects': specifier: workspace:* version: link:../../packages/adapters/redirects-base @@ -2421,6 +2434,9 @@ importers: countries-and-timezones: specifier: 3.9.0 version: 3.9.0 + cron-validate: + specifier: 1.4.5 + version: 1.4.5 csso: specifier: 5.0.5 version: 5.0.5 @@ -2460,6 +2476,9 @@ importers: express-session: specifier: 1.19.0 version: 1.19.0(supports-color@10.2.2) + fastq: + specifier: 'catalog:' + version: 1.20.1 file-type: specifier: 21.3.4 version: 21.3.4(supports-color@10.2.2) @@ -3728,6 +3747,36 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@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)) + packages/adapters/jobs-base: + devDependencies: + '@internal/cfg-eslint': + specifier: workspace:* + version: link:../../../configs/eslint + '@internal/cfg-typescript': + specifier: workspace:* + version: link:../../../configs/typescript + '@internal/cfg-vitest': + specifier: workspace:* + version: link:../../../configs/vitest + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.7.0)(supports-color@10.2.2) + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@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)) + packages/adapters/redirects-base: devDependencies: '@internal/cfg-eslint': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bdc3384939d..48946708aa9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -213,6 +213,9 @@ catalog: remark-validate-links: 13.1.0 markdownlint-cli2: 0.23.2 parse-email-address: 0.0.4 + '@breejs/later': 4.2.0 + fastq: ^1.20.1 + cron-validate: 1.4.5 catalogs: react17: @@ -230,6 +233,7 @@ overrides: # Ghost's migrations build queries with the 2.4.2 query builder and knex-migrator # executes them, so its knex must match Ghost's major or the 3.x runner throws on # the 2.x builder. Pin it back to 2.4.2 until ghost/core itself moves to knex 3.x. + cron-validate: 1.4.5 'knex-migrator>knex': 2.4.2 '@tryghost/errors': 'catalog:' '@tryghost/logging': 'catalog:' From 21ee9780467750a876b05e5b84694280b04bdb7d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 09:51:48 -0500 Subject: [PATCH 12/22] Used `const` for variables that are never reassigned (#30066) To quote [ESLint's docs][0]: > If a variable is never reassigned, using the `const` declaration is better. This enables the `prefer-const` ESLint rule and fixes all violations. [0]: https://eslint.org/docs/latest/rules/prefer-const --- .../adapters/scheduling/scheduling-default.ts | 13 ++++---- .../adapters/storage/LocalStorageBase.ts | 6 +--- .../email-analytics-service.ts | 2 +- ghost/core/eslint.config.mjs | 4 +-- .../server/adapters/storage/index.test.ts | 4 +-- .../storage/local-base-storage.test.ts | 10 +++---- .../server/adapters/storage/utils.test.ts | 30 +++++++------------ .../unit/server/models/set-is-roles.test.ts | 18 +++++------ .../service/recommendation-controller.test.ts | 2 +- 9 files changed, 35 insertions(+), 54 deletions(-) diff --git a/ghost/core/core/server/adapters/scheduling/scheduling-default.ts b/ghost/core/core/server/adapters/scheduling/scheduling-default.ts index 9c309e33696..ff3e33ca893 100644 --- a/ghost/core/core/server/adapters/scheduling/scheduling-default.ts +++ b/ghost/core/core/server/adapters/scheduling/scheduling-default.ts @@ -40,10 +40,10 @@ export default class SchedulingDefault extends SchedulingBase { request = request; _addJob(job: SchedulerJob) { - let timestamp = moment(job.time).valueOf(); + const timestamp = moment(job.time).valueOf(); let keys: string[] = []; - let sortedJobs: Record = {}; - let instantJob: Record = {}; + const sortedJobs: Record = {}; + const instantJob: Record = {}; let i = 0; // CASE: should have been already pinged or should be pinged soon @@ -95,15 +95,14 @@ export default class SchedulingDefault extends SchedulingBase { const self = this; // eslint-disable-line @typescript-eslint/no-this-alias keys.forEach(function (timestamp) { - let timeout: NodeJS.Timeout | undefined; - let diff = moment(Number(timestamp)).diff(moment()); + const diff = moment(Number(timestamp)).diff(moment()); // NOTE: awake a little before... - timeout = setTimeout(function () { + const timeout = setTimeout(function () { clearTimeout(timeout); (function retry() { - let immediate = setImmediate(function () { + const immediate = setImmediate(function () { clearImmediate(immediate); // CASE: It's not the time yet... diff --git a/ghost/core/core/server/adapters/storage/LocalStorageBase.ts b/ghost/core/core/server/adapters/storage/LocalStorageBase.ts index e21d71bf537..93711e53a6d 100644 --- a/ghost/core/core/server/adapters/storage/LocalStorageBase.ts +++ b/ghost/core/core/server/adapters/storage/LocalStorageBase.ts @@ -132,15 +132,11 @@ class LocalStorageBase extends StorageBase { * - returns a promise which ultimately returns the full url to the uploaded file */ async save(file: StorageFile, targetDir?: string): Promise { - let targetFilename; - targetDir = targetDir ? this._resolveAndValidateStoragePath(targetDir) : this.getTargetDir(this.storagePath); - const filename = await this.getUniqueFileName(file, targetDir); - - targetFilename = filename; + const targetFilename = await this.getUniqueFileName(file, targetDir); // Verify that we are saving directly under `targetDir` and not outside of it. const expectedPrefix = path.join(path.resolve(targetDir), '/'); diff --git a/ghost/core/core/server/services/email-analytics/email-analytics-service.ts b/ghost/core/core/server/services/email-analytics/email-analytics-service.ts index d396810628d..884cee64222 100644 --- a/ghost/core/core/server/services/email-analytics/email-analytics-service.ts +++ b/ghost/core/core/server/services/email-analytics/email-analytics-service.ts @@ -371,7 +371,7 @@ export class EmailAnalyticsService { const eventProcessor = this.#createEventProcessor(); // We keep the processing result here, so we also have a result in case of failures - let processingResult = new EventProcessingResult(); + const processingResult = new EventProcessingResult(); // Track cumulative event counts separately since processingResult gets reset during intermediate aggregations const cumulativeResult = new EventProcessingResult(); let error: unknown = null; diff --git a/ghost/core/eslint.config.mjs b/ghost/core/eslint.config.mjs index 941e7cfcbc9..9bbba44cfd5 100644 --- a/ghost/core/eslint.config.mjs +++ b/ghost/core/eslint.config.mjs @@ -44,9 +44,7 @@ const tsLegacyRelaxations = { // LEGACY: tseslint v8 default. `// @ts-ignore` comments still exist. '@typescript-eslint/ban-ts-comment': 'off', // LEGACY: previous posture from plugin:ghost/ts allowed inferrable types. - '@typescript-eslint/no-inferrable-types': 'off', - // LEGACY: `let` declarations across the codebase could be `const`. - 'prefer-const': 'off' + '@typescript-eslint/no-inferrable-types': 'off' }; const migrationLoopRules = ['error', diff --git a/ghost/core/test/unit/server/adapters/storage/index.test.ts b/ghost/core/test/unit/server/adapters/storage/index.test.ts index 8d751f7bd8b..372e4da28c9 100644 --- a/ghost/core/test/unit/server/adapters/storage/index.test.ts +++ b/ghost/core/test/unit/server/adapters/storage/index.test.ts @@ -61,12 +61,10 @@ describe('storage: index_spec', function () { '}' + 'module.exports = AnotherAdapter'; - let chosenStorage; - fs.writeFileSync(scope.adapter, jsFile); assert.equal(configUtils.config.get('storage:active'), 'custom-adapter'); - chosenStorage = adapterManager.getAdapter('storage:images'); + const chosenStorage = adapterManager.getAdapter('storage:images'); assert.equal((chosenStorage instanceof LocalStorageBase), false); assert.equal((chosenStorage instanceof StorageBase), true); }); diff --git a/ghost/core/test/unit/server/adapters/storage/local-base-storage.test.ts b/ghost/core/test/unit/server/adapters/storage/local-base-storage.test.ts index 3402ffa64ed..3b5481ee2b1 100644 --- a/ghost/core/test/unit/server/adapters/storage/local-base-storage.test.ts +++ b/ghost/core/test/unit/server/adapters/storage/local-base-storage.test.ts @@ -41,7 +41,7 @@ describe('Local Storage Base', function () { describe('urlToPath', function () { it('returns relative path from full url', function () { - let localStorageBase = new LocalStorageBase({ + const localStorageBase = new LocalStorageBase({ storagePath: '/media-storage/path/', staticFileURLPrefix: 'content/media', siteUrl: 'http://example.com/blog/' @@ -54,7 +54,7 @@ describe('Local Storage Base', function () { }); it('returns relative path from prefix url', function () { - let localStorageBase = new LocalStorageBase({ + const localStorageBase = new LocalStorageBase({ storagePath: '/media-storage/path/', staticFileURLPrefix: 'content/media', siteUrl: 'http://example.com/' @@ -67,7 +67,7 @@ describe('Local Storage Base', function () { }); it('throws if the url resolves outside the storage root', function () { - let localStorageBase = new LocalStorageBase({ + const localStorageBase = new LocalStorageBase({ storagePath: '/media-storage/path/', staticFileURLPrefix: 'content/media', siteUrl: 'http://example.com/blog/' @@ -79,7 +79,7 @@ describe('Local Storage Base', function () { }); it('throws if the prefix url resolves outside the storage root', function () { - let localStorageBase = new LocalStorageBase({ + const localStorageBase = new LocalStorageBase({ storagePath: '/media-storage/path/', staticFileURLPrefix: 'content/media', siteUrl: 'http://example.com/' @@ -91,7 +91,7 @@ describe('Local Storage Base', function () { }); it('throws if the url does not match current site', function () { - let localStorageBase = new LocalStorageBase({ + const localStorageBase = new LocalStorageBase({ storagePath: '/media-storage/path/', staticFileURLPrefix: 'content/media', siteUrl: 'http://example.com/blog/' diff --git a/ghost/core/test/unit/server/adapters/storage/utils.test.ts b/ghost/core/test/unit/server/adapters/storage/utils.test.ts index 87c20e7de6a..8ac915dfe65 100644 --- a/ghost/core/test/unit/server/adapters/storage/utils.test.ts +++ b/ghost/core/test/unit/server/adapters/storage/utils.test.ts @@ -31,70 +31,65 @@ describe('storage utils', function () { describe('fn: getLocalImagesStoragePath', function () { it('should return local file storage path for absolute URL', function () { const url = 'http://myblog.com/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns(''); - result = storageUtils.getLocalImagesStoragePath(url); + const result = storageUtils.getLocalImagesStoragePath(url); assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); it('should return local file storage path for absolute URL with subdirectory', function () { const url = 'http://myblog.com/blog/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns('/blog'); - result = storageUtils.getLocalImagesStoragePath(url); + const result = storageUtils.getLocalImagesStoragePath(url); assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); it('should return local file storage path for relative URL', function () { const filePath = '/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns(''); - result = storageUtils.getLocalImagesStoragePath(filePath); + const result = storageUtils.getLocalImagesStoragePath(filePath); assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); it('should return local file storage path for relative URL with subdirectory', function () { const filePath = '/blog/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns('/blog'); - result = storageUtils.getLocalImagesStoragePath(filePath); + const result = storageUtils.getLocalImagesStoragePath(filePath); assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); it('should not sanitize URL if not local file storage', function () { const url = 'http://example-blog.com/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns(''); - result = storageUtils.getLocalImagesStoragePath(url); + const result = storageUtils.getLocalImagesStoragePath(url); assertExists(result); assert.equal(result, 'http://example-blog.com/ghost-logo.png'); }); @@ -103,70 +98,65 @@ describe('storage utils', function () { describe('fn: isLocalImage', function () { it('should return true when absolute URL and local file', function () { const url = 'http://myblog.com/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns(''); - result = storageUtils.isLocalImage(url); + const result = storageUtils.isLocalImage(url); assertExists(result); assert.equal(result, true); }); it('should return true when absolute URL with subdirectory and local file', function () { const url = 'http://myblog.com/blog/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns('/blog'); - result = storageUtils.isLocalImage(url); + const result = storageUtils.isLocalImage(url); assertExists(result); assert.equal(result, true); }); it('should return true when relative URL and local file', function () { const url = '/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns(''); - result = storageUtils.isLocalImage(url); + const result = storageUtils.isLocalImage(url); assertExists(result); assert.equal(result, true); }); it('should return true when relative URL and local file (blog subdir)', function () { const url = '/blog/content/images/2017/07/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns('/blog'); - result = storageUtils.isLocalImage(url); + const result = storageUtils.isLocalImage(url); assertExists(result); assert.equal(result, true); }); it('should return false when no local file', function () { const url = 'http://somewebsite.com/ghost-logo.png'; - let result; urlForStub = sinon.stub(urlUtils, 'urlFor'); urlForStub.withArgs('home').returns('http://myblog.com/'); urlGetSubdirStub = sinon.stub(urlUtils, 'getSubdir'); urlGetSubdirStub.returns(''); - result = storageUtils.isLocalImage(url); + const result = storageUtils.isLocalImage(url); assertExists(result); assert.equal(result, false); }); diff --git a/ghost/core/test/unit/server/models/set-is-roles.test.ts b/ghost/core/test/unit/server/models/set-is-roles.test.ts index 34a3e37c1f3..00977655731 100644 --- a/ghost/core/test/unit/server/models/set-is-roles.test.ts +++ b/ghost/core/test/unit/server/models/set-is-roles.test.ts @@ -59,12 +59,12 @@ describe('setIsRoles function behavior', function () { }; it('returns an object', function () { - let result = setIsRoles(loadedPermissionsEditor); + const result = setIsRoles(loadedPermissionsEditor); assert(_.isPlainObject(result)); }); it('returns the correct object for Editor', function () { - let result = setIsRoles(loadedPermissionsEditor); + const result = setIsRoles(loadedPermissionsEditor); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); @@ -76,7 +76,7 @@ describe('setIsRoles function behavior', function () { }); it('returns the correct object for Administrator', function () { - let result = setIsRoles(loadedPermissionsAdmin); + const result = setIsRoles(loadedPermissionsAdmin); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, true); @@ -88,7 +88,7 @@ describe('setIsRoles function behavior', function () { }); it('returns the correct object for Author', function () { - let result = setIsRoles(loadedPermissionsAuthor); + const result = setIsRoles(loadedPermissionsAuthor); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); @@ -100,7 +100,7 @@ describe('setIsRoles function behavior', function () { }); it('returns the correct object for Super Editor', function () { - let result = setIsRoles(loadedPermissionsSuperEditor); + const result = setIsRoles(loadedPermissionsSuperEditor); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); @@ -112,7 +112,7 @@ describe('setIsRoles function behavior', function () { }); it('returns the correct object for multiple roles', function () { - let result = setIsRoles(loadedPermissionsWithMultipleRoles); + const result = setIsRoles(loadedPermissionsWithMultipleRoles); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); @@ -123,7 +123,7 @@ describe('setIsRoles function behavior', function () { assert.equal(result.isEitherEditor, true); }); it('returns the correct object for no roles', function () { - let result = setIsRoles(loadedPermissionsWithNoRoles); + const result = setIsRoles(loadedPermissionsWithNoRoles); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); @@ -134,7 +134,7 @@ describe('setIsRoles function behavior', function () { assert.equal(result.isEitherEditor, false); }); it('returns the correct object for no user', function () { - let result = setIsRoles(loadedPermissionsWithNoUser); + const result = setIsRoles(loadedPermissionsWithNoUser); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); @@ -145,7 +145,7 @@ describe('setIsRoles function behavior', function () { assert.equal(result.isEitherEditor, false); }); it('returns the correct object for permissions without role', function () { - let result = setIsRoles(loadedPermissionswithPermissions); + const result = setIsRoles(loadedPermissionswithPermissions); assert(_.isPlainObject(result)); assert.equal(result.isOwner, false); assert.equal(result.isAdmin, false); diff --git a/ghost/core/test/unit/server/services/recommendations/service/recommendation-controller.test.ts b/ghost/core/test/unit/server/services/recommendations/service/recommendation-controller.test.ts index 3965bbdbfe2..e80e032535b 100644 --- a/ghost/core/test/unit/server/services/recommendations/service/recommendation-controller.test.ts +++ b/ghost/core/test/unit/server/services/recommendations/service/recommendation-controller.test.ts @@ -590,7 +590,7 @@ describe('RecommendationController', function () { describe('include', function () { let listSpy: SinonSpy; - let rec = { + const rec = { id: '1', title: 'Test', description: null, From 612f10294917687d66bc6c85004beae0004cc157 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 09:51:48 -0500 Subject: [PATCH 13/22] Removed useless assertions from storage utils tests (#30068) no ref We had a bunch of code like this in a test: ```js assertExists(result); assert.equal(result, 123); ``` That `assertExists` can be removed. (This also fixes the fact that `assertExists` was imported strangely.) --- .../test/unit/server/adapters/storage/utils.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/ghost/core/test/unit/server/adapters/storage/utils.test.ts b/ghost/core/test/unit/server/adapters/storage/utils.test.ts index 8ac915dfe65..91d1bd64fdd 100644 --- a/ghost/core/test/unit/server/adapters/storage/utils.test.ts +++ b/ghost/core/test/unit/server/adapters/storage/utils.test.ts @@ -11,7 +11,6 @@ import type * as storageUtilsModule from '../../../../../core/server/adapters/st // come from the same `require` graph. const urlUtils: typeof urlUtilsInstance = require('../../../../../core/shared/url-utils').default; const configUtils = require('../../../../utils/config-utils'); -const {assertExists} = require('../../../../utils/assertions'); // Stuff we are testing const storageUtils: typeof storageUtilsModule = require('../../../../../core/server/adapters/storage/utils'); @@ -38,7 +37,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns(''); const result = storageUtils.getLocalImagesStoragePath(url); - assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); @@ -51,7 +49,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns('/blog'); const result = storageUtils.getLocalImagesStoragePath(url); - assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); @@ -64,7 +61,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns(''); const result = storageUtils.getLocalImagesStoragePath(filePath); - assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); @@ -77,7 +73,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns('/blog'); const result = storageUtils.getLocalImagesStoragePath(filePath); - assertExists(result); assert.equal(result, '/2017/07/ghost-logo.png'); }); @@ -90,7 +85,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns(''); const result = storageUtils.getLocalImagesStoragePath(url); - assertExists(result); assert.equal(result, 'http://example-blog.com/ghost-logo.png'); }); }); @@ -105,7 +99,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns(''); const result = storageUtils.isLocalImage(url); - assertExists(result); assert.equal(result, true); }); @@ -118,7 +111,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns('/blog'); const result = storageUtils.isLocalImage(url); - assertExists(result); assert.equal(result, true); }); @@ -131,7 +123,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns(''); const result = storageUtils.isLocalImage(url); - assertExists(result); assert.equal(result, true); }); @@ -144,7 +135,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns('/blog'); const result = storageUtils.isLocalImage(url); - assertExists(result); assert.equal(result, true); }); @@ -157,7 +147,6 @@ describe('storage utils', function () { urlGetSubdirStub.returns(''); const result = storageUtils.isLocalImage(url); - assertExists(result); assert.equal(result, false); }); }); From 1773748282248ee9af3cdca8670023e531ea67e6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 09:54:51 -0500 Subject: [PATCH 14/22] Added "total run count" stat to automation browse endpoint (#30037) closes https://linear.app/ghost/issue/NY-1529 ref ce0841435c44a24bd444277cc587e3a18adc052d This adds `stats.last_run_created_at` to each automation, which we'll display in the UI soon. This was built by Claude Opus 5 with the following prompt: > Commit `ce0841435c44a24bd444277cc587e3a18adc052d` adds a "last run created at" stat to the automation browse endpoint. > > I want a new key, `total_run_count`, which is a count of all the runs for that automation. > > * Update `AutomationBrowseResult`. > * Update `AutomationBrowseRow`. > * Update `loadAutomations`. > * Update `buildAutomationBrowseResult`. > * Update `ghost/core/test/e2e-api/admin/automations.test.js`. > * Update `ghost/core/test/unit/server/services/automations/automations-repository.test.ts`. > * Re-generate the snapshots with `UPDATE_SNAPSHOTS=1` and running the necessary tests. > > Use red/green TDD. > > This should be a fairly straightforward change. In addition to unit and E2E tests, I also verified that the data appeared by checking the Network tab of the Firefox devtools: ![devtools screenshot](https://github.com/user-attachments/assets/de9bb167-ac6d-487c-9196-37d7528ba6bd) Co-authored-by: Claude --- .../automations/automations-repository.ts | 1 + .../database-automations-repository.ts | 14 +++++++---- .../__snapshots__/automations.test.js.snap | 4 ++- .../test/e2e-api/admin/automations.test.js | 22 +++++++++++++++- .../automations-repository.test.ts | 25 +++++++++++++++++++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts index fca54f96902..2438bdb3699 100644 --- a/ghost/core/core/server/services/automations/automations-repository.ts +++ b/ghost/core/core/server/services/automations/automations-repository.ts @@ -68,6 +68,7 @@ export interface AutomationSummary { export interface AutomationBrowseResult extends AutomationSummary { stats: { last_run_created_at: Date | null; + total_run_count: number; }; } diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index 48f9b9e2340..bb1e921e156 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -55,6 +55,7 @@ interface AutomationRow { interface AutomationBrowseRow extends AutomationRow { last_run_created_at: DatabaseDate | null; + total_run_count: string | number | null; } interface ActionRow { @@ -1018,11 +1019,12 @@ async function loadAutomationBySlug(trx: Knex.Transaction, slug: string): Promis } async function loadAutomations(trx: Knex.Transaction): Promise { - const latestRunDates = trx('automation_runs') + const runStats = trx('automation_runs') .select('automation_id') .max({last_run_created_at: 'created_at'}) + .count({total_run_count: '*'}) .groupBy('automation_id') - .as('latest_run_dates'); + .as('run_stats'); return await trx('automations') .select( 'automations.id', @@ -1031,9 +1033,10 @@ async function loadAutomations(trx: Knex.Transaction): Promise ({ const matchAutomationSummary = () => ({ ...matchAutomationBase(), stats: { - last_run_created_at: null + last_run_created_at: null, + total_run_count: 0 } }); @@ -210,6 +211,25 @@ describe('Automations API', function () { assert.equal(automation.stats.last_run_created_at, latestRunCreatedAt.toISOString()); }); + it('returns zero total run counts for automations without runs', async function () { + const {body} = await agent.get('automations').expectStatus(200); + + assert.deepEqual(body.automations.map(automation => automation.stats.total_run_count), [0, 0]); + }); + + it('returns the total automation run count', async function () { + const {body: beforeBody} = await agent.get('automations').expectStatus(200); + const automationId = beforeBody.automations[0].id; + + await createAutomationRun(automationId, new Date('2026-01-01T00:00:00.000Z')); + await createAutomationRun(automationId, new Date('2026-01-02T00:00:00.000Z')); + + const {body} = await agent.get('automations').expectStatus(200); + const automation = body.automations.find(candidate => candidate.id === automationId); + + assert.equal(automation.stats.total_run_count, 2); + }); + it('upserts the default free and paid automations', async function () { const existingAutomations = await models.Base.knex('automations') .select('id') diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index 4726d0378fb..0c6ceb2078a 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -680,6 +680,31 @@ describe('automations repository', function () { assert.deepEqual(automation.stats.last_run_created_at, latestRunCreatedAt); }); + it('returns zero for "total run count" if the automation has no runs', async function () { + const result = await repo.browse(); + + assert(result.data.every(automation => automation.stats.total_run_count === 0)); + }); + + it('returns the number of runs for the automation', async function () { + const automationId = (await getAutomationBySlug('member-welcome-email-free')).id; + const otherAutomationId = (await getAutomationBySlug('member-welcome-email-paid')).id; + + await insertRun(automationId); + await insertRun(automationId); + await insertRun(automationId); + await insertRun(otherAutomationId); + + const browseResult = await repo.browse(); + const automation = browseResult.data.find(candidate => candidate.id === automationId); + const otherAutomation = browseResult.data.find(candidate => candidate.id === otherAutomationId); + assert(automation); + assert(otherAutomation); + + assert.equal(automation.stats.total_run_count, 3); + assert.equal(otherAutomation.stats.total_run_count, 1); + }); + it('creates missing default free and paid automations', async function () { const automationIds = await knex('automations') .whereIn('slug', ['member-welcome-email-free', 'member-welcome-email-paid']) From 829980815a6066614c17770e876fb44821ad0385 Mon Sep 17 00:00:00 2001 From: Sanja Ruzic Date: Tue, 18 Aug 2026 16:56:06 +0200 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=8C=90=20Updated=20Serbian=20transl?= =?UTF-8?q?ations=20(#29768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://github.com/TryGhost/Ghost/issues/23361 This PR completes the Serbian (`sr`) locale. I'm a native Serbian speaker, and I've filled in all the previously-empty strings across `comments.json`, `ghost.json`, and `portal.json` — including the Serbian plural forms (`{count} month_*` / `{count} year_*`, which need the `_few` paucal form). --- packages/i18n/locales/sr/comments.json | 24 ++--- packages/i18n/locales/sr/ghost.json | 60 +++++------ packages/i18n/locales/sr/portal.json | 138 ++++++++++++------------- 3 files changed, 111 insertions(+), 111 deletions(-) diff --git a/packages/i18n/locales/sr/comments.json b/packages/i18n/locales/sr/comments.json index 48aa1e45944..d2ddbcd6bce 100644 --- a/packages/i18n/locales/sr/comments.json +++ b/packages/i18n/locales/sr/comments.json @@ -12,7 +12,7 @@ "Already a member?": "Već ste član?", "Anonymous": "Anonimno", "Are you sure?": "Da li ste sigurni?", - "Back": "", + "Back": "Nazad", "Become a member of {publication} to start commenting.": "Postanite član {publication} da biste počeli sa komentarisanjem.", "Become a paid member of {publication} to start commenting.": "Postanite plaćeni član {publication} da biste počeli sa komentarisanjem.", "Best": "Najbolje", @@ -25,7 +25,7 @@ "Deleted member": "Obrisan član", "Deleting": "Brisanje", "Discussion": "Diskusija", - "Dislike": "", + "Dislike": "Ne sviđa mi se", "Edit": "Uredite", "Edit this comment": "Uredite ovaj komentar", "edited": "uređeno", @@ -39,7 +39,7 @@ "Jamie Larson": "Pera Perić", "Join the discussion": "Pridružite se diskusiji", "Just now": "Upravo sada", - "Like": "", + "Like": "Sviđa mi se", "Load more ({amount})": "Učitajte još ({amount})", "Local resident": "Lokalni stanovnik", "Member discussion": "Diskusija članova", @@ -50,11 +50,11 @@ "Once deleted, this comment can’t be recovered.": "Kada se obriše, ovaj komentar ne može biti vraćen.", "One hour ago": "Pre sat", "One min ago": "Pre minut", - "Pin comment": "", - "Pinned": "", - "Read more replies": "", - "Remove dislike": "", - "Remove like": "", + "Pin comment": "Zakačite komentar", + "Pinned": "Zakačeno", + "Read more replies": "Pročitajte još odgovora", + "Remove dislike": "Uklonite reakciju „Ne sviđa mi se“", + "Remove like": "Uklonite reakciju „Sviđa mi se“", "Reply": "Odgovorite", "Reply to": "Odgovorite korisniku", "Reply to comment": "Odgovorite na komentar", @@ -62,7 +62,7 @@ "Report comment": "Prijavite komentar", "Report this comment?": "Prijavite ovaj komentar?", "Save": "Sačuvajte", - "See full discussion": "", + "See full discussion": "Pogledajte celu diskusiju", "Sending": "Slanje", "Sent": "Poslato", "Show {amount} more replies": "Prikažite još {amount} odgovora", @@ -72,11 +72,11 @@ "Sign up now": "Registrujte se sada", "Sort by": "Sortirajte po", "Start the conversation": "Počnite razgovor", - "The linked comment is no longer available.": "", + "The linked comment is no longer available.": "Povezani komentar više nije dostupan.", "This comment has been hidden.": "Ovaj komentar je sakriven.", "This comment has been removed.": "Ovaj komentar je uklonjen.", - "Unpin": "", - "Unpin comment": "", + "Unpin": "Otkačite", + "Unpin comment": "Otkačite komentar", "Upgrade now": "Nadogradite sada", "View in admin": "Pogledajte u admin", "Yesterday": "Juče", diff --git a/packages/i18n/locales/sr/ghost.json b/packages/i18n/locales/sr/ghost.json index a73be9ce87d..9a3656ea22d 100644 --- a/packages/i18n/locales/sr/ghost.json +++ b/packages/i18n/locales/sr/ghost.json @@ -1,12 +1,12 @@ { - "{count} month_one": "", - "{count} month_few": "", - "{count} month_other": "", - "{count} year_one": "", - "{count} year_few": "", - "{count} year_other": "", + "{count} month_one": "{count} mesec", + "{count} month_few": "{count} meseca", + "{count} month_other": "{count} meseci", + "{count} year_one": "{count} godina", + "{count} year_few": "{count} godine", + "{count} year_other": "{count} godina", "{date}": "{date}", - "{tierName} membership": "", + "{tierName} membership": "{tierName} članstvo", "All the best!": "Sve najbolje!", "Become a paid member of {site} to get access to all premium content.": "Postanite plaćeni član {site}-a da biste dobili pristup premijum sadržaju.", "By {authors}": "Autori: {authors}", @@ -19,42 +19,42 @@ "Confirm your email address": "Potvrdite Vašu email adresu", "Confirm your email update for {siteTitle}!": "Potvrdite ažuriranje email adrese za {siteTitle}!", "Confirm your subscription to {siteTitle}": "Potvrdite Vašu pretplatu na {siteTitle}", - "Continue subscription": "", + "Continue subscription": "Nastavite pretplatu", "Device:": "Uređaj:", "Email": "Email", - "For security verification, enter the code below to sign in to {siteTitle}:": "Zbog verifikacije sigurnosti, unesite kod ispod da biste se prijavili na {siteTitle}:", + "For security verification, enter the code below to sign in to {siteTitle}:": "Radi bezbednosne provere, unesite kod ispod da biste se prijavili na {siteTitle}:", "For your security, the link will expire in 24 hours time.": "Link će postati nevalidan za 24 sata zbog Vaše sigurnosti.", "free": "besplatni", - "Gift subscription": "", - "Here's how to keep your {tierName} membership.": "", + "Gift subscription": "Poklon-pretplata", + "Here's how to keep your {tierName} membership.": "Evo kako da zadržite svoje {tierName} članstvo.", "Here's your code to login to {siteTitle}": "Ovo je Vaš kod za prijavu na {siteTitle}", "Hey there,": "Pozdrav,", "Hey there!": "Pozdrav!", - "Hi {firstName},": "", + "Hi {firstName},": "Zdravo {firstName},", "If you did not make this request, you can safely ignore this email.": "Ako niste zatražili ovo, slobodno ignorišite ovaj email.", "If you did not make this request, you can simply delete this message.": "Ako niste zatražili ovo, slobodno obrišite ovu poruku.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Ako niste pokušali nedavno da se prijavite, slobodno ignorišite ovaj email da biste zabranili pristup.", "Keep reading": "Nastavite sa čitanjem", "Less like this": "Manje nalik na ovo", "Manage subscription": "Upravljajte pretplatom", - "Manage your preferences": "", + "Manage your preferences": "Upravljajte svojim podešavanjima", "Member since": "Član od", "More like this": "Više nalik na ovo", "Name": "Ime", "New comment on {postTitle}": "Novi komentar na {postTitle}", "New reply to your comment on {siteTitle}": "Novi odgovor na Vaš komentar na {siteTitle}", - "one-month": "", - "one-year": "", - "Open this link to redeem your gift.": "", + "one-month": "mesec", + "one-year": "godina", + "Open this link to redeem your gift.": "Otvorite ovaj link da preuzmete svoj poklon.", "Or use this link to securely sign in": "Ili iskoristite ovaj link da se sigurno prijavite", "Or, skip the code and sign in directly": "Ili, preskočite kod i prijavite se direktno", "paid": "plaćeni", "Please confirm your email address with this link:": "Molimo Vas da potvrdite Vašu email adresu putem ovog linka:", - "Redeem your gift subscription": "", + "Redeem your gift subscription": "Preuzmite svoju poklon-pretplatu", "Secure sign in link for {siteTitle}": "Siguran link za prijavu na {siteTitle}", "See you soon!": "Vidimo se uskoro!", "Sent to {email}": "Poslato na {email}", - "Share": "", + "Share": "Podelite", "Sign in": "Prijavite se", "Sign in now": "Prijavite se sada", "Sign in to {siteTitle}": "Prijavite se na {siteTitle}", @@ -63,19 +63,19 @@ "Someone just replied to your comment": "Neko je upravo odgovorio na Vaš komentar", "Someone just replied to your comment on {postTitle}.": "Neko je upravo odgovorio na Vaš komentar na {postTitle}.", "Subscription details": "Detalji pretplate", - "Support {siteTitle}": "", + "Support {siteTitle}": "Podržite {siteTitle}", "Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:": "Kliknite na link ispod kako biste uspešno završili registraciju za {siteTitle} i automatski ćete biti prijavljeni:", "Thank you for signing up to {siteTitle}!": "Hvala na registraciji na {siteTitle}!", "Thank you for subscribing to {siteTitle}!": "Hvala za pretplatu na {siteTitle}!", "Thank you for subscribing to {siteTitle}.": "Hvala za pretplatu na {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Hvala za pretplatu na {siteTitle}. Kliknite na link ispod kako biste se automatski prijavili:", - "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", - "Thanks for reading {siteTitle}.": "", - "The link expires on {expiresAt} and can only be redeemed once.": "", + "Thank you for supporting {siteTitle}.": "Hvala na podršci za {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Hvala Vam na podršci. Podelite link ispod sa osobom kojoj želite da poklonite {cadenceLabel} {tierName} članstvo za {siteTitle}.", + "Thanks for reading {siteTitle}.": "Hvala na čitanju {siteTitle}.", + "The link expires on {expiresAt} and can only be redeemed once.": "Link ističe {expiresAt} i može se iskoristiti samo jednom.", "This email address will not be used.": "Ova email adresa neće biti korišćena.", "This message was sent from {siteDomain} to {email}.": "Ova poruka je poslata sa {siteDomain} na {email}.", - "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", + "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Da biste zadržali svoje {tierName} članstvo, nastavite sa plaćenom pretplatom već danas, a mi ćemo automatski dodati ostatak vašeg poklon perioda kao besplatan probni period.", "trialing": "probni", "Unsubscribe": "Poništite pretplatu", "Unsubscribe from comment reply notifications": "Poništite pretplatu na odgovore na komentar", @@ -97,13 +97,13 @@ "You will not be subscribed.": "Nećete biti pretplaćeni.", "You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "Jedan ste klik od pretplate na {siteTitle} - molimo Vas da potvrdite Vašu email adresu koristeći ovaj link:", "You're one tap away from subscribing to {siteTitle}!": "Jedan ste klik od pretplate na {siteTitle}!", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Dobili ste na poklon {duration}-mesečno {tierName} članstvo za {siteTitle}", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Dobili ste na poklon {duration}-godišnje {tierName} članstvo za {siteTitle}", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Vaš besplatan probni period ističe {date}, kada će Vam biti naplaćena redovna cena. Uvek možete otkazati pre tog roka.", - "Your gift is ready": "", - "Your gift subscription confirmation": "", - "Your gift subscription is ending soon": "", - "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift is ready": "Vaš poklon je spreman", + "Your gift subscription confirmation": "Potvrda Vaše poklon-pretplate", + "Your gift subscription is ending soon": "Vaša poklon-pretplata uskoro ističe", + "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Vaša poklon-pretplata na {siteTitle} ističe {consumesAt}.", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Vaša pretplata je otkazana i ističe {date}. Možete nastaviti pretplatu u podešavanjima Vašeg profila.", "Your subscription has expired.": "Vaša pretplata je istekla.", "Your subscription will expire on {date}.": "Vaša pretplata ističe {date}.", diff --git a/packages/i18n/locales/sr/portal.json b/packages/i18n/locales/sr/portal.json index 38cac5218ac..fa72ee3fb19 100644 --- a/packages/i18n/locales/sr/portal.json +++ b/packages/i18n/locales/sr/portal.json @@ -9,18 +9,18 @@ "{memberEmail} will no longer receive {newsletterName} newsletter.": "{memberEmail} više neće dobijati {newsletterName} bilten.", "{memberEmail} will no longer receive emails when someone replies to your comments.": "{memberEmail} više neće dobijati email-ove kada neko odgovori na Vaše komentare.", "{memberEmail} will no longer receive this newsletter.": "{memberEmail} više neće dobijati ovaj bilten.", - "{memberEmail} will no longer receive updates & announcements.": "", - "{months} months": "", - "{months} months free": "", - "{tierName} membership": "", + "{memberEmail} will no longer receive updates & announcements.": "{memberEmail} više neće primati novosti i obaveštenja.", + "{months} months": "{months} mes.", + "{months} months free": "{months} mes. besplatno", + "{tierName} membership": "{tierName} članstvo", "{trialDays} days free": "{trialDays} dana besplatno", - "{years} years": "", + "{years} years": "{years} god.", "+1 (123) 456-7890": "+381 60 123-456", - "1 month": "", - "1 month free": "", - "1 year": "", - "A gift, just for you": "", - "Access your RSS feeds": "", + "1 month": "1 mesec", + "1 month free": "1 mesec besplatno", + "1 year": "1 godina", + "A gift, just for you": "Poklon samo za Vas", + "Access your RSS feeds": "Pristupite svojim RSS fidovima", "Account": "Nalog", "Account details updated successfully": "Detalji naloga su uspešno ažurirani", "Account settings": "Podešavanja naloga", @@ -31,13 +31,13 @@ "An unexpected error occured. Please try again or contact support if the error persists.": "Došlo je do neočekivane greške. Pokušajte ponovo ili kontaktirajte podršku ako greška potraje.", "Back": "Nazad", "Back to Log in": "Nazad na prijavu", - "Before you go": "", - "Billing info & receipts": "", + "Before you go": "Pre nego što odete", + "Billing info & receipts": "Podatci o plaćanju i računi", "Black Friday": "Crni petak", - "Bluesky": "", + "Bluesky": "Bluesky", "Cancel anytime.": "Otkažite u bilo kom trenutku.", "Cancel subscription": "Otkažite pretplatu", - "Canceled": "", + "Canceled": "Otkazano", "Cancellation reason": "Razlog otkazivanja", "Change": "Promenite", "Change plan": "Promenite plan", @@ -49,7 +49,7 @@ "Choose a plan": "Izaberite plan", "Choose your newsletters": "Izaberite svoje biltene", "Click here to retry": "Kliknite ovde da pokušate ponovo", - "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "", + "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "Kliknite na link za potvrdu u svom sandučetu da biste završili preuzimanje članstva. Ako ne stigne u roku od 3 minuta, pogledajte fasciklu sa neželjenom poštom (spam).", "Close": "Zatvorite", "Code": "Kod", "Comment preferences updated.": "Preference komentara ažurirane.", @@ -61,10 +61,10 @@ "Contact support": "Kontaktirajte podršku", "Continue": "Nastavite", "Continue subscription": "Nastavite pretplatu", - "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "", - "Copied": "", - "Copy": "", - "Copy link": "", + "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "Nastavite sa plaćenom pretplatom u bilo kom trenutku. Ostatak Vašeg poklon perioda biće dodat kao besplatan probni period.", + "Copied": "Kopirano", + "Copy": "Kopirajte", + "Copy link": "Kopirajte link", "Could not create Stripe billing portal session": "Nije moguće kreirati sesiju Stripe portala naplate", "Could not create Stripe checkout session": "Nije moguće kreirati sesiju plaćanja pomoću Stripe-a", "Could not sign in. Login link expired.": "Nije moguće prijaviti se. Link za prijavu je istekao.", @@ -76,7 +76,7 @@ "Don't have an account?": "Nemate nalog?", "Edit": "Uredite", "Email": "Email", - "Email confirmation link expired.": "", + "Email confirmation link expired.": "Link za potvrdu email adrese je istekao.", "Email newsletter": "Email bilten", "Email newsletter settings updated": "Podešavanja email biltena ažurirana.", "Email preferences": "Preference za email", @@ -84,17 +84,17 @@ "Emails": "Email-ovi", "Emails disabled": "Email-ovi onemogućeni", "Ends {offerEndDate}": "Završava se {offerEndDate}", - "Enjoy {amountOff} off forever.": "", - "Enjoy {months} free months on us.": "", - "Enjoy {months} free months on us. You won't be charged until {newBillingDate}.": "", - "Enjoy a free month on us.": "", - "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "", + "Enjoy {amountOff} off forever.": "Uživajte u trajnom popustu od {amountOff}.", + "Enjoy {months} free months on us.": "Uživajte u {months} mes. besplatno na naš račun.", + "Enjoy {months} free months on us. You won't be charged until {newBillingDate}.": "Uživajte u {months} mes. besplatno na naš račun. Naplata počinje tek od {newBillingDate}.", + "Enjoy a free month on us.": "Uživajte u mesec dana besplatno na naš račun.", + "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "Uživajte u mesec dana besplatno na naš račun. Naplata počinje tek od {newBillingDate}.", "Enter code above": "Unesite kod iznad", "Enter your email address": "Unesite Vašu email adresu", "Enter your name": "Unesite Vaše ime", "Error": "Greška", "Expires {expiryDate}": "Ističe {expiryDate}", - "Facebook": "", + "Facebook": "Facebook", "Failed to cancel subscription, please try again": "Neuspešno otkazivanje pretplate, pokušajte ponovo", "Failed to log in, please try again": "Neuspešna prijava, pokušajte ponovo", "Failed to log out, please try again": "Neuspešno odjavljivanje, pokušajte ponovo", @@ -114,17 +114,17 @@ "Get help": "Zatražite pomoć", "Get in touch for help": "Kontaktirajte nas za pomoć", "Get notified when someone replies to your comment": "Primite obaveštenje kada neko odgovori na Vaš komentar", - "Gift a membership": "", - "Gift could not be redeemed": "", - "Gift details": "", - "Gift redeemed! You're all set.": "", - "Gift subscription": "", - "Gift subscriptions are not available right now.": "", - "Gift value": "", + "Gift a membership": "Poklonite članstvo", + "Gift could not be redeemed": "Nije moguće iskoristiti poklon", + "Gift details": "Detalji o poklonu", + "Gift redeemed! You're all set.": "Poklon je uspešno preuzet! Sve je spremno.", + "Gift subscription": "Poklon-pretplata", + "Gift subscriptions are not available right now.": "Poklon-pretplate trenutno nisu dostupne", + "Gift value": "Vrednost poklona", "Give feedback on this post": "Dajte povratnu informaciju o ovoj objavi", "Help! I'm not receiving emails": "Pomoć! Ne primam email-ove", "Here are a few other sites you may enjoy.": "Evo nekoliko drugih sajtova koji Vam mogu biti zanimljivi.", - "Hide details": "", + "Hide details": "Sakrijte detalje", "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Ako je bilten označen kao neželjena pošta, email-ovi su automatski onemogućeni za tu adresu kako biste prestali primati neželjene poruke.", "If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "Ako je žalba za neželjenu poštu bila slučajna ili želite ponovo da primate email-ove, možete se ponovo pretplatiti klikom na dugme na prethodnom ekranu.", "If you cancel your subscription now, you will continue to have access until {periodEnd}.": "Ako sada otkažete pretplatu imaćete pristup do {periodEnd}.", @@ -141,30 +141,30 @@ "Jamie Larson": "Pera Perić", "jamie@example.com": "pera@example.com", "Less like this": "Manje nalik na ovo", - "LinkedIn": "", + "LinkedIn": "LinkedIn", "Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "Uverite se da email-ovi slučajno ne završavaju u fasciklama za neželjenu poštu ili promocije u Vašem inbox-u. Ako jesu, kliknite na „Označi kao da nije neželjeno“ i/ili „Premesti u inbox“.", "Manage": "Upravljajte", "Maybe later": "Možda kasnije", - "Membership details": "", + "Membership details": "Detalji o članstvu", "Memberships from this email domain are currently restricted.": "Članstva sa ovog email domena su trenutno ograničena.", "Memberships unavailable, contact the owner for access.": "Članstva nisu dostupna, kontaktirajte vlasnika radi pristupa.", "month": "mesec", "Monthly": "Mesečno", "More like this": "Više nalik na ovo", - "More options": "", + "More options": "Dodatne opcije", "Name": "Ime", "Need more help? Contact support": "Treba Vam više pomoći? Kontaktirajte podršku", "Newsletters can be disabled on your account for two reasons: A previous email was marked as spam, or attempting to send an email resulted in a permanent failure (bounce).": "Bilteni mogu biti onemogućeni na Vašem nalogu iz dva razloga: prethodni email je označen kao neželjena pošta ili je pokušaj slanja email-a rezultirao trajnim neuspehom (odbijeno).", "No member exists with this e-mail address.": "Ne postoji član sa ovom email adresom.", - "No thanks, I want to cancel": "", - "Not ready to share? We've also emailed a copy to your inbox.": "", + "No thanks, I want to cancel": "Ne, hvala, želim da otkažem", + "Not ready to share? We've also emailed a copy to your inbox.": "Niste spremni za deljenje? Poslali smo kopiju i u Vaše sanduče.", "Not receiving emails?": "Ne primate email-ove?", "Now check your email!": "Sada proverite svoj email!", - "Occasional updates from {siteTitle}": "", + "Occasional updates from {siteTitle}": "Povremene novosti od {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Kada se ponovo pretplatite, ako i dalje ne vidite email-ove u svom inbox-u, proverite fasciklu za neželjenu poštu. Neki inbox provajderi čuvaju zapise o prethodnim žalbama na neželjenu poštu i nastaviće da označavaju email-ove. Ako se to desi, označite najnoviji bilten kao 'Nije neželjeno' da ga premestite nazad u svoj inbox.", "Open AOL Mail": "Otvorite AOL Mail", "Open email": "Otvorite email", - "Open Feedbin": "", + "Open Feedbin": "Otvorite Feedbin", "Open Gmail": "Otvorite Gmail", "Open Hey": "Otvorite Hey", "Open iCloud Mail": "Otvorite iCloud Mail", @@ -180,22 +180,22 @@ "Please contact {supportAddress} to adjust your complimentary subscription.": "Kontaktirajte {supportAddress} da podesite Vašu besplatnu pretplatu.", "Please enter {fieldName}": "Molimo Vas unesite {fieldName}", "Please fill in required fields": "Molimo Vas popunite obavezna polja", - "Podcasts": "", + "Podcasts": "Podkasti", "Re-enable emails": "Ponovo omogućite email-ove", "Recommendations": "Preporuke", - "Redeem your membership": "", - "Redeeming...": "", + "Redeem your membership": "Preuzmite svoje članstvo", + "Redeeming...": "Preuzimanje...", "Renews at {price}.": "Obnavlja se po ceni od {price}.", - "Resume subscription": "", + "Resume subscription": "Ponovo aktivirajte pretplatu", "Retry": "Pokušajte ponovo", "Save": "Sačuvajte", - "Save {amountOff} on your next {durationInMonths} billing cycles. Then {currency}{originalPrice}/{cadence}.": "", - "Save {amountOff} on your next billing cycle. Then {currency}{originalPrice}/{cadence}.": "", - "Send the link below to share it with whoever you'd like.": "", + "Save {amountOff} on your next {durationInMonths} billing cycles. Then {currency}{originalPrice}/{cadence}.": "Uštedite {amountOff} tokom narednih {durationInMonths} obračunskih ciklusa. Nakon toga cena je {currency}{originalPrice}/{cadence}.", + "Save {amountOff} on your next billing cycle. Then {currency}{originalPrice}/{cadence}.": "Uštedite {amountOff} tokom narednog obračunskog ciklusa. Nakon toga cena je {currency}{originalPrice}/{cadence}.", + "Send the link below to share it with whoever you'd like.": "Pošaljite link ispod da biste ga podelili sa kim god želite.", "Sending login link...": "Slanje linka za prijavu...", "Sending...": "Slanje...", - "Share": "", - "Share a full membership to {siteTitle} with a friend or colleague": "", + "Share": "Podelite", + "Share a full membership to {siteTitle} with a friend or colleague": "Podelite puno članstvo za {siteTitle} sa prijateljem ili kolegom", "Show all": "Prikažite sve", "Sign in": "Prijavite se", "Sign out": "Odjavite se", @@ -203,7 +203,7 @@ "Signup error: Invalid link": "Greška pri registraciji: Nevalidan link", "Signups from this email domain are currently restricted.": "Registracije sa ovog email domena su trenutno ograničene.", "Something went wrong, please try again later.": "Nešto je pošlo naopako, molimo Vas pokušajte kasnije.", - "Sorry, no paid plans are available.": "", + "Sorry, no paid plans are available.": "Nažalost, plaćeni paketi trenutno nisu dostupni.", "Sorry, no recommendations are available right now.": "Izvinite, ali preporuke trenutno nisu dostupne.", "Sorry, that didn’t work.": "Izvinite, to nije uspelo.", "Spam complaints": "Žalbe na neželjenu poštu", @@ -230,16 +230,16 @@ "There was an error continuing your subscription, please try again.": "Došlo je do greške pri nastavku Vaše pretplate, pokušajte ponovo.", "There was an error processing your payment. Please try again.": "Došlo je do greške pri obradi Vaše uplate. Molimo Vas pokušajte ponovo.", "There was an error sending the email, please try again": "Došlo je do greške pri slanju email-a, pokušajte ponovo.", - "This gift has already been consumed.": "", - "This gift has already been redeemed.": "", - "This gift has been refunded.": "", - "This gift has expired.": "", + "This gift has already been consumed.": "Ovaj poklon je već iskorišćen.", + "This gift has already been redeemed.": "Ovaj poklon je već preuzet.", + "This gift has been refunded.": "Za ovaj poklon je izvršen povrat novca.", + "This gift has expired.": "Ovaj poklon je istekao.", "This site is invite-only, contact the owner for access.": "Ovaj sajt je samo na poziv, kontaktirajte vlasnika radi pristupa.", "This site is not accepting donations at the moment.": "Ovaj sajt trenutno ne prima donacije.", "This site is not accepting payments at the moment.": "Ovaj sajt trenutno ne prihvata uplate.", "This site only accepts paid members.": "Ovaj sajt prihvata samo plaćene članove.", - "Threads": "", - "Tier": "", + "Threads": "Threads", + "Tier": "Paket", "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "Da biste završili registraciju, kliknite na link za potvrdu u inbox-u. Ako ne stigne u roku od 3 minuta, proverite fasciklu za neželjenu poštu!", "To continue to stay up to date, subscribe to {publication} below.": "Da biste ostali u toku, pretplatite se na {publication} u nastavku.", "Too many attempts try again in {number} days.": "Previše pokušaja, pokušajte ponovo za {number} dana.", @@ -258,41 +258,41 @@ "Unsubscribing from emails will not cancel your paid subscription to {title}": "Odjavljivanje sa email-ova neće otkazati Vašu plaćenu pretplatu na {title}", "Update": "Ažurirajte", "Update your preferences": "Ažurirajte svoje preference", - "Updates & announcements": "", + "Updates & announcements": "Novosti i obaveštenja", "Verification link sent, check your inbox": "Link za verifikaciju je poslat, proverite Vaš inbox", "Verify your email address is correct": "Proverite da li je Vaša email adresa ispravna", "Verifying...": "Verifikacija...", "View plans": "Pogledajte planove", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nismo mogli da Vas odjavimo jer email adresa nije pronađena. Molimo Vas da kontaktirate vlasnika sajta.", - "We'd hate to see you leave. How about a special offer to stay?": "", + "We'd hate to see you leave. How about a special offer to stay?": "Žao nam je što odlazite. Šta kažete na specijalnu ponudu da ostanete?", "Welcome back, {name}!": "Dobro došli nazad, {name}!", "Welcome back!": "Dobro došli nazad!", "Welcome to {siteTitle}": "Dobro došli na {siteTitle}", "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "Kada inbox ne prihvata email, to se obično naziva odbijanje. U mnogim slučajevima to može biti privremeno. Međutim, u nekim slučajevima, odbijeni email može biti vraćen kao trajni neuspeh ako je email adresa nevalidna ili nepostojeća.", "Why has my email been disabled?": "Zašto je moj email onemogućen?", - "X (Twitter)": "", + "X (Twitter)": "X (Twitter)", "year": "godina", "Yearly": "Godišnje", - "You already have an active subscription.": "", + "You already have an active subscription.": "Već imate aktivnu pretplatu.", "You currently have a free membership, upgrade to a paid subscription for full access.": "Trenutno imate besplatno članstvo, nadogradite na plaćenu pretplatu za puni pristup.", "You have been successfully resubscribed": "Uspešno ste ponovo pretplaćeni", - "You now have access to {tierName} until {expiryDate}. Enjoy!": "", + "You now have access to {tierName} until {expiryDate}. Enjoy!": "Sada imate pristup {tierName} paketu do {expiryDate}. Uživajte!", "You're currently not receiving emails": "Trenutno ne primate email-ove", "You're not receiving emails": "Ne primate email-ove", "You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "Ne primate email-ove jer ste ili označili nedavno primljenu poruku kao neželjenu, ili poruke nisu mogle biti isporučene na Vašu email adresu.", - "You've been gifted a membership": "", - "You've been gifted a membership to {siteTitle}": "", + "You've been gifted a membership": "Dobili ste članstvo na poklon", + "You've been gifted a membership to {siteTitle}": "Dobili ste na poklon članstvo za {siteTitle}", "You've successfully signed in.": "Uspešno ste se prijavili.", "You've successfully subscribed to {siteTitle}": "Uspešno ste se pretplatili na {siteTitle}", "Your account": "Vaš nalog", - "Your email": "", + "Your email": "Vaša email adresa", "Your email has failed to resubscribe, please try again": "Ponovna pretplata na Vaš email nije uspela, pokušajte ponovo", - "Your gift is ready": "", - "Your gift subscription will expire on {expiryDate}": "", + "Your gift is ready": "Vaš poklon je spreman", + "Your gift subscription will expire on {expiryDate}": "Vaša poklon-pretplata ističe {expiryDate}", "your inbox": "Vaš inbox", "Your input helps shape what gets published.": "Vaše učešće pomaže u oblikovanju onoga što će biti objavljeno.", - "Your name": "", - "Your subscription has been canceled and will expire on {expiryDate}.": "", + "Your name": "Vaše ime", + "Your subscription has been canceled and will expire on {expiryDate}.": "Vaša pretplata je otkazana i isteći će {expiryDate}.", "Your subscription will expire on {expiryDate}": "Vaša pretplata ističe {expiryDate}", "Your subscription will renew on {renewalDate}": "Vaša pretplata će biti obnovljena {renewalDate}", "Your subscription will start on {subscriptionStart}": "Vaša pretplata će početi {subscriptionStart}" From ad4d6d85062c3ffa9f8c1bfa2d6817e7639426fd Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 09:57:50 -0500 Subject: [PATCH 16/22] Removed all `@ts-ignore` usages (#30063) no ref This change should have no user impact. [`@ts-ignore` should never be used.][0] This removes all usages. Most of the time, we could simply delete it. Sometimes, we needed to replace it with something. All of the functionality changes were (1) only in tests (2) minor. [0]: https://evanhahn.com/ts-ignore-is-almost-always-the-worst-option/ --- .../serializers/output/mappers/snippets.js | 1 - .../settings-helpers/settings-helpers.js | 1 - .../services/stats/posts-stats-service.js | 2 -- .../core/server/services/stripe/stripe-api.js | 11 ++----- ghost/core/eslint.config.mjs | 2 +- ghost/core/test/e2e-api/admin/pages.test.js | 1 - ghost/core/test/e2e-api/admin/posts.test.js | 1 - .../services/email-service/cards.test.js | 33 ++++++++++--------- .../frontend/helpers/recommendations.test.js | 10 +++--- .../email-address-parser.test.ts | 4 +-- .../controllers/router-controller.test.js | 1 - .../test/utils/e2e-framework-mock-manager.js | 1 - 12 files changed, 28 insertions(+), 40 deletions(-) diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js index d0599d5b295..f6e35e0833e 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js @@ -10,7 +10,6 @@ module.exports = (snippet, frame) => { return { id: json.id, name: json.name, - // @ts-ignore mobiledoc: json.mobiledoc, lexical: json.lexical, created_at: json.created_at, diff --git a/ghost/core/core/server/services/settings-helpers/settings-helpers.js b/ghost/core/core/server/services/settings-helpers/settings-helpers.js index 4adb3b6d3b7..d4083ec447f 100644 --- a/ghost/core/core/server/services/settings-helpers/settings-helpers.js +++ b/ghost/core/core/server/services/settings-helpers/settings-helpers.js @@ -182,7 +182,6 @@ class SettingsHelpers { unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/'); if (uuid) { // hash key with member uuid for verification (and to not leak uuid) - it's possible to update member email prefs without logging in - // @ts-ignore const hmac = crypto.createHmac('sha256', key).update(`${uuid}`).digest('hex'); unsubscribeUrl.searchParams.set('uuid', uuid); unsubscribeUrl.searchParams.set('key', hmac); diff --git a/ghost/core/core/server/services/stats/posts-stats-service.js b/ghost/core/core/server/services/stats/posts-stats-service.js index f71d7b85a5c..66dc42c0f15 100644 --- a/ghost/core/core/server/services/stats/posts-stats-service.js +++ b/ghost/core/core/server/services/stats/posts-stats-service.js @@ -971,9 +971,7 @@ class PostsStatsService { const dailyChanges = []; for (const row of rawDeltas) { if (row) { - // @ts-ignore const dateValue = row.date || ''; - // @ts-ignore const changeValue = row.value || 0; dailyChanges.push({ date: String(dateValue), diff --git a/ghost/core/core/server/services/stripe/stripe-api.js b/ghost/core/core/server/services/stripe/stripe-api.js index 463d18b3b71..d4caffddafb 100644 --- a/ghost/core/core/server/services/stripe/stripe-api.js +++ b/ghost/core/core/server/services/stripe/stripe-api.js @@ -1,6 +1,4 @@ -// @ts-ignore const {VersionMismatchError} = require('@tryghost/errors'); -// @ts-ignore const debug = require('@tryghost/debug')('stripe'); const ghostConfig = require('../../../shared/config'); const stripe = require('stripe'); @@ -67,6 +65,7 @@ module.exports = class StripeAPI { * StripeAPI * @param {object} deps * @param {object} deps.labs + * @param {(key: string) => boolean} deps.labs.isSet */ constructor(deps) { /** @type {Stripe} */ @@ -223,8 +222,7 @@ module.exports = class StripeAPI { unit_amount: options.amount, active: options.active, nickname: options.nickname, - // @ts-ignore - custom_unit_amount: options.custom_unit_amount, // missing in .d.ts definitions in the Stripe node version we use, but should be supported in Stripe API at this version (: + custom_unit_amount: options.custom_unit_amount, recurring: options.type === 'recurring' && options.interval ? { interval: options.interval } : undefined @@ -593,7 +591,6 @@ module.exports = class StripeAPI { managed_payments: MANAGED_PAYMENTS_DISABLED, success_url: options.successUrl || this._config.checkoutSessionSuccessUrl, cancel_url: options.cancelUrl || this._config.checkoutSessionCancelUrl, - // @ts-ignore - we need to update to latest stripe library to correctly use newer features allow_promotion_codes: discounts ? undefined : this._config.enablePromoCodes, automatic_tax: { enabled: this._config.enableAutomaticTax @@ -622,7 +619,6 @@ module.exports = class StripeAPI { this._applyAutomaticTaxSessionOptions(stripeSessionOptions, {hasCustomer: Boolean(customerId)}); - // @ts-ignore const session = await this._stripe.checkout.sessions.create(stripeSessionOptions); return session; @@ -692,7 +688,6 @@ module.exports = class StripeAPI { this._applyAutomaticTaxSessionOptions(stripeSessionOptions, {hasCustomer: Boolean(customer)}); - // @ts-ignore const session = await this._stripe.checkout.sessions.create(stripeSessionOptions); return session; } @@ -750,7 +745,6 @@ module.exports = class StripeAPI { this._applyAutomaticTaxSessionOptions(stripeSessionOptions, {hasCustomer: Boolean(customer)}); - // @ts-ignore const session = await this._stripe.checkout.sessions.create(stripeSessionOptions); return session; @@ -783,7 +777,6 @@ module.exports = class StripeAPI { // Note: this is required for dynamic payment methods // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-currency - // @ts-ignore currency: this.labs.isSet('additionalPaymentMethods') ? options.currency : undefined }); diff --git a/ghost/core/eslint.config.mjs b/ghost/core/eslint.config.mjs index 9bbba44cfd5..ddeb9ae91f4 100644 --- a/ghost/core/eslint.config.mjs +++ b/ghost/core/eslint.config.mjs @@ -41,7 +41,7 @@ const tsLegacyRelaxations = { '@typescript-eslint/no-unused-expressions': 'off', // LEGACY: tseslint v8 default. `Function` used as a type for callbacks. '@typescript-eslint/no-unsafe-function-type': 'off', - // LEGACY: tseslint v8 default. `// @ts-ignore` comments still exist. + // LEGACY: tseslint v8 default. '@typescript-eslint/ban-ts-comment': 'off', // LEGACY: previous posture from plugin:ghost/ts allowed inferrable types. '@typescript-eslint/no-inferrable-types': 'off' diff --git a/ghost/core/test/e2e-api/admin/pages.test.js b/ghost/core/test/e2e-api/admin/pages.test.js index fb31cd4b6b1..f868ef4aa28 100644 --- a/ghost/core/test/e2e-api/admin/pages.test.js +++ b/ghost/core/test/e2e-api/admin/pages.test.js @@ -119,7 +119,6 @@ describe('Pages API', function () { status: 'draft' }; - // @ts-ignore const products = await models.Product.findAll(); const freeTier = products.models[0]; diff --git a/ghost/core/test/e2e-api/admin/posts.test.js b/ghost/core/test/e2e-api/admin/posts.test.js index ca333a4e4fb..ba88945c120 100644 --- a/ghost/core/test/e2e-api/admin/posts.test.js +++ b/ghost/core/test/e2e-api/admin/posts.test.js @@ -699,7 +699,6 @@ describe('Posts API', function () { status: 'draft' }; - // @ts-ignore const products = await models.Product.findAll(); const freeTier = products.models[0]; diff --git a/ghost/core/test/integration/services/email-service/cards.test.js b/ghost/core/test/integration/services/email-service/cards.test.js index e3a53bb3b90..a3badee48d6 100644 --- a/ghost/core/test/integration/services/email-service/cards.test.js +++ b/ghost/core/test/integration/services/email-service/cards.test.js @@ -32,16 +32,19 @@ const excludedNodes = [ * Remove the preheader span from the email html and put it in a separate field called preheader * @template {{html: string}} T * @param {T} data - * @returns {asserts data is T & {preheader: string}} + * @returns {T & {html: string, preheader: string}} */ function splitPreheader(data) { // Remove the preheader span from the email using cheerio const $ = cheerio.load(data.html); const preheader = $('.preheader'); - // @ts-ignore - data.preheader = preheader.html(); + const preheaderHtml = preheader.html(); preheader.remove(); - data.html = $.html(); + return { + ...data, + html: $.html(), + preheader: preheaderHtml + }; } function createParagraphCard(text = 'Hello world.') { @@ -129,12 +132,12 @@ describe('Can send cards via email', function () { }); // Remove the preheader span from the email using cheerio - splitPreheader(data); + const email = splitPreheader(data); // Check only contains once in every part - assert.equal(data.html.match(/This is a paragraph test\./g).length, 1); - assert.equal(data.plaintext.match(/This is a paragraph test\./g).length, 1); - assert.equal(data.preheader.match(/This is a paragraph test\./g).length, 1); + assert.equal(email.html.match(/This is a paragraph test\./g).length, 1); + assert.equal(email.plaintext.match(/This is a paragraph test\./g).length, 1); + assert.equal(email.preheader.match(/This is a paragraph test\./g).length, 1); await matchEmailSnapshot(); }); @@ -165,16 +168,16 @@ describe('Can send cards via email', function () { ]) }); - splitPreheader(data); + const email = splitPreheader(data); // Check the plaintext does contain the paragraph, but doesn't contain the signup card - assert.ok(!data.html.includes('Sign up for Koenig Lexical')); - assert.ok(!data.plaintext.includes('Sign up for Koenig Lexical')); - assert.ok(!data.preheader.includes('Sign up for Koenig Lexical')); + assert.ok(!email.html.includes('Sign up for Koenig Lexical')); + assert.ok(!email.plaintext.includes('Sign up for Koenig Lexical')); + assert.ok(!email.preheader.includes('Sign up for Koenig Lexical')); - assert.ok(data.html.includes('This is a paragraph')); - assert.ok(data.plaintext.includes('This is a paragraph')); - assert.ok(data.preheader.includes('This is a paragraph')); + assert.ok(email.html.includes('This is a paragraph')); + assert.ok(email.plaintext.includes('This is a paragraph')); + assert.ok(email.preheader.includes('This is a paragraph')); await matchEmailSnapshot(); }); diff --git a/ghost/core/test/unit/frontend/helpers/recommendations.test.js b/ghost/core/test/unit/frontend/helpers/recommendations.test.js index ca72d72b7f9..376e6b574f2 100644 --- a/ghost/core/test/unit/frontend/helpers/recommendations.test.js +++ b/ghost/core/test/unit/frontend/helpers/recommendations.test.js @@ -19,6 +19,8 @@ function trimSpaces(string) { describe('{{#recommendations}} helper', function () { let logging; + /** @type {sinon.SinonStub} */ + let settingsCacheGetStub; beforeAll(async function () { hbs.express4({ @@ -33,9 +35,8 @@ describe('{{#recommendations}} helper', function () { hbs.registerHelper('readable_url', readable_url); // Stub settings cache - sinon.stub(settingsCache, 'get'); - // @ts-ignore - settingsCache.get.withArgs('recommendations_enabled').returns(true); + settingsCacheGetStub = sinon.stub(settingsCache, 'get'); + settingsCacheGetStub.withArgs('recommendations_enabled').returns(true); // Stub Recommendation Content API const meta = {pagination: {}}; @@ -129,8 +130,7 @@ describe('{{#recommendations}} helper', function () { describe('when recommendations_enabled is false', function () { beforeAll(function () { - // @ts-ignore - settingsCache.get.withArgs('recommendations_enabled').returns(true); + settingsCacheGetStub.withArgs('recommendations_enabled').returns(true); }); it('renders nothing', async function () { diff --git a/ghost/core/test/unit/server/services/email-address/email-address-parser.test.ts b/ghost/core/test/unit/server/services/email-address/email-address-parser.test.ts index aeb6ff26546..eeac606f995 100644 --- a/ghost/core/test/unit/server/services/email-address/email-address-parser.test.ts +++ b/ghost/core/test/unit/server/services/email-address/email-address-parser.test.ts @@ -55,13 +55,13 @@ describe('EmailAddressParser', function () { }); it('should return null for null input', function () { - // @ts-ignore - Testing null input + // @ts-expect-error - Testing null input const email = EmailAddressParser.parse(null); assert.equal(email, null); }); it('should return null for undefined input', function () { - // @ts-ignore - Testing undefined input + // @ts-expect-error - Testing undefined input const email = EmailAddressParser.parse(undefined); assert.equal(email, null); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js index bb0df92e137..b1cb614d9bc 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js @@ -2,7 +2,6 @@ const sinon = require('sinon'); const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); -// @ts-ignore - Intentionally ignoring TypeScript errors for tests const RouterController = require('../../../../../../../core/server/services/members/members-api/controllers/router-controller'); describe('RouterController', function () { diff --git a/ghost/core/test/utils/e2e-framework-mock-manager.js b/ghost/core/test/utils/e2e-framework-mock-manager.js index 7188b13405b..3d8211f4dd1 100644 --- a/ghost/core/test/utils/e2e-framework-mock-manager.js +++ b/ghost/core/test/utils/e2e-framework-mock-manager.js @@ -247,7 +247,6 @@ const mockMailgun = (customStubbedSend) => { // We need to stub the Mailgun client before starting Ghost sinon.stub(MailgunClient.prototype, 'getInstance').returns({ - // @ts-ignore messages: { create: async function () { return await mailgunCreateMessageStub.call(this, ...arguments); From 291bddb60954491c10f2b744dc7efd94eff2e7a5 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 09:57:51 -0500 Subject: [PATCH 17/22] Forbade `@ts-ignore` (#30064) no ref [`@ts-ignore` should never be used.][0] ESLint now errors if you do. `@ts-nocheck` is also forbidden. `@ts-expect-error` is allowed with a description. No "real" code changes were necessary after this. [0]: https://evanhahn.com/ts-ignore-is-almost-always-the-worst-option/ --- ghost/core/eslint.config.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ghost/core/eslint.config.mjs b/ghost/core/eslint.config.mjs index ddeb9ae91f4..505413049cc 100644 --- a/ghost/core/eslint.config.mjs +++ b/ghost/core/eslint.config.mjs @@ -41,8 +41,6 @@ const tsLegacyRelaxations = { '@typescript-eslint/no-unused-expressions': 'off', // LEGACY: tseslint v8 default. `Function` used as a type for callbacks. '@typescript-eslint/no-unsafe-function-type': 'off', - // LEGACY: tseslint v8 default. - '@typescript-eslint/ban-ts-comment': 'off', // LEGACY: previous posture from plugin:ghost/ts allowed inferrable types. '@typescript-eslint/no-inferrable-types': 'off' }; @@ -128,6 +126,12 @@ export default tseslint.config( }], 'no-undef': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': ['error', { + 'ts-check': false, + 'ts-expect-error': 'allow-with-description', + 'ts-ignore': true, + 'ts-nocheck': true + }], ...tsLegacyRelaxations } }, From 50bc753317a94771677a90c1a4341689d44b4b86 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 10:07:58 -0500 Subject: [PATCH 18/22] Installed dormant Oxfmt (#29955) towards https://linear.app/ghost/issue/PLA-137 This installs Oxfmt in the Ghost repo. It doesn't add any scripts or enforcement, but it enables a workflow where we can run `pnpm exec oxfmt my-file.ts`, which will ease the future transition. --------- Co-authored-by: Steve Larson <9larsons@gmail.com> --- .oxfmtrc.json | 39 ++++++++ package.json | 1 + pnpm-lock.yaml | 228 ++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + 4 files changed, 269 insertions(+) create mode 100644 .oxfmtrc.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000..97ed230dc1e --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,39 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "arrowParens": "avoid", + "bracketSpacing": false, + "ignorePatterns": [ + "**/build/**", + "**/built/**", + "**/coverage/**", + "**/dist/**", + "**/*.min.*", + "**/*.hbs", + "**/*.html", + "**/test/**/fixtures/**", + "**/__snapshots__/**", + "apps/ember-admin/**", + "koenig/kg-default-nodes/**", + "koenig/kg-lexical-html-renderer/**", + "koenig/kg-simplemde/debug/**", + "koenig/koenig-lexical/**" + ], + "jsdoc": { + "addDefaultToDescription": false, + "bracketSpacing": false + }, + "jsxSingleQuote": true, + "printWidth": 120, + "singleQuote": true, + "sortPackageJson": false, + "tabWidth": 4, + "trailingComma": "none", + "overrides": [ + { + "files": ["*.yml", "*.yaml"], + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/package.json b/package.json index 9e4052295dd..0ec025e3584 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "nx": "23.1.1", + "oxfmt": "catalog:", "remark-cli": "catalog:", "remark-validate-links": "catalog:", "rimraf": "6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbaa23e7840..2a1fd2f281d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,9 @@ catalogs: node-html-markdown: specifier: 2.0.0 version: 2.0.0 + oxfmt: + specifier: 0.63.0 + version: 0.63.0 parse-email-address: specifier: 0.0.4 version: 0.0.4 @@ -603,6 +606,9 @@ importers: nx: specifier: 23.1.1 version: 23.1.1(@swc/core@1.15.43(@swc/helpers@0.5.23)) + oxfmt: + specifier: 'catalog:' + version: 0.63.0 remark-cli: specifier: 'catalog:' version: 12.0.1(bluebird@3.7.2)(supports-color@10.2.2) @@ -7222,6 +7228,128 @@ packages: cpu: [x64] os: [win32] + '@oxfmt/binding-android-arm-eabi@0.63.0': + resolution: {integrity: sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.63.0': + resolution: {integrity: sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.63.0': + resolution: {integrity: sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.63.0': + resolution: {integrity: sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.63.0': + resolution: {integrity: sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': + resolution: {integrity: sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.63.0': + resolution: {integrity: sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.63.0': + resolution: {integrity: sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.63.0': + resolution: {integrity: sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.63.0': + resolution: {integrity: sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.63.0': + resolution: {integrity: sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.63.0': + resolution: {integrity: sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.63.0': + resolution: {integrity: sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.63.0': + resolution: {integrity: sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.63.0': + resolution: {integrity: sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.63.0': + resolution: {integrity: sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.63.0': + resolution: {integrity: sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.63.0': + resolution: {integrity: sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.63.0': + resolution: {integrity: sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -18280,6 +18408,19 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxfmt@0.63.0: + resolution: {integrity: sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -21282,6 +21423,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -26308,6 +26453,63 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true + '@oxfmt/binding-android-arm-eabi@0.63.0': + optional: true + + '@oxfmt/binding-android-arm64@0.63.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.63.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.63.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.63.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.63.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.63.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.63.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.63.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.63.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.63.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.63.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.63.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.63.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.63.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.63.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.63.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.63.0': + optional: true + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -41809,6 +42011,30 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + oxfmt@0.63.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.63.0 + '@oxfmt/binding-android-arm64': 0.63.0 + '@oxfmt/binding-darwin-arm64': 0.63.0 + '@oxfmt/binding-darwin-x64': 0.63.0 + '@oxfmt/binding-freebsd-x64': 0.63.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.63.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.63.0 + '@oxfmt/binding-linux-arm64-gnu': 0.63.0 + '@oxfmt/binding-linux-arm64-musl': 0.63.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.63.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.63.0 + '@oxfmt/binding-linux-riscv64-musl': 0.63.0 + '@oxfmt/binding-linux-s390x-gnu': 0.63.0 + '@oxfmt/binding-linux-x64-gnu': 0.63.0 + '@oxfmt/binding-linux-x64-musl': 0.63.0 + '@oxfmt/binding-openharmony-arm64': 0.63.0 + '@oxfmt/binding-win32-arm64-msvc': 0.63.0 + '@oxfmt/binding-win32-ia32-msvc': 0.63.0 + '@oxfmt/binding-win32-x64-msvc': 0.63.0 + p-cancelable@2.1.1: {} p-defer@3.0.0: {} @@ -45457,6 +45683,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinypool@2.1.0: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 48946708aa9..d2de342fea6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -157,6 +157,7 @@ catalog: msw: 2.14.6 nock: 14.0.17 node-html-markdown: 2.0.0 + oxfmt: 0.63.0 postcss: 8.5.26 preact: ^10.29.2 prettier: 3.9.6 From a2c4557888e4a4bb9a33462b9a8377bd02bf8dd1 Mon Sep 17 00:00:00 2001 From: Renato Costa <130500381+renatoworks@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:17:36 +0300 Subject: [PATCH 19/22] Improved the field picker in the members import mapping (#30062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/BER-3876/refine-the-field-picker-in-the-members-import-mapping The trigger named the kind of every field it showed, putting "Membership field" under nearly every row to answer a question almost none of them raise. The kind is named now only where a custom field's name is one a native field already has, and with the second line gone the icon leaves its tile, so the control stands at the height every other one does. Building that turned up a bug. The list identified its items by their label, so a custom field named "Name" and the native "Name" were one item to cmdk: both drew as selected, and since the active item is resolved by querying the first match in the DOM, Enter took the native one whichever had been arrowed to. Identity moves to the targets, already namespaced apart as `name` and `custom_fields.name`, and the label moves to keywords — which needs a filter of its own, since cmdk scores an item's value and its keywords as one joined string and a match is otherwise free to run from one into the other. A composite's parts differ only in their last few words, so truncating the label as one string took away the only thing telling those rows apart. The name gives way first now and the part stays whole. --- .../custom-fields/field-picker.tsx | 140 +++++++++++++----- ...-members-custom-fields.acceptance.test.tsx | 34 ++++- apps/shade/src/components/ui/command.tsx | 8 +- 3 files changed, 141 insertions(+), 41 deletions(-) diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx index b144d1b7a57..e44dfa0cd6b 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/field-picker.tsx @@ -1,14 +1,22 @@ import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; -import {Button, Command, CommandCheck, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, inputSurfaceClasses} from '@tryghost/shade/components'; +import {Badge, Button, Command, CommandCheck, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, commandDefaultFilter, inputSurfaceClasses} from '@tryghost/shade/components'; import {LucideIcon, cn} from '@tryghost/shade/utils'; import {useRef} from 'react'; import {type MemberCustomFieldCsvColumn} from '@tryghost/admin-x-framework/api/member-custom-fields'; // One list, two sections, so what a column can be imported as is answered by reading rather // than by trying each kind in turn. The section a field sits in is what tells a native "Name" -// apart from a custom field someone called "Name", so the trigger repeats it once chosen. +// apart from a custom field someone called "Name", and the trigger has to carry that once the +// list is closed. +// +// Marked only where that is in doubt: a custom field whose name a native field already has. +// Naming every row's kind put "Membership field" under nearly the whole table to settle a +// question almost none of those rows raise, and a mark that appears where two things read the +// same is the same answer without the noise. Its own word rather than CUSTOM_GROUP: the +// headings pluralise with a bare `s`, and a badge has less room than a line of its own had. const NATIVE_GROUP = 'Membership field'; const CUSTOM_GROUP = 'Custom field'; +const CUSTOM_BADGE = 'Custom'; // Icons for the native targets. Written here rather than taken from somewhere shared because // nothing shared exists: the members filter picker hand-writes its own switch, and analytics @@ -29,6 +37,32 @@ const NATIVE_ICONS: Record = { import_tier: LucideIcon.Star }; +// "Shipping Address (Address line 1)" -> ["Shipping Address", "(Address line 1)"]. The parts of +// a composite are labelled `${name} (${part})` in memberCustomFieldCsvColumns, so every row of +// one address reads the same until its last few words. Split here, the name is what gives way to +// a narrow column and the part stays whole; truncating the label as one string takes away the +// only thing telling those rows apart. Anything without a bracketed tail has nothing to protect. +function splitPartLabel(label: string): [string, string] { + const parted = /^(.*) (\([^()]*\))$/.exec(label); + return parted ? [parted[1], parted[2]] : [label, '']; +} + +// Case and surrounding space are no help in telling two of these apart on sight, so a name that +// differs only by them counts as the same name. +function sameLabel(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +// Scored over the label alone, which the list's items carry as their one keyword. Identity is the +// target instead, and cmdk scores an item's value and its keywords as one joined string — so with +// the target in there, "custom" reached every namespaced column, and a match was free to run from +// one into the other: `subscribed_to_emails` and its own label together hold a c-u-s-t-o-m that +// neither holds by itself. Naming what to search leaves the target doing nothing but telling two +// fields of the same name apart. +function scoreByLabel(_target: string, query: string, keywords?: string[]): number { + return commandDefaultFilter((keywords ?? []).join(' '), query); +} + function NativeIcon({value, className}: {value: string; className?: string}) { const Icon = NATIVE_ICONS[value] ?? LucideIcon.Type; return ; @@ -80,6 +114,12 @@ export function FieldPicker({ const selectedCustom = customFieldMappings.find(field => field.value === value); const selectedNative = fieldMappings.find(field => field.value === value); const selected = selectedCustom ?? selectedNative; + const [labelHead, labelTail] = splitPartLabel(selected?.label ?? ''); + // Against what this picker is offering rather than the whole native set, since Tier is only + // among them on a site with tiers to import onto — where it is not offered, a custom field + // called "Tier" is the only "Tier" in the list and has nothing to be told apart from. + const ambiguous = selectedCustom !== undefined + && fieldMappings.some(native => sameLabel(native.label, selectedCustom.label)); const choose = (target: string) => { onOpenChange(false); @@ -117,10 +157,18 @@ export function FieldPicker({ aria-invalid={invalid || undefined} // Names the column and what it is mapped to. A bare "Field for " // would replace the trigger's own text in the accessible name, so the - // selection — the whole point of the two lines below — would never be read. - aria-label={selected ? `Field for ${columnKey}, ${selected.label}` : `Field for ${columnKey}, not chosen`} + // selection — the whole point of the label below — would never be read. + // Every custom field names its kind here, not only the ones the badge marks. + // The badge is spent narrowly because it costs room in a column that has none + // to spare; a name read aloud costs nothing, so there is no reason to hold it + // back from the rest. Spelled out in full, since "Custom" alone names nothing. + aria-label={selected ? `Field for ${columnKey}, ${selected.label}${selectedCustom ? `, ${CUSTOM_GROUP}` : ''}` : `Field for ${columnKey}, not chosen`} className={cn( - 'h-auto w-full scroll-my-8 justify-start gap-2 px-2.5 py-1.5 font-normal', + // px-2 rather than the combobox recipe's px-3: at the 32px control height a + // 16px icon sits 8px off the top and bottom, and the same 8px at the start + // squares that off. It is what the list's own items are set to, so the + // icon does not move sideways as the popover opens under it. + 'h-(--control-height) w-full scroll-my-8 justify-start gap-2 px-2 font-normal', className, // Button's outline hover (bg-button-hover) is what the Select trigger this // replaced used too, so hovering weighs what it always did. Only the text @@ -142,39 +190,50 @@ export function FieldPicker({ role="combobox" variant="outline" > - {/* Blocked rather than bare, the way the font pickers show a chosen face: next - to two lines of text a loose glyph reads as debris, and the tile gives it - somewhere to sit. Only here — inside the list the icons sit against a - single line and stay flat, as the filter picker has them. + {/* Flat and inline, the way these same icons sit inside the list below. The + tile they used to sit in was answering to the second line of text: two + lines give a loose glyph nothing to be centred against, and the tile both + gave it somewhere to sit and held the row's height open. With one line + left, neither job remains, and the control comes back to the height every + other one in Shade stands at. - The tile is what sets the control's height, so an empty row is exactly as - tall as a chosen one without a blank line held open to do it. Empty is an - outline: a slot still to fill, not a thing with no icon. */} - + Empty still carries a mark of its own, so the names down the column all + start in the same place whether a row is filled or not. An outline reads + as a slot still to fill rather than as a thing with no icon. */} + {selectedCustom && } {selectedNative && } - {/* Inside the slot rather than filling it: an empty dashed outline reads - larger than a filled tile of the same size, since the border is the - whole shape and there is nothing inside to give it a middle. Drawn - smaller, the two look the same size — and the slot around it keeps its - 32px, so a row does not change height as columns go in and out. */} - {!selected && } + {!selected && } {selected ? ( - // The field first, then which list it came from — without that second line - // a custom field named like a native one is indistinguishable once the - // list is closed. - - {selected.label} - {selectedCustom ? CUSTOM_GROUP : NATIVE_GROUP} - + // The badge sits against the end of the control rather than trailing the + // name, so it holds the same place on every row it appears on instead of + // one that moves with the length of the label. flex-1 on the label is what + // puts it there. + <> + + {/* Both give way, the name far sooner: shrink is a ratio, so a name + set to shrink hundreds of times faster is spent down to nothing + before the part it belongs to loses a character. What it cannot + be is unshrinkable — a part with nowhere to give runs out of the + control instead of truncating, which is what a narrow screen + does to an address. Nothing is lost with the name gone: the + column it maps from is named in full one cell to the left. */} + {labelHead} + {/* The space belongs to the text rather than to a gap between the + two boxes: read out, copied, or asserted on, this is one label + and it reads as one. Held by whitespace-pre, which is what stops + a space at the start of a box being dropped. */} + {labelTail && {` ${labelTail}`}} + + {/* Dropped on a narrow screen, where it is taking width from a label + that has none to give. Worth being plain about the trade: it only + appears where it is the one thing telling two same-named fields + apart, so this gives that up exactly where it was earning its keep, + and the name it makes room for is the more useful half of the row. + The kind survives in the accessible name and in the open list. */} + {ambiguous && {CUSTOM_BADGE}} + ) : ( Select field )} @@ -204,16 +263,25 @@ export function FieldPicker({ onCreateField(); }} > - + {/* Shade's own max-height stands; only the shrinking is added. flex-1 with min-h-0 lets the list give up height to the available-height cap on the content above, which is what keeps a row near the bottom of the dialog from opening a list that runs off the screen. */} + {/* Keyed by the target rather than by the label, because a site can call a + custom field "Name" and then two items carry the same label. cmdk holds + the highlighted item as a value and marks every item equal to it, so a + shared label is one item to it: both rows light up, and the one Enter + takes is whichever the DOM has first — always the native one, whichever + was arrowed to. The targets are already distinct, namespaced apart as + `name` and `custom_fields.name`, so they are what identity is taken + from, and the label moves to keywords, which is what scoreByLabel reads + so that search still answers to the name on the row. */} {fieldMappings.map(field => ( - choose(field.value)}> + choose(field.value)}> {field.label} {value === field.value && } @@ -222,7 +290,7 @@ export function FieldPicker({ {customFieldMappings.map(field => ( - choose(field.value)}> + choose(field.value)}> {field.label} {value === field.value && } diff --git a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx index ef945712c4a..0f206323d50 100644 --- a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx +++ b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx @@ -109,10 +109,38 @@ describe('Import members custom fields', () => { // The row carries the new field immediately, from the create response: the browse query // is invalidated but not awaited, so waiting for the refetch would leave the picker - // blank in between. And the trigger names the list it came from, so a custom field - // called "Name" could not be read as the member's own name once the list is closed. + // blank in between. await expect.element(fieldSelect('nickname')).toHaveTextContent('Nickname'); - await expect.element(fieldSelect('nickname')).toHaveTextContent('Custom field'); + }); + + it('marks a custom field a membership field already has the name of', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await userEvent.fill(createForm().getByLabelText('Name'), 'Name'); + await createForm().getByRole('button', {name: 'Save'}).click(); + + // Two rows read "Name" now, and the mark on the custom one is the whole of what tells + // them apart once the list is closed. The membership field carries nothing: it is the + // rule rather than the exception, and marking both is what this replaced. + await expect.element(fieldSelect('nickname')).toHaveTextContent('Name'); + await expect.element(fieldSelect('nickname')).toHaveTextContent('Custom'); + await expect.element(fieldSelect('name')).toHaveTextContent('Name'); + await expect.element(fieldSelect('name')).not.toHaveTextContent('Custom'); + }); + + it('leaves a custom field no membership field is named like unmarked', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + + await openCreateForm('nickname'); + await createForm().getByRole('button', {name: 'Save'}).click(); + + await expect.element(fieldSelect('nickname')).toHaveTextContent('Nickname'); + await expect.element(fieldSelect('nickname')).not.toHaveTextContent('Custom'); }); it('puts the create form away when another picker is opened', async () => { diff --git a/apps/shade/src/components/ui/command.tsx b/apps/shade/src/components/ui/command.tsx index 17c418c4072..8105c888c01 100644 --- a/apps/shade/src/components/ui/command.tsx +++ b/apps/shade/src/components/ui/command.tsx @@ -4,7 +4,10 @@ import React from 'react'; import {cn} from '@/lib/utils'; import {Dialog, DialogContent, DialogTitle} from '@/components/ui/dialog'; import {type DialogProps} from '@radix-ui/react-dialog'; -import {Command as CommandPrimitive} from 'cmdk'; +// The scorer cmdk filters with by default, passed on because a list whose items are identified +// by something other than their label has to say what to search instead, and the answer should +// be "the same way as everywhere else, over this string" rather than a second kind of matching. +import {Command as CommandPrimitive, defaultFilter as commandDefaultFilter} from 'cmdk'; import {Check, LucideIcon, Search} from 'lucide-react'; function Command({className, ...props}: React.ComponentProps) { @@ -135,5 +138,6 @@ export { CommandItem, CommandList, CommandSeparator, - CommandShortcut + CommandShortcut, + commandDefaultFilter }; From 6a53bd3e370ed8e0e964874d7fe2e43815e89c91 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 10:41:12 -0500 Subject: [PATCH 20/22] Changed membership settings dialogs to controlled components (#30016) no ref Uses the settings `DialogPortal` from #30014. The custom field, welcome email and welcome email customize dialogs were the last `NiceModal.create` dialogs in the membership and email settings areas. They now take an `onClose` prop and are rendered by the component that opens them: the two `SettingsModal` dialogs through the settings dialog portal so they paint above the settings chrome, and the customize dialog directly, since its `EmailDesignModal` is already a portalled Shade `Dialog`. With these converted (plus #30014 and #30015), no settings dialog is created or shown through NiceModal any more, which clears the way to remove the provider, the dependency and the compatibility bridges. --- .../app/components/settings/email/emails.tsx | 5 +++-- .../settings/membership/custom-fields.tsx | 6 ++++-- .../custom-fields/custom-field-modal.tsx | 16 +++++++------- .../settings/membership/member-emails.tsx | 21 +++++++++++++------ .../welcome-email-customize-modal.tsx | 18 ++++------------ .../member-emails/welcome-email-modal.tsx | 15 ++++++------- .../custom-fields.acceptance.test.tsx | 6 ++---- 7 files changed, 42 insertions(+), 45 deletions(-) diff --git a/apps/admin/src/settings/app/components/settings/email/emails.tsx b/apps/admin/src/settings/app/components/settings/email/emails.tsx index 673676d185e..28ec087424d 100644 --- a/apps/admin/src/settings/app/components/settings/email/emails.tsx +++ b/apps/admin/src/settings/app/components/settings/email/emails.tsx @@ -2,7 +2,6 @@ import DefaultRecipients from './default-recipients'; import EnableNewsletters from './enable-newsletters'; import MailGun from './mailgun'; import NewslettersTabContent, {type NewslettersFilter} from './newsletters/newsletters-tab-content'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useRef, useState} from 'react'; import SearchableSection from '@/settings/app/components/searchable-section'; import TopLevelGroup from '@/settings/app/components/top-level-group'; @@ -26,8 +25,9 @@ export const searchKeywords = { }; const TransactionalTabContent: React.FC = () => { + const [isCustomizeOpen, setIsCustomizeOpen] = useState(false); const openCustomizeModal = () => { - NiceModal.show(WelcomeEmailCustomizeModal); + setIsCustomizeOpen(true); }; return ( @@ -52,6 +52,7 @@ const TransactionalTabContent: React.FC = () => { + {isCustomizeOpen && setIsCustomizeOpen(false)} />} ); }; diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx b/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx index f02999d1a86..8174778a7c1 100644 --- a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx @@ -1,6 +1,5 @@ import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; import CustomFieldModal from './custom-fields/custom-field-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; @@ -13,6 +12,7 @@ import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; // How many fields render before the list collapses behind "Show all" — the // recommendations list's preview size. @@ -189,7 +189,8 @@ const CustomFields: React.FC<{keywords: string[]}> = ({keywords}) => { previousCounts.current = {active: activeFields.length, archived: archivedFields.length}; }, [activeFields.length, archivedFields.length]); - const openModal = (field?: MemberCustomField) => NiceModal.show(CustomFieldModal, {field}); + const [editingField, setEditingField] = useState<{field?: MemberCustomField} | null>(null); + const openModal = (field?: MemberCustomField) => setEditingField({field}); /** * Applied to the whole list rather than the active tab: a reorder names every @@ -241,6 +242,7 @@ const CustomFields: React.FC<{keywords: string[]}> = ({keywords}) => { setShowAllArchived(true)} /> )} + {editingField && setEditingField(null)} />} ); }; diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx index d5d09684ec7..969dbedcd63 100644 --- a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx @@ -1,5 +1,5 @@ +import React from 'react'; import {CustomFieldTypeOption} from '@/shared/member-custom-fields/custom-field-type-option'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -12,8 +12,7 @@ import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-cus const userTypeById = (id: string) => memberCustomFieldUserTypes.find(userType => userType.id === id) || memberCustomFieldUserTypes[0]; -const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) => { - const modal = useModal(); +const CustomFieldModal: React.FC<{field?: MemberCustomField; onClose: () => void}> = ({field, onClose}) => { const {confirm} = useConfirmation(); const {mutateAsync: createField} = useCreateMemberCustomField(); const {mutateAsync: editField} = useEditMemberCustomField(); @@ -68,7 +67,7 @@ const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) // newsletters pattern) — they change what every collection surface shows. const archiveButton = ( @@ -311,6 +314,12 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { onPaidEdit={() => handleEditClick('paid')} onPaidToggle={() => handleToggle('paid')} /> + {editingEmail && ( + + setEditingEmail(null)} /> + + )} + {isCustomizeOpen && setIsCustomizeOpen(false)} />} ); }; diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx index ed5cb313f53..dac07627acb 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx @@ -1,7 +1,6 @@ import EmailDesignModal from '@/settings/app/components/settings/email-design/email-design-modal'; import EmailPreview from '@/settings/app/components/settings/email-design/email-preview'; import HeaderImageField from '@/settings/app/components/settings/email-design/header-image-field'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import ShowBadgeField from '@/settings/app/components/settings/email-design/show-badge-field'; import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; @@ -333,8 +332,7 @@ const normalizeSenderValue = (value: string | null | undefined) => { return trimmed || null; }; -const WelcomeEmailCustomizeModal = NiceModal.create(() => { - const modal = useModal(); +const WelcomeEmailCustomizeModal: React.FC<{onClose: () => void}> = ({onClose}) => { const {siteData, settings: globalSettings, config} = useGlobalData(); const [siteTitle, defaultEmailAddress, icon, supportEmailAddress] = getSettingValues( globalSettings, @@ -510,10 +508,6 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { })); }, [updateForm]); - const handleClose = useCallback(() => { - modal.hide(); - }, [modal]); - const fetchErrorMessage = 'Unable to load email design settings. Please try again.'; const modalOkProps = hasSaveError ? { ...okProps, @@ -524,14 +518,9 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { return ( { - modal.resolveHide(); - modal.remove(); - }} dirty={saveState === 'unsaved'} isLoading={isLoading || isError} okProps={modalOkProps} - open={modal.visible} preview={isError ? ( ) : ( @@ -574,11 +563,12 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { } testId="welcome-email-customize-modal" title="Welcome emails" - onClose={handleClose} + open + onClose={onClose} onSave={() => handleSave({fakeWhenUnchanged: true})} /> ); -}); +}; export default WelcomeEmailCustomizeModal; diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx index 5e2669a7cd8..ac9c09155d5 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; @@ -90,15 +89,14 @@ const EmailPreviewBody: React.FC = ({children, className}
); -interface WelcomeEmailModalProps { +export interface WelcomeEmailModalProps { emailType: 'free' | 'paid'; automatedEmail: AutomatedEmail; } type PreviewMode = 'edit' | 'preview'; -const WelcomeEmailModal = NiceModal.create(({emailType = 'free', automatedEmail}) => { - const modal = useModal(); +const WelcomeEmailModal: React.FC void}> = ({emailType = 'free', automatedEmail, onClose}) => { const {settings: globalSettings, config} = useGlobalData(); const [siteTitle, defaultEmailAddress, supportEmailAddress] = getSettingValues(globalSettings, ['title', 'default_email_address', 'support_email_address']); const {updateRoute} = useSettingsNavigation(); @@ -144,10 +142,8 @@ const WelcomeEmailModal = NiceModal.create(({emailType = const isDirty = saveState === 'unsaved'; const handleClose = useCallback(() => { - confirm(isDirty, () => { - modal.remove(); - }); - }, [confirm, modal, isDirty]); + confirm(isDirty, onClose); + }, [confirm, onClose, isDirty]); const handleSaveRef = useRef(handleSave); useEffect(() => { @@ -216,6 +212,7 @@ const WelcomeEmailModal = NiceModal.create(({emailType = size='full' testId='welcome-email-modal' width='full' + onClose={onClose} > (({emailType = ); -}); +}; export default WelcomeEmailModal; diff --git a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx index 62dc12b6efd..a2eb7229d92 100644 --- a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx @@ -244,10 +244,8 @@ describe("Custom fields", () => { await expect(rows).toHaveCount(7); await expect.element(rows.last()).toHaveTextContent("Newest"); - // Wait for the modal to finish closing (Save holds it open ~500ms for - // its saving state). Ending the test earlier leaves that removal - // pending, and it would fire into the NEXT test's freshly-opened - // modal — NiceModal keys modals by component and dispatches globally. + // Save holds the modal open for its ~500ms saving state. Verify the + // async save has completed and the parent-controlled modal unmounts. await expect(modal).toHaveCount(0); }); From e35ef3eab8cf5a02b31ee9163848ac996f20e800 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:57:47 +0000 Subject: [PATCH 21/22] v6.58.0 --- .changeset/breezy-olives-brush.md | 20 ----- ...@tryghost!adapter-base-scheduling@0.2.1.md | 5 -- ...@tryghost!adapter-base-scheduling@0.2.2.md | 7 ++ .../@tryghost!adapter-base-sso@0.1.2.md | 5 ++ .../@tryghost!kg-card-factory@5.2.4.md | 5 ++ .../@tryghost!kg-clean-basic-html@4.3.4.md | 7 ++ .../@tryghost!kg-converters@1.2.5.md | 5 ++ .../@tryghost!kg-default-cards@10.3.5.md | 7 ++ .../@tryghost!kg-default-nodes@2.2.0.md | 11 +++ .../@tryghost!kg-default-transforms@1.3.4.md | 8 ++ .../@tryghost!kg-html-to-lexical@1.4.0.md | 12 +++ ...tryghost!kg-lexical-html-renderer@1.5.0.md | 12 +++ ...ryghost!kg-markdown-html-renderer@7.2.4.md | 7 ++ .../@tryghost!kg-unsplash-selector@0.4.4.md | 11 +++ .../changelogs/@tryghost!kg-utils@1.1.4.md | 7 ++ .../@tryghost!koenig-lexical@1.9.1.md | 5 -- .../@tryghost!koenig-lexical@1.9.2.md | 17 +++++ .changeset/eight-guests-listen.md | 16 ---- .changeset/ledger.yaml | 74 +++++++++++++++++++ .changeset/salty-cities-change.md | 20 ----- .changeset/typed-logging-args.md | 5 -- apps/ember-admin/package.json | 2 +- ghost/core/package.json | 2 +- koenig/kg-card-factory/package.json | 4 +- koenig/kg-clean-basic-html/package.json | 4 +- koenig/kg-converters/package.json | 2 +- koenig/kg-default-cards/package.json | 2 +- koenig/kg-default-nodes/package.json | 2 +- koenig/kg-default-transforms/package.json | 4 +- koenig/kg-html-to-lexical/package.json | 4 +- koenig/kg-lexical-html-renderer/package.json | 4 +- koenig/kg-markdown-html-renderer/package.json | 4 +- koenig/kg-unsplash-selector/package.json | 4 +- koenig/kg-utils/package.json | 4 +- koenig/koenig-lexical/package.json | 2 +- .../adapters/scheduling-base/package.json | 2 +- packages/adapters/sso-base/package.json | 2 +- 37 files changed, 219 insertions(+), 95 deletions(-) delete mode 100644 .changeset/breezy-olives-brush.md delete mode 100644 .changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md create mode 100644 .changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md create mode 100644 .changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md create mode 100644 .changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md create mode 100644 .changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md create mode 100644 .changeset/changelogs/@tryghost!kg-converters@1.2.5.md create mode 100644 .changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md create mode 100644 .changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md create mode 100644 .changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md create mode 100644 .changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md create mode 100644 .changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md create mode 100644 .changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md create mode 100644 .changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md create mode 100644 .changeset/changelogs/@tryghost!kg-utils@1.1.4.md delete mode 100644 .changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md create mode 100644 .changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md delete mode 100644 .changeset/eight-guests-listen.md delete mode 100644 .changeset/salty-cities-change.md delete mode 100644 .changeset/typed-logging-args.md diff --git a/.changeset/breezy-olives-brush.md b/.changeset/breezy-olives-brush.md deleted file mode 100644 index 20d462f0cb1..00000000000 --- a/.changeset/breezy-olives-brush.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@tryghost/kg-default-nodes": 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-transforms": none -"@tryghost/kg-html-to-lexical": none -"@tryghost/kg-lexical-html-renderer": none -"@tryghost/kg-markdown-html-renderer": none -"@tryghost/kg-unsplash-selector": none -"@tryghost/kg-utils": none -"@tryghost/koenig-lexical": none -"ghost-storage-base": none ---- - -Dev Dependency Updates diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md deleted file mode 100644 index a9f1b26a831..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.1.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.2.1 - -### Patch Changes - -- Fixed the test suite type-check against the typed @tryghost/logging error signature. diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md new file mode 100644 index 00000000000..fbff870d83f --- /dev/null +++ b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md @@ -0,0 +1,7 @@ +## 0.2.2 + +### Patch Changes + +- Update framework dependencies + +- Updated logging requests to avoid process crashes on redirects. diff --git a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md new file mode 100644 index 00000000000..a7e11d25ee4 --- /dev/null +++ b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md @@ -0,0 +1,5 @@ +## 0.1.2 + +### Patch Changes + +- Update framework dependencies diff --git a/.changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md b/.changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md new file mode 100644 index 00000000000..f5e66f47b75 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-card-factory@5.2.4.md @@ -0,0 +1,5 @@ +## 5.2.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md b/.changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md new file mode 100644 index 00000000000..7aa113e7681 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-clean-basic-html@4.3.4.md @@ -0,0 +1,7 @@ +## 4.3.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/changelogs/@tryghost!kg-converters@1.2.5.md b/.changeset/changelogs/@tryghost!kg-converters@1.2.5.md new file mode 100644 index 00000000000..c4c2d4e03af --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-converters@1.2.5.md @@ -0,0 +1,5 @@ +## 1.2.5 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md b/.changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md new file mode 100644 index 00000000000..0bd9ffe2541 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-cards@10.3.5.md @@ -0,0 +1,7 @@ +## 10.3.5 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md new file mode 100644 index 00000000000..25df9398a6f --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md @@ -0,0 +1,11 @@ +## 2.2.0 + +### Minor Changes + +- Update jsdom to 30 & node engines to match jsdom's + +### Patch Changes + +- Removed the unused emailCustomization and emailCustomizationAlpha feature options. + +- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md b/.changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md new file mode 100644 index 00000000000..f1a05dc1521 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-default-transforms@1.3.4.md @@ -0,0 +1,8 @@ +## 1.3.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies: + - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md b/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md new file mode 100644 index 00000000000..c80a8b4cc25 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md @@ -0,0 +1,12 @@ +## 1.4.0 + +### Minor Changes + +- Update jsdom to 30 & node engines to match jsdom's + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies: + - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md b/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md new file mode 100644 index 00000000000..bdd78e70a09 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md @@ -0,0 +1,12 @@ +## 1.5.0 + +### Minor Changes + +- Update jsdom to 30 & node engines to match jsdom's + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies: + - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md b/.changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md new file mode 100644 index 00000000000..8a331ce1fb6 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-markdown-html-renderer@7.2.4.md @@ -0,0 +1,7 @@ +## 7.2.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies diff --git a/.changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md b/.changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md new file mode 100644 index 00000000000..b9fe6f38f34 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-unsplash-selector@0.4.4.md @@ -0,0 +1,11 @@ +## 0.4.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies + +- Update jsdom to 30 & node engines to match jsdom's + +- Updated dependencies diff --git a/.changeset/changelogs/@tryghost!kg-utils@1.1.4.md b/.changeset/changelogs/@tryghost!kg-utils@1.1.4.md new file mode 100644 index 00000000000..c14982b63d4 --- /dev/null +++ b/.changeset/changelogs/@tryghost!kg-utils@1.1.4.md @@ -0,0 +1,7 @@ +## 1.1.4 + +### Patch Changes + +- Documented the package API and corrected the development instructions in the README + +- Updated dependencies diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md deleted file mode 100644 index c45d98a4d85..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.1.md +++ /dev/null @@ -1,5 +0,0 @@ -## 1.9.1 - -### Patch Changes - -- Fixed contrast text colors for light backgrounds diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md new file mode 100644 index 00000000000..43c4597d652 --- /dev/null +++ b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md @@ -0,0 +1,17 @@ +## 1.9.2 + +### Patch Changes + +- Updated the test commands in the README + +- Updated dependencies + +- Updated Koenig Lexical testing documentation. + +- Added a package description for npm + +- Updated dependencies + +- Updated dependencies + +- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/eight-guests-listen.md b/.changeset/eight-guests-listen.md deleted file mode 100644 index cd939f36102..00000000000 --- a/.changeset/eight-guests-listen.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@tryghost/kg-default-nodes": none -"@tryghost/koenig-lexical": none -"@tryghost/kg-card-factory": none -"@tryghost/kg-clean-basic-html": none -"@tryghost/kg-converters": none -"@tryghost/kg-default-cards": none -"@tryghost/kg-default-transforms": none -"@tryghost/kg-html-to-lexical": none -"@tryghost/kg-lexical-html-renderer": none -"@tryghost/kg-markdown-html-renderer": none -"@tryghost/kg-unsplash-selector": none -"@tryghost/kg-utils": none ---- - -Update dev deps diff --git a/.changeset/ledger.yaml b/.changeset/ledger.yaml index bf4eb15a2e8..2c16f4b0153 100644 --- a/.changeset/ledger.yaml +++ b/.changeset/ledger.yaml @@ -16,19 +16,46 @@ dir: packages/adapters/scheduling-base intents: - typed-logging-args +"@tryghost/adapter-base-scheduling@0.2.2": + dir: packages/adapters/scheduling-base + intents: + - open-shirts-arrive + - young-spiders-request "@tryghost/adapter-base-sso@0.1.1": dir: packages/adapters/sso-base intents: - major-areas-fail - plain-singers-cheat +"@tryghost/adapter-base-sso@0.1.2": + dir: packages/adapters/sso-base + intents: + - open-shirts-arrive +"@tryghost/kg-card-factory@5.2.4": + dir: koenig/kg-card-factory + intents: + - common-squids-argue +"@tryghost/kg-clean-basic-html@4.3.4": + dir: koenig/kg-clean-basic-html + intents: + - common-squids-argue + - six-parents-shout "@tryghost/kg-converters@1.2.4": dir: koenig/kg-converters intents: - tidy-types-check +"@tryghost/kg-converters@1.2.5": + dir: koenig/kg-converters + intents: + - common-squids-argue "@tryghost/kg-default-cards@10.3.4": dir: koenig/kg-default-cards intents: - spacy-poodles-hunt +"@tryghost/kg-default-cards@10.3.5": + dir: koenig/kg-default-cards + intents: + - common-squids-argue + - six-parents-shout "@tryghost/kg-default-nodes@2.1.4": dir: koenig/kg-default-nodes intents: @@ -38,6 +65,43 @@ intents: - spacy-poodles-hunt - warm-hotels-make +"@tryghost/kg-default-nodes@2.2.0": + dir: koenig/kg-default-nodes + intents: + - clean-flags-retire + - common-squids-argue + - six-parents-shout +"@tryghost/kg-default-transforms@1.3.4": + dir: koenig/kg-default-transforms + intents: + - common-squids-argue +"@tryghost/kg-html-to-lexical@1.4.0": + dir: koenig/kg-html-to-lexical + intents: + - common-squids-argue + - six-parents-shout +"@tryghost/kg-lexical-html-renderer@1.5.0": + dir: koenig/kg-lexical-html-renderer + intents: + - common-squids-argue + - six-parents-shout +"@tryghost/kg-markdown-html-renderer@7.2.4": + dir: koenig/kg-markdown-html-renderer + intents: + - common-squids-argue + - four-rings-relate +"@tryghost/kg-unsplash-selector@0.4.4": + dir: koenig/kg-unsplash-selector + intents: + - common-squids-argue + - petite-schools-accept + - six-parents-shout + - swift-guests-grin +"@tryghost/kg-utils@1.1.4": + dir: koenig/kg-utils + intents: + - common-squids-argue + - four-rings-relate "@tryghost/koenig-lexical@1.9.0": dir: koenig/koenig-lexical intents: @@ -46,6 +110,16 @@ dir: koenig/koenig-lexical intents: - warm-hotels-make +"@tryghost/koenig-lexical@1.9.2": + dir: koenig/koenig-lexical + intents: + - funky-bikes-chew + - funky-rice-shine + - loose-pans-argue + - olive-regions-stop + - petite-forks-lick + - petite-schools-accept + - six-parents-shout ghost-storage-base@3.0.0: dir: packages/adapters/storage-base intents: diff --git a/.changeset/salty-cities-change.md b/.changeset/salty-cities-change.md deleted file mode 100644 index 3acdbbd601c..00000000000 --- a/.changeset/salty-cities-change.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@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-default-transforms": none -"@tryghost/kg-html-to-lexical": none -"@tryghost/kg-lexical-html-renderer": none -"@tryghost/kg-markdown-html-renderer": none -"@tryghost/kg-unsplash-selector": none -"@tryghost/kg-utils": none -"@tryghost/koenig-lexical": none -"ghost-storage-base": none ---- - -Removed duplicate package license files because pnpm includes the repository root license automatically. diff --git a/.changeset/typed-logging-args.md b/.changeset/typed-logging-args.md deleted file mode 100644 index 8e3c5ec1423..00000000000 --- a/.changeset/typed-logging-args.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch ---- - -Fixed the test suite type-check against the typed @tryghost/logging error signature. diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index b20705daab9..3db93bff077 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.57.2-rc.0", + "version": "6.58.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 2b057be5420..11851f29827 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.58.0-rc.0", + "version": "6.58.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", diff --git a/koenig/kg-card-factory/package.json b/koenig/kg-card-factory/package.json index 3130f0553e8..85f18487965 100644 --- a/koenig/kg-card-factory/package.json +++ b/koenig/kg-card-factory/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-card-factory", - "version": "5.2.3", + "version": "5.2.4", "description": "Card definition factory for Ghost's Mobiledoc renderer", "repository": { "type": "git", @@ -41,10 +41,10 @@ "devDependencies": { "@eslint/js": "catalog:", "@types/node": "catalog:", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", "simple-dom": "1.4.0", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/kg-clean-basic-html/package.json b/koenig/kg-clean-basic-html/package.json index 57bc0b9d15b..23ed09992c2 100644 --- a/koenig/kg-clean-basic-html/package.json +++ b/koenig/kg-clean-basic-html/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-clean-basic-html", - "version": "4.3.3", + "version": "4.3.4", "description": "Sanitises and normalises the basic HTML snippets used in Ghost's editor cards", "repository": { "type": "git", @@ -42,10 +42,10 @@ "@eslint/js": "catalog:", "@types/jsdom": "catalog:", "@types/node": "catalog:", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", "jsdom": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/kg-converters/package.json b/koenig/kg-converters/package.json index 879c94bac4d..3d6086b5941 100644 --- a/koenig/kg-converters/package.json +++ b/koenig/kg-converters/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-converters", - "version": "1.2.4", + "version": "1.2.5", "description": "Converts between serialized Mobiledoc and Lexical formats for Ghost's editor", "repository": { "type": "git", diff --git a/koenig/kg-default-cards/package.json b/koenig/kg-default-cards/package.json index 4761277b40d..1040464bba8 100644 --- a/koenig/kg-default-cards/package.json +++ b/koenig/kg-default-cards/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-default-cards", - "version": "10.3.4", + "version": "10.3.5", "description": "Mobiledoc card definitions for Ghost's editor", "repository": { "type": "git", diff --git a/koenig/kg-default-nodes/package.json b/koenig/kg-default-nodes/package.json index 48f0d37c082..19215adc597 100644 --- a/koenig/kg-default-nodes/package.json +++ b/koenig/kg-default-nodes/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-default-nodes", - "version": "2.1.5", + "version": "2.2.0", "description": "Lexical node definitions and HTML renderers for Ghost's Koenig editor cards", "repository": { "type": "git", diff --git a/koenig/kg-default-transforms/package.json b/koenig/kg-default-transforms/package.json index 156237f56d0..059cfd20f2a 100644 --- a/koenig/kg-default-transforms/package.json +++ b/koenig/kg-default-transforms/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-default-transforms", - "version": "1.3.3", + "version": "1.3.4", "description": "Shared Lexical node transforms for Ghost's Koenig editor", "repository": { "type": "git", @@ -38,9 +38,9 @@ "@eslint/js": "catalog:", "@lexical/headless": "0.13.1", "@lexical/link": "0.13.1", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/kg-html-to-lexical/package.json b/koenig/kg-html-to-lexical/package.json index 5c77a136773..121c87339fe 100644 --- a/koenig/kg-html-to-lexical/package.json +++ b/koenig/kg-html-to-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-html-to-lexical", - "version": "1.3.3", + "version": "1.4.0", "description": "Converts HTML strings into Lexical editor state for Ghost", "repository": { "type": "git", @@ -42,9 +42,9 @@ "devDependencies": { "@eslint/js": "catalog:", "@types/jsdom": "catalog:", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/kg-lexical-html-renderer/package.json b/koenig/kg-lexical-html-renderer/package.json index 42d785d42c6..dc1083d8c73 100644 --- a/koenig/kg-lexical-html-renderer/package.json +++ b/koenig/kg-lexical-html-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-lexical-html-renderer", - "version": "1.4.3", + "version": "1.5.0", "description": "Renders serialized Lexical state to front-end or email HTML for Ghost", "repository": { "type": "git", @@ -44,10 +44,10 @@ "devDependencies": { "@eslint/js": "catalog:", "@types/jsdom": "catalog:", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", "prettier": "3.9.4", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/kg-markdown-html-renderer/package.json b/koenig/kg-markdown-html-renderer/package.json index 787c8d74362..3cb61abeaf6 100644 --- a/koenig/kg-markdown-html-renderer/package.json +++ b/koenig/kg-markdown-html-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-markdown-html-renderer", - "version": "7.2.3", + "version": "7.2.4", "description": "Markdown to HTML rendering for Ghost's markdown card", "repository": { "type": "git", @@ -43,9 +43,9 @@ "@types/markdown-it": "14.1.2", "@types/node": "catalog:", "@types/semver": "7.8.0", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/kg-unsplash-selector/package.json b/koenig/kg-unsplash-selector/package.json index b860fe4641e..3ba7a52327c 100644 --- a/koenig/kg-unsplash-selector/package.json +++ b/koenig/kg-unsplash-selector/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-unsplash-selector", - "version": "0.4.3", + "version": "0.4.4", "description": "React Unsplash image picker for Ghost's Koenig editor", "license": "MIT", "repository": { @@ -39,6 +39,7 @@ "@playwright/test": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@typescript/native": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", "autoprefixer": "10.5.4", @@ -52,7 +53,6 @@ "postcss": "catalog:", "postcss-import": "16.1.1", "tailwindcss": "catalog:tailwind3", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vite": "catalog:", diff --git a/koenig/kg-utils/package.json b/koenig/kg-utils/package.json index 6e35f8e7389..855bb179ab6 100644 --- a/koenig/kg-utils/package.json +++ b/koenig/kg-utils/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/kg-utils", - "version": "1.1.3", + "version": "1.1.4", "description": "Shared utilities for Ghost's Koenig editor packages", "repository": { "type": "git", @@ -39,9 +39,9 @@ "@eslint/js": "catalog:", "@types/node": "catalog:", "@types/semver": "7.8.0", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint-plugin-ghost": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vitest": "catalog:" diff --git a/koenig/koenig-lexical/package.json b/koenig/koenig-lexical/package.json index d899612785b..8d76827e8a4 100644 --- a/koenig/koenig-lexical/package.json +++ b/koenig/koenig-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/koenig-lexical", - "version": "1.9.1", + "version": "1.9.2", "description": "Ghost's Lexical-based rich text post editor", "repository": { "type": "git", diff --git a/packages/adapters/scheduling-base/package.json b/packages/adapters/scheduling-base/package.json index bb5fdfa5b0b..bb48004d452 100644 --- a/packages/adapters/scheduling-base/package.json +++ b/packages/adapters/scheduling-base/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/adapter-base-scheduling", - "version": "0.2.1", + "version": "0.2.2", "description": "The adapter-base-scheduling package for Ghost.", "type": "module", "repository": { diff --git a/packages/adapters/sso-base/package.json b/packages/adapters/sso-base/package.json index f9da28ada33..d5ab2d3ec00 100644 --- a/packages/adapters/sso-base/package.json +++ b/packages/adapters/sso-base/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/adapter-base-sso", - "version": "0.1.1", + "version": "0.1.2", "description": "The adapter-base-sso package for Ghost.", "type": "module", "repository": { From d3915e3646af7e4a2a7ed7bd2abd330fbb90e113 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:57:50 +0000 Subject: [PATCH 22/22] Bumped version to 6.58.1-rc.0 --- apps/ember-admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index 3db93bff077..707121fc837 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.58.0", + "version": "6.58.1-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 11851f29827..e1c26132830 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.58.0", + "version": "6.58.1-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org",