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
5 changes: 5 additions & 0 deletions .changeset/koenig-style-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tryghost/koenig-lexical": minor
---

Added a `./style.css` package export so ESM consumers can load the editor stylesheet, which only the UMD bundle injects on its own
15 changes: 15 additions & 0 deletions .changeset/weak-moose-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"ghost-storage-base": none
"@tryghost/adapter-base-cache": none
"@tryghost/adapter-base-scheduling": none
"@tryghost/adapter-base-sso": none
"@tryghost/kg-card-factory": none
"@tryghost/kg-clean-basic-html": none
"@tryghost/kg-converters": none
"@tryghost/kg-default-cards": none
"@tryghost/kg-default-nodes": none
"@tryghost/kg-markdown-html-renderer": none
"@tryghost/kg-utils": none
---

Update dependencies
39 changes: 37 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ jobs:
if [[ "${{ env.IS_TAG }}" != 'true' && "${{ steps.changed.outputs.any-code }}" != 'true' ]]; then
echo 'affected_projects=[]' >> "$GITHUB_OUTPUT"
echo 'affected_projects_str=' >> "$GITHUB_OUTPUT"
echo 'typecheck_projects_str=' >> "$GITHUB_OUTPUT"
echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT"
echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT"
echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT"
Expand All @@ -261,6 +262,9 @@ jobs:
AFFECTED_PROJECTS_STR=$(pnpm nx show projects ${AFFECTED_ARG} --sep=, | tr -d '\n')
echo "affected_projects_str=$AFFECTED_PROJECTS_STR" >> "$GITHUB_OUTPUT"

TYPECHECK_PROJECTS_STR=$(pnpm -s nx show projects ${AFFECTED_ARG} --withTarget test:types --sep=, | tr -d '\n')
echo "typecheck_projects_str=$TYPECHECK_PROJECTS_STR" >> "$GITHUB_OUTPUT"

UNIT_TEST_AFFECTED_ARG="$AFFECTED_ARG"
if [[ "${{ steps.changed.outputs.unit-test-globals }}" == 'true' ]]; then
UNIT_TEST_AFFECTED_ARG=""
Expand Down Expand Up @@ -296,6 +300,7 @@ jobs:
outputs:
affected_projects: ${{ steps.affected.outputs.affected_projects }}
affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }}
typecheck_projects_str: ${{ steps.affected.outputs.typecheck_projects_str }}
unit_test_projects_str: ${{ steps.affected.outputs.unit_test_projects_str }}
affected_playwright_projects: ${{ steps.affected.outputs.affected_playwright_projects }}
publish_public_apps_matrix: ${{ steps.affected.outputs.publish_public_apps_matrix }}
Expand Down Expand Up @@ -748,6 +753,33 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

job_typecheck:
runs-on: ubuntu-latest
needs: [job_setup]
if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.typecheck_projects_str != ''
name: Typecheck
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 1000
- uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
trust-lockfile: 'true'

- name: Typecheck projects
run: pnpm nx run-many -t test:types -p "${{ needs.job_setup.outputs.typecheck_projects_str }}"
env:
FORCE_COLOR: 0
NX_SKIP_LOG_GROUPING: true

- uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main
if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
status: ${{ job.status }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

job_acceptance-tests:
# Private copies of this repo get 2-core runners, where vitest's DB suite
# falls to a single worker; the public repo's runner is already 4-core.
Expand Down Expand Up @@ -2305,6 +2337,7 @@ jobs:
job_docker,
job_ghost-cli,
job_admin-tests,
job_typecheck,
job_unit-tests,
job_acceptance-tests,
job_legacy-tests,
Expand All @@ -2328,7 +2361,7 @@ jobs:
# Runs only on push-to-main — never on pull_request — so the `id-token: write`
# permission is never exposed to PR-controlled code (ref: ONC-1677).
publish_public_apps:
needs: [job_setup, job_lint, job_unit-tests, job_build_e2e_public_apps]
needs: [job_setup, job_lint, job_typecheck, job_unit-tests, job_build_e2e_public_apps]
name: Publish ${{ matrix.package_name }}
runs-on: ubuntu-latest
# Serialize per-app publishes so two quick main merges can't both compute the
Expand All @@ -2339,11 +2372,13 @@ jobs:
group: publish-public-app-${{ matrix.package_name }}
cancel-in-progress: false
if: |
github.event_name != 'pull_request'
always()
&& github.event_name != 'pull_request'
&& github.repository == 'TryGhost/Ghost'
&& needs.job_setup.outputs.is_main == 'true'
&& needs.job_setup.result == 'success'
&& needs.job_lint.result == 'success'
&& (needs.job_typecheck.result == 'success' || needs.job_typecheck.result == 'skipped')
&& needs.job_unit-tests.result == 'success'
&& needs.job_build_e2e_public_apps.result == 'success'
&& needs.job_setup.outputs.publish_public_apps_matrix != '[]'
Expand Down
4 changes: 3 additions & 1 deletion apps/admin-x-framework/src/api/content-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
type Override<Base, Changes> = Omit<Base, keyof Changes> & Changes;

export type Email = {
id?: string;
opened_count: number;
email_count: number;
status?: string;
status?: 'pending' | 'submitting' | 'submitted' | 'failed';
error?: string | null;
track_opens?: boolean;
track_clicks?: boolean;
};
Expand Down
67 changes: 67 additions & 0 deletions apps/admin-x-framework/src/api/email-previews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createMutation, createQueryWithId } from '../utils/api/hooks';

