Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@
// Node.js runtime declared in `engines`, not a dependency — so a hard
// version cap is the only lever. Raise it when we bump the Node engine.
{
description: 'Cap @types/node at the installed Node major (engines: ^22.23.1)',
description: 'Cap @types/node at the default Node major (devEngines: 22.23.1)',
matchPackageNames: ['@types/node'],
allowedVersions: '<23',
},
Expand Down
24 changes: 14 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1098,23 +1098,27 @@ jobs:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

job_ghost-cli:
name: Ghost-CLI tests (${{ matrix.scenario }})
name: Ghost-CLI tests (${{ matrix.scenario }}, Node ${{ matrix.node }})
needs: [job_setup, job_pack]
if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Clean install boots this build end to end, so it runs on every Node
# line `engines` claims — the same list the unit / legacy / acceptance
# matrices use. Boot is the only place runtime-only breakage shows up:
# a require/import race, a removed API. The suites that stop at module
# level can all pass while Ghost fails to start.
scenario: [clean-install]
node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }}
include:
# This build, installed from the tarball this run produced.
- scenario: clean-install
node: ${{ needs.job_setup.outputs.node_version }}
# Upgrade from the newest Ghost on npm, which is pinned to the Node
# version whose `engines` that release declared — it can't be raised
# until a release ships supporting the newer line. Move this to
# node_version once the published release supports it.
# Upgrading from the newest Ghost on npm only works on the Node
# version that release's `engines` declared, so this leg tracks the
# default rather than the full list. It can join the matrix above
# once a published release supports the newer line.
- scenario: latest-release
node: '22.23.1'
node: ${{ needs.job_setup.outputs.node_version }}
steps:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
env:
Expand Down Expand Up @@ -1142,7 +1146,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ghost-cli-debug-logs-${{ matrix.scenario }}
name: ghost-cli-debug-logs-${{ matrix.scenario }}-${{ matrix.node }}
path: /home/runner/.ghost/logs/

