diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5d341603b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/e2e/fixtures/basic/index.test.ts b/e2e/fixtures/basic/index.test.ts index 431afd84d..b0062dcd8 100644 --- a/e2e/fixtures/basic/index.test.ts +++ b/e2e/fixtures/basic/index.test.ts @@ -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)'); }); }); diff --git a/e2e/fixtures/no-config-root/index.test.ts b/e2e/fixtures/no-config-root/index.test.ts index 37dafcdcb..ad8f84f37 100644 --- a/e2e/fixtures/no-config-root/index.test.ts +++ b/e2e/fixtures/no-config-root/index.test.ts @@ -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'); }); }); @@ -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'); }); }); diff --git a/e2e/fixtures/production/index.test.ts b/e2e/fixtures/production/index.test.ts index f9a685e5d..646dbdf48 100644 --- a/e2e/fixtures/production/index.test.ts +++ b/e2e/fixtures/production/index.test.ts @@ -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 () => { @@ -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.*/); + } }); }); diff --git a/e2e/utils/search.ts b/e2e/utils/search.ts index fff045433..ae813fd64 100644 --- a/e2e/utils/search.ts +++ b/e2e/utils/search.ts @@ -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 { + 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 { 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; } diff --git a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts index 4e1bde76f..71a988f3c 100644 --- a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts +++ b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts @@ -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: @@ -61,7 +65,7 @@ const parseTitle = (rawTitle = '', isMDX = false) => { * will be transformed to: * *
- *
TIP
+ *
Tip
*
*

This is a tip

