diff --git a/.changeset/common-squids-argue.md b/.changeset/common-squids-argue.md
new file mode 100644
index 00000000000..538ca0cd0a7
--- /dev/null
+++ b/.changeset/common-squids-argue.md
@@ -0,0 +1,15 @@
+---
+"@tryghost/kg-utils": patch
+"@tryghost/kg-unsplash-selector": patch
+"@tryghost/kg-markdown-html-renderer": patch
+"@tryghost/kg-lexical-html-renderer": patch
+"@tryghost/kg-html-to-lexical": patch
+"@tryghost/kg-default-transforms": patch
+"@tryghost/kg-default-nodes": patch
+"@tryghost/kg-default-cards": patch
+"@tryghost/kg-converters": patch
+"@tryghost/kg-clean-basic-html": patch
+"@tryghost/kg-card-factory": patch
+---
+
+Documented the package API and corrected the development instructions in the README
diff --git a/.changeset/funky-bikes-chew.md b/.changeset/funky-bikes-chew.md
new file mode 100644
index 00000000000..844f52d6738
--- /dev/null
+++ b/.changeset/funky-bikes-chew.md
@@ -0,0 +1,5 @@
+---
+"@tryghost/koenig-lexical": patch
+---
+
+Updated the test commands in the README
diff --git a/.changeset/olive-regions-stop.md b/.changeset/olive-regions-stop.md
new file mode 100644
index 00000000000..286b0090c18
--- /dev/null
+++ b/.changeset/olive-regions-stop.md
@@ -0,0 +1,5 @@
+---
+"@tryghost/koenig-lexical": patch
+---
+
+Added a package description for npm
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 250aa9ce79f..5006aa5eff4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -173,9 +173,9 @@ jobs:
- '.github/workflows/ci.yml'
core:
- *shared
- # Repository documentation and ownership metadata do not affect
- # Ghost runtime behaviour, even though they live in .github.
- - '!.github/**/*.md'
+ # Documentation and ownership metadata do not affect Ghost
+ # runtime behaviour, even when they live inside a project root.
+ - '!**/*.md'
- '!.github/CODEOWNERS'
- 'ghost/**'
- '!ghost/core/core/server/data/tinybird/**'
@@ -234,6 +234,18 @@ jobs:
- name: Determine Affected Projects
id: affected
run: |
+ # Nx treats README files as project inputs. Avoid populating code-test
+ # matrices when every changed file is documentation.
+ if [[ "${{ env.IS_TAG }}" != 'true' && "${{ steps.changed.outputs.any-code }}" != 'true' ]]; then
+ echo 'affected_projects=[]' >> "$GITHUB_OUTPUT"
+ echo 'affected_projects_str=' >> "$GITHUB_OUTPUT"
+ echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT"
+ echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT"
+ echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT"
+ echo 'publish_public_apps_matrix=[]' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
# if the ci files have changed or we're in a tag, ensure we don't just look at affected
# projects and we run the necessary jobs on all projects
AFFECTED_ARG="--affected"
diff --git a/AGENTS.md b/AGENTS.md
index 42b6f935639..103b0e036e9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,7 +88,7 @@ import Interpolate from '@doist/react-interpolate';
{t('Could not sign in.')} {t('Click here to retry')}
```
-See `apps/portal/src/components/pages/email-receiving-faq.js` for a canonical example of correct `Interpolate` usage.
+See `apps/portal/src/components/pages/email-receiving-faq.jsx` for a canonical example of correct `Interpolate` usage.
### Build Dependencies (Nx)
diff --git a/e2e/.claude/E2E_TEST_WRITING_GUIDE.md b/e2e/.claude/E2E_TEST_WRITING_GUIDE.md
index 568be79c1ab..17db381114b 100644
--- a/e2e/.claude/E2E_TEST_WRITING_GUIDE.md
+++ b/e2e/.claude/E2E_TEST_WRITING_GUIDE.md
@@ -1,56 +1,37 @@
# E2E Test Writing Guide
-## Overview
-This guide provides instructions for writing E2E tests in the `/e2e/` directory using TypeScript and Playwright. Tests follow the Page Object Model pattern and utilize data factories for test data management.
+Worked examples for writing E2E tests in `/e2e/` with TypeScript and Playwright.
-## Environment Setup
+This guide covers *how to build the pieces* — page objects, common interaction
+patterns, and selector discovery. It deliberately does not repeat what is
+already documented elsewhere:
-### Running Tests
-```bash
-# From the e2e directory
-cd e2e
-
-# Run all tests
-pnpm test
+| For | Read |
+| --- | --- |
+| Running tests, dev/build modes, debugging, folder layout, test isolation, fixtures | [README.md](../README.md) |
+| Rules, locator priority, AAA structure, DO/DON'T, validation checklist | [AGENTS.md](../AGENTS.md) |
+| Available factories and how to add one | [data-factory/README.md](../data-factory/README.md) |
-# Run specific test file
-pnpm test tests/admin/feature.test.ts
+## Conventions
-# Run with visible browser (debugging)
-pnpm test --debug
+**Filenames are kebab-case.** `eslint.config.js` enforces
+`^[a-z0-9.-]+$` at error level, so `FeaturePage.ts` fails lint.
-# Run with specific timeout
-pnpm test --timeout=60000
+- Test files: `.test.ts`, named after the behaviour under test rather
+ than the page — `two-factor-auth.test.ts`, `member-signup.test.ts`
+- Page objects: `-page.ts` — `login-page.ts`, `admin-page.ts`
+- Class names stay PascalCase: `login-page.ts` exports `class LoginPage`
-# Keep environment running after test (useful for Playwright MCP exploration)
-PRESERVE_ENV=true pnpm test
+**Import through the `@/` path aliases**, never relative paths. The aliases are
+defined in `tsconfig.json`:
-# Enable debug logging
-DEBUG=@tryghost/e2e:* pnpm test
-```
-
-## Test Organization
-
-### Directory Structure
-```
-e2e/
-├── tests/
-│ ├── admin/ # Admin panel tests
-│ ├── public/ # Public site tests
-│ └── [area]/ # Other test areas
-├── helpers/
-│ ├── pages/ # Page Objects
-│ │ ├── admin/ # Admin page objects
-│ │ └── public/ # Public page objects
-│ └── playwright/ # Test fixtures and setup
-└── data-factory/ # Test data generators
+```typescript
+import {expect, test} from '@/helpers/playwright';
+import {LoginPage, PostsPage} from '@/admin-pages';
+import {createPostFactory} from '@/data-factory';
+import {usePerTestIsolation} from '@/helpers/playwright/isolation';
```
-### Test File Naming
-- Test files: `[PageName].test.ts` - Named after the page being tested (e.g., `PostEditor.test.ts`, `MembersList.test.ts`)
-- Page objects: `[Feature]Page.ts` (PascalCase)
-- Use descriptive names that clearly indicate what's being tested
-
## Page Object Pattern
### Core Principles
@@ -65,15 +46,15 @@ interaction when a Page Object would add indirection without reuse.
### Creating a Page Object
```typescript
-// e2e/helpers/pages/admin/FeaturePage.ts
-import {Page, Locator} from '@playwright/test';
-import {AdminPage} from './AdminPage';
+// e2e/helpers/pages/admin/feature-page.ts
+import {AdminPage} from './admin-page';
+import {Locator, Page} from '@playwright/test';
export class FeaturePage extends AdminPage {
// Define locators as readonly properties
- readonly elementName: Locator;
- readonly buttonName: Locator;
- readonly modalDialog: Locator;
+ readonly nameInput: Locator;
+ readonly saveButton: Locator;
+ readonly statusMessage: Locator;
constructor(page: Page) {
super(page);
@@ -81,88 +62,75 @@ export class FeaturePage extends AdminPage {
// Selector priority (use in this order):
// 1. ARIA roles with accessible names
- this.buttonName = page.getByRole('button', {name: 'Button Text'});
+ this.saveButton = page.getByRole('button', {name: 'Save'});
// 2. Labels for form elements
- this.elementName = page.getByLabel('Field Label');
+ this.nameInput = page.getByLabel('Name');
// 3. Text content (for unique text)
- this.elementName = page.getByText('Unique text');
+ this.statusMessage = page.getByText('Saved');
// 4. Stable test IDs when semantic locators are unavailable
- this.elementName = page.getByTestId('element-id');
+ // page.getByTestId('element-id');
// 5. Stable structural selectors only when necessary
}
// Action methods
- async performAction(): Promise {
- await this.buttonName.click();
- }
-
- async fillForm(data: {field1: string; field2: string}): Promise {
- await this.field1Input.fill(data.field1);
- await this.field2Input.fill(data.field2);
- }
-
- // State verification methods
- async isElementVisible(): Promise {
- return await this.elementName.isVisible();
- }
-
- async getElementText(): Promise {
- return await this.elementName.textContent() || '';
+ async save(): Promise {
+ await this.saveButton.click();
+ await this.statusMessage.waitFor({state: 'visible'});
}
- // Common utility methods (add to AdminPage or BasePage for reuse)
- async pressEscape(): Promise {
- await this.page.keyboard.press('Escape');
+ async fillForm(data: {name: string}): Promise {
+ await this.nameInput.fill(data.name);
}
- async waitForAutoSave(): Promise {
- await this.page.waitForFunction(() => {
- const status = document.querySelector('[data-test="status"]');
- return status?.textContent?.includes('Saved');
- });
+ // State verification methods — return locators or values, never assert
+ async getStatusText(): Promise {
+ return await this.statusMessage.textContent() || '';
}
}
```
+`BasePage` already provides `goto()`, `refresh()`, `pressKey()` and the `body`
+locator, and sets `pageUrl` from the constructor. `AdminPage` extends it with
+the `/ghost` base URL — subclasses override `pageUrl` for their own route.
+
### Modal/Dialog Pattern
+Modals are plain classes rather than page subclasses, scoping their locators to
+the dialog and waiting on visibility state as part of each action:
+
```typescript
+import {Locator, Page} from '@playwright/test';
+
export class FeatureModal {
private readonly page: Page;
- readonly modal: Locator;
- readonly closeButton: Locator;
- readonly saveButton: Locator;
+ public readonly modal: Locator;
+ public readonly saveButton: Locator;
+ public readonly cancelButton: Locator;
constructor(page: Page) {
this.page = page;
this.modal = page.getByRole('dialog');
- this.closeButton = this.modal.getByRole('button', {name: 'Close'});
this.saveButton = this.modal.getByRole('button', {name: 'Save'});
+ this.cancelButton = this.modal.getByRole('button', {name: 'Cancel'});
}
- async waitForVisible(): Promise {
+ async waitForModal(): Promise {
await this.modal.waitFor({state: 'visible'});
}
- async waitForHidden(): Promise {
+ async save(): Promise {
+ await this.saveButton.click();
await this.modal.waitFor({state: 'hidden'});
}
-
- async close(): Promise {
- await this.closeButton.click();
- await this.waitForHidden();
- }
-
- async isVisible(): Promise {
- return await this.modal.isVisible();
- }
}
```
+See `helpers/pages/admin/posts/custom-view-modal.ts` for a live example.
+
### Extending Base Pages
```typescript
@@ -171,181 +139,49 @@ export class PostEditorPage extends AdminPage {
// Implementation
}
-// Public pages extend BasePage
+// Public and portal pages extend BasePage
export class PublicHomePage extends BasePage {
// Implementation
}
```
-## Writing Tests
-
-### Test Structure (AAA Pattern)
-
-**Important: Write self-documenting tests without comments. Test names and method names should clearly express intent. If complex logic is needed, extract it to a well-named method in the Page Object.**
-
-Use **Arrange–Act–Assert (AAA)** as a readability heuristic:
-- **Arrange**: Set up test data and page objects
-- **Act**: Perform the actions being tested
-- **Assert**: Verify the expected outcomes
-
-The structure should be visually clear through spacing, not comments:
-
-```typescript
-import {test, expect} from '../../helpers/playwright';
-import {FeaturePage} from '../../helpers/pages/admin/FeaturePage';
-import {createPostFactory} from '../../data-factory';
-
-test.describe('Feature Name', () => {
- test('should perform expected behavior', async ({page, ghostInstance}) => {
- const featurePage = new FeaturePage(page);
- const postFactory = createPostFactory(page.request);
- const post = await postFactory.create({title: 'Test Post'});
-
- await featurePage.goto();
- await featurePage.performAction();
-
- expect(await featurePage.isElementVisible()).toBe(true);
- expect(await featurePage.getResultText()).toContain('Expected text');
- });
-});
-```
-
-### Test Fixtures
-
-The `page` fixture provides:
-- Pre-authenticated browser session (logged into Ghost admin)
-- Automatic cleanup after test
-
-The `ghostInstance` fixture provides:
-- `baseUrl`: The URL of the Ghost instance
-- `database`: Database name for this test
-- `port`: Port number the instance is running on
-
-Additional standalone fixtures exported from `helpers/playwright/fixture.ts` and re-exported by `@/helpers/playwright`:
-- `resolvedIsolation`: `'per-file' | 'per-test'`
-- `resetEnvironment()`: force a full environment recycle in per-file mode before stateful fixtures are resolved
-
-```typescript
-test.beforeEach(async ({resetEnvironment, resolvedIsolation}) => {
- if (resolvedIsolation === 'per-file') {
- await resetEnvironment();
- }
-});
-```
-
-Isolation rules:
-- Default is per-file isolation, so the underlying Ghost environment can be reused across tests in the same file.
-- Call `usePerTestIsolation()` at the root of a file to switch to per-test isolation and force a fresh Ghost environment for each test.
-- Import it from `@/helpers/playwright/isolation`.
-- `config` and `labs` participate in the per-file environment identity. If either changes, the shared environment is recycled.
-- `stripeEnabled` always forces per-test isolation because Ghost must boot against a per-test fake Stripe server.
-- `resetEnvironment()` is a hook-only escape hatch. Do not call it after `baseURL`, `page`, `pageWithAuthenticatedUser`, or `ghostAccountOwner` has already been resolved.
-- Do not treat `resetEnvironment()` as an in-test cleanup step. If you recycle the environment, you must re-establish any stateful fixtures, and the supported pattern is to call it in `beforeEach` before those fixtures are created.
-- ESLint catches direct misuse, but the runtime guard in the fixture is the final enforcement.
-
-When to use each option:
-- `config`: for boot-time Ghost config such as billing URLs or force-upgrade flags.
-- `labs`: for tests that need specific labs flags on or off.
-- `stripeEnabled`: for tests that need the fake Stripe server and Stripe-backed Ghost boot config.
-- `usePerTestIsolation()`: for whole files that mutate shared state heavily and should never reuse a Ghost environment across tests.
-
-## Data Factories
-
-### Using Data Factories
-
-Data factories provide a clean way to create test data. Import the factory you need and use it to generate data with specific attributes.
-
-```typescript
-import {createPostFactory, createMemberFactory} from '../../data-factory';
-
-test('test with data', async ({page}) => {
- const postFactory = createPostFactory(page.request);
- const memberFactory = createMemberFactory(page);
-
- const post = await postFactory.create({
- title: 'Test Post',
- content: 'Test content',
- status: 'published'
- });
-
- const member = await memberFactory.create({
- name: 'Test Member',
- email: 'test@example.com'
- });
-
- const postEditorPage = new PostEditorPage(page);
- await postEditorPage.gotoExistingPost(post.id);
-});
-```
-
-### Factory Pattern
-Factories are available for various Ghost entities. Check the `data-factory` directory for available factories. Common examples include:
-- Creating posts with different statuses and content
-- Creating members with subscriptions
-- Creating staff users with specific roles
-- Creating tags, offers, and other entities
-
-New factories are added as needed. When you need test data that doesn't have a factory yet, consider creating one rather than manually constructing the data.
-
-## Best Practices
-
-### DO's
-✅ **Use Page Objects for reusable UI structure and interactions**
-✅ **Write self-documenting tests** with clear method and test names
-✅ **Check existing Page Objects before creating new ones**
-✅ **Use proper waits** (`waitForLoadState`, `waitFor`, etc.)
-✅ **Keep tests isolated** - Each test gets its own Ghost instance
-✅ **Use descriptive test names** that explain what's being tested
-✅ **Extract complex logic to well-named methods** in Page Objects
-✅ **Use data factories** for complex test data
-✅ **Add meaningful assertions** beyond just visibility checks
-
-### DON'Ts
-❌ **Don't duplicate reusable selectors and interactions across test files**
-❌ **Don't write comments** - make code self-documenting instead
-❌ **Don't use hardcoded waits** (`page.waitForTimeout`)
-❌ **Don't use networkidle in waits** (`page.waitForLoadState('networkidle')`) - rely on web assertions to assess readiness instead
-❌ **Don't depend on test execution order**
-❌ **Don't manually log in** - use the pre-authenticated fixture
-❌ **Avoid XPath and selectors coupled to styling or DOM position**
-❌ **Don't create test data manually** if a factory exists
-
## Common Patterns
### Waiting for Elements
```typescript
-// Good - explicit waits
+// Good - wait on a locator's state, or use a web assertion
await element.waitFor({state: 'visible'});
-await page.waitForSelector('[data-test="element"]');
+await expect(page.getByRole('status')).toContainText('Saved');
-// Bad - arbitrary timeouts
-await page.waitForTimeout(5000); // Avoid this!
+// Bad - arbitrary timeouts and networkidle
+await page.waitForTimeout(5000);
+await page.waitForLoadState('networkidle');
```
### Handling Async Operations
+Wait for the UI signal the user would look for, not a fixed delay:
+
```typescript
-// Wait for save to complete
-await page.waitForFunction(() => {
- const status = document.querySelector('[data-test="status"]');
- return status?.textContent?.includes('Saved');
-});
+async waitForSave(): Promise {
+ await this.saveButton.click();
+ await this.statusMessage.waitFor({state: 'visible'});
+}
```
### Working with iframes
+Use `frameLocator()` — it retries like any other locator:
+
```typescript
-// Access iframe content
-const iframe = page.locator('iframe[title="preview"]');
-const frameContent = iframe.contentFrame();
-await frameContent.click('button');
+this.portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]');
+await this.portalFrame.getByRole('button', {name: 'Continue'}).click();
```
### Keyboard Shortcuts
```typescript
-// Press keyboard keys
await page.keyboard.press('Escape');
await page.keyboard.press('Control+S');
await page.keyboard.type('Hello World');
@@ -356,21 +192,18 @@ await page.keyboard.type('Hello World');
### Common Selectors
- Navigation: `data-test-nav="[section]"`
- Buttons: `data-test-button="[action]"`
-- Lists: `data-test-list="[name]"`
-- Modals: `[role="dialog"]` or `.gh-modal`
+- Modals: `[role="dialog"]`
- Loading states: `.gh-loading-spinner`
+Ember Admin uses `data-test-*` attributes; the React Admin apps use
+`data-testid`. Prefer a role or label over either where one exists.
+
### Admin URLs
- Editor: `/ghost/#/editor/post/[id]`
- Posts list: `/ghost/#/posts`
- Settings: `/ghost/#/settings`
- Members: `/ghost/#/members`
-### Common UI Elements
-- Buttons: `.gh-btn-[color]` (e.g., `.gh-btn-primary`)
-- Inputs: Often use `name` or `placeholder` attributes
-- Status indicators: `[data-test="status"]`
-
## Using Playwright MCP for Page Object Discovery
When creating new Page Objects or discovering selectors for unfamiliar UI:
@@ -401,98 +234,27 @@ mcp__playwright__browser_take_screenshot({filename: "feature-state.png"})
### 3. Extract Selectors for Page Objects
Based on your exploration, create the Page Object with discovered selectors:
- Note the element references from snapshots
-- Identify the best selector strategy (testId, role, label, text)
+- Identify the best selector strategy (role, label, text, testId)
- Test interactions before finalizing the Page Object
-## Debugging
-
-### Debug Mode
-```bash
-# See browser while test runs
-pnpm test --debug
-
-# UI mode for interactive debugging
-pnpm test --ui
-```
-
-### Debug Logging
-```bash
-# Enable all e2e debug logs
-DEBUG=@tryghost/e2e:* pnpm test
-
-# Specific debug namespace
-DEBUG=@tryghost/e2e:ghost-fixture pnpm test
-```
-
-### Preserve Environment
-```bash
-# Keep containers running after test
-PRESERVE_ENV=true pnpm test
-```
-
-### Test Artifacts
-- Screenshots on failure: `test-results/`
-- Playwright traces: `test-results/`
-
-## Test Isolation
-
-Each test automatically gets:
-1. **Fresh Ghost instance** with unique database
-2. **Unique port** to avoid conflicts
-3. **Pre-authenticated session**
-4. **Automatic cleanup** after test completion
-
-You don't need to worry about:
-- Database cleanup
-- Port conflicts
-- Login/logout
-- Test data pollution
+## Test Template
-## Validation Checklist
-
-Before submitting a test:
-- [ ] Reusable UI behavior is in Page Objects
-- [ ] Arrange, Act, and Assert phases are easy to identify
-- [ ] Test is deterministic (not flaky)
-- [ ] Uses proper waits (no arbitrary timeouts)
-- [ ] Has meaningful assertions
-- [ ] Follows naming conventions
-- [ ] Reuses existing Page Objects where possible
-- [ ] Test passes locally
-- [ ] Test fails for the right reason (if demonstrating a bug)
-
-## Quick Reference
-
-### Essential Imports
```typescript
-import {test, expect} from '../../helpers/playwright';
-import {PageName} from '../../helpers/pages/admin/PageName';
-import {createPostFactory} from '../../data-factory';
-```
+import {expect, test} from '@/helpers/playwright';
+import {FeaturePage} from '@/admin-pages';
+import {createPostFactory} from '@/data-factory';
-### Test Template
-```typescript
-test.describe('Feature', () => {
- test('specific behavior', async ({page, ghostInstance}) => {
- // Arrange
- const pageObject = new PageObject(page);
+test.describe('Ghost Admin - Feature', () => {
+ test('action performed - expected result', async ({page}) => {
+ const featurePage = new FeaturePage(page);
+ const postFactory = createPostFactory(page.request);
+ const post = await postFactory.create({title: 'Test Post'});
- // Act
- await pageObject.goto();
- await pageObject.action();
+ await featurePage.goto();
+ await featurePage.fillForm({name: post.title});
+ await featurePage.save();
- // Assert
- expect(await pageObject.getState()).toBe(expected);
+ await expect(featurePage.statusMessage).toBeVisible();
});
});
```
-
-### Run Commands
-```bash
-pnpm test # All tests
-pnpm test path/to/test.ts # Specific test
-pnpm test --debug # With browser
-pnpm test --grep "pattern" # Pattern matching
-PRESERVE_ENV=true pnpm test # Keep environment
-DEBUG=@tryghost/e2e:* pnpm test # Debug logs
-```
diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md
index 0d387e61489..346dffa6f50 100644
--- a/e2e/AGENTS.md
+++ b/e2e/AGENTS.md
@@ -12,17 +12,21 @@ When creating or modifying E2E tests, follow it first. Use
3. **Prefer semantic locators**, then stable test IDs
4. **Keep reusable UI structure and interactions in Page Objects**
5. **Avoid selectors coupled to styling or DOM position**
-6. **Prefer clear names over explanatory comments**
+6. **Prefer clear names and structure over explanatory comments**; add a
+ comment when an AAA boundary would otherwise be unclear
## Running E2E Tests
-**`pnpm dev` must be running before you run E2E tests.** The E2E test runner auto-detects
-whether the admin dev server is reachable at `http://127.0.0.1:5174`. If it is, tests run
-in **dev mode** (fast, no pre-built Docker image required). If not, tests fall back to
-**build mode** which requires a `ghost-e2e:local` Docker image that is only built in CI.
+For normal development, start `pnpm dev` before running E2E tests. The runner
+auto-detects whether the Admin dev server is reachable at
+`http://127.0.0.1:5174`: when it is, tests use **dev mode**, which is the fastest
+feedback loop and does not require a prebuilt Ghost E2E image.
-**If you see the error `Build image not found: ghost-e2e:local`, it means `pnpm dev` is
-not running.** Start it first, wait for the admin dev server to be ready, then re-run tests.
+The suite also supports **build mode** for local CI-like testing without dev
+servers. Build mode requires a prepared `ghost-e2e:local` image; follow the
+commands in the canonical README's [Build Mode](./README.md#build-mode-prebuilt-image)
+section. If `Build image not found: ghost-e2e:local` appears unexpectedly, either
+start `pnpm dev` to use dev mode or prepare the build-mode image.
```bash
# Terminal 1 (or background): Start dev environment from the repo root
@@ -110,12 +114,15 @@ practical.
### Factory Pattern (Required)
```typescript
-import {PostFactory, UserFactory} from '../data-factory';
+import {createPostFactory} from '@/data-factory';
const postFactory = createPostFactory(page.request);
-const post = await postFactory.create({userId: user.id});
+const post = await postFactory.create({title: 'Test Post'});
```
+Import through the `@/` path aliases in `tsconfig.json` (`@/data-factory`,
+`@/helpers/playwright`, `@/admin-pages`), never relative paths.
+
## Best Practices
### DO ✅
diff --git a/e2e/README.md b/e2e/README.md
index f548fe1c24c..a4a7623553e 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -1,6 +1,10 @@
# Ghost End-To-End Test Suite
-This test suite runs automated browser tests against a running Ghost instance to ensure critical user journeys work correctly.
+This top-level workspace is Ghost's browser end-to-end test suite. It runs
+automated browser tests against a complete, running Ghost instance to verify
+critical user journeys across packages and applications. A package's own
+Playwright suite is an *acceptance* suite, not an E2E one — see the
+[testing guide](../docs/contributing/testing.md) for how the layers differ.
## Quick Start
@@ -100,40 +104,48 @@ The test suite is organized into separate directories for different areas/functi
### **Current Test Suites**
- `tests/public/` - Public-facing site tests (homepage, posts, etc.)
- `tests/admin/` - Ghost admin panel tests (login, content creation, settings)
+- `tests/portal/` - Portal member journey tests
We can decide whether to add additional sub-folders as we add more tests.
-Example structure for admin tests:
+Filenames are kebab-case — `eslint.config.js` enforces this. Test files end in
+`.test.ts` and are named after the behaviour under test, not the page:
+
```text
tests/admin/
-├── login.spec.ts
-├── posts.spec.ts
-└── settings.spec.ts
+├── signin.test.ts
+├── two-factor-auth.test.ts
+└── whats-new.test.ts
```
-Project folder structure can be seen below:
+Project folder structure can be seen below:
```text
e2e/
├── tests/ # All the tests
│ ├── public/ # Public site tests
-│ │ └── testname.spec.ts # Test cases
+│ │ └── member-signup.test.ts
│ ├── admin/ # Admin site tests
-│ │ └── testname.spec.ts # Test cases
+│ │ └── signin.test.ts
+│ ├── portal/ # Portal tests
│ ├── global.setup.ts # Global setup script
-│ ├── global.teardown.ts # Global teardown script
-│ └── .eslintrc.js # Test-specific ESLint config
+│ └── global.teardown.ts # Global teardown script
├── helpers/ # All helpers that support the tests, utilities, fixtures, page objects etc.
│ ├── playwright/ # Playwright specific helpers
│ │ └── fixture.ts # Playwright fixtures
-│ ├── pages/ # Page Object Models
-│ │ └── HomePage.ts # Page Object
-│ ├── utils/ # Utils
-│ │ └── math.ts # Math related utils
-│ └── index.ts # Main exports
+│ ├── pages/ # Page Object Models, grouped by area
+│ │ ├── base-page.ts # Base class for all page objects
+│ │ └── admin/ # e.g. login-page.ts, admin-page.ts
+│ ├── environment/ # Ghost container/database lifecycle
+│ ├── services/ # Test doubles (fake Stripe, Mailgun, etc.)
+│ └── utils/ # Shared utilities
+├── data-factory/ # Test data factories (see its own README)
+├── visual-regression/ # Screenshot baseline suite (separate config)
+├── scripts/ # Infra and runner shell scripts
├── playwright.config.mjs # Playwright configuration
+├── eslint.config.js # Lint config for the workspace
├── package.json # Dependencies and scripts
-└── tsconfig.json # TypeScript configuration
+└── tsconfig.json # TypeScript configuration and path aliases
```
### Writing Tests
@@ -144,7 +156,12 @@ the behavior under test, then verify the outcome. Keep those phases clear throug
test structure and naming; comments are only useful when the boundaries would
otherwise be unclear.
+Import through the `@/` path aliases defined in `tsconfig.json`, not relative paths:
+
```typescript
+import {expect, test} from '@/helpers/playwright';
+import {HomePage} from '@/public-pages';
+
test.describe('Ghost Homepage', () => {
test('loads correctly', async ({page}) => {
const homePage = new HomePage(page);
@@ -179,34 +196,40 @@ See [Playwright's locator guidance](https://playwright.dev/docs/locators) and
[Martin Fowler's Page Object description](https://martinfowler.com/bliki/PageObject.html)
for background.
+Admin page objects extend `AdminPage`, public and portal ones extend `BasePage`.
+The base class supplies `goto()`, `refresh()` and `pressKey()`, so a subclass
+only sets its own `pageUrl` and locators:
+
```typescript
-// Create a page object for admin login
+// helpers/pages/admin/login-page.ts
+import {AdminPage} from './admin-page';
import type {Locator, Page} from '@playwright/test';
-export class AdminLoginPage {
- private readonly pageUrl = '/ghost';
- public readonly emailInput: Locator;
- public readonly passwordInput: Locator;
- public readonly signInButton: Locator;
-
- constructor(private readonly page: Page) {
- this.emailInput = page.getByLabel('Email address');
- this.passwordInput = page.getByLabel('Password');
- this.signInButton = page.getByRole('button', {name: 'Sign in'});
- }
+export class LoginPage extends AdminPage {
+ readonly emailAddressField: Locator;
+ readonly passwordField: Locator;
+ readonly signInButton: Locator;
- async goto(urlToVisit = this.pageUrl) {
- await this.page.goto(urlToVisit);
+ constructor(page: Page) {
+ super(page);
+ this.pageUrl = '/ghost/#/signin';
+
+ this.emailAddressField = page.getByRole('textbox', {name: 'Email address'});
+ this.passwordField = page.getByRole('textbox', {name: 'Password'});
+ this.signInButton = page.getByRole('button', {name: 'Sign in →'});
}
-
- async login(email: string, password: string) {
- await this.emailInput.fill(email);
- await this.passwordInput.fill(password);
+
+ async signIn(email: string, password: string) {
+ await this.emailAddressField.fill(email);
+ await this.passwordField.fill(password);
await this.signInButton.click();
}
}
```
+For worked examples of modals, iframes, and discovering selectors for new page
+objects, see the [test writing guide](./.claude/E2E_TEST_WRITING_GUIDE.md).
+
### Global Setup and Teardown
Tests use [Project Dependencies](https://playwright.dev/docs/test-global-setup-teardown#option-1-project-dependencies) to define special tests as global setup and teardown tests:
@@ -217,6 +240,15 @@ Tests use [Project Dependencies](https://playwright.dev/docs/test-global-setup-t
### Playwright Fixtures
[Playwright Fixtures](https://playwright.dev/docs/test-fixtures) are defined in `helpers/playwright/fixture.ts` and provide reusable test setup/teardown logic.
+
+The fixtures a test usually reaches for:
+- `page` - browser page against this test's Ghost instance
+- `pageWithAuthenticatedUser` - the same, already signed in to Ghost Admin
+- `ghostAccountOwner` - the owner account's credentials
+- `ghostInstance` - the running instance: `baseUrl`, `database`, `port`, `siteUuid`, `containerId`, `instanceId`
+- `resolvedIsolation` - `'per-file' | 'per-test'` for the current test
+- `resetEnvironment()` - force an environment recycle (see the escape hatch below)
+
The fixture resolves isolation mode per test file:
- Default: per-file isolation (one Ghost environment cycle per file)
- Opt-in per-test: call `usePerTestIsolation()` from `@/helpers/playwright/isolation` at the root of the file
diff --git a/koenig/README.md b/koenig/README.md
index 1076106b167..23821833592 100644
--- a/koenig/README.md
+++ b/koenig/README.md
@@ -90,12 +90,15 @@ edit/run/test loop against ghost/core.
- kg-* Node libraries share a Vitest base config —
[vitest.shared.ts](./vitest.shared.ts) (`createKoenigVitestConfig`). Run
- `pnpm test:unit` in the package, or `pnpm test` for the full gate (types +
- coverage thresholds + lint).
-- `koenig-lexical` has Vitest unit tests and a large Playwright acceptance
- suite: `pnpm test:unit`, `pnpm test:acceptance` (headless by default;
+ `pnpm test:unit` in the package for unit tests, `pnpm test:types` for type
+ checks, and `pnpm lint` for lint. `pnpm test` runs the package's configured
+ test suites and enforces coverage thresholds; packages with a `posttest`
+ hook also run lint automatically.
+- `koenig-lexical` has Vitest unit tests and a large Playwright browser
+ acceptance suite: `pnpm test:unit`, `pnpm test:acceptance` (headless by default;
`:headed`, `:report`, and `test:slowmo` variants for debugging). See
- [koenig-lexical/CLAUDE.md](./koenig-lexical/CLAUDE.md).
+ [koenig-lexical/AGENTS.md](./koenig-lexical/AGENTS.md). Ghost's end-to-end
+ suite is the separate top-level [`e2e/`](../e2e/) workspace.
- `kg-unsplash-selector` also has a Playwright suite (`pnpm test:acceptance`).
## Shipping
@@ -114,20 +117,21 @@ Ghost itself never installs these packages from npm:
### npm publishing (external consumers)
-The `publish_koenig_packages` job in
-[.github/workflows/ci.yml](../.github/workflows/ci.yml) runs on stable release
-tags only (no `-rc` prereleases), after `publish_ghost` succeeds — so every
-published version corresponds to content that shipped inside a released Ghost.
-It runs [scripts/publish-koenig-packages.cjs](../scripts/publish-koenig-packages.cjs),
-which for each non-private package:
-
-1. Skips it if its directory is unchanged since the previous release tag.
-2. Computes the next version: `package.json` pins the **major.minor** line,
- npm is the source of truth for the **patch**. To cut a new minor/major,
- bump it in `package.json`; never bump the patch by hand.
-3. Builds via Nx and publishes, in dependency order, so `workspace:~` specs
- are rewritten to the versions published in the same run.
-
-For urgent out-of-band publishes there's a `workflow_dispatch` escape hatch
-(`publish-koenig-package.yml`) that publishes a single named package from the
-current checkout, skipping change detection.
+The [Publish Packages workflow](../.github/workflows/publish-packages.yml) runs
+on stable Ghost release tags (no `-rc` prereleases), in parallel with Ghost's
+own publishing workflow. Release commits consume pending changesets and commit
+the resulting package versions before the tag is created.
+
+The workflow runs
+[`scripts/publish-packages.js`](../scripts/publish-packages.js), which:
+
+1. Selects every publishable workspace package, including the public Koenig
+ packages.
+2. Builds the selected packages and their workspace dependencies.
+3. Publishes each committed version that is not already on npm, in dependency
+ order, rewriting `workspace:` ranges to published versions.
+
+For an out-of-band publish, the same workflow has a `workflow_dispatch` escape
+hatch that can be narrowed to one package. The package version must already
+have been bumped, normally by consuming a changeset; the workflow does not
+calculate a new version itself.
diff --git a/koenig/kg-card-factory/README.md b/koenig/kg-card-factory/README.md
index 2442adfcd3d..f206df10f6a 100644
--- a/koenig/kg-card-factory/README.md
+++ b/koenig/kg-card-factory/README.md
@@ -1,38 +1,41 @@
# Koenig Card Factory
-## Install
+Card definition factory for Ghost's Mobiledoc renderer.
-`npm install @tryghost/kg-card-factory --save`
+> Legacy: this package supports posts that have never been converted from
+> Mobiledoc. New editor work belongs in the Lexical packages.
-or
+## Install
`npm install @tryghost/kg-card-factory`
-
## Usage
+```js
+import {CardFactory} from '@tryghost/kg-card-factory';
-## Develop
-
-This is a mono repository, managed with [lerna](https://lernajs.io/).
-
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+const factory = new CardFactory({siteUrl: 'https://example.com'});
+const card = factory.createCard(cardDefinition);
+```
+Options passed to the factory are merged into the render options of every card
+it creates, with per-render options taking precedence.
-## Run
+## Develop
-- `pnpm dev`
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-card-factory`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
# Copyright & License
diff --git a/koenig/kg-card-factory/package.json b/koenig/kg-card-factory/package.json
index 4283c5a7d21..3130f0553e8 100644
--- a/koenig/kg-card-factory/package.json
+++ b/koenig/kg-card-factory/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-card-factory",
"version": "5.2.3",
+ "description": "Card definition factory for Ghost's Mobiledoc renderer",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-clean-basic-html/README.md b/koenig/kg-clean-basic-html/README.md
index 6abdd59bbb8..f396feac467 100644
--- a/koenig/kg-clean-basic-html/README.md
+++ b/koenig/kg-clean-basic-html/README.md
@@ -1,38 +1,46 @@
# Koenig Clean Basic Html
-## Install
-
-`npm install @tryghost/kg-clean-basic-html --save`
+Sanitises and normalises the basic HTML snippets used in Ghost's editor cards, such as captions.
-or
+## Install
`npm install @tryghost/kg-clean-basic-html`
-
## Usage
+```js
+import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html';
-## Develop
+cleanBasicHtml(' Hello world
');
+// 'Hello world
'
+```
-This is a mono repository, managed with [lerna](https://lernajs.io/).
+In the browser the document is taken from the global `document`. In Node you
+must supply one, or the call throws:
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+```js
+import {JSDOM} from 'jsdom';
+cleanBasicHtml(html, {
+ createDocument: htmlString => new JSDOM(htmlString).window.document
+});
+```
-## Run
+## Develop
-- `pnpm dev`
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-clean-basic-html`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
# Copyright & License
diff --git a/koenig/kg-clean-basic-html/package.json b/koenig/kg-clean-basic-html/package.json
index 15bba2d0266..57bc0b9d15b 100644
--- a/koenig/kg-clean-basic-html/package.json
+++ b/koenig/kg-clean-basic-html/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-clean-basic-html",
"version": "4.3.3",
+ "description": "Sanitises and normalises the basic HTML snippets used in Ghost's editor cards",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-converters/README.md b/koenig/kg-converters/README.md
index 1db8e67800c..b496a4858c0 100644
--- a/koenig/kg-converters/README.md
+++ b/koenig/kg-converters/README.md
@@ -1,35 +1,40 @@
# Koenig Converters
-Functions for converting between serialized Lexical and Mobiledoc formats
+Converts between the serialized Mobiledoc and Lexical formats.
## Install
-`npm install @tryghost/kg-converters --save`
-
-or
-
`npm install @tryghost/kg-converters`
## Usage
+```js
+import {mobiledocToLexical, lexicalToMobiledoc} from '@tryghost/kg-converters';
-## Develop
+const lexical = mobiledocToLexical(serializedMobiledoc);
+const mobiledoc = lexicalToMobiledoc(serializedLexical);
+```
-This is a monorepo package.
+Both take and return serialized JSON strings. Mobiledoc cards are carried
+across as Lexical nodes of the same name, so a round trip preserves cards that
+both formats know about.
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+## Develop
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-converters`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
-# Copyright & License
+# Copyright & License
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE).
diff --git a/koenig/kg-converters/package.json b/koenig/kg-converters/package.json
index d364e4b4a2c..879c94bac4d 100644
--- a/koenig/kg-converters/package.json
+++ b/koenig/kg-converters/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-converters",
"version": "1.2.4",
+ "description": "Converts between serialized Mobiledoc and Lexical formats for Ghost's editor",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-default-cards/README.md b/koenig/kg-default-cards/README.md
index d6d42c30430..aa0c6cf0996 100644
--- a/koenig/kg-default-cards/README.md
+++ b/koenig/kg-default-cards/README.md
@@ -1,38 +1,43 @@
# Koenig Default Cards
-## Install
+Mobiledoc card definitions for Ghost's editor.
-`npm install @tryghost/kg-default-cards --save`
+> Legacy: this package supports posts that have never been converted from
+> Mobiledoc. New editor work belongs in the Lexical packages.
-or
+## Install
`npm install @tryghost/kg-default-cards`
-
## Usage
+```js
+import {cards} from '@tryghost/kg-default-cards';
-## Develop
-
-This is a mono repository, managed with [lerna](https://lernajs.io/).
-
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+cards.map(card => card.name);
+// ['bookmark', 'code', 'email', 'email-cta', 'embed', ...]
+```
+Each card exposes `name`, `type` and a `render` function, ready to hand to the
+Mobiledoc renderer. Cards that contain URLs also expose the relevant transform
+helpers (`absoluteToRelative`, `relativeToAbsolute`, `toTransformReady`) that
+Ghost applies when storing and serving content.
-## Run
+## Develop
-- `pnpm dev`
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-default-cards`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
# Copyright & License
diff --git a/koenig/kg-default-cards/package.json b/koenig/kg-default-cards/package.json
index 74dc0a24aae..4761277b40d 100644
--- a/koenig/kg-default-cards/package.json
+++ b/koenig/kg-default-cards/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-default-cards",
"version": "10.3.4",
+ "description": "Mobiledoc card definitions for Ghost's editor",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-default-nodes/README.md b/koenig/kg-default-nodes/README.md
index 23e54a9f039..0a6dd1c0b76 100644
--- a/koenig/kg-default-nodes/README.md
+++ b/koenig/kg-default-nodes/README.md
@@ -1,43 +1,49 @@
# Koenig Default Nodes
-Lexical node definitions for the default nodes used in Ghost's Koenig editor
+Lexical node definitions for all of Ghost's cards, including each node's HTML renderer. This is the single source of truth for node rendering — both the editor and the server render through it.
## Install
-`npm install @tryghost/kg-default-nodes --save`
-
-or
-
`npm install @tryghost/kg-default-nodes`
## Usage
+```js
+const {createEditor} = require('lexical');
+const {DEFAULT_NODES, DEFAULT_CONFIG} = require('@tryghost/kg-default-nodes');
-## Develop
-
-This is a monorepo package.
-
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+const editor = createEditor({
+ nodes: DEFAULT_NODES,
+ html: DEFAULT_CONFIG.html
+});
+```
+`DEFAULT_NODES` covers Ghost's own cards, so pair it with the base Lexical
+nodes your content needs. `DEFAULT_CONFIG.html` supplies the import serializers
+that keep pasted HTML mapping onto the right nodes.
+Individual nodes are exported by name (`ImageNode`, `CalloutNode`, and so on)
+when you need a subset rather than the full set.
-## Test
+This package must stay browser-safe: it runs inside the editor as well as on
+the server.
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
+## Develop
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-default-nodes`.
-## Running in Ghost Admin
-In order to run local changes, perform the following:
-This package is part of the Ghost monorepo workspace — `ghost/core` resolves
-it via `workspace:` automatically, so local changes are picked up with no
-linking. Run `pnpm dev` in this package for a rebuild-on-change watcher.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
-`kg-lexical-html-renderer` must also be linked when linking this package as they are codependencies.
+## Test
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
-# Copyright & License
+# Copyright & License
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE).
diff --git a/koenig/kg-default-nodes/package.json b/koenig/kg-default-nodes/package.json
index e2cc249b9ee..8f9725a7827 100644
--- a/koenig/kg-default-nodes/package.json
+++ b/koenig/kg-default-nodes/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-default-nodes",
"version": "2.1.5",
+ "description": "Lexical node definitions and HTML renderers for Ghost's Koenig editor cards",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-default-transforms/README.md b/koenig/kg-default-transforms/README.md
index 283461b9184..671bbed667b 100644
--- a/koenig/kg-default-transforms/README.md
+++ b/koenig/kg-default-transforms/README.md
@@ -1,35 +1,41 @@
# Koenig Default Transforms
-Default Lexical Node transforms used across our Koenig packages
+Lexical node transforms shared between the editor and the server, such as denesting and list merging.
## Install
-`npm install @tryghost/kg-default-transforms --save`
-
-or
-
`npm install @tryghost/kg-default-transforms`
## Usage
+```js
+const {registerDefaultTransforms} = require('@tryghost/kg-default-transforms');
-## Develop
-
-This is a monorepo package.
+const teardown = registerDefaultTransforms(editor);
+```
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+Returns a teardown function that unregisters every transform it added.
+Individual transforms are also exported by name for cases that need a subset.
+`registerRemoveAtLinkNodesTransform` is deliberately not part of the defaults —
+it is only wanted when rendering.
+## Develop
-## Test
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-default-transforms`.
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
+## Test
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
-# Copyright & License
+# Copyright & License
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE).
diff --git a/koenig/kg-default-transforms/package.json b/koenig/kg-default-transforms/package.json
index a63d9f5e46a..156237f56d0 100644
--- a/koenig/kg-default-transforms/package.json
+++ b/koenig/kg-default-transforms/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-default-transforms",
"version": "1.3.3",
+ "description": "Shared Lexical node transforms for Ghost's Koenig editor",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-html-to-lexical/README.md b/koenig/kg-html-to-lexical/README.md
index ebac325d7ca..924c177f020 100644
--- a/koenig/kg-html-to-lexical/README.md
+++ b/koenig/kg-html-to-lexical/README.md
@@ -1,34 +1,38 @@
# Html To Lexical
-Convert HTML strings into Lexical editor state objects
+Converts HTML strings into Lexical editor state, used by imports and the Admin API's `?source=html` option.
## Install
-`npm install @tryghost/kg-html-to-lexical --save`
-
-or
-
`npm install @tryghost/kg-html-to-lexical`
## Usage
+```js
+const {htmlToLexical} = require('@tryghost/kg-html-to-lexical');
-## Develop
+const state = htmlToLexical('Hello world
');
+// {root: {children: [...], type: 'root', ...}}
+```
-This is a monorepo package.
+Returns a serializable editor state object, not a string — `JSON.stringify` it
+before storing. Runs headlessly via JSDOM, so it works server-side.
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+## Develop
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-html-to-lexical`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
# Copyright & License
diff --git a/koenig/kg-html-to-lexical/package.json b/koenig/kg-html-to-lexical/package.json
index 671d74810c9..e1379c9a807 100644
--- a/koenig/kg-html-to-lexical/package.json
+++ b/koenig/kg-html-to-lexical/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-html-to-lexical",
"version": "1.3.3",
+ "description": "Converts HTML strings into Lexical editor state for Ghost",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-lexical-html-renderer/README.md b/koenig/kg-lexical-html-renderer/README.md
index 66f203b0939..5f17ae1c29a 100644
--- a/koenig/kg-lexical-html-renderer/README.md
+++ b/koenig/kg-lexical-html-renderer/README.md
@@ -1,22 +1,17 @@
# Koenig Lexical Html Renderer
-Renders a lexical editor state string to a HTML string.
+Renders a serialized Lexical editor state to an HTML string.
This library differs from Lexical's own [lexical-html](https://github.com/facebook/lexical/tree/main/packages/lexical-html) package in a few ways:
-1. it's output target is not an editor but rendered web pages or emails which means the handling of nodes (especially custom DecoratorNodes) will differ to the node's built-in editor-focused rendering
+1. its output target is not an editor but rendered web pages or emails, which means the handling of nodes (especially custom DecoratorNodes) will differ from the node's built-in editor-focused rendering
2. render output will vary based on supplied options and targets, e.g. when rendering for email the output may use `` elements in place of modern HTML structure
-3. it's primary usage environment is server-side
+3. its primary usage environment is server-side
## Install
-`npm install @tryghost/kg-lexical-html-renderer --save`
-
-or
-
`npm install @tryghost/kg-lexical-html-renderer`
-
## Usage
Basic usage:
@@ -29,6 +24,8 @@ const lexicalState = '{...}';
const html = await renderer.render(lexicalState);
```
+`render()` is async and returns a string.
+
Options can be passed in as the second argument to `.render()`.
```js
@@ -39,29 +36,34 @@ const html = await renderer.render(lexicalState, {target: 'email'});
| -------- | ------ |
| `target` | `'html'` (default), `'email'` |
-## Develop
-
-This is a mono repository, managed with [lerna](https://lernajs.io/).
+Options are passed through to each node's renderer in `kg-default-nodes`,
+which accepts further keys — `siteUrl`, `postUrl`, `imageOptimization` and
+others — for URL resolution and image handling.
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+## Develop
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-lexical-html-renderer`.
-## Test
+`ghost/core` resolves this package via a `source` export condition pointing at
+`src/`, so a change here is picked up by a running Ghost dev server without a
+rebuild. Run `pnpm dev` for a watching `tsc` build when you need the compiled
+output.
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
+Changes usually need to be made alongside
+[kg-default-nodes](../kg-default-nodes), which owns the per-node rendering this
+package drives.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
-## Running in Ghost Admin
-In order to run local changes, perform the following:
-This package is part of the Ghost monorepo workspace — `ghost/core` resolves
-it via `workspace:` automatically, so local changes are picked up with no
-linking. Run `pnpm dev` in this package for a rebuild-on-change watcher.
-
-`kg-default-nodes` must also be linked when linking this package as they are codependencies.
+## Test
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
# Copyright & License
diff --git a/koenig/kg-lexical-html-renderer/package.json b/koenig/kg-lexical-html-renderer/package.json
index 62705186317..f5c2b627763 100644
--- a/koenig/kg-lexical-html-renderer/package.json
+++ b/koenig/kg-lexical-html-renderer/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-lexical-html-renderer",
"version": "1.4.3",
+ "description": "Renders serialized Lexical state to front-end or email HTML for Ghost",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-markdown-html-renderer/README.md b/koenig/kg-markdown-html-renderer/README.md
index 28e5f456b6e..bc2d2ad6381 100644
--- a/koenig/kg-markdown-html-renderer/README.md
+++ b/koenig/kg-markdown-html-renderer/README.md
@@ -1,38 +1,46 @@
# Koenig Markdown Html Renderer
-## Install
-
-`npm install @tryghost/kg-markdown-html-renderer --save`
+Markdown to HTML rendering for Ghost's markdown card.
-or
+## Install
`npm install @tryghost/kg-markdown-html-renderer`
-
## Usage
+```js
+import {render} from '@tryghost/kg-markdown-html-renderer';
-## Develop
+render('# Hello');
+// 'Hello
\n'
+```
-This is a mono repository, managed with [lerna](https://lernajs.io/).
+Headings are given generated ids. Pass `{ghostVersion: '3.0'}` to get the
+pre-4.0 slug format, which older content's anchor links depend on:
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+```js
+render('## Hello, World!');
+// 'Hello, World!
\n'
+render('## Hello, World!', {ghostVersion: '3.0'});
+// 'Hello, World!
\n'
+```
-## Run
+## Develop
-- `pnpm dev`
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-markdown-html-renderer`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
# Copyright & License
diff --git a/koenig/kg-markdown-html-renderer/package.json b/koenig/kg-markdown-html-renderer/package.json
index 55879fed490..787c8d74362 100644
--- a/koenig/kg-markdown-html-renderer/package.json
+++ b/koenig/kg-markdown-html-renderer/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-markdown-html-renderer",
"version": "7.2.3",
+ "description": "Markdown to HTML rendering for Ghost's markdown card",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/kg-unsplash-selector/README.md b/koenig/kg-unsplash-selector/README.md
index b3c947c5d7c..e4d87fc86f2 100644
--- a/koenig/kg-unsplash-selector/README.md
+++ b/koenig/kg-unsplash-selector/README.md
@@ -1,32 +1,51 @@
# Unsplash Selector
-Unsplash Selector in React
+React Unsplash image picker used by Ghost's Koenig editor and Admin.
-## Development
+## Install
-### Pre-requisites
+`npm install @tryghost/kg-unsplash-selector`
-- Run `pnpm install` in the Ghost monorepo root
+## Usage
-### Running the development version
+```jsx
+import {UnsplashSearchModal} from '@tryghost/kg-unsplash-selector';
-Run `pnpm dev` to start the development server to test/develop the settings standalone. This will generate a demo site from the `index.html` file which renders the app and makes it available on http://localhost:5173
-To Run it in-memory, meaning the app will run in memory and not make any requests to the Unsplash API, run `VITE_APP_TESTING=true pnpm dev`
+ setOpen(false)}
+ onImageInsert={image => insert(image)}
+/>
+```
-## Develop
+Passing `null` as `unsplashProviderConfig` swaps in an in-memory provider that
+serves fixtures instead of calling the Unsplash API, which is what the
+standalone dev server uses.
-This is a monorepo package.
+## Develop
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-unsplash-selector`.
+Run `pnpm dev` to start a standalone development server, which renders the
+picker from `index.html` at http://localhost:5173. To develop without making
+requests to the Unsplash API, run `VITE_APP_TESTING=true pnpm dev` to serve
+in-memory fixtures instead.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test:acceptance` runs acceptance tests
-- `pnpm test:unit` runs unit tests
-- `pnpm test:acceptance path/to/test` runs a specific test
-- `pnpm test:acceptance:slowmo` runs acceptance tests in slow motion and headed mode, useful for debugging and developing tests
+- `pnpm test:unit` runs the unit tests
+- `pnpm test:acceptance` runs the Playwright acceptance tests
+- `pnpm test:acceptance ` runs a single acceptance test
+- `pnpm test:acceptance:slowmo` runs them headed and slowed down, for debugging
+- `pnpm test:acceptance:full` runs them against all configured browsers
+- `pnpm test` runs unit and acceptance tests
+
+# Copyright & License
+
+Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE).
diff --git a/koenig/kg-unsplash-selector/package.json b/koenig/kg-unsplash-selector/package.json
index 1a0bb797a45..b860fe4641e 100644
--- a/koenig/kg-unsplash-selector/package.json
+++ b/koenig/kg-unsplash-selector/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-unsplash-selector",
"version": "0.4.3",
+ "description": "React Unsplash image picker for Ghost's Koenig editor",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/koenig/kg-utils/README.md b/koenig/kg-utils/README.md
index 607b31521cb..e4a7e5850ee 100644
--- a/koenig/kg-utils/README.md
+++ b/koenig/kg-utils/README.md
@@ -1,39 +1,49 @@
# Koenig Utils
-## Install
-
-`npm install @tryghost/kg-utils --save`
+Small shared utilities used across the Koenig packages.
-or
+## Install
`npm install @tryghost/kg-utils`
-
## Usage
+```js
+import {slugify} from '@tryghost/kg-utils';
-## Develop
+slugify('My Post Title!');
+// 'my-post-title'
+```
-This is a mono repository, managed with [lerna](https://lernajs.io/).
+`slugify` accepts `{ghostVersion, type}`, both of which select a slug format
+rather than changing the input. `ghostVersion` defaults to `'4.0'`; passing an
+earlier version reproduces the pre-4.0 format, which older content's anchor
+links depend on:
-Follow the instructions for the top-level repo.
-1. `git clone` this repo & `cd` into it as usual
-2. Run `pnpm install` from the Ghost monorepo root.
+```js
+slugify('Ünïcödé Tïtlé');
+// '%C3%BCn%C3%AFc%C3%B6d%C3%A9-t%C3%AFtl%C3%A9'
+slugify('Ünïcödé Tïtlé', {ghostVersion: '3.0'});
+// '-n-c-d-t-tl-'
+```
-## Run
+## Develop
-- `pnpm dev`
+This package is part of the [Ghost monorepo](https://github.com/TryGhost/Ghost)
+and resolves through the pnpm workspace — there is no linking or per-package
+install step. Run `pnpm setup` in the monorepo root, then work in
+`koenig/kg-utils`.
+See the [Koenig README](../README.md) for the shared build, test and release
+workflow.
## Test
-- `pnpm lint` run just eslint
-- `pnpm test` run lint and tests
-
-
-
+- `pnpm test:unit` runs the unit tests
+- `pnpm test` runs the unit and type tests, including coverage thresholds
+- `pnpm lint` runs the lint checks
-# Copyright & License
+# Copyright & License
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](https://github.com/TryGhost/Ghost/blob/main/LICENSE).
diff --git a/koenig/kg-utils/package.json b/koenig/kg-utils/package.json
index 4d408a7bc5c..6e35f8e7389 100644
--- a/koenig/kg-utils/package.json
+++ b/koenig/kg-utils/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/kg-utils",
"version": "1.1.3",
+ "description": "Shared utilities for Ghost's Koenig editor packages",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/koenig/koenig-lexical/AGENTS.md b/koenig/koenig-lexical/AGENTS.md
new file mode 100644
index 00000000000..5bb1242072f
--- /dev/null
+++ b/koenig/koenig-lexical/AGENTS.md
@@ -0,0 +1,60 @@
+# Koenig Lexical Test Guide
+
+## Test Commands
+
+### Unit Tests
+```bash
+pnpm test:unit # Run unit tests once
+pnpm test:unit:watch # Run unit tests in watch mode
+```
+
+### Acceptance Tests (Playwright)
+```bash
+pnpm test:acceptance # Run Playwright tests (headless, list reporter)
+pnpm test:acceptance:quiet # Minimal output, failures only
+pnpm test:acceptance:headed # Run with browser UI visible
+pnpm test:acceptance:report # Run with HTML report
+pnpm test:slowmo # Slow motion + UI
+```
+
+### All Tests
+```bash
+pnpm test # Run unit + acceptance tests, then lint
+```
+
+## AI-Friendly Testing
+
+The test runner has been configured to work well with AI agents:
+
+- **Default behavior**: Headless mode with list reporter (no browser UI, no web pages)
+- **Quiet mode**: Use `pnpm test:acceptance:quiet` for minimal output (only shows failures)
+- **Clean exit**: Tests complete without hanging processes or opening browsers
+- **Clear output**: List reporter provides clear pass/fail information
+
+## Human-Friendly Testing
+
+For debugging and development:
+
+- Use `pnpm test:acceptance:headed` to see the browser UI
+- Use `pnpm test:acceptance:report` to generate an HTML report
+- Use `pnpm test:slowmo` for slow-motion debugging
+
+## Environment Variables
+
+- `PLAYWRIGHT_HEADED=true` - Show browser UI
+- `PLAYWRIGHT_HTML_REPORT=true` - Generate HTML report
+- `PLAYWRIGHT_SLOWMO=100` - Slow motion delay (ms)
+
+## Test Structure
+
+- `test/unit/` - Unit tests (Vitest)
+- `test/e2e/` - Package-level acceptance tests (Playwright,
+ `test:acceptance` target); Ghost's end-to-end suite lives in the repository's
+ top-level `e2e/` directory
+- `test/utils/` - Shared test utilities
+
+## Development Workflow
+
+1. Run unit tests during development: `pnpm test:unit:watch`
+2. Run acceptance tests before committing: `pnpm test:acceptance`
+3. Use headed mode for debugging: `pnpm test:acceptance:headed`
diff --git a/koenig/koenig-lexical/CLAUDE.md b/koenig/koenig-lexical/CLAUDE.md
deleted file mode 100644
index ad957fe5768..00000000000
--- a/koenig/koenig-lexical/CLAUDE.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# Koenig Lexical Test Guide
-
-## Test Commands
-
-### Unit Tests
-```bash
-pnpm test:unit # Run unit tests once
-pnpm test:unit:watch # Run unit tests in watch mode
-```
-
-### Acceptance Tests (Playwright)
-```bash
-pnpm test:acceptance # Run Playwright tests (headless, list reporter)
-pnpm test:acceptance:quiet # Minimal output, failures only
-pnpm test:acceptance:headed # Run with browser UI visible
-pnpm test:acceptance:report # Run with HTML report
-pnpm test:slowmo # Slow motion + UI
-```
-
-### All Tests
-```bash
-pnpm test # Run unit + acceptance tests, then lint
-```
-
-## AI-Friendly Testing
-
-The test runner has been configured to work well with AI agents:
-
-- **Default behavior**: Headless mode with list reporter (no browser UI, no web pages)
-- **Quiet mode**: Use `pnpm test:acceptance:quiet` for minimal output (only shows failures)
-- **Clean exit**: Tests complete without hanging processes or opening browsers
-- **Clear output**: List reporter provides clear pass/fail information
-
-## Human-Friendly Testing
-
-For debugging and development:
-
-- Use `pnpm test:acceptance:headed` to see the browser UI
-- Use `pnpm test:acceptance:report` to generate an HTML report
-- Use `pnpm test:slowmo` for slow-motion debugging
-
-## Environment Variables
-
-- `PLAYWRIGHT_HEADED=true` - Show browser UI
-- `PLAYWRIGHT_HTML_REPORT=true` - Generate HTML report
-- `PLAYWRIGHT_SLOWMO=100` - Slow motion delay (ms)
-
-## Test Structure
-
-- `test/unit/` - Unit tests (Vitest)
-- `test/e2e/` - Acceptance tests (Playwright, `test:acceptance` target)
-- `test/utils/` - Shared test utilities
-
-## Development Workflow
-
-1. Run unit tests during development: `pnpm test:unit:watch`
-2. Run acceptance tests before committing: `pnpm test:acceptance`
-3. Use headed mode for debugging: `pnpm test:acceptance:headed`
diff --git a/koenig/koenig-lexical/CLAUDE.md b/koenig/koenig-lexical/CLAUDE.md
new file mode 120000
index 00000000000..47dc3e3d863
--- /dev/null
+++ b/koenig/koenig-lexical/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/koenig/koenig-lexical/README.md b/koenig/koenig-lexical/README.md
index f75c866990c..2a42709e7e8 100644
--- a/koenig/koenig-lexical/README.md
+++ b/koenig/koenig-lexical/README.md
@@ -71,17 +71,23 @@ All imported files are processed/optimised via SVGO (see `svgo.config.js` for op
## Testing
-We use [Vitest](https://vitest.dev) for unit tests and [Playwright](https://playwright.dev) for e2e testing.
+We use [Vitest](https://vitest.dev) for unit tests and
+[Playwright](https://playwright.dev) for browser acceptance testing. Ghost's
+end-to-end test suite lives in the repository's top-level
+[`e2e/`](../../e2e/) directory.
- `pnpm test` runs all tests and exits
- `pnpm test:unit` runs unit tests
- `pnpm test:unit:watch` runs unit tests and starts a test watcher that re-runs tests on file changes
- `pnpm test:unit:watch --ui` runs unit tests and opens a browser UI for exploring and re-running tests
-- `pnpm test:e2e` runs e2e tests
-- `pnpm test:e2e --headed` runs tests in browser so you can watch the tests execute
-- `pnpm test:slowmo` same as `pnpm test:e2e --headed` but adds 100ms delay between instructions to make it easier to see what's happening (note that some tests may fail or timeout due to the added delays)
-- `pnpm test:e2e --ui` opens a [browser UI](https://playwright.dev/docs/test-ui-mode) in watch mode for exploring and re-running tests
-- `pnpm test:e2e --ui --headed` same as `pnpm test:e2e --ui` but also runs tests in browser so you can watch the tests execute
+- `pnpm test:acceptance` runs browser acceptance tests headlessly
+- `pnpm test:acceptance:headed` runs acceptance tests with the browser visible
+- `pnpm test:acceptance:report` generates an HTML report
+- `pnpm test:acceptance:quiet` uses minimal output, showing failures only
+- `pnpm test:acceptance --ui` opens Playwright's [UI mode](https://playwright.dev/docs/test-ui-mode)
+ in watch mode for exploring and re-running acceptance tests
+- `pnpm test:slowmo` runs headed acceptance tests with a 100ms delay between
+ instructions (some tests may fail or time out due to the added delays)
Before tests are started we build a version of the demo app that is used for the unit tests.
@@ -89,13 +95,13 @@ When developing it can be useful to limit unit tests to specific keywords (taken
- `pnpm test:unit:watch -t "buildCardMenu"`
-### How to debug e2e tests on CI
+### How to debug acceptance tests on CI
You can download the report in case of tests were failed. It can be found in the actions `Summary` in the `Artifacts` section.
To check traces, run command `npx playwright show-trace trace.zip`.
More information about traces can be found here https://playwright.dev/docs/trace-viewer
-### ESM in e2e tests
+### ESM in acceptance tests
Node enables ECMAScript modules if `type: 'module'` in package.json file. It leads to some restrictions:
- [No require, exports, module.exports, __filename, __dirname](https://github.com/GrosSacASac/node/blob/master/doc/api/esm.md#no-require-exports-moduleexports-__filename-__dirname)
@@ -111,7 +117,7 @@ The same issue was raised in the babel repo, but the loader won't be added while
in [experimental mode](https://github.com/babel/babel/issues/11934).
We can add our loader implementation to solve the issue. Still, in reality, we shouldn't need real
-JSX components in e2e tests. It can be a situation when some constants locate in the `jsx` file. In this case,
+JSX components in acceptance tests. It can be a situation when some constants locate in the `jsx` file. In this case,
we can move them to js file. If it is a problem in the future, we can add our implementation of the loader or
add an extension to all imports in the project.
@@ -120,6 +126,7 @@ add an extension to all imports in the project.
There's a [vitest vscode extension](https://marketplace.visualstudio.com/items?itemName=ZixuanChen.vitest-explorer) that
lets you run and debug individual unit tests/groups directly inside vscode.
-## Deployment
+## Shipping
-Koenig packages are shipped via Lerna at the monorepo level. Please refer to the monorepo's [README](../../README.md) for deployment instructions.
+Koenig packages are versioned and published as part of the Ghost monorepo's
+workspace package release process. See the Koenig [shipping guide](../README.md#shipping).
diff --git a/koenig/koenig-lexical/package.json b/koenig/koenig-lexical/package.json
index 0ec0b323787..d899612785b 100644
--- a/koenig/koenig-lexical/package.json
+++ b/koenig/koenig-lexical/package.json
@@ -1,6 +1,7 @@
{
"name": "@tryghost/koenig-lexical",
"version": "1.9.1",
+ "description": "Ghost's Lexical-based rich text post editor",
"repository": {
"type": "git",
"url": "git+https://github.com/TryGhost/Ghost.git",
diff --git a/scripts/change-check.js b/scripts/change-check.js
index c4a3b52d043..25050830814 100644
--- a/scripts/change-check.js
+++ b/scripts/change-check.js
@@ -2,6 +2,7 @@ import {parseArgs} from 'node:util'
import camelcaseKeys from 'camelcase-keys';
import {findPackagesNeedingChangeset} from './lib/pnpm.js';
+import {INTERNAL_DOCS_PATTERN} from './lib/constants.js';
const {values, positionals} = parseArgs({
options: {
@@ -26,7 +27,8 @@ const [
baseCommit = process.env.PR_BASE_SHA || 'main',
headCommit = process.env.PR_COMPARE_SHA || process.env.GITHUB_SHA || 'HEAD'
] = positionals;
-const ignorePatterns = [...testPattern, ...changedFilesIgnorePattern];
+// Always applied — the release policy, not a default callers can replace.
+const ignorePatterns = [INTERNAL_DOCS_PATTERN, ...testPattern, ...changedFilesIgnorePattern];
const missing = await findPackagesNeedingChangeset(baseCommit, headCommit, ignorePatterns);
diff --git a/scripts/lib/constants.js b/scripts/lib/constants.js
index fbdb850ce87..1bfcfe0c21d 100644
--- a/scripts/lib/constants.js
+++ b/scripts/lib/constants.js
@@ -2,3 +2,7 @@ import {join} from 'node:path';
export const SCRIPTS_DIR = join(import.meta.dirname, '..');
export const ROOT_DIR = join(SCRIPTS_DIR, '..');
+
+// Markdown that never leaves the repo. README is excluded because npm packs it
+// into the tarball, in whatever casing npm resolves it by.
+export const INTERNAL_DOCS_PATTERN = '**/!([Rr][Ee][Aa][Dd][Mm][Ee]).md';
diff --git a/scripts/lib/git.js b/scripts/lib/git.js
index 2ec3b661914..15cbe69ccd8 100644
--- a/scripts/lib/git.js
+++ b/scripts/lib/git.js
@@ -65,13 +65,27 @@ export async function getChangedFiles(path, baseCommit, headCommit = 'HEAD', onl
}
}
+/**
+ * Builds a matcher for ignore patterns, which are written relative to a
+ * directory while git reports repo-relative paths. Matches with `dot: true` so
+ * `**` reaches into `.claude`-style directories.
+ *
+ * @param {string} path - The directory the patterns are relative to.
+ * @param {string[]} ignorePatterns - Patterns relative to `path`.
+ * @returns {(file: string) => boolean} - True when a repo-relative path is ignored.
+ */
+export function buildIgnoreMatcher(path, ignorePatterns) {
+ return pm(ignorePatterns.map(pattern => `${path}/${pattern}`), {dot: true});
+}
+
/**
* Checks if a given path has any changes between two commits
*
* @param {string} path - The path to check for changes.
* @param {string} baseCommit - The first commit hash to compare.
* @param {string} headCommit - The second commit hash to compare.
- * @param {string[]} [ignorePatterns] - Optional patterns to ignore
+ * @param {string[]} [ignorePatterns] - Optional patterns to ignore, relative to
+ * `path`. See {@link buildIgnoreMatcher}.
*
* @returns {Promise} - A promise that resolves to true if there are changes, false otherwise.
*/
@@ -79,7 +93,7 @@ export async function pathHasChanges(path, baseCommit, headCommit, ignorePattern
let changedFiles = await getChangedFiles(path, baseCommit, headCommit);
if (ignorePatterns.length > 0) {
- const match = pm(ignorePatterns.map(pattern => `${path}/${pattern}`));
+ const match = buildIgnoreMatcher(path, ignorePatterns);
changedFiles = changedFiles.filter(file => !match(file));
}
diff --git a/scripts/test/change-check.test.js b/scripts/test/change-check.test.js
new file mode 100644
index 00000000000..afcb8e7d5b6
--- /dev/null
+++ b/scripts/test/change-check.test.js
@@ -0,0 +1,37 @@
+import {describe, it} from 'node:test';
+import assert from 'node:assert';
+
+import {buildIgnoreMatcher} from '../lib/git.js';
+import {INTERNAL_DOCS_PATTERN} from '../lib/constants.js';
+
+// The same matcher pathHasChanges builds, over paths as git reports them.
+const PACKAGE_DIR = 'koenig/kg-utils';
+const isIgnored = buildIgnoreMatcher(PACKAGE_DIR, [INTERNAL_DOCS_PATTERN]);
+
+describe('INTERNAL_DOCS_PATTERN', () => {
+ it('does not ignore the package README, which npm publishes', () => {
+ assert.strictEqual(isIgnored(`${PACKAGE_DIR}/README.md`), false);
+ });
+
+ it('does not ignore a README in any casing npm would resolve', () => {
+ for (const name of ['readme.md', 'Readme.md', 'ReadMe.md', 'READme.md', 'rEaDmE.md']) {
+ assert.strictEqual(isIgnored(`${PACKAGE_DIR}/${name}`), false, name);
+ }
+ });
+
+ it('ignores repo-only markdown', () => {
+ for (const name of ['AGENTS.md', 'CLAUDE.md', 'CHANGELOG.md', 'docs/testing.md']) {
+ assert.strictEqual(isIgnored(`${PACKAGE_DIR}/${name}`), true, name);
+ }
+ });
+
+ it('ignores markdown at any depth', () => {
+ assert.strictEqual(isIgnored(`${PACKAGE_DIR}/.claude/guide.md`), true);
+ });
+
+ it('does not ignore anything that is not markdown', () => {
+ for (const name of ['package.json', 'lib/index.js', 'src/readme.ts']) {
+ assert.strictEqual(isIgnored(`${PACKAGE_DIR}/${name}`), false, name);
+ }
+ });
+});