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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Repository guidelines

## Project structure & module organization

- Monorepo via `pnpm` + `Nx`.
- Packages: `packages/core` (CLI + `@rspress/core`), `packages/theme-default` (Default theme), `packages/plugin-*` (Official plugins), `packages/create-rspress` (scaffolder).
- Tests: `packages/*/tests` (unit) and `e2e/` (end-to-end tests); website examples in `website/`.
- Key config: `nx.json`, `biome.json`, `.prettierrc`, `playwright.config.ts`, `pnpm-workspace.yaml`.

## Build, test, and development commands

- Install: `pnpm install` (Node >= 18.0, pnpm >= 10.15).
- Build: `pnpm build` (all) and `pnpm build:website`.
- Watch dev: `pnpm dev` (all packages) or `pnpm dev:website` (documentation site).
- Lint/format: `pnpm lint`; auto-fix: `pnpm format`.
- Tests: `pnpm test`; targeted: `pnpm test:unit` or `pnpm test:e2e`; update snapshots: `pnpm testu`.

## Coding style & naming conventions

- TypeScript + ESM; spaces; single quotes.
- Biome is canonical linter/formatter; Prettier formats MD/CSS/JSON and `package.json`.
- Filenames: `camelCase` or `PascalCase` (Biome enforced).

## Testing guidelines

- Unit: `vitest`; E2E: `@playwright/test`.
- Naming: `*.test.ts`/`*.test.tsx`; snapshots in `__snapshots__/`.
- Placement: unit under `packages/*/tests`; e2e under `e2e/`.

## Commit & pull request guidelines

- Conventional Commits (e.g., `feat(plugin-algolia): ...`); keep commits focused; run lint + tests.
- User-facing changes need a Changeset (`pnpm changeset`); PRs should include description, linked issues, and doc/example updates when needed.

## Architecture overview

- `packages/core` (`@rspress/core`): CLI `rspress build/dev` (add `--watch`), config via `rspress.config.ts` using `defineConfig`; programmatic `import { defineConfig, loadConfig } from '@rspress/core'`.
- `packages/theme-default` (`@rspress/theme-default`): Default theme with components and layouts.
- `packages/plugin-*` (`@rspress/plugin-*`): Official plugins like `plugin-algolia` (search), `plugin-llms` (LLM optimization), `plugin-typedoc` (API docs), etc.
- `packages/create-rspress` (`create-rspress`): scaffold new projects/templates with `pnpm dlx create-rspress` (or `npx create-rspress`).

## Security & configuration tips

- Do not commit build artifacts (`dist/`, `compiled/`).
- Nx caching is enabled; scripts use `NX_DAEMON=false` for reproducible CI.
98 changes: 56 additions & 42 deletions e2e/fixtures/basic/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,62 +18,76 @@ test.describe('basic test', async () => {

test('Index page', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`);
const h1 = await page.$('h1');
const text = await page.evaluate(h1 => h1?.textContent, h1);
expect(text).toContain('Hello world');
// expect the .header-anchor to be rendered and take the correct href
const headerAnchor = await page.$('.header-anchor');
const href = await page.evaluate(
headerAnchor => headerAnchor?.getAttribute('href'),
headerAnchor,
);
expect(href).toBe('#hello-world');

// Check the main heading text using modern locator API
const h1 = page.locator('h1');
await expect(h1).toContainText('Hello world');

// Verify the header anchor link is rendered with correct href
const headerAnchor = page.locator('.header-anchor');
await expect(headerAnchor).toHaveAttribute('href', '#hello-world');
});

test('404 page', async ({ page }) => {
await page.goto(`http://localhost:${appPort}/404`, {
waitUntil: 'networkidle',
});
// find the 404 text in the page
const text = await page.evaluate(() => document.body.textContent);
expect(text).toContain('404');
await page.goto(`http://localhost:${appPort}/404`);

// Wait for page to load completely
await page.waitForLoadState('networkidle');

// Verify 404 text is present in the page
await expect(page.locator('body')).toContainText('404');
});

