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); + } + }); +});