diff --git a/src/api/manipulation.ts b/src/api/manipulation.ts index 449e1ed605..d3d1e66f36 100644 --- a/src/api/manipulation.ts +++ b/src/api/manipulation.ts @@ -920,8 +920,8 @@ export function replaceWith( } /** - * Removes all children from each item in the selection. Text nodes and comment - * nodes are left as is. + * Removes all children from each item in the selection. Items that cannot have + * children, such as text and comment nodes, are left as is. * * @category Manipulation * @example diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 5b8f9de7de..9f3fff6cc9 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { unified } from '@astrojs/markdown-remark'; import mdx from '@astrojs/mdx'; import react from '@astrojs/react'; @@ -11,9 +12,21 @@ import { remarkInternalLinks } from './src/plugins/remark-internal-links.ts'; import { remarkLiveCode } from './src/plugins/remark-live-code.ts'; import { remarkPageTitle } from './src/plugins/remark-page-title.ts'; +/* + * The live editors install cheerio from npm. Pin them to the version this site + * documents, so the examples can't drift from the docs when a new release ships. + */ +const cheerioVersion = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; + export default defineConfig({ site: 'https://cheerio.js.org', integrations: [mdx(), react(), sitemap()], + // `extract` moved from Advanced to Basics; keep the published URL working. + redirects: { + '/docs/advanced/extract': '/docs/basics/extract/', + }, image: { remotePatterns: [ { protocol: 'https', hostname: 'github.com' }, @@ -24,6 +37,9 @@ export default defineConfig({ }, vite: { plugins: [tailwindcss()], + define: { + __CHEERIO_VERSION__: JSON.stringify(cheerioVersion), + }, }, markdown: { processor: unified({ diff --git a/website/src/components/Sidebar.astro b/website/src/components/Sidebar.astro index c03aa0e74a..f6632ac481 100644 --- a/website/src/components/Sidebar.astro +++ b/website/src/components/Sidebar.astro @@ -1,15 +1,6 @@ --- import { getCollection } from 'astro:content'; - -interface SidebarItem { - label: string; - href: string; -} - -interface SidebarSection { - title: string; - items: SidebarItem[]; -} +import { type SidebarItem, sidebar } from '@/lib/docs-nav'; interface ApiGroup { title: string; @@ -22,33 +13,6 @@ interface Props { const { currentPath } = Astro.props; -const sidebar: SidebarSection[] = [ - { - title: 'Getting Started', - items: [{ label: 'Introduction', href: '/docs/intro/' }], - }, - { - title: 'Basics', - items: [ - { label: 'Loading Documents', href: '/docs/basics/loading/' }, - { label: 'Selecting Elements', href: '/docs/basics/selecting/' }, - { label: 'Traversing the DOM', href: '/docs/basics/traversing/' }, - { label: 'Manipulating Elements', href: '/docs/basics/manipulation/' }, - ], - }, - { - title: 'Advanced', - items: [ - { - label: 'Configuring Cheerio', - href: '/docs/advanced/configuring-cheerio/', - }, - { label: 'Extending Cheerio', href: '/docs/advanced/extending-cheerio/' }, - { label: 'Extracting Data', href: '/docs/advanced/extract/' }, - ], - }, -]; - // Dynamically build API sub-pages from the content collection const allDocs = await getCollection('docs'); const apiDocs = allDocs.filter( @@ -105,9 +69,6 @@ const isApiPage = currentPath.startsWith('/docs/api'); const normalizedPath = currentPath.endsWith('/') ? currentPath : `${currentPath}/`; - -export type { SidebarItem, SidebarSection }; -export { sidebar }; --- {/* ── Desktop sidebar ── */} diff --git a/website/src/components/live-code.tsx b/website/src/components/live-code.tsx index f3deeaa58c..472099f86a 100644 --- a/website/src/components/live-code.tsx +++ b/website/src/components/live-code.tsx @@ -1,96 +1,90 @@ -import { - SandpackCodeEditor, - SandpackConsole, - SandpackProvider, - useSandpack, -} from '@codesandbox/sandpack-react'; -import { useCallback } from 'react'; +import { Component, lazy, type ReactNode, Suspense, useState } from 'react'; + +/* + * Sandpack is ~600 kB and spins up its own iframe, bundler connection and npm + * install per instance. A guide can hold a dozen examples, so it is only loaded + * once a reader actually asks to edit one. Until then they get the ordinary + * syntax-highlighted code block that the Markdown pipeline already produced. + */ +const SandpackEditor = lazy(() => import('./sandpack-editor')); interface LiveCodeProps { + /** The raw source, handed to the editor when it opens. */ code: string; + /** The highlighted code block, rendered by the Markdown pipeline. */ + children?: ReactNode; } -function ResetButton() { - const { sandpack } = useSandpack(); - - const handleReset = useCallback(() => sandpack.resetAllFiles(), [sandpack]); - +function Loading() { return ( - +
+ Loading the editor… +
); } -function RunButton() { - const { sandpack } = useSandpack(); +/* + * The editor is a separate chunk from a third-party bundler, so it can fail to + * load — a flaky network or a content blocker is enough. Without this, the + * rejection unmounts the island and the reader loses the code sample they could + * already see. Fall back to the static block instead. + */ +class EditorBoundary extends Component< + { children: ReactNode; onError: () => void }, + { failed: boolean } +> { + static getDerivedStateFromError() { + return { failed: true }; + } - const handleRun = () => { - const { code } = sandpack.files['/index.js']; - sandpack.updateFile('/index.js', code, true); - }; + state = { failed: false }; - return ( - - ); -} + componentDidCatch() { + this.props.onError(); + } -function Toolbar() { - return ( -
- - Live Editor - -
- - -
-
- ); + render() { + return this.state.failed ? null : this.props.children; + } } -export function LiveCode({ code }: LiveCodeProps) { - // Wrap user code to run immediately and output via console.log - const wrappedCode = `import * as cheerio from 'cheerio'; +export function LiveCode({ code, children }: LiveCodeProps) { + const [isEditing, setIsEditing] = useState(false); -${code} -`; + if (isEditing) { + return ( + setIsEditing(false)}> + }> + setIsEditing(false)} /> + + + ); + } + /* + * The bar sits above the code rather than floating over it, so it can never + * cover a long line, and it survives the block scrolling horizontally. It + * also mirrors the editor's own toolbar, so opening one is a swap rather than + * a jump. The button stays visible rather than appearing on hover: a + * hover-only affordance is undiscoverable and unreachable on touch devices. + */ return ( -
- - - - - +
+
+ + Example + + +
+ {/* The block keeps `.prose pre`'s colours; drop its margin and radius so + it reads as one unit with the bar above it. */} +
{children}
); } diff --git a/website/src/components/sandpack-editor.tsx b/website/src/components/sandpack-editor.tsx new file mode 100644 index 0000000000..4b8a6b1aa5 --- /dev/null +++ b/website/src/components/sandpack-editor.tsx @@ -0,0 +1,116 @@ +import { + SandpackCodeEditor, + SandpackConsole, + SandpackProvider, + useSandpack, +} from '@codesandbox/sandpack-react'; +import { useCallback } from 'react'; + +interface SandpackEditorProps { + code: string; + onClose: () => void; +} + +/* + * Sandpack's editor does not size itself to its content, so a fixed height + * either clips the example or leaves a large empty gap. Derive the height from + * the line count instead, with a ceiling so a long example still scrolls rather + * than pushing the rest of the page away. + */ +const LINE_HEIGHT = 22; +const EDITOR_PADDING = 32; +const MAX_EDITOR_HEIGHT = 460; + +function editorHeight(source: string): number { + const lines = source.split('\n').length; + return Math.min(lines * LINE_HEIGHT + EDITOR_PADDING, MAX_EDITOR_HEIGHT); +} + +const toolbarButton = + 'px-2 py-1 text-xs font-medium text-slate-400 hover:text-slate-100 hover:bg-slate-700 rounded transition-colors'; + +function RunButton() { + const { sandpack } = useSandpack(); + + const handleRun = useCallback(() => { + const { code } = sandpack.files['/index.js']; + sandpack.updateFile('/index.js', code, true); + }, [sandpack]); + + return ( + + ); +} + +function ResetButton() { + const { sandpack } = useSandpack(); + + const handleReset = useCallback(() => sandpack.resetAllFiles(), [sandpack]); + + return ( + + ); +} + +function Toolbar({ onClose }: { onClose: () => void }) { + return ( +
+ + Live editor + +
+ + + +
+
+ ); +} + +export default function SandpackEditor({ code, onClose }: SandpackEditorProps) { + // Keep the import visible so the sample stays copy-pasteable. + const source = `import * as cheerio from 'cheerio';\n\n${code}`; + + /* + * Forced dark rather than `auto`: the site's static code blocks are dark in + * both colour schemes, so an auto-themed editor would flip the block from + * dark to light the moment a reader opened it. + */ + return ( +
+ + + + + +
+ ); +} diff --git a/website/src/content/docs/advanced/configuring-cheerio.md b/website/src/content/docs/advanced/configuring-cheerio.md index 69b73901fe..265d895444 100644 --- a/website/src/content/docs/advanced/configuring-cheerio.md +++ b/website/src/content/docs/advanced/configuring-cheerio.md @@ -5,35 +5,38 @@ description: Configure Cheerio to work with different documents. # Configuring Cheerio -In this guide, we'll cover how to configure Cheerio to work with different types -of documents, and how to use and configure the different parsers that ship with -the library. +Cheerio ships with two parsers and picks between them based on the options you +pass to `.load()`: + +- [`parse5`](https://parse5.js.org/) — the default for HTML. It rigorously + conforms to the HTML standard, so it produces the same tree a browser would. +- [`htmlparser2`](https://github.com/fb55/htmlparser2) — the default for XML. + It is faster, uses less memory, and is more forgiving of malformed markup. + +This guide covers how to configure each of them. ## Parsing HTML with parse5 -By default, Cheerio uses the [`parse5`](https://parse5.js.org/) parser for HTML -documents. `parse5` is an excellent project that rigorously conforms to the HTML -standard. However, if you need to modify parsing options for HTML input, you may -pass an extra object to `.load()`: +`parse5` is used automatically for HTML. To change how it parses, pass an +options object as the second argument to `.load()`: ```js -const cheerio = require('cheerio'); +import * as cheerio from 'cheerio'; + +// With scripting disabled, the contents of
+`); + +console.log(JSON.stringify($('div').text())); +console.log(JSON.stringify($('div').prop('innerText'))); +``` + +## `text()` is full of whitespace + +Cheerio preserves the document's whitespace exactly, including the newlines and +indentation between tags. Browsers collapse that when rendering; Cheerio does +not. Trim it yourself: + +```js live +const $ = cheerio.load(` +
  • + Apple +
  • +`); + +console.log(JSON.stringify($('li').text())); +console.log(JSON.stringify($('li').text().trim())); +``` + +Note that `.prop('innerText')` does *not* help here — it skips `