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