- name: Clean Install
Expand Down
6 changes: 6 additions & 0 deletions apps/admin-x-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
"import": "./dist/utils/get-site-timezone.js",
"require": "./dist/utils/get-site-timezone.cjs"
},
"./utils/recipient-filter": {
"types": "./types/utils/recipient-filter.d.ts",
"import": "./dist/utils/recipient-filter.js",
"require": "./dist/utils/recipient-filter.cjs"
},
"./vite": {
"types": "./types/vite.d.ts",
"import": "./dist/vite.js",
Expand Down Expand Up @@ -79,6 +84,7 @@
"@tryghost/limit-service": "catalog:",
"@tryghost/nql-string": "workspace:*",
"@tryghost/shade": "workspace:*",
"@tryghost/string": "catalog:",
"bson-objectid": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
Expand Down
6 changes: 6 additions & 0 deletions apps/admin-x-framework/src/api/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,23 @@ export const useEditorPage = (
export interface AddPagePayload {
page: CreateContentData<PageEditableData>;
options?: PostCreateOptions;
/** False when the caller handles an expired session itself instead of leaving the page. */
sessionExpiryRedirect?: boolean;
}

export interface EditPagePayload {
page: EditContentData<PageEditableData>;
options?: PageWriteOptions;
/** False when the caller handles an expired session itself instead of leaving the page. */
sessionExpiryRedirect?: boolean;
}

export const useAddPage = createMutation<PageResponseType, AddPagePayload>({
method: 'POST',
path: () => '/pages/',
searchParams: ({ options }) => buildPageWriteParams(options),
body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
invalidateQueries: { dataType },
});

Expand All @@ -122,6 +127,7 @@ export const useEditPage = createMutation<PageResponseType, EditPagePayload>({
path: ({ page }) => `/pages/${page.id}/`,
searchParams: ({ options }) => buildPageWriteParams(options),
body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
invalidateQueries: { dataType },
});

Expand Down
6 changes: 6 additions & 0 deletions apps/admin-x-framework/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,23 @@ export const useEditorPost = (
export interface AddPostPayload {
post: CreateContentData<PostEditableData>;
options?: PostCreateOptions;
/** False when the caller handles an expired session itself instead of leaving the page. */
sessionExpiryRedirect?: boolean;
}

export interface EditPostPayload {
post: EditContentData<PostEditableData>;
options?: PostWriteOptions;
/** False when the caller handles an expired session itself instead of leaving the page. */
sessionExpiryRedirect?: boolean;
}

export const useAddPost = createMutation<PostResponseType, AddPostPayload>({
method: 'POST',
path: () => '/posts/',
searchParams: ({ options }) => buildPostWriteParams(options),
body: ({ post }) => ({ posts: [serializePostPayload(post)] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
invalidateQueries: { dataType },
});

Expand All @@ -140,6 +145,7 @@ export const useEditPost = createMutation<PostResponseType, EditPostPayload>({
path: ({ post }) => `/posts/${post.id}/`,
searchParams: ({ options }) => buildPostWriteParams(options),
body: ({ post }) => ({ posts: [serializePostPayload(post)] }),
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
invalidateQueries: { dataType },
});

Expand Down
31 changes: 31 additions & 0 deletions apps/admin-x-framework/src/api/session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
import { createMutation } from '../utils/api/hooks';
import { JSONError } from '../utils/errors';

export interface SessionCredentials {
username: string;
password: string;
}

export interface SessionVerification {
token: string;
}

// The server replies 201 Created with only the status text ("Created") as a text/plain body.
export const useAddSession = createMutation<string, SessionCredentials>({
method: 'POST',
path: () => '/session/',
body: (credentials) => credentials,
});

// The server replies 200 OK with only the status text ("OK") as a text/plain body; a wrong code is a bare 401.
export const useVerifySession = createMutation<string, SessionVerification>({
method: 'PUT',
path: () => '/session/verify/',
body: ({ token }) => ({ token }),
});

// The server replies 204 No Content on sign-out, so the mutation resolves with no data.
export const useDeleteSession = createMutation<void, null>({
method: 'DELETE',
path: () => '/session/',
});

const twoFactorRequiredCodes = ['2FA_TOKEN_REQUIRED', '2FA_NEW_DEVICE_DETECTED'];

// Sign-in created the session but the server wants an emailed code before it is usable (403).
export const isTwoFactorRequiredError = (error: unknown): error is JSONError =>
error instanceof JSONError &&
twoFactorRequiredCodes.includes(error.data?.errors?.[0]?.code ?? '');
35 changes: 35 additions & 0 deletions apps/admin-x-framework/src/api/slugs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { slugify } from '@tryghost/string';
import { useCallback } from 'react';
import { apiUrl, useFetchApi } from '../utils/api/fetch-api';

export interface SlugsResponseType {
slugs: Array<{ slug: string }>;
}

export interface GenerateSlugParams {
/** Pages share the posts table, so they dedupe under `post` */
type: 'post' | 'tag' | 'user';
text: string;
/** The record being edited, so its own current slug is not counted as a collision */
id?: string;
}

export const useGenerateSlug = () => {
const fetchApi = useFetchApi();

return useCallback(
async ({ type, text, id }: GenerateSlugParams): Promise<string> => {
if (!text) {
return '';
}

// Slugified client-side first: raw reserved characters in the path (a newline as %0A) 404 at the CDN before reaching Ghost
const name = encodeURIComponent(slugify(text));
const path = id ? `/slugs/${type}/${name}/${id}/` : `/slugs/${type}/${name}/`;
const data = await fetchApi<SlugsResponseType>(apiUrl(path));

return data.slugs[0].slug;
},
[fetchApi],
);
};
3 changes: 3 additions & 0 deletions apps/admin-x-framework/src/string.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module '@tryghost/string' {
export function slugify(string: string, options?: { requiredChangesOnly?: boolean }): string;
}
7 changes: 6 additions & 1 deletion apps/admin-x-framework/src/utils/api/fetch-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface RequestOptions {
retry?: boolean;
/** Resolve the raw response body instead of parsing it as JSON/text */
responseType?: ResponseType;
/** False leaves the caller to handle `SessionExpiredError` instead of leaving the page. */
sessionExpiryRedirect?: boolean;
onUploadProgress?: (progress: number) => void;
}

Expand Down Expand Up @@ -172,6 +174,7 @@ export const useFetchApi = () => {
timeout,
retry = true,
responseType,
sessionExpiryRedirect = true,
onUploadProgress,
}: RequestOptions = {},
): Promise<ResponseData> => {
Expand Down Expand Up @@ -265,7 +268,9 @@ export const useFetchApi = () => {
}

if (error instanceof UnauthorizedError && isSessionExpiry(endpoint)) {
redirectOnSessionExpiry();
if (sessionExpiryRedirect) {
redirectOnSessionExpiry();
}
throw new SessionExpiredError(error.response!, error.data, { cause: error });
}

Expand Down
7 changes: 5 additions & 2 deletions apps/admin-x-framework/src/utils/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ interface MutationOptions<ResponseData, Payload>
headers?: Record<string, string>;
body?: (payload: Payload) => FormData | object;
searchParams?: (payload: Payload) => { [key: string]: string };
/** Per-payload transport options, merged over the ones declared on the hook. */
requestOptions?: (payload: Payload) => Omit<RequestOptions, 'body'>;
invalidateQueries?:
| { dataType: string | string[] }
| {
Expand All @@ -198,7 +200,7 @@ const mutate = <ResponseData, Payload>({
searchParams?: Record<string, string>;
options: Omit<MutationOptions<ResponseData, Payload>, 'path'>;
}) => {
const { defaultSearchParams, body, ...requestOptions } = options;
const { defaultSearchParams, body, requestOptions, ...staticOptions } = options;
const url = apiUrl(path, searchParams || defaultSearchParams);
const generatedBody = payload && body?.(payload);

Expand All @@ -211,7 +213,8 @@ const mutate = <ResponseData, Payload>({

return fetchApi<ResponseData>(url, {
body: requestBody,
...requestOptions,
...staticOptions,
...(payload === undefined ? {} : requestOptions?.(payload)),
});
};

Expand Down
Loading
Loading