test('dark mode', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`, {
waitUntil: 'networkidle',
});
const darkModeButton = await page.$('.rspress-nav-appearance');
const html = await page.$('html');
let htmlClass = await page.evaluate(
html => html?.getAttribute('class'),
html,
await page.goto(`http://localhost:${appPort}`);
await page.waitForLoadState('networkidle');

const darkModeButton = page.locator('.rspress-nav-appearance');
const htmlElement = page.locator('html');

// Get initial theme mode
const initialClass = await htmlElement.getAttribute('class');
const defaultMode = initialClass?.includes('dark') ? 'dark' : 'light';

// Toggle dark mode
await darkModeButton.click();

// Verify theme has changed
await expect(htmlElement).toHaveClass(
defaultMode === 'dark' ? /^(?!.*dark).*$/ : /.*dark.*/,
);
const defaultMode = htmlClass?.includes('dark') ? 'dark' : 'light';
await darkModeButton?.click();
// check the class in html
htmlClass = await page.evaluate(html => html?.getAttribute('class'), html);
expect(Boolean(htmlClass?.includes('dark'))).toBe(defaultMode !== 'dark');
// click the button again, check the class in html
await darkModeButton?.click();
htmlClass = await page.evaluate(html => html?.getAttribute('class'), html);
expect(Boolean(htmlClass?.includes('dark'))).toBe(defaultMode === 'dark');

// Toggle back to original mode
await darkModeButton.click();

// Verify theme has returned to original state
if (defaultMode === 'dark') {
await expect(htmlElement).toHaveClass(/.*dark.*/);
} else {
await expect(htmlElement).toHaveClass(/^(?!.*dark).*$/);
}
});

test('Hover over social links', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`);
await page.hover('.social-links');
await page.waitForTimeout(1000);
const logoLink = await page.$('a[href="/zh"]');
expect(logoLink).not.toBeNull();

// Hover over social links section
const socialLinks = page.locator('.social-links');
await socialLinks.hover();

// Wait for any hover effects to complete
await page.waitForTimeout(500);

// Verify the logo link is present
const logoLink = page.locator('a[href="/zh"]');
await expect(logoLink).toBeVisible();
});

test('globalStyles should work', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`);
const link = await page.$('.rspress-doc a');
const colorValue = await link?.evaluate(
element => getComputedStyle(element).color,
);
expect(colorValue).toEqual('rgb(255, 165, 0)');

// Check that global styles are applied to document links
const documentLink = page.locator('.rspress-doc a').first();
await expect(documentLink).toHaveCSS('color', 'rgb(255, 165, 0)');
});
});
19 changes: 10 additions & 9 deletions e2e/fixtures/no-config-root/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ test.describe('no config.root dev test', async () => {

test('Index page', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`);
const h1 = await page.$('h1');
const text = await page.evaluate(h1 => h1?.textContent, h1);
expect(text).toContain('Hello world');

// Verify the main heading using modern locator API
const h1 = page.locator('h1');
await expect(h1).toContainText('Hello world');
});
});

Expand All @@ -47,11 +48,11 @@ test.describe('no config.root build and preview test', async () => {
});

test('Index page', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`, {
waitUntil: 'networkidle',
});
const h1 = await page.$('h1');
const text = await page.evaluate(h1 => h1?.textContent, h1);
expect(text).toContain('Hello world');
await page.goto(`http://localhost:${appPort}`);
await page.waitForLoadState('networkidle');

// Verify the main heading using modern locator API
const h1 = page.locator('h1');
await expect(h1).toContainText('Hello world');
});
});
37 changes: 22 additions & 15 deletions e2e/fixtures/production/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
runPreviewCommand,
} from '../../utils/runCommands';

