diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc2b3662bf..d00c9a93251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,9 +224,11 @@ jobs: - 'docker/tb-cli/**' - name: Define Node test matrix + # Node lines the unit / legacy / acceptance suites must keep passing on. + # test:unit's Nx cache is keyed on `node -v` (nx.json) so each leg runs. id: node_matrix run: | - echo 'matrix=["22.23.1"]' >> $GITHUB_OUTPUT + echo 'matrix=["22.23.1", "24.20.0"]' >> $GITHUB_OUTPUT - name: Start Nx Cloud CI run run: pnpm nx start-ci-run @@ -552,8 +554,9 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} trust-lockfile: 'true' - # --force for the same reason as job_unit-tests: better-sqlite3 is an - # optionalDependency and boot uses it for the development database. + # better-sqlite3 is an optionalDependency that pnpm may skip when + # restoring from a cached store, and boot needs it for the + # development database. install-args: --force - name: Install hyperfine @@ -679,6 +682,7 @@ jobs: needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.unit_test_projects_str != '' strategy: + fail-fast: false matrix: node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} name: Unit tests (Node ${{ matrix.node }}) @@ -690,11 +694,9 @@ jobs: with: node-version: ${{ matrix.node }} trust-lockfile: 'true' - # better-sqlite3 is an optionalDependency. Without --force, pnpm may skip - # installing/linking it when restoring from a cached store. --force - # ensures all optional deps are installed regardless. - # (ghost core's test:unit job requires better-sqlite3) - install-args: --force + # Without --no-runtime, pnpm links the devEngines Node and every + # leg of this matrix runs that same version. + install-args: --no-runtime - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 @@ -778,15 +780,24 @@ jobs: --health-interval=2s --health-timeout=5s --health-retries=60 + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} env: - COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled }} - name: Acceptance tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8) + # Only the primary Node leg is instrumented — the others cover the same + # code, and a second leg would collide on the e2e-coverage artifact name. + COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled == 'true' && matrix.node == needs.job_setup.outputs.node_version }} + name: Acceptance tests (Node ${{ matrix.node }}, mysql8) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: ./.github/actions/setup-node-pnpm with: - node-version: ${{ needs.job_setup.outputs.node_version }} + node-version: ${{ matrix.node }} trust-lockfile: 'true' + # Without --no-runtime, pnpm links the devEngines Node and every + # leg of this matrix runs that same version. + install-args: --no-runtime - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 @@ -878,15 +889,22 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=12 - name: Legacy tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8) + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} + name: Legacy tests (Node ${{ matrix.node }}, mysql8) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: true - uses: ./.github/actions/setup-node-pnpm with: - node-version: ${{ needs.job_setup.outputs.node_version }} + node-version: ${{ matrix.node }} trust-lockfile: 'true' + # Without --no-runtime, pnpm links the devEngines Node and every + # leg of this matrix runs that same version. + install-args: --no-runtime - name: Set env vars (MySQL) run: | @@ -1048,16 +1066,29 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_ghost-cli: - name: Ghost-CLI tests + name: Ghost-CLI tests (${{ matrix.scenario }}) 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: + 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. + - scenario: latest-release + node: '22.23.1' steps: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 env: FORCE_COLOR: 0 with: - node-version: ${{ env.NODE_VERSION }} + node-version: ${{ matrix.node }} - name: Install Ghost-CLI run: npm install -g ghost-cli@latest @@ -1079,10 +1110,11 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ghost-cli-debug-logs + name: ghost-cli-debug-logs-${{ matrix.scenario }} path: /home/runner/.ghost/logs/ - name: Clean Install + if: matrix.scenario == 'clean-install' run: | DIR=$(mktemp -d) ghost install local -d "$DIR" --archive "$(pwd)/ghost.tgz" @@ -1091,6 +1123,7 @@ jobs: ghost stop -d "$DIR" - name: Latest Release + if: matrix.scenario == 'latest-release' # --force skips Ghost-CLI's version comparison between the archive and # the installed release. Without it this step is coupled to how far the # branch has drifted from main: a PR branched before the last release diff --git a/apps/admin-x-framework/src/api/content-types.ts b/apps/admin-x-framework/src/api/content-types.ts new file mode 100644 index 00000000000..64c206100aa --- /dev/null +++ b/apps/admin-x-framework/src/api/content-types.ts @@ -0,0 +1,237 @@ +/** Shared Admin API type contracts for posts and pages. */ + +type Override = Omit & Changes; + +export type Email = { + opened_count: number; + email_count: number; + status?: string; + track_opens?: boolean; + track_clicks?: boolean; +}; + +// Every field is optional because list and analytics endpoints return different +// projections of these relations. +export type PostAuthor = { + id?: string; + name?: string; + email?: string; + slug?: string; + profile_image?: string | null; +}; + +export type PostTag = { + id?: string; + name?: string; + slug?: string; + visibility?: string; +}; + +export type PostStatus = 'published' | 'draft' | 'scheduled' | 'sent'; +export type PageStatus = Exclude; + +type AtLeastOne = { + [Key in keyof T]-?: Required> & Partial>; +}[keyof T]; + +export type PostAuthorInput = string | AtLeastOne<{ id: string; slug: string; email: string }>; + +export type PostTagInput = + | string + | ({ id: string } & Partial<{ name: string; slug: string | null }>) + | ({ name: string } & Partial<{ id: string; slug: string | null }>) + | ({ slug: string } & Partial<{ id: string; name: string }>); + +export type PostTierInput = { id: string }; + +export type PostTier = { + id: string; + name?: string; + slug?: string | null; +}; + +export type PostRevision = { + id?: string; + post_id?: string; + lexical?: string | null; + title?: string | null; + feature_image?: string | null; + feature_image_alt?: string | null; + feature_image_caption?: string | null; + custom_excerpt?: string | null; + post_status?: string | null; + reason?: string | null; + created_at?: string; + author?: PostAuthor | null; +}; + +/** Fields shared by post and page list responses. */ +export type ContentListFields = { + featured?: boolean; + updated_at?: string | null; + created_at?: string; + excerpt?: string | null; + custom_excerpt?: string | null; + authors?: PostAuthor[]; + primary_author?: PostAuthor | null; + tags?: PostTag[]; + primary_tag?: PostTag | null; + tiers?: object[]; +}; + +/** Fields shared by post and page editor responses. */ +export type ContentEditorFields = { + lexical?: string | null; + mobiledoc?: string | null; + meta_title?: string | null; + meta_description?: string | null; + canonical_url?: string | null; + custom_template?: string | null; + codeinjection_head?: string | null; + codeinjection_foot?: string | null; + og_image?: string | null; + og_title?: string | null; + og_description?: string | null; + twitter_image?: string | null; + twitter_title?: string | null; + twitter_description?: string | null; + feature_image_alt?: string | null; + feature_image_caption?: string | null; + post_revisions?: PostRevision[]; +}; + +export type PostCount = { + clicks?: number; + conversions?: number; + signups?: number; + paid_conversions?: number; + positive_feedback?: number; + negative_feedback?: number; +}; + +export type PageCount = { + signups?: number; + paid_conversions?: number; +}; + +/** Broad response record shared by the posts and pages endpoints. */ +export type ContentRecord = { + id: string; + url: string; + slug: string; + title: string; + visibility?: string; + uuid?: string; + feature_image?: string | null; + published_at?: string | null; +} & ContentListFields & + ContentEditorFields; + +export type PostEmailFields = { + email?: Email | null; + email_subject?: string | null; + newsletter?: object | null; + email_only?: boolean; + email_segment?: string | null; +}; + +export type Post = Override< + ContentRecord, + { + uuid: string; + status?: PostStatus; + count?: PostCount; + } +> & + PostEmailFields; + +export type Page = Override< + ContentRecord, + { + status?: PageStatus; + count?: PageCount; + show_title_and_feature_image?: boolean; + } +>; + +// Write access stays opt-in: a new response field must not silently become +// writable. Pick supplies each allowed field's value type from ContentRecord. +type ContentWritableKey = + | 'title' + | 'slug' + | 'mobiledoc' + | 'lexical' + | 'feature_image' + | 'feature_image_alt' + | 'feature_image_caption' + | 'featured' + | 'meta_title' + | 'meta_description' + | 'updated_at' + | 'published_at' + | 'custom_excerpt' + | 'codeinjection_head' + | 'codeinjection_foot' + | 'og_image' + | 'og_title' + | 'og_description' + | 'twitter_image' + | 'twitter_title' + | 'twitter_description' + | 'custom_template' + | 'canonical_url'; + +type ContentEditableScalars = Partial>; + +/** Shared post/page input, including fields whose input shape differs from output. */ +export type ContentEditableData = Override< + ContentEditableScalars, + { + html?: string | null; + locale?: string | null; + // The serializer treats null visibility as "leave visibility unchanged". + visibility?: string | null; + visibility_filter?: string | null; + authors?: PostAuthorInput[]; + tags?: PostTagInput[]; + tiers?: PostTierInput[]; + } +>; + +export type PostEditableData = ContentEditableData & + Partial>; + +export type PageEditableData = ContentEditableData & + Partial>; + +type EditorRelations = { + updated_at: string | null; + authors?: Array; + tags?: Array; + tiers?: PostTier[]; +}; + +/** A single editor response has the relations required for a safe round-trip edit. */ +export type EditorRecord = Override; + +export type PostEditorRecord = EditorRecord; +export type PageEditorRecord = EditorRecord; + +export type CreateContentData = Override; + +export type EditContentData = Override< + Data, + { id: string; updated_at: string | null } +>; + +export type PostBulkAction = + | { type: 'feature' } + | { type: 'unfeature' } + | { type: 'unpublish' } + | { type: 'unschedule' } + | { type: 'addTag'; meta: { tags: { id?: string; name: string; slug?: string }[] } } + | { type: 'access'; meta: { visibility: string; tiers?: { id: string }[] } }; + +// Compatibility aliases for existing imports from api/posts. +export type PostListFields = ContentListFields; +export type PostEditorFields = ContentEditorFields & Pick; diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts index 33ce11511dc..d8e3a649224 100644 --- a/apps/admin-x-framework/src/api/pages.ts +++ b/apps/admin-x-framework/src/api/pages.ts @@ -1,35 +1,40 @@ import { InfiniteData } from '@tanstack/react-query'; -import { Meta, createInfiniteQuery, createMutation, createQuery } from '../utils/api/hooks'; -import type { Email, PostBulkAction, PostListFields } from './posts'; - -// A page is a post with `displayName: 'page'` server-side, so the list screens -// read the same fields off both. -export type Page = { - id: string; - title: string; - slug: string; - url: string; - status?: string; - published_at?: string; - visibility?: string; - uuid?: string; - feature_image?: string; - email?: Email; - count?: { - clicks?: number; - }; - // Pages are never emailed, but the list reads these off both resources - // through one type, so they have to be addressable here too. - email_only?: boolean; - email_segment?: string; - newsletter?: object; -} & PostListFields; +import { + Meta, + createInfiniteQuery, + createMutation, + createQuery, + createQueryWithId, +} from '../utils/api/hooks'; +import { + PageWriteOptions, + PostCreateOptions, + buildPostEditorReadParams, + buildPageWriteParams, + buildPostReadParams, + serializePostPayload, +} from './post-contract'; +import type { + CreateContentData, + EditContentData, + Page, + PageEditableData, + PageEditorRecord, + PostBulkAction, +} from './content-types'; + +export type { Page, PageEditableData, PageEditorRecord, PageStatus } from './content-types'; export interface PagesResponseType { meta?: Meta; pages: Page[]; } +export interface PageResponseType { + meta?: Meta; + pages: PageEditorRecord[]; +} + const dataType = 'PagesResponseType'; export const useBrowsePages = createQuery({ @@ -63,6 +68,63 @@ export const useBrowsePagesInfinite = createInfiniteQuery({ + dataType, + path: (id) => `/pages/${id}/`, +}); + +export const usePage = (id: string, options: Parameters[1] = {}) => { + const { searchParams, ...queryOptions } = options; + return usePageQuery(id, { + ...queryOptions, + searchParams: { ...buildPostReadParams(), ...searchParams }, + }); +}; + +const useEditorPageQuery = createQueryWithId({ + dataType, + path: (id) => `/pages/${id}/`, +}); + +export const useEditorPage = ( + id: string, + options: Parameters[1] = {}, +) => { + const { searchParams, ...queryOptions } = options; + return useEditorPageQuery(id, { + ...queryOptions, + searchParams: { ...searchParams, ...buildPostEditorReadParams() }, + }); +}; + +// The create endpoint only accepts include/formats/source - revision and +// email delivery options are update-only +export interface AddPagePayload { + page: CreateContentData; + options?: PostCreateOptions; +} + +export interface EditPagePayload { + page: EditContentData; + options?: PageWriteOptions; +} + +export const useAddPage = createMutation({ + method: 'POST', + path: () => '/pages/', + searchParams: ({ options }) => buildPageWriteParams(options), + body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), + invalidateQueries: { dataType }, +}); + +export const useEditPage = createMutation({ + method: 'PUT', + path: ({ page }) => `/pages/${page.id}/`, + searchParams: ({ options }) => buildPageWriteParams(options), + body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), + invalidateQueries: { dataType }, +}); + /** Duplicate a page. As with posts, the copy is always a draft. */ export const useCopyPage = createMutation({ method: 'POST', diff --git a/apps/admin-x-framework/src/api/post-contract.ts b/apps/admin-x-framework/src/api/post-contract.ts new file mode 100644 index 00000000000..ee7050b6a81 --- /dev/null +++ b/apps/admin-x-framework/src/api/post-contract.ts @@ -0,0 +1,170 @@ +/** + * Request contract for post/page reads and writes against the Admin API. + * + * The Ember editor's adapters and serializers are the spec here — these + * builders must produce the same query params and payload shapes as + * `apps/ember-admin/app/adapters/post.js`, `adapters/page.js` and + * `serializers/post.js`/`page.js` so the API sees identical requests from + * either client. + */ + +/** + * Posts/pages include all relations by default on reads, but create/update + * responses only include what is explicitly requested — so writes re-request + * everything, including `post_revisions` for the client-side revision compare. + */ +export const ALL_POST_INCLUDES = [ + 'tags', + 'authors', + 'authors.roles', + 'email', + 'tiers', + 'newsletter', + 'count.clicks', + 'post_revisions', + 'post_revisions.author', +].join(','); + +/** Every post/page request asks for both content formats. */ +export const POST_FORMATS = 'mobiledoc,lexical'; + +// The publish flow's "everyone" segment; the API expects it spelled `all` +const ALL_MEMBERS_SEGMENT = 'status:free,status:-free'; + +export interface PostCreateOptions { + /** Convert an HTML payload to lexical content. */ + source?: 'html'; +} + +export interface PageWriteOptions extends PostCreateOptions { + /** Force the server to store a post revision (explicit save, leaving the editor). */ + saveRevision?: boolean; + /** Ask the server to convert the record's mobiledoc content to lexical. */ + convertToLexical?: boolean; +} + +export interface PostWriteOptions extends PageWriteOptions { + /** Newsletter slug. Presence means "email this publish". */ + newsletter?: string; + /** NQL member filter for the email. Only sent alongside `newsletter`. */ + emailSegment?: string; +} + +export function buildPostReadParams(): Record { + return { formats: POST_FORMATS }; +} + +/** Editor reads additionally need revision history and its author relation. */ +export function buildPostEditorReadParams(): Record { + return { formats: POST_FORMATS, include: ALL_POST_INCLUDES }; +} + +/** Query params for post create/update requests. */ +export function buildPostWriteParams(options: PostWriteOptions = {}): Record { + const params: Record = { formats: POST_FORMATS }; + + if (options.source) { + params.source = options.source; + } + + if (options.newsletter) { + params.newsletter = options.newsletter; + + if (options.emailSegment) { + params.email_segment = + options.emailSegment === ALL_MEMBERS_SEGMENT ? 'all' : options.emailSegment; + } + } + + if (options.saveRevision) { + params.save_revision = 'true'; + } + + if (options.convertToLexical) { + params.convert_to_lexical = 'true'; + } + + params.include = ALL_POST_INCLUDES; + + return params; +} + +/** Query params for page create/update requests — as posts, minus email delivery. */ +export function buildPageWriteParams(options: PageWriteOptions = {}): Record { + const params: Record = { formats: POST_FORMATS }; + + if (options.source) { + params.source = options.source; + } + + if (options.saveRevision) { + params.save_revision = 'true'; + } + + if (options.convertToLexical) { + params.convert_to_lexical = 'true'; + } + + params.include = ALL_POST_INCLUDES; + + return params; +} + +// Read-only/virtual fields the API must not receive back on writes +const READ_ONLY_POST_FIELDS = [ + 'author_id', + 'uuid', + 'url', + 'send_email_when_published', + 'email_recipient_filter', + 'email', + 'newsletter', + 'post_revisions', + // deprecated single-author field, replaced by `authors` + 'author', +] as const; + +// Pages additionally never send email fields +const POST_ONLY_FIELDS = ['email_subject', 'email_only', 'email_id'] as const; + +/** + * Shape an editable post/page into the exact payload the API expects: strips + * read-only and virtual fields and resolves the visibility/tiers coupling + * (tiers only accompany `visibility: 'tiers'`, and an empty tiers selection + * means "leave visibility unchanged"). + */ +export function serializePostPayload( + data: object, + resource: 'post' | 'page' = 'post', +): Record { + const json: Record = { ...data }; + + for (const field of READ_ONLY_POST_FIELDS) { + delete json[field]; + } + + if (resource === 'page') { + for (const field of POST_ONLY_FIELDS) { + delete json[field]; + } + } else { + delete json.show_title_and_feature_image; + } + + if (json.visibility === null) { + delete json.visibility; + delete json.visibility_filter; + delete json.tiers; + } + + if (json.visibility === 'tiers') { + delete json.visibility_filter; + } + + if (json.visibility === 'tiers' && !(json.tiers as unknown[] | undefined)?.length) { + delete json.visibility; + delete json.tiers; + } + + return json; +} diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index e17bc6442a1..86c7702b54d 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -6,80 +6,51 @@ import { createQueryWithId, createMutation, } from '../utils/api/hooks'; - -export type Email = { - opened_count: number; - email_count: number; - status?: string; - track_opens?: boolean; - track_clicks?: boolean; -}; - -// Every field optional: these are supertypes of the narrower author/tag shapes -// already declared around the analytics screens, so widening `Post` doesn't -// invalidate them. The list only reads names and slugs. -export type PostAuthor = { - id?: string; - name?: string; - email?: string; - slug?: string; -}; - -export type PostTag = { - id?: string; - name?: string; - slug?: string; - visibility?: string; -}; - -/** - * Fields the list screens need on top of the analytics-shaped core. All - * optional: the analytics endpoints don't return them, and the list gets them - * from the server's default relations rather than an explicit `include`. - */ -export type PostListFields = { - featured?: boolean; - updated_at?: string; - created_at?: string; - excerpt?: string; - custom_excerpt?: string; - authors?: PostAuthor[]; - primary_author?: PostAuthor | null; - tags?: PostTag[]; - primary_tag?: PostTag | null; - tiers?: object[]; -}; - -export type Post = { - id: string; - url: string; - slug: string; - title: string; - visibility?: string; - uuid: string; - feature_image?: string; - count?: { - clicks?: number; - positive_feedback?: number; - negative_feedback?: number; - }; - email?: Email; - status?: string; - published_at?: string; - newsletter_id?: string; - newsletter?: object; - email_only?: boolean; - email_segment?: string; - email_recipient_filter?: string; - send_email_when_published?: boolean; - email_stats?: object; -} & PostListFields; +import { + PostWriteOptions, + PostCreateOptions, + buildPostEditorReadParams, + buildPostReadParams, + buildPostWriteParams, + serializePostPayload, +} from './post-contract'; +import type { + CreateContentData, + EditContentData, + Post, + PostBulkAction, + PostEditableData, + PostEditorRecord, +} from './content-types'; + +export type { + Email, + Post, + PostAuthor, + PostAuthorInput, + PostBulkAction, + PostEditableData, + PostEditorFields, + PostEditorRecord, + PostListFields, + PostRevision, + PostStatus, + PostTag, + PostTagInput, + PostTier, + PostTierInput, +} from './content-types'; export interface PostsResponseType { meta?: Meta; posts: Post[]; } +export interface PostResponseType { + meta?: Meta; + posts: PostEditorRecord[]; +} + const dataType = 'PostsResponseType'; export const useBrowsePosts = createQuery({ @@ -113,24 +84,68 @@ export const useBrowsePostsInfinite = createInfiniteQuery({ +const usePostQuery = createQueryWithId({ + dataType, + path: (id) => `/posts/${id}/`, +}); + +export const usePost = (id: string, options: Parameters[1] = {}) => { + const { searchParams, ...queryOptions } = options; + return usePostQuery(id, { + ...queryOptions, + searchParams: { ...buildPostReadParams(), ...searchParams }, + }); +}; + +const useEditorPostQuery = createQueryWithId({ dataType, path: (id) => `/posts/${id}/`, }); +export const useEditorPost = ( + id: string, + options: Parameters[1] = {}, +) => { + const { searchParams, ...queryOptions } = options; + return useEditorPostQuery(id, { + ...queryOptions, + searchParams: { ...searchParams, ...buildPostEditorReadParams() }, + }); +}; + +// The create endpoint only accepts include/formats/source - revision and +// email delivery options are update-only +export interface AddPostPayload { + post: CreateContentData; + options?: PostCreateOptions; +} + +export interface EditPostPayload { + post: EditContentData; + options?: PostWriteOptions; +} + +export const useAddPost = createMutation({ + method: 'POST', + path: () => '/posts/', + searchParams: ({ options }) => buildPostWriteParams(options), + body: ({ post }) => ({ posts: [serializePostPayload(post)] }), + invalidateQueries: { dataType }, +}); + +export const useEditPost = createMutation({ + method: 'PUT', + path: ({ post }) => `/posts/${post.id}/`, + searchParams: ({ options }) => buildPostWriteParams(options), + body: ({ post }) => ({ posts: [serializePostPayload(post)] }), + invalidateQueries: { dataType }, +}); + export const useDeletePost = createMutation({ method: 'DELETE', path: (id) => `/posts/${id}/`, }); -export type PostBulkAction = - | { type: 'feature' } - | { type: 'unfeature' } - | { type: 'unpublish' } - | { type: 'unschedule' } - | { type: 'addTag'; meta: { tags: { id?: string; name: string; slug?: string }[] } } - | { type: 'access'; meta: { visibility: string; tiers?: { id: string }[] } }; - /** * Bulk-edit posts matching an NQL filter. * diff --git a/apps/admin-x-framework/src/api/snippets.ts b/apps/admin-x-framework/src/api/snippets.ts new file mode 100644 index 00000000000..5c0b2a976a3 --- /dev/null +++ b/apps/admin-x-framework/src/api/snippets.ts @@ -0,0 +1,66 @@ +import { Meta, createMutation, createQuery } from '../utils/api/hooks'; + +// mobiledoc and lexical travel as JSON strings on the wire; callers parse/stringify +export type Snippet = { + id: string; + name: string; + mobiledoc: string; + lexical: string | null; + created_at: string; + updated_at: string | null; +}; + +// The add and edit schemas require name and mobiledoc on every item +export type SnippetEditableData = Pick & + Partial>; + +export interface SnippetsResponseType { + meta?: Meta; + snippets: Snippet[]; +} + +const dataType = 'SnippetsResponseType'; + +// Without `formats` the API strips `lexical` from responses (mobiledoc is the default format) +const formats = 'mobiledoc,lexical'; + +const useBrowseSnippetsQuery = createQuery({ + dataType, + path: '/snippets/', + defaultSearchParams: { limit: 'all', formats }, +}); + +export const useBrowseSnippets = ({ + searchParams, + ...args +}: Parameters[0] = {}) => + useBrowseSnippetsQuery({ + ...args, + // caller searchParams replace the defaults wholesale, so re-merge formats + searchParams: { limit: 'all', ...searchParams, formats }, + }); + +export const useAddSnippet = createMutation({ + method: 'POST', + path: () => '/snippets/', + searchParams: () => ({ formats }), + body: (snippet) => ({ snippets: [snippet] }), + invalidateQueries: { dataType }, +}); + +export const useEditSnippet = createMutation< + SnippetsResponseType, + SnippetEditableData & { id: string } +>({ + method: 'PUT', + path: ({ id }) => `/snippets/${id}/`, + searchParams: () => ({ formats }), + body: ({ id: _id, ...snippet }) => ({ snippets: [snippet] }), + invalidateQueries: { dataType }, +}); + +export const useDeleteSnippet = createMutation({ + method: 'DELETE', + path: (id) => `/snippets/${id}/`, + invalidateQueries: { dataType }, +}); diff --git a/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts b/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts index 96972beeff1..fb2a93dfe41 100644 --- a/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts +++ b/apps/admin-x-framework/src/hooks/use-koenig-link-suggestions.ts @@ -69,7 +69,7 @@ export const useKoenigLinkSuggestions = ({ title: post.title, url: post.url, visibility: post.visibility, - publishedAt: post.published_at, + publishedAt: post.published_at ?? undefined, })); return [ diff --git a/apps/admin-x-framework/src/vite.ts b/apps/admin-x-framework/src/vite.ts index 7da2d5862f0..6240869bc6e 100644 --- a/apps/admin-x-framework/src/vite.ts +++ b/apps/admin-x-framework/src/vite.ts @@ -22,7 +22,10 @@ const externalPlugin = ({ externals }: { externals: Record }): P if (originalId) { const module = await import(originalId); + // Node 24 adds a literal `module.exports` key to a CJS namespace, which + // is not a valid identifier to re-export. return Object.keys(module) + .filter((key) => /^[A-Za-z_$][\w$]*$/.test(key)) .map((key) => key === 'default' ? `export default ${externalName};` diff --git a/apps/admin-x-framework/test/unit/api/pages.test.tsx b/apps/admin-x-framework/test/unit/api/pages.test.tsx new file mode 100644 index 00000000000..ec907df2cf8 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/pages.test.tsx @@ -0,0 +1,136 @@ +import { act, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { useAddPage, useEditPage, useEditorPage, usePage } from '../../../src/api/pages'; +import { withMockFetch } from '../../utils/mock-fetch'; + +// The Ember editor's exact include list — page writes re-request it too +const ALL_INCLUDES = + 'tags,authors,authors.roles,email,tiers,newsletter,count.clicks,post_revisions,post_revisions.author'; + +const requestUrl = (mock: any) => new URL(mock.calls[0][0] as string); + +const requestParams = (mock: any) => Object.fromEntries(requestUrl(mock).searchParams.entries()); + +const requestBody = (mock: any) => JSON.parse(mock.calls[0][1].body as string); + +describe('pages api', () => { + it('reads a single page', async () => { + await withMockFetch( + { + json: { + pages: [{ id: 'page-1', title: 'About', slug: 'about', url: '/about/' }], + // the permissions gate fetches the current user through the same mock + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => usePage('page-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const pagesCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/pages/'), + ); + const pagesUrl = new URL(pagesCall[0] as string); + expect(pagesUrl.pathname).toBe('/ghost/api/admin/pages/page-1/'); + expect(Object.fromEntries(pagesUrl.searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + }); + }, + ); + }); + + it('reads an editor page with revision history', async () => { + await withMockFetch( + { + json: { + pages: [{ id: 'page-1', title: 'About', slug: 'about', url: '/about/' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEditorPage('page-1', { + searchParams: { formats: 'html', include: 'tags' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const pagesCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/pages/page-1/'), + ); + expect(Object.fromEntries(new URL(pagesCall[0] as string).searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + }, + ); + }); + + it('creates a page through the pages endpoint with the write contract params', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useAddPage()); + + await act(async () => { + await result.current.mutateAsync({ + page: { title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/pages/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + pages: [{ title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }], + }); + }); + }); + + it('saves a page with a revision and without email delivery params', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPage()); + + await act(async () => { + await result.current.mutateAsync({ + page: { + id: 'page-1', + title: 'About', + status: 'draft', + lexical: '{"root":{}}', + show_title_and_feature_image: false, + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { saveRevision: true }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/pages/page-1/'); + expect(mock.calls[0][1].method).toBe('PUT'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + save_revision: 'true', + include: ALL_INCLUDES, + }); + // pages keep show_title_and_feature_image - it's a page-only field + expect(requestBody(mock)).toEqual({ + pages: [ + { + id: 'page-1', + title: 'About', + status: 'draft', + lexical: '{"root":{}}', + show_title_and_feature_image: false, + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/post-contract.test.ts b/apps/admin-x-framework/test/unit/api/post-contract.test.ts new file mode 100644 index 00000000000..28a7aec0fce --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/post-contract.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, it } from 'vitest'; +import type { + AddPagePayload, + EditPagePayload, + Page, + PageEditableData, + PageResponseType, +} from '../../../src/api/pages'; +import type { + AddPostPayload, + EditPostPayload, + Post, + PostEditableData, + PostResponseType, + PostRevision, +} from '../../../src/api/posts'; +import { + ALL_POST_INCLUDES, + POST_FORMATS, + buildPageWriteParams, + buildPostEditorReadParams, + buildPostReadParams, + buildPostWriteParams, + serializePostPayload, +} from '../../../src/api/post-contract'; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; + +type Assert = Condition; + +type ExpectedContentEditableKey = + | 'title' + | 'slug' + | 'mobiledoc' + | 'lexical' + | 'html' + | 'locale' + | 'feature_image' + | 'feature_image_alt' + | 'feature_image_caption' + | 'featured' + | 'meta_title' + | 'meta_description' + | 'updated_at' + | 'published_at' + | 'custom_excerpt' + | 'codeinjection_head' + | 'codeinjection_foot' + | 'og_image' + | 'og_title' + | 'og_description' + | 'twitter_image' + | 'twitter_title' + | 'twitter_description' + | 'custom_template' + | 'canonical_url' + | 'visibility' + | 'visibility_filter' + | 'authors' + | 'tags' + | 'tiers'; + +const sharedTypeAssertions: [ + Assert>, + Assert>, + Assert< + Equal< + keyof PostEditableData, + ExpectedContentEditableKey | 'status' | 'email_subject' | 'email_only' + > + >, + Assert< + Equal< + keyof PageEditableData, + ExpectedContentEditableKey | 'status' | 'show_title_and_feature_image' + > + >, +] = [true, true, true, true]; + +// Compile-time cases: the build failing is the assertion. Each `@ts-expect-error` +// fails the build if the request and response types drift from the Admin API schema. +const postEditWithoutUpdatedAt: EditPostPayload = { + // @ts-expect-error post edits require the collision token + post: { id: 'post-1' }, +}; + +const pageEditWithoutUpdatedAt: EditPagePayload = { + // @ts-expect-error page edits require the collision token + page: { id: 'page-1' }, +}; + +const postCreateWithoutTitle: AddPostPayload = { + // @ts-expect-error post creates require a title + post: { status: 'draft' }, +}; + +const pageCreateWithoutTitle: AddPagePayload = { + // @ts-expect-error page creates require a title + page: { status: 'draft' }, +}; + +// The API accepts updated_at on creates, even though clients normally omit it. +const postCreateWithUpdatedAt: AddPostPayload = { + post: { title: 'Hello', updated_at: '2026-01-01T00:00:00.000Z' }, +}; + +const pageCreateWithUpdatedAt: AddPagePayload = { + page: { title: 'About', updated_at: '2026-01-01T00:00:00.000Z' }, +}; + +const postReadResponse: PostResponseType = { + posts: [ + { + id: 'post-1', + uuid: 'uuid-1', + url: '/hello/', + slug: 'hello', + title: 'Hello', + updated_at: null, + }, + ], +}; + +const pageReadResponse: PageResponseType = { + pages: [ + { + id: 'page-1', + url: '/about/', + slug: 'about', + title: 'About', + updated_at: null, + }, + ], +}; + +const nullablePostReadResponse: PostResponseType = { + posts: [ + { + id: 'post-2', + uuid: 'uuid-2', + url: '/empty/', + slug: 'empty', + title: 'Empty', + updated_at: null, + published_at: null, + feature_image: null, + excerpt: null, + custom_excerpt: null, + email: null, + newsletter: null, + email_segment: null, + }, + ], +}; + +const postWithAllCounts: Post = { + id: 'post-3', + uuid: 'uuid-3', + url: '/counts/', + slug: 'counts', + title: 'Counts', + count: { + clicks: 1, + conversions: 2, + signups: 3, + paid_conversions: 4, + positive_feedback: 5, + negative_feedback: 6, + }, +}; + +const pageWithPageCounts: Page = { + id: 'page-2', + url: '/page-counts/', + slug: 'page-counts', + title: 'Page counts', + count: { signups: 1, paid_conversions: 2 }, +}; + +const pageWithPostCount: Page = { + id: 'page-3', + url: '/invalid-count/', + slug: 'invalid-count', + title: 'Invalid count', + count: { + // @ts-expect-error click counts are not exposed by the pages endpoint + clicks: 1, + }, +}; + +const postWithInputOnlyResponseField: Post = { + id: 'post-4', + uuid: 'uuid-4', + url: '/input-only/', + slug: 'input-only', + title: 'Input only', + // @ts-expect-error visibility_filter is accepted on writes but not emitted in responses + visibility_filter: 'status:paid', +}; + +// Single-resource reads can flow directly into their edit mutations. +const postEditFromRead: EditPostPayload = { post: postReadResponse.posts[0] }; +const pageEditFromRead: EditPagePayload = { page: pageReadResponse.pages[0] }; + +const revisionWithImageMetadata: PostRevision = { + feature_image_alt: 'A ghost sign', + feature_image_caption: 'Photo by Ghost', + author: { profile_image: '/content/images/author.png' }, +}; + +const schemaAlignedPostEdits: PostEditableData = { + html: '

