diff --git a/.changeset/clean-flags-retire.md b/.changeset/clean-flags-retire.md new file mode 100644 index 00000000000..8af68dbcdd8 --- /dev/null +++ b/.changeset/clean-flags-retire.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-default-nodes": patch +--- + +Removed the unused emailCustomization and emailCustomizationAlpha feature options. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 558a85019a8..631f889097b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,28 +6,21 @@ For **help**, **support**, **questions** and **ideas** please use **[our forum]( ## Where to Start -If you're a developer looking to contribute, but you're not sure where to begin: Check out the [good first issue](https://github.com/TryGhost/Ghost/labels/good%20first%20issue) label on Github, which contains small piece of work that have been specifically flagged as being friendly to new contributors. +The [codebase documentation](../docs/README.md) explains how to set up the +monorepo and find your way around it. Start with the +[development setup guide](../docs/contributing/development-setup.md), then use +the [contribution workflow](../docs/contributing/workflow.md) when you are ready +to make a change. -After that, if you're looking for something a little more challenging to sink your teeth into, there's a broader [help wanted](https://github.com/TryGhost/Ghost/labels/help%20wanted) label encompassing issues which need some love. +If you're not sure what to work on, start with +[good first issues](https://github.com/TryGhost/Ghost/labels/good%20first%20issue) +or browse the broader +[help wanted](https://github.com/TryGhost/Ghost/labels/help%20wanted) list. -If you've got an idea for a new feature, please start by suggesting it in the [forum](https://forum.ghost.org), as adding new features to Ghost first requires generating consensus around a design and spec. +Discuss new features and substantial product or architectural changes in the +[forum](https://forum.ghost.org) before implementing them. - -## Working on Ghost Core - -If you're going to work on Ghost core you'll need to go through a slightly more involved install and setup process than the usual Ghost CLI version. - -First you'll need to fork [Ghost](https://github.com/tryghost/ghost) to your personal Github account, and then follow the detailed [install from source](https://ghost.org/docs/install/source/) setup guide. - - -### Branching Guide - -`main` on the main repository always contains the latest changes. This means that it is WIP for the next minor version and should NOT be considered stable. Stable versions are tagged using [semantic versioning](http://semver.org/). - -On your local repository, you should always work on a branch to make keeping up-to-date and submitting pull requests easier, but in most cases you should submit your pull requests to `main`. Where necessary, for example if multiple people are contributing on a large feature, or if a feature requires a database change, we make use of feature branches. - - -### Commit Messages +## Commit Messages We have a handful of simple standards for commit messages which help us to generate readable changelogs. Please follow this wherever possible and mention the associated issue number. @@ -58,10 +51,9 @@ There is no need to include what modules have changed in the commit message, as [Good example](https://github.com/TryGhost/Ghost/commit/95751a0e5fb719bb5bca74cb97fb5f29b225094f) +## Changesets -### Changesets - -Ghost publishes several workspace packages to npm — the `@tryghost/*` editor and adapter packages under `koenig/` and `packages/`. When your change touches one of these publishable packages, add a **changeset** so it gets a version bump and a changelog entry: +Ghost publishes several workspace packages to npm — the `@tryghost/*` editor and adapter packages under `koenig/` and `packages/`. When your change affects one of these publishable packages, including by changing a catalog entry it consumes, add a **changeset** so it gets a version bump and a changelog entry: ```bash pnpm change @@ -73,18 +65,18 @@ This records which packages changed and the bump type (patch / minor / major); t pnpm change --bump none ``` -CI enforces this — the **Check app version bump** job fails a pull request that modifies a publishable package without a covering changeset. The pre-commit hook prints a non-blocking reminder locally, and `pnpm change status` shows what's currently pending. +CI enforces this — the **Check app version bump** job fails a pull request that affects a publishable package without a covering changeset. The pre-commit hook prints a non-blocking reminder locally, and `pnpm change status` shows what's currently pending. +For more detail, see the [contribution workflow](../docs/contributing/workflow.md). -### Submitting Pull Requests +## Submitting Pull Requests -We aim to merge any straightforward, well-understood bug fixes or improvements immediately, as long as they pass our tests (run `pnpm test` to check locally). We generally don’t merge new features and larger changes without prior discussion with the core product team for tech/design specification. +We aim to merge any straightforward, well-understood bug fixes or improvements immediately, as long as they pass our tests (run `pnpm check` to ensure everything works). We generally don’t merge new features and larger changes without prior discussion with the core product team for tech/design specification. Please provide plenty of context and reasoning around your changes, to help us merge quickly. Closing an already open issue is our preferred workflow. If your PR gets out of date, we may ask you to rebase as you are more familiar with your changes than we will be. -### Sharing feedback on Documentation - -While the Docs are no longer Open Source, we welcome revisions and ideas on the forum! Please create a Post with your questions or suggestions in the [Contributing to Ghost Category](https://forum.ghost.org/c/contributing/27). Thank you for helping us keep the Docs relevant and up-to-date. +For branch, validation, and pull request details, follow the +[contribution workflow](../docs/contributing/workflow.md). --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3df26033d9..06bf1df3222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,12 +78,30 @@ jobs: echo "GITHUB_EVENT_NAME: ${{ github.event_name }}" echo "GITHUB_CONTEXT: ${{ toJson(github.event) }}" + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + env: + FORCE_COLOR: 0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + # Replaced nrwl/nx-set-shas, which verified each candidate commit over the + # API and hid the errors — see scripts/nx-set-shas.js. - name: Set SHAs for Nx Commands if: env.IS_TAG != 'true' - uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 - with: - main-branch-name: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} - error-on-no-successful-workflow: ${{ env.IS_MAIN == 'true' && github.repository == 'TryGhost/Ghost' }} + env: + GITHUB_TOKEN: ${{ github.token }} + BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} + # Canonical main is the one branch where too narrow a base means + # untested commits land, so there a lookup that comes up empty fails + # the run rather than falling back to the previous commit. + ON_MISSING: ${{ (env.IS_MAIN == 'true' && github.repository == 'TryGhost/Ghost') && 'error' || 'previous-commit' }} + run: node scripts/nx-set-shas.js --branch "$BRANCH" --head "$HEAD_COMMIT" --on-missing "$ON_MISSING" - name: Check user org membership id: check_user_org_membership @@ -145,6 +163,10 @@ jobs: - 'scripts/test/check-agent-skill-links.test.js' core: - *shared + # Repository documentation and ownership metadata do not affect + # Ghost runtime behaviour, even though they live in .github. + - '!.github/**/*.md' + - '!.github/CODEOWNERS' - 'ghost/**' - '!ghost/core/core/server/data/tinybird/**' # Unit tests + vitest config are exercised only by job_unit-tests; @@ -196,18 +218,6 @@ jobs: run: | echo 'matrix=["22.23.1"]' >> $GITHUB_OUTPUT - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - env: - FORCE_COLOR: 0 - with: - node-version: ${{ env.NODE_VERSION }} - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Start Nx Cloud CI run run: pnpm nx start-ci-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8742fc4b41d..a18d27ae62f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ run-name: "Release — ${{ inputs.bump-type || 'auto' }} from ${{ inputs.branch on: schedule: - - cron: '0 15 * * 5' # Friday 3pm UTC + - cron: '0 15 * * 2' # Tuesday 3pm UTC workflow_dispatch: inputs: branch: diff --git a/AGENTS.md b/AGENTS.md index faf348dc65e..4ee650360a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,223 +2,33 @@ This file provides guidance to AI Agents when working with code in this repository. -## Package Manager - -**Always use `pnpm` for all commands.** This repository uses pnpm workspaces, not npm. - -Shared dependency versions are pinned in `pnpm-workspace.yaml` under `catalog:` and referenced as `"pkg": "catalog:"` (or `catalog:` for named catalogs). `catalogMode` is `strict`, so `pnpm add` routes new deps into the catalog automatically — don't inline the version. - -## Monorepo Structure - -Ghost is a pnpm + Nx monorepo with four workspace groups: - -### ghost/* - Core Ghost packages -- **ghost/core** - Main Ghost application (Node.js/Express backend) - - Core server: `ghost/core/core/server/` - - Frontend rendering: `ghost/core/core/frontend/` - -### apps/* - React-based UI applications -Two categories of apps: - -**Admin Apps** (embedded in Ghost Admin): -- `ember-admin` - Ember.js admin client (legacy, being migrated to React) -- `admin` - The consolidated React admin shell, organized by domain (`src/{analytics,members,posts,tags,comments,automations,settings,...}`) -- `activitypub` - ActivityPub integration (route-composed into `admin`) -- Built with Vite + React + `@tanstack/react-query` - -**Public Apps** (served to site visitors): -- `portal`, `comments-ui`, `signup-form`, `sodo-search`, `announcement-bar` -- Built as UMD bundles, loaded via CDN in site themes - -**Foundation Libraries**: -- `admin-x-framework` - Shared API hooks, routing, utilities -- `admin-x-design-system` - Legacy design system (being phased out) -- `shade` - New design system (shadcn/ui + Radix UI + react-hook-form + zod) - -### koenig/* - Ghost editor (Koenig) packages -Merged from the former TryGhost/Koenig repo with full git history: - -- **koenig-lexical** - The Lexical-based rich text editor UI. Bundled into - Ghost Admin at build time (`apps/ember-admin` copies its UMD build into admin - assets; `apps/admin` imports it directly) -- **kg-*** - Editor support packages: server-side renderers and converters - consumed by `ghost/core` (kg-default-nodes, kg-lexical-html-renderer, - kg-html-to-lexical, ...) plus frontend helpers (kg-unsplash-selector) - -All Koenig packages resolve via `workspace:` — nothing in dev, CI, or the -release archive installs them from npm. They are published to npm for -external consumers only, automatically as part of the Ghost release lane -(see `publish_koenig_packages` in ci.yml). - -**Zero-build dev via the `source` export condition.** The `kg-*` libraries -consumed by `ghost/core` declare a `source` condition in their `package.json` -`exports` that points at the raw `src/*.ts`, listed *before* -`types`/`import`/`require`: - -```jsonc -".": { - "source": "./src/index.ts", // dev/test: read raw TS - "types": "./build/esm/index.d.ts", - "import": "./build/esm/index.js", - "require": "./build/cjs/index.js" // prod/published: compiled JS -} -``` - -`ghost/core`'s dev runner (`nodemon.json`: `node --conditions=source --import=tsx`) -and its Vitest configs (`resolve.conditions: ['source', 'node']` + -`--import tsx --conditions=source`) activate this condition, so a source change -in a `kg-*` package is picked up with **no `tsc` rebuild**. Production and the -published npm tarball run plain `node`, which ignores `source` and uses -`build/` — and `src/` is excluded from each package's `files` array, so it is -never shipped. The separate ESM and CommonJS outputs are part of Koenig's public -package contract; new internal packages use the ESM-only shape documented below. - -### packages/* - Shared workspace libraries -Backend and shared libraries. Internal packages are consumed via `workspace:*`; -selected adapter bases also have supported public releases: - -Read [`packages/README.md`](packages/README.md) before creating or modernizing an -internal package. It is the canonical lifetime contract; `packages/_template` -is its scaffold. - -- **i18n** - Centralized internationalization for all apps -- **parse-email-address** - Email address parsing -- **adapters/** - Adapter base classes (`adapter-base-*`: scheduling, storage, - SSO, redirects, route settings) -- **custom-field-types**, **testing** - Shared field-type definitions and test - helpers -- **_template** - Scaffold for new packages; excluded from the workspace - -### e2e/ - End-to-end tests -- Playwright-based E2E tests with Docker container isolation -- See `e2e/CLAUDE.md` for detailed testing guidance - -## Common Commands - -### Development -```bash -corepack enable pnpm # Enable corepack to use the correct pnpm version -pnpm run setup # First-time setup (installs deps + submodules + builds workspace packages) -pnpm dev # Start development (Docker backend + host frontend dev servers) -``` - -> **Fresh worktree / first run — run `pnpm setup` before anything else.** It installs deps and syncs submodules. `pnpm fix` does a clean reinstall if anything misbehaves after a branch switch. - -### Building -```bash -pnpm build # Build all packages (Nx handles dependencies) -pnpm build:clean # Clean build artifacts and rebuild -``` - -### Testing -```bash -# Unit tests (from root) -pnpm test:unit # Run all unit tests in all packages -pnpm test:watch # Watch mode — unified Vitest watcher (ghost/core + all apps) - -# Ghost core tests (from ghost/core/) -cd ghost/core -pnpm test:unit # Unit tests only (Vitest, run once) -pnpm test:watch # Watch mode — ghost/core unit tests only -pnpm test:integration # Integration tests -pnpm test:e2e # Server-side e2e suites (webhooks/server/frontend/api) — not browser -pnpm test:all # All test types - -# These run on sqlite with no extra services. The Redis/MinIO/S3 adapter suites -# probe for their service and auto-skip when it's down (run `pnpm dev:storage` -# etc. to exercise them); they always run in CI, which starts the services. - -# E2E browser tests (from root) -pnpm test:e2e # Run e2e/ Playwright tests - -# Running a single test -cd ghost/core -pnpm test:single test/unit/path/to/test.test.js # routes test/unit/* → unit config, test/* → DB config - -# Watch a single DB-backed file (integration/e2e) — the default test:watch only -# covers unit tests, so point it at the DB config explicitly: -pnpm exec vitest -c vitest.config.db.ts test/integration/path/to/test.test.js - -# Ember Admin tests (from the repository root) -pnpm nx run ghost-admin:test - -# Run one Ember Admin test file. Paths are relative to apps/ember-admin. -# The explicit `1` supplies the numeric value required by the test script's -# trailing `--parallel` option before additional Ember Exam arguments. -pnpm nx run ghost-admin:test -- 1 --file-path=tests/acceptance/editor/publish-flow-test.js -``` - -> **Always run Ember Admin tests through Nx.** Running `ember test` or -> `ember exam` directly from `apps/ember-admin` skips the dependency build -> graph and commonly fails in fresh worktrees with missing outputs such as -> `koenig-lexical.umd.js`, `@tryghost/admin-x-framework/hooks`, or -> `@tryghost/kg-converters`. For focused runs, use Ember Exam's `--file-path` -> as shown above rather than appending `--filter` to the package script. - -### Linting -```bash -pnpm lint # Lint all packages -cd ghost/core && pnpm lint # Lint Ghost core (server, shared, frontend, tests) -cd apps/ember-admin && pnpm lint # Lint Ember admin -``` - -### Database -```bash -pnpm knex-migrator migrate # Run database migrations -pnpm reset:data # Reset database with test data (1000 members, 100 posts) (requires pnpm dev running) -pnpm reset:data:empty # Reset database with no data (requires pnpm dev running) -``` - -### Docker -```bash -pnpm docker:build # Build Docker images -pnpm docker:clean # Stop containers, remove volumes and local images -pnpm docker:down # Stop containers -``` - -### How `pnpm dev` works - -The `pnpm dev` command uses a **hybrid Docker + host development** setup: - -**What runs in Docker:** -- Ghost Core backend (with hot-reload via mounted source) -- MySQL, Redis, Mailpit -- Caddy gateway/reverse proxy +Human-readable setup, workflow, testing, shipping, and architecture guidance +lives in the [codebase documentation](docs/README.md). Treat those guides and +nearby package READMEs as the source of truth for facts shared by humans and +agents. This file adds agent-specific execution rules and code constraints. -**What runs on host by default:** -- Admin, legacy Ember admin, Portal, and foundation library dev watchers -- Optional public UMD app watchers can be added when needed +Start with: -**Setup:** -```bash -# Start Ghost backend, Admin, Portal, and Docker services -pnpm dev +- [Development setup](docs/contributing/development-setup.md) +- [Contribution workflow](docs/contributing/workflow.md) +- [Testing](docs/contributing/testing.md) +- [Shipping](docs/contributing/shipping.md) +- [Monorepo structure](docs/codebase/monorepo-structure.md) -# Add optional public apps (comments-ui, sodo-search, signup-form, admin-toolbar) -pnpm dev:public +## Package Manager -# Develop the Koenig editor against Ghost Admin (adds a koenig-lexical rebuild -# watcher + preview server; Admin loads the editor from your local build) -pnpm dev:lexical +**Always use `pnpm` for all commands.** This repository uses pnpm workspaces, not npm. -# With optional services (uses Docker Compose file composition) -pnpm dev:analytics # Include Tinybird analytics -pnpm dev:storage # Include MinIO S3-compatible object storage -pnpm dev:stripe # Include Stripe webhook forwarding -pnpm dev:full # Include analytics, storage, Stripe, and public app watchers +Shared dependency versions are pinned in `pnpm-workspace.yaml` under `catalog:` and referenced as `"pkg": "catalog:"` (or `catalog:` for named catalogs). `catalogMode` is `strict`, so `pnpm add` routes new deps into the catalog automatically — don't inline the version. -# Everything available -pnpm dev:all # -``` +## Required Workflow -**Accessing Services:** -- Ghost: `http://localhost:2368` (database: `ghost_dev`) -- Mailpit UI: `http://localhost:8025` (email testing) -- MySQL: `localhost:3306` -- Redis: `localhost:6379` -- Tinybird: `http://localhost:7181` (when analytics enabled) -- MinIO Console: `http://localhost:9001` (when storage enabled) -- MinIO S3 API: `http://localhost:9000` (when storage enabled) +- Run `pnpm setup` before other commands in a fresh checkout or worktree. +- Use `pnpm check` as the default full validation command. Follow the + [testing guide](docs/contributing/testing.md) for focused commands and the + browser E2E and Ember Admin suites that run separately. +- Read the nearest `AGENTS.md`, `CLAUDE.md`, and README files before changing a + package or subsystem. More specific instructions override this file. ## Architecture Patterns @@ -387,16 +197,3 @@ Conventions: - **Config:** Add Tinybird config to `ghost/core/config.development.json` - **Scripts:** `ghost/core/core/server/data/tinybird/scripts/` - **Datafiles:** `ghost/core/core/server/data/tinybird/` - -## Troubleshooting - -### Build Issues -```bash -pnpm fix # Clean cache + node_modules + reinstall -pnpm build:clean # Clean build artifacts -pnpm nx reset # Reset Nx cache -``` - -### Test Issues -- **E2E failures:** Check `e2e/CLAUDE.md` for debugging tips -- **Docker issues:** `pnpm docker:clean && pnpm docker:build` diff --git a/README.md b/README.md index 3a4c70331c4..9df2c0f09ea 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Ghost.org • Forum • Docs • - Contributing • + Contributing • Twitter

@@ -79,7 +79,9 @@ Check out our [official documentation](https://ghost.org/docs/) for more informa ### Contributors & advanced developers -For anyone wishing to contribute to Ghost or to hack/customize core files we recommend following our full development setup guides: [Contributor guide](https://ghost.org/docs/contributing/) • [Developer setup](https://ghost.org/docs/install/source/) +To contribute to Ghost, start with the +[contributing guide](.github/CONTRIBUTING.md). To work on the monorepo, see the +[codebase documentation](docs/README.md).   diff --git a/apps/admin-x-framework/src/api/member-custom-fields.ts b/apps/admin-x-framework/src/api/member-custom-fields.ts index a01328c136d..b289bf03ea0 100644 --- a/apps/admin-x-framework/src/api/member-custom-fields.ts +++ b/apps/admin-x-framework/src/api/member-custom-fields.ts @@ -1,4 +1,4 @@ -import {FIELD_TYPE_IDS, type Address, type FieldType} from '@tryghost/custom-field-types'; +import {FIELD_TYPE_IDS, subFieldsOf, type FieldType, type PartsOf} from '@tryghost/custom-field-types'; import {csvColumnsForField} from '@tryghost/custom-field-types/csv'; import {Meta, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks'; @@ -30,32 +30,42 @@ export type MemberCustomField = { * The user-type catalog: the presentation layer over the shared field types. * * The shared catalog (@tryghost/custom-field-types) owns what a field type *is* - * - its storage and validation. This catalog owns what it *looks like* in admin: - * label, icon, and which control collects a value. Admin surfaces (settings + * - its storage and validation. This catalog owns what a publisher is told it is: + * its name, and which control collects a value. Admin surfaces (settings * list/modal, member detail) render from here so every surface presents fields * identically. The backend never sees any of this. + * + * The icon is not here: it is a component, so it sits with admin's, under the same type ids. */ export type MemberCustomFieldUserType = { id: FieldType; label: string; // Which control collects/edits a value of this type input: 'text' | 'textarea' | 'address'; - // Composite types only: label per sub-field, keyed by the sub-field key the shared - // value schema defines. Kept with the type's other presentation, not a parallel map. - subFields?: Record; }; -// Presentation for every field type in the shared catalog. The explicit -// Record annotation keeps this exhaustive: adding a field type -// upstream fails to compile here until it has a presentation. -const fieldTypePresentation: Record> = { +/** + * How one field type is presented, constrained by what its value is: a composite names + * every part its schema declares and no others, a scalar names none. + * + * The shared catalog owns which parts exist; this one owns what they are called, so adding, + * removing or renaming a part upstream fails the build here rather than reaching a publisher + * as a raw key. Enforced against a literal, which is how the catalog below is written; a + * pre-widened `Record` would satisfy it. + */ +export type FieldTypePresentation = { + label: string; + input: MemberCustomFieldUserType['input']; +} & ([PartsOf] extends [never] ? {subFields?: never} : {subFields: Record, string>}); + +// Presentation for every field type in the shared catalog. The mapped type keeps this +// exhaustive: adding a field type upstream fails to compile here until it has one. +const fieldTypePresentation: {[T in FieldType]: FieldTypePresentation} = { short_text: {label: 'Short text', input: 'text'}, long_text: {label: 'Long text', input: 'textarea'}, address: { label: 'Address', input: 'address', - // Keyed to the shared value schema's parts (`satisfies`), so a part added, removed, - // or mistyped upstream is a compile error here rather than a silently missing label. subFields: { line1: 'Address line 1', line2: 'Address line 2', @@ -63,10 +73,22 @@ const fieldTypePresentation: Record + } } }; +/** + * A type's part labels, keyed by part; empty for a type with no parts, and for one this + * build has never heard of. + * + * Total for every key the value schema declares, which is the only kind of key that + * reaches it: `FieldTypePresentation` refuses to compile a catalog missing one. + */ +const partLabelsFor = (type: FieldType): Record => { + const labels: Record | undefined = fieldTypePresentation[type]?.subFields; + return labels ?? {}; +}; + // The catalog in the shared catalog's declared order, so every admin surface // offers and renders the field types in the same order. export const memberCustomFieldUserTypes: MemberCustomFieldUserType[] = @@ -93,18 +115,32 @@ export type MemberCustomFieldCsvColumn = {label: string; value: string}; */ export const memberCustomFieldCsvColumns = (fields: MemberCustomField[]): MemberCustomFieldCsvColumn[] => { return fields.flatMap((field) => { - const columns = csvColumnsForField({key: field.key, type: field.type}); - return columns.map((column) => { - if (columns.length === 1) { - return {label: field.name, value: column}; - } - const sub = column.slice(column.lastIndexOf('.') + 1); - const subLabel = userTypeForFieldType(field.type).subFields?.[sub] ?? sub; - return {label: `${field.name} (${subLabel})`, value: column}; - }); + const labels = partLabelsFor(field.type); + return csvColumnsForField({key: field.key, type: field.type}).map(({column, subField}) => ({ + label: subField === null ? field.name : `${field.name} (${labels[subField]})`, + value: column + })); }); }; +/** One part of a composite field type: the key the value schema declares, and its label. */ +export type MemberCustomFieldPart = {key: PartsOf; label: string}; + +/** + * The parts of a composite field type, or null for a scalar. + * + * Which parts exist, and in what order, comes from the value schema; naming them is this + * catalog's job. + */ +export const memberCustomFieldParts = (type: T): MemberCustomFieldPart[] | null => { + const partKeys = subFieldsOf(type); + if (!partKeys) { + return null; + } + const labels = partLabelsFor(type); + return partKeys.map(key => ({key, label: labels[key]})); +}; + export interface MemberCustomFieldsResponseType { meta?: Meta; members_custom_fields: MemberCustomField[]; diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index ecf04830eeb..ac37fbcbd7d 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -2,7 +2,7 @@ import {InfiniteData, useIsFetching, useQueryClient} from '@tanstack/react-query import {useEffect} from 'react'; import {Meta, createInfiniteQuery, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks'; import {apiUrl} from '../utils/api/fetch-api'; -import type {Address} from '@tryghost/custom-field-types'; +import type {FieldValue} from '@tryghost/custom-field-types'; import {useCurrentUser} from './current-user'; import {canManageMembers} from './users'; @@ -507,11 +507,10 @@ export interface EditMemberData { newsletters?: Array<{id: string}>; tiers?: Array<{id: string; expiry_at?: string | null}>; // Merge semantics: only the keys present are written; `null` clears a - // value. Values are strings for text-backed fields and composite objects - // for address — every sub-field of which is optional, the server asking - // only that one of them is filled in. Requires the `membersCustomFields` - // flag server-side. - custom_fields?: Record; + // value. The value union is derived from the shared schemas, so a field type + // added there is writable here without this line being edited. Requires the + // `membersCustomFields` flag server-side. + custom_fields?: Record; } export const useEditMember = createMutation({ diff --git a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts index 89b2f15cc27..e1d0dc8b8d3 100644 --- a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts +++ b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts @@ -1,4 +1,25 @@ -import {type MemberCustomField, memberCustomFieldCsvColumns} from '../../../src/api/member-custom-fields'; +import {type FieldTypePresentation, type MemberCustomField, memberCustomFieldCsvColumns, memberCustomFieldParts} from '../../../src/api/member-custom-fields'; + +// Compile-time cases: the build failing is the assertion. Each `@ts-expect-error` fails the +// build if the case it names stops being an error. Declared on one line each, because the +// directive only covers the line below it and a spread literal reports on its inner line. +const composite = {line1: 'a', line2: 'b', city: 'c', state: 'd', postal_code: 'e', country: 'f'}; + +const labelled: FieldTypePresentation<'address'> = {label: 'Address', input: 'address', subFields: composite}; + +// @ts-expect-error a composite missing one of the parts its value schema declares +const missingPart: FieldTypePresentation<'address'> = {label: 'Address', input: 'address', subFields: {line1: 'a', line2: 'b', city: 'c', state: 'd', country: 'f'}}; + +// @ts-expect-error a composite naming a part its value schema does not declare +const unknownPart: FieldTypePresentation<'address'> = {label: 'Address', input: 'address', subFields: {...composite, county: 'g'}}; + +// @ts-expect-error a composite with no part labels at all +const unlabelled: FieldTypePresentation<'address'> = {label: 'Address', input: 'address'}; + +// @ts-expect-error a type whose value is one thing has no parts to name +const scalarWithParts: FieldTypePresentation<'short_text'> = {label: 'Short text', input: 'text', subFields: {line1: 'a'}}; + +export {labelled, missingPart, unknownPart, unlabelled, scalarWithParts}; const field = (overrides: Partial): MemberCustomField => ({ key: 'nickname', @@ -34,5 +55,35 @@ describe('member custom fields api helpers', () => { it('returns no targets for an empty field set', () => { expect(memberCustomFieldCsvColumns([])).toEqual([]); }); + + // An admin build older than the server it talks to is handed a type it has no + // presentation for. The mapping picker offering one fewer column beats it throwing. + it('offers a whole-column target for a type it has never heard of', () => { + const future = field({key: 'mystery', name: 'Mystery', type: 'a_type_from_the_future' as MemberCustomField['type']}); + + expect(memberCustomFieldCsvColumns([future])).toEqual([ + {label: 'Mystery', value: 'custom_fields.mystery'} + ]); + }); + }); + + describe('memberCustomFieldParts', () => { + // The null is the contract a caller branches on to tell a composite from a + // scalar, so it is pinned by name rather than only through the CSV columns. + it('has no parts for a scalar type', () => { + expect(memberCustomFieldParts('short_text')).toBeNull(); + expect(memberCustomFieldParts('long_text')).toBeNull(); + }); + + it('names a composite type\'s parts in the order its value schema declares them', () => { + expect(memberCustomFieldParts('address')).toEqual([ + {key: 'line1', label: 'Address line 1'}, + {key: 'line2', label: 'Address line 2'}, + {key: 'city', label: 'City'}, + {key: 'state', label: 'State'}, + {key: 'postal_code', label: 'Postal code'}, + {key: 'country', label: 'Country'} + ]); + }); }); }); diff --git a/apps/admin/src/members/detail/member-custom-fields-field.tsx b/apps/admin/src/members/detail/member-custom-fields-field.tsx index fe62c5e9b0c..a81d66fc591 100644 --- a/apps/admin/src/members/detail/member-custom-fields-field.tsx +++ b/apps/admin/src/members/detail/member-custom-fields-field.tsx @@ -2,10 +2,10 @@ import React from 'react'; import {Button, Card, CardContent, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, LoadingIndicator, Textarea} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {dequal} from 'dequal'; -import {ADDRESS_SUBFIELD_KEYS, buildCustomFieldSavePayload, getCustomFieldValidationErrors, getEditableCustomFieldValues, parseCustomFieldServerErrors} from './member-detail-edit'; +import {ADDRESS_PARTS, buildCustomFieldSavePayload, getCustomFieldValidationErrors, getEditableCustomFieldValues, parseCustomFieldServerErrors} from './member-detail-edit'; import {formatAddressValue} from './member-detail-format'; import {toast} from 'sonner'; -import {useBrowseMemberCustomFields, userTypeForField, userTypeForFieldType} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {useBrowseMemberCustomFields, userTypeForField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {useEditMember} from '@tryghost/admin-x-framework/api/members'; import type {EditableAddressValue, EditableCustomFieldValue} from './member-detail-edit'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; @@ -19,9 +19,6 @@ interface MemberCustomFieldsFieldProps { disabled?: boolean; } -// Shared with the CSV import mapping so a sub-field reads the same on every surface. -const ADDRESS_SUBFIELD_LABELS = userTypeForFieldType('address').subFields ?? {}; - // role='alert': after a save-attempt these render while focus stays on the Save // button, so an assertive live region is the only way a screen reader hears the // failure. @@ -43,12 +40,12 @@ const AddressInput: React.FC<{ }> = ({inputId, value, errors, disabled, onChange}) => { return (
- {ADDRESS_SUBFIELD_KEYS.map((subfield) => { + {ADDRESS_PARTS.map(({key: subfield, label}) => { const subfieldId = `${inputId}-${subfield}`; const error = errors?.[subfield]; return (
- + void; }> = ({field, inputId, value, errors, disabled, onChange}) => { const fieldError = errors?.['']; - switch (userTypeForField(field).input) { + const {input} = userTypeForField(field); + switch (input) { case 'text': return onChange(e.target.value)} />; case 'textarea': @@ -96,7 +99,9 @@ const CustomFieldInput: React.FC<{ /> ); default: - return null; + // Unreachable: `input` comes from the catalog, not the server. Typed never so a + // control added to the catalog fails the build here rather than rendering nothing. + return assertNoControl(input); } }; diff --git a/apps/admin/src/members/detail/member-detail-edit.ts b/apps/admin/src/members/detail/member-detail-edit.ts index 346286f6cc2..d55adc1012e 100644 --- a/apps/admin/src/members/detail/member-detail-edit.ts +++ b/apps/admin/src/members/detail/member-detail-edit.ts @@ -1,14 +1,16 @@ import moment from 'moment-timezone'; -import {MEMBER_CUSTOM_FIELD_TYPES} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {MEMBER_CUSTOM_FIELD_TYPES, memberCustomFieldParts} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {dequal} from 'dequal'; import type {EditMemberData, Member} from '@tryghost/admin-x-framework/api/members'; import type {MemberCustomField, MemberCustomFieldAddress} from '@tryghost/admin-x-framework/api/member-custom-fields'; -// The sub-fields of the address composite, in display order. Partial because a -// draft mid-edit (or a normalized sparse value) may hold any subset; the shared -// AddressValue schema — enforced by the server — decides completeness. -export const ADDRESS_SUBFIELD_KEYS = ['line1', 'line2', 'city', 'state', 'postal_code', 'country'] as const; -export type EditableAddressValue = Partial>; +// The parts of the address composite, in the order its value schema declares them, each +// with the label every other surface shows it under. +export const ADDRESS_PARTS = memberCustomFieldParts('address') ?? []; + +// Partial because a draft mid-edit (or a normalized sparse value) may hold any subset; the +// shared AddressValue schema — enforced by the server — decides completeness. +export type EditableAddressValue = Partial; export type EditableCustomFieldValue = string | EditableAddressValue; export interface MemberEditableLabel { @@ -99,10 +101,10 @@ export function getEditableCustomFieldValues(customFields: Record): EditableAddressValue | undefined { const address: EditableAddressValue = {}; - for (const subfield of ADDRESS_SUBFIELD_KEYS) { - const subvalue = value[subfield]; + for (const {key} of ADDRESS_PARTS) { + const subvalue = value[key]; if (typeof subvalue === 'string') { - address[subfield] = subvalue.trim(); + address[key] = subvalue.trim(); } } return Object.values(address).some(part => part !== '') ? address : undefined; @@ -115,10 +117,10 @@ function addressToSave(value: Record): EditableAddressValue | u */ function normalizeAddressValue(value: Record): EditableAddressValue | undefined { const address: EditableAddressValue = {}; - for (const subfield of ADDRESS_SUBFIELD_KEYS) { - const subvalue = value[subfield]; + for (const {key} of ADDRESS_PARTS) { + const subvalue = value[key]; if (typeof subvalue === 'string' && subvalue.trim() !== '') { - address[subfield] = subvalue.trim(); + address[key] = subvalue.trim(); } } return Object.keys(address).length ? address : undefined; diff --git a/apps/admin/src/members/detail/member-detail-format.ts b/apps/admin/src/members/detail/member-detail-format.ts index 2e31d6c7d7d..383915186e4 100644 --- a/apps/admin/src/members/detail/member-detail-format.ts +++ b/apps/admin/src/members/detail/member-detail-format.ts @@ -1,3 +1,5 @@ +import type {MemberCustomFieldAddress} from '@tryghost/admin-x-framework/api/member-custom-fields'; + export interface MemberGeolocation { country_code?: string; country?: string; @@ -54,8 +56,12 @@ export function formatMemberLocation(rawGeolocation: string | null | undefined): * "1 Main St, 12 apt B, New York, NY 00001, US". State and postal code pair * up the way people write them; whatever sub-fields are missing simply drop * out, so a partial address still reads naturally. + * + * The parts are named one by one rather than walked, because where each sits in the + * sentence is a fact about how an address reads, not one the value schema can supply. A + * part added upstream will not appear here until someone decides where it belongs. */ -export function formatAddressValue(address: Partial>): string { +export function formatAddressValue(address: Partial): string { const statePostal = [address.state, address.postal_code].filter(Boolean).join(' '); return [address.line1, address.line2, address.city, statePostal, address.country] .filter(Boolean) diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx index ae521c2fb8a..18205b5fa54 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx @@ -23,10 +23,6 @@ const features: Feature[] = [{ title: 'Stripe Automatic Tax (private beta)', description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe', flag: 'stripeAutomaticTax' -}, { - title: 'Email customization (internal beta)', - description: 'Newsletter customization settings that have been released to Ghost\'s own production sites', - flag: 'emailCustomization' }, { title: 'Import Member Tier', description: 'Enables tier to be specified when importing members', @@ -55,10 +51,6 @@ const features: Feature[] = [{ title: 'Picture Element', description: 'Use the HTML picture element to serve modern image formats (AVIF, WebP) with automatic fallbacks', flag: 'pictureImageFormats' -}, { - title: 'Smarter Counts', - description: 'Use optimized COUNT queries for API pagination when safe', - flag: 'smarterCounts' }, { title: 'Get helper deduplication', description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls', diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx b/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx index 163851e7de1..f02999d1a86 100644 --- a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx @@ -1,4 +1,4 @@ -import CustomFieldIcon from './custom-fields/custom-field-icon'; +import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; import CustomFieldModal from './custom-fields/custom-field-modal'; import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useRef, useState} from 'react'; diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx index ef9fc2ba5ee..d5d09684ec7 100644 --- a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx @@ -1,6 +1,5 @@ -import CustomFieldIcon from './custom-field-icon'; +import {CustomFieldTypeOption} from '@/shared/member-custom-fields/custom-field-type-option'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import React from 'react'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; @@ -11,24 +10,8 @@ import {useConfirmation} from '@/settings/app/components/providers/confirmation- import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; -const typeOptions = memberCustomFieldUserTypes.map(userType => ({value: userType.id, label: userType.label})); - const userTypeById = (id: string) => memberCustomFieldUserTypes.find(userType => userType.id === id) || memberCustomFieldUserTypes[0]; -// Fixed-width so option labels align in a column regardless of icon shape. -const TypeTile: React.FC<{userTypeId: string}> = ({userTypeId}) => ( - - - -); - -const renderTypeOption = (option: {label: string; value: string}) => ( - - - {option.label} - -); - const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) => { const modal = useModal(); const {confirm} = useConfirmation(); @@ -78,7 +61,7 @@ const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) }); const isArchived = field?.status === 'archived'; - const selectedType = typeOptions.find(option => option.value === formState.userTypeId); + const selectedType = userTypeById(formState.userTypeId); // The modal's third action mirrors the field's state: an active field can // be archived, an archived one reactivated. Both confirm first (the @@ -214,10 +197,10 @@ const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) updateForm(state => ({...state, userTypeId: userTypeById(value).id})); }}> - {selectedType && renderTypeOption(selectedType)} + - {typeOptions.map(option => {renderTypeOption(option)})} + {memberCustomFieldUserTypes.map(userType => )} {isEdit && Type can’t be changed after creation} diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-icon.tsx b/apps/admin/src/shared/member-custom-fields/custom-field-icon.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-icon.tsx rename to apps/admin/src/shared/member-custom-fields/custom-field-icon.tsx diff --git a/apps/admin/src/shared/member-custom-fields/custom-field-type-option.tsx b/apps/admin/src/shared/member-custom-fields/custom-field-type-option.tsx new file mode 100644 index 00000000000..6b725848a83 --- /dev/null +++ b/apps/admin/src/shared/member-custom-fields/custom-field-type-option.tsx @@ -0,0 +1,21 @@ +import CustomFieldIcon from './custom-field-icon'; +import {userTypeForFieldType} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; + +/** + * A field type as it appears in a picker: its icon and its name. + * + * Shared rather than owned by Settings so that wherever a publisher is offered the field + * types, they read the same, and so a type's icon is decided in one place. + */ +export function CustomFieldTypeOption({type}: {type: MemberCustomField['type']}) { + return ( + + {/* Fixed width so labels line up in a column whatever shape the icon is. */} + + + + {userTypeForFieldType(type).label} + + ); +} diff --git a/apps/ember-admin/app/components/posts-list/list-item-analytics.hbs b/apps/ember-admin/app/components/posts-list/list-item-analytics.hbs index e5245dc0d89..7d705da0485 100644 --- a/apps/ember-admin/app/components/posts-list/list-item-analytics.hbs +++ b/apps/ember-admin/app/components/posts-list/list-item-analytics.hbs @@ -72,16 +72,6 @@ {{svg-jar "star-fill" class="gh-featured-post"}} {{/if}} {{@post.title}} - - {{! Display lexical/mobiledoc indicators for easier testing of the feature --}} - {{#if (feature 'lexicalIndicators')}} - {{#if @post.lexical}} - L - {{else if @post.mobiledoc}} - M - {{/if}} - {{/if}} - {{#unless @hideAuthor }}