export type EmailPreview = {
html: string;
plaintext: string;
subject: string;
};

export interface EmailPreviewResponseType {
email_previews: EmailPreview[];
}

export interface EmailPreviewParams {
memberStatus?: 'free' | 'paid';
/** Tier slug - narrows the paid audience to a single tier */
memberTier?: string;
/** Newsletter slug - the server falls back to the post's, then the default newsletter */
newsletter?: string;
}

const dataType = 'EmailPreviewResponseType';

const useEmailPreviewQuery = createQueryWithId<EmailPreviewResponseType>({
dataType,
path: (id) => `/email_previews/posts/${id}/`,
});

export const useEmailPreview = (
id: string,
options: EmailPreviewParams & Parameters<typeof useEmailPreviewQuery>[1] = {},
) => {
const { memberStatus, memberTier, newsletter, searchParams, ...query } = options;

const params: Record<string, string> = { ...searchParams };
if (memberStatus) {
params.member_status = memberStatus;
}
if (memberTier) {
params.member_tier = memberTier;
}
if (newsletter) {
params.newsletter = newsletter;
}

return useEmailPreviewQuery(id, { ...query, searchParams: params });
};

export interface SendTestEmailPayload {
postId: string;
/** The server accepts exactly one recipient per request */
emails: string[];
memberStatus?: 'free' | 'paid';
memberTier?: string;
newsletter?: string;
}

/** Sends a test email for a post. Responds 204 with no body. */
export const useSendTestEmail = createMutation<unknown, SendTestEmailPayload>({
method: 'POST',
path: ({ postId }) => `/email_previews/posts/${postId}/`,
body: ({ emails, memberStatus, memberTier, newsletter }) => ({
emails,
...(newsletter && { newsletter }),
...(memberStatus && { member_status: memberStatus }),
...(memberTier && { member_tier: memberTier }),
}),
});
21 changes: 21 additions & 0 deletions apps/admin-x-framework/src/api/emails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createMutation } from '../utils/api/hooks';
import { postsDataType } from './posts';
import type { Email } from './content-types';

export interface EmailsResponseType {
emails: Email[];
}

/**
* Retry a failed email send.
*
* The framework has no email queries - the email consumers see is the copy
* embedded on the post (the editor read contract includes `email`), so a
* successful retry invalidates post queries to refresh that embedded copy.
*/
export const useRetryEmail = createMutation<EmailsResponseType, string>({
method: 'PUT',
path: (id) => `/emails/${id}/retry/`,
body: () => ({}),
invalidateQueries: { dataType: postsDataType },
});
127 changes: 127 additions & 0 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { apiUrl } from '../utils/api/fetch-api';
import type { FieldValue } from '@tryghost/custom-field-types';
import { useCurrentUser } from './current-user';
import { canManageMembers } from './users';
import { FREE_SEGMENT, PAID_SEGMENT } from '../utils/recipient-filter';