Hello

', + feature_image: null, + published_at: null, + visibility: null, + locale: null, +}; + +const schemaAlignedPageEdits: AddPagePayload['page'] = { + title: 'About', + html: '

About

', + feature_image: null, + published_at: null, + visibility: null, + locale: null, +}; + +const schemaAlignedRelations: PostEditableData = { + authors: [{ email: 'author@example.com' }], + tags: [{ name: 'News' }, { id: 'tag-1', slug: null }], + tiers: [{ id: 'tier-1' }], +}; + +const postWithPageOnlyField: AddPostPayload = { + post: { + title: 'Hello', + // @ts-expect-error page presentation settings are never writable on posts + show_title_and_feature_image: false, + }, +}; + +const pageWithPostOnlyFields: AddPagePayload = { + page: { + title: 'About', + // @ts-expect-error pages are never sent as email-only posts + email_only: true, + }, +}; + +const pageWithEmailSubject: AddPagePayload = { + page: { + title: 'About', + // @ts-expect-error pages have no email subject + email_subject: 'About us', + }, +}; + +const sentPage: AddPagePayload = { + page: { + title: 'About', + // @ts-expect-error sent is a post-only status + status: 'sent', + }, +}; + +const postWithReadOnlyFields: AddPostPayload = { + post: { + title: 'Hello', + // @ts-expect-error response URLs are not writable + url: '/hello/', + }, +}; + +const postWithRevisions: AddPostPayload = { + post: { + title: 'Hello', + // @ts-expect-error revision history is not writable through the post payload + post_revisions: [], + }, +}; + +const invalidRelations: PostEditableData = { + authors: [ + // @ts-expect-error author objects require id, slug, or email + {}, + ], + tags: [ + // @ts-expect-error tag objects require id, name, or a non-null slug + { slug: null }, + ], + tiers: [ + // @ts-expect-error tier objects require an id + { name: 'Supporters' }, + // @ts-expect-error tier strings are discarded by the model relation handler + 'tier-slug', + ], +}; + +describe('post request contract', () => { + it('keeps the compile-time request and response contracts exercised', () => { + for (const value of [ + postEditWithoutUpdatedAt, + pageEditWithoutUpdatedAt, + postCreateWithoutTitle, + pageCreateWithoutTitle, + postCreateWithUpdatedAt, + pageCreateWithUpdatedAt, + postEditFromRead, + pageEditFromRead, + nullablePostReadResponse, + postWithAllCounts, + pageWithPageCounts, + pageWithPostCount, + postWithInputOnlyResponseField, + revisionWithImageMetadata, + schemaAlignedPostEdits, + schemaAlignedPageEdits, + schemaAlignedRelations, + invalidRelations, + postWithPageOnlyField, + pageWithPostOnlyFields, + pageWithEmailSubject, + sentPage, + postWithReadOnlyFields, + postWithRevisions, + sharedTypeAssertions, + ]) { + expect(value).toBeTruthy(); + } + }); + + describe('query params', () => { + it('requests both content formats on reads', () => { + expect(buildPostReadParams()).toEqual({ + formats: 'mobiledoc,lexical', + }); + expect(buildPostEditorReadParams()).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_POST_INCLUDES, + }); + }); + + it('re-requests the full include list on writes', () => { + expect(buildPostWriteParams()).toEqual({ + formats: POST_FORMATS, + include: + 'tags,authors,authors.roles,email,tiers,newsletter,count.clicks,post_revisions,post_revisions.author', + }); + }); + + it('adds save_revision and convert_to_lexical only when requested', () => { + expect(buildPostWriteParams({ saveRevision: true, convertToLexical: true })).toEqual({ + formats: POST_FORMATS, + save_revision: 'true', + convert_to_lexical: 'true', + include: ALL_POST_INCLUDES, + }); + + expect(buildPostWriteParams({ saveRevision: false, convertToLexical: false })).toEqual({ + formats: POST_FORMATS, + include: ALL_POST_INCLUDES, + }); + }); + + it('requests HTML-to-lexical conversion when sending an HTML source', () => { + expect(buildPostWriteParams({ source: 'html' })).toEqual({ + formats: POST_FORMATS, + source: 'html', + include: ALL_POST_INCLUDES, + }); + + expect(buildPageWriteParams({ source: 'html' })).toEqual({ + formats: POST_FORMATS, + source: 'html', + include: ALL_POST_INCLUDES, + }); + }); + + it('sends newsletter and email segment when emailing a publish', () => { + expect(buildPostWriteParams({ newsletter: 'weekly', emailSegment: 'status:free' })).toEqual({ + formats: POST_FORMATS, + newsletter: 'weekly', + email_segment: 'status:free', + include: ALL_POST_INCLUDES, + }); + }); + + it('rewrites the "everyone" segment to all', () => { + expect( + buildPostWriteParams({ newsletter: 'weekly', emailSegment: 'status:free,status:-free' }), + ).toEqual({ + formats: POST_FORMATS, + newsletter: 'weekly', + email_segment: 'all', + include: ALL_POST_INCLUDES, + }); + }); + + it('only sends an email segment alongside a newsletter', () => { + expect(buildPostWriteParams({ emailSegment: 'status:free' })).toEqual({ + formats: POST_FORMATS, + include: ALL_POST_INCLUDES, + }); + }); + + it('never sends email delivery params for pages', () => { + expect(buildPageWriteParams({ saveRevision: true })).toEqual({ + formats: POST_FORMATS, + save_revision: 'true', + include: ALL_POST_INCLUDES, + }); + }); + }); + + describe('payload shaping', () => { + it('strips read-only and virtual fields from post payloads', () => { + expect( + serializePostPayload({ + id: 'post-1', + title: 'Hello', + lexical: '{"root":{}}', + author_id: 'author-1', + author: { id: 'author-1' }, + uuid: 'uuid-1', + url: 'https://example.com/hello/', + send_email_when_published: true, + email_recipient_filter: 'all', + email: { email_count: 1 }, + newsletter: { id: 'newsletter-1' }, + post_revisions: [{ id: 'revision-1' }], + }), + ).toEqual({ + id: 'post-1', + title: 'Hello', + lexical: '{"root":{}}', + }); + }); + + it('strips the page-only title/feature-image toggle from post payloads but keeps it for pages', () => { + const data = { title: 'Hello', show_title_and_feature_image: false }; + + expect(serializePostPayload(data)).toEqual({ title: 'Hello' }); + expect(serializePostPayload(data, 'page')).toEqual(data); + }); + + it('strips email fields from page payloads', () => { + expect( + serializePostPayload( + { title: 'Hello', email_subject: 'Subject', email_only: false, email_id: 'email-1' }, + 'page', + ), + ).toEqual({ title: 'Hello' }); + }); + + it('drops visibility, filter and tiers when visibility is null', () => { + expect( + serializePostPayload({ + title: 'Hello', + visibility: null, + visibility_filter: 'label:vip', + tiers: [{ id: 'tier-1' }], + }), + ).toEqual({ title: 'Hello' }); + }); + + it('drops the visibility filter when visibility is tiers', () => { + expect( + serializePostPayload({ + title: 'Hello', + visibility: 'tiers', + visibility_filter: 'label:vip', + tiers: [{ id: 'tier-1' }], + }), + ).toEqual({ + title: 'Hello', + visibility: 'tiers', + tiers: [{ id: 'tier-1' }], + }); + }); + + it('treats tiers visibility without tiers as unchanged visibility', () => { + expect(serializePostPayload({ title: 'Hello', visibility: 'tiers', tiers: [] })).toEqual({ + title: 'Hello', + }); + }); + + it('keeps other visibility values untouched', () => { + expect(serializePostPayload({ title: 'Hello', visibility: 'members', tiers: [] })).toEqual({ + title: 'Hello', + visibility: 'members', + tiers: [], + }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/posts.test.tsx b/apps/admin-x-framework/test/unit/api/posts.test.tsx index dbe4ffd35cf..d32dae1e46f 100644 --- a/apps/admin-x-framework/test/unit/api/posts.test.tsx +++ b/apps/admin-x-framework/test/unit/api/posts.test.tsx @@ -1,10 +1,111 @@ -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; -import { useImportContentCSV } from '../../../src/api/posts'; +import { + useAddPost, + useEditPost, + useEditorPost, + useImportContentCSV, + usePost, +} from '../../../src/api/posts'; import { withMockFetch } from '../../utils/mock-fetch'; +// The Ember editor's exact include list — writes must re-request everything +const ALL_INCLUDES = + 'tags,authors,authors.roles,email,tiers,newsletter,count.clicks,post_revisions,post_revisions.author'; + +const requestUrl = (mock: any) => new URL(mock.calls[0][0] as string); + +const requestParams = (mock: any) => Object.fromEntries(requestUrl(mock).searchParams.entries()); + +const requestBody = (mock: any) => JSON.parse(mock.calls[0][1].body as string); + describe('posts api', () => { + it('reads a single post with both content formats', async () => { + await withMockFetch( + { + json: { + posts: [{ id: 'post-1', title: 'Hello', slug: 'hello', url: '/hello/' }], + // the permissions gate fetches the current user through the same mock + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => usePost('post-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const postsCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/posts/post-1/'), + ); + const postsUrl = new URL(postsCall[0] as string); + expect(postsUrl.pathname).toBe('/ghost/api/admin/posts/post-1/'); + expect(Object.fromEntries(postsUrl.searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + }); + }, + ); + }); + + it('reads an editor post with revision history', async () => { + await withMockFetch( + { + json: { + posts: [{ id: 'post-1', title: 'Hello', slug: 'hello', url: '/hello/' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEditorPost('post-1', { + searchParams: { formats: 'html', include: 'tags' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const postsCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/posts/post-1/'), + ); + expect(Object.fromEntries(new URL(postsCall[0] as string).searchParams.entries())).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + }, + ); + }); + + it('preserves explicit params on generic post reads', async () => { + await withMockFetch( + { + json: { + posts: [{ id: 'post-1', title: 'Hello', slug: 'hello', url: '/hello/' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + usePost('post-1', { + searchParams: { formats: 'html', include: 'count.positive_feedback' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const postsCall = mock.calls.find(([input]: [unknown]) => + String(input).includes('/posts/post-1/'), + ); + expect(Object.fromEntries(new URL(postsCall[0] as string).searchParams.entries())).toEqual({ + formats: 'html', + include: 'count.positive_feedback', + }); + }, + ); + }); + it('imports CSV content via the posts upload endpoint', async () => { const file = new File(['title\nHello'], 'posts.csv', { type: 'text/csv' }); @@ -56,6 +157,178 @@ describe('posts api', () => { } }); + it('creates a draft through the posts endpoint with the write contract params', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useAddPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/posts/'); + expect(mock.calls[0][1].method).toBe('POST'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + posts: [{ title: '(Untitled)', status: 'draft', lexical: '{"root":{}}' }], + }); + }); + }); + + it('autosaves a draft in the background without forcing a revision', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + title: 'Draft in progress', + status: 'draft', + lexical: '{"root":{}}', + updated_at: '2026-01-01T00:00:00.000Z', + }, + }); + }); + + expect(requestUrl(mock).pathname).toBe('/ghost/api/admin/posts/post-1/'); + expect(mock.calls[0][1].method).toBe('PUT'); + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + posts: [ + { + id: 'post-1', + title: 'Draft in progress', + status: 'draft', + lexical: '{"root":{}}', + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + }); + + it('forces a revision on explicit saves', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + title: 'Saved', + status: 'draft', + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { saveRevision: true }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + save_revision: 'true', + include: ALL_INCLUDES, + }); + }); + }); + + it('publishes to a newsletter segment and strips read-only fields from the payload', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + // read-only relations come back on fetched posts - they must never be sent back + const fetchedPost = { + id: 'post-1', + title: 'Published', + status: 'published' as const, + published_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + email: { email_count: 0, opened_count: 0 }, + newsletter: { id: 'newsletter-1' }, + post_revisions: [{ id: 'revision-1' }], + }; + + await act(async () => { + await result.current.mutateAsync({ + post: fetchedPost, + options: { newsletter: 'weekly', emailSegment: 'label:vip' }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + newsletter: 'weekly', + email_segment: 'label:vip', + include: ALL_INCLUDES, + }); + expect(requestBody(mock)).toEqual({ + posts: [ + { + id: 'post-1', + title: 'Published', + status: 'published', + published_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + }); + + it('publishes to everyone as the all segment', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + status: 'published', + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { newsletter: 'weekly', emailSegment: 'status:free,status:-free' }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + newsletter: 'weekly', + email_segment: 'all', + include: ALL_INCLUDES, + }); + }); + }); + + it('requests a mobiledoc conversion with convert_to_lexical', async () => { + await withMockFetch({}, async (mock) => { + const { result } = renderHookWithProviders(() => useEditPost()); + + await act(async () => { + await result.current.mutateAsync({ + post: { + id: 'post-1', + status: 'draft', + updated_at: '2026-01-01T00:00:00.000Z', + }, + options: { convertToLexical: true }, + }); + }); + + expect(requestParams(mock)).toEqual({ + formats: 'mobiledoc,lexical', + convert_to_lexical: 'true', + include: ALL_INCLUDES, + }); + }); + }); + it('uploads a mapped CSV ZIP through the posts endpoint', async () => { const file = new File(['PK'], 'posts.zip', { type: 'application/zip' }); diff --git a/apps/admin-x-framework/test/unit/api/snippets.test.tsx b/apps/admin-x-framework/test/unit/api/snippets.test.tsx new file mode 100644 index 00000000000..9024b925c54 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/snippets.test.tsx @@ -0,0 +1,183 @@ +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ValidationError } from '../../../src/utils/errors'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { + useAddSnippet, + useBrowseSnippets, + useDeleteSnippet, + useEditSnippet, +} from '../../../src/api/snippets'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const { mockSonnerError } = vi.hoisted(() => ({ + mockSonnerError: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { + error: mockSonnerError, + dismiss: vi.fn(), + }, +})); + +const existingSnippet = { + id: 'snippet-1', + name: 'Existing snippet', + mobiledoc: '{}', + lexical: '{"nodes":[]}', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', +}; + +const okResponse = (json: unknown) => ({ + json, + headers: { 'content-type': 'application/json' }, + ok: true, + status: 200, +}); + +const duplicateSnippetResponse = { + errors: [ + { + code: 'VALIDATION', + context: 'Snippet already exists.', + details: null, + ghostErrorCode: null, + help: null, + id: 'snippet-error-id', + message: 'Validation error, cannot save snippet.', + property: null, + type: 'ValidationError', + }, + ], +}; + +const mockErrorFetch = { + json: duplicateSnippetResponse, + headers: { 'content-type': 'application/json' }, + ok: false, + status: 422, +}; + +const findCall = (mock: { calls: unknown[][] }, path: string) => + mock.calls.find((call) => String(call[0]).includes(path)); + +describe('snippets api', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('browses all snippets in both formats', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useBrowseSnippets()); + + await waitFor(() => { + expect(result.current.data).toEqual({ snippets: [existingSnippet] }); + }); + + const call = findCall(mock, '/snippets/'); + const url = new URL(String(call![0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/'); + expect(url.searchParams.get('limit')).toBe('all'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + }); + }); + + it('keeps the formats param when a caller passes its own search params', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => + useBrowseSnippets({ searchParams: { filter: 'name:foo' } }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual({ snippets: [existingSnippet] }); + }); + + const url = new URL(String(findCall(mock, '/snippets/')![0])); + expect(url.searchParams.get('filter')).toBe('name:foo'); + expect(url.searchParams.get('limit')).toBe('all'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + }); + }); + + it('rejects duplicate creates with a validation error without reporting', async () => { + await withMockFetch(mockErrorFetch, async () => { + const { result } = renderHookWithProviders(() => useAddSnippet()); + + await act(async () => { + await expect( + result.current.mutateAsync({ name: 'Existing snippet', mobiledoc: '{}' }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + expect(mockSonnerError).not.toHaveBeenCalled(); + }); + }); + + it('adds a snippet and requests both formats back', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useAddSnippet()); + + await act(async () => { + await result.current.mutateAsync({ + name: 'Existing snippet', + lexical: '{"nodes":[]}', + mobiledoc: '{}', + }); + }); + + const call = findCall(mock, '/snippets/')!; + const url = new URL(String(call[0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + + const options = call[1] as RequestInit; + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body as string)).toEqual({ + snippets: [{ name: 'Existing snippet', lexical: '{"nodes":[]}', mobiledoc: '{}' }], + }); + }); + }); + + // The edit schema requires name and mobiledoc on every item, so edits send the full record + it('edits a snippet with the full record and requests both formats back', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useEditSnippet()); + + await act(async () => { + await result.current.mutateAsync({ + id: 'snippet-1', + name: 'Existing snippet', + mobiledoc: '{}', + lexical: '{"nodes":[]}', + }); + }); + + const call = findCall(mock, '/snippets/snippet-1/')!; + const url = new URL(String(call[0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/snippet-1/'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + + const options = call[1] as RequestInit; + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body as string)).toEqual({ + snippets: [{ name: 'Existing snippet', mobiledoc: '{}', lexical: '{"nodes":[]}' }], + }); + }); + }); + + it('deletes a snippet by id', async () => { + await withMockFetch({ ok: true, status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useDeleteSnippet()); + + await act(async () => { + await result.current.mutateAsync('snippet-1'); + }); + + const call = findCall(mock, '/snippets/snippet-1/')!; + expect(String(call[0])).toBe('http://localhost:3000/ghost/api/admin/snippets/snippet-1/'); + expect((call[1] as RequestInit).method).toBe('DELETE'); + }); + }); +}); diff --git a/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx b/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx index 1062c7bd3c1..257fc7a5cf2 100644 --- a/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx +++ b/apps/admin/src/analytics/views/stats/overview/components/latest-post.tsx @@ -88,7 +88,8 @@ const LatestPost: React.FC = ({ latestPostStats, isLoading }) = analytics: { webAnalytics, membersTrackSources }, }); const shouldGoToEditor = postDestination.startsWith('/editor/'); - // Editor destinations are still Ember-owned and need a hash navigation. + // Editor destinations need a hash navigation while Ember serves them + // (the `editorReact` flag decides which side does). const destinationIsEmberOwned = useIsEmberOwnedRoute(postDestination); return ( diff --git a/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx b/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx index 6ed62f1bfb1..40ba9654195 100644 --- a/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx +++ b/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx @@ -21,6 +21,7 @@ import { getPeriodText } from '@/shared/analytics/chart-helpers'; import { getPostDestination } from '@/analytics/utils/url-helpers'; import { getPostStatusText } from '@tryghost/admin-x-framework/utils/post-utils'; import { getSiteTimezone } from '@tryghost/admin-x-framework/utils/get-site-timezone'; +import { useIsEmberOwnedRoute } from '@/routes'; import { useNavigate } from '@tryghost/admin-x-framework'; import { useEmailTrackClicks, @@ -81,6 +82,9 @@ interface TopPostsProps { const TopPosts: React.FC = ({ topPostsData, isLoading }) => { const navigate = useNavigate(); + // Whether `/editor/*` needs a hash navigation depends on the `editorReact` + // flag; ownership is the same for every post, so one probe path suffices. + const editorIsEmberOwned = useIsEmberOwnedRoute('/editor'); const { range } = useAnalytics(); const { settings } = useAnalyticsData(); const paidMembersEnabled = usePaidMembersEnabled(); @@ -126,8 +130,9 @@ const TopPosts: React.FC = ({ topPostsData, isLoading }) => { membersTrackSources, }, }); - // `/editor/*` is still Ember-owned (EMBER_ROUTES) and needs a hash navigation. - navigate(destination, { crossApp: destination.startsWith('/editor/') }); + navigate(destination, { + crossApp: destination.startsWith('/editor/') && editorIsEmberOwned, + }); }} > {post.feature_image ? ( diff --git a/apps/admin/src/editor-gate.test.tsx b/apps/admin/src/editor-gate.test.tsx new file mode 100644 index 00000000000..95b56eda49f --- /dev/null +++ b/apps/admin/src/editor-gate.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { EditorGate } from './editor-gate'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +const { mockUseBrowseConfig } = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig, +})); + +// `useEmberFeatureFlag` is the ownership authority when Ember is present. +// Mirroring the real reader (window.EmberBridge, undefined without it) lets +// tests cover both the standalone config path and the integrated Ember path. +vi.mock('./ember-bridge', () => ({ + EmberFallback: () => React.createElement('div', { 'data-testid': 'ember-fallback' }), + useEmberFeatureFlag: (flag: string) => { + const stateBridge = window.EmberBridge?.state; + if (!stateBridge?.isFeatureEnabled) { + return undefined; + } + return stateBridge.isFeatureEnabled(flag) ?? null; + }, +})); + +// Stand in for the real lazy screen module so the test asserts the wiring +// without pulling in the editor chunk. +vi.mock('./editor/editor-screen', () => ({ + default: () => React.createElement('div', { 'data-testid': 'react-editor' }), +})); + +const configResult = (overrides: Record) => ({ + data: undefined, + isError: false, + isLoading: false, + ...overrides, +}); + +const withLabs = (labs: Record) => configResult({ data: { config: { labs } } }); + +describe('EditorGate', () => { + beforeEach(() => { + mockUseBrowseConfig.mockReset(); + delete window.EmberBridge; + }); + + it('renders the Ember editor while the flag is off', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: false })); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders the Ember editor when Ember reports the flag off', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: false })); + window.EmberBridge = { + state: { + isFeatureEnabled: () => false, + }, + } as unknown as typeof window.EmberBridge; + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders the Ember editor when the flag is absent', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({})); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders the Ember editor when the config query fails', () => { + mockUseBrowseConfig.mockReturnValue(configResult({ isError: true })); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + // Ember present but its Labs settings still loading: React must not claim + // the route yet, or the two implementations could briefly split-brain. + it('renders nothing while Ember reports the flag as still loading', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: true })); + window.EmberBridge = { + state: { + isFeatureEnabled: () => null, + }, + } as unknown as typeof window.EmberBridge; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + // Ember's synchronously exposed feature state is the ownership authority, + // even over a config query that disagrees. + it('renders the React editor when Ember reports the flag on', async () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: false })); + window.EmberBridge = { + state: { + isFeatureEnabled: () => true, + }, + } as unknown as typeof window.EmberBridge; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('react-editor')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); + + it('renders nothing while config is loading', () => { + mockUseBrowseConfig.mockReturnValue(configResult({ isLoading: true })); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the React editor while the flag is on', async () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ editorReact: true })); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('react-editor')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/editor-gate.tsx b/apps/admin/src/editor-gate.tsx new file mode 100644 index 00000000000..62738db0817 --- /dev/null +++ b/apps/admin/src/editor-gate.tsx @@ -0,0 +1,17 @@ +import { FlagGatedRoute } from './flag-gated-route'; +import { lazy } from 'react'; +import { lazyEditorScreen } from './editor/api'; + +/** + * Serves `/editor/*` — new (`:type`) and edit (`:type/:postId`) — from the + * React editor screen when the `editorReact` Labs flag is on, and from Ember + * otherwise. The gating semantics (loading, error, and flag branching) live + * in FlagGatedRoute. + */ +const EditorReact = lazy(lazyEditorScreen); + +export function EditorGate() { + return ; +} + +export default EditorGate; diff --git a/apps/admin/src/editor/api.ts b/apps/admin/src/editor/api.ts new file mode 100644 index 00000000000..fe4fcf128c6 --- /dev/null +++ b/apps/admin/src/editor/api.ts @@ -0,0 +1,9 @@ +/** + * Public surface of the editor domain, consumed by the admin shell + * (apps/admin/src/routes.tsx via the editor gate). Everything else in this + * domain is internal. + */ + +// Lazy entry, not a component re-export: the shell mounts this behind +// `lazy()`, so a static re-export would pull the chunk into the shell bundle. +export const lazyEditorScreen = () => import('./editor-screen'); diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx new file mode 100644 index 00000000000..34571c94ef6 --- /dev/null +++ b/apps/admin/src/editor/editor-screen.tsx @@ -0,0 +1,33 @@ +import { AdminLink } from '@/shared/admin-link'; +import { useParams } from '@tryghost/admin-x-framework'; +import { Button } from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; + +/** + * Placeholder for the React editor, served behind the `editorReact` Labs + * flag. It only proves the EditorGate cutover seam end to end; the editor + * itself still lives in Ember while the flag is off. + */ +export default function EditorScreen() { + const editorPath = useParams()['*']; + const isPage = editorPath?.split('/')[0] === 'page'; + const listPath = isPage ? '/pages' : '/posts'; + const listLabel = isPage ? 'Back to pages' : 'Back to posts'; + + return ( + + + The React editor is under construction. Turn off the “React editor” flag in Labs to use the + editor. + + + + ); +} diff --git a/apps/admin/src/editor/editor.acceptance.test.tsx b/apps/admin/src/editor/editor.acceptance.test.tsx new file mode 100644 index 00000000000..b2c6cefa0a3 --- /dev/null +++ b/apps/admin/src/editor/editor.acceptance.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; + +import { renderAdminApp } from '@test-utils/acceptance'; + +const FLAG_ON = { labs: { editorReact: true } }; +const FLAG_OFF = { labs: { editorReact: false } }; + +/** + * Proves the `editorReact` flag swap end-to-end in the real admin app: the + * React placeholder appears only when the flag is on, and the Ember side of + * the URL is delegated to otherwise. + * + * There is no Ember app in this harness, so "Ember serves it" shows up as the + * React placeholder being absent rather than as an Ember editor being present + * — the Ember half of the handshake (the lexical-editor route aborting its + * transition) is covered in + * apps/ember-admin/tests/acceptance/editor-react-flag-test.js. + */ +describe('Editor flag', () => { + const placeholder = () => page.getByTestId('editor-react-placeholder'); + + it('renders the React placeholder when the flag is on', async () => { + await renderAdminApp('/editor/post/abc123', FLAG_ON); + + await expect.element(placeholder()).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Back to posts' })) + .toHaveAttribute('href', '#/posts'); + }); + + it('serves a new-post URL too', async () => { + await renderAdminApp('/editor/post', FLAG_ON); + + await expect.element(placeholder()).toBeVisible(); + }); + + it('returns page editors to the pages list', async () => { + await renderAdminApp('/editor/page/abc123', FLAG_ON); + + await expect.element(placeholder()).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Back to pages' })) + .toHaveAttribute('href', '#/pages'); + }); + + it('defers to Ember when the flag is off', async () => { + await renderAdminApp('/editor/post/abc123', FLAG_OFF); + + await expect(placeholder()).toHaveCount(0); + }); + + it('defers to Ember when the flag is absent entirely', async () => { + await renderAdminApp('/editor/post/abc123'); + + await expect(placeholder()).toHaveCount(0); + }); +}); diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index c302973f4fe..6331d027279 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -250,6 +250,34 @@ describe('useEmberDataSync', () => { }); }); + queryTest('invalidates snippets when Ember saves one', async ({ queryClient, wrapper }) => { + const mock = createMockStateBridge(); + window.EmberBridge = { state: mock.stateBridge }; + const snippetsKey = ['SnippetsResponseType', '/snippets']; + + queryClient.setQueryDefaults(snippetsKey, { gcTime: Infinity }); + queryClient.setQueryData(snippetsKey, { snippets: [] }); + + renderHook(() => useEmberDataSync(), { wrapper }); + + await waitFor(() => { + expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function)); + }); + + act(() => { + mock.emit('emberDataChange', { + operation: 'update', + modelName: 'snippet', + id: 'snippet-1', + data: null, + }); + }); + + await waitFor(() => { + expect(queryClient.getQueryState(snippetsKey)?.isInvalidated).toBe(true); + }); + }); + queryTest( 'invalidates the sidebar member count query for Ember member changes', async ({ queryClient, wrapper }) => { diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index d5d646971b5..c5f7cdf59de 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -105,6 +105,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { member: 'MembersResponseType', tag: 'TagsResponseType', label: 'LabelsResponseType', + snippet: 'SnippetsResponseType', }; /** diff --git a/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx b/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx index 8e1bd8a5cba..2c4cca1bb79 100644 --- a/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx +++ b/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx @@ -39,6 +39,15 @@ describe('Editor chrome', () => { await expect(sidebar()).toHaveCount(0); }); + // The decision lives on the route handle, so it must hold on both sides of + // the `editorReact` gate — here the React placeholder serves the route. + it('hides it with editorReact on', async () => { + await renderAdminApp('/editor/post/abc123', { labs: { editorReact: true } }); + + await expect.element(page.getByTestId('editor-react-placeholder')).toBeVisible(); + await expect(sidebar()).toHaveCount(0); + }); + // ...and still shows it everywhere else, or this would be a worse bug than // the one it fixes. it('leaves the sidebar alone on the posts list', async () => { diff --git a/apps/admin/src/members/members-filtering.acceptance.test.tsx b/apps/admin/src/members/members-filtering.acceptance.test.tsx index 996ee06f78f..f7231d3932c 100644 --- a/apps/admin/src/members/members-filtering.acceptance.test.tsx +++ b/apps/admin/src/members/members-filtering.acceptance.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { page } from 'vitest/browser'; import { + currentRoute, + fakeAdminEndpoint, fakeMemberCustomFields, fakeMembers, label, @@ -125,6 +127,42 @@ describe('Members list', () => { ); }); + // The deploy-compatibility rule in apps/admin/README.md: an Admin deployed ahead of a + // Core without the definitions endpoint must keep filtering intact. Adding a filter swaps + // the filter bar between its two placements, and each one asks for the definitions; the + // failed answer has to hold across that, or every swap asks again, the answer flips the + // page's field catalog, and the URL is rewritten in a loop that never settles. + it('adds a filter against a Core without the definitions endpoint', async () => { + const membersApi = fakeMembers(({ filter }) => + filter + ? [member({ name: 'Alice Alpha' })] + : [member({ name: 'Alice Alpha' }), member({ name: 'Bob Beta' })], + ); + // After fakeMembers, which serves an empty definitions list on behalf of specs that never + // mention custom fields: a handler registered later wins. + const definitionsApi = fakeAdminEndpoint( + 'GET', + /^\/members\/metafields\/custom\/(\?|$)/, + { errors: [{ type: 'NotFoundError', message: 'Resource not found error.' }] }, + { status: 404 }, + ); + await renderAdminApp('/members'); + + await expect(membersScreen.memberRows()).toHaveCount(2); + const requestsBeforeFilter = definitionsApi.requests.length; + + await membersScreen.addFilter('Name', 'Alice'); + + await expect(membersApi).toHaveSentFilter(/name:~'Alice'/); + await expect(membersScreen.memberRows()).toHaveCount(1); + // The loop asks again roughly every round trip, so a short pause is enough to catch it. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + expect(definitionsApi.requests.length).toBe(requestsBeforeFilter); + expect(currentRoute()).toMatch(/^\/members\?filter=/); + }); + it('builds a name filter through the filters UI', async () => { const membersApi = fakeMembers(({ filter }) => filter diff --git a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx index fa3866d427f..1e8134521ff 100644 --- a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx +++ b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx @@ -35,6 +35,7 @@ import { formatNumber, } from '@tryghost/shade/utils'; import { useAnalyticsData } from '@/shared/analytics/use-analytics-data'; +import { useIsEmberOwnedRoute } from '@/routes'; import { usePostAnalytics } from '@/posts/analytics/providers/post-analytics-context'; import { getSiteTimezone } from '@tryghost/admin-x-framework/utils/get-site-timezone'; import { giftAccessLabel } from '@/posts/analytics/utils/gift-link'; @@ -72,6 +73,9 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c const { settings, site, statsConfig } = useAnalyticsData(); const { post, isPostLoading, postId } = usePostAnalytics(); const canManageGiftLink = useCanManageGiftLink(post); + const editorPath = `/editor/post/${postId}`; + // Whether the editor needs a hash navigation depends on the `editorReact` flag. + const editorIsEmberOwned = useIsEmberOwnedRoute(editorPath); const siteTimezone = getSiteTimezone(settings); @@ -203,7 +207,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c canShareAsGift={canManageGiftLink} description="" faviconURL={site?.icon || ''} - featureImageURL={post?.feature_image} + featureImageURL={post?.feature_image ?? undefined} giftAccessLabel={giftAccessLabel(post?.visibility)} open={isShareOpen} postExcerpt={post?.excerpt || ''} @@ -238,7 +242,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c { - navigate(`/editor/post/${postId}`, { crossApp: true }); + navigate(editorPath, { crossApp: editorIsEmberOwned }); }} > diff --git a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts index d844a5b5389..2d0d82178d5 100644 --- a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts +++ b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts @@ -3,8 +3,8 @@ import { createContext, useContext } from 'react'; // Comprehensive Post type with all the includes we fetch in PostAnalytics export interface Post extends PostBase { - published_at?: string; - excerpt?: string; + published_at?: string | null; + excerpt?: string | null; // `name` optional, matching the framework's PostAuthor: invited staff have // an email but no name yet. authors?: { @@ -14,10 +14,10 @@ export interface Post extends PostBase { opened_count: number; email_count: number; status?: string; - }; + } | null; newsletter?: { feedback_enabled?: boolean; - }; + } | null; count?: { positive_feedback?: number; negative_feedback?: number; diff --git a/apps/admin/src/posts/list/hooks/use-posts-list.ts b/apps/admin/src/posts/list/hooks/use-posts-list.ts index 97f47742417..f8927e12ad3 100644 --- a/apps/admin/src/posts/list/hooks/use-posts-list.ts +++ b/apps/admin/src/posts/list/hooks/use-posts-list.ts @@ -16,8 +16,12 @@ import type { Post } from '@tryghost/admin-x-framework/api/posts'; import type { PostBucket, PostFilterContext, PostListParams } from '@/posts/list/post-query-params'; import type { PostResource } from '@/posts/list/post-resource'; -/** A row in the list. Pages are posts with a different `displayName`. */ -export type PostListItem = Post | Page; +/** + * A row in the shared list. Page API records never contain email data, but the + * resource-neutral rendering helpers need those properties to be addressable. + */ +export type PostListItem = (Post | Page) & + Partial>; export interface UsePostsListOptions { resource: PostResource; diff --git a/apps/admin/src/posts/list/post-row-copy.ts b/apps/admin/src/posts/list/post-row-copy.ts index e37f20453f9..9f688c505be 100644 --- a/apps/admin/src/posts/list/post-row-copy.ts +++ b/apps/admin/src/posts/list/post-row-copy.ts @@ -16,7 +16,7 @@ import type { PostResource } from '@/posts/list/post-resource'; type PostStatus = 'draft' | 'scheduled' | 'published' | 'sent'; function statusOf(post: PostListItem): PostStatus { - return (post.status ?? 'draft') as PostStatus; + return post.status ?? 'draft'; } /** @@ -73,7 +73,9 @@ export function getPostDateField(post: PostListItem): 'updated_at' | 'published_ } export function getPostDate(post: PostListItem): string | undefined { - return getPostDateField(post) === 'updated_at' ? post.updated_at : post.published_at; + return ( + (getPostDateField(post) === 'updated_at' ? post.updated_at : post.published_at) ?? undefined + ); } export interface PostMetaLine { diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 2a33a8c0fdc..b683f07c076 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -18,6 +18,7 @@ import MyProfileRedirect from './my-profile-redirect'; import { EmberFallback, ForceUpgradeGuard } from './ember-bridge'; import HomeRedirect from './home-redirect'; import { EmberListWithGiftLinks } from './gift-link-modal-host'; +import { EditorGate } from './editor-gate'; import { PagesListGate, PostsListGate } from './posts-list-gate'; import { TagDetailGate } from './tag-detail-gate'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; @@ -50,35 +51,16 @@ const EMBER_ROUTES: string[] = [ '/pro/*', '/posts/analytics/:postId/debug', '/restore', - '/editor/*', '/migrate/*', '/members-activity', ]; const emberFallbackHandle = { allowInForceUpgrade: true } satisfies AdminRouteHandle; -/** - * Ember routes that hide the nav sidebar. - * - * The editor is a focused writing surface and has always hidden it. Ember - * arranges that by setting `ui.isFullScreen` when the editor route *activates* — - * but with `postsListReact` on, the posts route aborts its transition, so the - * editor route never deactivates, and a second visit is a model change on an - * already-active route where `activate()` does not run again. The sidebar came - * back from the second post onwards. - * - * Deciding it from the route makes React the authority and removes the - * cross-implementation handshake, which had already caused the mirror-image bug - * (the sidebar going *missing* on returning from the editor). - */ -const EMBER_ROUTES_HIDING_SIDEBAR = new Set(['/editor/*']); - const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map((path) => ({ path, Component: EmberFallback, - handle: EMBER_ROUTES_HIDING_SIDEBAR.has(path) - ? ({ ...emberFallbackHandle, hideAdminSidebar: true } satisfies AdminRouteHandle) - : emberFallbackHandle, + handle: emberFallbackHandle, })); const appRoutes: RouteObject[] = [ @@ -194,6 +176,22 @@ const appRoutes: RouteObject[] = [ // on both sides of the flag. { path: '/posts', Component: PostsListGate, handle: emberFallbackHandle }, { path: '/pages', Component: PagesListGate, handle: emberFallbackHandle }, + { + // Served by React or Ember depending on the `editorReact` Labs flag. + // + // The editor is a focused writing surface and has always hidden the nav + // sidebar. Ember arranges that by setting `ui.isFullScreen` when the + // editor route *activates* — but with `postsListReact` on, the posts + // route aborts its transition, so the editor route never deactivates, + // and a second visit is a model change on an already-active route where + // `activate()` does not run again. The sidebar came back from the second + // post onwards. Deciding it from the route handle makes React the + // authority, removes the cross-implementation handshake, and applies to + // both sides of the flag. + path: '/editor/*', + Component: EditorGate, + handle: { ...emberFallbackHandle, hideAdminSidebar: true } satisfies AdminRouteHandle, + }, // Ember-handled routes ...emberFallbackRoutes, { @@ -228,6 +226,7 @@ const EMBER_ROUTE_COMPONENTS = new Set([EmberFallback, EmberListWithGif export function useIsEmberOwnedRoute(pathname: string): boolean { const tagDetailOwner = useFlagGatedRouteOwner('tagDetailsReact'); const postsListOwner = useFlagGatedRouteOwner('postsListReact'); + const editorOwner = useFlagGatedRouteOwner('editorReact'); const leaf = matchRoutes(routes, pathname)?.at(-1)?.route; if (!leaf) { return true; @@ -238,5 +237,8 @@ export function useIsEmberOwnedRoute(pathname: string): boolean { if (leaf.Component === PostsListGate || leaf.Component === PagesListGate) { return postsListOwner !== 'react'; } + if (leaf.Component === EditorGate) { + return editorOwner !== 'react'; + } return EMBER_ROUTE_COMPONENTS.has(leaf.Component); } diff --git a/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx b/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx index 4747a170680..538bc4d527e 100644 --- a/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx +++ b/apps/admin/src/settings/advanced/advanced.acceptance.test.tsx @@ -90,6 +90,32 @@ describe('Advanced settings', () => { ]); }); + it('treats an absent React editor flag as off and allows enabling it', async () => { + fakeSettingsScreens(); + const settingsApi = fakeEditSettings(); + const response = configResponse(); + response.config.enableDeveloperExperiments = true; + await renderAdminApp('/settings/labs', { labs: {}, boot: { browseConfig: { response } } }); + + const section = settingsScreen.section('labs'); + await section.getByRole('button', { name: 'Open' }).click(); + await section.getByRole('tab', { name: 'Private features' }).click(); + const toggle = section.getByRole('switch', { name: 'React editor' }); + await expect.element(toggle).not.toBeChecked(); + await toggle.click(); + await expect(settingsApi).toHaveEditedSettings([ + { + key: 'labs', + value: String( + settingsResponse({ labs: { editorReact: true } }).settings.find((setting) => { + return setting.key === 'labs'; + })!.value, + ), + }, + ]); + await expect.element(toggle).toBeChecked(); + }); + it('saves header and footer code injection', async () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); diff --git a/apps/admin/src/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx index 3e67973300b..86f5e36c550 100644 --- a/apps/admin/src/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -112,6 +112,12 @@ const features: Feature[] = [ 'Renders the posts (/posts) and pages (/pages) list screens from the React app instead of the Ember screens. Gates the migration behind a runtime toggle so we can compare both implementations.', flag: 'postsListReact', }, + { + title: 'React editor', + description: + 'Serves the editor (/editor) from the React app instead of the Ember editor. Gates the migration behind a runtime toggle; the React side is an early placeholder.', + flag: 'editorReact', + }, { title: 'Self-serve archives', description: diff --git a/apps/admin/src/shared/member-custom-fields/use-definitions.ts b/apps/admin/src/shared/member-custom-fields/use-definitions.ts index 7ba446a1a01..de794f81999 100644 --- a/apps/admin/src/shared/member-custom-fields/use-definitions.ts +++ b/apps/admin/src/shared/member-custom-fields/use-definitions.ts @@ -12,11 +12,18 @@ import { * custom fields, the member page shows no custom fields section, a filtered column falls * back to an unnamed header, and the import dialog maps onto Ghost's built-in member * fields alone. None of that is what the publisher came to the screen to do. + * + * A failed fetch is held for the life of the cache entry rather than retried whenever + * another consumer mounts. A retry in flight reports neither data nor an error, so every + * consumer would watch the definitions vanish and return on each mount. On the members + * page that swing rebuilt the field catalog, rewrote the URL, remounted the filter bar and + * retried again, without end. One attempt per visit settles it. */ +const quietlyDegrading = { defaultErrorHandler: false, retryOnMount: false } as const; + export const useCustomFieldDefinitions: typeof useBrowseMemberCustomFields = (options) => - useBrowseMemberCustomFields({ ...options, defaultErrorHandler: false }); + useBrowseMemberCustomFields({ ...options, ...quietlyDegrading }); /** As above, and also lists fields the publisher has archived. */ export const useCustomFieldDefinitionsIncludingArchived: typeof useBrowseMemberCustomFieldsIncludingArchived = - (options) => - useBrowseMemberCustomFieldsIncludingArchived({ ...options, defaultErrorHandler: false }); + (options) => useBrowseMemberCustomFieldsIncludingArchived({ ...options, ...quietlyDegrading }); diff --git a/apps/ember-admin/app/routes/lexical-editor.js b/apps/ember-admin/app/routes/lexical-editor.js index d194bab2a8f..2e7a4311a52 100644 --- a/apps/ember-admin/app/routes/lexical-editor.js +++ b/apps/ember-admin/app/routes/lexical-editor.js @@ -69,6 +69,35 @@ export default AuthenticatedRoute.extend({ classNames: ['editor'], + // React owns /editor/* when the flag is on. Aborting keeps the Ember + // editor subtree unrendered and skips `activate()`, so full-screen state + // is never set for a screen nobody sees. + beforeModel(transition) { + this._super(...arguments); + + // Strictly boolean: a non-boolean labs value must not hand the route + // to React. + if (this.feature.editorReact !== true) { + return; + } + + transition.abort(); + const reactRouteUrl = this._reactRouteUrl(transition); + + // Ember and React share window.location.hash, and an aborted + // transition never reaches updateURL. A URL intent (cold load, hash + // change, React-driven navigation) already has the browser URL + // pointing here, so React renders and there is nothing to do. A + // named intent (post list title links, Cmd-K search results, the + // post-success modal's revert-to-draft) has no URL yet — without + // writing one the click is a silent no-op. + if (!transition.intent?.url) { + this._navigateToReactRoute(reactRouteUrl); + } + + this._parkOnReactFallback(reactRouteUrl); + }, + activate() { this._super(...arguments); this.ui.set('isFullScreen', true); @@ -124,6 +153,70 @@ export default AuthenticatedRoute.extend({ }; }, + // Built by hand rather than with `router.urlFor`, whose output depends + // on the configured location — it returns `/ghost/editor/...` under the + // `none` location used in tests but `#/editor/...` under `trailing-hash` + // in the app. Unlike the list routes the editor has dynamic segments, so + // the path comes from the target route's own params. Every Ember-initiated + // transition into the editor passes string params, so `transition.to` has + // them serialized already. + _reactRouteUrl(transition) { + const {name, params} = transition.to ?? {}; + + if (name === 'lexical-editor.edit' && params?.type && params?.post_id) { + return `/editor/${params.type}/${params.post_id}`; + } + if (name === 'lexical-editor.new' && params?.type) { + return `/editor/${params.type}`; + } + + return '/editor'; + }, + + // Aborting stops Ember rendering this screen, but it also leaves the + // router believing it is still on the route we came from. That desync is + // only invisible until you navigate back to the very same URL: Ember + // compares it against the route it thinks it is on, finds no difference, + // and runs no transition at all. Park on `react-fallback` — the empty + // catch-all Ember already uses for URLs React owns — to keep the + // router's state honest. The fallback must use the real editor path: + // parking on `lexical-editor` briefly sends React to an unknown URL, and + // restoring the hash with replaceState does not notify React Router. That + // leaves the browser showing React's 404 until the next reload. + // See PostsRoute#_parkOnReactFallback for the full rationale (replace + // semantics, the parked-path guard, and URL restoration). + _parkOnReactFallback(reactRouteUrl) { + const fallbackPath = reactRouteUrl.replace(/^\//, ''); + const parkedPath = this.router.currentRouteName === 'react-fallback' + ? this.router.currentRoute?.params?.path + : null; + + if (parkedPath === fallbackPath) { + return; + } + + const url = window.location.hash; + const state = window.history.state; + + this.router.replaceWith('react-fallback', fallbackPath) + .finally(() => this._restoreUrl(url, state)); + }, + + // Parking writes the fallback route's own path, so the captured URL goes + // back afterwards. `replaceState`: no history entry, and no `hashchange` + // to re-enter routing. The captured history state goes back too — + // react-router keeps `{usr, key, idx}` there and a `null` state breaks + // its back/forward index and useBlocker. + _restoreUrl(url, state) { + window.history.replaceState(state, '', url); + }, + + // Seam so tests can assert the navigation without a real hash location — + // Ember acceptance tests run with `location: 'none'`. + _navigateToReactRoute(url) { + window.location.hash = url; + }, + _blurAndScheduleAction(func) { let selectedElement = $(document.activeElement); diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index b009d885eea..95c7e32b29a 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -85,6 +85,7 @@ export default class FeatureService extends Service { @feature('automations') automations; @feature('csvContentImporter') csvContentImporter; @feature('postsListReact') postsListReact; + @feature('editorReact') editorReact; _user = null; @computed('settings.labs') diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 8a57d0abb5b..3cad2e20c87 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -19,6 +19,7 @@ const emberDataTypeMapping = { NewslettersResponseType: {type: 'newsletter'}, RecommendationResponseType: {type: 'recommendation'}, SettingsResponseType: {type: 'setting', singleton: true}, + SnippetsResponseType: {type: 'snippet'}, TagsResponseType: {type: 'tag'}, ThemesResponseType: {type: 'theme'}, TiersResponseType: {type: 'tier'}, diff --git a/apps/ember-admin/tests/acceptance/editor-react-flag-test.js b/apps/ember-admin/tests/acceptance/editor-react-flag-test.js new file mode 100644 index 00000000000..6a6fe6db0f5 --- /dev/null +++ b/apps/ember-admin/tests/acceptance/editor-react-flag-test.js @@ -0,0 +1,129 @@ +import sinon from 'sinon'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {authenticateSession} from 'ember-simple-auth/test-support'; +import {enableLabsFlag} from '../helpers/labs-flag'; +import {expect} from 'chai'; +import {find, settled, visit} from '@ember/test-helpers'; +import {setupApplicationTest} from 'ember-mocha'; +import {setupMirage} from 'ember-cli-mirage/test-support'; +import {titleSelector} from '../helpers/editor'; + +// The `editorReact` flag hands /editor/* to the React app. Ember's side of +// that handshake is the lexical-editor route's beforeModel: it aborts so the +// Ember editor stays unrendered, and drives window.location.hash so +// navigations Ember itself starts (post list title links, Cmd-K search, the +// post-success modal) still land somewhere — an aborted transition never +// reaches updateURL, and the two apps share the hash. + +// `visit()` rejects with TransitionAborted whenever the route aborts, which is +// the whole point of the flag being on. Swallow only that rejection so the +// assertions below can run; anything else still fails the test. +async function visitExpectingAbort(url) { + try { + await visit(url); + } catch (error) { + if (error?.message !== 'TransitionAborted' && error?.name !== 'TransitionAborted') { + throw error; + } + } + await settled(); +} + +describe('Acceptance: editor React flag', function () { + let hooks = setupApplicationTest(); + setupMirage(hooks); + + beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + + let role = this.server.create('role', {name: 'Administrator'}); + let user = this.server.create('user', {roles: [role]}); + this.server.create('post', {authors: [user]}); + + return await authenticateSession(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('when the flag is off', function () { + it('renders the Ember editor', async function () { + await visit('/editor/post/1'); + + expect(find(titleSelector), 'Ember editor title input').to.exist; + }); + }); + + describe('when the flag is on', function () { + beforeEach(function () { + enableLabsFlag(this.server, 'editorReact'); + }); + + it('does not render the Ember editor', async function () { + await visitExpectingAbort('/editor/post/1'); + + expect(find(titleSelector), 'Ember editor title input').to.not.exist; + }); + + it('does not render the Ember editor for a new post', async function () { + await visitExpectingAbort('/editor/post'); + + expect(find(titleSelector), 'Ember editor title input').to.not.exist; + }); + + // The regression this guards: post list title links, Cmd-K search + // results, and the post-success modal's revert-to-draft all + // transition by route name. Without supplying a URL they are silent + // no-ops and the user is stranded on the previous screen. + it('navigates React when Ember initiates an edit transition', async function () { + const route = this.owner.lookup('route:lexical-editor'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('lexical-editor.edit', 'post', '1'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/editor/post/1'); + }); + + it('navigates React when Ember initiates a new-post transition', async function () { + const route = this.owner.lookup('route:lexical-editor'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('lexical-editor.new', 'post'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/editor/post'); + }); + + // A URL that already points at the editor must be left exactly as it + // is — React is already rendering it. + it('does not rewrite a URL-initiated navigation', async function () { + const route = this.owner.lookup('route:lexical-editor'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/editor/post/1'); + + expect(navigate.called, '_navigateToReactRoute called').to.be.false; + }); + + // Aborting alone leaves the router still reporting the route it came + // from, so returning to that same URL later would be a no-op + // transition that renders nothing. Parking on the catch-all at the + // actual editor path keeps both routers truthful. Using the Ember + // route name here sent React to its 404 until a reload. + it('parks the router on the React fallback at the editor path', async function () { + const router = this.owner.lookup('service:router'); + + await visitExpectingAbort('/editor/post/1'); + + expect(router.currentRouteName, 'currentRouteName after aborting').to.equal('react-fallback'); + expect(router.currentRoute?.params?.path, 'fallback path after aborting').to.equal('editor/post/1'); + }); + }); +}); diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index a74065d8ba0..62bf771f690 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -306,6 +306,14 @@ describe('Unit: Service: state-bridge', function () { expect(store.unloadAll.calledWith('integration')).to.be.true; }); + it('unloads snippets when React snippet queries are invalidated', function () { + run(() => { + service.onInvalidate('SnippetsResponseType'); + }); + + expect(store.unloadAll.calledOnceWith('snippet')).to.be.true; + }); + it('unloads all tags when tag queries are invalidated', function () { run(() => { service.onInvalidate('TagsResponseType'); diff --git a/ghost/core/.c8rc.e2e.json b/ghost/core/.c8rc.e2e.json deleted file mode 100644 index 0ac3e794ff5..00000000000 --- a/ghost/core/.c8rc.e2e.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "all": true, - "check-coverage": true, - "reporter": ["text-summary", "cobertura"], - "reportsDir": "./coverage-e2e", - "statements": 54, - "branches": 75, - "functions": 79, - "lines": 54, - "include": ["core/{*.js,frontend,server,shared}"], - "exclude": [ - "core/frontend/src/**", - "core/frontend/public/**", - "core/frontend/helpers/**", - "core/server/data/migrations/**", - "core/server/data/schema/schema.js", - "!core/server/data/migrations/utils.js", - "core/server/web/api/testmode/**", - "core/server/services/koenig/**" - ] -} diff --git a/ghost/core/.c8rc.json b/ghost/core/.c8rc.json deleted file mode 100644 index e89306f676a..00000000000 --- a/ghost/core/.c8rc.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "all": true, - "check-coverage": false, - "reporter": ["html-spa", "text-summary", "cobertura"], - "include": ["core/{*.js,frontend,server,shared}"], - "exclude": [ - "core/frontend/src/**", - "core/frontend/public/**", - "core/server/data/migrations/**", - "core/server/data/schema/schema.js", - "!core/server/data/migrations/utils.js", - "core/frontend/web/**", - "!core/frontend/web/middleware/**", - "core/server/web/**/app.js", - "core/server/web/api/testmode/**", - "core/server/web/parent/**", - "core/server/api/endpoints/**", - "!core/server/web/**/middleware/**", - "!core/server/api/endpoints/utils", - "core/server/services/email-analytics/jobs/**", - "core/server/services/members/jobs/**", - "core/server/services/email-service/wrapper.js", - "core/server/services/**/service.js" - ] -} diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 8c3b27b9c3a..62d0d5fa85d 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -59,6 +59,7 @@ const PRIVATE_FEATURES = [ 'selfServeArchives', 'machinePayments', 'postsListReact', + 'editorReact', ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/package.json b/ghost/core/package.json index 458c182f1e0..e77811acecb 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -63,8 +63,8 @@ "test:integration": "vitest run -c vitest.config.db.ts --project integration", "test:e2e": "vitest run -c vitest.config.db.ts --project e2e --project e2e-api --project e2e-isolated", "test:legacy": "vitest run -c vitest.config.db.ts --project legacy", - "test:ci:e2e": "c8 -c ./.c8rc.e2e.json -o coverage-e2e pnpm test:e2e", - "test:ci:integration": "c8 -c ./.c8rc.e2e.json -o coverage-integration --lines 52 --functions 47 --branches 73 --statements 52 pnpm test:integration", + "test:ci:e2e": "COVERAGE_LANE=e2e pnpm test:e2e --coverage", + "test:ci:integration": "COVERAGE_LANE=integration pnpm test:integration --coverage", "test:int:slow": "pnpm test:integration", "test:e2e:slow": "vitest run -c vitest.config.db.ts --project e2e --project e2e-api --reporter=verbose", "test:leg:slow": "vitest run -c vitest.config.db.ts --project legacy --reporter=verbose", @@ -279,7 +279,6 @@ "@types/supertest": "6.0.3", "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:", - "c8": "catalog:", "chai": "catalog:", "cli-progress": "3.12.0", "cssnano": "7.1.9", diff --git a/ghost/core/test/legacy/mock-express-style/utils/mock-express.js b/ghost/core/test/legacy/mock-express-style/utils/mock-express.js index 8fa6fabc8f7..5a61ee66275 100644 --- a/ghost/core/test/legacy/mock-express-style/utils/mock-express.js +++ b/ghost/core/test/legacy/mock-express-style/utils/mock-express.js @@ -37,7 +37,7 @@ module.exports = { err: res.req.err, body: body, statusCode: res.statusCode, - headers: res._headers, + headers: res.getHeaders(), template: res._template, req: req, res: res, @@ -49,7 +49,7 @@ module.exports = { err: res.req.err, body: body, statusCode: res.statusCode, - headers: res._headers, + headers: res.getHeaders(), template: res._template, req: req, res: res, diff --git a/ghost/core/vitest.config.db.ts b/ghost/core/vitest.config.db.ts index 2c0ad07aca3..d05a5f774dd 100644 --- a/ghost/core/vitest.config.db.ts +++ b/ghost/core/vitest.config.db.ts @@ -94,6 +94,43 @@ const sharedDbConfig = { hookTimeout: 60000, }; +// Coverage gates per acceptance lane, selected by COVERAGE_LANE (set in the +// test:ci:* scripts). Baselines measured against MySQL 8.0 + Redis + MinIO, +// minus ~2pt of headroom. Unset means no gate — ad-hoc local --coverage runs +// report without failing. +type CoverageLane = { + reportsDirectory: string; + thresholds: { + statements: number; + branches: number; + functions: number; + lines: number; + }; +}; + +const COVERAGE_LANES: Record = { + e2e: { + reportsDirectory: 'coverage-e2e', + thresholds: { statements: 70, branches: 56, functions: 74, lines: 70 }, + }, + integration: { + reportsDirectory: 'coverage-integration', + thresholds: { statements: 47, branches: 32, functions: 47, lines: 47 }, + }, +}; + +// Object.hasOwn, not a truthy lookup: 'constructor' and the rest of +// Object.prototype would otherwise resolve to inherited members and silently +// skip the gate. An empty string is a bad value too, not "unset". +const laneName = process.env.COVERAGE_LANE; +if (laneName !== undefined && !Object.hasOwn(COVERAGE_LANES, laneName)) { + // eslint-disable-next-line ghost/ghost-custom/no-native-error + throw new Error( + `Unknown COVERAGE_LANE '${laneName}' — expected one of: ${Object.keys(COVERAGE_LANES).join(', ')}`, + ); +} +const coverageLane = laneName === undefined ? undefined : COVERAGE_LANES[laneName]; + export default defineConfig({ test: { resolveSnapshotPath, @@ -109,6 +146,27 @@ export default defineConfig({ // Local runs use the compact `dot` reporter. CI uses `default` plus // `github-actions` for inline annotations (mirrors vitest.config.ts). reporters: process.env.GITHUB_ACTIONS ? ['default', 'github-actions'] : ['dot'], + // Coverage for the CI acceptance lanes (test:ci:e2e / test:ci:integration), + // which pass --coverage and pick their lane via COVERAGE_LANE. `include` + // is what reports never-loaded files under vitest 4 (there is no `all`). + coverage: { + provider: 'v8', + include: ['core/*.js', 'core/{frontend,server,shared}/**/*.{js,cjs,mjs,ts}'], + exclude: [ + 'core/frontend/src/**', + 'core/frontend/public/**', + 'core/frontend/helpers/**', + 'core/server/data/migrations/**', + 'core/server/data/schema/schema.js', + 'core/server/web/api/testmode/**', + 'core/server/services/koenig/**', + // Type-only declarations: no runtime code, and the remapper's parser + // rejects them ('Expected `from` but found `{`' on `import type`). + '**/*.d.ts', + ], + reporter: ['text-summary', 'cobertura'], + ...coverageLane, + }, projects: [ { ssr: sharedSsrConfig, diff --git a/nx.json b/nx.json index bceb8446cbb..3cbf4c61317 100644 --- a/nx.json +++ b/nx.json @@ -39,7 +39,8 @@ }, "test:unit": { "cache": true, - "dependsOn": ["build"] + "dependsOn": ["build"], + "inputs": ["default", "^default", { "runtime": "node -v" }] }, "test:ci:*": { "cache": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce2134e2a0c..8c2e92539c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,9 +383,6 @@ catalogs: bson-objectid: specifier: 2.0.4 version: 2.0.4 - c8: - specifier: 11.0.0 - version: 11.0.0 chai: specifier: 4.5.0 version: 4.5.0 @@ -1111,7 +1108,7 @@ importers: version: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) apps/admin-x-framework: dependencies: @@ -2699,7 +2696,7 @@ importers: version: 2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2) knex-migrator: specifier: 'catalog:' - version: 5.4.1(@types/node@22.20.1)(supports-color@10.2.2) + version: 5.4.1(@types/node@22.20.1)(bluebird@3.7.2)(supports-color@10.2.2) leaky-bucket: specifier: 2.2.0 version: 2.2.0 @@ -2929,9 +2926,6 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - c8: - specifier: 'catalog:' - version: 11.0.0 chai: specifier: 'catalog:' version: 4.5.0 @@ -6090,6 +6084,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@glimmer/component@1.1.2': resolution: {integrity: sha512-XyAsEEa4kWOPy+gIdMjJ8XlzA3qrGH55ZDv6nA16ibalCR17k74BI0CztxuRds+Rm6CtbUVgheCVlcCULuqD7A==} engines: {node: 6.* || 8.* || >= 10.*} @@ -6849,6 +6846,9 @@ packages: resolution: {integrity: sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw==} engines: {node: ^16.14.0 || >=18.0.0} + '@npmcli/fs@1.1.1': + resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} + '@npmcli/git@5.0.8': resolution: {integrity: sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6857,6 +6857,11 @@ packages: resolution: {integrity: sha512-tkYs0OYnzQm6iIRdfy+LcLBjcKuQCeE5YLb8KnrIlutJfheNaPvPpgoFEyEFgbjzl5PLZ3IA/BWAwRU0eHuQDA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + '@npmcli/move-file@1.1.2': + resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} + engines: {node: '>=10'} + deprecated: This functionality has been moved to @npmcli/fs + '@npmcli/name-from-folder@2.0.0': resolution: {integrity: sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10757,6 +10762,14 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + ajv-errors@1.0.1: resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==} peerDependencies: @@ -10922,6 +10935,11 @@ packages: resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==} deprecated: This package is no longer supported. + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -11982,19 +12000,13 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - c8@11.0.0: - resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} - engines: {node: 20 || >=22} - hasBin: true - peerDependencies: - monocart-coverage-reports: ^2 - peerDependenciesMeta: - monocart-coverage-reports: - optional: true - cacache@12.0.4: resolution: {integrity: sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==} + cacache@15.3.0: + resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} + engines: {node: '>= 10'} + cache-manager-ioredis@2.1.0: resolution: {integrity: sha512-TCxbp9ceuFveTKWuNaCX8QjoC41rAlHen4s63u9Yd+iXlw3efYmimc/u935PKPxSdhkXpnMes4mxtK3/yb0L4g==} engines: {node: '>=6.0.0'} @@ -12222,6 +12234,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -12427,6 +12443,10 @@ packages: resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} engines: {node: '>=18'} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + color@3.2.1: resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} @@ -15045,6 +15065,10 @@ packages: fs-merger@3.2.1: resolution: {integrity: sha512-AN6sX12liy0JE7C2evclwoo0aCG3PFulLjrTLsJpWh/2mM+DinhpSGqYLbHBBbIW1PLRNcFhJG8Axtz8mQW3ug==} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs-mkdirp-stream@2.0.1: resolution: {integrity: sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==} engines: {node: '>=10.13.0'} @@ -15101,6 +15125,11 @@ packages: resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==} deprecated: This package is no longer supported. + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + gelf-stream@1.1.1: resolution: {integrity: sha512-kCzCfI6DJ8+aaDhwMcsNm2l6CsBj6y4Is6CCxH2W9sYnZGcXg9WmJ/iZMoJVO6uTwTRL7dbIioAS8lCuGUXSFA==} @@ -15676,6 +15705,9 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -15900,6 +15932,10 @@ packages: resolution: {integrity: sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==} engines: {node: '>=6'} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + ip-regex@4.3.0: resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} engines: {node: '>=8'} @@ -16099,6 +16135,9 @@ packages: resolution: {integrity: sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==} engines: {node: '>=0.10.0'} + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-language-code@2.0.0: resolution: {integrity: sha512-6xKmRRcP2YdmMBZMVS3uiJRPQgcMYolkD6hFw2Y4KjqyIyaJlCGxUt56tuu0iIV8q9r8kMEo0Gjd/GFwKrgjbw==} @@ -17416,6 +17455,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-fetch-happen@9.1.0: + resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} + engines: {node: '>= 10'} + make-iterator@1.0.1: resolution: {integrity: sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==} engines: {node: '>=0.10.0'} @@ -17921,9 +17964,33 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@1.4.1: + resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} + engines: {node: '>=8'} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + minipass@2.9.0: resolution: {integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + minipass@4.2.8: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} @@ -17932,6 +17999,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} @@ -18167,6 +18238,9 @@ packages: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-dir@0.1.17: resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} engines: {node: '>= 0.10.5'} @@ -18193,6 +18267,11 @@ packages: engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true + node-gyp@8.4.1: + resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} + engines: {node: '>= 10.12.0'} + hasBin: true + node-html-markdown@2.0.0: resolution: {integrity: sha512-DqUC3GGP7pwSYxS93SwHoP+qCw78xcMP6C6H2DuC8rPD2AweJRjBzQb5SdXpKtDlqAQ7hVotJcfhgU7hU5Gthw==} engines: {node: '>=20.0.0'} @@ -18393,6 +18472,11 @@ packages: resolution: {integrity: sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==} hasBin: true + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + nopt@7.2.1: resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -18487,6 +18571,11 @@ packages: resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} deprecated: This package is no longer supported. + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -18751,6 +18840,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + p-map@7.0.6: resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} engines: {node: '>=18'} @@ -20990,6 +21083,10 @@ packages: slick@1.12.2: resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smartquotes@2.3.2: resolution: {integrity: sha512-0R6YJ5hLpDH4mZR7N5eZ12oCMLspvGOHL9A9SEm2e3b/CQmQidekW4SWSKEmor/3x6m3NCBBEqLzikcZC9VJNQ==} engines: {node: '>=4.0.0'} @@ -21020,6 +21117,14 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + socks-proxy-agent@6.2.1: + resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} + engines: {node: '>= 10'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -21120,6 +21225,9 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sqlite3@5.1.7: + resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} + ssh2@1.17.0: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} @@ -21127,6 +21235,10 @@ packages: ssri@6.0.2: resolution: {integrity: sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==} + ssri@8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} + stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' @@ -21608,10 +21720,6 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - test-exclude@8.0.0: - resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} - engines: {node: 20 || >=22} - testem@3.19.1: resolution: {integrity: sha512-h9LKg7pAF3B0aqp3V3Kx8vFlB/ocB1xXvRY1YxZyIKvV/3ZUXqYadi8OD2T15VoFjUZlRrm39s9e7N8A+gYBfA==} engines: {node: '>= 7.*'} @@ -24822,10 +24930,7 @@ snapshots: resolve: 1.22.12 semver: 7.8.5 transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - utf-8-validate '@embroider/macros@0.41.0(supports-color@10.2.2)': dependencies: @@ -25256,6 +25361,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@gar/promisify@1.1.3': + optional: true + '@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@glimmer/di': 0.1.11 @@ -26310,6 +26418,12 @@ snapshots: transitivePeerDependencies: - bluebird + '@npmcli/fs@1.1.1': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.8.5 + optional: true + '@npmcli/git@5.0.8(bluebird@3.7.2)': dependencies: '@npmcli/promise-spawn': 7.0.2 @@ -26331,6 +26445,12 @@ snapshots: minimatch: 9.0.9 read-package-json-fast: 3.0.2 + '@npmcli/move-file@1.1.2': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + optional: true + '@npmcli/name-from-folder@2.0.0': {} '@npmcli/package-json@5.2.1(bluebird@3.7.2)': @@ -30470,6 +30590,20 @@ snapshots: - vite optional: true + '@vitest/browser-playwright@4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(playwright@1.61.1)(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.1.10(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 @@ -30523,6 +30657,24 @@ snapshots: - vite optional: true + '@vitest/browser@4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -30535,9 +30687,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser': 4.1.10(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) '@vitest/expect@3.2.4': dependencies: @@ -30583,6 +30735,15 @@ snapshots: msw: 2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2) vite: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/mocker@4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@26.0.0)(typescript@5.9.3) + vite: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -30618,7 +30779,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@22.20.1)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(@typescript/typescript6@6.0.2))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -30972,6 +31133,17 @@ snapshots: transitivePeerDependencies: - supports-color + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + optional: true + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + optional: true + ajv-errors@1.0.1(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -31131,6 +31303,12 @@ snapshots: delegates: 1.0.0 readable-stream: 2.3.8 + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + arg@5.0.2: {} argparse@1.0.10: @@ -31376,7 +31554,7 @@ snapshots: babel-generator: 6.26.1 babel-helpers: 6.24.1(supports-color@10.2.2) babel-messages: 6.23.0 - babel-register: 6.26.0(supports-color@10.2.2) + babel-register: 6.26.0 babel-runtime: 6.26.0 babel-template: 6.26.0(supports-color@10.2.2) babel-traverse: 6.26.0(supports-color@10.2.2) @@ -31950,7 +32128,7 @@ snapshots: babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) optional: true - babel-register@6.26.0(supports-color@10.2.2): + babel-register@6.26.0: dependencies: babel-core: 6.26.3(supports-color@10.2.2) babel-runtime: 6.26.0 @@ -31959,8 +32137,6 @@ snapshots: lodash: 4.18.1 mkdirp: 0.5.6 source-map-support: 0.4.18 - transitivePeerDependencies: - - supports-color babel-runtime@6.26.0: dependencies: @@ -32930,20 +33106,6 @@ snapshots: bytes@3.1.2: {} - c8@11.0.0: - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@istanbuljs/schema': 0.1.6 - find-up: 5.0.0 - foreground-child: 3.3.1 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - test-exclude: 8.0.0 - v8-to-istanbul: 9.3.0 - yargs: 17.7.3 - yargs-parser: 21.1.1 - cacache@12.0.4: dependencies: bluebird: 3.7.2 @@ -32962,6 +33124,30 @@ snapshots: unique-filename: 1.1.1 y18n: 4.0.3 + cacache@15.3.0(bluebird@3.7.2): + dependencies: + '@npmcli/fs': 1.1.1 + '@npmcli/move-file': 1.1.2 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 7.2.3 + infer-owner: 1.0.4 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1(bluebird@3.7.2) + rimraf: 3.0.2 + ssri: 8.0.1 + tar: 7.5.16 + unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird + optional: true + cache-manager-ioredis@2.1.0(supports-color@10.2.2): dependencies: ioredis: 4.31.0(supports-color@10.2.2) @@ -33260,6 +33446,9 @@ snapshots: chownr@1.1.4: {} + chownr@2.0.0: + optional: true + chownr@3.0.0: {} chrome-trace-event@1.0.4: {} @@ -33451,6 +33640,9 @@ snapshots: dependencies: color-name: 2.1.0 + color-support@1.1.3: + optional: true + color@3.2.1: dependencies: color-convert: 1.9.3 @@ -35551,10 +35743,7 @@ snapshots: optionalDependencies: ember-mocha: 0.16.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - utf-8-validate ember-export-application-global@2.0.1: {} @@ -37698,6 +37887,11 @@ snapshots: transitivePeerDependencies: - supports-color + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + optional: true + fs-mkdirp-stream@2.0.1: dependencies: graceful-fs: 4.2.11 @@ -37785,6 +37979,18 @@ snapshots: strip-ansi: 3.0.1 wide-align: 1.1.5 + gauge@4.0.4: + dependencies: + aproba: 1.2.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + gelf-stream@1.1.1: dependencies: gelfling: 0.3.1 @@ -38526,6 +38732,11 @@ snapshots: human-signals@8.0.1: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + optional: true + husky@9.1.7: {} i18n-iso-countries@7.14.0: @@ -38781,6 +38992,9 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.7.0: + optional: true + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -38962,6 +39176,9 @@ snapshots: dependencies: is-glob: 2.0.1 + is-lambda@1.0.1: + optional: true + is-language-code@2.0.0: {} is-map@2.0.3: {} @@ -39970,7 +40187,7 @@ snapshots: kleur@4.1.5: {} - knex-migrator@5.4.1(@types/node@22.20.1)(supports-color@10.2.2): + knex-migrator@5.4.1(@types/node@22.20.1)(bluebird@3.7.2)(supports-color@10.2.2): dependencies: '@tryghost/database-info': 0.3.35 '@tryghost/errors': 3.3.9 @@ -39987,9 +40204,11 @@ snapshots: resolve: 1.22.12 optionalDependencies: better-sqlite3: 12.11.1 + sqlite3: 5.1.7(bluebird@3.7.2)(supports-color@10.2.2) transitivePeerDependencies: - '@75lb/nature' - '@types/node' + - bluebird - mysql - pg - pg-native @@ -40614,6 +40833,29 @@ snapshots: dependencies: semver: 7.8.5 + make-fetch-happen@9.1.0(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + agentkeepalive: 4.6.0 + cacache: 15.3.0(bluebird@3.7.2) + http-cache-semantics: 4.2.0 + http-proxy-agent: 4.0.1(supports-color@10.2.2) + https-proxy-agent: 5.0.1(supports-color@10.2.2) + is-lambda: 1.0.1 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 1.4.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 6.2.1(supports-color@10.2.2) + ssri: 8.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + make-iterator@1.0.1: dependencies: kind-of: 6.0.3 @@ -41436,15 +41678,55 @@ snapshots: minimist@1.2.8: {} + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-fetch@1.4.1: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + optional: true + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + optional: true + minipass@2.9.0: dependencies: safe-buffer: 5.2.1 yallist: 3.1.1 + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + optional: true + minipass@4.2.8: {} minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + optional: true + minizlib@3.1.0: dependencies: minipass: 7.1.3 @@ -41606,6 +41888,32 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@26.0.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + multer@2.2.0: dependencies: append-field: 1.0.0 @@ -41750,6 +42058,9 @@ snapshots: semver: 7.8.5 optional: true + node-addon-api@7.1.1: + optional: true + node-dir@0.1.17: dependencies: minimatch: 3.1.5 @@ -41782,6 +42093,23 @@ snapshots: undici: 8.10.0 which: 7.0.0 + node-gyp@8.4.1(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + env-paths: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 9.1.0(bluebird@3.7.2)(supports-color@10.2.2) + nopt: 5.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.8.5 + tar: 7.5.16 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + node-html-markdown@2.0.0: dependencies: node-html-parser: 6.1.13 @@ -41884,6 +42212,11 @@ snapshots: dependencies: abbrev: 1.1.1 + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + nopt@7.2.1: dependencies: abbrev: 2.0.0 @@ -41981,6 +42314,14 @@ snapshots: gauge: 2.7.4 set-blocking: 2.0.0 + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + optional: true + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -42476,6 +42817,11 @@ snapshots: p-map@2.1.0: {} + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + optional: true + p-map@7.0.6: {} p-timeout@3.2.0: @@ -45116,6 +45462,9 @@ snapshots: slick@1.12.2: {} + smart-buffer@4.2.0: + optional: true + smartquotes@2.3.2: {} smol-toml@1.6.1: {} @@ -45159,6 +45508,21 @@ snapshots: - supports-color - utf-8-validate + socks-proxy-agent@6.2.1(supports-color@10.2.2): + dependencies: + agent-base: 6.0.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.9: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + optional: true + sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -45251,6 +45615,19 @@ snapshots: sql-escaper@1.3.3: {} + sqlite3@5.1.7(bluebird@3.7.2)(supports-color@10.2.2): + dependencies: + bindings: 1.5.0 + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + tar: 7.5.16 + optionalDependencies: + node-gyp: 8.4.1(bluebird@3.7.2)(supports-color@10.2.2) + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + ssh2@1.17.0: dependencies: asn1: 0.2.6 @@ -45263,6 +45640,11 @@ snapshots: dependencies: figgy-pudding: 3.5.2 + ssri@8.0.1: + dependencies: + minipass: 3.3.6 + optional: true + stable@0.1.8: {} stack-utils@2.0.6: @@ -45960,12 +46342,6 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 - test-exclude@8.0.0: - dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 13.0.6 - minimatch: 10.2.5 - testem@3.19.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@xmldom/xmldom': 0.9.10 @@ -46781,6 +47157,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + optional: true v8flags@3.2.0: dependencies: @@ -47162,6 +47539,38 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.0.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.0.0 + '@vitest/browser-playwright': 4.1.10(msw@2.14.6(@types/node@26.0.0)(typescript@5.9.3))(playwright@1.61.1)(vite@8.1.3(@types/node@26.0.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + '@vitest/ui': 4.1.10(vitest@4.1.10) + jsdom: 30.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + vm-browserify@1.1.2: {} vscode-uri@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19fb5ef1ae7..c120e7254b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -122,7 +122,6 @@ catalog: ajv-formats: 3.0.1 better-sqlite3: 12.11.1 bson-objectid: 2.0.4 - c8: 11.0.0 chai: 4.5.0 chalk: 4.1.2 clsx: 2.1.1