Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/api/manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,8 +920,8 @@ export function replaceWith<T extends AnyNode>(
}

/**
* 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
Expand Down
16 changes: 16 additions & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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' },
Expand All @@ -24,6 +37,9 @@ export default defineConfig({
},
vite: {
plugins: [tailwindcss()],
define: {
__CHEERIO_VERSION__: JSON.stringify(cheerioVersion),
},
},
markdown: {
processor: unified({
Expand Down
41 changes: 1 addition & 40 deletions website/src/components/Sidebar.astro
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -105,9 +69,6 @@ const isApiPage = currentPath.startsWith('/docs/api');
const normalizedPath = currentPath.endsWith('/')
? currentPath
: `${currentPath}/`;

export type { SidebarItem, SidebarSection };
export { sidebar };
---

{/* ── Desktop sidebar ── */}
Expand Down
146 changes: 70 additions & 76 deletions website/src/components/live-code.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={handleReset}
className="px-2 py-1 text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors"
title="Reset code and re-run"
>
Reset
</button>
<div className="my-4 flex h-48 items-center justify-center rounded-lg border border-slate-700 text-sm text-slate-400">
Loading the editor…
</div>
);
}

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 (
<button
type="button"
onClick={handleRun}
className="px-2 py-1 text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors"
title="Run code"
>
Run
</button>
);
}
componentDidCatch() {
this.props.onError();
}

function Toolbar() {
return (
<div className="flex items-center justify-between px-3 py-2 bg-slate-100 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 uppercase tracking-wide">
Live Editor
</span>
<div className="flex items-center gap-2">
<RunButton />
<ResetButton />
</div>
</div>
);
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 (
<EditorBoundary onError={() => setIsEditing(false)}>
<Suspense fallback={<Loading />}>
<SandpackEditor code={code} onClose={() => setIsEditing(false)} />
</Suspense>
</EditorBoundary>
);
}

/*
* 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 (
<div className="my-4 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700 not-prose">
<SandpackProvider
template="vanilla"
theme="auto"
files={{
'/index.js': wrappedCode,
}}
customSetup={{
dependencies: {
cheerio: 'latest',
},
}}
>
<Toolbar />
<SandpackCodeEditor showLineNumbers style={{ height: '200px' }} />
<SandpackConsole
style={{ height: '150px' }}
standalone
showHeader
showResetConsoleButton
/>
</SandpackProvider>
<div className="my-4 overflow-hidden rounded-lg border border-slate-700 bg-slate-800">
<div className="flex items-center justify-between border-b border-slate-700 px-3 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-slate-400">
Example
</span>
<button
type="button"
onClick={() => setIsEditing(true)}
className="rounded px-2 py-1 text-xs font-medium text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-100"
>
Edit &amp; run
</button>
</div>
{/* The block keeps `.prose pre`'s colours; drop its margin and radius so
it reads as one unit with the bar above it. */}
<div className="[&_pre]:my-0 [&_pre]:rounded-none">{children}</div>
</div>
);
}
116 changes: 116 additions & 0 deletions website/src/components/sandpack-editor.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button type="button" onClick={handleRun} className={toolbarButton}>
Run
</button>
);
}

function ResetButton() {
const { sandpack } = useSandpack();

const handleReset = useCallback(() => sandpack.resetAllFiles(), [sandpack]);

return (
<button type="button" onClick={handleReset} className={toolbarButton}>
Reset
</button>
);
}

function Toolbar({ onClose }: { onClose: () => void }) {
return (
<div className="flex items-center justify-between border-b border-slate-700 bg-slate-800 px-3 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-slate-400">
Live editor
</span>
<div className="flex items-center gap-2">
<RunButton />
<ResetButton />
<button
type="button"
onClick={onClose}
className={toolbarButton}
aria-label="Close the editor and go back to the code sample"
>
Close
</button>
</div>
</div>
);
}

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 (
<div className="not-prose my-4 overflow-hidden rounded-lg border border-slate-700">
<SandpackProvider
template="vanilla"
theme="dark"
files={{ '/index.js': source }}
customSetup={{
// Pinned to the version this site documents, not whatever is latest.
dependencies: { cheerio: __CHEERIO_VERSION__ },
}}
>
<Toolbar onClose={onClose} />
<SandpackCodeEditor
showLineNumbers
style={{ height: `${editorHeight(source)}px` }}
/>
<SandpackConsole
style={{ height: '160px' }}
standalone
showHeader
showResetConsoleButton
showSyntaxError
/>
</SandpackProvider>
</div>
);
}
Loading