export type MemberLabel = {
id: string;
Expand Down Expand Up @@ -187,6 +188,132 @@ export function useMemberCount() {
return data?.meta?.pagination.total;
}

// -----------------------------------------------------------------------------
// Filtered member counts (email recipients)
// -----------------------------------------------------------------------------

// The Ember members-count-cache's TTL; the framework default staleTime (5min)
// is too stale for publish-flow recipient counts.
const MEMBERS_COUNT_STALE_TIME = 60 * 1000;

const useBrowseMembersCountQuery = createQuery<MembersResponseType>({
dataType,
path: membersPath,
});

export interface MembersCountResult {
/** `null` while loading and for roles that cannot browse members. */
count: number | null;
isLoading: boolean;
}

/**
* Number of members matching a filter, consolidated from the Ember
* `members-count-cache` service + `members-count-fetcher` resource: a browse
* request with `limit=1` reading `meta.pagination.total`, cached per-filter
* for 60 seconds. As in Ember, roles that cannot manage members get
* `count: null` without a request, a nullish filter counts as 0 without a
* request, and request errors resolve to 0 with no error toast. While the
* current user is still loading the result is `{count: null, isLoading: true}`
* so callers can tell it apart from a role that cannot browse members.
*/
export function useMembersCount(filter: string | null | undefined): MembersCountResult {
const { data: currentUser } = useCurrentUser();
const canFetch = Boolean(currentUser && canManageMembers(currentUser));
const enabled = canFetch && filter !== null && filter !== undefined;

const result = useBrowseMembersCountQuery({
// order/page pin the same cheap, stable request shape the Ember cache used
searchParams: { filter: filter ?? '', order: 'id', limit: '1', page: '1' },
staleTime: MEMBERS_COUNT_STALE_TIME,
enabled,
defaultErrorHandler: false,
});

if (currentUser === undefined) {
return { count: null, isLoading: true };
}

if (!enabled || result.isError) {
return { count: canFetch ? 0 : null, isLoading: false };
}

return {
count: result.data?.meta?.pagination.total ?? null,
isLoading: result.isLoading,
};
}

// gh-pluralize combined toLocaleString with ember-inflector; every noun used
// below pluralizes regularly so a trailing "s" matches its output.
function pluralizedCount(count: number, noun: string): string {
return `${count.toLocaleString()} ${count === 1 ? noun : `${noun}s`}`;
}

export interface MembersCountStringOptions {
/**
* The recipient count, usually from `useMembersCount`: a number renders
* numeric copy; `null` (role cannot browse members) and `undefined` (not
* fetched) render the descriptive fallback copy instead.
*/
count?: number | null;
/** With `hasMultipleNewsletters`, switches copy to "subscribers of <name>". */
newsletter?: { name: string; recipientFilter: string };
hasMultipleNewsletters?: boolean;
}

/**
* Human-readable copy for a recipient count, ported from the Ember
* `members-count-cache#countString` (which fetched the count itself; this
* pure port takes it from `useMembersCount`, which matches its semantics).
*/
export function membersCountString(
filter: string = '',
{ count, newsletter, hasMultipleNewsletters = false }: MembersCountStringOptions = {},
): string {
const nounSingular = newsletter && hasMultipleNewsletters ? 'subscriber' : 'member';
const nounPlural = `${nounSingular}s`;
const suffix = newsletter && hasMultipleNewsletters ? ` of ${newsletter.name}` : '';

// Strips the newsletter scope composed by getFullRecipientFilter to get back
// the user-selected segment; a plain comma split like the Ember original.
const basicFilter = newsletter
? filter.replace(newsletter.recipientFilter, '').replace(/^\+\((.*)\)$/, '$1')
: filter;
const filterParts = basicFilter.split(',');
const isFree = filterParts.length === 1 && filterParts[0] === FREE_SEGMENT;
const isPaid = filterParts.length === 1 && filterParts[0] === PAID_SEGMENT;
const isAll =
!filter || (filterParts.includes(FREE_SEGMENT) && filterParts.includes(PAID_SEGMENT));

// Ember reserved this copy for plain Editors and fetched a count for every
// other role on the spot; a pure function can't fetch, so an absent count —
// whatever the role — gets the descriptive copy rather than a bogus "0".
if (count === undefined || count === null) {
if (isFree) {
return `all free ${nounPlural}${suffix}`;
}
if (isPaid) {
return `all paid ${nounPlural}${suffix}`;
}
if (isAll) {
return `all ${nounPlural}${suffix}`;
}

return 'a custom members segment';
}

if (isFree) {
return pluralizedCount(count, `free ${nounSingular}`) + suffix;
}

if (isPaid) {
return pluralizedCount(count, `paid ${nounSingular}`) + suffix;
}

return pluralizedCount(count, nounSingular) + suffix;
}

export type NewMember = {
email: string;
name?: string | null;
Expand Down
2 changes: 2 additions & 0 deletions apps/admin-x-framework/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface PostResponseType {

const dataType = 'PostsResponseType';

export const postsDataType = dataType;

export const useBrowsePosts = createQuery<PostsResponseType>({
dataType,
path: '/posts/',
Expand Down
13 changes: 13 additions & 0 deletions apps/admin-x-framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ export {
} from './utils/post-helpers';
export { focusKoenigEditorOnBottomClick } from './utils/focus-koenig-editor-on-bottom-click';

// Recipient filter utilities
export {
EVERYONE_RECIPIENT_FILTER,
FREE_SEGMENT,
PAID_SEGMENT,
buildRecipientFilter,
getFullRecipientFilter,
getNewsletterRecipientFilter,
getRecipientType,
parseRecipientFilter,
} from './utils/recipient-filter';
export type { RecipientFilterSegments, RecipientType } from './utils/recipient-filter';

// Source utilities
export {
getFaviconDomain,
Expand Down
Loading
Loading