*
@@ -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', diff --git a/packages/plugin-twoslash/src/index.ts b/packages/plugin-twoslash/src/index.ts index 926ae9d4a..d66fffde9 100644 --- a/packages/plugin-twoslash/src/index.ts +++ b/packages/plugin-twoslash/src/index.ts @@ -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, { diff --git a/packages/runtime/src/hooks/useActiveMatcher.ts b/packages/runtime/src/hooks/useActiveMatcher.ts new file mode 100644 index 000000000..dae6a9d21 --- /dev/null +++ b/packages/runtime/src/hooks/useActiveMatcher.ts @@ -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; +}; diff --git a/packages/runtime/src/hooks/useSidebar.ts b/packages/runtime/src/hooks/useSidebar.ts index db76e448b..d7d9230fe 100644 --- a/packages/runtime/src/hooks/useSidebar.ts +++ b/packages/runtime/src/hooks/useSidebar.ts @@ -1,10 +1,15 @@ import { matchSidebar, type NormalizedSidebar, + type NormalizedSidebarGroup, type SidebarData, + type SidebarDivider, + type SidebarItem, + type SidebarSectionHeader, } from '@rspress/shared'; -import { useMemo } from 'react'; +import { useLayoutEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; +import { useActiveMatcher } from './useActiveMatcher'; import { useLocaleSiteData } from './useLocaleSiteData'; /** @@ -50,3 +55,78 @@ export function useSidebar(): SidebarData { return sidebarData; } + +function createInitialSidebar( + rawSidebarData: SidebarData, + activeMatcher: (link: string) => boolean, +) { + const matchCache = new WeakMap< + | NormalizedSidebarGroup + | SidebarItem + | SidebarDivider + | SidebarSectionHeader, + boolean + >(); + const match = ( + item: + | NormalizedSidebarGroup + | SidebarItem + | SidebarDivider + | SidebarSectionHeader, + ) => { + if (matchCache.has(item)) { + return matchCache.get(item); + } + if ('link' in item && item.link && activeMatcher(item.link)) { + matchCache.set(item, true); + return true; + } + if ('items' in item) { + const result = item.items.some(child => match(child)); + if (result) { + matchCache.set(item, true); + return true; + } + } + matchCache.set(item, false); + return false; + }; + const traverse = ( + item: + | NormalizedSidebarGroup + | SidebarItem + | SidebarDivider + | SidebarSectionHeader, + ) => { + if ('items' in item) { + item.items.forEach(traverse); + if (match(item)) { + item.collapsed = false; + } + } + }; + const newSidebarData = rawSidebarData.filter(Boolean).flat(); + newSidebarData.forEach(traverse); + return newSidebarData; +} + +/** + * handle the collapsed state of the sidebar groups + */ +export function useSidebarDynamic(): [ + SidebarData, + React.Dispatch>, +] { + const rawSidebarData = useSidebar(); + const activeMatcher = useActiveMatcher(); + + const [sidebar, setSidebar] = useState(() => + createInitialSidebar(rawSidebarData, activeMatcher), + ); + + useLayoutEffect(() => { + setSidebar(createInitialSidebar(rawSidebarData, activeMatcher)); + }, [rawSidebarData]); + + return [sidebar, setSidebar]; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7dd7acc79..f71363e50 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -2,6 +2,7 @@ export { Head } from '@unhead/react'; export { createPortal, flushSync } from 'react-dom'; export * from 'react-router-dom'; export { Content } from './Content'; +export { useActiveMatcher } from './hooks/useActiveMatcher'; export { ThemeContext, useDark } from './hooks/useDark'; export { useFrontmatter } from './hooks/useFrontmatter'; export { useI18n } from './hooks/useI18n'; @@ -11,13 +12,17 @@ export { useNav } from './hooks/useNav'; export { PageContext, usePage } from './hooks/usePage'; export { usePageData } from './hooks/usePageData'; export { usePages } from './hooks/usePages'; -export { getSidebarDataGroup, useSidebar } from './hooks/useSidebar'; +export { + getSidebarDataGroup, + useSidebar, + useSidebarDynamic, +} from './hooks/useSidebar'; export { useSite } from './hooks/useSite'; export { useVersion } from './hooks/useVersion'; export { useWindowSize } from './hooks/useWindowSize'; export { NoSSR } from './NoSSR'; -export { isActive, pathnameToRouteService } from './route'; +export { isActive, pathnameToRouteService, preloadLink } from './route'; export { addLeadingSlash, cleanUrlByConfig, diff --git a/packages/runtime/src/route.ts b/packages/runtime/src/route.ts index ad1261f9e..28e26a66b 100644 --- a/packages/runtime/src/route.ts +++ b/packages/runtime/src/route.ts @@ -45,3 +45,10 @@ export function isActive(itemLink: string, currentPathname: string): boolean { const linkMatched = matchPath(normalizedItemLink, normalizedCurrentPathname); return linkMatched !== null; } + +export const preloadLink = (link: string) => { + const route = pathnameToRouteService(link); + if (route) { + route.preload(); + } +}; diff --git a/packages/shared/src/types/defaultTheme.ts b/packages/shared/src/types/defaultTheme.ts index 4f6e8796d..c7ab045ec 100644 --- a/packages/shared/src/types/defaultTheme.ts +++ b/packages/shared/src/types/defaultTheme.ts @@ -106,11 +106,6 @@ export interface Config { * @default false */ enableAppearanceAnimation?: boolean; - /** - * Enable scroll to top button on documentation - * @default false - */ - enableScrollToTop?: boolean; /** * Whether to redirect to the closest locale when the user visits the site * @default 'auto' diff --git a/packages/theme-default/package.json b/packages/theme-default/package.json index 63d0e70e9..39eeed7f3 100644 --- a/packages/theme-default/package.json +++ b/packages/theme-default/package.json @@ -41,6 +41,7 @@ "@rspress/shared": "workspace:*", "@unhead/react": "^2.0.14", "body-scroll-lock": "4.0.0-beta.0", + "clsx": "2.1.1", "copy-to-clipboard": "^3.3.3", "flexsearch": "0.7.43", "github-slugger": "^2.0.0", diff --git a/packages/theme-default/rslib.config.ts b/packages/theme-default/rslib.config.ts index bc4c322f0..af5724b3f 100644 --- a/packages/theme-default/rslib.config.ts +++ b/packages/theme-default/rslib.config.ts @@ -30,7 +30,11 @@ export default defineConfig({ plugins: [ pluginReact(), pluginSvgr({ svgrOptions: { exportType: 'default' } }), - pluginSass(), + pluginSass({ + sassLoaderOptions: { + additionalData: `$prefix: 'rp-';`, + }, + }), ], source: { define: { diff --git a/packages/theme-default/src/assets/moon.svg b/packages/theme-default/src/assets/moon.svg index 228c56f68..f13dd85fe 100644 --- a/packages/theme-default/src/assets/moon.svg +++ b/packages/theme-default/src/assets/moon.svg @@ -1,3 +1,12 @@ - + + + + + + + + + + \ No newline at end of file diff --git a/packages/theme-default/src/assets/sun.svg b/packages/theme-default/src/assets/sun.svg index f07ba4360..eabbd893a 100644 --- a/packages/theme-default/src/assets/sun.svg +++ b/packages/theme-default/src/assets/sun.svg @@ -1,11 +1,5 @@ - + + + \ No newline at end of file diff --git a/packages/theme-default/src/components/Aside/ProgressCircle.module.scss b/packages/theme-default/src/components/Aside/ProgressCircle.module.scss new file mode 100644 index 000000000..525c05085 --- /dev/null +++ b/packages/theme-default/src/components/Aside/ProgressCircle.module.scss @@ -0,0 +1,3 @@ +.progressCircle { + display: inline-flex; +} diff --git a/packages/theme-default/src/components/Aside/ProgressCircle.tsx b/packages/theme-default/src/components/Aside/ProgressCircle.tsx new file mode 100644 index 000000000..dff231f0b --- /dev/null +++ b/packages/theme-default/src/components/Aside/ProgressCircle.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import type React from 'react'; +import * as styles from './ProgressCircle.module.scss'; + +interface CircleProgressProps { + percent: number; + size?: number; + strokeWidth?: number; + strokeColor?: string; + backgroundColor?: string; + className?: string; +} + +export const ProgressCircle: React.FC = ({ + percent, + size = 100, + strokeWidth = 8, + strokeColor = 'var(--rp-c-brand)', + backgroundColor = 'var(--rp-c-divider-light)', + className, +}) => { + const normalizedPercent = Math.min(100, Math.max(0, percent)); + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const offset = circumference - (normalizedPercent / 100) * circumference; + + return ( + + + + + ); +}; diff --git a/packages/theme-default/src/components/Aside/ScrollToTop.module.scss b/packages/theme-default/src/components/Aside/ScrollToTop.module.scss new file mode 100644 index 000000000..98091a402 --- /dev/null +++ b/packages/theme-default/src/components/Aside/ScrollToTop.module.scss @@ -0,0 +1,15 @@ +.scrollToTop { + cursor: pointer; + + font-size: 0.875rem; + font-style: normal; + font-weight: 400; + line-height: 20px; + color: var(--rp-c-text-2); + + background: transparent; + + display: flex; + align-items: center; + gap: 8px; +} diff --git a/packages/theme-default/src/components/Aside/ScrollToTop.tsx b/packages/theme-default/src/components/Aside/ScrollToTop.tsx new file mode 100644 index 000000000..4b612594d --- /dev/null +++ b/packages/theme-default/src/components/Aside/ScrollToTop.tsx @@ -0,0 +1,48 @@ +import * as styles from './ScrollToTop.module.scss'; + +export function ScrollToTop() { + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( + + ); +} diff --git a/packages/theme-default/src/components/Aside/TocItem.module.scss b/packages/theme-default/src/components/Aside/TocItem.module.scss new file mode 100644 index 000000000..83744293e --- /dev/null +++ b/packages/theme-default/src/components/Aside/TocItem.module.scss @@ -0,0 +1,18 @@ +.tocItem { + display: flex; + line-height: 24px; + justify-content: flex-start; + align-items: flex-start; + gap: 10px; + align-self: stretch; + border-radius: 6px; + + // text + color: var(--rp-c-text-2); + font-weight: 400; + font-size: 14px; + + &.active { + color: var(--rp-c-link); + } +} diff --git a/packages/theme-default/src/components/Aside/TocItem.tsx b/packages/theme-default/src/components/Aside/TocItem.tsx new file mode 100644 index 000000000..5d4ed3caf --- /dev/null +++ b/packages/theme-default/src/components/Aside/TocItem.tsx @@ -0,0 +1,39 @@ +import type { Header } from '@rspress/shared'; +import clsx from 'clsx'; +import { + parseInlineMarkdownText, + renderInlineMarkdown, +} from '../../logic/utils'; +import { Link } from '../Link'; +import * as styles from './TocItem.module.scss'; + +export const TocItem = ({ + header, + baseHeaderLevel, + active, +}: { + header: Header; + baseHeaderLevel: number; + active: boolean; +}) => { + return ( +
  • + + + +
  • + ); +}; diff --git a/packages/theme-default/src/components/Aside/index.module.scss b/packages/theme-default/src/components/Aside/index.module.scss new file mode 100644 index 000000000..f4f8a0213 --- /dev/null +++ b/packages/theme-default/src/components/Aside/index.module.scss @@ -0,0 +1,35 @@ +.asideContainer { + display: flex; + flex-direction: column; +} + +.outlineTitle { + font-size: 14px; + font-weight: 700; + height: 32px; + line-height: 32px; + font-family: Inter; + font-style: normal; + color: var(--rp-c-text-1); + + margin-bottom: 4px; + + display: inline-flex; + align-items: center; + gap: 4px; +} + +.divider { + height: 1px; + background: var(--rp-c-divider-light); + + margin-top: 16px; + margin-bottom: 16px; +} + +.tocContainer { + display: flex; + flex-direction: column; + flex: 1; + gap: 6px; +} diff --git a/packages/theme-default/src/components/Aside/index.scss b/packages/theme-default/src/components/Aside/index.scss deleted file mode 100644 index b5c908463..000000000 --- a/packages/theme-default/src/components/Aside/index.scss +++ /dev/null @@ -1,24 +0,0 @@ -.aside-link { - padding: 0.25rem 0; - margin-bottom: 1px; - border-radius: var(--rp-radius-small) 0 0 var(--rp-radius-small); - - &:hover { - background-color: var(--rp-c-bg-mute); - } - - &.aside-active { - &, - &:hover { - color: var(--rp-c-link); - background-color: var(--rp-c-brand-tint); - } - } -} - -.aside-link-text { - padding: 0px 12px; - font-size: 0.8125rem; - line-height: 1.25rem; - overflow-wrap: break-word; -} diff --git a/packages/theme-default/src/components/Aside/index.tsx b/packages/theme-default/src/components/Aside/index.tsx index 70a83be0f..24042d79a 100644 --- a/packages/theme-default/src/components/Aside/index.tsx +++ b/packages/theme-default/src/components/Aside/index.tsx @@ -1,50 +1,25 @@ import { useLocation } from '@rspress/runtime'; -import type { Header } from '@rspress/shared'; -import { useEffect, useMemo } from 'react'; -import { scrollToTarget, useBindingAsideScroll } from '../../logic/sideEffects'; -import { useUISwitch } from '../../logic/useUISwitch.js'; -import { - parseInlineMarkdownText, - renderInlineMarkdown, -} from '../../logic/utils'; +import { memo, useEffect, useMemo } from 'react'; +import { scrollToTarget } from '../../logic/sideEffects'; +import { type UISwitchResult, useUISwitch } from '../../logic/useUISwitch.js'; -import './index.scss'; +import * as styles from './index.module.scss'; +import { ProgressCircle } from './ProgressCircle'; +import { ScrollToTop } from './ScrollToTop'; +import { TocItem } from './TocItem'; +import { useActiveAnchor } from './useActiveAnchor'; import { useDynamicToc } from './useDynamicToc'; +import { useReadPercent } from './useReadPercent'; -const TocItem = ({ - header, - baseHeaderLevel, -}: { - header: Header; - baseHeaderLevel: number; -}) => { - return ( -
  • - { - e.preventDefault(); - window.location.hash = header.id; - }} - > - - -
  • - ); -}; +export interface AsideProps { + outlineTitle: string; + uiSwitch?: UISwitchResult; +} -export function Aside({ outlineTitle }: { outlineTitle: string }) { +export const Aside = memo(({ outlineTitle }: { outlineTitle: string }) => { const { scrollPaddingTop } = useUISwitch(); const headers = useDynamicToc(); + const [readPercent] = useReadPercent(); // For outline text highlight const baseHeaderLevel = 2; @@ -55,10 +30,8 @@ export function Aside({ outlineTitle }: { outlineTitle: string }) { [locationHash], ); - useBindingAsideScroll(headers); + const activeAnchorId = useActiveAnchor(headers, readPercent === 100); - // why window.scrollTo(0, 0)? - // when using history.scrollRestoration = 'auto' ref: "useUISwitch.ts", we scroll to the last page's position when navigating to nextPage useEffect(() => { if (decodedHash.length === 0) { window.scrollTo(0, 0); @@ -75,26 +48,32 @@ export function Aside({ outlineTitle }: { outlineTitle: string }) { } return ( -
    -
    -
    +
    +
    +
    {outlineTitle} +
    -
    + +
    +
    + +
    ); -} +}); + +Aside.displayName = 'Aside'; diff --git a/packages/theme-default/src/components/Aside/useActiveAnchor.ts b/packages/theme-default/src/components/Aside/useActiveAnchor.ts new file mode 100644 index 000000000..469d66ca3 --- /dev/null +++ b/packages/theme-default/src/components/Aside/useActiveAnchor.ts @@ -0,0 +1,54 @@ +import type { Header } from '@rspress/shared'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const useVisibleAnchors = (headers: Header[]): string[] => { + const [visibleAnchors, setVisibleAnchors] = useState([]); + + useEffect(() => { + const handleScroll = () => { + const offsets = headers.map(header => { + const el = document.getElementById(header.id); + if (!el) return { id: header.id, top: Infinity }; + const rect = el.getBoundingClientRect(); + return { id: header.id, top: rect.top }; + }); + + const visible = offsets + .filter(offset => offset.top >= 64 && offset.top < window.innerHeight) + .map(offset => offset.id); + + setVisibleAnchors(visible); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + handleScroll(); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [headers]); + + return visibleAnchors; +}; + +export const useActiveAnchor = (headers: Header[], isBottom: boolean) => { + const anchors = useVisibleAnchors(headers); + + const anchorDirty = useRef(null); + + const activeAnchor = useMemo(() => { + // If there are no anchors on the entire screen, use the before value. + if (anchors.length === 0) { + return anchorDirty.current; + } + const lastHeader = headers[headers.length - 1]; + if (isBottom) { + return lastHeader.id; + } + return anchors[0]; + }, [headers, anchors, isBottom]); + + anchorDirty.current = activeAnchor; + + return activeAnchor; +}; diff --git a/packages/theme-default/src/components/Aside/useDynamicToc.ts b/packages/theme-default/src/components/Aside/useDynamicToc.ts index 85094b4d7..3813996f9 100644 --- a/packages/theme-default/src/components/Aside/useDynamicToc.ts +++ b/packages/theme-default/src/components/Aside/useDynamicToc.ts @@ -25,7 +25,7 @@ const useSubScribe = () => { }, [forceUpdate]); }; -const headers: Header[] = [] satisfies Header[]; +let headers: Header[] = [] satisfies Header[]; function isElementOnlyVisible(element: Element): boolean { const style = window.getComputedStyle(element); @@ -76,8 +76,7 @@ function updateHeaders(target: Element) { } }); - headers.length = 0; - headers.push(...collectedHeaders); + headers = [...collectedHeaders]; distributeUpdate(); } diff --git a/packages/theme-default/src/components/Aside/useReadPercent.ts b/packages/theme-default/src/components/Aside/useReadPercent.ts new file mode 100644 index 000000000..c147c8d75 --- /dev/null +++ b/packages/theme-default/src/components/Aside/useReadPercent.ts @@ -0,0 +1,37 @@ +import { useWindowSize } from '@rspress/runtime'; +import { useEffect, useMemo, useState } from 'react'; + +export const useReadPercent = () => { + const [scrollTop, setScrollTop] = useState(0); + const [h, setH] = useState(0); + const { height } = useWindowSize(); + + useEffect(() => { + const scrollElement = window || document.documentElement; + const handler = () => { + const dom = document.querySelector('.rspress-doc'); + if (dom) { + const a = dom.getBoundingClientRect(); + setH(a.height || 0); + } + const scrollTop = window.scrollY || document.documentElement.scrollTop; + setScrollTop(scrollTop); + }; + handler(); + scrollElement?.addEventListener('scroll', handler); + return () => { + scrollElement?.removeEventListener('scroll', handler); + }; + }, []); + + const readPercent = useMemo(() => { + const deltaHeight = Math.min(scrollTop, height); + return ( + Math.floor( + Math.min(Math.max(0, ((scrollTop - 50 + deltaHeight) / h) * 100), 100), + ) || 0 + ); + }, [scrollTop, height]); + + return [readPercent, scrollTop]; +}; diff --git a/packages/theme-default/src/components/Badge/index.module.scss b/packages/theme-default/src/components/Badge/index.module.scss index 272c0f0dc..dbd3e3adb 100644 --- a/packages/theme-default/src/components/Badge/index.module.scss +++ b/packages/theme-default/src/components/Badge/index.module.scss @@ -1,6 +1,18 @@ .badge { font-weight: 500; transition: color 0.25s; + border-radius: 9999px; + + height: 1.5rem; // 24px + display: inline-flex; + justify-content: center; + align-items: center; + gap: 0.25rem; + + // text + font-weight: 500; + font-size: 0.75rem; // 12px + padding: 0.625rem 0.75rem 0.625rem 0.75rem; // 6px 12px &.tip { color: var(--rp-container-tip-text); @@ -23,7 +35,7 @@ } &.outline { - border: 1px solid; + border: 1px solid currentColor; &.tip { border-color: var(--rp-container-tip-border); diff --git a/packages/theme-default/src/components/Badge/index.tsx b/packages/theme-default/src/components/Badge/index.tsx index 0e2686a4e..ab7fdcfd4 100644 --- a/packages/theme-default/src/components/Badge/index.tsx +++ b/packages/theme-default/src/components/Badge/index.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import * as styles from './index.module.scss'; interface BadgeProps { @@ -52,11 +53,11 @@ export function Badge({ return ( {content} diff --git a/packages/theme-default/src/components/CodeBlockRuntime/index.tsx b/packages/theme-default/src/components/CodeBlockRuntime/index.tsx index 80303dc9b..1f125fa9d 100644 --- a/packages/theme-default/src/components/CodeBlockRuntime/index.tsx +++ b/packages/theme-default/src/components/CodeBlockRuntime/index.tsx @@ -9,11 +9,11 @@ import { codeToHast, createCssVariablesTheme, } from 'shiki'; -import { Code } from '../../layout/DocLayout/docComponents/code'; +import { Code } from '../../layout/DocLayout/docComponents/codeblock/code'; import { PreWithCodeButtonGroup, type PreWithCodeButtonGroupProps, -} from '../../layout/DocLayout/docComponents/pre'; +} from '../../layout/DocLayout/docComponents/codeblock/pre'; export interface CodeBlockRuntimeProps extends PreWithCodeButtonGroupProps { lang: string; diff --git a/packages/theme-default/src/components/DocFooter/index.module.scss b/packages/theme-default/src/components/DocFooter/index.module.scss index 7bc811337..7c72b354a 100644 --- a/packages/theme-default/src/components/DocFooter/index.module.scss +++ b/packages/theme-default/src/components/DocFooter/index.module.scss @@ -1,20 +1,9 @@ -@media (min-width: 640px) { - .pager { - display: flex; - flex-direction: column; - width: 50%; - } - - .pager.has-next { - padding-top: 0; - padding-left: 16px; - } +.docFooter { + margin-top: 48px; } - -.prev { - width: 100%; -} - -.next { +.divider { width: 100%; + height: 0.5px; + background-color: var(--rp-c-divider-light); + margin: 48px 0; } diff --git a/packages/theme-default/src/components/DocFooter/index.tsx b/packages/theme-default/src/components/DocFooter/index.tsx index 3e188ee6d..8fb24ee40 100644 --- a/packages/theme-default/src/components/DocFooter/index.tsx +++ b/packages/theme-default/src/components/DocFooter/index.tsx @@ -1,43 +1,18 @@ import { useLocaleSiteData, useSite } from '@rspress/runtime'; -import { EditLink, LastUpdated, PrevNextPage } from '@theme'; -import { usePrevNextPage } from '../../logic/usePrevNextPage'; +import { LastUpdated, PrevNextPage } from '@theme'; import * as styles from './index.module.scss'; export function DocFooter() { - const { prevPage, nextPage } = usePrevNextPage(); const { lastUpdated: localesLastUpdated = false } = useLocaleSiteData(); const { site } = useSite(); const { themeConfig } = site; const showLastUpdated = themeConfig.lastUpdated || localesLastUpdated; return ( -