Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 }
> {
state = { failed: false };

const handleRun = () => {
const { code } = sandpack.files['/index.js'];
sandpack.updateFile('/index.js', code, true);
};
static getDerivedStateFromError() {
return { failed: true };
}

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
Loading