test.describe('basic test', async () => {
test.describe('production build test', async () => {
let appPort;
let app;
test.beforeAll(async () => {
Expand All @@ -23,19 +23,26 @@ test.describe('basic test', async () => {
});

test('check whether the page can be interacted', async ({ page }) => {
await page.goto(`http://localhost:${appPort}`, {
waitUntil: 'networkidle',
});
const darkModeButton = await page.$('.rspress-nav-appearance');
const html = await page.$('html');
let htmlClass = await page.evaluate(
html => html?.getAttribute('class'),
html,
);
const defaultMode = htmlClass?.includes('dark') ? 'dark' : 'light';
await darkModeButton?.click();
// check the class in html
htmlClass = await page.evaluate(html => html?.getAttribute('class'), html);
expect(htmlClass?.includes('dark')).toBe(defaultMode !== 'dark');
await page.goto(`http://localhost:${appPort}`);
await page.waitForLoadState('networkidle');

const darkModeButton = page.locator('.rspress-nav-appearance');
const htmlElement = page.locator('html');

// Get initial theme mode
const initialClass = await htmlElement.getAttribute('class');
const defaultMode = initialClass?.includes('dark') ? 'dark' : 'light';

// Toggle dark mode and verify the change
await darkModeButton.click();

// Verify theme has changed using modern assertion patterns
if (defaultMode === 'dark') {
// Should no longer have dark class
await expect(htmlElement).toHaveClass(/^(?!.*dark).*$/);
} else {
// Should now have dark class
await expect(htmlElement).toHaveClass(/.*dark.*/);
}
});
});
52 changes: 29 additions & 23 deletions e2e/utils/search.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,48 @@
import assert from 'node:assert';
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';

async function getSearchButton(page: Page) {
const searchButton = await page.$('.rp-flex > .rspress-nav-search-button');
return searchButton;
async function getSearchButton(page: Page): Promise<Locator> {
return page.locator('.rp-flex > .rspress-nav-search-button');
}

/**
* @returns suggestItems domList
* Search for text in the page and return suggestion items
* @param page - The Playwright page instance
* @param searchText - Text to search for
* @param reset - Whether to clear the search input after searching
* @returns Array of suggestion item locators
*/
export async function searchInPage(
page: Page,
searchText: string,
reset = true,
) {
): Promise<Locator[]> {
const searchInputLoc = page.locator('.rspress-search-panel-input');
const isSearchInputVisible = await searchInputLoc.isVisible();

if (!isSearchInputVisible) {
const searchButton = await getSearchButton(page);
assert(searchButton);
await searchButton.click();
const searchInput = await page.$('.rspress-search-panel-input');
assert(searchInput);
// Wait for search panel to appear
await searchInputLoc.waitFor({ state: 'visible' });
}
const searchInput = await page.$('.rspress-search-panel-input');
assert(searchInput);
const isEditable = await searchInput.isEditable();
assert(isEditable);
await searchInput.focus();
await page.keyboard.type(searchText);
await page.waitForTimeout(400);
const elements = await page.$$('.rspress-search-suggest-item');

// reset

// Ensure search input is editable before proceeding
await searchInputLoc.waitFor({ state: 'attached' });
await searchInputLoc.focus();
await searchInputLoc.fill(searchText);

// Wait for search suggestions to appear
const suggestionsContainer = page.locator('.rspress-search-suggest-item');
await suggestionsContainer
.first()
.waitFor({ state: 'visible', timeout: 5000 });

const elements = await suggestionsContainer.all();

// Reset search input if requested
if (reset) {
for (let i = 0; i < searchText.length; i++) {
await page.keyboard.press('Backspace');
}
await searchInputLoc.clear();
}

return elements;
}
8 changes: 6 additions & 2 deletions packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const parseTitle = (rawTitle = '', isMDX = false) => {
return trimTailingQuote(matched?.[1] || rawTitle);
};

const getTypeName = (type: DirectiveType | string): string => {
return type[0].toUpperCase() + type.slice(1).toLowerCase();
};

/**
* Construct the DOM structure of the container directive.
* For example:
Expand All @@ -61,7 +65,7 @@ const parseTitle = (rawTitle = '', isMDX = false) => {
* will be transformed to:
*
* <div class="rspress-directive tip">
* <div class="rspress-directive-title">TIP</div>
* <div class="rspress-directive-title">Tip</div>
* <div class="rspress-directive-content">
* <p>This is a tip</p>
* </div>
Expand Down Expand Up @@ -96,7 +100,7 @@ const createContainer = (
class: 'rspress-directive-title',
},
},
children: [{ type: 'text', value: title || type.toUpperCase() }],
children: [{ type: 'text', value: title || getTypeName(type) }],
},
{
type: 'paragraph',
Expand Down
11 changes: 6 additions & 5 deletions packages/plugin-twoslash/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,12 @@ export function pluginTwoslash(options?: PluginTwoslashOptions): RspressPlugin {
class: 'twoslash-popup-trigger',
},
},
completionPopup: {
properties: {
class: 'twoslash-popup-inner',
},
},
// TODO: css changes
// completionPopup: {
// properties: {
// class: 'twoslash-popup-inner',
// },
// },
completionCompose: ({ cursor, popup }) => [
cursor,
<Element>{
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime/src/hooks/useActiveMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useCallback, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { isActive } from '../route';

export const useActiveMatcher = () => {
const { pathname: rawPathname } = useLocation();

const ref = useRef(rawPathname);
ref.current = rawPathname;

const activeMatcher = useCallback((link: string) => {
const rawPathname = ref.current;
const pathname = decodeURIComponent(rawPathname);
return isActive(link, pathname);
}, []);

return activeMatcher;
};
Loading