diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 3af2791d3c2..166027bc81a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -283,7 +283,7 @@ // Node.js runtime declared in `engines`, not a dependency — so a hard // version cap is the only lever. Raise it when we bump the Node engine. { - description: 'Cap @types/node at the installed Node major (engines: ^22.23.1)', + description: 'Cap @types/node at the default Node major (devEngines: 22.23.1)', matchPackageNames: ['@types/node'], allowedVersions: '<23', }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fba4ca3f909..1eed04e84aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1098,23 +1098,27 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_ghost-cli: - name: Ghost-CLI tests (${{ matrix.scenario }}) + name: Ghost-CLI tests (${{ matrix.scenario }}, Node ${{ matrix.node }}) needs: [job_setup, job_pack] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: + # Clean install boots this build end to end, so it runs on every Node + # line `engines` claims — the same list the unit / legacy / acceptance + # matrices use. Boot is the only place runtime-only breakage shows up: + # a require/import race, a removed API. The suites that stop at module + # level can all pass while Ghost fails to start. + scenario: [clean-install] + node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} include: - # This build, installed from the tarball this run produced. - - scenario: clean-install - node: ${{ needs.job_setup.outputs.node_version }} - # Upgrade from the newest Ghost on npm, which is pinned to the Node - # version whose `engines` that release declared — it can't be raised - # until a release ships supporting the newer line. Move this to - # node_version once the published release supports it. + # Upgrading from the newest Ghost on npm only works on the Node + # version that release's `engines` declared, so this leg tracks the + # default rather than the full list. It can join the matrix above + # once a published release supports the newer line. - scenario: latest-release - node: '22.23.1' + node: ${{ needs.job_setup.outputs.node_version }} steps: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: @@ -1142,7 +1146,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ghost-cli-debug-logs-${{ matrix.scenario }} + name: ghost-cli-debug-logs-${{ matrix.scenario }}-${{ matrix.node }} path: /home/runner/.ghost/logs/ - name: Clean Install diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index 74208b4d87f..2193314555b 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -46,6 +46,11 @@ "import": "./dist/utils/get-site-timezone.js", "require": "./dist/utils/get-site-timezone.cjs" }, + "./utils/recipient-filter": { + "types": "./types/utils/recipient-filter.d.ts", + "import": "./dist/utils/recipient-filter.js", + "require": "./dist/utils/recipient-filter.cjs" + }, "./vite": { "types": "./types/vite.d.ts", "import": "./dist/vite.js", @@ -79,6 +84,7 @@ "@tryghost/limit-service": "catalog:", "@tryghost/nql-string": "workspace:*", "@tryghost/shade": "workspace:*", + "@tryghost/string": "catalog:", "bson-objectid": "catalog:", "react": "catalog:", "react-dom": "catalog:", diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts index d8e3a649224..2341989fd6e 100644 --- a/apps/admin-x-framework/src/api/pages.ts +++ b/apps/admin-x-framework/src/api/pages.ts @@ -102,11 +102,15 @@ export const useEditorPage = ( export interface AddPagePayload { page: CreateContentData; options?: PostCreateOptions; + /** False when the caller handles an expired session itself instead of leaving the page. */ + sessionExpiryRedirect?: boolean; } export interface EditPagePayload { page: EditContentData; options?: PageWriteOptions; + /** False when the caller handles an expired session itself instead of leaving the page. */ + sessionExpiryRedirect?: boolean; } export const useAddPage = createMutation({ @@ -114,6 +118,7 @@ export const useAddPage = createMutation({ path: () => '/pages/', searchParams: ({ options }) => buildPageWriteParams(options), body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), + requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), invalidateQueries: { dataType }, }); @@ -122,6 +127,7 @@ export const useEditPage = createMutation({ path: ({ page }) => `/pages/${page.id}/`, searchParams: ({ options }) => buildPageWriteParams(options), body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), + requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), invalidateQueries: { dataType }, }); diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 65e8a345db7..2fab3208853 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -120,11 +120,15 @@ export const useEditorPost = ( export interface AddPostPayload { post: CreateContentData; options?: PostCreateOptions; + /** False when the caller handles an expired session itself instead of leaving the page. */ + sessionExpiryRedirect?: boolean; } export interface EditPostPayload { post: EditContentData; options?: PostWriteOptions; + /** False when the caller handles an expired session itself instead of leaving the page. */ + sessionExpiryRedirect?: boolean; } export const useAddPost = createMutation({ @@ -132,6 +136,7 @@ export const useAddPost = createMutation({ path: () => '/posts/', searchParams: ({ options }) => buildPostWriteParams(options), body: ({ post }) => ({ posts: [serializePostPayload(post)] }), + requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), invalidateQueries: { dataType }, }); @@ -140,6 +145,7 @@ export const useEditPost = createMutation({ path: ({ post }) => `/posts/${post.id}/`, searchParams: ({ options }) => buildPostWriteParams(options), body: ({ post }) => ({ posts: [serializePostPayload(post)] }), + requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), invalidateQueries: { dataType }, }); diff --git a/apps/admin-x-framework/src/api/session.ts b/apps/admin-x-framework/src/api/session.ts index ae759847e00..6647d45f5a4 100644 --- a/apps/admin-x-framework/src/api/session.ts +++ b/apps/admin-x-framework/src/api/session.ts @@ -1,7 +1,38 @@ import { createMutation } from '../utils/api/hooks'; +import { JSONError } from '../utils/errors'; + +export interface SessionCredentials { + username: string; + password: string; +} + +export interface SessionVerification { + token: string; +} + +// The server replies 201 Created with only the status text ("Created") as a text/plain body. +export const useAddSession = createMutation({ + method: 'POST', + path: () => '/session/', + body: (credentials) => credentials, +}); + +// The server replies 200 OK with only the status text ("OK") as a text/plain body; a wrong code is a bare 401. +export const useVerifySession = createMutation({ + method: 'PUT', + path: () => '/session/verify/', + body: ({ token }) => ({ token }), +}); // The server replies 204 No Content on sign-out, so the mutation resolves with no data. export const useDeleteSession = createMutation({ method: 'DELETE', path: () => '/session/', }); + +const twoFactorRequiredCodes = ['2FA_TOKEN_REQUIRED', '2FA_NEW_DEVICE_DETECTED']; + +// Sign-in created the session but the server wants an emailed code before it is usable (403). +export const isTwoFactorRequiredError = (error: unknown): error is JSONError => + error instanceof JSONError && + twoFactorRequiredCodes.includes(error.data?.errors?.[0]?.code ?? ''); diff --git a/apps/admin-x-framework/src/api/slugs.ts b/apps/admin-x-framework/src/api/slugs.ts new file mode 100644 index 00000000000..b771b27bfa0 --- /dev/null +++ b/apps/admin-x-framework/src/api/slugs.ts @@ -0,0 +1,35 @@ +import { slugify } from '@tryghost/string'; +import { useCallback } from 'react'; +import { apiUrl, useFetchApi } from '../utils/api/fetch-api'; + +export interface SlugsResponseType { + slugs: Array<{ slug: string }>; +} + +export interface GenerateSlugParams { + /** Pages share the posts table, so they dedupe under `post` */ + type: 'post' | 'tag' | 'user'; + text: string; + /** The record being edited, so its own current slug is not counted as a collision */ + id?: string; +} + +export const useGenerateSlug = () => { + const fetchApi = useFetchApi(); + + return useCallback( + async ({ type, text, id }: GenerateSlugParams): Promise => { + if (!text) { + return ''; + } + + // Slugified client-side first: raw reserved characters in the path (a newline as %0A) 404 at the CDN before reaching Ghost + const name = encodeURIComponent(slugify(text)); + const path = id ? `/slugs/${type}/${name}/${id}/` : `/slugs/${type}/${name}/`; + const data = await fetchApi(apiUrl(path)); + + return data.slugs[0].slug; + }, + [fetchApi], + ); +}; diff --git a/apps/admin-x-framework/src/string.d.ts b/apps/admin-x-framework/src/string.d.ts new file mode 100644 index 00000000000..860f32f8d5d --- /dev/null +++ b/apps/admin-x-framework/src/string.d.ts @@ -0,0 +1,3 @@ +declare module '@tryghost/string' { + export function slugify(string: string, options?: { requiredChangesOnly?: boolean }): string; +} diff --git a/apps/admin-x-framework/src/utils/api/fetch-api.ts b/apps/admin-x-framework/src/utils/api/fetch-api.ts index 71e7008f348..ce7ab47ce2b 100644 --- a/apps/admin-x-framework/src/utils/api/fetch-api.ts +++ b/apps/admin-x-framework/src/utils/api/fetch-api.ts @@ -23,6 +23,8 @@ export interface RequestOptions { retry?: boolean; /** Resolve the raw response body instead of parsing it as JSON/text */ responseType?: ResponseType; + /** False leaves the caller to handle `SessionExpiredError` instead of leaving the page. */ + sessionExpiryRedirect?: boolean; onUploadProgress?: (progress: number) => void; } @@ -172,6 +174,7 @@ export const useFetchApi = () => { timeout, retry = true, responseType, + sessionExpiryRedirect = true, onUploadProgress, }: RequestOptions = {}, ): Promise => { @@ -265,7 +268,9 @@ export const useFetchApi = () => { } if (error instanceof UnauthorizedError && isSessionExpiry(endpoint)) { - redirectOnSessionExpiry(); + if (sessionExpiryRedirect) { + redirectOnSessionExpiry(); + } throw new SessionExpiredError(error.response!, error.data, { cause: error }); } diff --git a/apps/admin-x-framework/src/utils/api/hooks.ts b/apps/admin-x-framework/src/utils/api/hooks.ts index 087115b95c8..5f9552695e3 100644 --- a/apps/admin-x-framework/src/utils/api/hooks.ts +++ b/apps/admin-x-framework/src/utils/api/hooks.ts @@ -172,6 +172,8 @@ interface MutationOptions headers?: Record; body?: (payload: Payload) => FormData | object; searchParams?: (payload: Payload) => { [key: string]: string }; + /** Per-payload transport options, merged over the ones declared on the hook. */ + requestOptions?: (payload: Payload) => Omit; invalidateQueries?: | { dataType: string | string[] } | { @@ -198,7 +200,7 @@ const mutate = ({ searchParams?: Record; options: Omit, 'path'>; }) => { - const { defaultSearchParams, body, ...requestOptions } = options; + const { defaultSearchParams, body, requestOptions, ...staticOptions } = options; const url = apiUrl(path, searchParams || defaultSearchParams); const generatedBody = payload && body?.(payload); @@ -211,7 +213,8 @@ const mutate = ({ return fetchApi(url, { body: requestBody, - ...requestOptions, + ...staticOptions, + ...(payload === undefined ? {} : requestOptions?.(payload)), }); }; diff --git a/apps/admin-x-framework/test/unit/api/session.test.tsx b/apps/admin-x-framework/test/unit/api/session.test.tsx index 2729f147082..70bc6d5464d 100644 --- a/apps/admin-x-framework/test/unit/api/session.test.tsx +++ b/apps/admin-x-framework/test/unit/api/session.test.tsx @@ -1,10 +1,187 @@ import { act } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { renderHookWithProviders } from '../../../src/test/test-utils'; -import { useDeleteSession } from '../../../src/api/session'; +import { + isTwoFactorRequiredError, + useAddSession, + useDeleteSession, + useVerifySession, +} from '../../../src/api/session'; +import { + JSONError, + SessionExpiredError, + UnauthorizedError, + ValidationError, +} from '../../../src/utils/errors'; import { withMockFetch } from '../../utils/mock-fetch'; +const passwordIncorrectResponse = { + errors: [ + { + code: 'PASSWORD_INCORRECT', + context: 'Your password is incorrect.', + details: null, + ghostErrorCode: null, + help: 'Visit and save your profile after logging in to check for problems.', + id: 'session-error-id', + message: 'Your password is incorrect.', + property: null, + type: 'ValidationError', + }, + ], +}; + +const twoFactorRequiredResponse = { + errors: [ + { + code: '2FA_TOKEN_REQUIRED', + context: + 'A 6-digit sign-in verification code has been sent to your email to keep your account safe.', + details: null, + ghostErrorCode: null, + help: null, + id: 'session-error-id', + message: 'User must verify session to login.', + property: null, + type: 'Needs2FAError', + }, + ], +}; + describe('session api', () => { + it('signs in via POST with the username/password body and resolves the 201', async () => { + await withMockFetch( + { status: 201, headers: { 'content-type': 'text/plain; charset=utf-8' } }, + async (mock) => { + const { result } = renderHookWithProviders(() => useAddSession()); + + await act(async () => { + await expect( + result.current.mutateAsync({ username: 'owner@example.com', password: 'hunter22' }), + ).resolves.not.toThrow(); + }); + + expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/session/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(mock.calls[0][1].credentials).toBe('include'); + expect(mock.calls[0][1].headers['content-type']).toBe('application/json'); + expect(JSON.parse(mock.calls[0][1].body)).toEqual({ + username: 'owner@example.com', + password: 'hunter22', + }); + }, + ); + }); + + it('surfaces the 403 two-factor response as a detectable JSONError', async () => { + await withMockFetch( + { + json: twoFactorRequiredResponse, + headers: { 'content-type': 'application/json' }, + ok: false, + status: 403, + }, + async () => { + const { result } = renderHookWithProviders(() => useAddSession()); + + let error: unknown; + await act(async () => { + try { + await result.current.mutateAsync({ username: 'owner@example.com', password: 'x' }); + } catch (caught) { + error = caught; + } + }); + + expect(error).toBeInstanceOf(JSONError); + expect(isTwoFactorRequiredError(error)).toBe(true); + }, + ); + }); + + it('does not treat missing credentials (401) as a two-factor prompt or a session expiry', async () => { + await withMockFetch({ status: 401, ok: false }, async () => { + const { result } = renderHookWithProviders(() => useAddSession()); + + let error: unknown; + await act(async () => { + try { + await result.current.mutateAsync({ username: 'owner@example.com', password: '' }); + } catch (caught) { + error = caught; + } + }); + + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error).not.toBeInstanceOf(SessionExpiredError); + expect(isTwoFactorRequiredError(error)).toBe(false); + }); + }); + + it('surfaces a wrong password as a 422 ValidationError carrying PASSWORD_INCORRECT', async () => { + await withMockFetch( + { + json: passwordIncorrectResponse, + headers: { 'content-type': 'application/json' }, + ok: false, + status: 422, + }, + async () => { + const { result } = renderHookWithProviders(() => useAddSession()); + + let error: unknown; + await act(async () => { + try { + await result.current.mutateAsync({ username: 'owner@example.com', password: 'x' }); + } catch (caught) { + error = caught; + } + }); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).data?.errors[0].code).toBe('PASSWORD_INCORRECT'); + expect((error as ValidationError).message).toBe('Your password is incorrect.'); + expect(isTwoFactorRequiredError(error)).toBe(false); + }, + ); + }); + + it('verifies via PUT with the token body and resolves the 200', async () => { + await withMockFetch( + { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' } }, + async (mock) => { + const { result } = renderHookWithProviders(() => useVerifySession()); + + await act(async () => { + await expect(result.current.mutateAsync({ token: '123456' })).resolves.not.toThrow(); + }); + + expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/session/verify/'); + expect(mock.calls[0][1].method).toBe('PUT'); + expect(mock.calls[0][1].credentials).toBe('include'); + expect(JSON.parse(mock.calls[0][1].body)).toEqual({ token: '123456' }); + }, + ); + }); + + it('rejects a wrong verification code (bare 401) without redirecting as a session expiry', async () => { + await withMockFetch({ status: 401, ok: false }, async () => { + const { result } = renderHookWithProviders(() => useVerifySession()); + + let error: unknown; + await act(async () => { + try { + await result.current.mutateAsync({ token: '000000' }); + } catch (caught) { + error = caught; + } + }); + + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error).not.toBeInstanceOf(SessionExpiredError); + }); + }); + it('signs out via DELETE and resolves the 204 with no data', async () => { await withMockFetch({ status: 204 }, async (mock) => { const { result } = renderHookWithProviders(() => useDeleteSession()); diff --git a/apps/admin-x-framework/test/unit/api/slugs.test.tsx b/apps/admin-x-framework/test/unit/api/slugs.test.tsx new file mode 100644 index 00000000000..f4c828b7433 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/slugs.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { useGenerateSlug } from '../../../src/api/slugs'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const slugResponse = { + json: { slugs: [{ slug: 'hello-world-cafe-quotes-1-100-2' }] }, + headers: { 'content-type': 'application/json' }, +}; + +describe('slugs api', () => { + it('slugifies and encodes the text client-side, then returns the deduplicated server slug', async () => { + await withMockFetch(slugResponse, async (mock) => { + const { result } = renderHookWithProviders(() => useGenerateSlug()); + + const slug = await result.current({ + type: 'post', + text: 'Hello World! Café & "Quotes" #1 / 100%', + }); + + expect(mock.calls[0][0]).toBe( + 'http://localhost:3000/ghost/api/admin/slugs/post/hello-world-cafe-quotes-1-100/', + ); + expect(mock.calls[0][1].method).toBe('GET'); + expect(slug).toBe('hello-world-cafe-quotes-1-100-2'); + }); + }); + + it('strips characters that would otherwise be sent as raw path escapes', async () => { + await withMockFetch(slugResponse, async (mock) => { + const { result } = renderHookWithProviders(() => useGenerateSlug()); + + await result.current({ type: 'post', text: 'Line one\nLine two' }); + await result.current({ type: 'post', text: ' Ünïcode — dash… 50% off? ' }); + + expect(mock.calls[0][0]).toBe( + 'http://localhost:3000/ghost/api/admin/slugs/post/line-oneline-two/', + ); + expect(mock.calls[1][0]).toBe( + 'http://localhost:3000/ghost/api/admin/slugs/post/unicode-dash-50-off/', + ); + }); + }); + + it('appends the record id so its own slug is not counted as a collision', async () => { + await withMockFetch(slugResponse, async (mock) => { + const { result } = renderHookWithProviders(() => useGenerateSlug()); + + await result.current({ type: 'post', text: 'Hello world', id: '64f1c0ffee0000000000abcd' }); + + expect(mock.calls[0][0]).toBe( + 'http://localhost:3000/ghost/api/admin/slugs/post/hello-world/64f1c0ffee0000000000abcd/', + ); + }); + }); + + it('resolves an empty slug for empty text without calling the API', async () => { + await withMockFetch(slugResponse, async (mock) => { + const { result } = renderHookWithProviders(() => useGenerateSlug()); + + await expect(result.current({ type: 'post', text: '' })).resolves.toBe(''); + + expect(mock.calls).toHaveLength(0); + }); + }); +}); diff --git a/apps/admin/package.json b/apps/admin/package.json index 6329a8405ba..3997f305753 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -51,6 +51,7 @@ "i18n-iso-countries": "7.14.0", "jszip": "3.10.1", "lucide-react": "catalog:", + "microdiff": "catalog:", "mingo": "catalog:", "moment": "catalog:", "moment-timezone": "catalog:", diff --git a/apps/admin/src/editor/card-config.test.ts b/apps/admin/src/editor/card-config.test.ts index 03f6af18683..d67dead9831 100644 --- a/apps/admin/src/editor/card-config.test.ts +++ b/apps/admin/src/editor/card-config.test.ts @@ -90,7 +90,7 @@ describe('getCardVisibilitySettings', () => { }); describe('buildPostCardConfig', () => { - it('assembles the Ember editor card config', () => { + it('assembles the post editor card config', () => { const cardConfig = buildPostCardConfig(sources(), ports); expect(cardConfig).toMatchObject({ diff --git a/apps/admin/src/editor/editor-save.acceptance.test.tsx b/apps/admin/src/editor/editor-save.acceptance.test.tsx new file mode 100644 index 00000000000..54ed7132171 --- /dev/null +++ b/apps/admin/src/editor/editor-save.acceptance.test.tsx @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + currentRoute, + fakeAdminEndpoint, + fakePosts, + fakeSnippets, + post, + renderAdminApp, + type EndpointCapture, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; +import { OLD_SCHEMA_CORPUS } from '@/editor/engine/__fixtures__'; + +const POST_ID = 'abc123'; +const NEW_POST_ID = 'new789'; +const FLAG_ON = { labs: { editorReact: true } }; +const LOADED_AT = '2026-01-01T00:00:00.000Z'; + +// The autosave debounce is 3s, so these journeys outlast the default timeout. +const SLOW = 20_000; +const SAVE_POLL = { timeout: 10_000 }; + +type SavedPost = ReturnType; + +function submittedPost(capture: EndpointCapture): Record { + const body = capture.lastRequest?.body as { posts: Record[] }; + return body.posts[0]; +} + +function editorChrome() { + fakeSnippets([]); + fakePosts([]); +} + +/** + * A post that answers saves the way Ghost does: the response carries the + * submitted fields back with a fresh collision token, and the read endpoint + * serves whatever was saved last. + */ +function fakeSavablePost(overrides: Partial = {}) { + editorChrome(); + let current = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + published_at: null, + tags: [], + ...overrides, + }); + let saves = 0; + + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), () => ({ posts: [current] })); + + const saveApi = fakeAdminEndpoint('PUT', new RegExp(`^/posts/${POST_ID}/\\?`), ({ body }) => { + saves += 1; + const submitted = (body as { posts: Partial[] }).posts[0]; + current = { ...current, ...submitted, updated_at: `2026-01-01T00:00:0${saves}.000Z` }; + return { posts: [current] }; + }); + + return saveApi; +} + +async function typeIntoBody(text: string) { + await editorScreen.body().click(); + await userEvent.keyboard(`{End}${text}`); +} + +function bodyElement(): Element | null { + return document.querySelector('[data-testid="editor-body"]'); +} + +/** + * The React post editor's save engine wired to the API: body edits autosave, + * a new post is created on its first edit, and a rejected save surfaces in + * place instead of losing what was typed. + */ +describe('Post editor saving', () => { + it( + 'autosaves the body and sends the write contract', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + const url = saveApi.lastRequest?.url ?? ''; + expect(url).toContain('formats=mobiledoc%2Clexical'); + expect(url).toContain('include=tags%2Cauthors'); + // A background save never asks the server for a revision. + expect(url).not.toContain('save_revision'); + + expect(submittedPost(saveApi)).toMatchObject({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + updated_at: LOADED_AT, + }); + expect(String(submittedPost(saveApi).lexical)).toContain('Hello from React and more'); + }, + SLOW, + ); + + it( + 'stays clean once the save has been acknowledged and refetched', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + await editorScreen.titleInput().click(); + await editorScreen.body().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + }, + SLOW, + ); + + it( + 'leaves an old-schema post alone until it is edited', + async () => { + const legacy = OLD_SCHEMA_CORPUS.find(({ name }) => name === 'legacy-text-nodes'); + const saveApi = fakeSavablePost({ lexical: JSON.stringify(legacy?.before) }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toBeVisible(); + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(0); + + await typeIntoBody(' edited'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + }, + SLOW, + ); + + it( + 'creates a new post on the first edit and swaps the URL without remounting', + async () => { + editorChrome(); + fakeAdminEndpoint('GET', /^\/slugs\/post\/untitled\//, { slugs: [{ slug: 'untitled' }] }); + let created = post({ + id: NEW_POST_ID, + title: '(Untitled)', + slug: 'untitled', + status: 'draft', + updated_at: LOADED_AT, + published_at: null, + tags: [], + }); + const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + created = { ...created, ...submitted, id: NEW_POST_ID, updated_at: LOADED_AT }; + return { posts: [created] }; + }); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${NEW_POST_ID}/\\?`), () => ({ + posts: [created], + })); + fakeAdminEndpoint('PUT', new RegExp(`^/posts/${NEW_POST_ID}/\\?`), () => ({ + posts: [created], + })); + + await renderAdminApp('/editor/post', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + const mountedBody = bodyElement(); + + await typeIntoBody('First words'); + + await expect.poll(() => createApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(createApi)).toMatchObject({ title: '(Untitled)', slug: 'untitled' }); + expect(submittedPost(createApi).id).toBeUndefined(); + + await expect.poll(currentRoute).toBe(`/editor/post/${NEW_POST_ID}`); + expect(bodyElement()).toBe(mountedBody); + await expect.element(editorScreen.body()).toHaveTextContent('First words'); + }, + SLOW, + ); + + it( + 'halts on a collision and keeps the content', + async () => { + editorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [ + post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + tags: [], + }), + ], + }); + const saveApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + { + errors: [ + { + code: 'UPDATE_COLLISION', + type: 'UpdateCollisionError', + message: 'Saving failed! Someone else is editing this post.', + }, + ], + }, + { status: 409 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + + await expect + .element(editorScreen.conflictBanner()) + .toHaveTextContent('Someone else is editing this post'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and more'); + + await typeIntoBody(' again'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + await expect.element(editorScreen.body()).toHaveTextContent('and more again'); + }, + SLOW, + ); + + it( + 'offers a retry in place when the session expired', + async () => { + editorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [ + post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + tags: [], + }), + ], + }); + const saveApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }] }, + { status: 401 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + + await expect.element(editorScreen.reauthBanner()).toHaveTextContent('Your session expired'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and more'); + expect(currentRoute()).toBe(`/editor/post/${POST_ID}`); + + await editorScreen.retryReauth().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(2); + }, + SLOW, + ); +}); diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index cf1f5436ef3..3f1f3d7177a 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -28,6 +28,8 @@ import { } from '@tryghost/admin-x-framework/api/users'; import type { CardConfigPostSource, PostType } from './card-config'; import { PostEditor } from './post-editor'; +import { SessionBanners } from './session/session-banners'; +import { useEditorSession, useEditorSessionKey } from './session/use-editor-session'; import { usePostCardConfig } from './use-post-card-config'; import { usePostSnippets } from './use-post-snippets'; @@ -70,10 +72,7 @@ function EditorHeader({ postType }: { postType: PostType }) { function EditorSurface({ postType, record }: { postType: PostType; record?: EditorRecord }) { const { data: currentUser } = useCurrentUser(); const showExcerpt = useFeatureFlag('editorExcerpt'); - const [title, setTitle] = useState(() => - record?.title === '(Untitled)' ? '' : (record?.title ?? ''), - ); - const [excerpt, setExcerpt] = useState(() => record?.custom_excerpt ?? ''); + const session = useEditorSession({ postType, record }); const canManageSnippets = !!currentUser && @@ -104,17 +103,18 @@ function EditorSurface({ postType, record }: { postType: PostType; record?: Edit return ( +
{snippetDialog} @@ -167,15 +167,17 @@ function useLexicalConversion(postType: PostType) { return { state, convert }; } -function ExistingPostEditor({ postType, id }: { postType: PostType; id: string }) { +function EditorLoader({ postType, id }: { postType: PostType; id?: string }) { + // A create replaces the URL with the id it acquired; the load must not restart. + const [openedId] = useState(id); const navigate = useNavigate(); const { data: currentUser } = useCurrentUser(); - const postQuery = useEditorPost(id, { - enabled: postType === 'post', + const postQuery = useEditorPost(openedId ?? '', { + enabled: postType === 'post' && !!openedId, defaultErrorHandler: false, }); - const pageQuery = useEditorPage(id, { - enabled: postType === 'page', + const pageQuery = useEditorPage(openedId ?? '', { + enabled: postType === 'page' && !!openedId, defaultErrorHandler: false, }); const query = postType === 'page' ? pageQuery : postQuery; @@ -198,6 +200,10 @@ function ExistingPostEditor({ postType, id }: { postType: PostType; id: string } } }, [needsConversion, loaded, conversion?.id, convert]); + if (!openedId) { + return ; + } + const notFound = query.error instanceof APIError && query.error.response?.status === 404; if (notFound) { return ; @@ -240,11 +246,12 @@ function ExistingPostEditor({ postType, id }: { postType: PostType; id: string } record = converted.record; } - return ; + return ; } export default function EditorScreen() { const editorPath = useParams()['*'] ?? ''; + const sessionKey = useEditorSessionKey(); const [typeSegment, id, ...rest] = editorPath.split('/').filter(Boolean); if (!typeSegment) { @@ -255,9 +262,5 @@ export default function EditorScreen() { return ; } - if (id) { - return ; - } - - return ; + return ; } diff --git a/apps/admin/src/editor/editor.acceptance.test.tsx b/apps/admin/src/editor/editor.acceptance.test.tsx index 04b392fc7ea..b73d5e830f7 100644 --- a/apps/admin/src/editor/editor.acceptance.test.tsx +++ b/apps/admin/src/editor/editor.acceptance.test.tsx @@ -13,15 +13,8 @@ const FLAG_ON = { labs: { editorReact: true } }; const FLAG_OFF = { labs: { editorReact: false } }; /** - * Proves the `editorReact` flag swap end-to-end in the real admin app: the - * React editor appears only when the flag is on, and the Ember side of the - * URL is delegated to otherwise. - * - * There is no Ember app in this harness, so "Ember serves it" shows up as the - * React editor being absent rather than as an Ember editor being present — - * the Ember half of the handshake (the lexical-editor route aborting its - * transition) is covered in - * apps/ember-admin/tests/acceptance/editor-react-flag-test.js. + * The editor route mounts only when its feature flag is enabled. Disabled and + * missing flags leave it unmounted. */ describe('Editor flag', () => { function fakeEditorWorld() { @@ -54,13 +47,13 @@ describe('Editor flag', () => { await expect.element(editorScreen.backLink('page')).toHaveAttribute('href', '#/pages'); }); - it('defers to Ember when the flag is off', async () => { + it('does not mount the editor when the flag is off', async () => { await renderAdminApp('/editor/post/abc123', FLAG_OFF); await expect(editorScreen.root()).toHaveCount(0); }); - it('defers to Ember when the flag is absent entirely', async () => { + it('does not mount the editor when the flag is absent', async () => { await renderAdminApp('/editor/post/abc123'); await expect(editorScreen.root()).toHaveCount(0); diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 28c0201c3cf..4c942a10501 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -1,8 +1,10 @@ import { page } from 'vitest/browser'; import { editorBody, + editorConflictBanner, editorExcerptInput, editorLoadError, + editorReauthBanner, editorSecondaryInstance, editorTitleInput, editorWordCount, @@ -22,6 +24,9 @@ export const editorScreen = { secondaryInstance: () => page.getByTestId(editorSecondaryInstance), wordCount: () => page.getByTestId(editorWordCount), loadError: () => page.getByTestId(editorLoadError), + reauthBanner: () => page.getByTestId(editorReauthBanner), + retryReauth: () => page.getByTestId(editorReauthBanner).getByRole('button', { name: 'Retry' }), + conflictBanner: () => page.getByTestId(editorConflictBanner), notFound: () => page.getByRole('heading', { name: 'Page not found' }), titleTkIndicator: () => page.getByTestId(tkIndicator), backLink: (postType: 'post' | 'page') => diff --git a/apps/admin/src/editor/engine/README.md b/apps/admin/src/editor/engine/README.md new file mode 100644 index 00000000000..04cdde3b548 --- /dev/null +++ b/apps/admin/src/editor/engine/README.md @@ -0,0 +1,351 @@ +# Editor engine + +`apps/admin/src/editor/engine/` holds the pure, React-free modules behind the React post editor: none of them import React or the network, and every side effect goes through an injected port. This file describes each module's behavior and what callers own. + +## Save engine + +`save-engine.ts` is a single-flight queue over typed save intents with one coalescing pending slot, a prepare stage, typed outcomes, and a leave decision. It combines restartable and timed autosaves, field saves, explicit saves, leave saves, and status transitions without ever letting two requests overlap. + +### Intents + +| Intent | Trigger | Debounce | `save_revision` | Changes status? | +| --------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `autosave` | body change; a new post's first edit fires immediately | 3s restartable (none for new posts) | no | never; drafts only, pinned to `draft` | +| `timed` | armed by an autosave dispatch, fires after 60s of continuous editing | 60s cycle | no | never; drafts only | +| `field` | title blur, excerpt blur, feature-image change | none | no | never; drafts only. On a published/scheduled/sent post it is dropped with reason `not-draft`: the sidebar stages those edits until Update | +| `explicit` | Cmd-S / Save / Update | none | yes | never; preserves the current status (a past-scheduled post saves as `scheduled`, the server owns that transition) | +| `leave` | navigating away from a dirty draft with unrevisioned changes or an armed autosave | none | yes | never; preserves the current status | +| `publish` / `schedule` / `revert` | the publish flow | none | no | the only status-changing commands; each carries an explicit target | + +### Commands + +`dispatch(kind, options?)` captures an immutable `SaveCommand` (`{kind, target?, requiresRevision, requiresReconfirmation}`) at dispatch time. Status commands derive their `target` from the source transition when captured and never re-derive it from a later snapshot, so a response resync cannot turn a queued schedule into a publish: + +| Command | Target | +| -------------------------------- | ------------------------------------------------------------------------ | +| `publish` | `published`; keeps the post's publish time unless `publishedAt` is given | +| `schedule` | `scheduled` at the given time | +| `revert` from `scheduled` | `draft`, publish time cleared, `emailOnly: false` | +| `revert` from `published`/`sent` | `draft`, publish time kept as history, `emailOnly: false` | + +Email extras (`newsletter`, `emailSegment`, `emailOnly`) ride on exactly that command's request. A failed status command is disarmed: nothing retains its target, the publish flow dispatches a fresh command. Publish times are serialized with zeroed milliseconds. + +### Queue semantics + +One save in flight, one pending slot. A command arriving while idle runs immediately (after its debounce); one arriving during a save lands in the pending slot and coalesces: priority `publish`/`schedule`/`revert` > `explicit` > `leave` > `field` > `timed` > `autosave`, the winner's kind executes, every waiter keeps its own command, `requiresRevision` ORs across the slot, and the payload is rebuilt from the current post at execution, so coalescing never loses newer content. A later status command supersedes only the earlier status command; its riders stay with the winner. A new autosave restarts the debounce; an explicit cancels it and carries its waiters. + +Every dispatch settles with a typed `SaveCompletion`: + +| Completion | Meaning | +| -------------------------------- | ----------------------------------------------------------------------------------------- | +| `saved` (`result`, `executedAs`) | the save that carried this command's content landed; `executedAs` names the kind that ran | +| `failed` (`error`, `executedAs`) | typed error; content stays dirty | +| `dropped` (`reason`) | `not-draft`, `clean`, `suppressed`, `conflict`, `halted`, `disposed` | +| `superseded` (`by`) | a later status command replaced this one before it ran | +| `needs-retry` | re-auth interrupted a status-changing command; the publish flow re-confirms | + +### Lifecycle + +`capture → prepare → execute → reconcile → drain`, all inside the single-flight unit: + +1. **Prepare** awaits pending manual slug work (`slug.settled()`), re-reads the snapshot, substitutes `(Untitled)` for a blank or whitespace title, asks `slug.fromTitle()` for every draft save and whenever the post has no slug (the port answers `generated` or `unchanged`; after an `unchanged` answer the slug current at that moment is sent), re-reads the snapshot again after the answer and re-runs the drop rules on it, resolves the target, then hands the `SaveRequest` to the caller's `prepare()` to build and validate the candidate. IO starts only after prepare settles; a rejected prepare is a typed failure with no request sent, and a save disposed during slug work is never prepared. +2. **Execute** is IO only and returns a typed `SaveOutcome`; its `AbortSignal` is aborted on `dispose()`, and a response arriving after dispose is never reconciled. +3. **Reconcile** is awaited before the pending slot drains and must not throw: adopt the acknowledged id, status and `updated_at` first, keep edits made after `prepared.snapshot.version`, resync server-normalized values only where the local value did not change in flight. +4. **Drain** starts the pending slot only when nothing is in flight. + +Reconcile-before-drain is a hard ordering contract because the server enforces optimistic concurrency on posts: any post update whose `updated_at` differs from the persisted one is rejected with `UPDATE_COLLISION` when a meaningful field changed. A queued save built from the pre-response snapshot carries the superseded `updated_at` and is rejected. The persisted snapshot type therefore requires `updatedAt` alongside `id`. + +### Errors and states + +| Error kind | State | Exit | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `session-invalid` | `reauth-pending`; queue frozen, later commands coalesce into the pending slot, content untouched | `reauthSucceeded()` / `reauthAbandoned()` | +| `not-found` with an id | `halted` (deleted elsewhere); every queued command dropped `halted`, content kept for copy-out | none | +| `not-found` without an id | `crashed` (corrupt new-post state) | none | +| `conflict` (`UPDATE_COLLISION`) | `conflict`; timers and the pending slot dropped `conflict`, background saves refused while the snapshot still carries the rejected `updated_at`, content intact and dirty | an explicit save, or a snapshot whose `updatedAt` no longer matches the rejected one (reload) | +| `validation` | `error`; background saves suppressed until the snapshot version moves | next edit, or an explicit save | +| `host-limit` | `error`; suppression as for validation, but only for a status-preserving save (a publish limit never halts autosave) | next edit, or an explicit save | +| `transport` / `unknown` | `error`, no suppression | next save | + +`error` and `conflict` persist until a save actually starts; timers arming or a dropped save do not clear them. Other states: `idle`, `debouncing`, `saving`, `pending-coalesced`, `disposed`. + +### Re-auth + +`reauthSucceeded()` inspects every waiter in both the frozen and the pending slot and judges each by its resolved effect against the current post: a command whose target would change the status resolves `needs-retry` and never auto-fires; everything else is coalesced into the pending slot with its own command and drained, without re-debouncing, so a frozen explicit rider re-runs while the publish it coalesced into does not. Content a disarmed status command would have carried resumes through the autosave path; if the snapshot cannot be read at that point the debounce is re-armed so the retry surfaces a failure instead of abandoning content. `reauthAbandoned()` settles every waiter with the session error and moves to `error`; the caller decides on a sign-in redirect, the queue never dangles. + +### Leave + +`leaveRequested()` returns `proceed` or `confirm` and loops until nothing is in flight, pending, or armed with dirty content, re-reading the post after every wait: `saving` is never safe to leave. Save-on-leave (with a revision) fires at most once per attempt, only for a dirty draft with unrevisioned changes or an armed autosave, never while the snapshot carries a rejected `updated_at`, never while frozen, halted, or crashed. A post still dirty afterwards asks for confirmation, and so does an unreadable snapshot. Concurrent calls share one decision; a decision that outlives the engine resolves `proceed`. + +### Subscriptions + +`subscribe()` suits `useSyncExternalStore`: emissions are deduplicated, listeners receive the emitted value, a throwing `onStateChange` port or subscriber is reported through `onListenerError` without interrupting the save, and a nested transition ends the outer pass so no listener sees an out-of-order state. + +## Change tracker + +`change-tracker.ts` + `lexical-compare.ts`. Answers one question for the editor: does the live post differ from what is persisted, and why. Pure, React-free; the editor's hidden second Koenig instance supplies the baseline. + +### State model + +Three documents: **saved** (last persisted state, from load/refetch/acknowledged save), **baseline** (the hidden instance's post-load serialization — the document after Lexical's load-time transforms), **live** (the visible editor). Body verdict: dirty ⇔ live differs from saved **and** from baseline. Baseline readiness is separate from its value: `pending` (not reported yet), `ready` (a known document; `null`, `''`, and an empty root are all known-empty), `failed`. A live edit while pending is dirty (`BASELINE_PENDING`, fail closed); a failed baseline falls back to live-vs-saved (`BASELINE_FAILED`) and never disables body protection. Title, ordered tag names, and the editable attributes contribute their own dirty bits. Stable reason codes identify each cause: `POST_HAS_ERROR`, `POST_TAGS_DIVERGED`, `POST_TITLE_DIVERGED`, `SCRATCH_DIVERGED_FROM_SECONDARY`, `NEW_POST_HAS_CHANGED_ATTRIBUTES`, `POST_HAS_DIRTY_ATTRIBUTES`, `BASELINE_PENDING`, `BASELINE_FAILED`, `LEXICAL_PARSE_FAILED` (malformed or structurally invalid Lexical is dirty, never a thrown route blocker). + +### API (id-first; events for another post are dropped) + +| Method | Meaning | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `load(postId, post)` | Reset for a post; `postId` is `null` for a new post until the create is acknowledged | +| `setSaved(postId, post)` | Query refetch only. Never re-baselines. Dropped entirely if its `updated_at` is older than the held one | +| `saveAcknowledged(postId, submitted, acknowledged)` | The response of a successful save. Three-way rebase per key: base = submitted value (or previous saved when omitted); live still equal to base adopts the server value, otherwise the later edit wins. Always adopts `acknowledged.updated_at`; re-baselines to the acknowledged body. A create acknowledgement passes the created id — the projection carries none | +| `setBaseline(postId, lexical)` / `baselineFailed(postId, error)` | The hidden instance's report | +| `setLive(postId, patch)` | Patch semantics; `updated_at` in a patch is ignored | +| `revisionRestored(postId, projection)` | After a restore has been saved: adopts body, title, custom excerpt, feature image + alt + caption into saved and live atomically; baseline goes pending until the hidden instance re-reports | +| `markSaveError()` / `clearSaveError()` | A failed save keeps the post dirty until an acknowledged save | +| `verdict({includeDiff?})` | `{dirty, reasons[, diff]}` | +| `hasChangedSinceRevision(latest)` | Compares against a revision projection (body, title, custom excerpt, feature image), body compared semantically | +| `dispose()` | Inert thereafter | + +After a create acknowledgement promotes `null → id`, `null` is accepted as an alias on `setLive`/`setBaseline`/`baselineFailed` until the next `load()`/`dispose()`, so keystrokes between the acknowledgement and the caller's id swap are not dropped. While the id is still `null`, an acknowledgement for an id this tracker has already held is refused (a stale completion from a previous post). + +### Normalization + +Element-node `direction` is stripped recursively before compare (Lexical's reconciler infers it per environment). Site URLs are normalized to relative only on URL-typed properties: the `url` property of any node outside the card map, and for cards the properties the server rewrites on save (image/gallery/audio/video/file sources, bookmark url + icon/thumbnail, button/header/email-cta urls, product image, embed url), tolerant of trailing-slash and subdirectory site URLs; prose, code, html, markdown, captions, and opaque card payloads are never rewritten, so a deleted literal site URL stays dirty. Snapshots are cloned at ingress; tags compare as ordered name arrays. + +## Slug machine + +`slug-machine.ts` owns slug intent for one post: it derives +a slug from the title, accepts manual slug edits, sends both through the +server's slug endpoint for sanitizing and deduplication, and reports the outcome +as proposals. It never persists anything; the caller owns the input UI and the +save. + +### State model + +Two modes and three statuses. + +| Mode | Meaning | +| --------- | ----------------------------------------------------------------- | +| `derived` | The slug follows the title. Eligible title commits regenerate it. | +| `custom` | The slug belongs to the user. Title commits never touch it. | + +Mode is `custom` while a manual edit that can still apply is in flight. +Otherwise it is the settled mode, which changes only when a post loads or a +manual edit applies. A manual edit that fails, returns nothing, or resolves back +to the current slug leaves the settled mode as it was. Mode is never re-derived +from the title while a post is open; only loading a post runs the custom +detection described under Rules. + +| Status | Meaning | +| --------- | ---------------------------------------------------------------------------------------------------------------- | +| `custom` | Mode is `custom`. | +| `derived` | Mode is `derived` and the last committed title would generate. | +| `frozen` | Mode is `derived` but the last committed title would not generate: it is blank, or `(Untitled)` with a slug set. | + +Status follows the latest committed title regardless of what happened to that +commit: a post whose blank title was just committed reads `frozen`, and a post +whose commit failed reads `derived`. + +`getState()` returns `{status, mode, slug, title, lastCommittedTitle, pending}`. + +- `slug`: the current slug. +- `title`: the title the slug was loaded with or last generated from. Only a + load or an applied generation advances it, so a refused or failed commit can + be retried with the same title. +- `lastCommittedTitle`: the trimmed title of the most recent `titleCommitted` + call, whatever its outcome. +- `pending`: true while a title or manual request that can still apply is in + flight. Withdrawn requests and requests from a previous post are not pending + even if their HTTP call has not returned. A submission waiting behind an + active request is not pending until it starts. + +### Inputs and proposals + +`createSlugMachine({generateSlug, onListenerError})` takes the generator port +(`(text: string) => Promise`) and an error sink for listener failures. + +| Call | Effect | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `loaded({slug, title})` | Document boundary. Resets the machine to the post, infers the settled mode, discards in-flight and waiting work from the previous post, notifies with a `null` proposal. | +| `titleCommitted(title)` | The title was committed (blur). Resolves with a proposal; never rejects. | +| `slugEdited(input)` | The slug input was committed. Resolves with a proposal; never rejects. | +| `getState()` | Snapshot of the state above. | +| `subscribe(listener)` | `listener(state, proposal)` on every state change, `proposal` being `null` when only `pending` changed or a post loaded. Returns an unsubscribe function. | + +A listener that throws is reported to `onListenerError` and affects neither the +transition nor the other listeners. + +Proposals are `{slug, source}`: + +| Source | Meaning | Caller action | +| ----------- | ------------------------------------------------------------ | --------------------------- | +| `generated` | A slug generated from the title was applied. | Show `slug` and persist it. | +| `manual` | A manual edit was applied after server sanitizing and dedup. | Show `slug` and persist it. | +| `unchanged` | Nothing was applied; `reason` says why. | See the table below. | + +`unchanged` proposals carry a `reason`: + +| Reason | When | Caller action | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `same-title` | The committed title equals the title the slug came from and a slug exists. No request was made. | None. | +| `custom` | The machine is in custom mode. No request was made. | None. | +| `frozen` | The committed title is blank, or is `(Untitled)` while a slug exists. No request was made. | None. | +| `stale` | The call was superseded before it could apply: replaced by a newer submission, withdrawn, or a post loaded. `slug` is the slug at call time, not necessarily current. | Ignore it. | +| `empty-result` | The server returned a blank slug. | None. | +| `reverted` | A manual edit was blank or unchanged, or the server resolved it back to the current slug. | Reset the slug input to `slug`. | +| `error` | The generator threw; `error` carries the thrown value. | Surface the error; reset the slug input to `slug` if manual. | + +Every proposal except `stale` is delivered to subscribers with the state it +produced. Subscribers are also notified with a `null` proposal when a request +starts (`pending` becomes true) and when a post loads. A rejected manual edit is +always reported (`reverted`, `empty-result`, or `error`) so the input can be +reset to the kept slug instead of showing the rejected text. + +### Rules + +Generation + +- A title commit generates when mode is `derived`, the trimmed title is not + blank, and neither the `same-title` nor the `frozen` case applies. A blank + title never generates. `(Untitled)` generates `untitled` once, when no slug + exists, and is frozen after that. +- The same-title check only applies when a slug exists; a post loaded with a + title and no slug generates on its first commit. +- The server result is applied as returned. A deduplicated result (`hello-2`) + is still a derived slug and keeps following the title for the rest of the + session. +- A whitespace-only server result is ignored (`empty-result`). + +Custom detection at load + +- A loaded slug is custom when it is non-empty and differs from + `slugify(title)`, unless the title is `(Untitled)` or ends with `(Copy)`. A + blank slug is never custom. +- `(Copy)`: a duplicated post's slug is derived regardless of its value, so the + first rename regenerates it. This is the one case where a slug that differs + from `slugify(title)` is not custom. +- Known limitation: posts do not store slug provenance, so any + server-transformed slug (a deduplicated `hello-2`, a truncated or + protected-slug-suffixed result) reads as custom after reload and stops + following the title. + +Manual edits + +- Input is trimmed. Blank or unchanged input reverts without a request. The + trimmed text is sent to the generator as typed; the server sanitizes it and + the result is applied, so `My Slug` becomes `my-slug`. +- If the server returns the current slug, the edit reverts. +- Dedup guard: if the server returns `-` with `N > 0` and that + is not exactly `slugify(candidate)`, the server is assumed to have appended a + uniqueness counter to a candidate that sanitized back to the current slug, and + the edit reverts. Typing `top 10` on slug `top` still applies `top-10`. Known + false positive: the guard decides by shape, so a candidate the server + canonicalizes differently from `slugify` (protected slugs, the 185-character + cap) is reverted when its result happens to take that shape. +- An applied manual edit switches mode to `custom` for the rest of the session; + no later title commit regenerates the slug until the post is reloaded. + Reverted, empty, and failed edits leave the mode where it was. + +Ordering and staleness + +- At most one generator request is in flight. Further submissions wait behind + it; only the newest waiting submission is kept, and each one it replaces + resolves `stale` without reaching the server. The kept submission runs when + the active request settles and is evaluated against the state at that time. +- A title commit behind an in-flight manual edit is deferred, not refused. If + the edit applies, the deferred commit resolves `custom`; if the edit fails or + reverts, the commit generates as normal. +- A manual edit behind an in-flight title generation waits for it. Withdrawing + that waiting edit (blank or unchanged input) drops it and leaves the + generation running. +- Withdrawing an in-flight manual edit makes its result `stale`, and mode and + `pending` fall back immediately; a title commit waiting behind it still runs + once the request physically settles. +- Committing the slug's source title, or a frozen title, while a title + generation is in flight invalidates that generation immediately, drops any + waiting submission, and returns `same-title` or `frozen`. +- `loaded()` invalidates everything from the previous post: in-flight results + resolve `stale` to their callers, are not delivered to subscribers, and the + new post reads not pending. +- A failed or reverted manual edit never leaves the machine in custom mode and + never discards a title commit queued behind it. + +## Invariants + +- A background command (`autosave`/`timed`/`field`) can never change status, publish, or send email. +- No two saves are in flight; payloads are built at execution; coalescing never loses the newest content. +- Session expiry during a save loses nothing: re-auth completes, the save lands, content is present. +- Save-on-leave fires at most once per attempt and only for dirty drafts. +- Loading any post, including old-schema fixtures, is a clean verdict until the user edits. +- A failed save leaves the post dirty and recoverable; no error path discards the payload. +- Explicit and leave saves set `save_revision`; background saves do not; publish does not force one (coalescing ORs). +- A published/scheduled/sent post's persisted state changes only via explicit Update, publish-flow commands, delete, or restore. +- Slug generation never overwrites a custom slug and never applies a stale proposal. +- Scheduled saves serialize with zeroed milliseconds and preserve the publish time unless the user changed it. +- Clearing a non-empty body is dirty. + +## Design decisions + +- Pending-slot coalescing carries autosaves that arrive during an in-flight create. +- A manually edited slug stays custom for the session; a server-deduplicated slug keeps following the title. +- `direction` is stripped recursively before Lexical documents are compared. +- A query refetch never re-baselines; only an acknowledged save does. +- Site URLs are normalized structurally only on known URL-bearing node properties. +- `verdict({includeDiff: true})` produces the human-readable diff used by the leave modal. + +## What the caller owns + +### Editing session + +The wiring hook owns everything the three modules deliberately do not: + +- One session per opened post, built and disposed together. A new post always + gets its own; nothing is carried from one new post to the next. +- A live projection held beside the tracker and patched on every title, excerpt + and body change, so a snapshot can be read synchronously at any moment. The + version counter moves with it. +- The persisted identity (id and collision token), replaced from every + acknowledgement, so the next request carries the token the server just issued. +- A blank title held in the live projection as the default title while the input + stays empty, so a post persisted under that title does not read as permanently + diverged from what the writer sees. +- Routing query responses to `setSaved` and mutation responses to + `saveAcknowledged`, passing the projection the request submitted and the full + record the server acknowledged. +- Replacing the URL once a create acquires an id, with the screen keyed on the + session so the swap does not remount the editor. +- Deciding what a halted queue looks like: `reauth-pending` and `conflict` are + states, not UI. The writer gets a way back in and the content stays untouched. + +### Save engine + +- `getSnapshot()` returns the complete post as the caller holds it: id with its `updatedAt` (both `null` until the create is acknowledged), status, publish time, title, slug, dirty bits, `changedSinceLastRevision`, and a monotonic edit `version`. +- `prepare(request, signal)` builds and validates the candidate from the `SaveRequest`; the returned object is a plain structural superset of the request (no brand) and is handed unchanged to `execute` and `reconcile`, with `prepared.snapshot` being the complete execution-time snapshot. +- `execute(prepared, signal)` performs the IO only and returns a typed `SaveOutcome`, mapping API failures to `SaveError` kinds (401 → `session-invalid`, 404 → `not-found`, `UPDATE_COLLISION` → `conflict`, 422 → `validation`, host-limit errors → `host-limit`, unreachable → `transport`); its result is the acknowledged post, whose `id` is the one a create returns. +- `reconcile(prepared, result)` runs after a successful save and must not throw: adopt the acknowledged id, status and `updated_at` first, keep edits made after `prepared.snapshot.version`, resync server-normalized values only where the local value did not change in flight, advance the saved/revision baselines without marking newer edits clean. +- A request mode that does not redirect on 401 and instead throws a session-expired error, so the engine can enter `reauth-pending` rather than losing the page. +- The `SlugPort`: `settled()` resolves once the latest manual slug submission has settled (not when an in-progress flag drops before a deferred submission starts); `fromTitle(title, postId, signal)` resolves `{slug, source: 'generated' | 'unchanged'}`, where `unchanged` means keep the current slug (custom, same title, frozen); the raw title is pre-slugified before any generator request. +- Detecting a past-scheduled post for the UI; the engine preserves the status but does not interpret its publish time. +- Replacing the URL from new → edit as a state-driven effect after the create acknowledgement, keyed on the editing session so the switch does not remount the editor. +- `(Untitled)` substitution is the engine's prepare duty; the title input never shows it. + +### Change tracker + +- A fresh tracker per editing session, including a new one for each new-post session — two consecutive new posts share the null id, so a stale create acknowledgement from a previous session must never reach the current tracker. +- Query data goes to `setSaved`, save responses to `saveAcknowledged`; never the reverse. +- `revisionRestored` only after the restore has been saved. +- Pass the snapshot the save request was built from as `submitted`. + +### Slug machine + +- Persistence. The machine does not save proposals; the caller persists + `generated` and `manual` slugs. +- The generator port. The machine passes trimmed text as typed, whether a title + or a manual candidate. The port must send `encodeURIComponent(slugify(text))` + to `GET /slugs/post/:name/:id` (raw text containing a character such as a + newline is not a valid path segment) and pass the post id so the server does + not count the post's own slug as a collision. +- Draft-only title commits. Title blur drives generation for drafts only; do + not call `titleCommitted` on blur for published or scheduled posts. +- Regenerate when there is no slug, for any status, before save, including + after `(Untitled)` substitution. +- `(Untitled)` substitution for a blank title before save. +- No save for a new post on a manual edit; the first explicit save persists it. diff --git a/apps/admin/src/editor/engine/__fixtures__/adjacent-lists-merge.json b/apps/admin/src/editor/engine/__fixtures__/adjacent-lists-merge.json new file mode 100644 index 00000000000..eca0d1bdd60 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/adjacent-lists-merge.json @@ -0,0 +1,198 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "One", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Two", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Numbered", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "number", + "start": 1, + "tag": "ol" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "One", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Two", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 2 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Numbered", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "number", + "start": 1, + "tag": "ol" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/after-load.test.tsx b/apps/admin/src/editor/engine/__fixtures__/after-load.test.tsx new file mode 100644 index 00000000000..a50de99a88e --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/after-load.test.tsx @@ -0,0 +1,76 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { koenigFileUploadTypes } from '@tryghost/admin-x-framework/hooks'; +import { KoenigComposer, KoenigEditor } from '@tryghost/koenig-lexical'; +import { OLD_SCHEMA_CORPUS } from '@/editor/engine/__fixtures__'; +import { stripDirection, type LexicalDocument } from '@/editor/engine/lexical-compare'; + +interface EditorApi { + editorInstance: { + update(fn: () => void, options: { discrete: boolean }): void; + getEditorState(): { toJSON(): LexicalDocument }; + }; +} + +const fileUploader = { + useFileUpload: () => ({ + progress: 0, + isLoading: false, + errors: [], + filesNumber: 0, + upload: () => Promise.resolve(null), + }), + fileTypes: koenigFileUploadTypes, +}; + +const tick = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +// Render the same hidden Koenig instance that supplies the change tracker's normalization baseline. +async function loadThroughKoenig(before: LexicalDocument): Promise { + let api: EditorApi | undefined; + + render( + + { + api = registered; + }} + /> + , + ); + + await act(tick); + if (!api) { + throw new Error('Koenig did not register its API'); + } + const { editorInstance } = api; + await act(async () => { + editorInstance.update(() => {}, { discrete: true }); + await tick(); + }); + + return editorInstance.getEditorState().toJSON(); +} + +// `direction` is inferred by the reconciler and differs between jsdom, a real +// browser, and a headless parse; the comparator strips it, so this does too. +describe('old-schema corpus freshness', () => { + afterEach(cleanup); + + it.each(OLD_SCHEMA_CORPUS)( + '$name loads to its recorded after state', + async ({ before, after }) => { + const loaded = await loadThroughKoenig(before); + + expect(stripDirection(loaded.root)).toEqual(stripDirection(after.root)); + }, + ); +}); diff --git a/apps/admin/src/editor/engine/__fixtures__/aligned-blocks.json b/apps/admin/src/editor/engine/__fixtures__/aligned-blocks.json new file mode 100644 index 00000000000..5ad5bac5c07 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/aligned-blocks.json @@ -0,0 +1,98 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Centered paragraph", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "center", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Right heading", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "right", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h2" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Centered paragraph", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Right heading", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "extended-heading", + "version": 1, + "tag": "h2" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/direction-null-vs-ltr.json b/apps/admin/src/editor/engine/__fixtures__/direction-null-vs-ltr.json new file mode 100644 index 00000000000..4c8da5e0e98 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/direction-null-vs-ltr.json @@ -0,0 +1,220 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Paragraph text", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Nested item", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Deeper item", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 1, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 2 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + }, + { + "children": [], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Paragraph text", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Nested item", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Deeper item", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 1, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 2 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + }, + { + "children": [], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/empty-document-paragraph.json b/apps/admin/src/editor/engine/__fixtures__/empty-document-paragraph.json new file mode 100644 index 00000000000..8a96d4eea8f --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/empty-document-paragraph.json @@ -0,0 +1,31 @@ +{ + "before": { + "root": { + "children": [], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/heading-in-listitem.json b/apps/admin/src/editor/engine/__fixtures__/heading-in-listitem.json new file mode 100644 index 00000000000..ffb223643f9 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/heading-in-listitem.json @@ -0,0 +1,111 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Heading", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "extended-heading", + "version": 1, + "tag": "h4" + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Paragraph", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Heading", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "extended-heading", + "version": 1, + "tag": "h4" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Paragraph", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/index.ts b/apps/admin/src/editor/engine/__fixtures__/index.ts new file mode 100644 index 00000000000..ad73f1d58ee --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/index.ts @@ -0,0 +1,49 @@ +import adjacentListsMerge from './adjacent-lists-merge.json'; +import alignedBlocks from './aligned-blocks.json'; +import directionNullVsLtr from './direction-null-vs-ltr.json'; +import emptyDocumentParagraph from './empty-document-paragraph.json'; +import headingInListitem from './heading-in-listitem.json'; +import invalidNesting from './invalid-nesting.json'; +import legacyHeadingQuoteNodes from './legacy-heading-quote-nodes.json'; +import legacyTextNodes from './legacy-text-nodes.json'; +import listInHeading from './list-in-heading.json'; +import missingDefaultProps from './missing-default-props.json'; +import nestedEditorHtml from './nested-editor-html.json'; +import oldVisibilityFormat from './old-visibility-format.json'; +import partialVisibilityFormat from './partial-visibility-format.json'; +import type { LexicalDocument } from '@/editor/engine/lexical-compare'; + +// headless-koenig / mounted-koenig: `after` recorded from koenig-lexical loading `before`. +// hand-authored: pair written by hand; after-load.test.tsx verifies every pair regardless. +export type FixtureProvenance = 'headless-koenig' | 'mounted-koenig' | 'hand-authored'; + +export interface OldSchemaFixture { + name: string; + provenance: FixtureProvenance; + before: LexicalDocument; + after: LexicalDocument; +} + +function fixture( + name: string, + provenance: FixtureProvenance, + pair: { before: LexicalDocument; after: LexicalDocument }, +): OldSchemaFixture { + return { name, provenance, before: pair.before, after: pair.after }; +} + +export const OLD_SCHEMA_CORPUS: OldSchemaFixture[] = [ + fixture('legacy-text-nodes', 'headless-koenig', legacyTextNodes), + fixture('legacy-heading-quote-nodes', 'headless-koenig', legacyHeadingQuoteNodes), + fixture('old-visibility-format', 'headless-koenig', oldVisibilityFormat), + fixture('missing-default-props', 'headless-koenig', missingDefaultProps), + fixture('invalid-nesting', 'headless-koenig', invalidNesting), + fixture('adjacent-lists-merge', 'headless-koenig', adjacentListsMerge), + fixture('aligned-blocks', 'headless-koenig', alignedBlocks), + fixture('nested-editor-html', 'mounted-koenig', nestedEditorHtml), + fixture('partial-visibility-format', 'mounted-koenig', partialVisibilityFormat), + fixture('list-in-heading', 'mounted-koenig', listInHeading), + fixture('heading-in-listitem', 'mounted-koenig', headingInListitem), + fixture('direction-null-vs-ltr', 'hand-authored', directionNullVsLtr), + fixture('empty-document-paragraph', 'hand-authored', emptyDocumentParagraph), +]; diff --git a/apps/admin/src/editor/engine/__fixtures__/invalid-nesting.json b/apps/admin/src/editor/engine/__fixtures__/invalid-nesting.json new file mode 100644 index 00000000000..ee8931118b7 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/invalid-nesting.json @@ -0,0 +1,248 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Intro ", + "type": "text", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Nested heading", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h3" + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " outro", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Item one", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Item with image", + "type": "text", + "version": 1 + }, + { + "type": "image", + "version": 1, + "src": "https://example.com/content/images/nested.jpg", + "width": 100, + "height": 100, + "title": "", + "alt": "", + "caption": "", + "cardWidth": "regular", + "href": "" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 2 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Intro ", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Nested heading", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "extended-heading", + "version": 1, + "tag": "h3" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " outro", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Item one", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "bullet", + "start": 1, + "tag": "ul" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Item with image", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "type": "image", + "version": 1, + "src": "https://example.com/content/images/nested.jpg", + "width": 100, + "height": 100, + "title": "", + "alt": "", + "caption": "", + "cardWidth": "regular", + "href": "" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/legacy-heading-quote-nodes.json b/apps/admin/src/editor/engine/__fixtures__/legacy-heading-quote-nodes.json new file mode 100644 index 00000000000..ca49d8f0613 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/legacy-heading-quote-nodes.json @@ -0,0 +1,134 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Heading two", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h2" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "A quotation", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "quote", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Body", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Heading two", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "extended-heading", + "version": 1, + "tag": "h2" + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "A quotation", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "extended-quote", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Body", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/legacy-text-nodes.json b/apps/admin/src/editor/engine/__fixtures__/legacy-text-nodes.json new file mode 100644 index 00000000000..29c7efd45b3 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/legacy-text-nodes.json @@ -0,0 +1,176 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Plain ", + "type": "text", + "version": 1 + }, + { + "detail": 0, + "format": 1, + "mode": "normal", + "style": "", + "text": "bold", + "type": "text", + "version": 1 + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " text.", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Second paragraph with a ", + "type": "text", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "link", + "type": "text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "link", + "version": 1, + "rel": null, + "target": null, + "title": null, + "url": "https://example.com/" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Plain ", + "type": "extended-text", + "version": 1 + }, + { + "detail": 0, + "format": 1, + "mode": "normal", + "style": "", + "text": "bold", + "type": "extended-text", + "version": 1 + }, + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": " text.", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Second paragraph with a ", + "type": "extended-text", + "version": 1 + }, + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "link", + "type": "extended-text", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "link", + "version": 1, + "rel": null, + "target": null, + "title": null, + "url": "https://example.com/" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/list-in-heading.json b/apps/admin/src/editor/engine/__fixtures__/list-in-heading.json new file mode 100644 index 00000000000..83fb2a99da5 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/list-in-heading.json @@ -0,0 +1,96 @@ +{ + "before": { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "This should be plain text", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "number", + "start": 1, + "tag": "ol" + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "heading", + "version": 1, + "tag": "h3" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "This should be plain text", + "type": "extended-text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "listitem", + "version": 1, + "value": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "list", + "version": 1, + "listType": "number", + "start": 1, + "tag": "ol" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/missing-default-props.json b/apps/admin/src/editor/engine/__fixtures__/missing-default-props.json new file mode 100644 index 00000000000..54b08a34e08 --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/missing-default-props.json @@ -0,0 +1,135 @@ +{ + "before": { + "root": { + "children": [ + { + "type": "image", + "version": 1, + "src": "https://example.com/content/images/photo.jpg", + "width": 800, + "height": 600, + "caption": "", + "alt": "" + }, + { + "type": "callout", + "version": 1, + "calloutText": "Heads up" + }, + { + "type": "header", + "version": 1, + "size": "small", + "style": "dark", + "buttonEnabled": false, + "buttonUrl": "", + "buttonText": "", + "header": "Hello", + "subheader": "", + "backgroundImageSrc": "" + }, + { + "type": "signup", + "version": 1, + "alignment": "left", + "header": "Sign up", + "subheader": "", + "disclaimer": "", + "labels": [], + "layout": "wide" + }, + { + "type": "button", + "version": 1, + "buttonText": "Go", + "buttonUrl": "https://example.com/", + "alignment": "center" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "type": "image", + "version": 1, + "src": "https://example.com/content/images/photo.jpg", + "width": 800, + "height": 600, + "title": "", + "alt": "", + "caption": "", + "cardWidth": "regular", + "href": "" + }, + { + "type": "callout", + "version": 1, + "calloutText": "

Heads up

", + "calloutEmoji": "💡", + "backgroundColor": "blue" + }, + { + "type": "header", + "version": 1, + "size": "small", + "style": "dark", + "buttonEnabled": false, + "buttonUrl": "", + "buttonText": "", + "header": "Hello", + "subheader": "", + "backgroundImageSrc": "", + "accentColor": "#FF1A75", + "alignment": "center", + "backgroundColor": "#000000", + "backgroundImageWidth": null, + "backgroundImageHeight": null, + "backgroundSize": "cover", + "textColor": "#FFFFFF", + "buttonColor": "#ffffff", + "buttonTextColor": "#000000", + "layout": "full", + "swapped": false + }, + { + "type": "signup", + "version": 1, + "alignment": "left", + "backgroundColor": "#F0F0F0", + "backgroundImageSrc": "", + "backgroundSize": "cover", + "textColor": "#000000", + "buttonColor": "accent", + "buttonTextColor": "#FFFFFF", + "buttonText": "Subscribe", + "disclaimer": "", + "header": "Sign up", + "layout": "wide", + "subheader": "", + "successMessage": "Email sent! Check your inbox to complete your signup.", + "swapped": false, + "labels": [] + }, + { + "type": "button", + "version": 1, + "buttonText": "Go", + "alignment": "center", + "buttonUrl": "https://example.com/" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/nested-editor-html.json b/apps/admin/src/editor/engine/__fixtures__/nested-editor-html.json new file mode 100644 index 00000000000..3d503e5c0eb --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/nested-editor-html.json @@ -0,0 +1,50 @@ +{ + "before": { + "root": { + "children": [ + { + "type": "callout", + "version": 1, + "calloutText": "Callout with bold and italic", + "calloutEmoji": "💡", + "backgroundColor": "blue" + }, + { + "type": "toggle", + "version": 1, + "heading": "Toggle heading", + "content": "

First paragraph

Second with a link

" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "type": "callout", + "version": 1, + "calloutText": "

Callout with bold and italic

", + "calloutEmoji": "💡", + "backgroundColor": "blue" + }, + { + "type": "toggle", + "version": 1, + "heading": "Toggle heading", + "content": "

First paragraph

Second with a link

" + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/old-visibility-format.json b/apps/admin/src/editor/engine/__fixtures__/old-visibility-format.json new file mode 100644 index 00000000000..1a90926d88b --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/old-visibility-format.json @@ -0,0 +1,103 @@ +{ + "before": { + "root": { + "children": [ + { + "type": "html", + "version": 1, + "html": "

free members only

", + "visibility": { + "showOnEmail": true, + "showOnWeb": true, + "segment": "status:free" + } + }, + { + "type": "html", + "version": 1, + "html": "

email only

", + "visibility": { + "emailOnly": true, + "segment": "" + } + }, + { + "type": "html", + "version": 1, + "html": "

web only

", + "visibility": { + "showOnEmail": false, + "showOnWeb": true, + "segment": "status:-free+status:-paid" + } + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "type": "html", + "version": 1, + "html": "

free members only

", + "visibility": { + "showOnEmail": true, + "showOnWeb": true, + "segment": "status:free", + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + }, + "email": { + "memberSegment": "status:free" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

email only

", + "visibility": { + "emailOnly": true, + "segment": "", + "web": { + "nonMember": false, + "memberSegment": "" + }, + "email": { + "memberSegment": "status:free,status:-free" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

web only

", + "visibility": { + "showOnEmail": false, + "showOnWeb": true, + "segment": "status:-free+status:-paid", + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + }, + "email": { + "memberSegment": "" + } + } + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/__fixtures__/partial-visibility-format.json b/apps/admin/src/editor/engine/__fixtures__/partial-visibility-format.json new file mode 100644 index 00000000000..2ce60b9b66b --- /dev/null +++ b/apps/admin/src/editor/engine/__fixtures__/partial-visibility-format.json @@ -0,0 +1,123 @@ +{ + "before": { + "root": { + "children": [ + { + "type": "html", + "version": 1, + "html": "

web object only

", + "visibility": { + "web": { + "nonMember": true + } + } + }, + { + "type": "html", + "version": 1, + "html": "

web without email

", + "visibility": { + "web": { + "nonMember": false, + "memberSegment": "" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

email object only

", + "visibility": { + "email": { + "memberSegment": "status:free" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

email without segment

", + "visibility": { + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + }, + "email": {} + } + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + }, + "after": { + "root": { + "children": [ + { + "type": "html", + "version": 1, + "html": "

web object only

", + "visibility": { + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + }, + "email": { + "memberSegment": "status:free,status:-free" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

web without email

", + "visibility": { + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + }, + "email": { + "memberSegment": "status:free,status:-free" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

email object only

", + "visibility": { + "email": { + "memberSegment": "status:free,status:-free" + }, + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + } + } + }, + { + "type": "html", + "version": 1, + "html": "

email without segment

", + "visibility": { + "web": { + "nonMember": true, + "memberSegment": "status:free,status:-free" + }, + "email": { + "memberSegment": "status:free,status:-free" + } + } + } + ], + "direction": null, + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } +} diff --git a/apps/admin/src/editor/engine/change-tracker.test.ts b/apps/admin/src/editor/engine/change-tracker.test.ts new file mode 100644 index 00000000000..5b854e39789 --- /dev/null +++ b/apps/admin/src/editor/engine/change-tracker.test.ts @@ -0,0 +1,1199 @@ +import { describe, expect, it } from 'vitest'; +import { OLD_SCHEMA_CORPUS } from '@/editor/engine/__fixtures__'; +import { + createChangeTracker, + type EditablePostProjection, + type PostId, +} from '@/editor/engine/change-tracker'; +import { + lexicalEquals, + stripDirection, + type LexicalDocument, +} from '@/editor/engine/lexical-compare'; + +const textNode = (text: string) => ({ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text, + type: 'extended-text', + version: 1, +}); + +const paragraph = (text: string, direction: string | null = null) => ({ + children: [textNode(text)], + direction, + format: '', + indent: 0, + type: 'paragraph', + version: 1, +}); + +const doc = (children: unknown[], direction: string | null = null): LexicalDocument => ({ + root: { children, direction, format: '', indent: 0, type: 'root', version: 1 }, +}); + +const serialize = (document: LexicalDocument) => JSON.stringify(document); + +function appendParagraph(document: LexicalDocument, text: string): LexicalDocument { + const copy = JSON.parse(JSON.stringify(document)) as { root: { children: unknown[] } }; + copy.root.children.push(paragraph(text)); + return copy; +} + +function withLtrEverywhere(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(withLtrEverywhere); + } + if (typeof value === 'object' && value !== null) { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = key === 'direction' ? 'ltr' : withLtrEverywhere(child); + } + return out; + } + return value; +} + +const SAVED_DOC = doc([paragraph('Hello')]); +const BLANK_DOC = doc([{ ...paragraph(''), children: [] }]); +const POST_ID = 'post-1'; +const T0 = '2026-09-01T10:00:00.000Z'; +const T1 = '2026-09-01T10:01:00.000Z'; +const T2 = '2026-09-01T10:02:00.000Z'; + +function post(overrides: Partial = {}): EditablePostProjection { + return { + title: 'Title', + slug: 'title', + lexical: serialize(SAVED_DOC), + tags: [{ name: 'News' }], + custom_excerpt: null, + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + updated_at: T0, + ...overrides, + }; +} + +function loadedTracker(saved: EditablePostProjection = post(), id: PostId = POST_ID) { + const tracker = createChangeTracker(); + tracker.load(id, saved); + tracker.setBaseline(id, saved.lexical); + tracker.setLive(id, { lexical: saved.lexical }); + return tracker; +} + +const codes = (tracker: ReturnType) => + tracker.verdict().reasons.map((r) => r.code); + +describe('createChangeTracker', () => { + it('is clean before a post is loaded', () => { + expect(createChangeTracker().verdict()).toEqual({ dirty: false, reasons: [] }); + }); + + it('is clean right after load, before the editors report', () => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post()); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }); + + describe('loading old-schema content is clean until the user edits', () => { + it.each(OLD_SCHEMA_CORPUS)('$name ($provenance)', ({ before, after }) => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(before) })); + tracker.setBaseline(POST_ID, serialize(after)); + tracker.setLive(POST_ID, { lexical: serialize(after) }); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }); + + it.each(OLD_SCHEMA_CORPUS)('$name: a user edit after load is dirty', ({ before, after }) => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(before) })); + tracker.setBaseline(POST_ID, serialize(after)); + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(after, 'User typed this')) }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it.each(OLD_SCHEMA_CORPUS)( + '$name: direction inferred at depth by a mounted editor is ignored', + ({ before, after }) => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(before) })); + tracker.setBaseline(POST_ID, serialize(after)); + tracker.setLive(POST_ID, { + lexical: serialize(withLtrEverywhere(after) as LexicalDocument), + }); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }, + ); + + it.each(OLD_SCHEMA_CORPUS)( + '$name: a refetch of the saved post after load stays clean', + ({ before, after }) => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(before) })); + tracker.setBaseline(POST_ID, serialize(after)); + tracker.setLive(POST_ID, { lexical: serialize(after) }); + + tracker.setSaved(POST_ID, post({ lexical: serialize(before) })); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }, + ); + + it.each(OLD_SCHEMA_CORPUS)( + '$name: the transformed live state is dirty only until the baseline reports', + ({ before, after }) => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(before) })); + tracker.setLive(POST_ID, { lexical: serialize(after) }); + const transformed = !lexicalEquals(before, after); + expect(codes(tracker)).toEqual(transformed ? ['BASELINE_PENDING'] : []); + + tracker.setBaseline(POST_ID, serialize(after)); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }, + ); + }); + + describe('lexical divergence', () => { + it('requires divergence from both the saved and the baseline state', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(SAVED_DOC, 'Edit')) }); + expect(tracker.verdict().dirty).toBe(true); + + tracker.setLive(POST_ID, { lexical: serialize(SAVED_DOC) }); + expect(tracker.verdict().dirty).toBe(false); + }); + + it('is dirty for a new post without saved content once the user types', () => { + const tracker = createChangeTracker(); + tracker.load(null, post({ lexical: null })); + tracker.setBaseline(null, serialize(BLANK_DOC)); + tracker.setLive(null, { lexical: serialize(BLANK_DOC) }); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setLive(null, { lexical: serialize(doc([paragraph('Typed')])) }); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it('fails closed when the lexical state cannot be parsed', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { lexical: '{not json' }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'LEXICAL_PARSE_FAILED', + reason: 'lexical state could not be parsed for comparison', + context: { error: expect.stringContaining('JSON') as string }, + }, + ]); + }); + + it.each(['{}', '{"root":{}}', '{"root":{"children":"invalid"}}'])( + 'fails closed for the structurally invalid document %s', + (invalid) => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { lexical: invalid }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'LEXICAL_PARSE_FAILED', + reason: 'lexical state could not be parsed for comparison', + context: { error: 'lexical root must be an object with a children array' }, + }, + ]); + expect(tracker.verdict({ includeDiff: true }).diff).toBeUndefined(); + }, + ); + + it('ignores undefined fields passed to setLive', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(SAVED_DOC, 'Edit')) }); + tracker.setLive(POST_ID, { lexical: undefined, title: undefined }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it('carries the three serialized states in the reason context', () => { + const tracker = loadedTracker(); + const edited = serialize(appendParagraph(SAVED_DOC, 'Edit')); + tracker.setLive(POST_ID, { lexical: edited }); + + expect(tracker.verdict().reasons[0]).toEqual({ + code: 'SCRATCH_DIVERGED_FROM_SECONDARY', + reason: 'main editor content has diverged from both hidden editor and saved content', + context: { + secondaryLexical: serialize(SAVED_DOC), + lexical: serialize(SAVED_DOC), + scratch: edited, + }, + }); + }); + }); + + describe('baseline readiness', () => { + it('is dirty when the user types before the hidden editor reports', () => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post()); + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(SAVED_DOC, 'Edit')) }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'BASELINE_PENDING', + reason: + 'main editor content has diverged from saved content before the hidden editor reported', + context: { + lexical: serialize(SAVED_DOC), + scratch: serialize(appendParagraph(SAVED_DOC, 'Edit')), + }, + }, + ]); + }); + + it('is dirty when a new post is typed into before the hidden editor reports', () => { + const tracker = createChangeTracker(); + tracker.load(null, post({ lexical: null })); + tracker.setLive(null, { lexical: serialize(doc([paragraph('Typed')])) }); + + expect(codes(tracker)).toEqual(['BASELINE_PENDING']); + }); + + it('stays clean while pending when the live state matches the saved state', () => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post()); + tracker.setLive(POST_ID, { lexical: serialize(SAVED_DOC) }); + + expect(tracker.verdict().dirty).toBe(false); + }); + + it.each([null, '', serialize(doc([]))])( + 'treats a ready baseline of %j as a known empty document', + (empty) => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: null })); + tracker.setBaseline(POST_ID, empty); + tracker.setLive(POST_ID, { lexical: null }); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setLive(POST_ID, { lexical: serialize(doc([paragraph('Typed')])) }); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }, + ); + + it.each([null, ''])( + 'treats explicitly clearing non-empty content to %j as dirty', + (cleared) => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { lexical: cleared }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }, + ); + + it('treats clearing to an empty root as dirty', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { lexical: serialize(doc([])) }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it('falls back to a live-vs-saved compare when the hidden editor fails', () => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post()); + tracker.baselineFailed(POST_ID, new Error('hidden editor crashed')); + tracker.setLive(POST_ID, { lexical: serialize(SAVED_DOC) }); + expect(tracker.verdict().dirty).toBe(false); + + const edited = serialize(appendParagraph(SAVED_DOC, 'Edit')); + tracker.setLive(POST_ID, { lexical: edited }); + expect(tracker.verdict().reasons).toEqual([ + { + code: 'BASELINE_FAILED', + reason: + 'main editor content has diverged from saved content and the hidden editor failed', + context: { + lexical: serialize(SAVED_DOC), + scratch: edited, + error: 'hidden editor crashed', + }, + }, + ]); + }); + + it('keeps body protection for an old-schema post after the hidden editor fails', () => { + const [fixture] = OLD_SCHEMA_CORPUS; + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(fixture.before) })); + tracker.baselineFailed(POST_ID, 'boom'); + tracker.setLive(POST_ID, { lexical: serialize(fixture.after) }); + + expect(codes(tracker)).toEqual(['BASELINE_FAILED']); + }); + + it('recovers once a late baseline report arrives after a failure', () => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post()); + tracker.baselineFailed(POST_ID, 'boom'); + const edited = serialize(appendParagraph(SAVED_DOC, 'Edit')); + tracker.setLive(POST_ID, { lexical: edited }); + + tracker.setBaseline(POST_ID, edited); + expect(tracker.verdict().dirty).toBe(false); + }); + }); + + describe('tags', () => { + it('compares tags by ordered name list', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { tags: [{ name: 'News' }, { name: 'Tech' }] }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'POST_TAGS_DIVERGED', + reason: 'tags are different', + context: { currentTags: ['News', 'Tech'], previousTags: ['News'] }, + }, + ]); + }); + + it('ignores identity when the names match', () => { + const tracker = loadedTracker(post({ tags: [{ name: 'News', id: '1' } as never] })); + tracker.setLive(POST_ID, { tags: [{ name: 'News', id: 'unsaved' } as never] }); + + expect(tracker.verdict().dirty).toBe(false); + }); + + it('treats reordered tags as a change', () => { + const tracker = loadedTracker(post({ tags: [{ name: 'A' }, { name: 'B' }] })); + tracker.setLive(POST_ID, { tags: [{ name: 'B' }, { name: 'A' }] }); + + expect(codes(tracker)).toEqual(['POST_TAGS_DIVERGED']); + }); + + it('does not collide on delimiter-bearing names', () => { + const tracker = loadedTracker(post({ tags: [{ name: 'A, B' }] })); + tracker.setLive(POST_ID, { tags: [{ name: 'A' }, { name: 'B' }] }); + + expect(codes(tracker)).toEqual(['POST_TAGS_DIVERGED']); + }); + }); + + describe('title', () => { + it('reports a changed title', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { title: 'New title' }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'POST_TITLE_DIVERGED', + reason: 'title is different', + context: { current: 'Title', scratch: 'New title' }, + }, + ]); + }); + + it('ignores surrounding whitespace', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { title: ' Title ' }); + + expect(tracker.verdict().dirty).toBe(false); + }); + }); + + describe('save errors', () => { + it('keeps the post dirty until the error is cleared', () => { + const tracker = loadedTracker(); + tracker.markSaveError(['Validation failed']); + + expect(tracker.verdict().reasons).toEqual([ + { code: 'POST_HAS_ERROR', reason: 'isError', context: { messages: ['Validation failed'] } }, + ]); + + tracker.setLive(POST_ID, { lexical: serialize(SAVED_DOC) }); + expect(codes(tracker)).toEqual(['POST_HAS_ERROR']); + + tracker.clearSaveError(); + expect(tracker.verdict().dirty).toBe(false); + }); + + it('is cleared by an acknowledged save', () => { + const tracker = loadedTracker(); + tracker.markSaveError(); + tracker.saveAcknowledged(POST_ID, post(), post()); + + expect(tracker.verdict().dirty).toBe(false); + }); + + it('survives a refetch of the saved post', () => { + const tracker = loadedTracker(); + tracker.markSaveError(); + tracker.setSaved(POST_ID, post()); + + expect(codes(tracker)).toEqual(['POST_HAS_ERROR']); + }); + + it('is listed first when other reasons apply', () => { + const tracker = loadedTracker(); + tracker.markSaveError(); + tracker.setLive(POST_ID, { title: 'Changed' }); + + expect(codes(tracker)).toEqual(['POST_HAS_ERROR', 'POST_TITLE_DIVERGED']); + }); + }); + + describe('attributes', () => { + it('reports changed attributes on an existing post', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { custom_excerpt: 'Excerpt' }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'POST_HAS_DIRTY_ATTRIBUTES', + reason: 'post.hasDirtyAttributes === true', + context: { custom_excerpt: [null, 'Excerpt'] }, + }, + ]); + }); + + it('reports changed attributes on a new post under its own code', () => { + const tracker = loadedTracker(post({ lexical: null }), null); + tracker.setLive(null, { feature_image: 'https://site.example/a.jpg' }); + + expect(tracker.verdict().reasons).toEqual([ + { + code: 'NEW_POST_HAS_CHANGED_ATTRIBUTES', + reason: 'post.changedAttributes.length > 0', + context: { feature_image: [null, 'https://site.example/a.jpg'] }, + }, + ]); + }); + + it('patches the projection so independent observers do not clobber each other', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { custom_excerpt: 'Excerpt' }); + tracker.setLive(POST_ID, { feature_image: 'https://site.example/a.jpg' }); + + expect(tracker.verdict().reasons[0]?.context).toEqual({ + custom_excerpt: [null, 'Excerpt'], + feature_image: [null, 'https://site.example/a.jpg'], + }); + + tracker.setLive(POST_ID, { custom_excerpt: null }); + expect(tracker.verdict().reasons[0]?.context).toEqual({ + feature_image: [null, 'https://site.example/a.jpg'], + }); + }); + + it('reports a changed slug', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { slug: 'custom' }); + + expect(tracker.verdict().reasons[0]?.context).toEqual({ slug: ['title', 'custom'] }); + }); + + it('never treats updated_at as a live edit', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { updated_at: T2 }); + + expect(tracker.verdict().dirty).toBe(false); + }); + }); + + describe('mutable aliasing', () => { + it('clones the saved state at ingress', () => { + const saved = post({ tags: [{ name: 'News' }], feature_image_caption: 'Caption' }); + const tracker = loadedTracker(saved); + (saved.tags as Array<{ name: string }>).push({ name: 'Injected' }); + saved.feature_image_caption = 'Mutated'; + + expect(tracker.verdict().dirty).toBe(false); + }); + + it('clones live patches at ingress', () => { + const tracker = loadedTracker(); + const patch = { tags: [{ name: 'News' }] }; + tracker.setLive(POST_ID, patch); + patch.tags.push({ name: 'Injected' }); + + expect(tracker.verdict().dirty).toBe(false); + }); + }); + + describe('saved-state lifecycle', () => { + it('does not reseed the live state on a refetch', () => { + const tracker = loadedTracker(post({ title: 'First' })); + tracker.setLive(POST_ID, { title: 'Edited' }); + tracker.setSaved(POST_ID, post({ title: 'First' })); + + expect(codes(tracker)).toEqual(['POST_TITLE_DIVERGED']); + }); + + it('does not touch the baseline on a refetch', () => { + const [fixture] = OLD_SCHEMA_CORPUS; + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(fixture.before) })); + tracker.setBaseline(POST_ID, serialize(fixture.after)); + tracker.setLive(POST_ID, { lexical: serialize(fixture.after) }); + + tracker.setSaved(POST_ID, post({ lexical: serialize(fixture.before), updated_at: T1 })); + expect(tracker.verdict().dirty).toBe(false); + }); + + it('adopts a refetch carrying a newer collision token', () => { + const tracker = loadedTracker(); + tracker.setSaved(POST_ID, post({ title: 'Renamed elsewhere', updated_at: T1 })); + + expect(tracker.verdict().reasons[0]).toMatchObject({ + code: 'POST_TITLE_DIVERGED', + context: { current: 'Renamed elsewhere', scratch: 'Title' }, + }); + }); + + it('drops a refetch older than the held collision token', () => { + const tracker = loadedTracker(); + const edited = serialize(appendParagraph(SAVED_DOC, 'Edit')); + tracker.setLive(POST_ID, { lexical: edited, title: 'Edited' }); + tracker.saveAcknowledged( + POST_ID, + post({ lexical: edited, title: 'Edited' }), + post({ lexical: edited, title: 'Edited', updated_at: T1 }), + ); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setSaved(POST_ID, post({ updated_at: T0 })); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setSaved(POST_ID, post({ lexical: edited, title: 'Edited', updated_at: T1 })); + expect(tracker.verdict().dirty).toBe(false); + }); + + it('always adopts the acknowledged collision token', () => { + const tracker = loadedTracker(post({ updated_at: T2 })); + tracker.saveAcknowledged(POST_ID, post(), post({ updated_at: T1 })); + + tracker.setSaved(POST_ID, post({ title: 'Refetched', updated_at: T1 })); + expect(codes(tracker)).toEqual(['POST_TITLE_DIVERGED']); + }); + + it('becomes clean once the acknowledged save matches the live state', () => { + const tracker = loadedTracker(); + const edited = serialize(appendParagraph(SAVED_DOC, 'Edit')); + tracker.setLive(POST_ID, { lexical: edited, title: 'Edited' }); + expect(tracker.verdict().dirty).toBe(true); + + const submitted = post({ lexical: edited, title: 'Edited' }); + tracker.saveAcknowledged(POST_ID, submitted, { ...submitted, updated_at: T1 }); + expect(tracker.verdict().dirty).toBe(false); + }); + + it('re-baselines on an acknowledged save', () => { + const [fixture] = OLD_SCHEMA_CORPUS; + const tracker = createChangeTracker(); + tracker.load(POST_ID, post({ lexical: serialize(fixture.before) })); + tracker.setBaseline(POST_ID, serialize(fixture.after)); + tracker.setLive(POST_ID, { lexical: serialize(fixture.after) }); + + const edited = serialize(appendParagraph(fixture.after, 'Edit')); + tracker.setLive(POST_ID, { lexical: edited }); + tracker.saveAcknowledged(POST_ID, post({ lexical: edited }), post({ lexical: edited })); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setLive(POST_ID, { lexical: serialize(fixture.after) }); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + }); + + describe('acknowledgement rebase', () => { + it('keeps edits made while the save was in flight', () => { + const tracker = loadedTracker(); + const submittedBody = serialize(appendParagraph(SAVED_DOC, 'Submitted')); + tracker.setLive(POST_ID, { + lexical: submittedBody, + title: 'Submitted', + tags: [{ name: 'News' }, { name: 'Tech' }], + custom_excerpt: 'Submitted excerpt', + }); + const submitted = post({ + lexical: submittedBody, + title: 'Submitted', + tags: [{ name: 'News' }, { name: 'Tech' }], + custom_excerpt: 'Submitted excerpt', + }); + + const laterBody = serialize( + appendParagraph(appendParagraph(SAVED_DOC, 'Submitted'), 'Later'), + ); + tracker.setLive(POST_ID, { + lexical: laterBody, + title: 'Later', + tags: [{ name: 'News' }, { name: 'Tech' }, { name: 'Later' }], + custom_excerpt: 'Later excerpt', + }); + + tracker.saveAcknowledged(POST_ID, submitted, { ...submitted, updated_at: T1 }); + + const verdict = tracker.verdict(); + expect(verdict.reasons.map((r) => r.code)).toEqual([ + 'POST_TAGS_DIVERGED', + 'POST_TITLE_DIVERGED', + 'SCRATCH_DIVERGED_FROM_SECONDARY', + 'POST_HAS_DIRTY_ATTRIBUTES', + ]); + expect(verdict.reasons[3]?.context).toEqual({ + custom_excerpt: ['Submitted excerpt', 'Later excerpt'], + }); + }); + + it('adopts server-canonicalized values where the live state still matches the submission', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { title: ' My post ', slug: '' }); + const submitted = post({ title: ' My post ', slug: '' }); + tracker.saveAcknowledged(POST_ID, submitted, { + ...submitted, + title: 'My post', + slug: 'my-post', + updated_at: T1, + }); + + expect(tracker.verdict().dirty).toBe(false); + tracker.setSaved(POST_ID, post({ title: 'My post', slug: 'my-post', updated_at: T1 })); + expect(tracker.verdict().dirty).toBe(false); + }); + + it('rebases fields absent from a partial submission against the previous saved state', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { custom_excerpt: 'Typed during the save' }); + tracker.saveAcknowledged( + POST_ID, + { title: 'Title' }, + post({ slug: 'title-2', updated_at: T1 }), + ); + + expect(tracker.verdict().reasons).toEqual([ + expect.objectContaining({ + code: 'POST_HAS_DIRTY_ATTRIBUTES', + context: { custom_excerpt: [null, 'Typed during the save'] }, + }), + ]); + }); + + it('treats an undefined submitted field as not submitted', () => { + const tracker = loadedTracker(); + tracker.saveAcknowledged( + POST_ID, + { title: undefined }, + post({ title: 'Server title', updated_at: T1 }), + ); + + expect(tracker.verdict().dirty).toBe(false); + }); + + it('treats a semantically equal body as still matching the submission', () => { + const tracker = loadedTracker(); + const submittedBody = serialize(appendParagraph(SAVED_DOC, 'Edit')); + tracker.setLive(POST_ID, { + lexical: serialize( + withLtrEverywhere(appendParagraph(SAVED_DOC, 'Edit')) as LexicalDocument, + ), + }); + const submitted = post({ lexical: submittedBody }); + tracker.saveAcknowledged(POST_ID, submitted, { ...submitted, updated_at: T1 }); + + expect(tracker.verdict().dirty).toBe(false); + }); + }); + + describe('post identity', () => { + it('adopts the created id from the first acknowledgement and keeps the live state', () => { + const tracker = createChangeTracker(); + tracker.load(null, post({ title: '', slug: '', lexical: null, tags: [], updated_at: null })); + tracker.setBaseline(null, serialize(BLANK_DOC)); + tracker.setLive(null, { lexical: serialize(BLANK_DOC) }); + + const typed = serialize(doc([paragraph('Typed')])); + tracker.setLive(null, { lexical: typed, title: 'Hi' }); + const submitted = post({ title: 'Hi', slug: '', lexical: typed, tags: [], updated_at: null }); + + const typedMore = serialize(doc([paragraph('Typed more')])); + tracker.setLive(null, { lexical: typedMore }); + tracker.saveAcknowledged('created-1', submitted, { + ...submitted, + slug: 'hi', + updated_at: T1, + }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + + tracker.setSaved( + 'created-1', + post({ title: 'Hi', slug: 'hi', lexical: typed, tags: [], updated_at: T1 }), + ); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + + tracker.setSaved(null, post({ title: 'Stale', lexical: null })); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it('keeps accepting null-id editor events after the created id is adopted', () => { + const tracker = createChangeTracker(); + tracker.load(null, post({ lexical: null, updated_at: null })); + tracker.setBaseline(null, serialize(BLANK_DOC)); + tracker.setLive(null, { lexical: serialize(BLANK_DOC) }); + const submitted = post({ lexical: serialize(BLANK_DOC), updated_at: null }); + tracker.saveAcknowledged('new1', submitted, { ...submitted, updated_at: T1 }); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setLive(null, { lexical: serialize(doc([paragraph('Typed')])) }); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + + tracker.setBaseline(null, serialize(doc([paragraph('Typed')]))); + expect(tracker.verdict().dirty).toBe(false); + tracker.baselineFailed(null, 'boom'); + tracker.setLive(null, { lexical: serialize(doc([paragraph('Typed more')])) }); + expect(codes(tracker)).toEqual(['BASELINE_FAILED']); + + tracker.setSaved(null, post({ title: 'Stale' })); + expect(codes(tracker)).toEqual(['BASELINE_FAILED']); + }); + + it('does not alias null to a post loaded with an id', () => { + const tracker = loadedTracker(); + tracker.setLive(null, { title: 'Edited' }); + + expect(tracker.verdict().dirty).toBe(false); + }); + + it('rejects a late acknowledgement for a previously held post while a new post is open', () => { + const tracker = loadedTracker(post({ title: 'A' }), 'a'); + tracker.setLive('a', { title: 'A edited' }); + tracker.load(null, post({ title: '', lexical: null, updated_at: null })); + + tracker.saveAcknowledged( + 'a', + post({ title: 'A edited' }), + post({ title: 'A edited', updated_at: T2 }), + ); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + tracker.setSaved('a', post({ title: 'A refetched', updated_at: T2 })); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + + const submitted = post({ title: 'New', lexical: null, updated_at: null }); + tracker.setLive(null, { title: 'New' }); + tracker.saveAcknowledged('new1', submitted, { ...submitted, updated_at: T2 }); + tracker.setSaved('new1', post({ title: 'New refetched', lexical: null, updated_at: T2 })); + expect(codes(tracker)).toEqual(['POST_TITLE_DIVERGED']); + }); + + it('ignores a refetch of the previous post after switching', () => { + const tracker = loadedTracker(post({ title: 'A' }), 'a'); + tracker.load('b', post({ title: 'B' })); + tracker.setBaseline('b', serialize(SAVED_DOC)); + tracker.saveAcknowledged('b', post({ title: 'B' }), post({ title: 'B', updated_at: T1 })); + + tracker.setSaved('a', post({ title: 'A refetched', updated_at: T2 })); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }); + + it('ignores callbacks from the previous post after switching', () => { + const tracker = loadedTracker(post({ title: 'A' }), 'a'); + tracker.load('b', post({ title: 'B' })); + + tracker.setBaseline('a', serialize(doc([paragraph('A baseline')]))); + tracker.baselineFailed('a', 'boom'); + tracker.setLive('a', { title: 'A edited', lexical: serialize(doc([paragraph('A typed')])) }); + tracker.saveAcknowledged( + 'a', + post({ title: 'A edited' }), + post({ title: 'A edited', updated_at: T2 }), + ); + tracker.revisionRestored('a', { + lexical: serialize(doc([paragraph('A restored')])), + title: 'A restored', + custom_excerpt: null, + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + }); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + tracker.setLive('b', { lexical: serialize(doc([paragraph('B typed')])) }); + expect(codes(tracker)).toEqual(['BASELINE_PENDING']); + }); + + it('is inert after dispose', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { title: 'Edited' }); + tracker.dispose(); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + expect(tracker.hasChangedSinceRevision(null)).toBe(false); + + tracker.load(POST_ID, post()); + tracker.setLive(POST_ID, { title: 'Edited again' }); + tracker.markSaveError('boom'); + tracker.setSaved(POST_ID, post()); + tracker.saveAcknowledged(POST_ID, post(), post()); + tracker.setBaseline(POST_ID, serialize(SAVED_DOC)); + tracker.baselineFailed(POST_ID, 'boom'); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }); + }); + + describe('revision restore', () => { + const restored = { + lexical: serialize(doc([paragraph('Restored')])), + title: 'Restored title', + custom_excerpt: 'Restored excerpt', + feature_image: 'https://site.example/restored.jpg', + feature_image_alt: 'Restored alt', + feature_image_caption: 'Restored caption', + }; + + it('adopts the full restored projection into saved and live', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { + lexical: serialize(appendParagraph(SAVED_DOC, 'Edit')), + title: 'Edited', + custom_excerpt: 'Edited excerpt', + }); + expect(tracker.verdict().dirty).toBe(true); + + tracker.revisionRestored(POST_ID, restored); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + + tracker.setLive(POST_ID, { lexical: restored.lexical, title: restored.title }); + expect(tracker.verdict().dirty).toBe(false); + + tracker.setLive(POST_ID, { title: 'Edited after restore' }); + expect(tracker.verdict().reasons[0]).toMatchObject({ + code: 'POST_TITLE_DIVERGED', + context: { current: 'Restored title', scratch: 'Edited after restore' }, + }); + }); + + it('waits for the hidden editor to re-report after an old-schema restore', () => { + const [fixture] = OLD_SCHEMA_CORPUS; + const tracker = loadedTracker(); + tracker.revisionRestored(POST_ID, { ...restored, lexical: serialize(fixture.before) }); + + tracker.setLive(POST_ID, { lexical: serialize(fixture.after) }); + expect(codes(tracker)).toEqual(['BASELINE_PENDING']); + + tracker.setBaseline(POST_ID, serialize(fixture.after)); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + + tracker.setLive(POST_ID, { + lexical: serialize(appendParagraph(fixture.after, 'After restore')), + }); + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it('reports dirty when a later save fails', () => { + const tracker = loadedTracker(); + tracker.revisionRestored(POST_ID, restored); + tracker.markSaveError(['Server error']); + + expect(codes(tracker)).toEqual(['POST_HAS_ERROR']); + }); + }); + + describe('hasChangedSinceRevision', () => { + const siteUrl = 'https://site.example'; + const absolute = serialize( + doc([ + { + type: 'image', + version: 1, + src: `${siteUrl}/content/images/a.jpg`, + href: `${siteUrl}/about/`, + }, + ]), + ); + const relative = serialize( + doc([{ type: 'image', version: 1, src: '/content/images/a.jpg', href: '/about/' }]), + ); + const revision = (overrides: Partial = {}) => { + const saved = post(overrides); + return { + lexical: saved.lexical, + title: saved.title, + custom_excerpt: saved.custom_excerpt, + feature_image: saved.feature_image, + }; + }; + + function trackerWithSite(saved: EditablePostProjection) { + const tracker = createChangeTracker({ siteUrl }); + tracker.load(POST_ID, saved); + tracker.setBaseline(POST_ID, saved.lexical); + tracker.setLive(POST_ID, { lexical: saved.lexical }); + return tracker; + } + + it('is true when there is no revision yet', () => { + const tracker = loadedTracker(); + + expect(tracker.hasChangedSinceRevision(undefined)).toBe(true); + expect(tracker.hasChangedSinceRevision(null)).toBe(true); + }); + + it('is false for a new post', () => { + const tracker = createChangeTracker({ siteUrl }); + tracker.load(null, post({ lexical: absolute })); + + expect(tracker.hasChangedSinceRevision(revision({ lexical: relative }))).toBe(false); + }); + + it('normalizes site URLs on both sides before comparing', () => { + const tracker = trackerWithSite(post({ lexical: absolute })); + + expect(tracker.hasChangedSinceRevision(revision({ lexical: relative }))).toBe(false); + expect(tracker.hasChangedSinceRevision(revision({ lexical: absolute }))).toBe(false); + }); + + it('compares the saved state, not the live scratch', () => { + const tracker = trackerWithSite(post({ lexical: absolute })); + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(SAVED_DOC, 'Unsaved')) }); + + expect(tracker.hasChangedSinceRevision(revision({ lexical: absolute }))).toBe(false); + }); + + it('detects a saved body change since the revision', () => { + const tracker = trackerWithSite(post({ lexical: absolute })); + + expect(tracker.hasChangedSinceRevision(revision())).toBe(true); + }); + + it.each([ + ['title', { title: 'Renamed' }], + ['custom_excerpt', { custom_excerpt: 'Excerpt' }], + ['feature_image', { feature_image: 'https://site.example/a.jpg' }], + ] as const)('detects a %s change without a body change', (_field, change) => { + const tracker = loadedTracker(post(change)); + + expect(tracker.hasChangedSinceRevision(revision())).toBe(true); + expect(tracker.hasChangedSinceRevision(revision(change))).toBe(false); + }); + + it('treats a missing revision excerpt or feature image as null', () => { + const tracker = loadedTracker(); + + expect( + tracker.hasChangedSinceRevision({ lexical: serialize(SAVED_DOC), title: 'Title' }), + ).toBe(false); + }); + + it('ignores key order and direction in the body', () => { + const tracker = loadedTracker( + post({ lexical: serialize(doc([paragraph('Hello', 'ltr')], 'ltr')) }), + ); + const reordered = JSON.stringify({ + root: { + version: 1, + type: 'root', + indent: 0, + format: '', + direction: null, + children: [paragraph('Hello')], + }, + }); + + expect(tracker.hasChangedSinceRevision(revision({ lexical: reordered }))).toBe(false); + }); + + it('fails closed when the revision body cannot be parsed', () => { + const tracker = loadedTracker(); + + expect(tracker.hasChangedSinceRevision(revision({ lexical: '{}' }))).toBe(true); + }); + }); + + describe('site URL normalization', () => { + const link = (href: string) => ({ + children: [textNode('link')], + direction: null, + format: '', + indent: 0, + type: 'link', + version: 1, + rel: null, + target: null, + title: null, + url: href, + }); + const withLink = (href: string) => + serialize(doc([{ ...paragraph('See the '), children: [textNode('See the '), link(href)] }])); + + function trackerWithSite(siteUrl: string, savedLexical: string) { + const tracker = createChangeTracker({ siteUrl }); + tracker.load(POST_ID, post({ lexical: savedLexical })); + tracker.setBaseline(POST_ID, savedLexical); + return tracker; + } + + it.each(['https://site.example', 'https://site.example/'])( + 'treats a relative live link and its absolute saved form as the same content (%s)', + (siteUrl) => { + const tracker = trackerWithSite(siteUrl, withLink('https://site.example/about/')); + tracker.setLive(POST_ID, { lexical: withLink('/about/') }); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }, + ); + + it.each(['https://site.example/blog', 'https://site.example/blog/'])( + 'keeps the subdirectory of a subdirectory install (%s)', + (siteUrl) => { + const tracker = trackerWithSite(siteUrl, withLink('https://site.example/blog/about/')); + tracker.setLive(POST_ID, { lexical: withLink('/blog/about/') }); + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + + tracker.setLive(POST_ID, { lexical: withLink('https://site.example/other/') }); + expect(tracker.verdict().dirty).toBe(true); + }, + ); + + it('normalizes card URL properties', () => { + const siteUrl = 'https://site.example'; + const image = (prefix: string) => + serialize( + doc([ + { + type: 'image', + version: 1, + src: `${prefix}/content/images/a.jpg`, + href: `${prefix}/about/`, + caption: 'Caption', + }, + ]), + ); + const tracker = trackerWithSite(siteUrl, image(siteUrl)); + tracker.setLive(POST_ID, { lexical: image('') }); + + expect(tracker.verdict()).toEqual({ dirty: false, reasons: [] }); + }); + + it('still detects a changed link', () => { + const tracker = trackerWithSite( + 'https://site.example', + withLink('https://site.example/about/'), + ); + tracker.setLive(POST_ID, { lexical: withLink('/contact/') }); + + expect(tracker.verdict({ includeDiff: true })).toEqual({ + dirty: true, + reasons: [expect.objectContaining({ code: 'SCRATCH_DIVERGED_FROM_SECONDARY' })], + diff: [ + { + type: 'CHANGE', + path: 'root.children.0[paragraph].children.1[link].url', + value: '/contact/', + oldValue: '/about/', + }, + ], + }); + }); + + it('keeps a literal site URL deleted from prose dirty', () => { + const tracker = trackerWithSite( + 'https://site.example', + serialize(doc([paragraph('Read https://site.example')])), + ); + tracker.setLive(POST_ID, { lexical: serialize(doc([paragraph('Read ')])) }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it.each([ + [ + 'codeblock', + { type: 'codeblock', version: 1, language: '', code: 'fetch("https://site.example/api/")' }, + ], + ['html', { type: 'html', version: 1, html: 'x' }], + ['markdown', { type: 'markdown', version: 1, markdown: '[x](https://site.example/about/)' }], + [ + 'image caption', + { type: 'image', version: 1, src: '/a.jpg', caption: 'See https://site.example/about/' }, + ], + [ + 'call-to-action', + { type: 'call-to-action', version: 1, buttonUrl: 'https://site.example/about/' }, + ], + ])('keeps a literal URL edited in %s dirty', (_name, node) => { + const siteUrl = 'https://site.example'; + const tracker = trackerWithSite(siteUrl, serialize(doc([node]))); + const edited = JSON.parse(JSON.stringify(node).replaceAll(siteUrl, '')) as Record< + string, + unknown + >; + tracker.setLive(POST_ID, { lexical: serialize(doc([edited])) }); + + expect(codes(tracker)).toEqual(['SCRATCH_DIVERGED_FROM_SECONDARY']); + }); + + it('compares verbatim when no site URL is configured', () => { + const tracker = trackerWithSite('', withLink('https://site.example/about/')); + tracker.setLive(POST_ID, { lexical: withLink('/about/') }); + + expect(tracker.verdict().dirty).toBe(true); + }); + }); + + describe('diff', () => { + it('is omitted unless requested and the content diverged', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { title: 'Edited' }); + + expect(tracker.verdict({ includeDiff: true }).diff).toBeUndefined(); + + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(SAVED_DOC, 'Edit')) }); + expect(tracker.verdict().diff).toBeUndefined(); + }); + + it('is omitted while the baseline is pending', () => { + const tracker = createChangeTracker(); + tracker.load(POST_ID, post()); + tracker.setLive(POST_ID, { lexical: serialize(appendParagraph(SAVED_DOC, 'Edit')) }); + + expect(tracker.verdict({ includeDiff: true }).diff).toBeUndefined(); + }); + + it('humanizes the baseline-to-live difference with node types', () => { + const tracker = loadedTracker(); + tracker.setLive(POST_ID, { + lexical: serialize(doc([paragraph('Hello world', 'ltr')], 'ltr')), + }); + + expect(tracker.verdict({ includeDiff: true }).diff).toEqual([ + { + type: 'CHANGE', + path: 'root.children.0[paragraph].children.0[extended-text].text', + value: 'Hello world', + oldValue: 'Hello', + }, + ]); + }); + + it('reports a deleted block as a removal', () => { + const tracker = loadedTracker( + post({ lexical: serialize(doc([paragraph('Hello'), paragraph('Gone')])) }), + ); + tracker.setLive(POST_ID, { lexical: serialize(doc([paragraph('Hello')])) }); + + expect(tracker.verdict({ includeDiff: true }).diff).toEqual([ + { + type: 'REMOVE', + path: 'root.children.1[paragraph]', + oldValue: stripDirection(paragraph('Gone')), + }, + ]); + }); + }); +}); diff --git a/apps/admin/src/editor/engine/change-tracker.ts b/apps/admin/src/editor/engine/change-tracker.ts new file mode 100644 index 00000000000..9c6a9cfbef5 --- /dev/null +++ b/apps/admin/src/editor/engine/change-tracker.ts @@ -0,0 +1,490 @@ +import { dequal } from 'dequal'; +import { + humanizeLexicalDiff, + lexicalEquals, + type HumanizedDiffEntry, + type LexicalInput, +} from '@/editor/engine/lexical-compare'; + +// Codes are reported to Sentry when the leave modal opens; keep them stable. +export type ChangeReasonCode = + | 'POST_HAS_ERROR' + | 'POST_TAGS_DIVERGED' + | 'POST_TITLE_DIVERGED' + | 'SCRATCH_DIVERGED_FROM_SECONDARY' + | 'BASELINE_PENDING' + | 'BASELINE_FAILED' + | 'LEXICAL_PARSE_FAILED' + | 'NEW_POST_HAS_CHANGED_ATTRIBUTES' + | 'POST_HAS_DIRTY_ATTRIBUTES'; + +export interface ChangeReason { + code: ChangeReasonCode; + reason: string; + context: Record; +} + +export interface ChangeVerdict { + dirty: boolean; + reasons: ChangeReason[]; + diff?: HumanizedDiffEntry[]; +} + +/** null until the create request has been acknowledged. */ +export type PostId = string | null; + +export interface PostTagLike { + name?: string; +} + +// Client-owned editable fields only; other server metadata lives with the save engine. +export interface EditablePostProjection { + title: string; + slug: string; + lexical: string | null; + tags: ReadonlyArray; + custom_excerpt: string | null; + feature_image: string | null; + feature_image_alt: string | null; + feature_image_caption: string | null; + /** Server collision token: carried and rebased, never a dirty signal. */ + updated_at: string | null; +} + +export type EditablePostPatch = Partial; + +export type RestoredRevision = Pick< + EditablePostProjection, + | 'lexical' + | 'title' + | 'custom_excerpt' + | 'feature_image' + | 'feature_image_alt' + | 'feature_image_caption' +>; + +// The server's own revision projection (post-revisions.ts). +export interface RevisionProjection { + lexical: string | null; + title: string; + custom_excerpt?: string | null; + feature_image?: string | null; +} + +export interface VerdictOptions { + includeDiff?: boolean; +} + +export interface ChangeTrackerOptions { + siteUrl?: string; +} + +export interface ChangeTracker { + load(postId: PostId, post: EditablePostProjection): void; + setSaved(postId: PostId, post: EditablePostProjection): void; + saveAcknowledged( + postId: PostId, + submitted: EditablePostPatch, + acknowledged: EditablePostProjection, + ): void; + setBaseline(postId: PostId, lexical: LexicalInput): void; + baselineFailed(postId: PostId, error: unknown): void; + setLive(postId: PostId, patch: EditablePostPatch): void; + markSaveError(messages?: unknown): void; + clearSaveError(): void; + revisionRestored(postId: PostId, restored: RestoredRevision): void; + verdict(options?: VerdictOptions): ChangeVerdict; + hasChangedSinceRevision(latestRevision: RevisionProjection | null | undefined): boolean; + dispose(): void; +} + +type ProjectionKey = keyof EditablePostProjection; + +const PROJECTION_KEYS: ReadonlyArray = [ + 'title', + 'slug', + 'lexical', + 'tags', + 'custom_excerpt', + 'feature_image', + 'feature_image_alt', + 'feature_image_caption', + 'updated_at', +]; + +const RUNG_KEYS: ReadonlySet = new Set(['title', 'lexical', 'tags', 'updated_at']); + +type Baseline = + | { status: 'pending' } + | { status: 'ready'; lexical: string | null } + | { status: 'failed'; error: string }; + +interface SaveError { + messages: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function clonePlain(value: T): T { + if (Array.isArray(value)) { + return value.map(clonePlain) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, clonePlain(entry)]), + ) as T; + } + return value; +} + +function pickProjection(post: EditablePostProjection): EditablePostProjection { + const out: Record = {}; + for (const key of PROJECTION_KEYS) { + out[key] = clonePlain(post[key]); + } + return out as unknown as EditablePostProjection; +} + +function pickPatch(patch: EditablePostPatch): EditablePostPatch { + const out: Record = {}; + for (const key of PROJECTION_KEYS) { + if (key in patch && patch[key] !== undefined) { + out[key] = clonePlain(patch[key]); + } + } + return out; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function serializeLexical(lexical: LexicalInput): string | null { + if (lexical === null || lexical === undefined) { + return null; + } + return typeof lexical === 'string' ? lexical : JSON.stringify(lexical); +} + +function tagNames(tags: ReadonlyArray | undefined): string[] { + return (tags ?? []).map((tag) => tag.name ?? ''); +} + +function isOlderToken(candidate: string | null, held: string | null): boolean { + if (candidate === null || held === null) { + return false; + } + const candidateTime = Date.parse(candidate); + const heldTime = Date.parse(held); + return !Number.isNaN(candidateTime) && !Number.isNaN(heldTime) && candidateTime < heldTime; +} + +export function createChangeTracker(options: ChangeTrackerOptions = {}): ChangeTracker { + const siteUrl = options.siteUrl ?? ''; + let postId: PostId = null; + let idAdopted = false; + const heldIds = new Set(); + let saved: EditablePostProjection | null = null; + let live: EditablePostProjection | null = null; + let baseline: Baseline = { status: 'pending' }; + let saveError: SaveError | null = null; + let disposed = false; + + function sameLexical(a: string | null, b: string | null): boolean { + return lexicalEquals(a, b, siteUrl); + } + + function sameField(key: ProjectionKey, a: unknown, b: unknown): boolean { + if (key === 'lexical') { + try { + return sameLexical(a as string | null, b as string | null); + } catch { + return false; + } + } + if (key === 'tags') { + return dequal( + tagNames(a as ReadonlyArray), + tagNames(b as ReadonlyArray), + ); + } + return dequal(a, b); + } + + function isCurrent(id: PostId): boolean { + return !disposed && saved !== null && id === postId; + } + + // Editor-side events may still say null between a create ack and the caller learning the id. + function isCurrentOrAlias(id: PostId): boolean { + return isCurrent(id) || (id === null && idAdopted && !disposed && saved !== null); + } + + function changedAttributes(): Record { + const changed: Record = {}; + if (!saved || !live) { + return changed; + } + for (const key of PROJECTION_KEYS) { + if (!RUNG_KEYS.has(key) && !sameField(key, saved[key], live[key])) { + changed[key] = [saved[key], live[key]]; + } + } + return changed; + } + + function collectReasons(): ChangeReason[] { + if (!saved || !live) { + return []; + } + + const reasons: ChangeReason[] = []; + + if (saveError) { + reasons.push({ + code: 'POST_HAS_ERROR', + reason: 'isError', + context: { messages: saveError.messages }, + }); + } + + const currentTags = tagNames(live.tags); + const previousTags = tagNames(saved.tags); + if (!dequal(currentTags, previousTags)) { + reasons.push({ + code: 'POST_TAGS_DIVERGED', + reason: 'tags are different', + context: { currentTags, previousTags }, + }); + } + + if (live.title.trim() !== saved.title.trim()) { + reasons.push({ + code: 'POST_TITLE_DIVERGED', + reason: 'title is different', + context: { current: saved.title, scratch: live.title }, + }); + } + + const scratch = live.lexical; + try { + if (!sameLexical(saved.lexical, scratch)) { + if (baseline.status === 'pending') { + reasons.push({ + code: 'BASELINE_PENDING', + reason: + 'main editor content has diverged from saved content before the hidden editor reported', + context: { lexical: saved.lexical, scratch }, + }); + } else if (baseline.status === 'failed') { + reasons.push({ + code: 'BASELINE_FAILED', + reason: + 'main editor content has diverged from saved content and the hidden editor failed', + context: { lexical: saved.lexical, scratch, error: baseline.error }, + }); + } else if (!sameLexical(baseline.lexical, scratch)) { + reasons.push({ + code: 'SCRATCH_DIVERGED_FROM_SECONDARY', + reason: 'main editor content has diverged from both hidden editor and saved content', + context: { secondaryLexical: baseline.lexical, lexical: saved.lexical, scratch }, + }); + } + } + } catch (error) { + reasons.push({ + code: 'LEXICAL_PARSE_FAILED', + reason: 'lexical state could not be parsed for comparison', + context: { error: errorMessage(error) }, + }); + } + + const changed = changedAttributes(); + if (Object.keys(changed).length > 0) { + reasons.push( + postId === null + ? { + code: 'NEW_POST_HAS_CHANGED_ATTRIBUTES', + reason: 'post.changedAttributes.length > 0', + context: changed, + } + : { + code: 'POST_HAS_DIRTY_ATTRIBUTES', + reason: 'post.hasDirtyAttributes === true', + context: changed, + }, + ); + } + + return reasons; + } + + return { + load(id, post) { + if (disposed) { + return; + } + postId = id; + idAdopted = false; + if (id !== null) { + heldIds.add(id); + } + saved = pickProjection(post); + live = pickProjection(post); + baseline = { status: 'pending' }; + saveError = null; + }, + + // Query data (load, refetch) never moves the baseline or the live state; + // a refetch older than the held collision token is stale and dropped. + setSaved(id, post) { + if (!isCurrent(id) || !saved || !live) { + return; + } + const next = pickProjection(post); + if (isOlderToken(next.updated_at, saved.updated_at)) { + return; + } + saved = next; + live = { ...live, updated_at: next.updated_at }; + }, + + // Single-flight save engine with one coalescing pending slot: acknowledgements + // arrive in submit order, so no save-attempt id is needed here. + // Callers must build a fresh tracker per load(null) or fence stale completions + // themselves; a new post only refuses acks for ids this tracker has already held. + saveAcknowledged(id, submitted, acknowledged) { + if (disposed || !saved || !live || (postId !== null && id !== postId)) { + return; + } + if (postId === null && id !== null && heldIds.has(id)) { + return; + } + const next = pickProjection(acknowledged); + const rebased: Record = { ...live }; + for (const key of PROJECTION_KEYS) { + const base = submitted[key] !== undefined ? submitted[key] : saved[key]; + if (key === 'updated_at' || sameField(key, live[key], base)) { + rebased[key] = next[key]; + } + } + if (postId === null && id !== null) { + idAdopted = true; + heldIds.add(id); + } + postId = id; + saved = next; + live = rebased as unknown as EditablePostProjection; + baseline = { status: 'ready', lexical: next.lexical }; + saveError = null; + }, + + setBaseline(id, lexical) { + if (!isCurrentOrAlias(id)) { + return; + } + baseline = { status: 'ready', lexical: serializeLexical(lexical) }; + }, + + baselineFailed(id, error) { + if (!isCurrentOrAlias(id)) { + return; + } + baseline = { status: 'failed', error: errorMessage(error) }; + }, + + setLive(id, patch) { + if (!isCurrentOrAlias(id) || !live) { + return; + } + const defined = pickPatch(patch); + delete defined.updated_at; + live = { ...live, ...defined }; + }, + + markSaveError(messages) { + if (disposed) { + return; + } + saveError = { messages }; + }, + + clearSaveError() { + if (disposed) { + return; + } + saveError = null; + }, + + // Call only after the restore save is acknowledged; a failed restore never reaches here. + revisionRestored(id, restored) { + if (!isCurrent(id) || !saved || !live) { + return; + } + const adopted = clonePlain({ + lexical: restored.lexical, + title: restored.title, + custom_excerpt: restored.custom_excerpt, + feature_image: restored.feature_image, + feature_image_alt: restored.feature_image_alt, + feature_image_caption: restored.feature_image_caption, + }); + saved = { ...saved, ...adopted }; + live = { ...live, ...adopted }; + baseline = { status: 'pending' }; + saveError = null; + }, + + verdict({ includeDiff = false } = {}) { + const reasons = collectReasons(); + const result: ChangeVerdict = { dirty: reasons.length > 0, reasons }; + + if ( + includeDiff && + baseline.status === 'ready' && + reasons.some((r) => r.code === 'SCRATCH_DIVERGED_FROM_SECONDARY') + ) { + result.diff = humanizeLexicalDiff(baseline.lexical, live?.lexical, siteUrl); + } + + return result; + }, + + hasChangedSinceRevision(latestRevision) { + if (disposed || !saved) { + return false; + } + if (!latestRevision) { + return true; + } + if (postId === null) { + return false; + } + if ( + saved.title !== latestRevision.title || + saved.custom_excerpt !== (latestRevision.custom_excerpt ?? null) || + saved.feature_image !== (latestRevision.feature_image ?? null) + ) { + return true; + } + try { + return !sameLexical(saved.lexical, latestRevision.lexical); + } catch { + return true; + } + }, + + dispose() { + disposed = true; + postId = null; + idAdopted = false; + heldIds.clear(); + saved = null; + live = null; + baseline = { status: 'pending' }; + saveError = null; + }, + }; +} diff --git a/apps/admin/src/editor/engine/lexical-compare.test.ts b/apps/admin/src/editor/engine/lexical-compare.test.ts new file mode 100644 index 00000000000..bbd31bca91a --- /dev/null +++ b/apps/admin/src/editor/engine/lexical-compare.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest'; +import { + humanizeLexicalDiff, + lexicalEquals, + LexicalParseError, + normalizeLexicalForCompare, + normalizeSiteUrls, + parseLexical, + stripDirection, +} from '@/editor/engine/lexical-compare'; + +const textNode = (text: string) => ({ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text, + type: 'extended-text', + version: 1, +}); + +const paragraph = (text: string, direction: string | null = null) => ({ + children: [textNode(text)], + direction, + format: '', + indent: 0, + type: 'paragraph', + version: 1, +}); + +const doc = (children: unknown[], direction: string | null = null) => ({ + root: { children, direction, format: '', indent: 0, type: 'root', version: 1 }, +}); + +describe('stripDirection', () => { + it('removes direction from element nodes at every nesting level', () => { + const input = { + direction: 'ltr', + children: [{ direction: 'ltr', children: [{ direction: null, text: 'x' }] }], + }; + + expect(stripDirection(input)).toEqual({ + children: [{ children: [{ direction: null, text: 'x' }] }], + }); + }); + + it('does not reach into card payloads', () => { + const card = { type: 'html', html: '

x

', visibility: { direction: 'keep' } }; + + expect(stripDirection({ children: [card] })).toEqual({ children: [card] }); + }); + + it('leaves primitives and arrays of primitives untouched', () => { + expect(stripDirection('a')).toBe('a'); + expect(stripDirection([1, null, 'b'])).toEqual([1, null, 'b']); + }); +}); + +describe('normalizeLexicalForCompare', () => { + it('accepts strings and objects and yields the same output', () => { + const asObject = doc([paragraph('Hello')]); + + expect(normalizeLexicalForCompare(JSON.stringify(asObject))).toBe( + normalizeLexicalForCompare(asObject), + ); + }); + + it('is independent of key order', () => { + const a = JSON.stringify(doc([paragraph('Hello')])); + const b = JSON.stringify({ + root: { + version: 1, + type: 'root', + indent: 0, + format: '', + direction: null, + children: [ + { + version: 1, + type: 'paragraph', + indent: 0, + format: '', + direction: null, + children: [{ ...textNode('Hello') }], + }, + ], + }, + }); + + expect(normalizeLexicalForCompare(a)).toBe(normalizeLexicalForCompare(b)); + }); + + it('treats null, undefined, empty string and a childless root alike', () => { + expect(normalizeLexicalForCompare(null)).toBe('[]'); + expect(normalizeLexicalForCompare(undefined)).toBe('[]'); + expect(normalizeLexicalForCompare('')).toBe('[]'); + expect(normalizeLexicalForCompare(doc([]))).toBe('[]'); + }); +}); + +describe('lexicalEquals', () => { + it('ignores direction differences at the root and at depth', () => { + const nullDirections = doc([ + paragraph('Hello'), + { + children: [{ ...paragraph('Nested'), type: 'listitem', value: 1 }], + direction: null, + format: '', + indent: 0, + type: 'list', + version: 1, + listType: 'bullet', + start: 1, + tag: 'ul', + }, + ]); + const ltrDirections = doc( + [ + paragraph('Hello', 'ltr'), + { + children: [{ ...paragraph('Nested', 'ltr'), type: 'listitem', value: 1 }], + direction: 'ltr', + format: '', + indent: 0, + type: 'list', + version: 1, + listType: 'bullet', + start: 1, + tag: 'ul', + }, + ], + 'ltr', + ); + + expect(lexicalEquals(nullDirections, ltrDirections)).toBe(true); + }); + + it('detects text edits', () => { + expect(lexicalEquals(doc([paragraph('Hello')]), doc([paragraph('Hello!')]))).toBe(false); + }); + + it('detects added and removed blocks', () => { + expect(lexicalEquals(doc([paragraph('A')]), doc([paragraph('A'), paragraph('B')]))).toBe(false); + }); +}); + +describe('parseLexical', () => { + it('treats null, undefined and an empty string as a known empty document', () => { + expect(parseLexical(null)).toBeNull(); + expect(parseLexical(undefined)).toBeNull(); + expect(parseLexical('')).toBeNull(); + }); + + it('accepts a serialized or parsed document with a children array', () => { + expect(parseLexical(doc([]))).toEqual(doc([])); + expect(parseLexical(JSON.stringify(doc([])))).toEqual(doc([])); + }); + + it('throws a typed error for invalid JSON', () => { + expect(() => parseLexical('{not json')).toThrow(LexicalParseError); + }); + + it.each(['{}', '{"root":{}}', '{"root":{"children":"invalid"}}', '[]', 'null', '"x"'])( + 'throws a typed error for the structurally invalid document %s', + (invalid) => { + expect(() => parseLexical(invalid)).toThrow(LexicalParseError); + expect(() => lexicalEquals(invalid, doc([]))).toThrow(LexicalParseError); + expect(() => lexicalEquals(doc([]), invalid)).toThrow(LexicalParseError); + expect(() => humanizeLexicalDiff(invalid, doc([]))).toThrow(LexicalParseError); + }, + ); + + it('rejects a parsed object without a children array', () => { + expect(() => parseLexical({ root: { children: 'invalid' } })).toThrow( + 'lexical root must be an object with a children array', + ); + }); +}); + +describe('normalizeSiteUrls', () => { + const siteUrl = 'https://site.example'; + + it('rewrites link node urls to the site-relative form', () => { + const link = { type: 'link', url: `${siteUrl}/about/?q=1#top`, children: [] }; + + expect(normalizeSiteUrls([link], siteUrl)).toEqual([ + { type: 'link', url: '/about/?q=1#top', children: [] }, + ]); + }); + + it('rewrites only the url-typed properties of cards', () => { + const cards = [ + { + type: 'image', + src: `${siteUrl}/a.jpg`, + href: `${siteUrl}/about/`, + caption: `See ${siteUrl}/about/`, + }, + { + type: 'bookmark', + url: `${siteUrl}/post/`, + metadata: { + icon: `${siteUrl}/icon.png`, + thumbnail: `${siteUrl}/thumb.png`, + title: siteUrl, + }, + }, + { + type: 'gallery', + images: [{ src: `${siteUrl}/g.jpg`, caption: siteUrl }], + caption: siteUrl, + }, + { type: 'html', html: `x` }, + { type: 'markdown', markdown: `[x](${siteUrl}/about/)` }, + { type: 'call-to-action', buttonUrl: `${siteUrl}/about/` }, + ]; + + expect(normalizeSiteUrls(cards, siteUrl)).toEqual([ + { type: 'image', src: '/a.jpg', href: '/about/', caption: `See ${siteUrl}/about/` }, + { + type: 'bookmark', + url: '/post/', + metadata: { icon: '/icon.png', thumbnail: '/thumb.png', title: siteUrl }, + }, + { type: 'gallery', images: [{ src: '/g.jpg', caption: siteUrl }], caption: siteUrl }, + { type: 'html', html: `x` }, + { type: 'markdown', markdown: `[x](${siteUrl}/about/)` }, + { type: 'call-to-action', buttonUrl: `${siteUrl}/about/` }, + ]); + }); + + it('never rewrites text', () => { + const nodes = [paragraph(`Read ${siteUrl}`)]; + + expect(normalizeSiteUrls(nodes, siteUrl)).toEqual(nodes); + }); + + it('descends into element children', () => { + const nodes = [ + { ...paragraph('x'), children: [{ type: 'link', url: `${siteUrl}/a/`, children: [] }] }, + ]; + + expect(normalizeSiteUrls(nodes, siteUrl)).toEqual([ + { ...paragraph('x'), children: [{ type: 'link', url: '/a/', children: [] }] }, + ]); + }); + + it('leaves other hosts, relative urls and non-http urls alone', () => { + const nodes = [ + { type: 'link', url: 'https://other.example/about/', children: [] }, + { type: 'link', url: '/about/', children: [] }, + { type: 'link', url: 'mailto:hi@site.example', children: [] }, + { type: 'link', url: 'https://site.example.evil/', children: [] }, + ]; + + expect(normalizeSiteUrls(nodes, siteUrl)).toEqual(nodes); + }); + + it('resolves protocol-relative urls against the site protocol', () => { + const nodes = [ + { type: 'link', url: '//site.example/about/', children: [] }, + { type: 'link', url: '//other.example/about/', children: [] }, + ]; + + expect(normalizeSiteUrls(nodes, siteUrl)).toEqual([ + { type: 'link', url: '/about/', children: [] }, + { type: 'link', url: '//other.example/about/', children: [] }, + ]); + }); + + it('leaves urls carrying credentials alone', () => { + const nodes = [ + { type: 'link', url: 'https://user:pw@site.example/x', children: [] }, + { type: 'link', url: 'https://other:pw@site.example/x', children: [] }, + ]; + + expect(normalizeSiteUrls(nodes, siteUrl)).toEqual(nodes); + }); + + it('ignores the protocol and tolerates a trailing slash on the site url', () => { + const nodes = [{ type: 'link', url: 'http://site.example/about/', children: [] }]; + + expect(normalizeSiteUrls(nodes, 'https://site.example/')).toEqual([ + { type: 'link', url: '/about/', children: [] }, + ]); + }); + + it('keeps the subdirectory of a subdirectory install and ignores paths outside it', () => { + const nodes = [ + { type: 'link', url: 'https://site.example/blog/about/', children: [] }, + { type: 'link', url: 'https://site.example/blog', children: [] }, + { type: 'link', url: 'https://site.example/other/', children: [] }, + { type: 'link', url: 'https://site.example/blogger/x', children: [] }, + ]; + + for (const subdirectorySite of ['https://site.example/blog', 'https://site.example/blog/']) { + expect(normalizeSiteUrls(nodes, subdirectorySite)).toEqual([ + { type: 'link', url: '/blog/about/', children: [] }, + { type: 'link', url: '/blog', children: [] }, + { type: 'link', url: 'https://site.example/other/', children: [] }, + { type: 'link', url: 'https://site.example/blogger/x', children: [] }, + ]); + } + }); + + it('returns the input untouched without a valid site url', () => { + const nodes = [{ type: 'link', url: `${siteUrl}/about/`, children: [] }]; + + expect(normalizeSiteUrls(nodes, '')).toBe(nodes); + expect(normalizeSiteUrls(nodes, 'not a url')).toBe(nodes); + }); + + it('does not mutate the input', () => { + const nodes = [{ type: 'image', src: `${siteUrl}/a.jpg` }]; + normalizeSiteUrls(nodes, siteUrl); + + expect(nodes[0]?.src).toBe(`${siteUrl}/a.jpg`); + }); +}); + +describe('lexicalEquals with a site url', () => { + it('treats absolute and relative site urls as equal', () => { + const absolute = doc([{ type: 'image', version: 1, src: 'https://site.example/a.jpg' }]); + const relative = doc([{ type: 'image', version: 1, src: '/a.jpg' }]); + + expect(lexicalEquals(absolute, relative, 'https://site.example')).toBe(true); + expect(lexicalEquals(absolute, relative)).toBe(false); + }); +}); + +describe('humanizeLexicalDiff', () => { + it('annotates numeric path segments with the node type from the source document', () => { + const from = doc([paragraph('Hello')]); + const to = doc([paragraph('Hello world')]); + + expect(humanizeLexicalDiff(from, to)).toEqual([ + { + type: 'CHANGE', + path: 'root.children.0[paragraph].children.0[extended-text].text', + value: 'Hello world', + oldValue: 'Hello', + }, + ]); + }); + + it('reports added blocks with a CREATE entry and removed blocks with a REMOVE entry', () => { + const one = doc([paragraph('A')]); + const two = doc([paragraph('A'), paragraph('B')]); + + expect(humanizeLexicalDiff(one, two)).toEqual([ + { type: 'CREATE', path: 'root.children.1', value: stripDirection(paragraph('B')) }, + ]); + expect(humanizeLexicalDiff(two, one)).toEqual([ + { + type: 'REMOVE', + path: 'root.children.1[paragraph]', + oldValue: stripDirection(paragraph('B')), + }, + ]); + }); + + it('ignores direction-only differences', () => { + expect(humanizeLexicalDiff(doc([paragraph('A')]), doc([paragraph('A', 'ltr')], 'ltr'))).toEqual( + [], + ); + }); + + it('accepts serialized strings and missing documents', () => { + expect(humanizeLexicalDiff(JSON.stringify(doc([paragraph('A')])), null)).toEqual([ + { type: 'REMOVE', path: 'root', oldValue: stripDirection(doc([paragraph('A')]).root) }, + ]); + expect(humanizeLexicalDiff(null, doc([paragraph('A')]))).toEqual([ + { type: 'CREATE', path: 'root', value: stripDirection(doc([paragraph('A')]).root) }, + ]); + }); +}); diff --git a/apps/admin/src/editor/engine/lexical-compare.ts b/apps/admin/src/editor/engine/lexical-compare.ts new file mode 100644 index 00000000000..66c27036662 --- /dev/null +++ b/apps/admin/src/editor/engine/lexical-compare.ts @@ -0,0 +1,271 @@ +import microdiff from 'microdiff'; + +export type LexicalDocument = Record; +export type LexicalInput = string | LexicalDocument | null | undefined; + +export interface HumanizedDiffEntry { + type: 'CREATE' | 'REMOVE' | 'CHANGE'; + path: string; + value?: unknown; + oldValue?: unknown; +} + +export class LexicalParseError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'LexicalParseError'; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// null, undefined and '' are a known-empty document; anything else must carry +// an object root with a children array or it is invalid, never empty. +export function parseLexical(input: LexicalInput): LexicalDocument | null { + if (input === null || input === undefined || input === '') { + return null; + } + let parsed: unknown = input; + if (typeof input === 'string') { + try { + parsed = JSON.parse(input); + } catch (cause) { + const message = cause instanceof Error ? cause.message : 'invalid JSON'; + throw new LexicalParseError(message, { cause }); + } + } + if (!isRecord(parsed) || !isRecord(parsed.root) || !Array.isArray(parsed.root.children)) { + throw new LexicalParseError('lexical root must be an object with a children array'); + } + return parsed; +} + +// Lexical's reconciler infers element `direction` from rendered text at mount +// time, so it differs between a mounted editor, a headless parse, and saved JSON. +export function stripDirection(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripDirection); + } + if (isRecord(value) && Array.isArray(value.children)) { + const out: Record = {}; + for (const key of Object.keys(value)) { + if (key === 'direction') { + continue; + } + out[key] = key === 'children' ? stripDirection(value.children) : value[key]; + } + return out; + } + return value; +} + +type UrlField = 'url' | Record; + +// Mirrors kg-default-nodes' urlTransformMap (the properties the server rewrites +// on save), url-typed entries only; html/markdown/caption fields stay opaque. +const CARD_URL_FIELDS: Record> = { + audio: { src: 'url' }, + bookmark: { url: 'url', 'metadata.icon': 'url', 'metadata.thumbnail': 'url' }, + button: { buttonUrl: 'url' }, + 'email-cta': { buttonUrl: 'url' }, + embed: { url: 'url' }, + file: { src: 'url' }, + gallery: { images: { src: 'url' } }, + header: { buttonUrl: 'url', backgroundImageSrc: 'url' }, + image: { src: 'url', href: 'url' }, + product: { productImageSrc: 'url' }, + video: { src: 'url', thumbnailSrc: 'url', customThumbnailSrc: 'url' }, +}; + +function parseSiteUrl(siteUrl: string): URL | null { + if (!siteUrl) { + return null; + } + try { + return new URL(siteUrl); + } catch { + return null; + } +} + +function underSubdirectory(pathname: string, sitePathname: string): boolean { + const subdirectory = sitePathname.endsWith('/') ? sitePathname.slice(0, -1) : sitePathname; + return pathname === subdirectory || pathname.startsWith(`${subdirectory}/`); +} + +// Same rule as url-utils' absoluteToRelative: host match (protocol ignored), a path +// under the site's subdirectory (kept in the result), and userinfo URLs left alone. +function toSiteRelative(value: string, site: URL): string { + let parsed: URL; + try { + parsed = new URL(value.startsWith('//') ? `${site.protocol}${value}` : value); + } catch { + return value; + } + if (parsed.username || parsed.password) { + return value; + } + if (parsed.host !== site.host || !underSubdirectory(parsed.pathname, site.pathname)) { + return value; + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} + +function updateAt( + record: Record, + [head, ...rest]: string[], + update: (value: unknown) => unknown, +): Record { + if (head === undefined || !(head in record)) { + return record; + } + const current = record[head]; + if (rest.length === 0) { + return { ...record, [head]: update(current) }; + } + return isRecord(current) ? { ...record, [head]: updateAt(current, rest, update) } : record; +} + +function normalizeUrlField(value: unknown, field: UrlField, site: URL): unknown { + if (field === 'url') { + return typeof value === 'string' ? toSiteRelative(value, site) : value; + } + if (!Array.isArray(value)) { + return value; + } + const items: unknown[] = value; + return items.map((item) => (isRecord(item) ? normalizeUrlFields(item, field, site) : item)); +} + +function normalizeUrlFields( + node: Record, + fields: Record, + site: URL, +): Record { + let out = node; + for (const [path, field] of Object.entries(fields)) { + out = updateAt(out, path.split('.'), (value) => normalizeUrlField(value, field, site)); + } + return out; +} + +function normalizeNodeUrls(value: unknown, site: URL): unknown { + if (Array.isArray(value)) { + return value.map((item) => normalizeNodeUrls(item, site)); + } + if (!isRecord(value)) { + return value; + } + const fields = typeof value.type === 'string' ? CARD_URL_FIELDS[value.type] : undefined; + let out = fields ? normalizeUrlFields(value, fields, site) : value; + if (!fields && typeof value.url === 'string') { + out = { ...out, url: toSiteRelative(value.url, site) }; + } + if (Array.isArray(value.children)) { + out = { ...out, children: normalizeNodeUrls(value.children, site) }; + } + return out; +} + +export function normalizeSiteUrls(children: unknown[], siteUrl: string): unknown[] { + const site = parseSiteUrl(siteUrl); + return site ? (normalizeNodeUrls(children, site) as unknown[]) : children; +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (isRecord(value)) { + const keys = Object.keys(value).sort(); + const entries = keys + .filter((key) => value[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +function rootChildren(document: LexicalDocument | null): unknown[] { + const root = document?.root; + return isRecord(root) && Array.isArray(root.children) ? root.children : []; +} + +function comparableChildren(input: LexicalInput, siteUrl: string): unknown { + return stripDirection(normalizeSiteUrls(rootChildren(parseLexical(input)), siteUrl)); +} + +export function normalizeLexicalForCompare(input: LexicalInput, siteUrl = ''): string { + return stableStringify(comparableChildren(input, siteUrl)); +} + +export function lexicalEquals(a: LexicalInput, b: LexicalInput, siteUrl = ''): boolean { + return normalizeLexicalForCompare(a, siteUrl) === normalizeLexicalForCompare(b, siteUrl); +} + +function nodeAt(document: unknown, path: ReadonlyArray): unknown { + let current: unknown = document; + for (const segment of path) { + if (Array.isArray(current)) { + current = current[Number(segment)]; + } else if (isRecord(current)) { + current = current[String(segment)]; + } else { + return undefined; + } + } + return current; +} + +function humanizePath(path: ReadonlyArray, document: unknown): string { + return path + .map((segment, index) => { + if (typeof segment !== 'number') { + return segment; + } + const node = nodeAt(document, path.slice(0, index + 1)); + const type = isRecord(node) ? node.type : undefined; + return typeof type === 'string' ? `${segment}[${type}]` : String(segment); + }) + .join('.'); +} + +function comparableDocument(input: LexicalInput, siteUrl: string): LexicalDocument { + const document = parseLexical(input); + if (!document) { + return {}; + } + const root = document.root as Record; + const normalized = { + ...document, + root: { ...root, children: normalizeSiteUrls(rootChildren(document), siteUrl) }, + }; + return Object.fromEntries( + Object.entries(normalized).map(([key, value]) => [key, stripDirection(value)]), + ); +} + +export function humanizeLexicalDiff( + from: LexicalInput, + to: LexicalInput, + siteUrl = '', +): HumanizedDiffEntry[] { + const fromDocument = comparableDocument(from, siteUrl); + const toDocument = comparableDocument(to, siteUrl); + + return microdiff(fromDocument, toDocument, { cyclesFix: false }).map((change) => { + const entry: HumanizedDiffEntry = { + type: change.type, + path: humanizePath(change.path, fromDocument), + }; + if ('value' in change) { + entry.value = change.value; + } + if ('oldValue' in change) { + entry.oldValue = change.oldValue; + } + return entry; + }); +} diff --git a/apps/admin/src/editor/engine/save-engine.test.ts b/apps/admin/src/editor/engine/save-engine.test.ts new file mode 100644 index 00000000000..b12519833e1 --- /dev/null +++ b/apps/admin/src/editor/engine/save-engine.test.ts @@ -0,0 +1,1969 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deferred, type Deferred } from '@/utils/deferred'; +import { + AUTOSAVE_DEBOUNCE_MS, + createSaveEngine, + DEFAULT_TITLE, + deriveTarget, + resolveTarget, + TIMED_SAVE_INTERVAL_MS, + zeroMilliseconds, + type DispatchIntent, + type PostStatus, + type SaveCommand, + type SaveEngine, + type SaveEngineState, + type SaveError, + type SaveOutcome, + type SaveRequest, + type SaveResult, + type SaveSnapshot, + type SaveTarget, + type SlugPort, + type SlugProposal, +} from './save-engine'; + +const NOW = Date.parse('2026-09-02T12:00:00.000Z'); +const FUTURE = '2026-09-03T09:30:00.000Z'; +const PAST = '2026-09-01T09:30:00.000Z'; +const BASELINE = '2026-09-02T11:00:00.000Z'; + +const flush = () => vi.advanceTimersByTimeAsync(0); + +type SnapshotFields = Omit & { + id: string | null; + updatedAt: string | null; +}; + +const BASE: SnapshotFields = { + id: 'post-1', + updatedAt: BASELINE, + status: 'draft', + publishedAt: null, + title: 'Hello', + slug: 'hello', + isDirty: true, + changedSinceLastRevision: true, + version: 1, +}; + +const idleSlug: SlugPort = { + settled: () => Promise.resolve(), + fromTitle: () => Promise.resolve({ slug: '', source: 'unchanged' }), +}; + +function dispatchAny(engine: SaveEngine, kind: DispatchIntent) { + switch (kind) { + case 'schedule': + return engine.dispatch('schedule', { publishedAt: FUTURE }); + case 'publish': + return engine.dispatch('publish'); + default: + return engine.dispatch(kind); + } +} + +function setup(overrides: Partial = {}) { + let snapshot = { ...BASE, ...overrides } as SaveSnapshot; + const requests: SaveRequest[] = []; + const signals: AbortSignal[] = []; + const outstanding: Deferred[] = []; + const states: SaveEngineState[] = []; + const listenerErrors: unknown[] = []; + const slugRequests: Array<{ + title: string; + postId: string | null; + outcome: Deferred; + }> = []; + let concurrent = 0; + let maxConcurrent = 0; + let snapshotError: Error | null = null; + let slugSettled: Deferred | null = null; + let holdSlugRequests = false; + let sequence = 0; + + // Answers "unchanged" immediately unless a test holds the requests to answer them itself. + const slug: SlugPort = { + settled: vi.fn(() => (slugSettled ? slugSettled.promise : Promise.resolve())), + fromTitle: vi.fn((title: string, postId: string | null) => { + if (!holdSlugRequests) { + return Promise.resolve({ slug: snapshot.slug, source: 'unchanged' }); + } + const outcome = deferred(); + slugRequests.push({ title, postId, outcome }); + return outcome.promise; + }), + }; + + const prepare = vi.fn((request: SaveRequest) => Promise.resolve(request)); + + const execute = vi.fn(async (prepared: SaveRequest, signal: AbortSignal) => { + requests.push(prepared); + signals.push(signal); + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + const outcome = deferred(); + outstanding.push(outcome); + try { + return await outcome.promise; + } finally { + concurrent -= 1; + } + }); + + // Adopts the response; edits made after the request left keep the post dirty. + const reconcile = vi.fn((prepared: SaveRequest, result: SaveResult) => { + const editedInFlight = snapshot.version !== prepared.snapshot.version; + snapshot = { + ...snapshot, + id: result.id, + updatedAt: result.updatedAt, + status: result.status, + publishedAt: prepared.target.publishedAt, + slug: prepared.slug, + isDirty: editedInFlight, + }; + }); + + const engine = createSaveEngine({ + getSnapshot: () => { + if (snapshotError) { + const error = snapshotError; + snapshotError = null; + throw error; + } + return snapshot; + }, + slug, + prepare, + execute, + reconcile, + onStateChange: (state) => states.push(state), + onListenerError: (error) => listenerErrors.push(error), + }); + + function nextRequest() { + return requests[requests.length - outstanding.length]; + } + + return { + engine, + execute, + prepare, + reconcile, + slug, + slugRequests, + requests, + signals, + states, + listenerErrors, + nextRequest, + get snapshot() { + return snapshot; + }, + maxConcurrent: () => maxConcurrent, + patch(changes: Partial) { + snapshot = { ...snapshot, ...changes } as SaveSnapshot; + }, + edit() { + snapshot = { ...snapshot, isDirty: true, version: snapshot.version + 1 }; + }, + throwNextSnapshot(error: Error) { + snapshotError = error; + }, + holdSlugWork() { + slugSettled = deferred(); + return async () => { + slugSettled?.resolve(); + slugSettled = null; + await flush(); + }; + }, + holdSlugRequests() { + holdSlugRequests = true; + }, + async resolveSlug(value: string, source: SlugProposal['source'] = 'generated') { + slugRequests.shift()!.outcome.resolve({ slug: value, source }); + await flush(); + }, + // Each of these lets the prepare stage reach execute before answering the request. + async succeed(result: Partial = {}) { + await flush(); + const request = nextRequest(); + const outcome = outstanding.shift()!; + sequence += 1; + outcome.resolve({ + ok: true, + result: { + id: request.snapshot.id ?? 'post-1', + status: request.target.status, + updatedAt: new Date(NOW + sequence * 1000).toISOString(), + ...result, + }, + }); + await flush(); + }, + async fail(error: SaveError) { + await flush(); + outstanding.shift()!.resolve({ ok: false, error }); + await flush(); + }, + async reject(cause: unknown) { + await flush(); + outstanding.shift()!.reject(cause); + await flush(); + }, + }; +} + +type Harness = ReturnType; + +const validation: SaveError = { kind: 'validation', message: 'Title is too long' }; +const hostLimit: SaveError = { kind: 'host-limit', message: 'Upgrade required' }; +const transport: SaveError = { kind: 'transport', message: 'Server unreachable' }; +const sessionInvalid: SaveError = { kind: 'session-invalid', message: 'Unauthorized' }; +const notFound: SaveError = { kind: 'not-found', message: 'Post not found' }; +const conflict: SaveError = { kind: 'conflict', message: 'Someone else is editing this post' }; +const unknown: SaveError = { kind: 'unknown', message: 'Boom' }; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('deriveTarget', () => { + it('publishes now and keeps the post’s existing publish time unless told otherwise', () => { + expect(deriveTarget('publish', { status: 'draft', publishedAt: null })).toEqual({ + status: 'published', + publishedAt: null, + }); + expect( + deriveTarget('publish', { status: 'draft', publishedAt: '2026-09-01T09:30:15.789Z' }), + ).toEqual({ status: 'published', publishedAt: '2026-09-01T09:30:15.000Z' }); + expect( + deriveTarget('publish', { status: 'draft', publishedAt: PAST }, { publishedAt: null }), + ).toEqual({ status: 'published', publishedAt: null }); + }); + + it('carries email extras only when the flow provides them', () => { + expect( + deriveTarget( + 'publish', + { status: 'draft', publishedAt: null }, + { emailOnly: true, newsletter: 'weekly', emailSegment: 'status:free' }, + ), + ).toEqual({ + status: 'published', + publishedAt: null, + emailOnly: true, + newsletter: 'weekly', + emailSegment: 'status:free', + }); + }); + + it('schedules with a zeroed publish time', () => { + expect( + deriveTarget( + 'schedule', + { status: 'draft', publishedAt: null }, + { publishedAt: '2026-09-03T09:30:15.789Z' }, + ), + ).toEqual({ status: 'scheduled', publishedAt: '2026-09-03T09:30:15.000Z' }); + }); + + it('unschedules to a draft with no publish time', () => { + expect(deriveTarget('revert', { status: 'scheduled', publishedAt: FUTURE })).toEqual({ + status: 'draft', + publishedAt: null, + emailOnly: false, + }); + }); + + it.each(['published', 'sent'])( + 'unpublishes a %s post to a draft that keeps its historical publish time', + (status) => { + expect(deriveTarget('revert', { status, publishedAt: PAST })).toEqual({ + status: 'draft', + publishedAt: PAST, + emailOnly: false, + }); + }, + ); +}); + +describe('resolveTarget', () => { + const command = (kind: SaveCommand['kind']): SaveCommand => ({ + kind, + requiresRevision: false, + requiresReconfirmation: false, + }); + + it.each(['autosave', 'timed', 'field'])( + '%s pins the status to draft and leaves the publish time alone', + (kind) => { + expect(resolveTarget(command(kind), { status: 'draft', publishedAt: PAST })).toEqual({ + status: 'draft', + publishedAt: PAST, + }); + }, + ); + + it.each<[SaveCommand['kind'], PostStatus, string | null]>([ + ['explicit', 'draft', null], + ['explicit', 'published', PAST], + ['explicit', 'scheduled', FUTURE], + ['explicit', 'scheduled', PAST], + ['explicit', 'sent', PAST], + ['leave', 'published', PAST], + ['leave', 'draft', null], + ])('%s on a %s post preserves the status', (kind, status, publishedAt) => { + expect(resolveTarget(command(kind), { status, publishedAt })).toEqual({ + status, + publishedAt, + }); + }); + + it('returns a captured target untouched', () => { + const target: SaveTarget = { status: 'scheduled', publishedAt: FUTURE, newsletter: 'weekly' }; + expect( + resolveTarget({ ...command('schedule'), target }, { status: 'published', publishedAt: PAST }), + ).toBe(target); + }); +}); + +describe('zeroMilliseconds', () => { + it('drops milliseconds and leaves everything else untouched', () => { + expect(zeroMilliseconds('2026-09-03T09:30:15.789Z')).toBe('2026-09-03T09:30:15.000Z'); + expect(zeroMilliseconds(null)).toBeNull(); + expect(zeroMilliseconds('not a date')).toBe('not a date'); + }); +}); + +describe('createSaveEngine', () => { + describe('background saves never change status', () => { + it('persists a field change on a draft pinned to draft without a revision', async () => { + const h = setup({ publishedAt: PAST }); + + void h.engine.dispatch('field'); + await flush(); + + expect(h.requests).toHaveLength(1); + expect(h.requests[0]).toMatchObject({ + command: { kind: 'field', requiresRevision: false }, + target: { status: 'draft', publishedAt: PAST }, + saveRevision: false, + }); + }); + + it.each(['published', 'scheduled', 'sent'])( + 'drops field and autosave intents for a %s post with a typed reason', + async (status) => { + const h = setup({ status, publishedAt: FUTURE }); + + const completions = await Promise.all([ + h.engine.dispatch('autosave'), + h.engine.dispatch('field'), + ]); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS + AUTOSAVE_DEBOUNCE_MS); + + expect(completions).toEqual(Array(2).fill({ kind: 'dropped', reason: 'not-draft' })); + expect(h.execute).not.toHaveBeenCalled(); + }, + ); + + it('drops a pending background save once the in-flight save has published the post', async () => { + const h = setup(); + const publish = h.engine.dispatch('publish'); + const field = h.engine.dispatch('field'); + + await h.succeed(); + + expect(h.snapshot.status).toBe('published'); + await expect(publish).resolves.toMatchObject({ kind: 'saved', executedAs: 'publish' }); + await expect(field).resolves.toEqual({ kind: 'dropped', reason: 'not-draft' }); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('single flight: payloads are built at execution time and coalescing loses nothing', () => { + it('runs one save at a time and builds each payload from the snapshot current at execution', async () => { + const h = setup(); + const first = h.engine.dispatch('explicit'); + await flush(); + h.edit(); + const second = h.engine.dispatch('field'); + h.edit(); + const third = h.engine.dispatch('explicit'); + + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'explicit', + pending: 'explicit', + }); + expect(h.execute).toHaveBeenCalledTimes(1); + + await h.succeed(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'explicit' }, + snapshot: { version: 3 }, + }); + + await h.succeed(); + await expect(first).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + await expect(second).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + await expect(third).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + expect(h.maxConcurrent()).toBe(1); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + + it('restarts the autosave debounce on every edit and fires once, 3s after the last one', async () => { + const h = setup(); + + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(2000); + h.edit(); + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(2000); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toEqual({ kind: 'debouncing' }); + + await vi.advanceTimersByTimeAsync(1000); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.requests[0]).toMatchObject({ + command: { kind: 'autosave' }, + snapshot: { version: 2 }, + }); + }); + }); + + describe('session expiry loses nothing', () => { + it('freezes the queue on 401 and re-dispatches the failed save after re-authentication', async () => { + const h = setup(); + const autosave = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + + await h.fail(sessionInvalid); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'autosave' }); + + h.edit(); + const laterAutosave = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'autosave' }); + + h.engine.reauthSucceeded(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'autosave' }, + snapshot: { version: 2 }, + }); + + await h.succeed(); + await expect(autosave).resolves.toMatchObject({ kind: 'saved' }); + await expect(laterAutosave).resolves.toMatchObject({ kind: 'saved' }); + expect(h.snapshot.isDirty).toBe(false); + }); + + it('lets a higher-priority intent queued during re-auth carry the frozen save', async () => { + const h = setup(); + const field = h.engine.dispatch('field'); + await h.fail(sessionInvalid); + + const explicit = h.engine.dispatch('explicit'); + expect(h.execute).toHaveBeenCalledTimes(1); + + h.engine.reauthSucceeded(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ command: { kind: 'explicit' }, saveRevision: true }); + + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + await expect(field).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + }); + + it('ignores reauthSucceeded when nothing is waiting on re-authentication', async () => { + const h = setup(); + h.engine.reauthSucceeded(); + await flush(); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + + it('asks for confirmation instead of enqueueing a leave save while re-auth is pending', async () => { + const h = setup(); + void h.engine.dispatch('field'); + await h.fail(sessionInvalid); + + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('save-on-leave fires at most once and only for dirty drafts', () => { + it('saves a dirty draft with unrevisioned changes exactly once, with a revision, then proceeds', async () => { + const h = setup(); + const decision = h.engine.leaveRequested(); + await flush(); + + expect(h.requests).toHaveLength(1); + expect(h.requests[0]).toMatchObject({ + command: { kind: 'leave' }, + target: { status: 'draft' }, + saveRevision: true, + }); + + await h.succeed(); + await expect(decision).resolves.toBe('proceed'); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('proceeds without saving when the post is clean', async () => { + const h = setup({ isDirty: false }); + await expect(h.engine.leaveRequested()).resolves.toBe('proceed'); + expect(h.execute).not.toHaveBeenCalled(); + }); + + it('never saves a dirty published post on leave and asks for confirmation', async () => { + const h = setup({ status: 'published', publishedAt: PAST }); + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + expect(h.execute).not.toHaveBeenCalled(); + }); + + it('cancels an armed autosave and saves once for an already-revisioned dirty draft', async () => { + const h = setup({ changedSinceLastRevision: false }); + void h.engine.dispatch('autosave'); + + const decision = h.engine.leaveRequested(); + await flush(); + expect(h.requests[0]).toMatchObject({ command: { kind: 'leave' }, saveRevision: true }); + + await h.succeed(); + await expect(decision).resolves.toBe('proceed'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('does not save a second time in the same leave attempt when the first leave save failed', async () => { + const h = setup(); + const decision = h.engine.leaveRequested(); + await flush(); + h.edit(); + void h.engine.dispatch('autosave'); + + await h.fail(transport); + await expect(decision).resolves.toBe('confirm'); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('a failed save keeps the post dirty and recoverable', () => { + it.each([validation, hostLimit, transport, unknown])( + 'surfaces a $kind error to the dispatcher and still runs the pending explicit save', + async (error) => { + const h = setup(); + const failing = h.engine.dispatch('field'); + const explicit = h.engine.dispatch('explicit'); + + await h.fail(error); + await expect(failing).resolves.toEqual({ kind: 'failed', error, executedAs: 'field' }); + expect(h.snapshot.isDirty).toBe(true); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ command: { kind: 'explicit' } }); + + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved' }); + }, + ); + + it('reports an error state until the next save starts', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + await h.fail(transport); + expect(h.engine.getState()).toEqual({ kind: 'error', intent: 'explicit', error: transport }); + + void h.engine.dispatch('explicit'); + expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + }); + + it('treats a rejected execute as an unknown error rather than swallowing it', async () => { + const h = setup(); + const completion = h.engine.dispatch('explicit'); + const cause = new Error('network down'); + + await h.reject(cause); + + await expect(completion).resolves.toEqual({ + kind: 'failed', + error: { kind: 'unknown', message: 'network down', cause }, + executedAs: 'explicit', + }); + }); + + it('treats a rejected prepare as an unknown error before any IO starts', async () => { + const h = setup(); + const cause = new Error('invalid candidate'); + h.prepare.mockRejectedValueOnce(cause); + + await expect(h.engine.dispatch('explicit')).resolves.toEqual({ + kind: 'failed', + error: { kind: 'unknown', message: 'invalid candidate', cause }, + executedAs: 'explicit', + }); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toMatchObject({ kind: 'error', intent: 'explicit' }); + }); + }); + + describe('only explicit and leave saves set save_revision', () => { + it.each<[DispatchIntent, boolean]>([ + ['explicit', true], + ['leave', true], + ['autosave', false], + ['field', false], + ['publish', false], + ['schedule', false], + ['revert', false], + ])('%s requests save_revision=%s', async (intent, saveRevision) => { + const h = setup(); + void dispatchAny(h.engine, intent); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + + expect(h.requests).toHaveLength(1); + expect(h.requests[0]).toMatchObject({ command: { kind: intent }, saveRevision }); + }); + + it('ORs the revision requirement across coalesced work', async () => { + const h = setup(); + void h.engine.dispatch('field'); + const explicit = h.engine.dispatch('explicit'); + const publish = h.engine.dispatch('publish'); + + await h.succeed(); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'publish', requiresRevision: true }, + saveRevision: true, + }); + + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved', executedAs: 'publish' }); + await expect(publish).resolves.toMatchObject({ kind: 'saved', executedAs: 'publish' }); + }); + }); + + describe('scheduled saves zero milliseconds and preserve the publish time', () => { + it('serializes a future scheduled post with a zeroed publish time across saves', async () => { + const h = setup({ status: 'scheduled', publishedAt: '2026-09-03T09:30:15.789Z' }); + + void h.engine.dispatch('explicit'); + await h.succeed(); + void h.engine.dispatch('explicit'); + await h.succeed(); + + expect(h.requests).toHaveLength(2); + for (const request of h.requests) { + expect(request).toMatchObject({ + target: { status: 'scheduled', publishedAt: '2026-09-03T09:30:15.000Z' }, + }); + } + }); + + it('preserves a past scheduled time on an explicit save and leaves the transition to the server', async () => { + const h = setup({ status: 'scheduled', publishedAt: PAST }); + void h.engine.dispatch('explicit'); + await flush(); + expect(h.requests[0]).toMatchObject({ target: { status: 'scheduled', publishedAt: PAST } }); + }); + }); + + describe('lifecycle: capture, prepare, execute, reconcile, drain', () => { + it('reconciles the create response before the queued save runs: one POST, then one PUT with the newest content and the returned updated_at', async () => { + type Doc = SaveSnapshot & { body: string }; + type Prepared = SaveRequest & { method: 'POST' | 'PUT' }; + let doc = { ...BASE, id: null, updatedAt: null, body: 'first' } as Doc; + const wire: Array<{ method: string; body: string; updatedAt: string | null }> = []; + const responses: Deferred[] = []; + + const engine = createSaveEngine({ + getSnapshot: () => doc, + slug: idleSlug, + prepare: (request) => + Promise.resolve({ ...request, method: request.snapshot.id ? 'PUT' : 'POST' }), + execute: async (prepared) => { + wire.push({ + method: prepared.method, + body: prepared.snapshot.body, + updatedAt: prepared.snapshot.updatedAt, + }); + const response = deferred(); + responses.push(response); + return response.promise; + }, + reconcile: (prepared, result) => { + doc = { + ...doc, + id: result.id, + updatedAt: result.updatedAt, + status: result.status, + isDirty: doc.version !== prepared.snapshot.version, + }; + }, + }); + + const create = engine.dispatch('explicit'); + await flush(); + doc = { ...doc, body: 'second', version: 2 }; + const autosave = engine.dispatch('autosave'); + expect(wire).toEqual([{ method: 'POST', body: 'first', updatedAt: null }]); + + responses[0].resolve({ + ok: true, + result: { id: 'post-9', status: 'draft', updatedAt: '2026-09-02T12:00:01.000Z' }, + }); + await flush(); + expect(wire).toEqual([ + { method: 'POST', body: 'first', updatedAt: null }, + { method: 'PUT', body: 'second', updatedAt: '2026-09-02T12:00:01.000Z' }, + ]); + + responses[1].resolve({ + ok: true, + result: { id: 'post-9', status: 'draft', updatedAt: '2026-09-02T12:00:02.000Z' }, + }); + await flush(); + await expect(create).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + await expect(autosave).resolves.toMatchObject({ kind: 'saved', executedAs: 'autosave' }); + expect(doc).toMatchObject({ + id: 'post-9', + updatedAt: '2026-09-02T12:00:02.000Z', + isDirty: false, + }); + }); + + it('starts IO only after prepare settles', async () => { + const h = setup(); + const prepared = deferred(); + h.prepare.mockReturnValueOnce(prepared.promise); + + void h.engine.dispatch('explicit'); + await flush(); + expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + expect(h.execute).not.toHaveBeenCalled(); + + prepared.resolve(h.prepare.mock.calls[0][0]); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('aborts the in-flight signal on dispose and never reconciles the late response', async () => { + const h = setup(); + const explicit = h.engine.dispatch('explicit'); + await flush(); + expect(h.signals[0].aborted).toBe(false); + + h.engine.dispose(); + expect(h.signals[0].aborted).toBe(true); + await expect(explicit).resolves.toEqual({ kind: 'dropped', reason: 'disposed' }); + + await h.succeed(); + expect(h.reconcile).not.toHaveBeenCalled(); + expect(h.snapshot.updatedAt).toBe(BASELINE); + expect(h.engine.getState()).toEqual({ kind: 'disposed' }); + }); + }); + + describe('prepare stage: title and slug', () => { + it('serializes an explicit save behind manual slug work in progress', async () => { + const h = setup(); + const release = h.holdSlugWork(); + + const explicit = h.engine.dispatch('explicit'); + await flush(); + expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + expect(h.execute).not.toHaveBeenCalled(); + + await release(); + expect(h.execute).toHaveBeenCalledTimes(1); + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved' }); + }); + + it('waits for a slow slug request before a leave save leaves', async () => { + const h = setup({ slug: '' }); + h.holdSlugRequests(); + const decision = h.engine.leaveRequested(); + await flush(); + expect(h.slug.fromTitle).toHaveBeenCalledWith('Hello', 'post-1', expect.any(AbortSignal)); + expect(h.execute).not.toHaveBeenCalled(); + + await h.resolveSlug('hello'); + expect(h.requests[0]).toMatchObject({ command: { kind: 'leave' }, slug: 'hello' }); + + await h.succeed(); + await expect(decision).resolves.toBe('proceed'); + }); + + it('creates a titleless body-first post as (Untitled) with a generated slug', async () => { + const h = setup({ id: null, updatedAt: null, title: '', slug: '' }); + h.holdSlugRequests(); + void h.engine.dispatch('autosave'); + await flush(); + expect(h.slug.fromTitle).toHaveBeenCalledWith(DEFAULT_TITLE, null, expect.any(AbortSignal)); + + await h.resolveSlug('untitled'); + expect(h.requests[0]).toMatchObject({ + title: DEFAULT_TITLE, + slug: 'untitled', + target: { status: 'draft' }, + snapshot: { id: null, title: '' }, + }); + }); + + it('treats a whitespace title as blank', async () => { + const h = setup({ id: null, updatedAt: null, title: ' ', slug: '' }); + h.holdSlugRequests(); + void h.engine.dispatch('explicit'); + await flush(); + expect(h.slug.fromTitle).toHaveBeenCalledWith(DEFAULT_TITLE, null, expect.any(AbortSignal)); + + await h.resolveSlug('untitled'); + expect(h.requests[0]).toMatchObject({ title: DEFAULT_TITLE, slug: 'untitled' }); + }); + + it('applies a generated proposal requested with the post id', async () => { + const h = setup({ title: 'Hello world' }); + h.holdSlugRequests(); + void h.engine.dispatch('explicit'); + await flush(); + expect(h.slug.fromTitle).toHaveBeenCalledWith( + 'Hello world', + 'post-1', + expect.any(AbortSignal), + ); + + await h.resolveSlug('hello-world-2'); + expect(h.requests[0]).toMatchObject({ slug: 'hello-world-2', snapshot: { id: 'post-1' } }); + await h.succeed(); + expect(h.snapshot.slug).toBe('hello-world-2'); + }); + + it('sends the slug current after an unchanged answer, not the one read before the request', async () => { + const h = setup({ slug: 'my-slug' }); + h.holdSlugRequests(); + void h.engine.dispatch('explicit'); + await flush(); + + h.patch({ slug: 'my-custom-slug' }); + await h.resolveSlug('ignored', 'unchanged'); + expect(h.requests[0]).toMatchObject({ + slug: 'my-custom-slug', + snapshot: { slug: 'my-custom-slug' }, + }); + }); + + it('asks the slug port on every draft save and lets it keep the slug', async () => { + const h = setup({ title: '' }); + void h.engine.dispatch('explicit'); + await flush(); + + expect(h.slug.fromTitle).toHaveBeenCalledWith( + DEFAULT_TITLE, + 'post-1', + expect.any(AbortSignal), + ); + expect(h.requests[0]).toMatchObject({ title: DEFAULT_TITLE, slug: 'hello' }); + }); + + it('never asks for a slug when a non-draft post already has one', async () => { + const h = setup({ status: 'published', publishedAt: PAST, title: 'Renamed' }); + void h.engine.dispatch('explicit'); + await flush(); + + expect(h.slug.fromTitle).not.toHaveBeenCalled(); + expect(h.requests[0]).toMatchObject({ slug: 'hello' }); + }); + + it('asks for a slug for a post of any status that has none', async () => { + const h = setup({ status: 'published', publishedAt: PAST, slug: '' }); + h.holdSlugRequests(); + void h.engine.dispatch('explicit'); + await flush(); + + expect(h.slug.fromTitle).toHaveBeenCalledWith('Hello', 'post-1', expect.any(AbortSignal)); + await h.resolveSlug('hello'); + expect(h.requests[0]).toMatchObject({ slug: 'hello' }); + }); + + it('drops a background save whose post was published during the slug request', async () => { + const h = setup(); + h.holdSlugRequests(); + const field = h.engine.dispatch('field'); + await flush(); + + h.patch({ status: 'published', publishedAt: PAST, updatedAt: FUTURE }); + await h.resolveSlug('hello', 'unchanged'); + expect(h.prepare).not.toHaveBeenCalled(); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + await expect(field).resolves.toEqual({ kind: 'dropped', reason: 'not-draft' }); + }); + + it('drops a background save whose post went clean during the slug request', async () => { + const h = setup(); + h.holdSlugRequests(); + const field = h.engine.dispatch('field'); + await flush(); + + h.patch({ isDirty: false }); + await h.resolveSlug('hello', 'unchanged'); + expect(h.prepare).not.toHaveBeenCalled(); + expect(h.execute).not.toHaveBeenCalled(); + await expect(field).resolves.toEqual({ kind: 'dropped', reason: 'clean' }); + }); + + it('never prepares a save disposed while slug work was settling', async () => { + const h = setup(); + const release = h.holdSlugWork(); + const explicit = h.engine.dispatch('explicit'); + await flush(); + + h.engine.dispose(); + await expect(explicit).resolves.toEqual({ kind: 'dropped', reason: 'disposed' }); + await release(); + expect(h.prepare).not.toHaveBeenCalled(); + expect(h.execute).not.toHaveBeenCalled(); + }); + + it('never prepares a save disposed while a slug proposal was pending', async () => { + const h = setup(); + h.holdSlugRequests(); + const explicit = h.engine.dispatch('explicit'); + await flush(); + expect(h.slug.fromTitle).toHaveBeenCalledTimes(1); + + h.engine.dispose(); + await expect(explicit).resolves.toEqual({ kind: 'dropped', reason: 'disposed' }); + await h.resolveSlug('late'); + expect(h.prepare).not.toHaveBeenCalled(); + expect(h.execute).not.toHaveBeenCalled(); + }); + }); + + describe('commands: captured at dispatch', () => { + it('captures a schedule target that a response resync cannot turn into a publish', async () => { + const h = setup({ id: null, updatedAt: null }); + void h.engine.dispatch('explicit'); + await flush(); + const schedule = h.engine.dispatch('schedule', { publishedAt: FUTURE }); + h.patch({ publishedAt: PAST }); + + await h.succeed(); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'schedule', target: { status: 'scheduled', publishedAt: FUTURE } }, + target: { status: 'scheduled', publishedAt: FUTURE }, + snapshot: { id: 'post-1', publishedAt: null }, + }); + + await h.succeed(); + await expect(schedule).resolves.toMatchObject({ kind: 'saved', executedAs: 'schedule' }); + }); + + it('attaches email extras to exactly the publish request', async () => { + const h = setup(); + void h.engine.dispatch('publish', { newsletter: 'weekly', emailSegment: 'all' }); + const explicit = h.engine.dispatch('explicit'); + + await h.succeed(); + expect(h.requests[0].target).toEqual({ + status: 'published', + publishedAt: null, + newsletter: 'weekly', + emailSegment: 'all', + }); + expect(h.requests[1].target).toEqual({ status: 'published', publishedAt: null }); + + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + }); + + it('disarms a failed publish: the next explicit save preserves the draft status', async () => { + const h = setup(); + const publish = h.engine.dispatch('publish', { newsletter: 'weekly' }); + await h.fail(transport); + await expect(publish).resolves.toEqual({ + kind: 'failed', + error: transport, + executedAs: 'publish', + }); + + void h.engine.dispatch('explicit'); + await flush(); + expect(h.requests[1].target).toEqual({ status: 'draft', publishedAt: null }); + }); + + it('unschedules to a draft with no publish time', async () => { + const h = setup({ status: 'scheduled', publishedAt: FUTURE }); + void h.engine.dispatch('revert'); + await flush(); + expect(h.requests[0].target).toEqual({ + status: 'draft', + publishedAt: null, + emailOnly: false, + }); + }); + + it('unpublishes to a draft that keeps its historical publish time', async () => { + const h = setup({ status: 'published', publishedAt: PAST }); + void h.engine.dispatch('revert'); + await flush(); + expect(h.requests[0].target).toEqual({ + status: 'draft', + publishedAt: PAST, + emailOnly: false, + }); + }); + + it.each(['published', 'scheduled', 'sent'])( + 'preserves the %s status on an explicit save', + async (status) => { + const h = setup({ status, publishedAt: status === 'scheduled' ? FUTURE : PAST }); + void h.engine.dispatch('explicit'); + await flush(); + expect(h.requests[0].target.status).toBe(status); + }, + ); + }); + + describe('interleavings', () => { + it('saves a new post immediately on its first edit', async () => { + const h = setup({ id: null, updatedAt: null }); + void h.engine.dispatch('autosave'); + expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'autosave' }); + await flush(); + + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.requests[0]).toMatchObject({ + command: { kind: 'autosave' }, + target: { status: 'draft' }, + snapshot: { id: null }, + }); + }); + + it('coalesces an autosave arriving during an in-flight create and rebuilds its payload', async () => { + const h = setup({ id: null, updatedAt: null }); + const create = h.engine.dispatch('explicit'); + await flush(); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'explicit', + pending: 'autosave', + }); + expect(h.execute).toHaveBeenCalledTimes(1); + + await h.succeed(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'autosave' }, + snapshot: { id: 'post-1', version: 2 }, + }); + + await h.succeed(); + await expect(create).resolves.toMatchObject({ kind: 'saved' }); + await expect(autosave).resolves.toMatchObject({ kind: 'saved' }); + }); + + it('coalesces a debounced autosave that fires during a slow explicit save', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + await flush(); + h.edit(); + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'explicit', + pending: 'autosave', + }); + + await h.succeed(); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'autosave' }, + snapshot: { version: 2 }, + }); + }); + + it('lets an explicit save cancel a pending autosave debounce and supersede it', async () => { + const h = setup(); + const autosave = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(1000); + + const explicit = h.engine.dispatch('explicit'); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.requests[0]).toMatchObject({ command: { kind: 'explicit' } }); + + await h.succeed(); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + await expect(autosave).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + await expect(explicit).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + }); + + it('waits for an in-flight save on leave, then saves once with a revision', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + const decision = h.engine.leaveRequested(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(1); + + await h.succeed(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ command: { kind: 'leave' }, saveRevision: true }); + + await h.succeed(); + await expect(decision).resolves.toBe('proceed'); + }); + + it('lets publish win the pending slot over a queued autosave', async () => { + const h = setup(); + void h.engine.dispatch('field'); + await flush(); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'field', + pending: 'autosave', + }); + + const publish = h.engine.dispatch('publish'); + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'field', + pending: 'publish', + }); + + await h.succeed(); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'publish' }, + target: { status: 'published' }, + snapshot: { version: 2 }, + }); + + await h.succeed(); + await expect(publish).resolves.toMatchObject({ kind: 'saved', executedAs: 'publish' }); + await expect(autosave).resolves.toMatchObject({ kind: 'saved', executedAs: 'publish' }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + }); + + it('halts permanently on a 404 for a known post id', async () => { + const h = setup(); + const failing = h.engine.dispatch('explicit'); + await flush(); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + + await h.fail(notFound); + expect(h.engine.getState()).toEqual({ kind: 'halted' }); + await expect(failing).resolves.toEqual({ + kind: 'failed', + error: notFound, + executedAs: 'explicit', + }); + await expect(autosave).resolves.toEqual({ kind: 'dropped', reason: 'halted' }); + + await expect(h.engine.dispatch('explicit')).resolves.toEqual({ + kind: 'dropped', + reason: 'halted', + }); + await expect(h.engine.dispatch('autosave')).resolves.toEqual({ + kind: 'dropped', + reason: 'halted', + }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + }); + + it('crashes on a 404 for a post that has no id yet', async () => { + const h = setup({ id: null, updatedAt: null }); + void h.engine.dispatch('explicit'); + + await h.fail(notFound); + expect(h.engine.getState()).toEqual({ kind: 'crashed' }); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'halted', + }); + }); + + it.each([validation, hostLimit])( + 'suppresses background saves after a $kind error on a draft save until the snapshot changes', + async (error) => { + const h = setup(); + void h.engine.dispatch('explicit'); + await h.fail(error); + expect(h.engine.getState()).toEqual({ kind: 'error', intent: 'explicit', error }); + + await expect(h.engine.dispatch('autosave')).resolves.toEqual({ + kind: 'dropped', + reason: 'suppressed', + }); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'suppressed', + }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + + void h.engine.dispatch('explicit'); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + await h.fail(error); + + h.edit(); + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(3); + expect(h.requests[2]).toMatchObject({ + command: { kind: 'autosave' }, + snapshot: { version: 2 }, + }); + }, + ); + + it('scopes host-limit suppression to the failing operation: a publish limit never halts autosave', async () => { + const h = setup(); + const publish = h.engine.dispatch('publish'); + await h.fail(hostLimit); + await expect(publish).resolves.toMatchObject({ kind: 'failed', error: hostLimit }); + + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ command: { kind: 'autosave' } }); + }); + + it('keeps autosaving after a transport error', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + await h.fail(transport); + + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ command: { kind: 'autosave' } }); + }); + + it('forces a timed save after 60s of continuous editing', async () => { + const h = setup(); + const autosaves: Promise[] = []; + for (let second = 0; second < 60; second += 1) { + h.edit(); + autosaves.push(h.engine.dispatch('autosave')); + await vi.advanceTimersByTimeAsync(1000); + } + + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.requests[0]).toMatchObject({ + command: { kind: 'timed' }, + target: { status: 'draft' }, + saveRevision: false, + snapshot: { version: 61 }, + }); + + await h.succeed(); + const completions = await Promise.all(autosaves); + expect(completions).toHaveLength(60); + for (const completion of completions) { + expect(completion).toMatchObject({ kind: 'saved', executedAs: 'timed' }); + } + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('skips a background save that finds the post clean at execution time', async () => { + const h = setup(); + const autosave = h.engine.dispatch('autosave'); + h.patch({ isDirty: false }); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + + await expect(autosave).resolves.toEqual({ kind: 'dropped', reason: 'clean' }); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + + it('skips a background save that finds the post clean after slug work settled', async () => { + const h = setup(); + const release = h.holdSlugWork(); + const field = h.engine.dispatch('field'); + await flush(); + expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'field' }); + + h.patch({ isDirty: false }); + await release(); + await expect(field).resolves.toEqual({ kind: 'dropped', reason: 'clean' }); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + + it('notifies subscribers of every transition until they unsubscribe', async () => { + const h = setup(); + const seen: SaveEngineState[] = []; + const unsubscribe = h.engine.subscribe((state) => seen.push(state)); + + void h.engine.dispatch('explicit'); + await h.succeed(); + expect(seen).toEqual([{ kind: 'saving', intent: 'explicit' }, { kind: 'idle' }]); + expect(h.states).toEqual(seen); + + unsubscribe(); + void h.engine.dispatch('explicit'); + expect(seen).toHaveLength(2); + }); + + it('dispose cancels timers and settles every outstanding dispatch', async () => { + const h = setup(); + const explicit = h.engine.dispatch('explicit'); + await flush(); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + + h.engine.dispose(); + await expect(explicit).resolves.toEqual({ kind: 'dropped', reason: 'disposed' }); + await expect(autosave).resolves.toEqual({ kind: 'dropped', reason: 'disposed' }); + + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + await expect(h.engine.dispatch('explicit')).resolves.toEqual({ + kind: 'dropped', + reason: 'disposed', + }); + await expect(h.engine.leaveRequested()).resolves.toBe('proceed'); + expect(h.engine.getState()).toEqual({ kind: 'disposed' }); + }); + }); + + describe('collisions', () => { + // Two engines over the same post; the fake server accepts only the updated_at it last returned. + it('gives the second writer a typed conflict that halts its automatic saves until it reloads', async () => { + const server = { updatedAt: BASELINE }; + async function respond(h: Harness) { + await flush(); + if (h.nextRequest().snapshot.updatedAt !== server.updatedAt) { + await h.fail(conflict); + return; + } + server.updatedAt = new Date(Date.parse(server.updatedAt) + 60000).toISOString(); + await h.succeed({ updatedAt: server.updatedAt }); + } + const first = setup(); + const second = setup(); + + void first.engine.dispatch('explicit'); + await respond(first); + expect(first.snapshot.updatedAt).toBe(server.updatedAt); + + const explicit = second.engine.dispatch('explicit'); + await respond(second); + await expect(explicit).resolves.toEqual({ + kind: 'failed', + error: conflict, + executedAs: 'explicit', + }); + expect(second.engine.getState()).toEqual({ + kind: 'conflict', + intent: 'explicit', + error: conflict, + }); + expect(second.snapshot).toMatchObject({ isDirty: true, updatedAt: BASELINE }); + + await expect(second.engine.dispatch('autosave')).resolves.toEqual({ + kind: 'dropped', + reason: 'conflict', + }); + await expect(second.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'conflict', + }); + await expect(second.engine.leaveRequested()).resolves.toBe('confirm'); + + const retry = second.engine.dispatch('explicit'); + await flush(); + expect(second.execute).toHaveBeenCalledTimes(2); + await respond(second); + await expect(retry).resolves.toMatchObject({ kind: 'failed', error: conflict }); + + second.patch({ updatedAt: server.updatedAt }); + void second.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(second.execute).toHaveBeenCalledTimes(3); + await respond(second); + expect(second.engine.getState()).toEqual({ kind: 'idle' }); + expect(second.snapshot).toMatchObject({ isDirty: false, updatedAt: server.updatedAt }); + }); + + it('drops queued background work on a conflict and keeps the content dirty', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + await flush(); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + + await h.fail(conflict); + await expect(autosave).resolves.toEqual({ kind: 'dropped', reason: 'conflict' }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.snapshot).toMatchObject({ isDirty: true, version: 2 }); + }); + + it('never auto-retries a pending explicit save against the stale baseline', async () => { + const h = setup(); + void h.engine.dispatch('field'); + const explicit = h.engine.dispatch('explicit'); + + await h.fail(conflict); + await expect(explicit).resolves.toEqual({ kind: 'dropped', reason: 'conflict' }); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.engine.getState()).toEqual({ kind: 'conflict', intent: 'field', error: conflict }); + + void h.engine.dispatch('explicit'); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + }); + + it('drains a publish with the updated_at the preceding save reconciled', async () => { + const h = setup(); + void h.engine.dispatch('field'); + const publish = h.engine.dispatch('publish'); + + await h.succeed({ updatedAt: '2026-09-02T12:30:00.000Z' }); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'publish' }, + snapshot: { updatedAt: '2026-09-02T12:30:00.000Z' }, + }); + + await h.succeed(); + await expect(publish).resolves.toMatchObject({ kind: 'saved', executedAs: 'publish' }); + }); + + it('fails a past-scheduled explicit save whose changed publish time the server rejects', async () => { + const h = setup({ status: 'scheduled', publishedAt: PAST }); + const explicit = h.engine.dispatch('explicit'); + await h.fail(validation); + + await expect(explicit).resolves.toEqual({ + kind: 'failed', + error: validation, + executedAs: 'explicit', + }); + expect(h.snapshot).toMatchObject({ isDirty: true, status: 'scheduled', publishedAt: PAST }); + expect(h.engine.getState()).toEqual({ kind: 'error', intent: 'explicit', error: validation }); + }); + }); + + describe('leave outcomes', () => { + it('asks for confirmation when the in-flight save it waited for fails', async () => { + const h = setup({ changedSinceLastRevision: false }); + void h.engine.dispatch('explicit'); + const decision = h.engine.leaveRequested(); + await flush(); + + await h.fail(transport); + await expect(decision).resolves.toBe('confirm'); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('re-reads the post after the in-flight save and asks for confirmation when it is still dirty', async () => { + const h = setup({ changedSinceLastRevision: false }); + void h.engine.dispatch('explicit'); + const decision = h.engine.leaveRequested(); + await flush(); + + h.edit(); + await h.succeed(); + expect(h.snapshot.isDirty).toBe(true); + await expect(decision).resolves.toBe('confirm'); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('asks for confirmation when the leave save replacing an armed autosave fails', async () => { + const h = setup({ changedSinceLastRevision: false }); + void h.engine.dispatch('autosave'); + const decision = h.engine.leaveRequested(); + await flush(); + expect(h.requests[0]).toMatchObject({ command: { kind: 'leave' } }); + + await h.fail(transport); + await expect(decision).resolves.toBe('confirm'); + }); + + it('shares one leave save across concurrent leave requests', async () => { + const h = setup(); + const first = h.engine.leaveRequested(); + const second = h.engine.leaveRequested(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(1); + + await h.succeed(); + await expect(first).resolves.toBe('proceed'); + await expect(second).resolves.toBe('proceed'); + expect(h.execute).toHaveBeenCalledTimes(1); + }); + + it('re-runs a leave save interrupted by re-auth and then proceeds', async () => { + const h = setup(); + const decision = h.engine.leaveRequested(); + await flush(); + + await h.fail(sessionInvalid); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'leave' }); + + h.engine.reauthSucceeded(); + await flush(); + expect(h.requests[1]).toMatchObject({ command: { kind: 'leave' }, saveRevision: true }); + + await h.succeed(); + await expect(decision).resolves.toBe('proceed'); + }); + + it('asks for confirmation when re-auth is abandoned during a leave save', async () => { + const h = setup(); + const decision = h.engine.leaveRequested(); + await flush(); + await h.fail(sessionInvalid); + + h.engine.reauthAbandoned(); + await expect(decision).resolves.toBe('confirm'); + expect(h.engine.getState()).toEqual({ + kind: 'error', + intent: 'leave', + error: sessionInvalid, + }); + }); + }); + + describe('re-auth outcomes', () => { + it.each(['publish', 'schedule', 'revert'])( + 'never auto-fires a %s after re-auth; it resolves needs-retry', + async (intent) => { + const h = setup(intent === 'revert' ? { status: 'published', publishedAt: PAST } : {}); + const completion = dispatchAny(h.engine, intent); + await h.fail(sessionInvalid); + + h.engine.reauthSucceeded(); + await flush(); + await expect(completion).resolves.toEqual({ kind: 'needs-retry' }); + expect(h.execute).toHaveBeenCalledTimes(1); + + // A dirty draft resumes autosaving on its own; a published post has nothing to resume. + if (intent === 'revert') { + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + } else { + expect(h.engine.getState()).toEqual({ kind: 'debouncing' }); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'autosave' }, + target: { status: 'draft' }, + }); + } + }, + ); + + it('never auto-fires a publish queued while a background save was frozen', async () => { + const h = setup(); + const field = h.engine.dispatch('field'); + await h.fail(sessionInvalid); + const publish = h.engine.dispatch('publish', { newsletter: 'weekly' }); + + h.engine.reauthSucceeded(); + await flush(); + await expect(publish).resolves.toEqual({ kind: 'needs-retry' }); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'field' }, + target: { status: 'draft', publishedAt: null }, + }); + + await h.succeed(); + await expect(field).resolves.toMatchObject({ kind: 'saved', executedAs: 'field' }); + expect(h.snapshot.status).toBe('draft'); + }); + + it('re-runs a frozen explicit rider on its own but never the publish it coalesced into', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + const rider = h.engine.dispatch('explicit'); + const publish = h.engine.dispatch('publish'); + await h.succeed(); + expect(h.requests[1]).toMatchObject({ command: { kind: 'publish' }, saveRevision: true }); + + await h.fail(sessionInvalid); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'publish' }); + + h.engine.reauthSucceeded(); + await flush(); + await expect(publish).resolves.toEqual({ kind: 'needs-retry' }); + expect(h.execute).toHaveBeenCalledTimes(3); + expect(h.requests[2]).toMatchObject({ + command: { kind: 'explicit' }, + target: { status: 'draft' }, + saveRevision: true, + }); + + await h.succeed(); + await expect(rider).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + }); + + it('judges re-auth by the resolved effect: a frozen revert whose post is already a draft re-runs', async () => { + const h = setup({ status: 'published', publishedAt: PAST }); + const revert = h.engine.dispatch('revert'); + await h.fail(sessionInvalid); + + h.patch({ status: 'draft' }); + h.engine.reauthSucceeded(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'revert' }, + target: { status: 'draft', publishedAt: PAST }, + }); + + await h.succeed(); + await expect(revert).resolves.toMatchObject({ kind: 'saved', executedAs: 'revert' }); + }); + + it('never double-creates a new post whose publish needs retry after re-auth', async () => { + const h = setup({ id: null, updatedAt: null }); + const publish = h.engine.dispatch('publish'); + await h.fail(sessionInvalid); + const explicit = h.engine.dispatch('explicit'); + + h.engine.reauthSucceeded(); + await flush(); + await expect(publish).resolves.toEqual({ kind: 'needs-retry' }); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'explicit' }, + target: { status: 'draft' }, + snapshot: { id: null }, + }); + expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved' }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.maxConcurrent()).toBe(1); + }); + + it('resumes a new post’s autosave after a failed publish without a concurrent create', async () => { + const h = setup({ id: null, updatedAt: null }); + void h.engine.dispatch('publish'); + await h.fail(sessionInvalid); + + h.engine.reauthSucceeded(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'autosave' }, + target: { status: 'draft' }, + }); + + await h.succeed(); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.maxConcurrent()).toBe(1); + }); + + it('re-saves rider content folded into a publish that needs retry', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + await flush(); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + const publish = h.engine.dispatch('publish'); + + await h.succeed(); + expect(h.requests[1]).toMatchObject({ command: { kind: 'publish' } }); + await h.fail(sessionInvalid); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'publish' }); + + h.engine.reauthSucceeded(); + await flush(); + await expect(publish).resolves.toEqual({ kind: 'needs-retry' }); + expect(h.execute).toHaveBeenCalledTimes(3); + expect(h.requests[2]).toMatchObject({ + command: { kind: 'autosave' }, + target: { status: 'draft' }, + snapshot: { version: 2 }, + }); + + await h.succeed(); + await expect(autosave).resolves.toMatchObject({ kind: 'saved', executedAs: 'autosave' }); + }); + + it('re-arms the autosave when the snapshot cannot be read after re-auth', async () => { + const h = setup(); + void h.engine.dispatch('publish'); + await h.fail(sessionInvalid); + + h.throwNextSnapshot(new Error('snapshot exploded')); + h.engine.reauthSucceeded(); + await flush(); + expect(h.engine.getState()).toEqual({ kind: 'debouncing' }); + + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ command: { kind: 'autosave' } }); + }); + + it('settles every waiter with the session error when re-auth is abandoned', async () => { + const h = setup(); + const field = h.engine.dispatch('field'); + await h.fail(sessionInvalid); + const explicit = h.engine.dispatch('explicit'); + h.edit(); + const autosave = h.engine.dispatch('autosave'); + + h.engine.reauthAbandoned(); + await expect(field).resolves.toEqual({ + kind: 'failed', + error: sessionInvalid, + executedAs: 'field', + }); + await expect(explicit).resolves.toEqual({ + kind: 'failed', + error: sessionInvalid, + executedAs: 'explicit', + }); + await expect(autosave).resolves.toEqual({ + kind: 'failed', + error: sessionInvalid, + executedAs: 'autosave', + }); + expect(h.engine.getState()).toEqual({ + kind: 'error', + intent: 'field', + error: sessionInvalid, + }); + + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).toHaveBeenCalledTimes(1); + void h.engine.dispatch('explicit'); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(2); + }); + + it('ignores reauthAbandoned when nothing is waiting on re-authentication', () => { + const h = setup(); + h.engine.reauthAbandoned(); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + }); + + describe('coalescing and state reporting', () => { + it('lets a later revert supersede only the pending publish and keeps its riders', async () => { + const h = setup({ status: 'scheduled', publishedAt: FUTURE }); + void h.engine.dispatch('explicit'); + const rider = h.engine.dispatch('explicit'); + const publish = h.engine.dispatch('publish'); + const revert = h.engine.dispatch('revert'); + + await expect(publish).resolves.toEqual({ kind: 'superseded', by: 'revert' }); + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'explicit', + pending: 'revert', + }); + + await h.succeed(); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'revert', requiresRevision: true }, + target: { status: 'draft', publishedAt: null, emailOnly: false }, + saveRevision: true, + }); + await h.succeed(); + await expect(revert).resolves.toMatchObject({ kind: 'saved', executedAs: 'revert' }); + await expect(rider).resolves.toMatchObject({ kind: 'saved', executedAs: 'revert' }); + expect(h.execute).toHaveBeenCalledTimes(2); + }); + + it('emits each state once even when several timers arm', () => { + const h = setup(); + void h.engine.dispatch('autosave'); + h.edit(); + void h.engine.dispatch('autosave'); + h.edit(); + void h.engine.dispatch('autosave'); + + expect(h.states).toEqual([{ kind: 'debouncing' }]); + }); + + it('keeps an error state while a dropped-clean autosave passes through', async () => { + const h = setup(); + void h.engine.dispatch('explicit'); + await h.fail(transport); + + const autosave = h.engine.dispatch('autosave'); + h.patch({ isDirty: false }); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + + await expect(autosave).resolves.toEqual({ kind: 'dropped', reason: 'clean' }); + expect(h.engine.getState()).toEqual({ kind: 'error', intent: 'explicit', error: transport }); + }); + + it('surfaces a throwing snapshot port as an unknown failure', async () => { + const h = setup(); + const cause = new Error('snapshot exploded'); + h.throwNextSnapshot(cause); + + await expect(h.engine.dispatch('explicit')).resolves.toEqual({ + kind: 'failed', + error: { kind: 'unknown', message: 'snapshot exploded', cause }, + executedAs: 'explicit', + }); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.engine.getState()).toMatchObject({ kind: 'error', intent: 'explicit' }); + }); + + it('resolves a background dispatch as failed when the snapshot port throws', async () => { + const h = setup(); + const cause = new Error('snapshot exploded'); + h.throwNextSnapshot(cause); + + await expect(h.engine.dispatch('autosave')).resolves.toEqual({ + kind: 'failed', + error: { kind: 'unknown', message: 'snapshot exploded', cause }, + executedAs: 'autosave', + }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).not.toHaveBeenCalled(); + }); + + it('asks for confirmation when the snapshot port throws during a leave decision', async () => { + const h = setup(); + h.throwNextSnapshot(new Error('snapshot exploded')); + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + + const decision = h.engine.leaveRequested(); + await flush(); + expect(h.requests[0]).toMatchObject({ command: { kind: 'leave' } }); + h.throwNextSnapshot(new Error('snapshot exploded')); + await h.succeed(); + await expect(decision).resolves.toBe('confirm'); + }); + + it('lets a leave decision through once the engine is disposed mid-wait', async () => { + const h = setup(); + const decision = h.engine.leaveRequested(); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(1); + + h.engine.dispose(); + await expect(decision).resolves.toBe('proceed'); + }); + + it('tolerates listeners that dispatch or unsubscribe during notification', () => { + const h = setup(); + const seen: string[] = []; + const unsubscribe = h.engine.subscribe((state) => { + seen.push(state.kind); + if (state.kind === 'saving') { + unsubscribe(); + void h.engine.dispatch('field'); + } + }); + h.engine.subscribe((state) => seen.push(`other:${state.kind}`)); + + void h.engine.dispatch('explicit'); + + expect(seen).toEqual(['saving', 'other:pending-coalesced']); + expect(h.engine.getState()).toEqual({ + kind: 'pending-coalesced', + intent: 'explicit', + pending: 'field', + }); + }); + + it('reports a throwing listener without interrupting the others', () => { + const h = setup(); + const failure = new Error('listener exploded'); + const seen: SaveEngineState[] = []; + h.engine.subscribe(() => { + throw failure; + }); + h.engine.subscribe((state) => seen.push(state)); + + void h.engine.dispatch('explicit'); + + expect(h.listenerErrors).toEqual([failure]); + expect(seen).toEqual([{ kind: 'saving', intent: 'explicit' }]); + }); + + it('still saves when the onStateChange port throws, and reports it', async () => { + const failure = new Error('state port exploded'); + const reported: unknown[] = []; + const snapshot = { ...BASE, status: 'published', publishedAt: PAST } as SaveSnapshot; + const engine = createSaveEngine({ + getSnapshot: () => snapshot, + slug: idleSlug, + prepare: (request) => Promise.resolve(request), + execute: (prepared) => + Promise.resolve({ + ok: true, + result: { id: prepared.snapshot.id!, status: 'published', updatedAt: FUTURE }, + }), + reconcile: () => {}, + onStateChange: () => { + throw failure; + }, + onListenerError: (error) => reported.push(error), + }); + + await expect(engine.dispatch('explicit')).resolves.toMatchObject({ kind: 'saved' }); + expect(engine.getState()).toEqual({ kind: 'idle' }); + expect(reported).toEqual([failure, failure]); + }); + + it('re-runs a frozen explicit after a superseded publish while the winning revert needs retry', async () => { + const h = setup({ status: 'scheduled', publishedAt: FUTURE }); + const explicit = h.engine.dispatch('explicit'); + const publish = h.engine.dispatch('publish'); + const revert = h.engine.dispatch('revert'); + await expect(publish).resolves.toEqual({ kind: 'superseded', by: 'revert' }); + + await h.fail(sessionInvalid); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'explicit' }); + + h.engine.reauthSucceeded(); + await flush(); + await expect(revert).resolves.toEqual({ kind: 'needs-retry' }); + expect(h.execute).toHaveBeenCalledTimes(2); + expect(h.requests[1]).toMatchObject({ + command: { kind: 'explicit' }, + target: { status: 'scheduled', publishedAt: FUTURE }, + }); + + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + }); +}); diff --git a/apps/admin/src/editor/engine/save-engine.ts b/apps/admin/src/editor/engine/save-engine.ts new file mode 100644 index 00000000000..4b56285c689 --- /dev/null +++ b/apps/admin/src/editor/engine/save-engine.ts @@ -0,0 +1,957 @@ +import type { PostStatus } from '@tryghost/admin-x-framework/api/posts'; + +export type { PostStatus }; + +export const AUTOSAVE_DEBOUNCE_MS = 3000; +export const TIMED_SAVE_INTERVAL_MS = 60000; +/** Title the API stores for a blank editor title. */ +export const DEFAULT_TITLE = '(Untitled)'; + +export type SaveIntent = + | 'autosave' + | 'timed' + | 'field' + | 'explicit' + | 'leave' + | 'publish' + | 'schedule' + | 'revert'; + +/** `timed` is engine-internal: an autosave dispatch arms the 60s cycle. */ +export type DispatchIntent = Exclude; + +/** The only intents that may change a post's status. */ +export type StatusIntent = 'publish' | 'schedule' | 'revert'; + +// Coalescing ladder: the higher number wins the pending slot. +const PRIORITY: Record = { + autosave: 0, + timed: 1, + field: 2, + leave: 3, + explicit: 4, + publish: 5, + schedule: 5, + revert: 5, +}; + +export function isBackgroundIntent(intent: SaveIntent): boolean { + return intent === 'autosave' || intent === 'timed' || intent === 'field'; +} + +export function isStatusIntent(intent: SaveIntent): intent is StatusIntent { + return intent === 'publish' || intent === 'schedule' || intent === 'revert'; +} + +export interface SaveTarget { + status: PostStatus; + /** ISO 8601 with zeroed milliseconds, or null */ + publishedAt: string | null; + emailOnly?: boolean; + newsletter?: string; + emailSegment?: string; +} + +export interface PublishOptions { + /** Omitted keeps the post's current publish time; null clears it. */ + publishedAt?: string | null; + emailOnly?: boolean; + newsletter?: string; + emailSegment?: string; +} + +export interface ScheduleOptions extends PublishOptions { + publishedAt: string; +} + +/** Captured at dispatch and never re-derived from a later snapshot. */ +export interface SaveCommand { + readonly kind: SaveIntent; + /** Present only for status intents; email extras ride on exactly this command's request. */ + readonly target?: Readonly; + /** ORed across coalesced work: a rider that needs a revision makes the carrying save request one. */ + readonly requiresRevision: boolean; + /** The target changed the status when captured; re-auth falls back to this when the snapshot is unreadable. */ + readonly requiresReconfirmation: boolean; +} + +/** A persisted post always carries the server's updated_at; every update sends it for the collision check. */ +export type PersistedIdentity = { id: string; updatedAt: string } | { id: null; updatedAt: null }; + +export type SaveSnapshot = PersistedIdentity & { + status: PostStatus; + /** ISO 8601 or null */ + publishedAt: string | null; + title: string; + /** Empty until generated */ + slug: string; + isDirty: boolean; + changedSinceLastRevision: boolean; + /** Monotonic local edit counter; validation/host-limit suppression lifts once it moves. */ + version: number; +}; + +/** Handed unchanged from prepare through reconcile; the engine never mutates it. */ +export interface SaveRequest { + readonly command: SaveCommand; + /** The complete post as read at execution time, after pending slug work settled. */ + readonly snapshot: S; + /** `(Untitled)` substituted for a blank title */ + readonly title: string; + /** The slug port's generated proposal, else the slug current after slug work settled */ + readonly slug: string; + readonly target: Readonly; + readonly saveRevision: boolean; +} + +/** The acknowledged identity; a create exposes the id the caller adopts. */ +export interface SaveResult { + id: string; + status: PostStatus; + updatedAt: string; +} + +export type SaveErrorKind = + | 'session-invalid' + | 'not-found' + | 'conflict' + | 'host-limit' + | 'transport' + | 'validation' + | 'unknown'; + +export interface SaveError { + kind: SaveErrorKind; + message: string; + cause?: unknown; +} + +export type SaveOutcome = + | { ok: true; result: R } + | { ok: false; error: SaveError }; + +export type DropReason = 'not-draft' | 'clean' | 'suppressed' | 'conflict' | 'halted' | 'disposed'; + +export type SaveCompletion = + | { kind: 'saved'; result: SaveResult; executedAs: SaveIntent } + | { kind: 'failed'; error: SaveError; executedAs: SaveIntent } + | { kind: 'dropped'; reason: DropReason } + /** A later status command replaced this one before it ran; riders stayed with the winner. */ + | { kind: 'superseded'; by: SaveIntent } + /** The command would change the status and re-auth interrupted it; the publish flow must re-confirm. */ + | { kind: 'needs-retry' }; + +export type SaveEngineState = + | { kind: 'idle' } + | { kind: 'debouncing' } + | { kind: 'saving'; intent: SaveIntent } + | { kind: 'pending-coalesced'; intent: SaveIntent; pending: SaveIntent } + | { kind: 'reauth-pending'; intent: SaveIntent } + | { kind: 'error'; intent: SaveIntent; error: SaveError } + /** The server rejected a stale updated_at; automatic saves halt until the baseline changes. */ + | { kind: 'conflict'; intent: SaveIntent; error: SaveError } + | { kind: 'halted' } + | { kind: 'crashed' } + | { kind: 'disposed' }; + +export type LeaveDecision = 'proceed' | 'confirm'; + +/** A fresh generation, or `unchanged` when the slug port keeps the current slug (custom, same title, frozen). */ +export interface SlugProposal { + slug: string; + source: 'generated' | 'unchanged'; +} + +export interface SlugPort { + /** Must resolve once the latest manual slug submission has settled, not when an in-progress flag drops before a deferred submission starts. */ + settled: () => Promise; + /** Asked for every draft save and whenever the post has no slug; `postId` excludes the post from dedup. */ + fromTitle: (title: string, postId: string | null, signal: AbortSignal) => Promise; +} + +/** `P` is a plain structural superset of the request (no brand); `R` of the acknowledged result. */ +export interface SaveEnginePorts< + S extends SaveSnapshot = SaveSnapshot, + P extends SaveRequest = SaveRequest, + R extends SaveResult = SaveResult, +> { + getSnapshot: () => S; + slug: SlugPort; + /** Builds and validates the candidate; runs inside the single-flight unit, before any IO. */ + prepare: (request: SaveRequest, signal: AbortSignal) => Promise

; + /** IO only. A rejected promise is treated as an `unknown` error. */ + execute: (prepared: P, signal: AbortSignal) => Promise>; + /** Awaited before the pending slot drains. Must not throw: adopt the acknowledged id/status/updated_at before any work that can fail. */ + reconcile: (prepared: P, result: R) => Promise | void; + setTimeout?: (fn: () => void, ms: number) => unknown; + clearTimeout?: (handle: unknown) => void; + onStateChange?: (state: SaveEngineState) => void; + /** A throwing `onStateChange` or subscriber is reported here instead of interrupting the save. */ + onListenerError?: (error: unknown) => void; +} + +export interface SaveEngine { + dispatch(kind: 'schedule', options: ScheduleOptions): Promise; + dispatch(kind: 'publish', options?: PublishOptions): Promise; + dispatch(kind: Exclude): Promise; + getState(): SaveEngineState; + subscribe(listener: (state: SaveEngineState) => void): () => void; + reauthSucceeded(): void; + reauthAbandoned(): void; + leaveRequested(): Promise; + /** Also aborts the in-flight signal; a response arriving afterwards is never reconciled. */ + dispose(): void; +} + +export type TargetSource = Pick; + +export function zeroMilliseconds(iso: string | null): string | null { + if (iso === null) { + return null; + } + const time = Date.parse(iso); + if (Number.isNaN(time)) { + return iso; + } + return new Date(time - (time % 1000)).toISOString(); +} + +function withEmail(target: SaveTarget, options: PublishOptions): SaveTarget { + if (options.emailOnly !== undefined) { + target.emailOnly = options.emailOnly; + } + if (options.newsletter !== undefined) { + target.newsletter = options.newsletter; + } + if (options.emailSegment !== undefined) { + target.emailSegment = options.emailSegment; + } + return target; +} + +/** Transition-derived target for a status intent, from the source the command was captured against. */ +export function deriveTarget( + kind: StatusIntent, + source: TargetSource, + options: PublishOptions = {}, +): SaveTarget { + switch (kind) { + case 'publish': + return withEmail( + { + status: 'published', + publishedAt: zeroMilliseconds( + options.publishedAt === undefined ? source.publishedAt : options.publishedAt, + ), + }, + options, + ); + case 'schedule': + return withEmail( + { status: 'scheduled', publishedAt: zeroMilliseconds(options.publishedAt ?? null) }, + options, + ); + case 'revert': + // Unscheduling clears the publish time; unpublishing keeps it as history. + return { + status: 'draft', + publishedAt: source.status === 'scheduled' ? null : zeroMilliseconds(source.publishedAt), + emailOnly: false, + }; + } +} + +/** Background intents pin draft; explicit and leave preserve the status; status intents carry their own target. */ +export function resolveTarget(command: SaveCommand, snapshot: TargetSource): Readonly { + if (command.target) { + return command.target; + } + return { + status: isBackgroundIntent(command.kind) ? 'draft' : snapshot.status, + publishedAt: zeroMilliseconds(snapshot.publishedAt), + }; +} + +function changesStatus(command: SaveCommand, snapshot: TargetSource): boolean { + return resolveTarget(command, snapshot).status !== snapshot.status; +} + +interface Waiter { + command: SaveCommand; + resolve: (completion: SaveCompletion) => void; +} + +interface Slot { + command: SaveCommand; + waiters: Waiter[]; +} + +interface Timer { + handle: unknown; + waiters: Waiter[]; +} + +interface Frozen { + slot: Slot; + error: SaveError; +} + +const AUTOSAVE: SaveCommand = { + kind: 'autosave', + requiresRevision: false, + requiresReconfirmation: false, +}; +const TIMED: SaveCommand = { + kind: 'timed', + requiresRevision: false, + requiresReconfirmation: false, +}; + +function dropped(reason: DropReason): SaveCompletion { + return { kind: 'dropped', reason }; +} + +function failed(error: SaveError, executedAs: SaveIntent): SaveCompletion { + return { kind: 'failed', error, executedAs }; +} + +function settle(waiters: Waiter[], completion: SaveCompletion): void { + for (const waiter of waiters) { + waiter.resolve(completion); + } +} + +function withRevision(command: SaveCommand, waiters: Waiter[]): SaveCommand { + const requiresRevision = + command.requiresRevision || waiters.some((waiter) => waiter.command.requiresRevision); + return requiresRevision === command.requiresRevision ? command : { ...command, requiresRevision }; +} + +function toSaveError(cause: unknown): SaveError { + const message = cause instanceof Error ? cause.message : 'Save failed'; + return { kind: 'unknown', message, cause }; +} + +function sameState(a: SaveEngineState, b: SaveEngineState): boolean { + if (a.kind !== b.kind) { + return false; + } + const left = a as Partial>; + const right = b as Partial>; + return ( + left.intent === right.intent && left.pending === right.pending && left.error === right.error + ); +} + +function rethrowAsync(error: unknown): void { + queueMicrotask(() => { + throw error; + }); +} + +export function createSaveEngine< + S extends SaveSnapshot = SaveSnapshot, + P extends SaveRequest = SaveRequest, + R extends SaveResult = SaveResult, +>(ports: SaveEnginePorts): SaveEngine { + const schedule = ports.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms)); + const cancel = ports.clearTimeout ?? ((handle) => globalThis.clearTimeout(handle as number)); + const reportListenerError = ports.onListenerError ?? rethrowAsync; + const listeners = new Set<(state: SaveEngineState) => void>(); + const transitionWatchers = new Set<() => void>(); + + let state: SaveEngineState = { kind: 'idle' }; + let inFlight: Slot | null = null; + let inFlightAbort: AbortController | null = null; + let pending: Slot | null = null; + // Set while re-authentication is pending: the failed slot freezes the queue until reauth resolves. + let frozen: Frozen | null = null; + let debounce: Timer | null = null; + let timedCycle: Timer | null = null; + let suppressedVersion: number | null = null; + // updated_at the server rejected; automatic saves stay halted while the snapshot still carries it. + let staleUpdatedAt: string | null = null; + let leaveInProgress: Promise | null = null; + let disposed = false; + + function setState(next: SaveEngineState): void { + if (sameState(state, next)) { + return; + } + state = next; + for (const listener of [ports.onStateChange, ...listeners]) { + // A nested transition already notified everyone with the newer state. + if (state !== next) { + break; + } + try { + listener?.(next); + } catch (error) { + reportListenerError(error); + } + } + for (const watcher of [...transitionWatchers]) { + watcher(); + } + } + + function isTerminal(): boolean { + return state.kind === 'halted' || state.kind === 'crashed'; + } + + // Errors persist until a save actually starts; timers arming or dropping do not clear them. + function deriveState(): SaveEngineState { + if (frozen || isTerminal() || disposed) { + return state; + } + if (inFlight) { + return pending + ? { + kind: 'pending-coalesced', + intent: inFlight.command.kind, + pending: pending.command.kind, + } + : { kind: 'saving', intent: inFlight.command.kind }; + } + if (state.kind === 'error' || state.kind === 'conflict') { + return state; + } + if (debounce || timedCycle) { + return { kind: 'debouncing' }; + } + return { kind: 'idle' }; + } + + function isSuppressed(snapshot: S): boolean { + return suppressedVersion !== null && snapshot.version === suppressedVersion; + } + + function isStale(snapshot: S): boolean { + return staleUpdatedAt !== null && snapshot.updatedAt === staleUpdatedAt; + } + + // Field saves on published/scheduled/sent posts are dropped: the sidebar stages those edits until Update. + function backgroundDropReason(snapshot: S): DropReason | null { + if (snapshot.status !== 'draft') { + return 'not-draft'; + } + if (isSuppressed(snapshot)) { + return 'suppressed'; + } + if (isStale(snapshot)) { + return 'conflict'; + } + return null; + } + + // Any save start cancels both timers; their waiters chain into the save that supersedes them. + function clearTimers(into: Waiter[]): void { + for (const timer of [debounce, timedCycle]) { + if (timer) { + cancel(timer.handle); + into.push(...timer.waiters); + } + } + debounce = null; + timedCycle = null; + } + + function restartDebounce(waiter?: Waiter): void { + const waiters = debounce ? debounce.waiters : []; + if (debounce) { + cancel(debounce.handle); + } + if (waiter) { + waiters.push(waiter); + } + const handle = schedule(() => { + debounce = null; + enqueue(AUTOSAVE, waiters); + }, AUTOSAVE_DEBOUNCE_MS); + debounce = { handle, waiters }; + setState(deriveState()); + } + + function armTimedCycle(): void { + if (timedCycle) { + return; + } + const waiters: Waiter[] = []; + const handle = schedule(() => { + timedCycle = null; + enqueue(TIMED, waiters); + }, TIMED_SAVE_INTERVAL_MS); + timedCycle = { handle, waiters }; + setState(deriveState()); + } + + // Higher priority wins and carries the riders; a later status command supersedes only the earlier one. + function coalesce(command: SaveCommand, waiters: Waiter[]): void { + if (!pending) { + pending = { command: withRevision(command, waiters), waiters }; + return; + } + let winner = pending.command; + if (isStatusIntent(command.kind) && isStatusIntent(pending.command.kind)) { + const superseded = pending.waiters.filter((waiter) => isStatusIntent(waiter.command.kind)); + pending.waiters = pending.waiters.filter((waiter) => !isStatusIntent(waiter.command.kind)); + settle(superseded, { kind: 'superseded', by: command.kind }); + winner = command; + } else if (PRIORITY[command.kind] > PRIORITY[pending.command.kind]) { + winner = command; + } + pending.waiters.push(...waiters); + pending.command = withRevision(winner, pending.waiters); + } + + function enqueue(command: SaveCommand, waiters: Waiter[]): void { + if (inFlight || frozen) { + coalesce(command, waiters); + setState(deriveState()); + return; + } + void run({ command, waiters }); + } + + function drain(): void { + if (inFlight) { + return; + } + const next = pending; + pending = null; + if (next) { + void run(next); + } else { + setState(deriveState()); + } + } + + function failSlot(slot: Slot, error: SaveError): void { + settle(slot.waiters, failed(error, slot.command.kind)); + setState({ kind: 'error', intent: slot.command.kind, error }); + drain(); + } + + function blankToDefault(title: string): string { + return title.trim() ? title : DEFAULT_TITLE; + } + + // Status is the one rule the slug port cannot know; it answers custom/same-title/frozen itself. + function proposeSlug(snapshot: S, signal: AbortSignal): Promise { + if (snapshot.slug && snapshot.status !== 'draft') { + return Promise.resolve(null); + } + return ports.slug.fromTitle(blankToDefault(snapshot.title), snapshot.id, signal); + } + + function buildRequest( + command: SaveCommand, + snapshot: S, + proposal: SlugProposal | null, + ): SaveRequest { + return { + command, + snapshot, + title: blankToDefault(snapshot.title), + slug: proposal?.source === 'generated' ? proposal.slug : snapshot.slug, + target: resolveTarget(command, snapshot), + saveRevision: command.requiresRevision, + }; + } + + function dropInFlight(slot: Slot, snapshot: S): boolean { + const reason = dropReason(slot, snapshot); + if (!reason) { + return false; + } + inFlight = null; + inFlightAbort = null; + settle(slot.waiters, dropped(reason)); + drain(); + return true; + } + + function dropReason(slot: Slot, snapshot: S): DropReason | null { + if (!isBackgroundIntent(slot.command.kind)) { + return null; + } + if (snapshot.status !== 'draft') { + return 'not-draft'; + } + if (!snapshot.isDirty) { + return 'clean'; + } + return backgroundDropReason(snapshot); + } + + async function run(slot: Slot): Promise { + clearTimers(slot.waiters); + slot.command = withRevision(slot.command, slot.waiters); + + let snapshot: S; + try { + snapshot = ports.getSnapshot(); + } catch (cause) { + failSlot(slot, toSaveError(cause)); + return; + } + const early = dropReason(slot, snapshot); + if (early) { + settle(slot.waiters, dropped(early)); + drain(); + return; + } + + inFlight = slot; + const abort = new AbortController(); + inFlightAbort = abort; + setState(deriveState()); + + let outcome: SaveOutcome; + try { + await ports.slug.settled(); + if (disposed) { + return; + } + // Every await re-reads the post and re-runs the drop rules on it; the payload reflects the post as it is now. + snapshot = ports.getSnapshot(); + if (dropInFlight(slot, snapshot)) { + return; + } + const proposal = await proposeSlug(snapshot, abort.signal); + if (disposed) { + return; + } + if (proposal) { + snapshot = ports.getSnapshot(); + if (dropInFlight(slot, snapshot)) { + return; + } + } + const prepared = await ports.prepare( + buildRequest(slot.command, snapshot, proposal), + abort.signal, + ); + if (disposed) { + return; + } + outcome = await ports.execute(prepared, abort.signal); + if (disposed) { + return; + } + if (outcome.ok) { + await ports.reconcile(prepared, outcome.result); + } + } catch (cause) { + outcome = { ok: false, error: toSaveError(cause) }; + } + + if (disposed) { + return; + } + inFlight = null; + inFlightAbort = null; + + if (outcome.ok) { + suppressedVersion = null; + staleUpdatedAt = null; + settle(slot.waiters, { + kind: 'saved', + result: outcome.result, + executedAs: slot.command.kind, + }); + drain(); + return; + } + handleError(slot, snapshot, outcome.error); + } + + function handleError(slot: Slot, snapshot: S, error: SaveError): void { + const intent = slot.command.kind; + + if (error.kind === 'session-invalid') { + frozen = { slot, error }; + setState({ kind: 'reauth-pending', intent }); + return; + } + + if (error.kind === 'not-found') { + const dropWaiters: Waiter[] = []; + clearTimers(dropWaiters); + if (pending) { + dropWaiters.push(...pending.waiters); + pending = null; + } + settle(dropWaiters, dropped('halted')); + settle(slot.waiters, failed(error, intent)); + setState({ kind: snapshot.id ? 'halted' : 'crashed' }); + return; + } + + // No automatic retry against the stale baseline: queued work is dropped, an explicit retry is the way out. + if (error.kind === 'conflict') { + staleUpdatedAt = snapshot.updatedAt; + const dropWaiters: Waiter[] = []; + clearTimers(dropWaiters); + if (pending) { + dropWaiters.push(...pending.waiters); + pending = null; + } + settle(dropWaiters, dropped('conflict')); + settle(slot.waiters, failed(error, intent)); + setState({ kind: 'conflict', intent, error }); + return; + } + + if (error.kind === 'validation') { + suppressedVersion = snapshot.version; + } + // A limit hit by a status change says nothing about draft persistence; only a draft save's limit halts it. + if (error.kind === 'host-limit' && !changesStatus(slot.command, snapshot)) { + suppressedVersion = snapshot.version; + } + failSlot(slot, error); + } + + function captureCommand(kind: DispatchIntent, snapshot: S | null, options?: PublishOptions) { + if (isStatusIntent(kind) && snapshot) { + const target = deriveTarget(kind, snapshot, options); + return { + kind, + target, + requiresRevision: false, + requiresReconfirmation: target.status !== snapshot.status, + } satisfies SaveCommand; + } + return { + kind, + requiresRevision: kind === 'explicit' || kind === 'leave', + requiresReconfirmation: false, + } satisfies SaveCommand; + } + + function dispatch(kind: DispatchIntent, options?: PublishOptions): Promise { + return new Promise((resolve) => { + if (disposed) { + resolve(dropped('disposed')); + return; + } + if (isTerminal()) { + resolve(dropped('halted')); + return; + } + + let snapshot: S | null = null; + if (isBackgroundIntent(kind) || isStatusIntent(kind)) { + try { + snapshot = ports.getSnapshot(); + } catch (cause) { + resolve(failed(toSaveError(cause), kind)); + return; + } + } + const waiter: Waiter = { command: captureCommand(kind, snapshot, options), resolve }; + + if (!isBackgroundIntent(kind) || !snapshot) { + const waiters = [waiter]; + clearTimers(waiters); + enqueue(waiter.command, waiters); + return; + } + + const reason = backgroundDropReason(snapshot); + if (reason) { + resolve(dropped(reason)); + return; + } + if (kind === 'field') { + enqueue(waiter.command, [waiter]); + return; + } + armTimedCycle(); + if (snapshot.id === null) { + enqueue(waiter.command, [waiter]); + return; + } + restartDebounce(waiter); + }); + } + + function readSnapshot(): S | null { + try { + return ports.getSnapshot(); + } catch { + return null; + } + } + + // Judged by the resolved effect against the current post, not by the intent label. + function needsReconfirmation(command: SaveCommand, snapshot: S | null): boolean { + return snapshot ? changesStatus(command, snapshot) : command.requiresReconfirmation; + } + + function reauthSucceeded(): void { + if (!frozen || disposed) { + return; + } + const slots = pending ? [frozen.slot, pending] : [frozen.slot]; + frozen = null; + pending = null; + const snapshot = readSnapshot(); + let reconfirm = false; + for (const slot of slots) { + for (const waiter of slot.waiters) { + if (needsReconfirmation(waiter.command, snapshot)) { + waiter.resolve({ kind: 'needs-retry' }); + reconfirm = true; + } else { + coalesce(waiter.command, [waiter]); + } + } + } + // Content a disarmed status command would have carried resumes through the normal autosave path. + if (reconfirm && !pending) { + resumeAutosave(snapshot); + } + drain(); + } + + function resumeAutosave(snapshot: S | null): void { + if (!snapshot) { + // The re-armed autosave re-reads the snapshot and surfaces a failure instead of abandoning content. + armTimedCycle(); + restartDebounce(); + return; + } + if (snapshot.status !== 'draft' || !snapshot.isDirty || backgroundDropReason(snapshot)) { + return; + } + armTimedCycle(); + if (snapshot.id === null) { + enqueue(AUTOSAVE, []); + return; + } + restartDebounce(); + } + + function reauthAbandoned(): void { + if (!frozen || disposed) { + return; + } + const { slot, error } = frozen; + frozen = null; + settle(slot.waiters, failed(error, slot.command.kind)); + const waiters: Waiter[] = []; + clearTimers(waiters); + if (pending) { + waiters.push(...pending.waiters); + pending = null; + } + for (const waiter of waiters) { + waiter.resolve(failed(error, waiter.command.kind)); + } + setState({ kind: 'error', intent: slot.command.kind, error }); + } + + function queueSettled(): Promise { + return new Promise((resolve) => { + const check = () => { + if ((!inFlight && !pending) || frozen || disposed || isTerminal()) { + transitionWatchers.delete(check); + resolve(); + } + }; + transitionWatchers.add(check); + check(); + }); + } + + function leaveRequested(): Promise { + if (!leaveInProgress) { + leaveInProgress = decideLeave().finally(() => { + leaveInProgress = null; + }); + } + return leaveInProgress; + } + + // Loops until nothing is in flight, pending, or armed, re-reading the post after every wait. + // Fails closed: an unreadable snapshot asks for confirmation; a disposed engine lets the caller go. + async function decideLeave(): Promise { + let saveOnLeavePerformed = false; + for (;;) { + if (disposed) { + return 'proceed'; + } + const snapshot = readSnapshot(); + if (!snapshot) { + return 'confirm'; + } + if (isTerminal() || frozen) { + return snapshot.isDirty ? 'confirm' : 'proceed'; + } + const canSaveOnLeave = + !saveOnLeavePerformed && + snapshot.isDirty && + snapshot.status === 'draft' && + !isStale(snapshot); + if (canSaveOnLeave && snapshot.changedSinceLastRevision) { + saveOnLeavePerformed = true; + await dispatch('leave'); + continue; + } + if (inFlight || pending) { + await queueSettled(); + continue; + } + if (!snapshot.isDirty) { + return 'proceed'; + } + if (canSaveOnLeave && (debounce || timedCycle)) { + saveOnLeavePerformed = true; + await dispatch('leave'); + continue; + } + return 'confirm'; + } + } + + function subscribe(listener: (state: SaveEngineState) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + function dispose(): void { + if (disposed) { + return; + } + disposed = true; + inFlightAbort?.abort(); + inFlightAbort = null; + const waiters: Waiter[] = []; + clearTimers(waiters); + for (const slot of [inFlight, pending, frozen?.slot ?? null]) { + if (slot) { + waiters.push(...slot.waiters); + } + } + inFlight = null; + pending = null; + frozen = null; + settle(waiters, dropped('disposed')); + setState({ kind: 'disposed' }); + listeners.clear(); + } + + return { + dispatch, + getState: () => state, + subscribe, + reauthSucceeded, + reauthAbandoned, + leaveRequested, + dispose, + }; +} diff --git a/apps/admin/src/editor/engine/slug-machine.test.ts b/apps/admin/src/editor/engine/slug-machine.test.ts new file mode 100644 index 00000000000..71f275019d2 --- /dev/null +++ b/apps/admin/src/editor/engine/slug-machine.test.ts @@ -0,0 +1,847 @@ +import { describe, expect, it, vi } from 'vitest'; +import { slugify } from '@tryghost/string'; +import { deferred } from '@/utils/deferred'; +import { DEFAULT_TITLE } from './save-engine'; +import { + createSlugMachine, + isCustomSlug, + normalizeManualSlug, + resolveDedupedSlug, + shouldGenerateSlug, + type SlugMachineState, + type SlugProposal, +} from './slug-machine'; + +function createHarness( + generateSlug = vi.fn((text: string) => Promise.resolve(slugify(text))), + onListenerError = vi.fn(), +) { + const machine = createSlugMachine({ generateSlug, onListenerError }); + const proposals: SlugProposal[] = []; + const events: Array<{ state: SlugMachineState; proposal: SlugProposal | null }> = []; + machine.subscribe((state, proposal) => { + events.push({ state, proposal }); + if (proposal) { + proposals.push(proposal); + } + }); + return { machine, generateSlug, onListenerError, proposals, events }; +} + +describe('isCustomSlug', () => { + it.each([ + ['', 'Hello', false], + ['hello', 'Hello', false], + ['hello-world', 'Hello World!', false], + ['unicode-ttitle', 'Ünïcödé Ttitle', false], + ['my-own-slug', 'Hello', true], + ['hello-2', 'Hello', true], + ['whatever', DEFAULT_TITLE, false], + ['untitled-3', DEFAULT_TITLE, false], + ['foo-copy-2', 'Foo (Copy)', false], + ['anything', 'Foo (Copy)', false], + ['anything', 'Foo (copy)', true], + ['anything', '', true], + ])('slug %j with saved title %j → custom: %s', (slug, title, expected) => { + expect(isCustomSlug(slug, title)).toBe(expected); + }); +}); + +describe('shouldGenerateSlug', () => { + it.each([ + ['custom mode', { mode: 'custom', slug: 'x' }, 'New title', false], + ['custom mode without slug', { mode: 'custom', slug: '' }, 'New title', false], + ['blank title', { mode: 'derived', slug: 'x' }, '', false], + ['whitespace title', { mode: 'derived', slug: 'x' }, ' ', false], + ['untitled with slug', { mode: 'derived', slug: 'untitled' }, DEFAULT_TITLE, false], + [ + 'padded untitled with slug', + { mode: 'derived', slug: 'untitled' }, + ` ${DEFAULT_TITLE} `, + false, + ], + ['untitled without slug', { mode: 'derived', slug: '' }, DEFAULT_TITLE, true], + ['normal title', { mode: 'derived', slug: 'old' }, 'New title', true], + ['copy-suffixed title', { mode: 'derived', slug: 'foo-copy' }, 'Foo (Copy)', true], + ] as const)('%s', (_label, state, title, expected) => { + expect(shouldGenerateSlug(state, title)).toBe(expected); + }); +}); + +describe('normalizeManualSlug', () => { + it.each([ + ['', 'slug', null], + [' ', 'slug', null], + ['slug', 'slug', null], + ['slug ', 'slug', null], + ['', '', null], + [' changed ', 'slug', 'changed'], + ['Hello World', 'slug', 'Hello World'], + ])('input %j with current %j → %j', (input, current, expected) => { + expect(normalizeManualSlug(input, current)).toBe(expected); + }); +}); + +describe('resolveDedupedSlug', () => { + it.each([ + ['server returns current', 'whatever', 'whatever#slug', 'whatever', 'whatever'], + ['server appends increment to current', 'whatever-2', 'whatever!', 'whatever', 'whatever'], + ['user typed the incremented value', 'whatever-2', 'whatever-2', 'whatever', 'whatever-2'], + ['increment on a hyphenated current', 'a-b-3', 'a b', 'a-b', 'a-b'], + ['zero is not an increment', 'whatever-0', 'whatever 0', 'whatever', 'whatever-0'], + ['trailing number that is a different base', 'other-2', 'other', 'whatever', 'other-2'], + ['distinct slug', 'changed', 'changed', 'whatever', 'changed'], + ['typed number word on current base', 'top-10', 'top 10', 'top', 'top-10'], + ['numeric result on an empty current slug', '2', '2!', '', '2'], + ])('%s', (_label, serverSlug, candidate, current, expected) => { + expect(resolveDedupedSlug(serverSlug, candidate, current)).toBe(expected); + }); +}); + +describe('createSlugMachine', () => { + describe('loaded', () => { + it.each([ + ['new blank post', { slug: '', title: '' }, 'derived', 'frozen'], + ['saved untitled post', { slug: 'untitled', title: DEFAULT_TITLE }, 'derived', 'frozen'], + ['derived slug', { slug: 'hello', title: 'Hello' }, 'derived', 'derived'], + ['custom slug', { slug: 'my-slug', title: 'Hello' }, 'custom', 'custom'], + ['deduped slug reads as custom', { slug: 'hello-2', title: 'Hello' }, 'custom', 'custom'], + ['duplicated post', { slug: 'foo-copy-2', title: 'Foo (Copy)' }, 'derived', 'derived'], + ] as const)('%s', (_label, post, mode, status) => { + const { machine, events } = createHarness(); + machine.loaded(post); + const expected = { ...post, lastCommittedTitle: post.title, mode, status, pending: false }; + expect(machine.getState()).toEqual(expected); + expect(events).toEqual([{ state: expected, proposal: null }]); + }); + }); + + describe('titleCommitted', () => { + it('generates a slug from the committed title and emits it', async () => { + const { machine, generateSlug, proposals } = createHarness(); + machine.loaded({ slug: '', title: '' }); + + const proposal = await machine.titleCommitted(' Hello World '); + + expect(generateSlug).toHaveBeenCalledWith('Hello World'); + expect(proposal).toEqual({ slug: 'hello-world', source: 'generated' }); + expect(proposals).toEqual([proposal]); + expect(machine.getState()).toMatchObject({ + slug: 'hello-world', + title: 'Hello World', + status: 'derived', + }); + }); + + it('notifies when a request starts and when it settles', async () => { + const pending = deferred(); + const { machine, events } = createHarness(vi.fn().mockReturnValueOnce(pending.promise)); + machine.loaded({ slug: '', title: '' }); + events.length = 0; + + const commit = machine.titleCommitted('Hello'); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ state: { pending: true }, proposal: null }); + + pending.resolve('hello'); + await commit; + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + state: { pending: false, slug: 'hello' }, + proposal: { slug: 'hello', source: 'generated' }, + }); + }); + + it('keeps following the title after a deduplicated response', async () => { + const generateSlug = vi.fn().mockResolvedValueOnce('hello-2').mockResolvedValueOnce('world'); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: '', title: '' }); + + await machine.titleCommitted('Hello'); + expect(machine.getState()).toMatchObject({ slug: 'hello-2', mode: 'derived' }); + + await expect(machine.titleCommitted('World')).resolves.toEqual({ + slug: 'world', + source: 'generated', + }); + }); + + it('ignores an unchanged title when a slug exists', async () => { + const { machine, generateSlug } = createHarness(); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.titleCommitted('Hello ')).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'same-title', + }); + expect(generateSlug).not.toHaveBeenCalled(); + }); + + it('discards an in-flight generation when the title returns to the generated title', async () => { + const pending = deferred(); + const { machine, events, proposals } = createHarness( + vi.fn().mockReturnValueOnce(pending.promise), + ); + machine.loaded({ slug: 'hello', title: 'Hello' }); + events.length = 0; + + const commit = machine.titleCommitted('Changed'); + await expect(machine.titleCommitted('Hello')).resolves.toMatchObject({ + source: 'unchanged', + reason: 'same-title', + }); + const eventCount = events.length; + pending.resolve('changed'); + + await expect(commit).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'stale', + }); + expect(machine.getState()).toMatchObject({ + slug: 'hello', + title: 'Hello', + lastCommittedTitle: 'Hello', + pending: false, + }); + expect(proposals.filter((proposal) => proposal.source === 'generated')).toEqual([]); + expect(events).toHaveLength(eventCount); + expect(events.at(-1)).toMatchObject({ + state: { pending: false }, + proposal: { reason: 'same-title' }, + }); + }); + + it('discards an in-flight generation when a later title is frozen', async () => { + const pending = deferred(); + const { machine } = createHarness(vi.fn().mockReturnValueOnce(pending.promise)); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const commit = machine.titleCommitted('Changed'); + await expect(machine.titleCommitted('')).resolves.toMatchObject({ + source: 'unchanged', + reason: 'frozen', + }); + pending.resolve('changed'); + + await expect(commit).resolves.toMatchObject({ source: 'unchanged', reason: 'stale' }); + expect(machine.getState()).toMatchObject({ + slug: 'hello', + title: 'Hello', + lastCommittedTitle: '', + pending: false, + }); + }); + + it('generates for an unchanged title when the slug is missing', async () => { + const { machine } = createHarness(); + machine.loaded({ slug: '', title: 'Hello' }); + + await expect(machine.titleCommitted('Hello')).resolves.toEqual({ + slug: 'hello', + source: 'generated', + }); + }); + + it('freezes blank titles without calling the generator', async () => { + const { machine, generateSlug } = createHarness(); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.titleCommitted(' ')).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'frozen', + }); + expect(generateSlug).not.toHaveBeenCalled(); + expect(machine.getState()).toMatchObject({ + slug: 'hello', + title: 'Hello', + lastCommittedTitle: '', + status: 'frozen', + }); + }); + + it('reports derived after a failed commit on an untitled post', async () => { + const { machine } = createHarness(vi.fn().mockRejectedValueOnce(new Error('boom'))); + machine.loaded({ slug: 'untitled', title: DEFAULT_TITLE }); + expect(machine.getState().status).toBe('frozen'); + + await expect(machine.titleCommitted('Hello')).resolves.toMatchObject({ reason: 'error' }); + + expect(machine.getState()).toMatchObject({ + status: 'derived', + title: DEFAULT_TITLE, + lastCommittedTitle: 'Hello', + slug: 'untitled', + }); + }); + + it('stops reporting pending for the previous post once a new one loads', async () => { + const pending = deferred(); + const { machine } = createHarness(vi.fn().mockReturnValueOnce(pending.promise)); + machine.loaded({ slug: '', title: '' }); + + const commit = machine.titleCommitted('Old post'); + expect(machine.getState().pending).toBe(true); + + machine.loaded({ slug: 'other', title: 'Other' }); + expect(machine.getState().pending).toBe(false); + + pending.resolve('old-post'); + await expect(commit).resolves.toMatchObject({ reason: 'stale' }); + expect(machine.getState().pending).toBe(false); + }); + + it('sets the untitled slug once and never again', async () => { + const { machine, generateSlug } = createHarness(); + machine.loaded({ slug: '', title: '' }); + + await expect(machine.titleCommitted(DEFAULT_TITLE)).resolves.toEqual({ + slug: 'untitled', + source: 'generated', + }); + await machine.titleCommitted('Draft'); + await expect(machine.titleCommitted(DEFAULT_TITLE)).resolves.toEqual({ + slug: 'draft', + source: 'unchanged', + reason: 'frozen', + }); + expect(generateSlug).toHaveBeenCalledTimes(2); + }); + + it('regenerates when the saved title was (Untitled)', async () => { + const { machine } = createHarness(); + machine.loaded({ slug: 'untitled-2', title: DEFAULT_TITLE }); + + await expect(machine.titleCommitted('Real title')).resolves.toEqual({ + slug: 'real-title', + source: 'generated', + }); + }); + + it('regenerates when the saved title ended with (Copy)', async () => { + const { machine } = createHarness(); + machine.loaded({ slug: 'foo-copy-2', title: 'Foo (Copy)' }); + + await expect(machine.titleCommitted('Bar')).resolves.toEqual({ + slug: 'bar', + source: 'generated', + }); + }); + + it('never touches a custom slug detected at load', async () => { + const { machine, generateSlug } = createHarness(); + machine.loaded({ slug: 'my-slug', title: 'Hello' }); + + await expect(machine.titleCommitted('Changed')).resolves.toEqual({ + slug: 'my-slug', + source: 'unchanged', + reason: 'custom', + }); + await machine.titleCommitted(DEFAULT_TITLE); + await machine.titleCommitted('Foo (Copy)'); + expect(generateSlug).not.toHaveBeenCalled(); + expect(machine.getState()).toMatchObject({ + slug: 'my-slug', + status: 'custom', + title: 'Hello', + }); + }); + + it('serializes overlapping title commits', async () => { + const first = deferred(); + const second = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const { machine, proposals } = createHarness(generateSlug); + machine.loaded({ slug: '', title: '' }); + + const firstCommit = machine.titleCommitted('First'); + const secondCommit = machine.titleCommitted('Second'); + expect(machine.getState()).toMatchObject({ pending: true, title: '' }); + + first.resolve('first'); + await expect(firstCommit).resolves.toEqual({ + slug: 'first', + source: 'generated', + }); + expect(generateSlug).toHaveBeenCalledTimes(2); + + second.resolve('second'); + await expect(secondCommit).resolves.toEqual({ slug: 'second', source: 'generated' }); + + expect(machine.getState()).toMatchObject({ slug: 'second', title: 'Second', pending: false }); + expect(proposals.filter((p) => p.source === 'generated')).toEqual([ + { slug: 'first', source: 'generated' }, + { slug: 'second', source: 'generated' }, + ]); + }); + + it('retains only the latest deferred title commit', async () => { + const first = deferred(); + const third = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(third.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: '', title: '' }); + + const firstCommit = machine.titleCommitted('First'); + const secondCommit = machine.titleCommitted('Second'); + const thirdCommit = machine.titleCommitted('Third'); + + await expect(secondCommit).resolves.toMatchObject({ source: 'unchanged', reason: 'stale' }); + expect(generateSlug).toHaveBeenCalledTimes(1); + + first.resolve('first'); + await expect(firstCommit).resolves.toEqual({ slug: 'first', source: 'generated' }); + expect(generateSlug).toHaveBeenCalledTimes(2); + + third.resolve('third'); + await expect(thirdCommit).resolves.toEqual({ slug: 'third', source: 'generated' }); + expect(machine.getState()).toMatchObject({ slug: 'third', title: 'Third', pending: false }); + }); + + it('discards in-flight responses when a post is loaded', async () => { + const pending = deferred(); + const { machine, events } = createHarness(vi.fn().mockReturnValueOnce(pending.promise)); + machine.loaded({ slug: '', title: '' }); + + const commit = machine.titleCommitted('Old post'); + machine.loaded({ slug: 'other', title: 'Other' }); + events.length = 0; + pending.resolve('old-post'); + + await expect(commit).resolves.toEqual({ + slug: '', + source: 'unchanged', + reason: 'stale', + }); + expect(machine.getState().slug).toBe('other'); + expect(events).toEqual([]); + }); + + it('reports generator failures without changing the slug', async () => { + const error = new Error('boom'); + const { machine } = createHarness(vi.fn().mockRejectedValueOnce(error)); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.titleCommitted('Changed')).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'error', + error, + }); + expect(machine.getState()).toMatchObject({ slug: 'hello', title: 'Hello', pending: false }); + }); + + it('retries the same title after a generator failure', async () => { + const generateSlug = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce('changed'); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.titleCommitted('Changed')).resolves.toMatchObject({ reason: 'error' }); + await expect(machine.titleCommitted('Changed')).resolves.toEqual({ + slug: 'changed', + source: 'generated', + }); + expect(generateSlug).toHaveBeenCalledTimes(2); + }); + + it('ignores an empty generator result', async () => { + const { machine } = createHarness(vi.fn().mockResolvedValueOnce('')); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.titleCommitted('Changed')).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'empty-result', + }); + }); + + it('ignores a whitespace-only title result', async () => { + const { machine } = createHarness(vi.fn().mockResolvedValueOnce(' ')); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.titleCommitted('Changed')).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'empty-result', + }); + expect(machine.getState()).toMatchObject({ slug: 'hello', title: 'Hello', mode: 'derived' }); + }); + + it.each(['', 'hello'])( + 'does not let a no-op manual %j cancel title generation', + async (input) => { + const pending = deferred(); + const { machine } = createHarness(vi.fn().mockReturnValueOnce(pending.promise)); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const commit = machine.titleCommitted('Changed'); + await expect(machine.slugEdited(input)).resolves.toMatchObject({ + source: 'unchanged', + reason: 'reverted', + }); + expect(machine.getState()).toMatchObject({ mode: 'derived', pending: true }); + + pending.resolve('changed'); + await expect(commit).resolves.toEqual({ slug: 'changed', source: 'generated' }); + }, + ); + + it('keeps title generation when a deferred manual edit is withdrawn', async () => { + const pending = deferred(); + const generateSlug = vi.fn().mockReturnValueOnce(pending.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const commit = machine.titleCommitted('Changed'); + const manual = machine.slugEdited('mine'); + await expect(machine.slugEdited('')).resolves.toMatchObject({ reason: 'reverted' }); + await expect(manual).resolves.toMatchObject({ reason: 'stale' }); + expect(generateSlug).toHaveBeenCalledTimes(1); + + pending.resolve('changed'); + await expect(commit).resolves.toEqual({ slug: 'changed', source: 'generated' }); + expect(machine.getState()).toMatchObject({ + slug: 'changed', + mode: 'derived', + pending: false, + }); + }); + }); + + describe('slugEdited', () => { + it.each([ + ['', 'blank'], + [' ', 'whitespace'], + ['hello', 'unchanged'], + ['hello ', 'padded unchanged'], + ])('reverts a %j (%s) edit without calling the generator', async (input) => { + const { machine, generateSlug } = createHarness(); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.slugEdited(input)).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'reverted', + }); + expect(generateSlug).not.toHaveBeenCalled(); + expect(machine.getState().mode).toBe('derived'); + }); + + it.each([ + ['', 'blank'], + ['hello', 'current slug'], + ])('discards an in-flight manual edit when a later edit reverts to %j (%s)', async (input) => { + const pending = deferred(); + const { machine, proposals } = createHarness(vi.fn().mockReturnValueOnce(pending.promise)); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const edit = machine.slugEdited('mine'); + await expect(machine.slugEdited(input)).resolves.toMatchObject({ + source: 'unchanged', + reason: 'reverted', + }); + expect(machine.getState()).toMatchObject({ mode: 'derived', pending: false }); + pending.resolve('mine'); + + await expect(edit).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'stale', + }); + expect(machine.getState()).toMatchObject({ + slug: 'hello', + mode: 'derived', + pending: false, + }); + expect(proposals.filter((proposal) => proposal.source === 'manual')).toEqual([]); + }); + + it('generates from the title after an invalidated manual request settles', async () => { + const pending = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce('changed'); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const edit = machine.slugEdited('mine'); + await machine.slugEdited('hello'); + const commit = machine.titleCommitted('Changed'); + + pending.resolve('mine'); + await expect(edit).resolves.toMatchObject({ source: 'unchanged', reason: 'stale' }); + await expect(commit).resolves.toEqual({ + slug: 'changed', + source: 'generated', + }); + expect(machine.getState()).toMatchObject({ + slug: 'changed', + mode: 'derived', + pending: false, + }); + + expect(machine.getState()).toMatchObject({ slug: 'changed', pending: false }); + }); + + it('applies the server result and switches to custom', async () => { + const { machine, generateSlug, proposals } = createHarness(); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const proposal = await machine.slugEdited(' My Slug '); + + expect(generateSlug).toHaveBeenCalledWith('My Slug'); + expect(proposal).toEqual({ slug: 'my-slug', source: 'manual' }); + expect(proposals).toEqual([proposal]); + expect(machine.getState()).toMatchObject({ + slug: 'my-slug', + mode: 'custom', + status: 'custom', + title: 'Hello', + }); + }); + + it('never regenerates after a manual edit', async () => { + const { machine, generateSlug } = createHarness(); + machine.loaded({ slug: 'hello', title: 'Hello' }); + await machine.slugEdited('custom'); + + await expect(machine.titleCommitted('Another title')).resolves.toEqual({ + slug: 'custom', + source: 'unchanged', + reason: 'custom', + }); + await machine.titleCommitted(DEFAULT_TITLE); + expect(generateSlug).toHaveBeenCalledTimes(1); + }); + + it('keeps the current slug when the server only appended an increment', async () => { + const { machine } = createHarness(vi.fn().mockResolvedValueOnce('whatever-2')); + machine.loaded({ slug: 'whatever', title: 'Whatever' }); + + await expect(machine.slugEdited('whatever!')).resolves.toEqual({ + slug: 'whatever', + source: 'unchanged', + reason: 'reverted', + }); + expect(machine.getState().mode).toBe('derived'); + }); + + it('keeps the current slug when the server sanitizes back to it', async () => { + const { machine } = createHarness(vi.fn().mockResolvedValueOnce('whatever')); + machine.loaded({ slug: 'whatever', title: 'Whatever' }); + + await expect(machine.slugEdited('whatever#slug')).resolves.toMatchObject({ + source: 'unchanged', + reason: 'reverted', + }); + expect(machine.getState().mode).toBe('derived'); + }); + + it('ignores a whitespace-only manual result', async () => { + const { machine } = createHarness(vi.fn().mockResolvedValueOnce(' ')); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + await expect(machine.slugEdited('changed')).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'empty-result', + }); + expect(machine.getState()).toMatchObject({ slug: 'hello', mode: 'derived' }); + }); + + it('returns to derived when the generator fails', async () => { + const error = new Error('boom'); + const { machine } = createHarness(vi.fn().mockRejectedValueOnce(error)); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const edit = machine.slugEdited('changed'); + expect(machine.getState().mode).toBe('custom'); + await expect(edit).resolves.toEqual({ + slug: 'hello', + source: 'unchanged', + reason: 'error', + error, + }); + expect(machine.getState()).toMatchObject({ slug: 'hello', mode: 'derived', pending: false }); + }); + + it('defers title generation while a manual edit is in flight', async () => { + const pending = deferred(); + const generateSlug = vi.fn().mockReturnValueOnce(pending.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const edit = machine.slugEdited('mine'); + const commit = machine.titleCommitted('Changed'); + + pending.resolve('mine'); + await expect(edit).resolves.toEqual({ slug: 'mine', source: 'manual' }); + await expect(commit).resolves.toMatchObject({ source: 'unchanged', reason: 'custom' }); + expect(generateSlug).toHaveBeenCalledTimes(1); + }); + + it('runs a deferred title commit when the in-flight manual edit fails', async () => { + const pending = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce('changed'); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const edit = machine.slugEdited('mine'); + const commit = machine.titleCommitted('Changed'); + + pending.reject(new Error('boom')); + await expect(edit).resolves.toMatchObject({ reason: 'error' }); + await expect(commit).resolves.toEqual({ + slug: 'changed', + source: 'generated', + }); + expect(machine.getState()).toMatchObject({ + mode: 'derived', + slug: 'changed', + title: 'Changed', + }); + }); + + it('finishes title generation before running a deferred manual edit', async () => { + const generation = deferred(); + const edit = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(generation.promise) + .mockReturnValueOnce(edit.promise) + .mockResolvedValueOnce('changed'); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const commit = machine.titleCommitted('Changed'); + const manual = machine.slugEdited('mine'); + edit.reject(new Error('boom')); + generation.resolve('changed'); + + await expect(manual).resolves.toMatchObject({ reason: 'error' }); + await expect(commit).resolves.toEqual({ + slug: 'changed', + source: 'generated', + }); + expect(machine.getState()).toMatchObject({ + mode: 'derived', + slug: 'changed', + title: 'Changed', + }); + }); + + it('serializes overlapping manual edits', async () => { + const first = deferred(); + const second = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const firstEdit = machine.slugEdited('one'); + const secondEdit = machine.slugEdited('two'); + first.resolve('one'); + + await expect(firstEdit).resolves.toEqual({ slug: 'one', source: 'manual' }); + second.resolve('two'); + await expect(secondEdit).resolves.toEqual({ slug: 'two', source: 'manual' }); + expect(machine.getState().mode).toBe('custom'); + }); + + it('retains only the latest deferred manual edit', async () => { + const first = deferred(); + const third = deferred(); + const generateSlug = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(third.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const firstEdit = machine.slugEdited('one'); + const secondEdit = machine.slugEdited('two'); + const thirdEdit = machine.slugEdited('three'); + + await expect(secondEdit).resolves.toMatchObject({ source: 'unchanged', reason: 'stale' }); + first.resolve('one'); + await expect(firstEdit).resolves.toEqual({ slug: 'one', source: 'manual' }); + third.resolve('three'); + await expect(thirdEdit).resolves.toEqual({ slug: 'three', source: 'manual' }); + expect(machine.getState()).toMatchObject({ slug: 'three', mode: 'custom', pending: false }); + }); + }); + + it('stops notifying after unsubscribe', async () => { + const { machine } = createHarness(); + machine.loaded({ slug: '', title: '' }); + const listener = vi.fn(); + const unsubscribe = machine.subscribe(listener); + + await machine.titleCommitted('One'); + unsubscribe(); + await machine.titleCommitted('Two'); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ slug: 'one', status: 'derived', pending: false }), + { slug: 'one', source: 'generated' }, + ); + }); + + it('isolates subscriber failures from transitions and other subscribers', async () => { + const generateSlug = vi.fn().mockResolvedValue('changed'); + const onListenerError = vi.fn(); + const { machine, events } = createHarness(generateSlug, onListenerError); + machine.loaded({ slug: 'hello', title: 'Hello' }); + machine.subscribe(() => { + throw new Error('listener failed'); + }); + const laterListener = vi.fn(); + machine.subscribe(laterListener); + + await expect(machine.titleCommitted('Changed')).resolves.toEqual({ + slug: 'changed', + source: 'generated', + }); + expect(onListenerError).toHaveBeenCalledTimes(2); + expect(laterListener).toHaveBeenCalledTimes(2); + expect(events.at(-1)).toMatchObject({ state: { pending: false, slug: 'changed' } }); + expect(machine.getState()).toMatchObject({ pending: false, mode: 'derived', slug: 'changed' }); + }); + + it('keeps a manual response atomic with its pending custom mode', async () => { + const pending = deferred(); + const generateSlug = vi.fn().mockReturnValueOnce(pending.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + + const edit = machine.slugEdited('mine'); + const interleavedCommit = pending.promise.then(() => machine.titleCommitted('Changed')); + pending.resolve('mine'); + + await expect(edit).resolves.toEqual({ slug: 'mine', source: 'manual' }); + await expect(interleavedCommit).resolves.toMatchObject({ + source: 'unchanged', + reason: 'custom', + }); + expect(generateSlug).toHaveBeenCalledTimes(1); + expect(machine.getState()).toMatchObject({ slug: 'mine', mode: 'custom', pending: false }); + }); +}); diff --git a/apps/admin/src/editor/engine/slug-machine.ts b/apps/admin/src/editor/engine/slug-machine.ts new file mode 100644 index 00000000000..f777ccaf0d9 --- /dev/null +++ b/apps/admin/src/editor/engine/slug-machine.ts @@ -0,0 +1,474 @@ +/** + * Slug behavior contract + * ---------------------- + * + * This machine owns slug intent and request ordering; callers own the input UI and persistence of + * emitted proposals. The rules below define its complete behavior contract. + * + * Ownership + * - A derived slug follows eligible title commits. A custom slug stops following the title. + * - Loading infers ownership structurally because posts do not persist slug provenance: a slug + * matching `slugify(title)` is derived; a different slug is custom. `(Untitled)` and titles ending + * in `(Copy)` remain derived so a first meaningful title or duplicated-post rename regenerates it. + * - A successful manual edit makes the slug custom. Blank, unchanged, failed, or rejected manual + * edits leave ownership unchanged. + * + * Generation + * - Derived slugs regenerate for non-blank committed titles. An existing slug is frozen for a blank + * title or `(Untitled)` so transient editor titles cannot erase it. + * - The server remains authoritative for sanitization and uniqueness. Whitespace-only responses are + * ignored, and an apparent uniqueness increment is ignored when it only increments the current + * slug rather than representing the user's canonicalized input. + * + * Ordering + * - At most one slug request is sent at a time. While it is active, only the latest additional + * request-requiring submission is retained and run after it settles; replaced submissions + * resolve as stale. + * - Withdrawing a deferred manual edit does not cancel active title generation. Withdrawing an + * active manual edit invalidates its result without starting another request. + * - `loaded()` is a document boundary: it invalidates active and deferred work from the old post. + * + * Observation + * - Applied, rejected, and no-op decisions emit proposals. Stale completions return a proposal to + * their caller but never notify subscribers, so old work cannot look like a current state change. + * - Listener failures are reported and isolated from transitions and from other listeners. + */ +import { slugify } from '@tryghost/string'; +import { DEFAULT_TITLE } from './save-engine'; + +export const DUPLICATED_POST_TITLE_SUFFIX = '(Copy)'; + +export type SlugMode = 'derived' | 'custom'; +export type SlugStatus = 'derived' | 'custom' | 'frozen'; + +export interface SlugMachineState { + readonly status: SlugStatus; + readonly mode: SlugMode; + readonly slug: string; + /** The title the slug was loaded with or last generated from; drives the same-title check. */ + readonly title: string; + /** The title from the latest titleCommitted call regardless of outcome; drives `status`. */ + readonly lastCommittedTitle: string; + /** True while an applicable title or manual request is in flight. */ + readonly pending: boolean; +} + +export type UnchangedReason = + | 'same-title' + | 'custom' + | 'frozen' + | 'stale' + | 'empty-result' + | 'reverted' + | 'error'; + +export type SlugProposal = + | { readonly slug: string; readonly source: 'generated' } + | { readonly slug: string; readonly source: 'manual' } + | { + readonly slug: string; + readonly source: 'unchanged'; + readonly reason: UnchangedReason; + readonly error?: unknown; + }; + +export interface LoadedPost { + readonly slug: string; + readonly title: string; +} + +export interface SlugMachineOptions { + /** Port for GET /slugs/post/:name/:id — receives the raw text, returns the deduplicated slug. */ + generateSlug: (text: string) => Promise; + /** Listener failures are isolated from machine transitions and reported here. Must not throw. */ + onListenerError: (error: unknown) => void; +} + +/** Called on every state change; `proposal` is null when only `pending` changed or a post loaded. */ +export type SlugListener = (state: SlugMachineState, proposal: SlugProposal | null) => void; + +export interface SlugMachine { + loaded(post: LoadedPost): void; + titleCommitted(title: string): Promise; + slugEdited(input: string): Promise; + getState(): SlugMachineState; + subscribe(listener: SlugListener): () => void; +} + +// A slug that differs from slugify(saved title) is custom unless the saved title is +// (Untitled) or ends with (Copy), whose next meaningful title should regenerate it. +export function isCustomSlug(slug: string, title: string): boolean { + if (!slug) { + return false; + } + if (title === DEFAULT_TITLE || title.endsWith(DUPLICATED_POST_TITLE_SUFFIX)) { + return false; + } + return slugify(title) !== slug; +} + +export function shouldGenerateSlug( + state: Pick, + title: string, +): boolean { + if (state.mode === 'custom') { + return false; + } + const trimmed = title.trim(); + if (!trimmed) { + return false; + } + if (trimmed === DEFAULT_TITLE && state.slug) { + return false; + } + return true; +} + +// Returns null when the input must revert to the current slug (blank or unchanged). +export function normalizeManualSlug(input: string, currentSlug: string): string | null { + const candidate = (input || currentSlug).trim(); + if (!candidate || candidate === currentSlug) { + return null; + } + return candidate; +} + +// Keep the current slug when the server only appended an incrementor to it and the user did not +// type that exact value. +export function resolveDedupedSlug( + serverSlug: string, + candidate: string, + currentSlug: string, +): string { + if (serverSlug === currentSlug) { + return currentSlug; + } + const tokens = serverSlug.split('-'); + const increment = Number(tokens.pop()); + if (increment > 0 && tokens.join('-') === currentSlug && serverSlug !== slugify(candidate)) { + return currentSlug; + } + return serverSlug; +} + +export function createSlugMachine({ + generateSlug, + onListenerError, +}: SlugMachineOptions): SlugMachine { + type Submission = { kind: 'title'; value: string } | { kind: 'manual'; value: string }; + type DeferredSubmission = { + submission: Submission; + slugAtSubmission: string; + resolve: (proposal: SlugProposal) => void; + reject: (error: unknown) => void; + }; + + // Only load and an applied manual edit move settledMode. An active manual request reads as + // custom until it settles, and request tickets still guard explicit invalidation and post loads. + let settledMode: SlugMode = 'derived'; + const pendingManual = new Set(); + let slug = ''; + let title = ''; + let lastCommittedTitle = ''; + // Requests have separate applicability per intent. A manual request supersedes title generation, + // but a no-op manual blur only supersedes older manual work and must not cancel a title request. + let nextTicket = 0; + let latestTitleTicket = 0; + let latestManualTicket = 0; + const inFlightTickets = new Set(); + const listeners = new Set(); + // Slug requests are serialized. While one is active, only the latest deferred submission is + // retained so a slow response cannot create overlapping title/manual request races. + let nextSubmissionToken = 0; + let activeSubmissionToken = 0; + let activeSubmissionKind: Submission['kind'] | null = null; + let deferredSubmission: DeferredSubmission | null = null; + + const mode = (): SlugMode => (pendingManual.has(latestManualTicket) ? 'custom' : settledMode); + + const getState = (): SlugMachineState => { + const currentMode = mode(); + const status: SlugStatus = + currentMode === 'custom' + ? 'custom' + : shouldGenerateSlug({ mode: currentMode, slug }, lastCommittedTitle) + ? 'derived' + : 'frozen'; + return { + status, + mode: currentMode, + slug, + title, + lastCommittedTitle, + pending: inFlightTickets.has(latestTitleTicket) || inFlightTickets.has(latestManualTicket), + }; + }; + + const notify = (proposal: SlugProposal | null): void => { + const state = getState(); + for (const listener of listeners) { + try { + listener(state, proposal); + } catch (error) { + try { + onListenerError(error); + } catch { + // Error reporting must not corrupt the state transition or prevent other listeners. + } + } + } + }; + + const emit = (proposal: SlugProposal): SlugProposal => { + notify(proposal); + return proposal; + }; + + const unchanged = (reason: UnchangedReason, error?: unknown): SlugProposal => + emit({ slug, source: 'unchanged', reason, ...(error !== undefined && { error }) }); + + const stale = (slugAtRequest: string): SlugProposal => ({ + slug: slugAtRequest, + source: 'unchanged', + reason: 'stale', + }); + + const invalidateTitleRequest = (): void => { + inFlightTickets.delete(latestTitleTicket); + latestTitleTicket = 0; + }; + + const invalidateManualRequest = (): void => { + inFlightTickets.delete(latestManualTicket); + pendingManual.delete(latestManualTicket); + latestManualTicket = 0; + }; + + const cleanupRequest = (ticket: number): boolean => { + const stateBeforeCleanup = getState(); + inFlightTickets.delete(ticket); + pendingManual.delete(ticket); + const stateAfterCleanup = getState(); + return ( + stateBeforeCleanup.pending !== stateAfterCleanup.pending || + stateBeforeCleanup.mode !== stateAfterCleanup.mode || + stateBeforeCleanup.status !== stateAfterCleanup.status + ); + }; + + const request = async ( + text: string, + manual: boolean, + ): Promise<{ + ticket: number; + result?: string; + error?: unknown; + slugAtRequest: string; + }> => { + nextTicket += 1; + const ticket = nextTicket; + const slugAtRequest = slug; + if (manual) { + invalidateTitleRequest(); + invalidateManualRequest(); + latestManualTicket = ticket; + } else { + invalidateTitleRequest(); + latestTitleTicket = ticket; + } + inFlightTickets.add(ticket); + if (manual) { + pendingManual.add(ticket); + } + notify(null); + let result: string | undefined; + let error: unknown; + try { + result = await generateSlug(text); + } catch (requestError) { + error = requestError; + } + return { ticket, result, error, slugAtRequest }; + }; + + const commitTitle = async (rawTitle: string): Promise => { + const nextTitle = rawTitle.trim(); + lastCommittedTitle = nextTitle; + if (mode() === 'custom') { + return unchanged('custom'); + } + if (nextTitle === title && slug) { + invalidateTitleRequest(); + return unchanged('same-title'); + } + if (!shouldGenerateSlug({ mode: 'derived', slug }, nextTitle)) { + invalidateTitleRequest(); + return unchanged('frozen'); + } + + const { ticket, result, error, slugAtRequest } = await request(nextTitle, false); + const cleanupChangedState = cleanupRequest(ticket); + if (ticket !== latestTitleTicket) { + if (cleanupChangedState) { + notify(null); + } + return stale(slugAtRequest); + } + if (error !== undefined) { + return unchanged('error', error); + } + if (!result?.trim()) { + return unchanged('empty-result'); + } + slug = result; + title = nextTitle; + return emit({ slug, source: 'generated' }); + }; + + const editSlug = async (input: string): Promise => { + const candidate = normalizeManualSlug(input, slug); + if (candidate === null) { + invalidateManualRequest(); + return unchanged('reverted'); + } + + const { ticket, result, error, slugAtRequest } = await request(candidate, true); + const cleanupChangedState = cleanupRequest(ticket); + if (ticket !== latestManualTicket) { + if (cleanupChangedState) { + notify(null); + } + return stale(slugAtRequest); + } + if (error !== undefined) { + return unchanged('error', error); + } + if (!result?.trim()) { + return unchanged('empty-result'); + } + const resolved = resolveDedupedSlug(result, candidate, slug); + if (resolved === slug) { + return unchanged('reverted'); + } + slug = resolved; + settledMode = 'custom'; + return emit({ slug, source: 'manual' }); + }; + + const executeSubmission = (submission: Submission): Promise => + submission.kind === 'title' ? commitTitle(submission.value) : editSlug(submission.value); + + const finishSubmission = (token: number): void => { + if (token !== activeSubmissionToken) { + return; + } + const next = deferredSubmission; + deferredSubmission = null; + if (!next) { + activeSubmissionToken = 0; + activeSubmissionKind = null; + return; + } + const nextPromise = startSubmission(next.submission); + void nextPromise.then(next.resolve, next.reject); + }; + + const startSubmission = (submission: Submission): Promise => { + nextSubmissionToken += 1; + const token = nextSubmissionToken; + activeSubmissionToken = token; + activeSubmissionKind = submission.kind; + const submissionPromise = executeSubmission(submission); + void submissionPromise.then( + () => finishSubmission(token), + () => finishSubmission(token), + ); + return submissionPromise; + }; + + const submit = (submission: Submission): Promise => { + if (!activeSubmissionToken) { + return startSubmission(submission); + } + + // A no-op manual blur cancels a deferred manual value. If a manual request itself is active, + // it also withdraws that request without disturbing an active title generation. + if (submission.kind === 'manual' && normalizeManualSlug(submission.value, slug) === null) { + if (deferredSubmission?.submission.kind === 'manual') { + deferredSubmission.resolve(stale(deferredSubmission.slugAtSubmission)); + deferredSubmission = null; + } + if (activeSubmissionKind === 'manual') { + invalidateManualRequest(); + } + return Promise.resolve(unchanged('reverted')); + } + + // A title returning to a settled/frozen value can invalidate active title generation without + // waiting for its physical request. Title intent behind a manual request remains deferred. + if (submission.kind === 'title' && activeSubmissionKind === 'title') { + const nextTitle = submission.value.trim(); + lastCommittedTitle = nextTitle; + if (nextTitle === title && slug) { + if (deferredSubmission) { + deferredSubmission.resolve(stale(deferredSubmission.slugAtSubmission)); + deferredSubmission = null; + } + invalidateTitleRequest(); + return Promise.resolve(unchanged('same-title')); + } + if (!shouldGenerateSlug({ mode: 'derived', slug }, nextTitle)) { + if (deferredSubmission) { + deferredSubmission.resolve(stale(deferredSubmission.slugAtSubmission)); + deferredSubmission = null; + } + invalidateTitleRequest(); + return Promise.resolve(unchanged('frozen')); + } + } + + if (deferredSubmission) { + deferredSubmission.resolve(stale(deferredSubmission.slugAtSubmission)); + } + return new Promise((resolve, reject) => { + deferredSubmission = { submission, slugAtSubmission: slug, resolve, reject }; + }); + }; + + return { + loaded(post) { + activeSubmissionToken = 0; + activeSubmissionKind = null; + if (deferredSubmission) { + deferredSubmission.resolve(stale(deferredSubmission.slugAtSubmission)); + deferredSubmission = null; + } + latestTitleTicket = 0; + latestManualTicket = 0; + inFlightTickets.clear(); + pendingManual.clear(); + slug = post.slug; + title = post.title; + lastCommittedTitle = post.title; + settledMode = isCustomSlug(post.slug, post.title) ? 'custom' : 'derived'; + notify(null); + }, + + titleCommitted(rawTitle) { + return submit({ kind: 'title', value: rawTitle }); + }, + + slugEdited(input) { + return submit({ kind: 'manual', value: input }); + }, + + getState, + + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx index da8c0438d83..e0074784603 100644 --- a/apps/admin/src/editor/koenig-post-editor.tsx +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -25,6 +25,8 @@ export interface KoenigPostEditorProps { cursorDidExitAtTop?: () => void; onChange?: (lexical: unknown) => void; onSecondaryChange?: (lexical: unknown) => void; + /** The hidden instance failed, so its serialization cannot be a change baseline. */ + onSecondaryError?: (error: unknown) => void; registerAPI: (api: KoenigInstance | null) => void; registerSecondaryAPI: (api: KoenigInstance | null) => void; onWordCountChange: (count: number) => void; @@ -87,8 +89,9 @@ function KoenigInstanceMount({ export function KoenigPostEditor(props: KoenigPostEditorProps) { const editor = useMemo(() => loadKoenig(), []); + const { onSecondaryError } = props; - const onError = useCallback((error: unknown) => { + const reportError = useCallback((error: unknown) => { // eslint-disable-next-line no-console console.error(error); @@ -103,6 +106,14 @@ export function KoenigPostEditor(props: KoenigPostEditorProps) { // not rethrown: Lexical attempts to recover without losing user data }, []); + const onSecondaryInstanceError = useCallback( + (error: unknown) => { + reportError(error); + onSecondaryError?.(error); + }, + [reportError, onSecondaryError], + ); + return (

@@ -113,8 +124,18 @@ export function KoenigPostEditor(props: KoenigPostEditorProps) {
} > - - + + diff --git a/apps/admin/src/editor/link-suggestions.test.ts b/apps/admin/src/editor/link-suggestions.test.ts index 6da0512bc5a..4a59b58c603 100644 --- a/apps/admin/src/editor/link-suggestions.test.ts +++ b/apps/admin/src/editor/link-suggestions.test.ts @@ -21,7 +21,7 @@ describe('buildAutocompleteLinks', () => { recommendationsEnabled: true, }; - it('lists every portal link in the Ember order', () => { + it('lists every portal link in display order', () => { const offerLinks = buildOfferLinks( [{ name: 'Spring sale', code: 'spring' }], settings.homepageUrl, diff --git a/apps/admin/src/editor/link-suggestions.ts b/apps/admin/src/editor/link-suggestions.ts index a2ff477597a..d5269c1f721 100644 --- a/apps/admin/src/editor/link-suggestions.ts +++ b/apps/admin/src/editor/link-suggestions.ts @@ -121,7 +121,7 @@ export function buildAutocompleteLinks( ]; } -// Ember renders `D MMM YYYY` in the site timezone +// Published dates use `D MMM YYYY` in the site's timezone. export function formatPublishedDate(publishedAt: string, timezone: string): string { const parts = new Intl.DateTimeFormat('en-US', { day: 'numeric', diff --git a/apps/admin/src/editor/post-editor.acceptance.test.tsx b/apps/admin/src/editor/post-editor.acceptance.test.tsx index 89079e9ccf2..443301d9217 100644 --- a/apps/admin/src/editor/post-editor.acceptance.test.tsx +++ b/apps/admin/src/editor/post-editor.acceptance.test.tsx @@ -59,10 +59,10 @@ function pasteText(content: string) { } /** - * The React post editor behind the `editorReact` flag: loads the post, - * mounts Koenig with its hidden secondary instance and keeps edits in memory. - * Nothing is saved yet — any write request other than the mobiledoc - * conversion would 418 and fail the test. + * Covers post loading, Koenig mounting, and in-memory editing. Write requests + * remain unfaked unless a test expects a mobiledoc conversion, so any + * unexpected write returns 418 and fails the test. Saving is covered in + * editor-save.acceptance.test.tsx. */ describe('Post editor', () => { it('loads the post into the title and body', async () => { diff --git a/apps/admin/src/editor/post-editor.tsx b/apps/admin/src/editor/post-editor.tsx index 5f26b5f286d..3c2d176cdd6 100644 --- a/apps/admin/src/editor/post-editor.tsx +++ b/apps/admin/src/editor/post-editor.tsx @@ -18,9 +18,12 @@ export interface PostEditorProps { showExcerpt: boolean; autofocusTitle?: boolean; onTitleChange: (title: string) => void; + onTitleBlur?: () => void; onExcerptChange: (excerpt: string) => void; + onExcerptBlur?: () => void; onLexicalChange?: (lexical: unknown) => void; onSecondaryChange?: (lexical: unknown) => void; + onSecondaryError?: (error: unknown) => void; registerEditorApi?: (api: KoenigInstance | null) => void; registerSecondaryApi?: (api: KoenigInstance | null) => void; onTkCountChange?: (count: number) => void; @@ -84,9 +87,12 @@ export function PostEditor({ showExcerpt, autofocusTitle = false, onTitleChange, + onTitleBlur, onExcerptChange, + onExcerptBlur, onLexicalChange, onSecondaryChange, + onSecondaryError, registerEditorApi, registerSecondaryApi, onTkCountChange, @@ -260,6 +266,7 @@ export function PostEditor({ placeholder={`${capitalize(postType)} title`} rows={1} value={title} + onBlur={onTitleBlur} onChange={(event) => onTitleChange(event.target.value)} onKeyDown={onTitleKeyDown} onPaste={cleanPastedTitle} @@ -280,6 +287,7 @@ export function PostEditor({ placeholder="Add an excerpt" rows={1} value={excerpt} + onBlur={onExcerptBlur} onChange={(event) => onExcerptChange(event.target.value)} onKeyDown={onExcerptKeyDown} /> @@ -297,6 +305,7 @@ export function PostEditor({ registerSecondaryAPI={registerSecondary} onChange={onLexicalChange} onSecondaryChange={onSecondaryChange} + onSecondaryError={onSecondaryError} onTkCountChange={setBodyTkCount} onWordCountChange={setWordCount} /> diff --git a/apps/admin/src/editor/publish/README.md b/apps/admin/src/editor/publish/README.md new file mode 100644 index 00000000000..9cbee50a121 --- /dev/null +++ b/apps/admin/src/editor/publish/README.md @@ -0,0 +1,142 @@ +# Publish options + +`createPublishOptions()` is the state machine behind the editor's publish flow: it holds the choices a user makes in that flow (publish type, schedule, newsletter, recipients) and turns them into the save command that changes a post's status. + +It is pure TypeScript. It imports no React and performs no network calls; every input is plain data supplied by the caller, and the only asynchronous work goes through injected limit ports. Every transition is synchronous and caller-driven, so there is no subscription: call a transition, then read `getState()`. + +## Inputs + +| Input | Contents | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `post` | `status`, `isPage`, `visibility`, `tiers`, the persisted `newsletter` slug and `emailSegment`, and the post's `email` record when one exists | +| `site` | `membersEnabled`, `mailgunConfigured`, `editorDefaultEmailRecipients` and its filter, `memberCount`, and the full `newsletters` list | +| `user` | `isAdmin` and `isAuthorOrContributor`, which decide whether each limit is evaluated | +| `limits` | Optional ports for the sending and publishing limit checks | +| `now` | Optional clock, injected for tests | + +Inputs are read once, at creation: the machine is built after the data it needs has loaded, and replaced rather than updated when the post changes. + +`memberCount` is read for admins only, because nobody else can browse members. A count of `null` — not read, or not readable — counts as "has members" so email is never disabled for the wrong reason. + +Only newsletters with `status: 'active'` are selectable; they are ordered by `sortOrder`. The post's persisted active newsletter is selected initially when it is present, otherwise the first active newsletter is the default. A failed-email retry also retains its persisted newsletter even when it is no longer selectable. `onlyDefaultNewsletter` reports whether there is exactly one active newsletter, which is what decides whether the flow offers a newsletter picker at all. + +## Publish types + +Three types: + +| Value | Label | Effect | +| -------------- | ----------------- | -------------------------------------------- | +| `publish+send` | Publish and email | Publishes and emails the selected recipients | +| `publish` | Publish only | Publishes without emailing | +| `send` | Email only | Emails without listing the post publicly | + +`willPublish` is every type but `send`; `willOnlyEmail` is only `send`. + +The initial type is `publish+send`, narrowed in order: + +1. `publish` when email is unavailable or disabled. +2. `publish` when the site's default recipients are "usually nobody" (a `filter` default with no filter). Recipients still follow post visibility, so turning email on is a single click. +3. `send` for a post that has already been sent, whatever the two rules above decided. + +`setPublishType()` does not validate, so `availablePublishTypes` is what tells the UI which types to offer: + +| State | `availablePublishTypes` | +| ----------------- | --------------------------------- | +| Email available | `publish+send`, `publish`, `send` | +| Email disabled | `publish` | +| Email unavailable | `publish` | + +Selecting a type that is not on offer never produces an email: the post-emails rules below gate on availability, not on the selection alone. + +## Email availability + +Two separate states, because they have different UI consequences. + +**Unavailable** hides the type picker entirely. `emailUnavailableReason` names the first reason that applies: + +| Reason | Condition | +| ---------------------- | ------------------------------------------------------------ | +| `page` | The post is a page | +| `already-emailed` | The post already has an email record, including a failed one | +| `disabled-in-settings` | Default recipients are `disabled`, or members are turned off | + +**Disabled** shows the picker with the two email types disabled. `emailDisabledReason` names the first reason that applies: + +| Reason | Condition | +| -------------------- | --------------------------------------------------- | +| `no-mailgun` | No bulk email provider is configured | +| `no-members` | The member count is exactly `0` | +| `no-newsletter` | No newsletter is selected, so no email can be built | +| `sending-limit` | The host's email limit would be exceeded | +| `email-verification` | Sending is on hold while the account is in review | + +The last two are set by `checkLimits()`. + +## Recipients + +`recipientFilter` is the member filter the email targets. Until the user picks one it is the post's own `emailSegment` (only when the post also carries a newsletter), otherwise the site default: + +| Default recipients setting | Filter | +| ------------------------------------------ | --------------------------------- | +| `disabled` | none | +| `filter` with a filter | that filter | +| `filter` with no filter ("usually nobody") | follows post visibility, as below | +| `visibility` | follows post visibility | + +Following visibility maps a public or members-only post to everyone (`status:free,status:-free`), a paid post to `status:-free`, and a tiers-restricted post to its tier segments (`tier:` joined by commas, or no filter when it has no tiers). Any other visibility value is used verbatim as the filter. + +`setRecipientFilter(null)` is a real choice — "no recipients" — and is distinct from never having chosen. + +Core represents the special segments as `all` and `none`. Inputs and explicit selections normalize those API sentinels to the editor's expanded everyone filter and `null`, matching the legacy Admin transform. + +`fullRecipientFilter` is what the email service receives: the newsletter's own audience filter (subscribed to that newsletter, email not disabled, plus paid-only for a paid newsletter), AND-ed with the recipient filter when there is one. It is `null` while no newsletter is selected. + +## Whether the post emails + +`willEmail` requires a selected newsletter and email that is not disabled. Fresh emails must also be available; a failed-email draft is the only unavailable state allowed through because it retries an existing email. Given those prerequisites, it is true when either holds: + +- the type is not `publish`, a recipient filter is set, the post is still a draft, and it has no email record; or +- the post is a draft whose email record failed. A failed send is retried regardless of the selected type or filter. + +`willEmailImmediately` is `willEmail` on an unscheduled post — the flow's confirmation copy and any post-save email polling hang off it. + +## Scheduling + +Times are ISO 8601 strings with milliseconds zeroed, because the API stores seconds and a non-zero millisecond value can fail validation when a scheduled post is updated. + +- `minScheduledAt` is five seconds ahead of now and is recomputed on every read; it is the floor the picker enforces. +- `scheduledAt` starts at that floor. +- `setIsScheduled(true)` snaps a time that is earlier than ten minutes ahead of now forward to exactly that default; calling it with no argument toggles. +- `setScheduledAt()` zeroes milliseconds and clamps anything before the floor up to it. An unparseable date is ignored. +- `resetPastScheduledAt()` turns scheduling off when the chosen time has fallen into the past. It leaves the stale time in place: re-enabling scheduling snaps it forward to the default, so the stale value is never offered. + +## Producing a save command + +`toDispatch()` returns the status command for the current options, shaped exactly as the save engine accepts it: + +| Options | Command | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Unscheduled | `{kind: 'publish', options}` — no publish time, so the post keeps the one it has | +| Scheduled | `{kind: 'schedule', options}` with `publishedAt` set to the scheduled time | +| Not a draft | `null` — publishing and scheduling are draft-only transitions, and `canPublish` reports the same thing | +| Email-only without an executable email | `null` — an invalid email choice must never fall through to a public publish | + +Email extras ride on the command only when `willEmail` is true: `emailOnly` (true only for `send`), the selected newsletter's slug, and the recipient filter as `emailSegment`. Failed-email retries omit the newsletter and segment overrides so Core validates and retries the durable email against the values it was created with. `toRevertDispatch()` returns `{kind: 'revert'}` for any status; the engine derives the rest of that transition (clearing the publish time only when unscheduling, and always clearing `emailOnly`). + +The machine never mutates a post, so it needs no snapshot-and-rollback around a failed save: the save engine builds each request from its own snapshot and adopts the acknowledged status only once the server confirms it. A failed publish leaves both the post and these options exactly as they were, ready to dispatch again. + +## Limits + +`checkLimits()` runs the two host checks concurrently and returns — and stores — a typed result. It clears any result from a previous run first. + +The sending check awaits `refreshSettings()` before anything else, so a hold applied since the editor opened is seen; a failed refresh rejects `checkLimits()`. It then evaluates the email limit, skipping it for authors and contributors, who cannot read email counts. A rejection becomes a `sending-limit` block carrying the host's message. Only if the limit passes is the verification hold read, so a site under its email limit still surfaces a hold; a hold without host-specific copy uses the default message. + +The publishing check runs for admins only, since nobody else can read the member count. A rejection becomes a `host-limit` block with the host's message split into `parts`, where the segment marked `upgrade` is the phrase to render as an upgrade link. The message is returned as data, never as markup. + +Both blocks feed the state directly. An email block disables the email publish types and re-applies the initial-type rules, so the selection falls back to `publish`; that demotion also applies to a type the user picked before the block landed, since a block that arrives late must not leave an unsendable type selected. + +## Dirty state and reset + +`isDirty` compares the publish type, scheduling, newsletter and recipient filter against the values the machine started with; selecting the value that was already there is not a change. The scheduled time counts only while scheduling is on or the user has actually chosen a time, so turning scheduling on and back off leaves the state clean. + +`reset()` restores every option and re-arms the automatic type fallback that `setPublishType()` disables. It takes a fresh scheduled time from the current clock, since the floor the machine was created with may itself have passed. diff --git a/apps/admin/src/editor/publish/email-confirmation.test.ts b/apps/admin/src/editor/publish/email-confirmation.test.ts new file mode 100644 index 00000000000..f6fce680f90 --- /dev/null +++ b/apps/admin/src/editor/publish/email-confirmation.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CONFIRM_EMAIL_MAX_POLL_LENGTH, + CONFIRM_EMAIL_POLL_LENGTH, + type EmailConfirmationPost, + createEmailConfirmation, + isPartialEmailFailure, +} from './email-confirmation'; + +const MAX_ATTEMPTS = CONFIRM_EMAIL_MAX_POLL_LENGTH / CONFIRM_EMAIL_POLL_LENGTH; + +function published(email: EmailConfirmationPost['email']): EmailConfirmationPost { + return { status: 'published', email }; +} + +const pending = published({ status: 'pending', opened_count: 0, email_count: 1 }); +const submitted = published({ status: 'submitted', opened_count: 0, email_count: 1 }); + +function failed(error: string | null) { + return published({ status: 'failed', error, opened_count: 0, email_count: 1 }); +} + +function setup(reloads: EmailConfirmationPost[]) { + const reload = vi.fn(() => + Promise.resolve(reloads[Math.min(reload.mock.calls.length, reloads.length) - 1]), + ); + const retry = vi.fn(() => Promise.resolve()); + + return { reload, retry, confirmation: createEmailConfirmation({ reload, retry }) }; +} + +async function advance(times: number) { + for (let index = 0; index < times; index += 1) { + await vi.advanceTimersByTimeAsync(CONFIRM_EMAIL_POLL_LENGTH); + } +} + +describe('createEmailConfirmation', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('pins the poll interval to a second and the poll window to fifteen seconds', () => { + expect(CONFIRM_EMAIL_POLL_LENGTH).toBe(1000); + expect(CONFIRM_EMAIL_MAX_POLL_LENGTH).toBe(15000); + }); + + it('polls until the email is submitted', async () => { + const { reload, confirmation } = setup([pending, pending, submitted]); + + const result = confirmation.confirm('post-1'); + await advance(3); + + await expect(result).resolves.toEqual({ kind: 'submitted' }); + expect(reload).toHaveBeenCalledTimes(3); + expect(reload).toHaveBeenCalledWith('post-1'); + }); + + it('does not poll when the supplied post already has a submitted email', async () => { + const { reload, confirmation } = setup([submitted]); + + await expect(confirmation.confirm('post-1', submitted)).resolves.toEqual({ kind: 'submitted' }); + expect(reload).not.toHaveBeenCalled(); + }); + + it('reports the supplied post having no email as nothing to confirm', async () => { + const { reload, confirmation } = setup([submitted]); + + await expect(confirmation.confirm('post-1', { status: 'published' })).resolves.toEqual({ + kind: 'not-needed', + }); + expect(reload).not.toHaveBeenCalled(); + }); + + it('reports a reloaded post having no email as nothing to confirm', async () => { + const { reload, confirmation } = setup([{ status: 'published' }]); + + const result = confirmation.confirm('post-1'); + await advance(1); + + await expect(result).resolves.toEqual({ kind: 'not-needed' }); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('keeps polling while the email is still submitting', async () => { + const { reload, confirmation } = setup([ + published({ status: 'submitting', opened_count: 0, email_count: 1 }), + submitted, + ]); + + const result = confirmation.confirm('post-1'); + await advance(2); + + await expect(result).resolves.toEqual({ kind: 'submitted' }); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('reports a full failure with the email error', async () => { + const { confirmation } = setup([failed('The email service returned an error.')]); + + const result = confirmation.confirm('post-1'); + await advance(1); + + await expect(result).resolves.toEqual({ + kind: 'failed', + error: 'The email service returned an error.', + partial: false, + }); + }); + + it('reports a partial failure when the error message says partially', async () => { + const { confirmation } = setup([failed('Email was partially sent to 3 of 10 members.')]); + + const result = confirmation.confirm('post-1'); + await advance(1); + + await expect(result).resolves.toEqual({ + kind: 'failed', + error: 'Email was partially sent to 3 of 10 members.', + partial: true, + }); + }); + + it('reports a failure with a null error when the email has none', async () => { + const { confirmation } = setup([failed(null)]); + + const result = confirmation.confirm('post-1'); + await advance(1); + + await expect(result).resolves.toEqual({ kind: 'failed', error: null, partial: false }); + }); + + it('times out after the maximum number of attempts', async () => { + const { reload, confirmation } = setup([pending]); + + const result = confirmation.confirm('post-1'); + await advance(MAX_ATTEMPTS); + + await expect(result).resolves.toEqual({ kind: 'timeout' }); + expect(reload).toHaveBeenCalledTimes(15); + }); + + it('is still polling one attempt before the timeout', async () => { + const { confirmation } = setup([pending]); + const settled = vi.fn(); + + void confirmation.confirm('post-1').then(settled); + await advance(MAX_ATTEMPTS - 1); + + expect(settled).not.toHaveBeenCalled(); + }); + + it('stops polling once the post is no longer published or sent', async () => { + const { reload, confirmation } = setup([{ status: 'draft', email: pending.email }, pending]); + + const result = confirmation.confirm('post-1'); + await advance(2); + + await expect(result).resolves.toEqual({ kind: 'unpublished' }); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('treats a sent post as still emailing', async () => { + const { confirmation } = setup([{ status: 'sent', email: submitted.email }]); + + const result = confirmation.confirm('post-1'); + await advance(1); + + await expect(result).resolves.toEqual({ kind: 'submitted' }); + }); + + it('rejects when a reload fails instead of continuing to poll', async () => { + const transportError = new Error('Network request failed'); + const reload = vi.fn().mockRejectedValue(transportError); + const retry = vi.fn(() => Promise.resolve()); + const confirmation = createEmailConfirmation({ reload, retry }); + + const result = confirmation.confirm('post-1'); + const assertion = expect(result).rejects.toBe(transportError); + await advance(MAX_ATTEMPTS); + + await assertion; + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('retries the email and then polls for submission', async () => { + const { reload, retry, confirmation } = setup([pending, submitted]); + + const result = confirmation.retryAndConfirm('post-1', 'email-1'); + await advance(2); + + await expect(result).resolves.toEqual({ kind: 'submitted' }); + expect(retry).toHaveBeenCalledWith('email-1'); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('reports a failed retry with partial detection', async () => { + const { confirmation } = setup([failed('Email was partially sent.')]); + + const result = confirmation.retryAndConfirm('post-1', 'email-1'); + await advance(1); + + await expect(result).resolves.toEqual({ + kind: 'failed', + error: 'Email was partially sent.', + partial: true, + }); + }); + + it('keeps polling a retry even when the post is no longer published', async () => { + const { reload, confirmation } = setup([ + { status: 'draft', email: pending.email }, + { status: 'draft', email: submitted.email }, + ]); + + const result = confirmation.retryAndConfirm('post-1', 'email-1'); + await advance(2); + + await expect(result).resolves.toEqual({ kind: 'submitted' }); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('rejects when the retry request fails', async () => { + const transportError = new Error('Unable to connect'); + const reload = vi.fn(() => Promise.resolve(pending)); + const retry = vi.fn().mockRejectedValue(transportError); + const confirmation = createEmailConfirmation({ reload, retry }); + + await expect(confirmation.retryAndConfirm('post-1', 'email-1')).rejects.toBe(transportError); + expect(reload).not.toHaveBeenCalled(); + }); + + it('settles as cancelled and clears the pending timer when cancelled mid-poll', async () => { + const { reload, retry } = setup([pending]); + const clearedHandles: unknown[] = []; + const confirmation = createEmailConfirmation({ + reload, + retry, + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (handle) => { + clearedHandles.push(handle); + clearTimeout(handle as Parameters[0]); + }, + }); + + const result = confirmation.confirm('post-1'); + await advance(2); + confirmation.cancel(); + + await expect(result).resolves.toEqual({ kind: 'cancelled' }); + expect(clearedHandles).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + + await advance(MAX_ATTEMPTS); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('settles as cancelled when cancelled while a reload is in flight', async () => { + let releaseReload: (post: EmailConfirmationPost) => void = () => {}; + const reload = vi.fn( + () => + new Promise((resolve) => { + releaseReload = resolve; + }), + ); + const confirmation = createEmailConfirmation({ reload, retry: () => Promise.resolve() }); + + const result = confirmation.confirm('post-1'); + await advance(1); + confirmation.cancel(); + + await expect(result).resolves.toEqual({ kind: 'cancelled' }); + releaseReload(submitted); + }); + + it('settles as cancelled when cancelled during the retry request', async () => { + let releaseRetry: () => void = () => {}; + const { reload } = setup([submitted]); + const retry = vi.fn( + () => + new Promise((resolve) => { + releaseRetry = resolve; + }), + ); + const confirmation = createEmailConfirmation({ reload, retry }); + + const result = confirmation.retryAndConfirm('post-1', 'email-1'); + confirmation.cancel(); + + await expect(result).resolves.toEqual({ kind: 'cancelled' }); + releaseRetry(); + expect(reload).not.toHaveBeenCalled(); + }); + + it('does not reject when an abandoned reload fails after cancellation', async () => { + let rejectReload: (error: Error) => void = () => {}; + const transportError = new Error('Late network failure'); + const reload = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectReload = reject; + }), + ); + const confirmation = createEmailConfirmation({ reload, retry: () => Promise.resolve() }); + + const result = confirmation.confirm('post-1'); + await advance(1); + confirmation.cancel(); + rejectReload(transportError); + + await expect(result).resolves.toEqual({ kind: 'cancelled' }); + }); + + it('accepts a new confirmation immediately after cancelling a stuck one', async () => { + let releaseStuckReload: (post: EmailConfirmationPost) => void = () => {}; + const reload = vi.fn(() => { + if (reload.mock.calls.length > 1) { + return Promise.resolve(submitted); + } + + return new Promise((resolve) => { + releaseStuckReload = resolve; + }); + }); + const confirmation = createEmailConfirmation({ reload, retry: () => Promise.resolve() }); + + const abandoned = confirmation.confirm('post-1'); + await advance(1); + confirmation.cancel(); + + const restarted = confirmation.confirm('post-1'); + expect(restarted).not.toBe(abandoned); + + await advance(1); + + await expect(restarted).resolves.toEqual({ kind: 'submitted' }); + await expect(abandoned).resolves.toEqual({ kind: 'cancelled' }); + expect(reload).toHaveBeenCalledTimes(2); + releaseStuckReload(submitted); + }); + + it('coalesces a repeat confirmation of the same post', async () => { + const { reload, confirmation } = setup([pending, submitted]); + + const first = confirmation.confirm('post-1'); + const second = confirmation.confirm('post-1'); + expect(second).toBe(first); + + await advance(2); + + await expect(first).resolves.toEqual({ kind: 'submitted' }); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('abandons the run in progress when a different post is confirmed', async () => { + const posts: Record = { + 'post-1': pending, + 'post-2': submitted, + }; + const reload = vi.fn((postId: string) => Promise.resolve(posts[postId])); + const confirmation = createEmailConfirmation({ reload, retry: () => Promise.resolve() }); + + const abandoned = confirmation.confirm('post-1'); + await advance(1); + + const started = confirmation.confirm('post-2'); + expect(started).not.toBe(abandoned); + await advance(1); + + await expect(abandoned).resolves.toEqual({ kind: 'cancelled' }); + await expect(started).resolves.toEqual({ kind: 'submitted' }); + expect(reload).toHaveBeenCalledWith('post-2'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('abandons a confirmation in progress when the same post is retried', async () => { + const { retry, confirmation } = setup([pending, submitted]); + + const abandoned = confirmation.confirm('post-1'); + await advance(1); + + const started = confirmation.retryAndConfirm('post-1', 'email-1'); + expect(started).not.toBe(abandoned); + await advance(1); + + await expect(abandoned).resolves.toEqual({ kind: 'cancelled' }); + await expect(started).resolves.toEqual({ kind: 'submitted' }); + expect(retry).toHaveBeenCalledWith('email-1'); + }); + + it('coalesces a repeat retry of the same post', async () => { + const { retry, confirmation } = setup([submitted]); + + const first = confirmation.retryAndConfirm('post-1', 'email-1'); + const second = confirmation.retryAndConfirm('post-1', 'email-1'); + expect(second).toBe(first); + + await advance(1); + + await expect(first).resolves.toEqual({ kind: 'submitted' }); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('allows a new run once the previous one has settled', async () => { + const { reload, confirmation } = setup([submitted]); + + const first = confirmation.confirm('post-1'); + await advance(1); + await first; + + const second = confirmation.confirm('post-1'); + expect(second).not.toBe(first); + await advance(1); + + await expect(second).resolves.toEqual({ kind: 'submitted' }); + expect(reload).toHaveBeenCalledTimes(2); + }); +}); + +describe('isPartialEmailFailure', () => { + it.each([ + ['Email was partially sent.', true], + ['partially', true], + ['The email failed to send.', false], + ['', false], + [null, false], + [undefined, false], + ])('reads %j as %s', (error, expected) => { + expect(isPartialEmailFailure(error)).toBe(expected); + }); +}); diff --git a/apps/admin/src/editor/publish/email-confirmation.ts b/apps/admin/src/editor/publish/email-confirmation.ts new file mode 100644 index 00000000000..e2bfc9a09df --- /dev/null +++ b/apps/admin/src/editor/publish/email-confirmation.ts @@ -0,0 +1,227 @@ +import type { Email, PostStatus } from '@tryghost/admin-x-framework/api/posts'; + +export const CONFIRM_EMAIL_POLL_LENGTH = 1000; +export const CONFIRM_EMAIL_MAX_POLL_LENGTH = 15 * 1000; + +export interface EmailConfirmationPost { + status?: PostStatus; + email?: Email | null; +} + +export type EmailConfirmationOutcome = + | { kind: 'submitted' } + | { kind: 'failed'; error: string | null; partial: boolean } + | { kind: 'unpublished' } + | { kind: 'timeout' } + | { kind: 'not-needed' } + | { kind: 'cancelled' }; + +export type TimerHandle = unknown; + +export interface EmailConfirmationOptions { + reload: (postId: string) => Promise; + retry: (emailId: string) => Promise; + setTimeout?: (callback: () => void, delay: number) => TimerHandle; + clearTimeout?: (handle: TimerHandle) => void; +} + +export interface EmailConfirmation { + confirm(postId: string, currentPost?: EmailConfirmationPost): Promise; + retryAndConfirm(postId: string, emailId: string): Promise; + cancel(): void; +} + +type RunOperation = 'confirm' | 'retry'; + +interface RunState { + operation: RunOperation; + postId: string; + cancelled: boolean; + timer: TimerHandle; + release: (() => void) | null; + settleCancelled: () => void; +} + +// A partially delivered send is only distinguishable by the word "partially" +// appearing in the error message the API stores on the email. +export function isPartialEmailFailure(error?: string | null): boolean { + return !!error && error.includes('partially'); +} + +function failureOutcome(email?: Email | null): EmailConfirmationOutcome { + const error = email?.error ?? null; + return { kind: 'failed', error, partial: isPartialEmailFailure(error) }; +} + +export function createEmailConfirmation(options: EmailConfirmationOptions): EmailConfirmation { + const { reload, retry } = options; + const schedule = + options.setTimeout ?? ((callback: () => void, delay: number) => setTimeout(callback, delay)); + const unschedule = + options.clearTimeout ?? + ((handle: TimerHandle) => clearTimeout(handle as Parameters[0])); + + let current: { state: RunState; promise: Promise } | null = null; + + function wait(state: RunState, delay: number): Promise { + return new Promise((resolve) => { + let fired = false; + const release = () => { + if (fired) { + return; + } + + fired = true; + state.timer = null; + state.release = null; + resolve(); + }; + + state.release = release; + + const handle = schedule(release, delay); + + // Only keep a handle that is still pending: a scheduler that runs its + // callback synchronously has already finished with this one. + if (!fired) { + state.timer = handle; + } + }); + } + + function stop(state: RunState): void { + state.cancelled = true; + + if (state.timer !== null) { + unschedule(state.timer); + state.timer = null; + } + + state.release?.(); + state.release = null; + state.settleCancelled(); + } + + function cancel(): void { + if (!current) { + return; + } + + stop(current.state); + current = null; + } + + async function poll( + state: RunState, + { stopWhenUnpublished }: { stopWhenUnpublished: boolean }, + ): Promise { + let pollTimeout = 0; + + while (pollTimeout < CONFIRM_EMAIL_MAX_POLL_LENGTH) { + await wait(state, CONFIRM_EMAIL_POLL_LENGTH); + + if (state.cancelled) { + return { kind: 'cancelled' }; + } + + pollTimeout += CONFIRM_EMAIL_POLL_LENGTH; + + const post = await reload(state.postId); + + if (state.cancelled) { + return { kind: 'cancelled' }; + } + + // A post that is no longer published or sent never sends or retries an + // email, so there is nothing left to wait for. + if (stopWhenUnpublished && post.status !== 'sent' && post.status !== 'published') { + return { kind: 'unpublished' }; + } + + if (!post.email) { + return { kind: 'not-needed' }; + } + + if (post.email.status === 'submitted') { + return { kind: 'submitted' }; + } + + if (post.email.status === 'failed') { + return failureOutcome(post.email); + } + } + + return { kind: 'timeout' }; + } + + function run( + operation: RunOperation, + postId: string, + work: (state: RunState) => Promise, + ): Promise { + if (current) { + if (current.state.operation === operation && current.state.postId === postId) { + return current.promise; + } + + // A run belongs to one operation on one post, so only an identical + // repeat coalesces; anything else abandons the run in progress. + stop(current.state); + current = null; + } + + let settleCancelled: () => void = () => {}; + const cancellation = new Promise((resolve) => { + settleCancelled = () => resolve({ kind: 'cancelled' }); + }); + const state: RunState = { + operation, + postId, + cancelled: false, + timer: null, + release: null, + settleCancelled, + }; + const promise = Promise.race([work(state), cancellation]); + const settle = () => { + if (current?.state === state) { + current = null; + } + }; + + current = { state, promise }; + promise.then(settle, settle); + + return promise; + } + + return { + confirm(postId, currentPost) { + return run('confirm', postId, (state) => { + if (currentPost && !currentPost.email) { + return Promise.resolve({ kind: 'not-needed' }); + } + + if (currentPost?.email?.status === 'submitted') { + return Promise.resolve({ kind: 'submitted' }); + } + + return poll(state, { stopWhenUnpublished: true }); + }); + }, + + retryAndConfirm(postId, emailId) { + return run('retry', postId, async (state) => { + await retry(emailId); + + if (state.cancelled) { + return { kind: 'cancelled' }; + } + + return poll(state, { stopWhenUnpublished: false }); + }); + }, + + cancel, + }; +} diff --git a/apps/admin/src/editor/publish/publish-options.test.ts b/apps/admin/src/editor/publish/publish-options.test.ts new file mode 100644 index 00000000000..8199a0c92c9 --- /dev/null +++ b/apps/admin/src/editor/publish/publish-options.test.ts @@ -0,0 +1,1200 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_SCHEDULE_LEAD_MS, + EMAIL_VERIFICATION_HOLD_MESSAGE, + MIN_SCHEDULE_LEAD_MS, + createPublishOptions, + getDefaultRecipientFilter, + getEmailDisabledReason, + getEmailUnavailableReason, + getInitialPublishType, + normalizeRecipientFilter, + selectableNewsletters, + splitUpgradeMessage, + tiersSegment, + type NewsletterInput, + type PublishOptionsInputs, + type PublishPostInput, + type PublishSiteInput, + type PublishUserInput, +} from './publish-options'; + +const NOW = new Date('2026-09-02T10:00:00.000Z'); +const MIN_SCHEDULED_AT = '2026-09-02T10:00:05.000Z'; +const DEFAULT_SCHEDULED_AT = '2026-09-02T10:10:00.000Z'; +const EVERYONE = 'status:free,status:-free'; + +const WEEKLY: NewsletterInput = { + slug: 'weekly', + name: 'Weekly', + status: 'active', + visibility: 'members', + sortOrder: 0, +}; + +const PAID_NEWSLETTER: NewsletterInput = { + slug: 'paid-only', + name: 'Paid only', + status: 'active', + visibility: 'paid', + sortOrder: 1, +}; + +function createPost(overrides: Partial = {}): PublishPostInput { + return { + status: 'draft', + isPage: false, + visibility: 'public', + tiers: [], + newsletter: null, + emailSegment: null, + email: null, + ...overrides, + }; +} + +function createSite(overrides: Partial = {}): PublishSiteInput { + return { + membersEnabled: true, + mailgunConfigured: true, + editorDefaultEmailRecipients: 'visibility', + editorDefaultEmailRecipientsFilter: null, + memberCount: 100, + newsletters: [WEEKLY], + ...overrides, + }; +} + +function createUser(overrides: Partial = {}): PublishUserInput { + return { isAdmin: true, isAuthorOrContributor: false, ...overrides }; +} + +function create(overrides: Partial = {}) { + return createPublishOptions({ + post: createPost(), + site: createSite(), + user: createUser(), + now: () => NOW, + ...overrides, + }); +} + +describe('selectableNewsletters', () => { + it('keeps active newsletters in sort order', () => { + const archived = { slug: 'old', status: 'archived', sortOrder: 0 }; + const second = { slug: 'second', status: 'active', sortOrder: 2 }; + const first = { slug: 'first', status: 'active', sortOrder: 1 }; + + expect(selectableNewsletters([archived, second, first]).map((n) => n.slug)).toEqual([ + 'first', + 'second', + ]); + }); +}); + +describe('tiersSegment', () => { + it.each([ + [[], null], + [[{ slug: 'gold' }], 'tier:gold'], + [[{ slug: 'gold' }, { slug: 'silver' }], 'tier:gold,tier:silver'], + ])('%j → %j', (tiers, expected) => { + expect(tiersSegment(tiers)).toBe(expected); + }); +}); + +describe('normalizeRecipientFilter', () => { + it.each([ + ['all', EVERYONE], + ['none', null], + [null, null], + ['label:vip', 'label:vip'], + ])('normalizes %j to %j', (filter, expected) => { + expect(normalizeRecipientFilter(filter)).toBe(expected); + }); +}); + +describe('getEmailUnavailableReason', () => { + it.each([ + ['post with members and email settings on', {}, {}, null], + ['page', { isPage: true }, {}, 'page'], + ['already emailed', { email: { status: 'submitted' } }, {}, 'already-emailed'], + [ + 'failed email still counts as emailed', + { email: { status: 'failed' } }, + {}, + 'already-emailed', + ], + [ + 'recipients disabled', + {}, + { editorDefaultEmailRecipients: 'disabled' as const }, + 'disabled-in-settings', + ], + ['members off', {}, { membersEnabled: false }, 'disabled-in-settings'], + ['page wins over settings', { isPage: true }, { membersEnabled: false }, 'page'], + ])('%s', (_name, post, site, expected) => { + expect(getEmailUnavailableReason(createPost(post), createSite(site))).toBe(expected); + }); +}); + +describe('getEmailDisabledReason', () => { + it.each([ + ['configured with members', {}, null, true, null], + ['no mailgun', { mailgunConfigured: false }, null, true, 'no-mailgun'], + ['no members', { memberCount: 0 }, null, true, 'no-members'], + ['unknown member count', { memberCount: null }, null, true, null], + ['no newsletter to send', {}, null, false, 'no-newsletter'], + [ + 'sending limit', + {}, + { kind: 'sending-limit' as const, message: 'over' }, + true, + 'sending-limit', + ], + [ + 'verification hold', + {}, + { kind: 'email-verification' as const, message: 'in review' }, + true, + 'email-verification', + ], + [ + 'mailgun wins over a block', + { mailgunConfigured: false }, + { kind: 'sending-limit' as const, message: 'over' }, + true, + 'no-mailgun', + ], + [ + 'a missing newsletter wins over a block', + {}, + { kind: 'sending-limit' as const, message: 'over' }, + false, + 'no-newsletter', + ], + ])('%s', (_name, site, block, hasNewsletter, expected) => { + expect(getEmailDisabledReason(createSite(site), block, hasNewsletter)).toBe(expected); + }); +}); + +describe('getInitialPublishType', () => { + it.each([ + ['email available', {}, {}, { emailUnavailable: false, emailDisabled: false }, 'publish+send'], + ['email unavailable', {}, {}, { emailUnavailable: true, emailDisabled: false }, 'publish'], + ['email disabled', {}, {}, { emailUnavailable: false, emailDisabled: true }, 'publish'], + [ + 'usually nobody', + {}, + { editorDefaultEmailRecipients: 'filter' as const, editorDefaultEmailRecipientsFilter: null }, + { emailUnavailable: false, emailDisabled: false }, + 'publish', + ], + [ + 'usually nobody from the API sentinel', + {}, + { + editorDefaultEmailRecipients: 'filter' as const, + editorDefaultEmailRecipientsFilter: 'none', + }, + { emailUnavailable: false, emailDisabled: false }, + 'publish', + ], + [ + 'explicit default filter', + {}, + { + editorDefaultEmailRecipients: 'filter' as const, + editorDefaultEmailRecipientsFilter: 'label:vip', + }, + { emailUnavailable: false, emailDisabled: false }, + 'publish+send', + ], + [ + 'sent post overrides everything', + { status: 'sent' as const }, + {}, + { emailUnavailable: true, emailDisabled: true }, + 'send', + ], + ])('%s', (_name, post, site, availability, expected) => { + expect(getInitialPublishType(createPost(post), createSite(site), availability)).toBe(expected); + }); +}); + +describe('publish type availability', () => { + it.each([ + ['default site', {}, {}, 'publish+send', ['publish+send', 'publish', 'send']], + ['mailgun missing', {}, { mailgunConfigured: false }, 'publish', ['publish']], + ['no members', {}, { memberCount: 0 }, 'publish', ['publish']], + [ + 'member count unknown', + {}, + { memberCount: null }, + 'publish+send', + ['publish+send', 'publish', 'send'], + ], + ['members disabled', {}, { membersEnabled: false }, 'publish', ['publish']], + [ + 'recipients disabled', + {}, + { editorDefaultEmailRecipients: 'disabled' as const }, + 'publish', + ['publish'], + ], + ['page', { isPage: true }, {}, 'publish', ['publish']], + ['already emailed post', { email: { status: 'submitted' } }, {}, 'publish', ['publish']], + ['sent post', { status: 'sent' as const }, {}, 'send', ['publish+send', 'publish', 'send']], + ['no active newsletters', {}, { newsletters: [] }, 'publish', ['publish']], + [ + 'only archived newsletters', + {}, + { newsletters: [{ slug: 'old', status: 'archived' }] }, + 'publish', + ['publish'], + ], + ])('%s', (_name, post, site, publishType, available) => { + const state = create({ post: createPost(post), site: createSite(site) }).getState(); + + expect(state.publishType).toBe(publishType); + expect(state.availablePublishTypes).toEqual(available); + }); + + it('exposes the option labels the flow renders', () => { + expect( + create({ site: createSite({ mailgunConfigured: false }) }).getState().publishTypeOptions, + ).toEqual([ + { + value: 'publish+send', + label: 'Publish and email', + display: 'Publish and email', + disabled: true, + }, + { value: 'publish', label: 'Publish only', display: 'Publish', disabled: false }, + { value: 'send', label: 'Email only', display: 'Email', disabled: true }, + ]); + }); + + it('does not validate the type being set', () => { + const machine = create({ site: createSite({ memberCount: 0 }) }); + + machine.setPublishType('send'); + + expect(machine.getState().publishType).toBe('send'); + expect(machine.getState().emailDisabled).toBe(true); + }); + + it.each([ + ['page draft', { isPage: true }, {}], + ['members disabled', {}, { membersEnabled: false }], + ['recipients disabled', {}, { editorDefaultEmailRecipients: 'disabled' as const }], + ])('rejects a forced send for a %s', (_name, post, site) => { + const machine = create({ post: createPost(post), site: createSite(site) }); + + machine.setRecipientFilter('label:vip'); + machine.setPublishType('send'); + + const state = machine.getState(); + + expect(state.emailUnavailable).toBe(true); + expect(state.willEmail).toBe(false); + expect(state.canPublish).toBe(false); + expect(machine.toDispatch()).toBeNull(); + }); + + it('reports the newsletter selection', () => { + const single = create().getState(); + const many = create({ + site: createSite({ newsletters: [WEEKLY, PAID_NEWSLETTER] }), + }).getState(); + + expect(single.newsletter?.slug).toBe('weekly'); + expect(single.onlyDefaultNewsletter).toBe(true); + expect(many.newsletter?.slug).toBe('weekly'); + expect(many.onlyDefaultNewsletter).toBe(false); + }); + + it('starts with the active newsletter persisted on the post', () => { + const state = create({ + post: createPost({ newsletter: 'paid-only' }), + site: createSite({ newsletters: [WEEKLY, PAID_NEWSLETTER] }), + }).getState(); + + expect(state.newsletter?.slug).toBe('paid-only'); + }); + + it('trusts a member count of zero only from an admin', () => { + const site = createSite({ memberCount: 0 }); + + expect(create({ site }).getState().emailDisabledReason).toBe('no-members'); + expect( + create({ site, user: createUser({ isAdmin: false }) }).getState().emailDisabledReason, + ).toBeNull(); + }); +}); + +describe('without a newsletter to send', () => { + const site = createSite({ newsletters: [] }); + + it('disables email rather than offering a send with no newsletter', () => { + const state = create({ site }).getState(); + + expect(state.newsletter).toBeNull(); + expect(state.emailDisabled).toBe(true); + expect(state.emailDisabledReason).toBe('no-newsletter'); + expect(state.willEmail).toBe(false); + expect(state.willEmailImmediately).toBe(false); + expect(state.fullRecipientFilter).toBeNull(); + }); + + it('does not dispatch a chosen send type', () => { + const machine = create({ site }); + + machine.setPublishType('send'); + + expect(machine.getState().willEmail).toBe(false); + expect(machine.getState().canPublish).toBe(false); + expect(machine.toDispatch()).toBeNull(); + }); + + it('emits no email options for a failed email retry', () => { + const machine = create({ post: createPost({ email: { status: 'failed' } }), site }); + + machine.setPublishType('publish'); + + expect(machine.getState().willEmail).toBe(false); + expect(machine.getState().canPublish).toBe(true); + expect(machine.toDispatch()).toEqual({ kind: 'publish', options: {} }); + }); + + it('disables email when the selected newsletter is cleared', () => { + const machine = create(); + + machine.setNewsletter(null); + + expect(machine.getState().emailDisabledReason).toBe('no-newsletter'); + expect(machine.getState().willEmail).toBe(false); + expect(machine.toDispatch()).toEqual({ kind: 'publish', options: {} }); + }); +}); + +describe('will* matrix', () => { + it.each([ + ['publish+send draft', {}, 'publish+send' as const, false, true, true, false, true], + ['publish draft', {}, 'publish' as const, false, false, true, false, false], + ['send draft', {}, 'send' as const, false, true, false, true, true], + ['publish+send scheduled', {}, 'publish+send' as const, true, true, true, false, false], + ['send scheduled', {}, 'send' as const, true, true, false, true, false], + [ + 'published post', + { status: 'published' as const }, + 'publish+send' as const, + false, + false, + true, + false, + false, + ], + ['sent post', { status: 'sent' as const }, 'send' as const, false, false, false, true, false], + [ + 'draft that already emailed', + { email: { status: 'submitted' } }, + 'publish+send' as const, + false, + false, + true, + false, + false, + ], + [ + 'draft whose email failed', + { email: { status: 'failed' } }, + 'publish' as const, + false, + true, + true, + false, + true, + ], + [ + 'draft whose email failed, scheduled', + { email: { status: 'failed' } }, + 'publish' as const, + true, + true, + true, + false, + false, + ], + ])( + '%s', + ( + _name, + post, + publishType, + scheduled, + willEmail, + willPublish, + willOnlyEmail, + willEmailImmediately, + ) => { + const machine = create({ post: createPost(post) }); + + machine.setPublishType(publishType); + machine.setIsScheduled(scheduled); + + const state = machine.getState(); + + expect(state.willEmail).toBe(willEmail); + expect(state.willPublish).toBe(willPublish); + expect(state.willOnlyEmail).toBe(willOnlyEmail); + expect(state.willEmailImmediately).toBe(willEmailImmediately); + }, + ); + + it('does not email without a recipient filter', () => { + const machine = create(); + + machine.setRecipientFilter(null); + + expect(machine.getState().willEmail).toBe(false); + }); + + it('emails a failed-email draft even without a recipient filter', () => { + const machine = create({ post: createPost({ email: { status: 'failed' } }) }); + + machine.setRecipientFilter(null); + + expect(machine.getState().emailUnavailable).toBe(true); + expect(machine.getState().willEmail).toBe(true); + }); +}); + +describe('scheduling', () => { + it('starts unscheduled at the earliest allowed time', () => { + const state = create().getState(); + + expect(state.isScheduled).toBe(false); + expect(state.scheduledAt).toBe(MIN_SCHEDULED_AT); + expect(state.minScheduledAt).toBe(MIN_SCHEDULED_AT); + expect(MIN_SCHEDULE_LEAD_MS).toBe(5000); + expect(DEFAULT_SCHEDULE_LEAD_MS).toBe(600000); + }); + + it('snaps a stale time forward to the default when scheduling is turned on', () => { + const machine = create(); + + machine.setIsScheduled(true); + + expect(machine.getState().scheduledAt).toBe(DEFAULT_SCHEDULED_AT); + }); + + it('keeps a later time when scheduling is turned on', () => { + const machine = create(); + + machine.setScheduledAt('2026-09-03T09:00:00.000Z'); + machine.setIsScheduled(true); + + expect(machine.getState().scheduledAt).toBe('2026-09-03T09:00:00.000Z'); + }); + + it('toggles when no value is given', () => { + const machine = create(); + + machine.setIsScheduled(); + expect(machine.getState().isScheduled).toBe(true); + + machine.setIsScheduled(); + expect(machine.getState().isScheduled).toBe(false); + }); + + it.each([ + ['zeroes milliseconds', '2026-09-03T09:00:00.789Z', '2026-09-03T09:00:00.000Z'], + ['accepts a Date', new Date('2026-09-03T09:00:00.456Z'), '2026-09-03T09:00:00.000Z'], + ['floors a past time to the minimum', '2026-09-01T09:00:00.000Z', MIN_SCHEDULED_AT], + ['floors a time inside the lead window', '2026-09-02T10:00:01.000Z', MIN_SCHEDULED_AT], + ['accepts the minimum itself', MIN_SCHEDULED_AT, MIN_SCHEDULED_AT], + ])('%s', (_name, input, expected) => { + const machine = create(); + + machine.setScheduledAt(input); + + expect(machine.getState().scheduledAt).toBe(expected); + }); + + it('ignores an unparseable date', () => { + const machine = create(); + + machine.setScheduledAt('not a date'); + + expect(machine.getState().scheduledAt).toBe(MIN_SCHEDULED_AT); + }); + + it('unschedules a time that fell into the past, keeping the stale time', () => { + let now = NOW; + const machine = create({ now: () => now }); + + machine.setScheduledAt('2026-09-02T10:30:00.000Z'); + machine.setIsScheduled(true); + + now = new Date('2026-09-02T11:00:00.000Z'); + machine.resetPastScheduledAt(); + + expect(machine.getState().isScheduled).toBe(false); + expect(machine.getState().scheduledAt).toBe('2026-09-02T10:30:00.000Z'); + + machine.setIsScheduled(true); + expect(machine.getState().scheduledAt).toBe('2026-09-02T11:10:00.000Z'); + }); + + it('resets to a floor taken from the current time, not from creation', () => { + let now = NOW; + const machine = create({ now: () => now }); + + machine.setScheduledAt('2026-09-02T10:30:00.000Z'); + machine.setIsScheduled(true); + + now = new Date('2026-09-02T11:00:00.000Z'); + machine.reset(); + + expect(machine.getState().scheduledAt).toBe('2026-09-02T11:00:05.000Z'); + expect(machine.getState().isDirty).toBe(false); + }); + + it('keeps a future schedule untouched', () => { + const machine = create(); + + machine.setScheduledAt('2026-09-03T09:00:00.000Z'); + machine.setIsScheduled(true); + machine.resetPastScheduledAt(); + + expect(machine.getState().isScheduled).toBe(true); + expect(machine.getState().scheduledAt).toBe('2026-09-03T09:00:00.000Z'); + }); +}); + +describe('getDefaultRecipientFilter', () => { + it.each([ + ['visibility, public post', { visibility: 'public' }, {}, EVERYONE], + ['visibility, members post', { visibility: 'members' }, {}, EVERYONE], + ['visibility, paid post', { visibility: 'paid' }, {}, 'status:-free'], + [ + 'visibility, tiers post', + { visibility: 'tiers', tiers: [{ slug: 'gold' }, { slug: 'silver' }] }, + {}, + 'tier:gold,tier:silver', + ], + ['visibility, tiers post without tiers', { visibility: 'tiers' }, {}, null], + ['visibility, unknown visibility', { visibility: 'custom' }, {}, 'custom'], + [ + 'disabled', + { visibility: 'public' }, + { editorDefaultEmailRecipients: 'disabled' as const }, + null, + ], + [ + 'explicit filter', + { visibility: 'public' }, + { + editorDefaultEmailRecipients: 'filter' as const, + editorDefaultEmailRecipientsFilter: 'label:vip', + }, + 'label:vip', + ], + [ + 'usually nobody follows visibility', + { visibility: 'paid' }, + { + editorDefaultEmailRecipients: 'filter' as const, + editorDefaultEmailRecipientsFilter: null, + }, + 'status:-free', + ], + [ + 'raw all sentinel expands to everyone', + { visibility: 'paid' }, + { + editorDefaultEmailRecipients: 'filter' as const, + editorDefaultEmailRecipientsFilter: 'all', + }, + EVERYONE, + ], + [ + 'raw none sentinel follows visibility', + { visibility: 'paid' }, + { + editorDefaultEmailRecipients: 'filter' as const, + editorDefaultEmailRecipientsFilter: 'none', + }, + 'status:-free', + ], + ])('%s', (_name, post, site, expected) => { + expect(getDefaultRecipientFilter(createPost(post), createSite(site))).toBe(expected); + }); +}); + +describe('recipient filter', () => { + it('prefers the segment the post was saved with', () => { + const state = create({ + post: createPost({ newsletter: 'weekly', emailSegment: 'label:vip' }), + }).getState(); + + expect(state.recipientFilter).toBe('label:vip'); + }); + + it.each([ + ['no newsletter on the post', { newsletter: null, emailSegment: 'label:vip' }], + ['no segment on the post', { newsletter: 'weekly', emailSegment: null }], + ['raw none segment on the post', { newsletter: 'weekly', emailSegment: 'none' }], + ])('falls back to the site default with %s', (_name, post) => { + expect(create({ post: createPost(post) }).getState().recipientFilter).toBe(EVERYONE); + }); + + it('follows an explicit selection, including clearing it', () => { + const machine = create(); + + machine.setRecipientFilter('label:vip'); + expect(machine.getState().recipientFilter).toBe('label:vip'); + + machine.setRecipientFilter(null); + expect(machine.getState().recipientFilter).toBeNull(); + }); + + it('normalizes raw sentinels from the post and explicit selections', () => { + const machine = create({ + post: createPost({ newsletter: 'weekly', emailSegment: 'all' }), + }); + + expect(machine.getState().recipientFilter).toBe(EVERYONE); + + machine.setRecipientFilter('none'); + expect(machine.getState().recipientFilter).toBeNull(); + }); + + it.each([ + [ + 'members newsletter with a filter', + [WEEKLY], + EVERYONE, + 'newsletters.slug:weekly+email_disabled:0+(status:free,status:-free)', + ], + [ + 'paid newsletter with a filter', + [PAID_NEWSLETTER], + EVERYONE, + 'newsletters.slug:paid-only+email_disabled:0+status:-free+(status:free,status:-free)', + ], + [ + 'members newsletter without a filter', + [WEEKLY], + null, + 'newsletters.slug:weekly+email_disabled:0', + ], + ])('composes the full filter: %s', (_name, newsletters, filter, expected) => { + const machine = create({ site: createSite({ newsletters }) }); + + machine.setRecipientFilter(filter); + + expect(machine.getState().fullRecipientFilter).toBe(expected); + }); + + it('has no full filter without a newsletter', () => { + expect( + create({ site: createSite({ newsletters: [] }) }).getState().fullRecipientFilter, + ).toBeNull(); + }); + + it('recomposes when the newsletter changes', () => { + const machine = create({ site: createSite({ newsletters: [WEEKLY, PAID_NEWSLETTER] }) }); + + machine.setNewsletter(PAID_NEWSLETTER); + + expect(machine.getState().newsletter?.slug).toBe('paid-only'); + expect(machine.getState().fullRecipientFilter).toBe( + 'newsletters.slug:paid-only+email_disabled:0+status:-free+(status:free,status:-free)', + ); + }); +}); + +describe('toDispatch', () => { + it.each([ + [ + 'publish+send', + 'publish+send' as const, + false, + { + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: EVERYONE }, + }, + ], + [ + 'publish+send scheduled', + 'publish+send' as const, + true, + { + kind: 'schedule', + options: { + emailOnly: false, + newsletter: 'weekly', + emailSegment: EVERYONE, + publishedAt: DEFAULT_SCHEDULED_AT, + }, + }, + ], + ['publish', 'publish' as const, false, { kind: 'publish', options: {} }], + [ + 'publish scheduled', + 'publish' as const, + true, + { kind: 'schedule', options: { publishedAt: DEFAULT_SCHEDULED_AT } }, + ], + [ + 'send', + 'send' as const, + false, + { + kind: 'publish', + options: { emailOnly: true, newsletter: 'weekly', emailSegment: EVERYONE }, + }, + ], + [ + 'send scheduled', + 'send' as const, + true, + { + kind: 'schedule', + options: { + emailOnly: true, + newsletter: 'weekly', + emailSegment: EVERYONE, + publishedAt: DEFAULT_SCHEDULED_AT, + }, + }, + ], + ])('%s', (_name, publishType, scheduled, expected) => { + const machine = create(); + + machine.setPublishType(publishType); + machine.setIsScheduled(scheduled); + + expect(machine.toDispatch()).toEqual(expected); + }); + + it('carries the selected segment', () => { + const machine = create(); + + machine.setRecipientFilter('label:vip'); + + expect(machine.toDispatch()).toEqual({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'label:vip' }, + }); + }); + + it('uses the persisted newsletter and segment when retrying a failed email', () => { + const machine = create({ + post: createPost({ + newsletter: 'paid-only', + emailSegment: 'label:vip', + email: { status: 'failed' }, + }), + site: createSite({ newsletters: [WEEKLY, PAID_NEWSLETTER] }), + }); + + machine.setRecipientFilter(null); + + expect(machine.getState().newsletter?.slug).toBe('paid-only'); + expect(machine.toDispatch()).toEqual({ + kind: 'publish', + options: { emailOnly: false }, + }); + }); + + it.each([ + ['archived', [{ ...WEEKLY }, { ...PAID_NEWSLETTER, status: 'archived' }]], + ['missing', [WEEKLY]], + ])( + 'does not override the durable newsletter for a %s failed-email retry', + (_name, newsletters) => { + const machine = create({ + post: createPost({ newsletter: 'paid-only', email: { status: 'failed' } }), + site: createSite({ newsletters }), + }); + + expect(machine.getState().newsletter?.slug).toBe('paid-only'); + expect(machine.toDispatch()).toEqual({ + kind: 'publish', + options: { emailOnly: false }, + }); + }, + ); + + it('does not dispatch email-only when there are no recipients', () => { + const machine = create(); + + machine.setPublishType('send'); + machine.setRecipientFilter(null); + + expect(machine.getState().willEmail).toBe(false); + expect(machine.getState().canPublish).toBe(false); + expect(machine.toDispatch()).toBeNull(); + }); + + it('omits email options when the post will not email', () => { + const machine = create({ post: createPost({ email: { status: 'submitted' } }) }); + + machine.setPublishType('publish+send'); + + expect(machine.toDispatch()).toEqual({ kind: 'publish', options: {} }); + }); + + it.each([ + ['published', 'published' as const], + ['scheduled', 'scheduled' as const], + ['sent', 'sent' as const], + ])('offers no transition for a %s post', (_name, status) => { + const machine = create({ post: createPost({ status }) }); + + expect(machine.getState().canPublish).toBe(false); + expect(machine.toDispatch()).toBeNull(); + }); + + it.each([ + ['draft', 'draft' as const], + ['published', 'published' as const], + ['scheduled', 'scheduled' as const], + ['sent', 'sent' as const], + ])('reverts a %s post without options', (_name, status) => { + expect(create({ post: createPost({ status }) }).toRevertDispatch()).toEqual({ kind: 'revert' }); + }); +}); + +describe('splitUpgradeMessage', () => { + it.each([ + [ + 'no phrase', + 'You are over your member limit.', + [{ text: 'You are over your member limit.', kind: 'text' }], + ], + [ + 'phrase in the middle', + 'Your plan is full, please upgrade to continue.', + [ + { text: 'Your plan is full, ', kind: 'text' }, + { text: 'please upgrade', kind: 'upgrade' }, + { text: ' to continue.', kind: 'text' }, + ], + ], + [ + 'phrase at the start', + 'Please upgrade your plan.', + [ + { text: 'Please upgrade', kind: 'upgrade' }, + { text: ' your plan.', kind: 'text' }, + ], + ], + ['phrase alone', 'please upgrade', [{ text: 'please upgrade', kind: 'upgrade' }]], + [ + 'only the first phrase links', + 'please upgrade or please upgrade', + [ + { text: 'please upgrade', kind: 'upgrade' }, + { text: ' or please upgrade', kind: 'text' }, + ], + ], + ])('%s', (_name, message, expected) => { + expect(splitUpgradeMessage(message)).toEqual(expected); + }); +}); + +describe('checkLimits', () => { + function ports(overrides = {}) { + return { + refreshSettings: vi.fn(() => Promise.resolve()), + checkSendingLimit: vi.fn(() => Promise.resolve()), + checkPublishingLimit: vi.fn(() => Promise.resolve()), + getEmailVerification: vi.fn(() => ({ required: false })), + ...overrides, + }; + } + + it('reports no blocks when every check passes', async () => { + const limits = ports(); + + await expect(create({ limits }).checkLimits()).resolves.toEqual({ + emailBlock: null, + publishBlock: null, + }); + }); + + it('refreshes settings before reading the sending limit', async () => { + const calls: string[] = []; + const limits = ports({ + refreshSettings: vi.fn(() => { + calls.push('refresh'); + return Promise.resolve(); + }), + checkSendingLimit: vi.fn(() => { + calls.push('sending'); + return Promise.resolve(); + }), + getEmailVerification: vi.fn(() => { + calls.push('verification'); + return { required: false }; + }), + }); + + await create({ limits }).checkLimits(); + + expect(calls).toEqual(['refresh', 'sending', 'verification']); + }); + + it('rejects when the settings refresh fails', async () => { + const limits = ports({ + refreshSettings: vi.fn(() => Promise.reject(new Error('offline'))), + }); + + await expect(create({ limits }).checkLimits()).rejects.toThrow('offline'); + }); + + it('blocks email on the sending limit and skips the verification read', async () => { + const limits = ports({ + checkSendingLimit: vi.fn(() => + Promise.reject(new Error('Your plan is over its email limit, please upgrade.')), + ), + }); + + const result = await create({ limits }).checkLimits(); + + expect(result.emailBlock).toEqual({ + kind: 'sending-limit', + message: 'Your plan is over its email limit, please upgrade.', + }); + expect(limits.getEmailVerification).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'host message', + { required: true, message: 'Sending is paused for review.' }, + 'Sending is paused for review.', + ], + ['default message', { required: true }, EMAIL_VERIFICATION_HOLD_MESSAGE], + ['blank host message', { required: true, message: '' }, EMAIL_VERIFICATION_HOLD_MESSAGE], + ])('holds email on verification with the %s', async (_name, hold, message) => { + const limits = ports({ getEmailVerification: vi.fn(() => hold) }); + + const result = await create({ limits }).checkLimits(); + + expect(result.emailBlock).toEqual({ kind: 'email-verification', message }); + }); + + it('does not evaluate the sending limit for authors and contributors', async () => { + const limits = ports({ + checkSendingLimit: vi.fn(() => Promise.reject(new Error('over limit'))), + getEmailVerification: vi.fn(() => ({ required: true, message: 'in review' })), + }); + + const result = await create({ + limits, + user: createUser({ isAdmin: false, isAuthorOrContributor: true }), + }).checkLimits(); + + expect(limits.checkSendingLimit).not.toHaveBeenCalled(); + expect(result.emailBlock).toEqual({ kind: 'email-verification', message: 'in review' }); + }); + + it('does not evaluate the publishing limit for non-admins', async () => { + const limits = ports({ + checkPublishingLimit: vi.fn(() => Promise.reject(new Error('over member limit'))), + }); + + const result = await create({ limits, user: createUser({ isAdmin: false }) }).checkLimits(); + + expect(limits.checkPublishingLimit).not.toHaveBeenCalled(); + expect(result.publishBlock).toBeNull(); + }); + + it('returns the publishing limit as linkable parts', async () => { + const limits = ports({ + checkPublishingLimit: vi.fn(() => + Promise.reject(new Error('You have reached your member limit, please upgrade your plan.')), + ), + }); + + const machine = create({ limits }); + const result = await machine.checkLimits(); + + expect(result.publishBlock).toEqual({ + kind: 'host-limit', + message: 'You have reached your member limit, please upgrade your plan.', + parts: [ + { text: 'You have reached your member limit, ', kind: 'text' }, + { text: 'please upgrade', kind: 'upgrade' }, + { text: ' your plan.', kind: 'text' }, + ], + }); + expect(machine.getState().emailDisabled).toBe(false); + }); + + it('disables the email types and falls back to publish only', async () => { + const limits = ports({ getEmailVerification: vi.fn(() => ({ required: true })) }); + const machine = create({ limits }); + + expect(machine.getState().publishType).toBe('publish+send'); + + await machine.checkLimits(); + + const state = machine.getState(); + + expect(state.emailDisabled).toBe(true); + expect(state.emailDisabledReason).toBe('email-verification'); + expect(state.availablePublishTypes).toEqual(['publish']); + expect(state.publishType).toBe('publish'); + expect(state.isDirty).toBe(false); + }); + + it('demotes an email type the user chose before the block landed', async () => { + const limits = ports({ getEmailVerification: vi.fn(() => ({ required: true })) }); + const machine = create({ limits }); + + machine.setPublishType('send'); + await machine.checkLimits(); + + expect(machine.getState().publishType).toBe('publish'); + expect(machine.getState().willEmail).toBe(false); + expect(machine.toDispatch()).toEqual({ kind: 'publish', options: {} }); + }); + + it('keeps a type the user chose while email stays available', async () => { + const limits = ports(); + const machine = create({ limits }); + + machine.setPublishType('publish'); + await machine.checkLimits(); + + expect(machine.getState().publishType).toBe('publish'); + }); + + it('emits no email options for a send type chosen after a block', async () => { + const limits = ports({ getEmailVerification: vi.fn(() => ({ required: true })) }); + const machine = create({ limits }); + + await machine.checkLimits(); + machine.setPublishType('send'); + + expect(machine.getState().willEmail).toBe(false); + expect(machine.getState().canPublish).toBe(false); + expect(machine.toDispatch()).toBeNull(); + }); + + it('keeps send for a sent post', async () => { + const limits = ports({ getEmailVerification: vi.fn(() => ({ required: true })) }); + const machine = create({ limits, post: createPost({ status: 'sent' }) }); + + await machine.checkLimits(); + + expect(machine.getState().publishType).toBe('send'); + }); + + it('clears blocks from an earlier run', async () => { + let required = true; + const limits = ports({ getEmailVerification: vi.fn(() => ({ required })) }); + const machine = create({ limits }); + + await machine.checkLimits(); + expect(machine.getState().emailBlock).not.toBeNull(); + + required = false; + await machine.checkLimits(); + + expect(machine.getState().emailBlock).toBeNull(); + expect(machine.getState().publishType).toBe('publish+send'); + }); +}); + +describe('dirty tracking and reset', () => { + it('starts clean', () => { + expect(create().getState().isDirty).toBe(false); + }); + + it.each([ + ['publish type', (m: ReturnType) => m.setPublishType('send'), true], + [ + 'same publish type', + (m: ReturnType) => m.setPublishType('publish+send'), + false, + ], + ['scheduling', (m: ReturnType) => m.setIsScheduled(true), true], + [ + 'scheduled time', + (m: ReturnType) => m.setScheduledAt('2026-09-03T09:00:00.000Z'), + true, + ], + ['recipient filter', (m: ReturnType) => m.setRecipientFilter('label:vip'), true], + [ + 'same recipient filter', + (m: ReturnType) => m.setRecipientFilter(EVERYONE), + false, + ], + [ + 'cleared recipient filter', + (m: ReturnType) => m.setRecipientFilter(null), + true, + ], + ])('changing the %s → dirty: %s', (_name, change, expected) => { + const machine = create(); + + change(machine); + + expect(machine.getState().isDirty).toBe(expected); + }); + + it('is clean again once scheduling is turned back off', () => { + const machine = create(); + + machine.setIsScheduled(true); + expect(machine.getState().isDirty).toBe(true); + + machine.setIsScheduled(false); + expect(machine.getState().isDirty).toBe(false); + }); + + it('stays dirty when a chosen time survives unscheduling', () => { + const machine = create(); + + machine.setScheduledAt('2026-09-03T09:00:00.000Z'); + machine.setIsScheduled(true); + machine.setIsScheduled(false); + + expect(machine.getState().scheduledAt).toBe('2026-09-03T09:00:00.000Z'); + expect(machine.getState().isDirty).toBe(true); + }); + + it('is dirty when the newsletter changes', () => { + const machine = create({ site: createSite({ newsletters: [WEEKLY, PAID_NEWSLETTER] }) }); + + machine.setNewsletter(PAID_NEWSLETTER); + + expect(machine.getState().isDirty).toBe(true); + }); + + it('restores every option', () => { + const machine = create({ site: createSite({ newsletters: [WEEKLY, PAID_NEWSLETTER] }) }); + + machine.setPublishType('send'); + machine.setIsScheduled(true); + machine.setNewsletter(PAID_NEWSLETTER); + machine.setRecipientFilter('label:vip'); + + machine.reset(); + + const state = machine.getState(); + + expect(state.publishType).toBe('publish+send'); + expect(state.isScheduled).toBe(false); + expect(state.scheduledAt).toBe(MIN_SCHEDULED_AT); + expect(state.newsletter?.slug).toBe('weekly'); + expect(state.recipientFilter).toBe(EVERYONE); + expect(state.isDirty).toBe(false); + }); +}); diff --git a/apps/admin/src/editor/publish/publish-options.ts b/apps/admin/src/editor/publish/publish-options.ts new file mode 100644 index 00000000000..8276b719ae2 --- /dev/null +++ b/apps/admin/src/editor/publish/publish-options.ts @@ -0,0 +1,630 @@ +import { + EVERYONE_RECIPIENT_FILTER, + PAID_SEGMENT, + getFullRecipientFilter, + getNewsletterRecipientFilter, +} from '@tryghost/admin-x-framework/utils/recipient-filter'; +import type { PostStatus } from '@tryghost/admin-x-framework/api/posts'; +import type { + PublishOptions as PublishCommandOptions, + ScheduleOptions as ScheduleCommandOptions, +} from '@/editor/engine/save-engine'; + +/** The server rejects a schedule in the past; the picker floor sits just ahead of now. */ +export const MIN_SCHEDULE_LEAD_MS = 5 * 1000; +export const DEFAULT_SCHEDULE_LEAD_MS = 10 * 60 * 1000; + +export const EMAIL_VERIFICATION_HOLD_MESSAGE = + 'Email sending is temporarily disabled because your account is currently in review. You should have an email about this from us already, but you can also reach us any time at support@ghost.org.'; + +const UPGRADE_PHRASE = /please upgrade/i; + +export type PublishType = 'publish+send' | 'publish' | 'send'; + +export interface PublishTypeOption { + value: PublishType; + /** Shown in the expanded options list. */ + label: string; + /** Shown in the collapsed option title. */ + display: string; + disabled: boolean; +} + +export interface PublishPostInput { + status: PostStatus; + /** Pages never email. */ + isPage?: boolean; + visibility?: string | null; + tiers?: ReadonlyArray<{ slug: string }>; + /** The post's persisted newsletter slug; pairs with `emailSegment` for the initial filter. */ + newsletter?: string | null; + emailSegment?: string | null; + /** The post's email record, if one was ever created. */ + email?: { status?: string | null } | null; +} + +export interface NewsletterInput { + slug: string; + name?: string; + /** Only `active` newsletters are selectable. */ + status?: string; + visibility?: string; + sortOrder?: number; +} + +export type DefaultEmailRecipients = 'disabled' | 'visibility' | 'filter'; + +export interface PublishSiteInput { + /** False when members signup access is `none`. */ + membersEnabled: boolean; + mailgunConfigured: boolean; + editorDefaultEmailRecipients: DefaultEmailRecipients; + editorDefaultEmailRecipientsFilter: string | null; + /** Read for admins only; null when the count could not be read, and treated as "has members". */ + memberCount: number | null; + newsletters: ReadonlyArray; +} + +export interface PublishUserInput { + /** Only admins and owners can read the member count and the publishing limit. */ + isAdmin: boolean; + /** Authors and contributors cannot browse emails, so the sending limit is not evaluated for them. */ + isAuthorOrContributor: boolean; +} + +export interface EmailVerificationHold { + required: boolean; + /** Host-specific copy; the default hold message is used when absent. */ + message?: string | null; +} + +export interface PublishLimitPorts { + /** Awaited before the sending checks so a fresh hold is seen. A rejection propagates. */ + refreshSettings?: () => Promise; + /** Resolves when sending is allowed; rejects with the host's message when the email limit would be exceeded. */ + checkSendingLimit?: () => Promise; + /** Resolves when publishing is allowed; rejects with the host's message when over the member limit. */ + checkPublishingLimit?: () => Promise; + /** Read after `refreshSettings`. */ + getEmailVerification?: () => EmailVerificationHold; +} + +export interface LimitMessagePart { + text: string; + /** `upgrade` marks the phrase to render as an upgrade link. */ + kind: 'text' | 'upgrade'; +} + +export interface EmailBlock { + kind: 'sending-limit' | 'email-verification'; + message: string; +} + +export interface PublishBlock { + kind: 'host-limit'; + message: string; + parts: LimitMessagePart[]; +} + +export interface PublishLimits { + emailBlock: EmailBlock | null; + publishBlock: PublishBlock | null; +} + +export type EmailUnavailableReason = 'page' | 'already-emailed' | 'disabled-in-settings'; + +export type EmailDisabledReason = + | 'no-mailgun' + | 'no-members' + | 'no-newsletter' + | 'sending-limit' + | 'email-verification'; + +export interface PublishOptionsState { + readonly publishType: PublishType; + readonly publishTypeOptions: readonly PublishTypeOption[]; + readonly availablePublishTypes: readonly PublishType[]; + readonly isScheduled: boolean; + /** ISO 8601, milliseconds zeroed. */ + readonly scheduledAt: string; + /** The earliest time the picker may offer, recomputed on every read. */ + readonly minScheduledAt: string; + readonly newsletter: NewsletterInput | null; + /** Active newsletters in sort order. */ + readonly newsletters: readonly NewsletterInput[]; + readonly onlyDefaultNewsletter: boolean; + readonly recipientFilter: string | null; + /** The newsletter audience AND-ed with the recipient filter; null without a newsletter. */ + readonly fullRecipientFilter: string | null; + readonly willEmail: boolean; + readonly willEmailImmediately: boolean; + readonly willPublish: boolean; + readonly willOnlyEmail: boolean; + readonly emailUnavailable: boolean; + readonly emailUnavailableReason: EmailUnavailableReason | null; + readonly emailDisabled: boolean; + readonly emailDisabledReason: EmailDisabledReason | null; + readonly emailBlock: EmailBlock | null; + readonly publishBlock: PublishBlock | null; + /** Draft-only, and false when email-only has no executable email. */ + readonly canPublish: boolean; + readonly isDirty: boolean; +} + +export type PublishDispatch = + | { kind: 'publish'; options: PublishCommandOptions } + | { kind: 'schedule'; options: ScheduleCommandOptions } + | { kind: 'revert' }; + +export interface PublishOptionsMachine { + getState(): PublishOptionsState; + setPublishType(publishType: PublishType): void; + setIsScheduled(shouldSchedule?: boolean): void; + setScheduledAt(date: string | Date): void; + resetPastScheduledAt(): void; + setNewsletter(newsletter: NewsletterInput | null): void; + setRecipientFilter(filter: string | null): void; + reset(): void; + checkLimits(): Promise; + /** Null when no safe status transition is on offer. */ + toDispatch(): PublishDispatch | null; + toRevertDispatch(): PublishDispatch; +} + +export interface PublishOptionsInputs { + post: PublishPostInput; + site: PublishSiteInput; + user: PublishUserInput; + limits?: PublishLimitPorts; + now?: () => Date; +} + +function zeroMilliseconds(time: number): string { + return new Date(time - (time % 1000)).toISOString(); +} + +function isBefore(iso: string, other: string): boolean { + return Date.parse(iso) < Date.parse(other); +} + +export function selectableNewsletters( + newsletters: ReadonlyArray, +): NewsletterInput[] { + return newsletters + .filter((newsletter) => newsletter.status === 'active') + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); +} + +/** The tier list spelled as a recipient filter; null when the post has no tiers. */ +export function tiersSegment(tiers: ReadonlyArray<{ slug: string }>): string | null { + return tiers.map((tier) => `tier:${tier.slug}`).join(',') || null; +} + +/** Expands the API's legacy segment sentinels into the filters used by the editor. */ +export function normalizeRecipientFilter(filter: string | null | undefined): string | null { + if (filter === 'all') { + return EVERYONE_RECIPIENT_FILTER; + } + if (!filter || filter === 'none') { + return null; + } + return filter; +} + +export function getDefaultRecipientFilter( + post: PublishPostInput, + site: Pick< + PublishSiteInput, + 'editorDefaultEmailRecipients' | 'editorDefaultEmailRecipientsFilter' + >, +): string | null { + const recipients = site.editorDefaultEmailRecipients; + const filter = normalizeRecipientFilter(site.editorDefaultEmailRecipientsFilter); + const usuallyNobody = recipients === 'filter' && filter === null; + + if (recipients === 'disabled') { + return null; + } + + if (recipients === 'visibility' || usuallyNobody) { + switch (post.visibility) { + case 'public': + case 'members': + return EVERYONE_RECIPIENT_FILTER; + case 'paid': + return PAID_SEGMENT; + case 'tiers': + return tiersSegment(post.tiers ?? []); + default: + return post.visibility ?? null; + } + } + + return filter; +} + +export function getEmailUnavailableReason( + post: PublishPostInput, + site: Pick, +): EmailUnavailableReason | null { + if (post.isPage) { + return 'page'; + } + if (post.email) { + return 'already-emailed'; + } + if (site.editorDefaultEmailRecipients === 'disabled' || !site.membersEnabled) { + return 'disabled-in-settings'; + } + return null; +} + +export function getEmailDisabledReason( + { mailgunConfigured, memberCount }: Pick, + emailBlock: EmailBlock | null, + hasNewsletter: boolean, +): EmailDisabledReason | null { + if (!mailgunConfigured) { + return 'no-mailgun'; + } + if (memberCount === 0) { + return 'no-members'; + } + if (!hasNewsletter) { + return 'no-newsletter'; + } + return emailBlock?.kind ?? null; +} + +export function getInitialPublishType( + post: PublishPostInput, + site: Pick< + PublishSiteInput, + 'editorDefaultEmailRecipients' | 'editorDefaultEmailRecipientsFilter' + >, + { emailUnavailable, emailDisabled }: { emailUnavailable: boolean; emailDisabled: boolean }, +): PublishType { + let publishType: PublishType = 'publish+send'; + + if (emailUnavailable || emailDisabled) { + publishType = 'publish'; + } + + // "Usually nobody" starts as publish-only but keeps recipients matching visibility, + // so turning email on is a single click. + if ( + site.editorDefaultEmailRecipients === 'filter' && + normalizeRecipientFilter(site.editorDefaultEmailRecipientsFilter) === null + ) { + publishType = 'publish'; + } + + if (post.status === 'sent') { + publishType = 'send'; + } + + return publishType; +} + +/** Splits the host's message so the upgrade phrase can be rendered as a link without HTML injection. */ +export function splitUpgradeMessage(message: string): LimitMessagePart[] { + const match = UPGRADE_PHRASE.exec(message); + + if (!match) { + return [{ text: message, kind: 'text' }]; + } + + const parts: LimitMessagePart[] = []; + const before = message.slice(0, match.index); + const after = message.slice(match.index + match[0].length); + + if (before) { + parts.push({ text: before, kind: 'text' }); + } + parts.push({ text: match[0], kind: 'upgrade' }); + if (after) { + parts.push({ text: after, kind: 'text' }); + } + + return parts; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createPublishOptions({ + post, + site, + user, + limits = {}, + now = () => new Date(), +}: PublishOptionsInputs): PublishOptionsMachine { + const newsletters = selectableNewsletters(site.newsletters); + const defaultNewsletter = newsletters[0] ?? null; + const isDraft = post.status === 'draft'; + const retryingFailedEmail = isDraft && post.email?.status === 'failed'; + const persistedNewsletter = post.newsletter + ? (site.newsletters.find((option) => option.slug === post.newsletter) ?? + (retryingFailedEmail ? { slug: post.newsletter } : null)) + : null; + const initialNewsletter = + persistedNewsletter && (persistedNewsletter.status === 'active' || retryingFailedEmail) + ? persistedNewsletter + : defaultNewsletter; + // Only admins can browse members, so nobody else's count is trusted as a zero. + const memberCount = user.isAdmin ? site.memberCount : null; + + const emailUnavailableReason = getEmailUnavailableReason(post, site); + const emailUnavailable = emailUnavailableReason !== null; + + let emailBlock: EmailBlock | null = null; + let publishBlock: PublishBlock | null = null; + let newsletter: NewsletterInput | null = initialNewsletter; + + const emailDisabledReason = () => + getEmailDisabledReason({ ...site, memberCount }, emailBlock, newsletter !== null); + const emailDisabled = () => emailDisabledReason() !== null; + + const minScheduledAt = () => zeroMilliseconds(now().getTime() + MIN_SCHEDULE_LEAD_MS); + const defaultScheduledAt = () => zeroMilliseconds(now().getTime() + DEFAULT_SCHEDULE_LEAD_MS); + + let publishType = getInitialPublishType(post, site, { + emailUnavailable, + emailDisabled: emailDisabled(), + }); + let publishTypeTouched = false; + let isScheduled = false; + let scheduledAt = minScheduledAt(); + // A time only counts as a change once it is chosen, so unscheduling cannot leave the state dirty. + let scheduledAtTouched = false; + // `undefined` means "not chosen": the filter follows the post and the site default. + let selectedRecipientFilter: string | null | undefined; + + const recipientFilter = (): string | null => { + if (selectedRecipientFilter === undefined) { + return ( + (post.newsletter && normalizeRecipientFilter(post.emailSegment)) || + getDefaultRecipientFilter(post, site) || + null + ); + } + return selectedRecipientFilter; + }; + + let initial = { + publishType, + isScheduled, + scheduledAt, + newsletterSlug: newsletter?.slug ?? null, + recipientFilter: recipientFilter(), + }; + + const fullRecipientFilter = (): string | null => { + if (!newsletter) { + return null; + } + return getFullRecipientFilter( + getNewsletterRecipientFilter({ slug: newsletter.slug, visibility: newsletter.visibility }), + recipientFilter(), + ); + }; + + const willEmail = (): boolean => { + // Failed emails are unavailable for a fresh send but must remain retryable. + if (!newsletter || emailDisabled() || (emailUnavailable && !retryingFailedEmail)) { + return false; + } + + const hasEmail = Boolean(post.email); + const emailFailed = post.email?.status === 'failed'; + + return ( + (publishType !== 'publish' && Boolean(recipientFilter()) && isDraft && !hasEmail) || + (isDraft && hasEmail && emailFailed) + ); + }; + + const publishTypeOptions = (): PublishTypeOption[] => { + const disabled = emailDisabled(); + + return [ + { value: 'publish+send', label: 'Publish and email', display: 'Publish and email', disabled }, + { value: 'publish', label: 'Publish only', display: 'Publish', disabled: false }, + { value: 'send', label: 'Email only', display: 'Email', disabled }, + ]; + }; + + const isDirty = (): boolean => + publishType !== initial.publishType || + isScheduled !== initial.isScheduled || + ((isScheduled || scheduledAtTouched) && scheduledAt !== initial.scheduledAt) || + (newsletter?.slug ?? null) !== initial.newsletterSlug || + recipientFilter() !== initial.recipientFilter; + + const getState = (): PublishOptionsState => { + const options = publishTypeOptions(); + const emails = willEmail(); + + return { + publishType, + publishTypeOptions: options, + availablePublishTypes: emailUnavailable + ? ['publish'] + : options.filter((o) => !o.disabled).map((o) => o.value), + isScheduled, + scheduledAt, + minScheduledAt: minScheduledAt(), + newsletter, + newsletters, + onlyDefaultNewsletter: newsletters.length === 1, + recipientFilter: recipientFilter(), + fullRecipientFilter: fullRecipientFilter(), + willEmail: emails, + willEmailImmediately: emails && !isScheduled, + willPublish: publishType !== 'send', + willOnlyEmail: publishType === 'send', + emailUnavailable, + emailUnavailableReason, + emailDisabled: emailDisabled(), + emailDisabledReason: emailDisabledReason(), + emailBlock, + publishBlock, + canPublish: isDraft && (publishType !== 'send' || emails), + isDirty: isDirty(), + }; + }; + + const setScheduledAt = (date: string | Date): void => { + const time = date instanceof Date ? date.getTime() : Date.parse(date); + + if (Number.isNaN(time)) { + return; + } + + const candidate = zeroMilliseconds(time); + const floor = minScheduledAt(); + + scheduledAt = isBefore(candidate, floor) ? floor : candidate; + scheduledAtTouched = true; + }; + + const runSendingCheck = async (): Promise => { + await limits.refreshSettings?.(); + + try { + if (!user.isAuthorOrContributor) { + await limits.checkSendingLimit?.(); + } + + // Checked after the limit so a site under its email limit still shows a verification hold. + const hold = limits.getEmailVerification?.(); + + if (hold?.required) { + emailBlock = { + kind: 'email-verification', + message: hold.message || EMAIL_VERIFICATION_HOLD_MESSAGE, + }; + } + } catch (error) { + emailBlock = { kind: 'sending-limit', message: errorMessage(error) }; + } + }; + + const runPublishingCheck = async (): Promise => { + if (!user.isAdmin) { + return; + } + + try { + await limits.checkPublishingLimit?.(); + } catch (error) { + const message = errorMessage(error); + publishBlock = { kind: 'host-limit', message, parts: splitUpgradeMessage(message) }; + } + }; + + return { + getState, + + setPublishType(newValue) { + publishType = newValue; + publishTypeTouched = true; + }, + + setIsScheduled(shouldSchedule) { + isScheduled = shouldSchedule === undefined ? !isScheduled : shouldSchedule; + + if (isScheduled && isBefore(scheduledAt, defaultScheduledAt())) { + scheduledAt = defaultScheduledAt(); + } + }, + + setScheduledAt, + + resetPastScheduledAt() { + if (isBefore(scheduledAt, minScheduledAt())) { + isScheduled = false; + } + }, + + setNewsletter(newValue) { + newsletter = newValue; + }, + + setRecipientFilter(filter) { + selectedRecipientFilter = normalizeRecipientFilter(filter); + }, + + reset() { + publishType = initial.publishType; + publishTypeTouched = false; + isScheduled = initial.isScheduled; + // The construction-time floor may itself be in the past by now. + scheduledAt = minScheduledAt(); + scheduledAtTouched = false; + newsletter = initialNewsletter; + selectedRecipientFilter = undefined; + }, + + async checkLimits() { + emailBlock = null; + publishBlock = null; + + await Promise.all([runSendingCheck(), runPublishingCheck()]); + + // A block that lands after the user picked an email type still demotes that pick. + if (!publishTypeTouched || emailDisabled()) { + publishType = getInitialPublishType(post, site, { + emailUnavailable, + emailDisabled: emailDisabled(), + }); + initial = { ...initial, publishType }; + } + + return { emailBlock, publishBlock }; + }, + + toDispatch() { + if (!isDraft) { + return null; + } + + const emails = willEmail(); + + // Never turn an invalid email-only choice into a public publish. + if (publishType === 'send' && !emails) { + return null; + } + + const options: PublishCommandOptions = {}; + + if (emails) { + options.emailOnly = publishType === 'send'; + + // A retry must be validated against the newsletter and segment persisted with the + // failed email, not mutable picker state or the site's current default. + if (!retryingFailedEmail) { + const filter = recipientFilter(); + + if (newsletter) { + options.newsletter = newsletter.slug; + } + if (filter) { + options.emailSegment = filter; + } + } + } + + if (isScheduled) { + return { kind: 'schedule', options: { ...options, publishedAt: scheduledAt } }; + } + + return { kind: 'publish', options }; + }, + + toRevertDispatch() { + return { kind: 'revert' }; + }, + }; +} diff --git a/apps/admin/src/editor/session/editor-session.test.ts b/apps/admin/src/editor/session/editor-session.test.ts new file mode 100644 index 00000000000..b0d752cc9ab --- /dev/null +++ b/apps/admin/src/editor/session/editor-session.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest'; +import { slugify } from '@tryghost/string'; +import { buildLexicalParagraph } from '@tryghost/test-data'; +import { + createEditorSession, + type EditorSessionOptions, + type EditorWritePayload, +} from './editor-session'; +import type { EditorRecord } from './projection'; + +const LOADED_AT = '2026-01-01T00:00:00.000Z'; + +function body(text: string): unknown { + return JSON.parse(buildLexicalParagraph(text)); +} + +function record(overrides: Partial = {}): EditorRecord { + return { + id: 'abc123', + uuid: 'uuid', + url: 'https://example.com/hello/', + title: 'Hello', + slug: 'hello', + status: 'draft', + lexical: buildLexicalParagraph('Hello'), + updated_at: LOADED_AT, + published_at: null, + tags: [], + ...overrides, + }; +} + +interface Harness { + updates: Array<{ payload: EditorWritePayload; saveRevision?: boolean }>; + creates: EditorWritePayload[]; + acquiredIds: string[]; + /** The record every acknowledgement answers with; tests advance it. */ + acknowledged: EditorRecord; +} + +function harness(options: Partial = {}) { + const state: Harness = { + updates: [], + creates: [], + acquiredIds: [], + acknowledged: record(), + }; + let saveCount = 0; + + const session = createEditorSession({ + saveFailureMessage: 'Couldn’t save this post.', + onIdAcquired: (id) => state.acquiredIds.push(id), + onError: vi.fn(), + transport: { + create: (payload) => { + state.creates.push(payload); + saveCount += 1; + state.acknowledged = record({ + ...state.acknowledged, + id: 'created-id', + title: payload.title as string, + slug: payload.slug as string, + lexical: payload.lexical as string, + updated_at: `2026-01-01T00:00:0${saveCount}.000Z`, + }); + return Promise.resolve(state.acknowledged); + }, + update: (payload, writeOptions) => { + state.updates.push({ payload, saveRevision: writeOptions.saveRevision }); + saveCount += 1; + state.acknowledged = record({ + ...state.acknowledged, + title: payload.title as string, + slug: payload.slug as string, + lexical: payload.lexical as string, + custom_excerpt: (payload.custom_excerpt ?? null) as string | null, + updated_at: `2026-01-01T00:00:0${saveCount}.000Z`, + }); + return Promise.resolve(state.acknowledged); + }, + generateSlug: (text) => Promise.resolve(slugify(text)), + }, + ...options, + }); + + return { session, state }; +} + +describe('createEditorSession', () => { + it('loads a post clean and dirties it on the first edit', () => { + const { session } = harness({ record: record() }); + + expect(session.isDirty()).toBe(false); + + session.patchLexical(body('Hello and more')); + + expect(session.isDirty()).toBe(true); + }); + + it('submits the edited body and lands the post clean again', async () => { + const { session, state } = harness({ record: record() }); + const edited = body('Hello and more'); + + session.setBaseline(record().lexical); + session.patchLexical(edited); + await session.dispatchExplicit(); + + expect(state.updates).toHaveLength(1); + expect(state.updates[0].payload).toMatchObject({ + id: 'abc123', + title: 'Hello', + slug: 'hello', + lexical: JSON.stringify(edited), + updated_at: LOADED_AT, + status: 'draft', + }); + expect(state.updates[0].saveRevision).toBe(true); + expect(session.isDirty()).toBe(false); + }); + + it('sends the acknowledged collision token on the next save', async () => { + const { session, state } = harness({ record: record() }); + + session.patchLexical(body('One')); + await session.dispatchExplicit(); + session.patchLexical(body('Two')); + await session.dispatchExplicit(); + + expect(state.updates[0].payload.updated_at).toBe(LOADED_AT); + expect(state.updates[1].payload.updated_at).toBe('2026-01-01T00:00:01.000Z'); + }); + + it('creates a new post, adopts its id and updates it afterwards', async () => { + const { session, state } = harness(); + + session.patchLexical(body('First words')); + await session.dispatchExplicit(); + + expect(state.creates).toHaveLength(1); + expect(state.creates[0]).not.toHaveProperty('id'); + // A blank title persists as the default and its slug is generated from it. + expect(state.creates[0]).toMatchObject({ title: '(Untitled)', slug: 'untitled' }); + expect(state.acquiredIds).toEqual(['created-id']); + + session.patchLexical(body('More words')); + await session.dispatchExplicit(); + + expect(state.updates[0].payload).toMatchObject({ id: 'created-id' }); + expect(state.acquiredIds).toEqual(['created-id']); + }); + + it('keeps the excerpt it was given and clears it back to null', async () => { + const { session, state } = harness({ record: record() }); + + session.patchExcerpt('A summary'); + await session.dispatchExplicit(); + session.patchExcerpt(''); + await session.dispatchExplicit(); + + expect(state.updates[0].payload.custom_excerpt).toBe('A summary'); + expect(state.updates[1].payload.custom_excerpt).toBeNull(); + }); + + it('gives each new post its own state', () => { + const first = harness(); + const second = harness(); + + first.session.patchLexical(body('Only mine')); + + expect(first.session.isDirty()).toBe(true); + expect(second.session.isDirty()).toBe(false); + }); + + it('adopts a refetched record without re-baselining', () => { + const { session } = harness({ record: record() }); + const edited = body('Unsaved edit'); + + session.setBaseline(record().lexical); + session.patchLexical(edited); + session.recordRefetched(record({ updated_at: '2026-01-02T00:00:00.000Z' })); + + expect(session.isDirty()).toBe(true); + }); + + it('stops saving once disposed', async () => { + const { session, state } = harness({ record: record() }); + + session.patchLexical(body('Too late')); + session.dispose(); + await session.dispatchExplicit(); + + expect(state.updates).toHaveLength(0); + }); +}); diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts new file mode 100644 index 00000000000..2c2697307aa --- /dev/null +++ b/apps/admin/src/editor/session/editor-session.ts @@ -0,0 +1,278 @@ +import { createChangeTracker } from '@/editor/engine/change-tracker'; +import { createSlugMachine } from '@/editor/engine/slug-machine'; +import { + DEFAULT_TITLE, + createSaveEngine, + type PersistedIdentity, + type PostStatus, + type SaveCompletion, + type SaveEngineState, + type SaveOutcome, + type SaveRequest, + type SaveResult, +} from '@/editor/engine/save-engine'; +import type { EditablePostProjection, RevisionProjection } from '@/editor/engine/change-tracker'; +import type { LexicalInput } from '@/editor/engine/lexical-compare'; +import type { PostWriteOptions } from '@tryghost/admin-x-framework/api/post-contract'; +import { toSaveError } from './error-mapping'; +import { createSlugPort } from './slug-port'; +import { buildSaveSnapshot, type EditorSaveSnapshot } from './snapshot'; +import { latestRevisionOf, newPostProjection, projectionOf, type EditorRecord } from './projection'; + +export type EditorWritePayload = Record; + +/** The full acknowledged record travels with the result so reconcile can rebase on it. */ +export interface EditorSaveResult extends SaveResult { + post: EditorRecord; +} + +export interface PreparedSave extends SaveRequest { + /** What the request submits, for the tracker's three-way rebase. */ + projection: EditablePostProjection; + payload: EditorWritePayload; + options: PostWriteOptions; + isCreate: boolean; +} + +export interface EditorSessionTransport { + create: (payload: EditorWritePayload) => Promise; + update: ( + payload: EditorWritePayload, + options: PostWriteOptions, + ) => Promise; + generateSlug: (text: string, postId: string | null) => Promise; +} + +export interface EditorSessionOptions { + record?: EditorRecord; + siteUrl?: string; + saveFailureMessage: string; + transport: EditorSessionTransport; + /** Called once the create acknowledges; the caller replaces the URL. */ + onIdAcquired: (id: string) => void; + onError: (error: unknown) => void; +} + +export interface EditorSession { + getState: () => SaveEngineState; + subscribe: (listener: () => void) => () => void; + isDirty: () => boolean; + patchTitle: (title: string) => void; + patchExcerpt: (excerpt: string) => void; + patchLexical: (lexical: unknown) => void; + setBaseline: (lexical: LexicalInput) => void; + baselineFailed: (error: unknown) => void; + commitTitle: (title: string) => void; + dispatchField: () => void; + dispatchAutosave: () => void; + dispatchExplicit: () => Promise; + recordRefetched: (record: EditorRecord) => void; + reauthSucceeded: () => void; + reauthAbandoned: () => void; + dispose: () => void; +} + +function isOlder(candidate: string, held: string | null): boolean { + if (!held) { + return false; + } + const candidateTime = Date.parse(candidate); + const heldTime = Date.parse(held); + return !Number.isNaN(candidateTime) && !Number.isNaN(heldTime) && candidateTime < heldTime; +} + +/** + * Composes the change tracker, slug machine and save engine into one editing + * session: one per opened post, never shared between two new posts. + */ +export function createEditorSession({ + record, + siteUrl, + saveFailureMessage, + transport, + onIdAcquired, + onError, +}: EditorSessionOptions): EditorSession { + let identity: PersistedIdentity = record + ? { id: record.id, updatedAt: record.updated_at ?? '' } + : { id: null, updatedAt: null }; + let status: PostStatus = record?.status ?? 'draft'; + let publishedAt: string | null = record?.published_at ?? null; + let live: EditablePostProjection = record ? projectionOf(record) : newPostProjection(); + let latestRevision: RevisionProjection | null = latestRevisionOf(record); + let version = 0; + + const tracker = createChangeTracker({ siteUrl }); + tracker.load(identity.id, live); + + const machine = createSlugMachine({ + generateSlug: (text) => transport.generateSlug(text, identity.id), + onListenerError: onError, + }); + machine.loaded({ slug: live.slug, title: live.title }); + + const slug = createSlugPort(machine); + + function patchLive(patch: Partial): void { + live = { ...live, ...patch }; + version += 1; + tracker.setLive(identity.id, patch); + } + + function getSnapshot(): EditorSaveSnapshot { + return buildSaveSnapshot({ + identity, + status, + publishedAt, + title: live.title, + slug: machine.getState().slug, + slugIsCustom: machine.getState().mode === 'custom', + verdict: tracker.verdict(), + changedSinceLastRevision: tracker.hasChangedSinceRevision(latestRevision), + version, + }); + } + + function prepare(request: SaveRequest): Promise { + const isCreate = request.snapshot.id === null; + const projection: EditablePostProjection = { + ...live, + title: request.title, + slug: request.slug, + updated_at: request.snapshot.updatedAt, + }; + + const payload: EditorWritePayload = { + title: projection.title, + slug: projection.slug, + lexical: projection.lexical, + custom_excerpt: projection.custom_excerpt, + feature_image: projection.feature_image, + feature_image_alt: projection.feature_image_alt, + feature_image_caption: projection.feature_image_caption, + tags: projection.tags, + status: request.target.status, + published_at: request.target.publishedAt, + }; + if (!isCreate) { + payload.id = request.snapshot.id; + payload.updated_at = projection.updated_at; + } + if (request.target.emailOnly !== undefined) { + payload.email_only = request.target.emailOnly; + } + + return Promise.resolve({ + ...request, + projection, + payload, + options: { + saveRevision: request.saveRevision, + newsletter: request.target.newsletter, + emailSegment: request.target.emailSegment, + }, + isCreate, + }); + } + + async function execute(prepared: PreparedSave): Promise> { + try { + const saved = prepared.isCreate + ? await transport.create(prepared.payload) + : await transport.update(prepared.payload, prepared.options); + + if (!saved) { + return { ok: false, error: { kind: 'unknown', message: saveFailureMessage } }; + } + + return { + ok: true, + result: { + id: saved.id, + status: saved.status ?? 'draft', + updatedAt: saved.updated_at ?? '', + post: saved, + }, + }; + } catch (error) { + return { ok: false, error: toSaveError(error, saveFailureMessage) }; + } + } + + function reconcile(prepared: PreparedSave, result: EditorSaveResult): void { + const acknowledged = projectionOf(result.post); + tracker.saveAcknowledged(result.id, prepared.projection, acknowledged); + + const created = identity.id === null; + identity = { id: result.id, updatedAt: result.updatedAt }; + status = result.status; + publishedAt = result.post.published_at ?? null; + latestRevision = latestRevisionOf(result.post); + live = { ...live, slug: acknowledged.slug, updated_at: result.updatedAt }; + + if (created) { + onIdAcquired(result.id); + } + } + + const engine = createSaveEngine({ + getSnapshot, + slug: slug.port, + prepare, + execute, + reconcile, + onStateChange: (next) => { + if (next.kind === 'error' || next.kind === 'conflict') { + tracker.markSaveError(next.error.message); + } + }, + onListenerError: onError, + }); + + return { + getState: () => engine.getState(), + subscribe: (listener) => engine.subscribe(listener), + isDirty: () => tracker.verdict().dirty, + + // A blank title persists as the default, so the live projection carries it + // even while the input stays empty. + patchTitle: (title) => patchLive({ title: title.trim() ? title : DEFAULT_TITLE }), + patchExcerpt: (excerpt) => patchLive({ custom_excerpt: excerpt === '' ? null : excerpt }), + patchLexical: (lexical) => patchLive({ lexical: JSON.stringify(lexical) }), + setBaseline: (lexical) => tracker.setBaseline(identity.id, lexical), + baselineFailed: (error) => tracker.baselineFailed(identity.id, error), + + // Only a draft's title drives the slug; a published URL must not move. + commitTitle: (title) => { + if (status === 'draft') { + slug.commitTitle(title); + } + }, + dispatchField: () => void engine.dispatch('field'), + dispatchAutosave: () => void engine.dispatch('autosave'), + dispatchExplicit: () => engine.dispatch('explicit'), + + recordRefetched: (next) => { + if (identity.id !== next.id) { + return; + } + tracker.setSaved(next.id, projectionOf(next)); + const updatedAt = next.updated_at ?? ''; + if (isOlder(updatedAt, identity.updatedAt)) { + return; + } + identity = { id: next.id, updatedAt }; + status = next.status ?? status; + publishedAt = next.published_at ?? null; + latestRevision = latestRevisionOf(next); + }, + + reauthSucceeded: () => engine.reauthSucceeded(), + reauthAbandoned: () => engine.reauthAbandoned(), + + dispose: () => { + engine.dispose(); + tracker.dispose(); + }, + }; +} diff --git a/apps/admin/src/editor/session/error-mapping.test.ts b/apps/admin/src/editor/session/error-mapping.test.ts new file mode 100644 index 00000000000..5af704ca2f9 --- /dev/null +++ b/apps/admin/src/editor/session/error-mapping.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + APIError, + HostLimitError, + JSONError, + MaintenanceError, + ServerUnreachableError, + SessionExpiredError, + TimeoutError, + UnauthorizedError, + ValidationError, + type ErrorResponse, +} from '@tryghost/admin-x-framework/errors'; +import { toSaveError } from './error-mapping'; + +function errorBody(overrides: Partial = {}): ErrorResponse { + return { + errors: [ + { + code: '', + context: null, + details: null, + ghostErrorCode: null, + help: '', + id: 'id', + message: 'Saving failed.', + property: null, + type: 'InternalServerError', + ...overrides, + }, + ], + }; +} + +function response(status: number): Response { + return new Response(null, { status }); +} + +describe('toSaveError', () => { + it.each<[string, unknown, string]>([ + [ + 'a collision', + new JSONError(response(409), errorBody({ code: 'UPDATE_COLLISION' })), + 'conflict', + ], + ['an expired session', new SessionExpiredError(response(401), errorBody()), 'session-invalid'], + [ + 'an unauthorized response', + new UnauthorizedError(response(401), errorBody()), + 'session-invalid', + ], + ['a host limit', new HostLimitError(response(403), errorBody()), 'host-limit'], + ['an unreachable server', new ServerUnreachableError(), 'transport'], + ['maintenance', new MaintenanceError(response(503), ''), 'transport'], + ['a timeout', new TimeoutError(), 'transport'], + ['a validation failure', new ValidationError(response(422), errorBody()), 'validation'], + ['a missing post', new APIError(response(404)), 'not-found'], + ['an unprocessable body', new JSONError(response(422), errorBody()), 'validation'], + ['a server error', new JSONError(response(500), errorBody()), 'unknown'], + ['a thrown non-error', 'broken', 'unknown'], + ])('maps %s', (_label, error, kind) => { + expect(toSaveError(error, 'fallback').kind).toBe(kind); + }); + + it('keeps the fallback message when the failure carries none', () => { + expect(toSaveError({}, 'Could not save').message).toBe('Could not save'); + }); + + it('carries the cause for reporting', () => { + const error = new ServerUnreachableError(); + expect(toSaveError(error, 'fallback').cause).toBe(error); + }); + + it('reads a collision ahead of the status it arrives with', () => { + // Core answers 409 for UPDATE_COLLISION, which no framework error class claims. + const collision = new JSONError(response(409), errorBody({ code: 'UPDATE_COLLISION' })); + expect(toSaveError(collision, 'fallback').kind).toBe('conflict'); + }); +}); diff --git a/apps/admin/src/editor/session/error-mapping.ts b/apps/admin/src/editor/session/error-mapping.ts new file mode 100644 index 00000000000..994228fea20 --- /dev/null +++ b/apps/admin/src/editor/session/error-mapping.ts @@ -0,0 +1,65 @@ +import { + APIError, + HostLimitError, + JSONError, + MaintenanceError, + ServerUnreachableError, + SessionExpiredError, + TimeoutError, + UnauthorizedError, + ValidationError, +} from '@tryghost/admin-x-framework/errors'; +import type { SaveError } from '@/editor/engine/save-engine'; + +// @tryghost/bookshelf-collision rejects a stale updated_at with this code. +const COLLISION_CODE = 'UPDATE_COLLISION'; + +function apiErrorCode(error: unknown): string | undefined { + return error instanceof JSONError ? (error.data?.errors?.[0]?.code ?? undefined) : undefined; +} + +function status(error: unknown): number | undefined { + return error instanceof APIError ? error.response?.status : undefined; +} + +function messageOf(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +/** Maps a transport failure onto the save engine's error kinds. */ +export function toSaveError(error: unknown, fallback: string): SaveError { + const kind = ((): SaveError['kind'] => { + if (apiErrorCode(error) === COLLISION_CODE) { + return 'conflict'; + } + if (error instanceof SessionExpiredError || error instanceof UnauthorizedError) { + return 'session-invalid'; + } + if (error instanceof HostLimitError) { + return 'host-limit'; + } + if ( + error instanceof ServerUnreachableError || + error instanceof MaintenanceError || + error instanceof TimeoutError + ) { + return 'transport'; + } + if (error instanceof ValidationError) { + return 'validation'; + } + const code = status(error); + if (code === 401) { + return 'session-invalid'; + } + if (code === 404) { + return 'not-found'; + } + if (code === 422) { + return 'validation'; + } + return 'unknown'; + })(); + + return { kind, message: messageOf(error, fallback), cause: error }; +} diff --git a/apps/admin/src/editor/session/projection.ts b/apps/admin/src/editor/session/projection.ts new file mode 100644 index 00000000000..4f9727f954f --- /dev/null +++ b/apps/admin/src/editor/session/projection.ts @@ -0,0 +1,57 @@ +import type { PageEditorRecord } from '@tryghost/admin-x-framework/api/pages'; +import type { PostEditorRecord, PostRevision } from '@tryghost/admin-x-framework/api/posts'; +import type { EditablePostProjection, RevisionProjection } from '@/editor/engine/change-tracker'; + +export type EditorRecord = PostEditorRecord | PageEditorRecord; + +export function newPostProjection(): EditablePostProjection { + return { + title: '', + slug: '', + lexical: null, + tags: [], + custom_excerpt: null, + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + updated_at: null, + }; +} + +export function projectionOf(record: EditorRecord): EditablePostProjection { + return { + title: record.title, + slug: record.slug, + lexical: record.lexical ?? null, + tags: record.tags ?? [], + custom_excerpt: record.custom_excerpt ?? null, + feature_image: record.feature_image ?? null, + feature_image_alt: record.feature_image_alt ?? null, + feature_image_caption: record.feature_image_caption ?? null, + updated_at: record.updated_at, + }; +} + +function revisionTime(revision: PostRevision): number { + const time = Date.parse(revision.created_at ?? ''); + return Number.isNaN(time) ? 0 : time; +} + +/** The newest revision the server sent, or null when the record carries none. */ +export function latestRevisionOf(record: EditorRecord | undefined): RevisionProjection | null { + const revisions = record?.post_revisions ?? []; + if (revisions.length === 0) { + return null; + } + + const latest = revisions.reduce((newest, revision) => + revisionTime(revision) >= revisionTime(newest) ? revision : newest, + ); + + return { + lexical: latest.lexical ?? null, + title: latest.title ?? '', + custom_excerpt: latest.custom_excerpt ?? null, + feature_image: latest.feature_image ?? null, + }; +} diff --git a/apps/admin/src/editor/session/session-banners.tsx b/apps/admin/src/editor/session/session-banners.tsx new file mode 100644 index 00000000000..06d6f7a3acc --- /dev/null +++ b/apps/admin/src/editor/session/session-banners.tsx @@ -0,0 +1,60 @@ +import { Banner, Button } from '@tryghost/shade/components'; +import { Inline, Text } from '@tryghost/shade/primitives'; +import type { SaveEngineState } from '@/editor/engine/save-engine'; + +export interface SessionBannersProps { + state: SaveEngineState; + onRetryReauth: () => void; + onDismissReauth: () => void; +} + +export function SessionBanners({ state, onRetryReauth, onDismissReauth }: SessionBannersProps) { + if (state.kind === 'reauth-pending') { + return ( + + + Your session expired. Sign in again in a new tab, then retry. + + + + + ); + } + + if (state.kind === 'conflict') { + return ( + + + Someone else is editing this post + + + + ); + } + + return null; +} + +function reloadAfterConfirm(): void { + if (window.confirm('Reload to get the latest version? Unsaved changes will be lost.')) { + window.location.reload(); + } +} diff --git a/apps/admin/src/editor/session/slug-port.test.ts b/apps/admin/src/editor/session/slug-port.test.ts new file mode 100644 index 00000000000..b57219f64cc --- /dev/null +++ b/apps/admin/src/editor/session/slug-port.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { slugify } from '@tryghost/string'; +import { deferred } from '@/utils/deferred'; +import { createSlugMachine } from '@/editor/engine/slug-machine'; +import { createSlugPort } from './slug-port'; + +function harness(generateSlug = vi.fn((text: string) => Promise.resolve(slugify(text)))) { + const machine = createSlugMachine({ generateSlug, onListenerError: vi.fn() }); + machine.loaded({ slug: 'original', title: 'Original' }); + return { machine, generateSlug, ...createSlugPort(machine) }; +} + +const flush = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +// The engine's signal is only used to abandon work the session already dropped. +const signal = () => new AbortController().signal; + +describe('createSlugPort', () => { + it('resolves a generated proposal from the title', async () => { + const { port } = harness(); + + await expect(port.fromTitle('A New Title', null, signal())).resolves.toEqual({ + slug: 'a-new-title', + source: 'generated', + }); + }); + + it('reports a refused commit as unchanged', async () => { + const { port } = harness(); + + await expect(port.fromTitle('Original', null, signal())).resolves.toEqual({ + slug: 'original', + source: 'unchanged', + }); + }); + + it('answers a superseded commit with the slug the machine holds', async () => { + const active = deferred(); + const generateSlug = vi + .fn<(text: string) => Promise>() + .mockReturnValueOnce(active.promise) + .mockImplementation((text) => Promise.resolve(slugify(text))); + const { port, commitTitle, machine } = harness(generateSlug); + + commitTitle('First'); + const superseded = port.fromTitle('Second', null, signal()); + commitTitle('Third'); + active.resolve('first'); + + // The stale answer never proposes a slug of its own; the engine re-reads + // the snapshot instead. + await expect(superseded).resolves.toEqual({ + slug: machine.getState().slug, + source: 'unchanged', + }); + await port.settled(); + expect(machine.getState().slug).toBe('third'); + }); + + it('settles on the latest submission rather than the pending flag', async () => { + const active = deferred(); + const queued = deferred(); + const generateSlug = vi + .fn<(text: string) => Promise>() + .mockReturnValueOnce(active.promise) + .mockReturnValueOnce(queued.promise); + const { port, commitTitle, machine } = harness(generateSlug); + + commitTitle('First'); + commitTitle('Second'); + + let settled = false; + void port.settled().then(() => { + settled = true; + }); + + active.resolve('first'); + await flush(); + expect(settled).toBe(false); + expect(machine.getState().pending).toBe(true); + + queued.resolve('second'); + await flush(); + expect(settled).toBe(true); + expect(machine.getState().slug).toBe('second'); + }); + + it('settles immediately when nothing was submitted', async () => { + const { port } = harness(); + + await expect(port.settled()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/admin/src/editor/session/slug-port.ts b/apps/admin/src/editor/session/slug-port.ts new file mode 100644 index 00000000000..45ccf084a38 --- /dev/null +++ b/apps/admin/src/editor/session/slug-port.ts @@ -0,0 +1,43 @@ +import type { SlugMachine } from '@/editor/engine/slug-machine'; +import type { SlugPort, SlugProposal } from '@/editor/engine/save-engine'; + +export interface SlugPortAdapter { + port: SlugPort; + /** Commits a title without waiting for it; the save's `settled()` picks the work up. */ + commitTitle: (title: string) => void; +} + +/** + * Adapter over the slug machine. The machine's `pending` flag reads false + * between an active request and a queued one, so settling follows the + * submission promises instead. + */ +export function createSlugPort(machine: SlugMachine): SlugPortAdapter { + let latest: Promise = Promise.resolve(); + + function track(submission: Promise): Promise { + latest = submission.catch(() => undefined); + return submission; + } + + async function settled(): Promise { + let awaited; + do { + awaited = latest; + await awaited; + } while (awaited !== latest); + } + + async function fromTitle(title: string): Promise { + const proposal = await track(machine.titleCommitted(title)); + + return proposal.source === 'generated' + ? { slug: proposal.slug, source: 'generated' } + : { slug: machine.getState().slug, source: 'unchanged' }; + } + + return { + port: { settled, fromTitle }, + commitTitle: (title) => void track(machine.titleCommitted(title)), + }; +} diff --git a/apps/admin/src/editor/session/snapshot.test.ts b/apps/admin/src/editor/session/snapshot.test.ts new file mode 100644 index 00000000000..97c844e4308 --- /dev/null +++ b/apps/admin/src/editor/session/snapshot.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import type { ChangeVerdict } from '@/editor/engine/change-tracker'; +import { buildSaveSnapshot, type SnapshotSources } from './snapshot'; + +const clean: ChangeVerdict = { dirty: false, reasons: [] }; + +const titleDiverged: ChangeVerdict = { + dirty: true, + reasons: [{ code: 'POST_TITLE_DIVERGED', reason: 'title is different', context: {} }], +}; + +function sources(overrides: Partial = {}): SnapshotSources { + return { + identity: { id: 'abc', updatedAt: '2026-01-01T00:00:00.000Z' }, + status: 'draft', + publishedAt: null, + title: 'Hello', + slug: 'hello', + slugIsCustom: false, + verdict: clean, + changedSinceLastRevision: false, + version: 3, + ...overrides, + }; +} + +describe('buildSaveSnapshot', () => { + it('carries the persisted identity of a saved post', () => { + expect(buildSaveSnapshot(sources())).toMatchObject({ + id: 'abc', + updatedAt: '2026-01-01T00:00:00.000Z', + status: 'draft', + title: 'Hello', + slug: 'hello', + version: 3, + }); + }); + + it('pairs a null id with a null collision token', () => { + const snapshot = buildSaveSnapshot(sources({ identity: { id: null, updatedAt: null } })); + + expect(snapshot.id).toBeNull(); + expect(snapshot.updatedAt).toBeNull(); + }); + + it('takes dirtiness from the verdict', () => { + expect(buildSaveSnapshot(sources()).isDirty).toBe(false); + expect(buildSaveSnapshot(sources({ verdict: titleDiverged })).isDirty).toBe(true); + }); + + it('reads the title bit off the verdict reasons', () => { + expect(buildSaveSnapshot(sources()).titleDirty).toBe(false); + expect(buildSaveSnapshot(sources({ verdict: titleDiverged })).titleDirty).toBe(true); + }); + + it('reports the slug ownership and revision state it was given', () => { + const snapshot = buildSaveSnapshot( + sources({ slugIsCustom: true, changedSinceLastRevision: true, publishedAt: '2026-02-02' }), + ); + + expect(snapshot.slugIsCustom).toBe(true); + expect(snapshot.changedSinceLastRevision).toBe(true); + expect(snapshot.publishedAt).toBe('2026-02-02'); + }); +}); diff --git a/apps/admin/src/editor/session/snapshot.ts b/apps/admin/src/editor/session/snapshot.ts new file mode 100644 index 00000000000..f4ec99ad45f --- /dev/null +++ b/apps/admin/src/editor/session/snapshot.ts @@ -0,0 +1,47 @@ +import type { PersistedIdentity, PostStatus, SaveSnapshot } from '@/editor/engine/save-engine'; +import type { ChangeVerdict } from '@/editor/engine/change-tracker'; + +export type EditorSaveSnapshot = SaveSnapshot & { + titleDirty: boolean; + slugIsCustom: boolean; +}; + +export interface SnapshotSources { + identity: PersistedIdentity; + status: PostStatus; + publishedAt: string | null; + title: string; + slug: string; + slugIsCustom: boolean; + verdict: ChangeVerdict; + changedSinceLastRevision: boolean; + version: number; +} + +export function buildSaveSnapshot({ + identity, + status, + publishedAt, + title, + slug, + slugIsCustom, + verdict, + changedSinceLastRevision, + version, +}: SnapshotSources): EditorSaveSnapshot { + const editable = { + status, + publishedAt, + title, + slug, + slugIsCustom, + isDirty: verdict.dirty, + titleDirty: verdict.reasons.some((reason) => reason.code === 'POST_TITLE_DIVERGED'), + changedSinceLastRevision, + version, + }; + + return identity.id === null + ? { id: null, updatedAt: null, ...editable } + : { id: identity.id, updatedAt: identity.updatedAt, ...editable }; +} diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts new file mode 100644 index 00000000000..29171271ac9 --- /dev/null +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -0,0 +1,247 @@ +import * as Sentry from '@sentry/react'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useLocation, useNavigate } from '@tryghost/admin-x-framework'; +import { useGenerateSlug } from '@tryghost/admin-x-framework/api/slugs'; +import { useBrowseSite } from '@tryghost/admin-x-framework/api/site'; +import { + useAddPage, + useEditPage, + useEditorPage, + type PageEditableData, +} from '@tryghost/admin-x-framework/api/pages'; +import { + useAddPost, + useEditPost, + useEditorPost, + type PostEditableData, +} from '@tryghost/admin-x-framework/api/posts'; +import type { + CreateContentData, + EditContentData, +} from '@tryghost/admin-x-framework/api/content-types'; +import type { PostWriteOptions } from '@tryghost/admin-x-framework/api/post-contract'; +import { DEFAULT_TITLE, type SaveEngineState } from '@/editor/engine/save-engine'; +import type { LexicalInput } from '@/editor/engine/lexical-compare'; +import type { PostType } from '@/editor/card-config'; +import { createEditorSession, type EditorSession, type EditorWritePayload } from './editor-session'; +import type { EditorRecord } from './projection'; + +interface EditorSessionLocationState { + editorSession?: string; +} + +/** + * Identifies the editing session behind the current URL. A create replaces the + * URL and carries the key forward, so the same session survives the swap. + */ +export function useEditorSessionKey(): string { + const location = useLocation(); + const state = location.state as EditorSessionLocationState | null; + return state?.editorSession ?? location.key; +} + +export interface EditorSessionBinding { + title: string; + excerpt: string; + initialLexical: string | null; + onTitleChange: (title: string) => void; + onTitleBlur: () => void; + onExcerptChange: (excerpt: string) => void; + onExcerptBlur: () => void; + onLexicalChange: (lexical: unknown) => void; + onSecondaryChange: (lexical: unknown) => void; + onSecondaryError: (error: unknown) => void; +} + +export interface EditorSessionHandle { + bind: EditorSessionBinding; + state: SaveEngineState; + isDirty: () => boolean; + dispatchExplicit: () => void; + reauthSucceeded: () => void; + reauthAbandoned: () => void; +} + +export interface UseEditorSessionOptions { + postType: PostType; + record?: EditorRecord; +} + +function reportError(error: unknown): void { + // eslint-disable-next-line no-console + console.error(error); + Sentry.captureException(error); +} + +export function useEditorSession({ + postType, + record, +}: UseEditorSessionOptions): EditorSessionHandle { + const navigate = useNavigate(); + const sessionKey = useEditorSessionKey(); + const generateSlug = useGenerateSlug(); + const { data: site } = useBrowseSite(); + const { mutateAsync: addPost } = useAddPost(); + const { mutateAsync: editPost } = useEditPost(); + const { mutateAsync: addPage } = useAddPage(); + const { mutateAsync: editPage } = useEditPage(); + + const [persistedId, setPersistedId] = useState(record?.id ?? null); + const [title, setTitle] = useState(() => + record?.title === DEFAULT_TITLE ? '' : (record?.title ?? ''), + ); + const [excerpt, setExcerpt] = useState(() => record?.custom_excerpt ?? ''); + const [initialLexical] = useState(() => record?.lexical ?? null); + + const transport = useRef({ addPost, editPost, addPage, editPage, generateSlug, postType }); + useEffect(() => { + transport.current = { addPost, editPost, addPage, editPage, generateSlug, postType }; + }); + + const [session] = useState(() => + createEditorSession({ + record, + siteUrl: site?.site.url, + saveFailureMessage: `Couldn’t save this ${postType}.`, + onIdAcquired: setPersistedId, + onError: reportError, + transport: { + create: async (payload: EditorWritePayload) => { + const current = transport.current; + if (current.postType === 'page') { + const { pages } = await current.addPage({ + page: payload as CreateContentData, + sessionExpiryRedirect: false, + }); + return pages[0]; + } + const { posts } = await current.addPost({ + post: payload as CreateContentData, + sessionExpiryRedirect: false, + }); + return posts[0]; + }, + update: async (payload: EditorWritePayload, options: PostWriteOptions) => { + const current = transport.current; + if (current.postType === 'page') { + const { pages } = await current.editPage({ + page: payload as EditContentData, + options, + sessionExpiryRedirect: false, + }); + return pages[0]; + } + const { posts } = await current.editPost({ + post: payload as EditContentData, + options, + sessionExpiryRedirect: false, + }); + return posts[0]; + }, + generateSlug: (text, postId) => + transport.current.generateSlug({ type: 'post', text, id: postId ?? undefined }), + }, + }), + ); + + // Disposal is deferred by a tick: StrictMode tears an effect down and sets it + // up again in the same commit, and that must not dispose a live session. + const pendingDispose = useRef>(undefined); + useEffect(() => { + clearTimeout(pendingDispose.current); + return () => { + pendingDispose.current = setTimeout(() => session.dispose()); + }; + }, [session]); + + const state = useSyncExternalStore(session.subscribe, session.getState); + + // The saved record: the same query key the screen loaded with, so an existing + // post shares one cache entry and a created one starts observing its own. + const postQuery = useEditorPost(persistedId ?? '', { + enabled: postType === 'post' && !!persistedId, + defaultErrorHandler: false, + }); + const pageQuery = useEditorPage(persistedId ?? '', { + enabled: postType === 'page' && !!persistedId, + defaultErrorHandler: false, + }); + const saved = postType === 'page' ? pageQuery.data?.pages[0] : postQuery.data?.posts[0]; + + useEffect(() => { + if (saved) { + session.recordRefetched(saved); + } + }, [saved, session]); + + const isNew = !record; + useEffect(() => { + if (isNew && persistedId) { + navigate(`/editor/${postType}/${persistedId}`, { + replace: true, + state: { editorSession: sessionKey }, + }); + } + }, [isNew, persistedId, postType, navigate, sessionKey]); + + const onTitleChange = useCallback( + (next: string) => { + setTitle(next); + session.patchTitle(next); + }, + [session], + ); + + const onExcerptChange = useCallback( + (next: string) => { + setExcerpt(next); + session.patchExcerpt(next); + }, + [session], + ); + + const onTitleBlur = useCallback(() => { + session.commitTitle(title); + session.dispatchField(); + }, [session, title]); + + const onExcerptBlur = useCallback(() => session.dispatchField(), [session]); + + const onLexicalChange = useCallback( + (lexical: unknown) => { + session.patchLexical(lexical); + session.dispatchAutosave(); + }, + [session], + ); + + const onSecondaryChange = useCallback( + (lexical: unknown) => session.setBaseline(lexical as LexicalInput), + [session], + ); + + const onSecondaryError = useCallback( + (error: unknown) => session.baselineFailed(error), + [session], + ); + + return { + bind: { + title, + excerpt, + initialLexical, + onTitleChange, + onTitleBlur, + onExcerptChange, + onExcerptBlur, + onLexicalChange, + onSecondaryChange, + onSecondaryError, + }, + state, + isDirty: session.isDirty, + dispatchExplicit: () => void session.dispatchExplicit(), + reauthSucceeded: session.reauthSucceeded, + reauthAbandoned: session.reauthAbandoned, + }; +} diff --git a/apps/admin/src/editor/tk.ts b/apps/admin/src/editor/tk.ts index 08f40bf9396..b9ad2b5b131 100644 --- a/apps/admin/src/editor/tk.ts +++ b/apps/admin/src/editor/tk.ts @@ -1,5 +1,4 @@ -// Ported from the Ember lexical-editor controller; matches Koenig's TK node -// detection for plain-text fields (title, excerpt). +// Matches Koenig's TK node detection for plain-text fields such as titles and excerpts. const TK_REGEX = /(^|.)([^\p{L}\p{N}\s]*(TK)+[^\p{L}\p{N}\s]*)(.)?/u; const WORD_CHAR_REGEX = /\p{L}|\p{N}/u; diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index c656e00d86b..9fe5a4eccdb 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -25,7 +25,7 @@ "lint": "pnpm run '/^lint:/'" }, "engines": { - "node": "^22.23.1" + "node": "^22.23.1 || ^24.20.0" }, "devDependencies": { "@babel/core": "7.29.7", diff --git a/docs/reference/node-compatibility.md b/docs/reference/node-compatibility.md index 80ed88df63f..9189a3715e8 100644 --- a/docs/reference/node-compatibility.md +++ b/docs/reference/node-compatibility.md @@ -56,6 +56,7 @@ Node.js or Ghost-CLI cell means that value did not change in that release. | 2026-04-13 | >= 6.29.0 | | `^1.29.1` | Bumped Ghost-CLI minimum | | 2026-06-19 | >= 6.46.0 | `^22.18.0` | | Bumped Node.js 22 minimum | | 2026-07-17 | >= 6.53.0 | `^22.23.1` | | Bumped Node.js 22 minimum | +| 2026-09-02 | >= 6.63.0 | `^22.23.1 \|\| ^24.20.0` | | Added Node.js 24 | ## Ghost-CLI Node.js compatibility diff --git a/ghost/core/package.json b/ghost/core/package.json index 73fca460717..0f94f90d28a 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -314,7 +314,7 @@ }, "engines": { "cli": "^1.29.1", - "node": "^22.23.1" + "node": "^22.23.1 || ^24.20.0" }, "nx": { "tags": [ diff --git a/package.json b/package.json index 87280488ace..04452de7a20 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ } }, "engines": { - "node": "^22.23.1" + "node": "^22.23.1 || ^24.20.0" }, "packageManager": "pnpm@12.2.1+sha512.f55ca68aacb9eb5ab69c66f828a98af89a32286703aef1f8da131ca7eab4d677f34bfa85e186467a919c0799df8b6f4aa240f7dc2919b33b3273190104162616", "monorepo": { diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index 2ab51e02b84..18db2898e80 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -11,6 +11,8 @@ export const editorBody = 'editor-body'; export const editorSecondaryInstance = 'editor-secondary-instance'; export const editorWordCount = 'editor-word-count'; export const editorLoadError = 'editor-load-error'; +export const editorReauthBanner = 'editor-reauth-banner'; +export const editorConflictBanner = 'editor-conflict-banner'; export const tkIndicator = 'tk-indicator'; // accessible names diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb830aaa62a..f501d210629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,9 @@ catalogs: markdownlint-cli2: specifier: 0.23.2 version: 0.23.2 + microdiff: + specifier: 1.5.0 + version: 1.5.0 mingo: specifier: 2.5.3 version: 2.5.3 @@ -948,6 +951,9 @@ importers: lucide-react: specifier: 'catalog:' version: 1.23.0(react@18.3.1) + microdiff: + specifier: 'catalog:' + version: 1.5.0 mingo: specifier: 'catalog:' version: 2.5.3 @@ -1130,6 +1136,9 @@ importers: '@tryghost/shade': specifier: workspace:* version: link:../shade + '@tryghost/string': + specifier: 'catalog:' + version: 0.3.5 bson-objectid: specifier: 'catalog:' version: 2.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb41f64c980..bc2b0d600af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -150,6 +150,7 @@ catalog: knex-migrator: 5.4.1 lodash: 4.18.1 lucide-react: 1.23.0 + microdiff: 1.5.0 mingo: 2.5.3 moment: 2.30.1 moment-timezone: 0.5.45