feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+1145
View File
@@ -0,0 +1,1145 @@
/**
* Methods for getting and modifying attributes.
*
* @module cheerio/attributes
*/
import { text } from '../static.js';
import { domEach, camelCase, cssCase } from '../utils.js';
import { isTag, type AnyNode, type Element } from 'domhandler';
import type { Cheerio } from '../cheerio.js';
import { innerText, textContent } from 'domutils';
import { ElementType } from 'htmlparser2';
const hasOwn =
// @ts-expect-error `hasOwn` is a standard object method
(Object.hasOwn as (object: unknown, prop: string) => boolean) ??
((object: unknown, prop: string) =>
Object.prototype.hasOwnProperty.call(object, prop));
const rspace = /\s+/;
const dataAttrPrefix = 'data-';
// Attributes that are booleans
const rboolean =
/^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i;
// Matches strings that look like JSON objects or arrays
const rbrace = /^{[^]*}$|^\[[^]*]$/;
/**
* Gets a node's attribute. For boolean attributes, it will return the value's
* name should it be set.
*
* Also supports getting the `value` of several form elements.
*
* @private
* @category Attributes
* @param elem - Element to get the attribute of.
* @param name - Name of the attribute.
* @param xmlMode - Disable handling of special HTML attributes.
* @returns The attribute's value.
*/
function getAttr(
elem: AnyNode,
name: undefined,
xmlMode?: boolean,
): Record<string, string> | undefined;
function getAttr(
elem: AnyNode,
name: string,
xmlMode?: boolean,
): string | undefined;
function getAttr(
elem: AnyNode,
name: string | undefined,
xmlMode?: boolean,
): Record<string, string> | string | undefined {
if (!elem || !isTag(elem)) return undefined;
elem.attribs ??= {};
// Return the entire attribs object if no attribute specified
if (!name) {
return elem.attribs;
}
if (hasOwn(elem.attribs, name)) {
// Get the (decoded) attribute
return !xmlMode && rboolean.test(name) ? name : elem.attribs[name];
}
// Mimic the DOM and return text content as value for `option's`
if (elem.name === 'option' && name === 'value') {
return text(elem.children);
}
// Mimic DOM with default value for radios/checkboxes
if (
elem.name === 'input' &&
(elem.attribs['type'] === 'radio' || elem.attribs['type'] === 'checkbox') &&
name === 'value'
) {
return 'on';
}
return undefined;
}
/**
* Sets the value of an attribute. The attribute will be deleted if the value is
* `null`.
*
* @private
* @param el - The element to set the attribute on.
* @param name - The attribute's name.
* @param value - The attribute's value.
*/
function setAttr(el: Element, name: string, value: string | null) {
if (value === null) {
removeAttribute(el, name);
} else {
el.attribs[name] = `${value}`;
}
}
/**
* Method for getting attributes. Gets the attribute value for only the first
* element in the matched set.
*
* @category Attributes
* @example
*
* ```js
* $('ul').attr('id');
* //=> fruits
* ```
*
* @param name - Name of the attribute.
* @returns The attribute's value.
* @see {@link https://api.jquery.com/attr/}
*/
export function attr<T extends AnyNode>(
this: Cheerio<T>,
name: string,
): string | undefined;
/**
* Method for getting all attributes and their values of the first element in
* the matched set.
*
* @category Attributes
* @example
*
* ```js
* $('ul').attr();
* //=> { id: 'fruits' }
* ```
*
* @returns The attribute's values.
* @see {@link https://api.jquery.com/attr/}
*/
export function attr<T extends AnyNode>(
this: Cheerio<T>,
): Record<string, string> | undefined;
/**
* Method for setting attributes. Sets the attribute value for all elements in
* the matched set. If you set an attribute's value to `null`, you remove that
* attribute. You may also pass a `map` and `function`.
*
* @category Attributes
* @example
*
* ```js
* $('.apple').attr('id', 'favorite').prop('outerHTML');
* //=> <li class="apple" id="favorite">Apple</li>
* ```
*
* @param name - Name of the attribute.
* @param value - The new value of the attribute.
* @returns The instance itself.
* @see {@link https://api.jquery.com/attr/}
*/
export function attr<T extends AnyNode>(
this: Cheerio<T>,
name: string,
value?:
| string
| null
| ((this: Element, i: number, attrib: string) => string | null),
): Cheerio<T>;
/**
* Method for setting multiple attributes at once. Sets the attribute value for
* all elements in the matched set. If you set an attribute's value to `null`,
* you remove that attribute.
*
* @category Attributes
* @example
*
* ```js
* $('.apple').attr({ id: 'favorite' }).prop('outerHTML');
* //=> <li class="apple" id="favorite">Apple</li>
* ```
*
* @param values - Map of attribute names and values.
* @returns The instance itself.
* @see {@link https://api.jquery.com/attr/}
*/
export function attr<T extends AnyNode>(
this: Cheerio<T>,
values: Record<string, string | null>,
): Cheerio<T>;
export function attr<T extends AnyNode>(
this: Cheerio<T>,
name?: string | Record<string, string | null>,
value?:
| string
| null
| ((this: Element, i: number, attrib: string) => string | null),
): string | Cheerio<T> | undefined | Record<string, string> {
// Set the value (with attr map support)
if (typeof name === 'object' || value !== undefined) {
if (typeof value === 'function') {
if (typeof name !== 'string') {
{
throw new Error('Bad combination of arguments.');
}
}
return domEach(this, (el, i) => {
if (isTag(el)) setAttr(el, name, value.call(el, i, el.attribs[name]));
});
}
return domEach(this, (el) => {
if (!isTag(el)) return;
if (typeof name === 'object') {
for (const objName of Object.keys(name)) {
const objValue = name[objName];
setAttr(el, objName, objValue);
}
} else {
setAttr(el, name!, value!);
}
});
}
return arguments.length > 1
? this
: getAttr(this[0], name!, this.options.xmlMode);
}
/**
* Gets a node's prop.
*
* @private
* @category Attributes
* @param el - Element to get the prop of.
* @param name - Name of the prop.
* @param xmlMode - Disable handling of special HTML attributes.
* @returns The prop's value.
*/
function getProp(
el: Element,
name: string,
xmlMode?: boolean,
): string | undefined | boolean | Element[keyof Element] {
return name in el
? // @ts-expect-error TS doesn't like us accessing the value directly here.
(el[name] as string | undefined)
: !xmlMode && rboolean.test(name)
? getAttr(el, name, false) !== undefined
: getAttr(el, name, xmlMode);
}
/**
* Sets the value of a prop.
*
* @private
* @param el - The element to set the prop on.
* @param name - The prop's name.
* @param value - The prop's value.
* @param xmlMode - Disable handling of special HTML attributes.
*/
function setProp(el: Element, name: string, value: unknown, xmlMode?: boolean) {
if (name in el) {
// @ts-expect-error Overriding value
el[name] = value;
} else {
setAttr(
el,
name,
!xmlMode && rboolean.test(name)
? value
? ''
: null
: `${value as string}`,
);
}
}
interface StyleProp {
length: number;
[key: string]: string | number;
[index: number]: string;
}
/**
* Method for getting and setting properties. Gets the property value for only
* the first element in the matched set.
*
* @category Attributes
* @example
*
* ```js
* $('input[type="checkbox"]').prop('checked');
* //=> false
*
* $('input[type="checkbox"]').prop('checked', true).val();
* //=> ok
* ```
*
* @param name - Name of the property.
* @returns If `value` is specified the instance itself, otherwise the prop's
* value.
* @see {@link https://api.jquery.com/prop/}
*/
export function prop<T extends AnyNode>(
this: Cheerio<T>,
name: 'tagName' | 'nodeName',
): string | undefined;
export function prop<T extends AnyNode>(
this: Cheerio<T>,
name: 'innerHTML' | 'outerHTML' | 'innerText' | 'textContent',
): string | null;
/**
* Get a parsed CSS style object.
*
* @param name - Name of the property.
* @returns The style object, or `undefined` if the element has no `style`
* attribute.
*/
export function prop<T extends AnyNode>(
this: Cheerio<T>,
name: 'style',
): StyleProp | undefined;
/**
* Resolve `href` or `src` of supported elements. Requires the `baseURI` option
* to be set, and a global `URL` object to be part of the environment.
*
* @example With `baseURI` set to `'https://example.com'`:
*
* ```js
* $('<img src="image.png">').prop('src');
* //=> 'https://example.com/image.png'
* ```
*
* @param name - Name of the property.
* @returns The resolved URL, or `undefined` if the element is not supported.
*/
export function prop<T extends AnyNode>(
this: Cheerio<T>,
name: 'href' | 'src',
): string | undefined;
/**
* Get a property of an element.
*
* @param name - Name of the property.
* @returns The property's value.
*/
export function prop<T extends AnyNode, K extends keyof Element>(
this: Cheerio<T>,
name: K,
): Element[K];
/**
* Set a property of an element.
*
* @param name - Name of the property.
* @param value - Value to set the property to.
* @returns The instance itself.
*/
export function prop<T extends AnyNode, K extends keyof Element>(
this: Cheerio<T>,
name: K,
value:
| Element[K]
| ((this: Element, i: number, prop: K) => Element[keyof Element]),
): Cheerio<T>;
/**
* Set multiple properties of an element.
*
* @example
*
* ```js
* $('input[type="checkbox"]').prop({
* checked: true,
* disabled: false,
* });
* ```
*
* @param map - Object of properties to set.
* @returns The instance itself.
*/
export function prop<T extends AnyNode>(
this: Cheerio<T>,
map: Record<string, string | Element[keyof Element] | boolean>,
): Cheerio<T>;
/**
* Set a property of an element.
*
* @param name - Name of the property.
* @param value - Value to set the property to.
* @returns The instance itself.
*/
export function prop<T extends AnyNode>(
this: Cheerio<T>,
name: string,
value:
| string
| boolean
| null
| ((this: Element, i: number, prop: string) => string | boolean),
): Cheerio<T>;
/**
* Get a property of an element.
*
* @param name - The property's name.
* @returns The property's value.
*/
export function prop<T extends AnyNode>(this: Cheerio<T>, name: string): string;
export function prop<T extends AnyNode>(
this: Cheerio<T>,
name: string | Record<string, string | Element[keyof Element] | boolean>,
value?: unknown,
):
| Cheerio<T>
| string
| boolean
| undefined
| null
| Element[keyof Element]
| StyleProp {
if (typeof name === 'string' && value === undefined) {
const el = this[0];
if (!el) return undefined;
switch (name) {
case 'style': {
const property = this.css() as StyleProp;
const keys = Object.keys(property);
for (let i = 0; i < keys.length; i++) {
property[i] = keys[i];
}
property.length = keys.length;
return property;
}
case 'tagName':
case 'nodeName': {
if (!isTag(el)) return undefined;
return el.name.toUpperCase();
}
case 'href':
case 'src': {
if (!isTag(el)) return undefined;
const prop = el.attribs?.[name];
if (
typeof URL !== 'undefined' &&
((name === 'href' && (el.tagName === 'a' || el.tagName === 'link')) ||
(name === 'src' &&
(el.tagName === 'img' ||
el.tagName === 'iframe' ||
el.tagName === 'audio' ||
el.tagName === 'video' ||
el.tagName === 'source'))) &&
prop !== undefined &&
this.options.baseURI
) {
return new URL(prop, this.options.baseURI).href;
}
return prop;
}
case 'innerText': {
return innerText(el);
}
case 'textContent': {
return textContent(el);
}
case 'outerHTML': {
if (el.type === ElementType.Root) return this.html();
return this.clone().wrap('<container />').parent().html();
}
case 'innerHTML': {
return this.html();
}
default: {
if (!isTag(el)) return undefined;
return getProp(el, name, this.options.xmlMode);
}
}
}
if (typeof name === 'object' || value !== undefined) {
if (typeof value === 'function') {
if (typeof name === 'object') {
throw new TypeError('Bad combination of arguments.');
}
return domEach(this, (el, i) => {
if (isTag(el)) {
setProp(
el,
name,
value.call(el, i, getProp(el, name, this.options.xmlMode)),
this.options.xmlMode,
);
}
});
}
return domEach(this, (el) => {
if (!isTag(el)) return;
if (typeof name === 'object') {
for (const key of Object.keys(name)) {
const val = name[key];
setProp(el, key, val, this.options.xmlMode);
}
} else {
setProp(el, name, value, this.options.xmlMode);
}
});
}
return undefined;
}
/**
* An element with a data attribute.
*
* @private
*/
interface DataElement extends Element {
/** The data attribute. */
data?: Record<string, unknown>;
}
/**
* Sets the value of a data attribute.
*
* @private
* @param elem - The element to set the data attribute on.
* @param name - The data attribute's name.
* @param value - The data attribute's value.
*/
function setData(
elem: DataElement,
name: string | Record<string, unknown>,
value?: unknown,
) {
elem.data ??= {};
if (typeof name === 'object') Object.assign(elem.data, name);
else if (typeof name === 'string' && value !== undefined) {
elem.data[name] = value;
}
}
/**
* Read _all_ HTML5 `data-*` attributes from the equivalent HTML5 `data-*`
* attribute, and cache the value in the node's internal data store.
*
* @private
* @category Attributes
* @param el - Element to get the data attribute of.
* @returns A map with all of the data attributes.
*/
function readAllData(el: DataElement): unknown {
for (const domName of Object.keys(el.attribs)) {
if (!domName.startsWith(dataAttrPrefix)) {
continue;
}
const jsName = camelCase(domName.slice(dataAttrPrefix.length));
if (!hasOwn(el.data, jsName)) {
el.data![jsName] = parseDataValue(el.attribs[domName]);
}
}
return el.data;
}
/**
* Read the specified attribute from the equivalent HTML5 `data-*` attribute,
* and (if present) cache the value in the node's internal data store.
*
* @private
* @category Attributes
* @param el - Element to get the data attribute of.
* @param name - Name of the data attribute.
* @returns The data attribute's value.
*/
function readData(el: DataElement, name: string): unknown {
const domName = dataAttrPrefix + cssCase(name);
const data = el.data!;
if (hasOwn(data, name)) {
return data[name];
}
if (hasOwn(el.attribs, domName)) {
return (data[name] = parseDataValue(el.attribs[domName]));
}
return undefined;
}
/**
* Coerce string data-* attributes to their corresponding JavaScript primitives.
*
* @private
* @category Attributes
* @param value - The value to parse.
* @returns The parsed value.
*/
function parseDataValue(value: string): unknown {
if (value === 'null') return null;
if (value === 'true') return true;
if (value === 'false') return false;
const num = Number(value);
if (value === String(num)) return num;
if (rbrace.test(value)) {
try {
return JSON.parse(value);
} catch {
/* Ignore */
}
}
return value;
}
/**
* Method for getting data attributes, for only the first element in the matched
* set.
*
* @category Attributes
* @example
*
* ```js
* $('<div data-apple-color="red"></div>').data('apple-color');
* //=> 'red'
* ```
*
* @param name - Name of the data attribute.
* @returns The data attribute's value, or `undefined` if the attribute does not
* exist.
* @see {@link https://api.jquery.com/data/}
*/
export function data<T extends AnyNode>(
this: Cheerio<T>,
name: string,
): unknown;
/**
* Method for getting all of an element's data attributes, for only the first
* element in the matched set.
*
* @category Attributes
* @example
*
* ```js
* $('<div data-apple-color="red"></div>').data();
* //=> { appleColor: 'red' }
* ```
*
* @returns A map with all of the data attributes.
* @see {@link https://api.jquery.com/data/}
*/
export function data<T extends AnyNode>(
this: Cheerio<T>,
): Record<string, unknown>;
/**
* Method for setting data attributes, for only the first element in the matched
* set.
*
* @category Attributes
* @example
*
* ```js
* const apple = $('.apple').data('kind', 'mac');
*
* apple.data('kind');
* //=> 'mac'
* ```
*
* @param name - Name of the data attribute.
* @param value - The new value.
* @returns The instance itself.
* @see {@link https://api.jquery.com/data/}
*/
export function data<T extends AnyNode>(
this: Cheerio<T>,
name: string,
value: unknown,
): Cheerio<T>;
/**
* Method for setting multiple data attributes at once, for only the first
* element in the matched set.
*
* @category Attributes
* @example
*
* ```js
* const apple = $('.apple').data({ kind: 'mac' });
*
* apple.data('kind');
* //=> 'mac'
* ```
*
* @param values - Map of names to values.
* @returns The instance itself.
* @see {@link https://api.jquery.com/data/}
*/
export function data<T extends AnyNode>(
this: Cheerio<T>,
values: Record<string, unknown>,
): Cheerio<T>;
export function data<T extends AnyNode>(
this: Cheerio<T>,
name?: string | Record<string, unknown>,
value?: unknown,
): unknown {
const elem = this[0];
if (!elem || !isTag(elem)) return;
const dataEl: DataElement = elem;
dataEl.data ??= {};
// Return the entire data object if no data specified
if (name == null) {
return readAllData(dataEl);
}
// Set the value (with attr map support)
if (typeof name === 'object' || value !== undefined) {
domEach(this, (el) => {
if (isTag(el)) {
if (typeof name === 'object') setData(el, name);
else setData(el, name, value);
}
});
return this;
}
return readData(dataEl, name);
}
/**
* Method for getting the value of input, select, and textarea. Note: Support
* for `map`, and `function` has not been added yet.
*
* @category Attributes
* @example
*
* ```js
* $('input[type="text"]').val();
* //=> input_text
* ```
*
* @returns The value.
* @see {@link https://api.jquery.com/val/}
*/
export function val<T extends AnyNode>(
this: Cheerio<T>,
): string | undefined | string[];
/**
* Method for setting the value of input, select, and textarea. Note: Support
* for `map`, and `function` has not been added yet.
*
* @category Attributes
* @example
*
* ```js
* $('input[type="text"]').val('test').prop('outerHTML');
* //=> <input type="text" value="test"/>
* ```
*
* @param value - The new value.
* @returns The instance itself.
* @see {@link https://api.jquery.com/val/}
*/
export function val<T extends AnyNode>(
this: Cheerio<T>,
value: string | string[],
): Cheerio<T>;
export function val<T extends AnyNode>(
this: Cheerio<T>,
value?: string | string[],
): string | string[] | Cheerio<T> | undefined {
const querying = arguments.length === 0;
const element = this[0];
if (!element || !isTag(element)) return querying ? undefined : this;
switch (element.name) {
case 'textarea': {
return this.text(value as string);
}
case 'select': {
const option = this.find('option:selected');
if (!querying) {
if (this.attr('multiple') == null && typeof value === 'object') {
return this;
}
this.find('option').removeAttr('selected');
const values = typeof value === 'object' ? value : [value];
for (const val of values) {
this.find(`option[value="${val}"]`).attr('selected', '');
}
return this;
}
return this.attr('multiple')
? option.toArray().map((el) => text(el.children))
: option.attr('value');
}
case 'button':
case 'input':
case 'option': {
return querying
? this.attr('value')
: this.attr('value', value as string);
}
}
return undefined;
}
/**
* Remove an attribute.
*
* @private
* @param elem - Node to remove attribute from.
* @param name - Name of the attribute to remove.
*/
function removeAttribute(elem: Element, name: string) {
if (!elem.attribs || !hasOwn(elem.attribs, name)) return;
delete elem.attribs[name];
}
/**
* Splits a space-separated list of names to individual names.
*
* @category Attributes
* @param names - Names to split.
* @returns - Split names.
*/
function splitNames(names?: string): string[] {
return names ? names.trim().split(rspace) : [];
}
/**
* Method for removing attributes by `name`.
*
* @category Attributes
* @example
*
* ```js
* $('.pear').removeAttr('class').prop('outerHTML');
* //=> <li>Pear</li>
*
* $('.apple').attr('id', 'favorite');
* $('.apple').removeAttr('id class').prop('outerHTML');
* //=> <li>Apple</li>
* ```
*
* @param name - Name of the attribute.
* @returns The instance itself.
* @see {@link https://api.jquery.com/removeAttr/}
*/
export function removeAttr<T extends AnyNode>(
this: Cheerio<T>,
name: string,
): Cheerio<T> {
const attrNames = splitNames(name);
for (const attrName of attrNames) {
domEach(this, (elem) => {
if (isTag(elem)) removeAttribute(elem, attrName);
});
}
return this;
}
/**
* Check to see if _any_ of the matched elements have the given `className`.
*
* @category Attributes
* @example
*
* ```js
* $('.pear').hasClass('pear');
* //=> true
*
* $('apple').hasClass('fruit');
* //=> false
*
* $('li').hasClass('pear');
* //=> true
* ```
*
* @param className - Name of the class.
* @returns Indicates if an element has the given `className`.
* @see {@link https://api.jquery.com/hasClass/}
*/
export function hasClass<T extends AnyNode>(
this: Cheerio<T>,
className: string,
): boolean {
return this.toArray().some((elem) => {
const clazz = isTag(elem) && elem.attribs['class'];
let idx = -1;
if (clazz && className.length > 0) {
while ((idx = clazz.indexOf(className, idx + 1)) > -1) {
const end = idx + className.length;
if (
(idx === 0 || rspace.test(clazz[idx - 1])) &&
(end === clazz.length || rspace.test(clazz[end]))
) {
return true;
}
}
}
return false;
});
}
/**
* Adds class(es) to all of the matched elements. Also accepts a `function`.
*
* @category Attributes
* @example
*
* ```js
* $('.pear').addClass('fruit').prop('outerHTML');
* //=> <li class="pear fruit">Pear</li>
*
* $('.apple').addClass('fruit red').prop('outerHTML');
* //=> <li class="apple fruit red">Apple</li>
* ```
*
* @param value - Name of new class.
* @returns The instance itself.
* @see {@link https://api.jquery.com/addClass/}
*/
export function addClass<T extends AnyNode, R extends ArrayLike<T>>(
this: R,
value?:
| string
| ((this: Element, i: number, className: string) => string | undefined),
): R {
// Support functions
if (typeof value === 'function') {
return domEach(this, (el, i) => {
if (isTag(el)) {
const className = el.attribs['class'] || '';
addClass.call([el], value.call(el, i, className));
}
});
}
// Return if no value or not a string or function
if (!value || typeof value !== 'string') return this;
const classNames = value.split(rspace);
const numElements = this.length;
for (let i = 0; i < numElements; i++) {
const el = this[i];
// If selected element isn't a tag, move on
if (!isTag(el)) continue;
// If we don't already have classes — always set xmlMode to false here, as it doesn't matter for classes
const className = getAttr(el, 'class', false);
if (className) {
let setClass = ` ${className} `;
// Check if class already exists
for (const cn of classNames) {
const appendClass = `${cn} `;
if (!setClass.includes(` ${appendClass}`)) setClass += appendClass;
}
setAttr(el, 'class', setClass.trim());
} else {
setAttr(el, 'class', classNames.join(' ').trim());
}
}
return this;
}
/**
* Removes one or more space-separated classes from the selected elements. If no
* `className` is defined, all classes will be removed. Also accepts a
* `function`.
*
* @category Attributes
* @example
*
* ```js
* $('.pear').removeClass('pear').prop('outerHTML');
* //=> <li class="">Pear</li>
*
* $('.apple').addClass('red').removeClass().prop('outerHTML');
* //=> <li class="">Apple</li>
* ```
*
* @param name - Name of the class. If not specified, removes all elements.
* @returns The instance itself.
* @see {@link https://api.jquery.com/removeClass/}
*/
export function removeClass<T extends AnyNode, R extends ArrayLike<T>>(
this: R,
name?:
| string
| ((this: Element, i: number, className: string) => string | undefined),
): R {
// Handle if value is a function
if (typeof name === 'function') {
return domEach(this, (el, i) => {
if (isTag(el)) {
removeClass.call([el], name.call(el, i, el.attribs['class'] || ''));
}
});
}
const classes = splitNames(name);
const numClasses = classes.length;
const removeAll = arguments.length === 0;
return domEach(this, (el) => {
if (!isTag(el)) return;
if (removeAll) {
// Short circuit the remove all case as this is the nice one
el.attribs['class'] = '';
} else {
const elClasses = splitNames(el.attribs['class']);
let changed = false;
for (let j = 0; j < numClasses; j++) {
const index = elClasses.indexOf(classes[j]);
if (index !== -1) {
elClasses.splice(index, 1);
changed = true;
/*
* We have to do another pass to ensure that there are not duplicate
* classes listed
*/
j--;
}
}
if (changed) {
el.attribs['class'] = elClasses.join(' ');
}
}
});
}
/**
* Add or remove class(es) from the matched elements, depending on either the
* class's presence or the value of the switch argument. Also accepts a
* `function`.
*
* @category Attributes
* @example
*
* ```js
* $('.apple.green').toggleClass('fruit green red').prop('outerHTML');
* //=> <li class="apple fruit red">Apple</li>
*
* $('.apple.green').toggleClass('fruit green red', true).prop('outerHTML');
* //=> <li class="apple green fruit red">Apple</li>
* ```
*
* @param value - Name of the class. Can also be a function.
* @param stateVal - If specified the state of the class.
* @returns The instance itself.
* @see {@link https://api.jquery.com/toggleClass/}
*/
export function toggleClass<T extends AnyNode, R extends ArrayLike<T>>(
this: R,
value?:
| string
| ((
this: Element,
i: number,
className: string,
stateVal?: boolean,
) => string),
stateVal?: boolean,
): R {
// Support functions
if (typeof value === 'function') {
return domEach(this, (el, i) => {
if (isTag(el)) {
toggleClass.call(
[el],
value.call(el, i, el.attribs['class'] || '', stateVal),
stateVal,
);
}
});
}
// Return if no value or not a string or function
if (!value || typeof value !== 'string') return this;
const classNames = value.split(rspace);
const numClasses = classNames.length;
const state = typeof stateVal === 'boolean' ? (stateVal ? 1 : -1) : 0;
const numElements = this.length;
for (let i = 0; i < numElements; i++) {
const el = this[i];
// If selected element isn't a tag, move on
if (!isTag(el)) continue;
const elementClasses = splitNames(el.attribs['class']);
// Check if class already exists
for (let j = 0; j < numClasses; j++) {
// Check if the class name is currently defined
const index = elementClasses.indexOf(classNames[j]);
// Add if stateValue === true or we are toggling and there is no value
if (state >= 0 && index === -1) {
elementClasses.push(classNames[j]);
} else if (state <= 0 && index !== -1) {
// Otherwise remove but only if the item exists
elementClasses.splice(index, 1);
}
}
el.attribs['class'] = elementClasses.join(' ');
}
return this;
}
+224
View File
@@ -0,0 +1,224 @@
import { domEach } from '../utils.js';
import { isTag, type Element, type AnyNode } from 'domhandler';
import type { Cheerio } from '../cheerio.js';
/**
* Get the value of a style property for the first element in the set of matched
* elements.
*
* @category CSS
* @param names - Optionally the names of the properties of interest.
* @returns A map of all of the style properties.
* @see {@link https://api.jquery.com/css/}
*/
export function css<T extends AnyNode>(
this: Cheerio<T>,
names?: string[],
): Record<string, string> | undefined;
/**
* Get the value of a style property for the first element in the set of matched
* elements.
*
* @category CSS
* @param name - The name of the property.
* @returns The property value for the given name.
* @see {@link https://api.jquery.com/css/}
*/
export function css<T extends AnyNode>(
this: Cheerio<T>,
name: string,
): string | undefined;
/**
* Set one CSS property for every matched element.
*
* @category CSS
* @param prop - The name of the property.
* @param val - The new value.
* @returns The instance itself.
* @see {@link https://api.jquery.com/css/}
*/
export function css<T extends AnyNode>(
this: Cheerio<T>,
prop: string,
val:
| string
| ((this: Element, i: number, style: string) => string | undefined),
): Cheerio<T>;
/**
* Set multiple CSS properties for every matched element.
*
* @category CSS
* @param map - A map of property names and values.
* @returns The instance itself.
* @see {@link https://api.jquery.com/css/}
*/
export function css<T extends AnyNode>(
this: Cheerio<T>,
map: Record<string, string>,
): Cheerio<T>;
/**
* Set multiple CSS properties for every matched element.
*
* @category CSS
* @param prop - The names of the properties.
* @param val - The new values.
* @returns The instance itself.
* @see {@link https://api.jquery.com/css/}
*/
export function css<T extends AnyNode>(
this: Cheerio<T>,
prop?: string | string[] | Record<string, string>,
val?:
| string
| ((this: Element, i: number, style: string) => string | undefined),
): Cheerio<T> | Record<string, string> | string | undefined {
if (
(prop != null && val != null) ||
// When `prop` is a "plain" object
(typeof prop === 'object' && !Array.isArray(prop))
) {
return domEach(this, (el, i) => {
if (isTag(el)) {
// `prop` can't be an array here anymore.
setCss(el, prop as string, val, i);
}
});
}
if (this.length === 0) {
return undefined;
}
return getCss(this[0], prop as string);
}
/**
* Set styles of all elements.
*
* @private
* @param el - Element to set style of.
* @param prop - Name of property.
* @param value - Value to set property to.
* @param idx - Optional index within the selection.
*/
function setCss(
el: Element,
prop: string | Record<string, string>,
value:
| string
| ((this: Element, i: number, style: string) => string | undefined)
| undefined,
idx: number,
) {
if (typeof prop === 'string') {
const styles = getCss(el);
const val =
typeof value === 'function' ? value.call(el, idx, styles[prop]) : value;
if (val === '') {
delete styles[prop];
} else if (val != null) {
styles[prop] = val;
}
el.attribs['style'] = stringify(styles);
} else if (typeof prop === 'object') {
const keys = Object.keys(prop);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
setCss(el, k, prop[k], i);
}
}
}
/**
* Get the parsed styles of the first element.
*
* @private
* @category CSS
* @param el - Element to get styles from.
* @param props - Optionally the names of the properties of interest.
* @returns The parsed styles.
*/
function getCss(el: AnyNode, props?: string[]): Record<string, string>;
/**
* Get a property from the parsed styles of the first element.
*
* @private
* @category CSS
* @param el - Element to get styles from.
* @param prop - Name of the prop.
* @returns The value of the property.
*/
function getCss(el: AnyNode, prop: string): string | undefined;
function getCss(
el: AnyNode,
prop?: string | string[],
): Record<string, string> | string | undefined {
if (!el || !isTag(el)) return;
const styles = parse(el.attribs['style']);
if (typeof prop === 'string') {
return styles[prop];
}
if (Array.isArray(prop)) {
const newStyles: Record<string, string> = {};
for (const item of prop) {
if (styles[item] != null) {
newStyles[item] = styles[item];
}
}
return newStyles;
}
return styles;
}
/**
* Stringify `obj` to styles.
*
* @private
* @category CSS
* @param obj - Object to stringify.
* @returns The serialized styles.
*/
function stringify(obj: Record<string, string>): string {
return Object.keys(obj).reduce(
(str, prop) => `${str}${str ? ' ' : ''}${prop}: ${obj[prop]};`,
'',
);
}
/**
* Parse `styles`.
*
* @private
* @category CSS
* @param styles - Styles to be parsed.
* @returns The parsed styles.
*/
function parse(styles: string): Record<string, string> {
styles = (styles || '').trim();
if (!styles) return {};
const obj: Record<string, string> = {};
let key: string | undefined;
for (const str of styles.split(';')) {
const n = str.indexOf(':');
// If there is no :, or if it is the first/last character, add to the previous item's value
if (n < 1 || n === str.length - 1) {
const trimmed = str.trimEnd();
if (trimmed.length > 0 && key !== undefined) {
obj[key] += `;${trimmed}`;
}
} else {
key = str.slice(0, n).trim();
obj[key] = str.slice(n + 1).trim();
}
}
return obj;
}
+92
View File
@@ -0,0 +1,92 @@
import type { AnyNode, Element } from 'domhandler';
import type { Cheerio } from '../cheerio.js';
import type { prop } from './attributes.js';
type ExtractDescriptorFn = (
el: Element,
key: string,
// TODO: This could be typed with ExtractedMap
obj: Record<string, unknown>,
) => unknown;
interface ExtractDescriptor {
selector: string;
value?: string | ExtractDescriptorFn | ExtractMap;
}
type ExtractValue = string | ExtractDescriptor | [string | ExtractDescriptor];
export type ExtractMap = Record<string, ExtractValue>;
type ExtractedValue<V extends ExtractValue> = V extends [
string | ExtractDescriptor,
]
? NonNullable<ExtractedValue<V[0]>>[]
: V extends string
? string | undefined
: V extends ExtractDescriptor
? V['value'] extends infer U
? U extends ExtractMap
? ExtractedMap<U> | undefined
: U extends ExtractDescriptorFn
? ReturnType<U> | undefined
: ReturnType<typeof prop> | undefined
: never
: never;
export type ExtractedMap<M extends ExtractMap> = {
[key in keyof M]: ExtractedValue<M[key]>;
};
function getExtractDescr(
descr: string | ExtractDescriptor,
): Required<ExtractDescriptor> {
if (typeof descr === 'string') {
return { selector: descr, value: 'textContent' };
}
return {
selector: descr.selector,
value: descr.value ?? 'textContent',
};
}
/**
* Extract multiple values from a document, and store them in an object.
*
* @param map - An object containing key-value pairs. The keys are the names of
* the properties to be created on the object, and the values are the
* selectors to be used to extract the values.
* @returns An object containing the extracted values.
*/
export function extract<M extends ExtractMap, T extends AnyNode>(
this: Cheerio<T>,
map: M,
): ExtractedMap<M> {
const ret: Record<string, unknown> = {};
for (const key in map) {
const descr = map[key];
const isArray = Array.isArray(descr);
const { selector, value } = getExtractDescr(isArray ? descr[0] : descr);
const fn: ExtractDescriptorFn =
typeof value === 'function'
? value
: typeof value === 'string'
? (el: Element) => this._make(el).prop(value)
: (el: Element) => this._make(el).extract(value);
if (isArray) {
ret[key] = this._findBySelector(selector, Number.POSITIVE_INFINITY)
.map((_, el) => fn(el, key, ret))
.get();
} else {
const $ = this._findBySelector(selector, 1);
ret[key] = $.length > 0 ? fn($[0], key, ret) : undefined;
}
}
return ret as ExtractedMap<M>;
}
+103
View File
@@ -0,0 +1,103 @@
import { isTag, type AnyNode } from 'domhandler';
import type { Cheerio } from '../cheerio.js';
/*
* https://github.com/jquery/jquery/blob/2.1.3/src/manipulation/var/rcheckableType.js
* https://github.com/jquery/jquery/blob/2.1.3/src/serialize.js
*/
const submittableSelector = 'input,select,textarea,keygen';
const r20 = /%20/g;
const rCRLF = /\r?\n/g;
/**
* Encode a set of form elements as a string for submission.
*
* @category Forms
* @example
*
* ```js
* $('<form><input name="foo" value="bar" /></form>').serialize();
* //=> 'foo=bar'
* ```
*
* @returns The serialized form.
* @see {@link https://api.jquery.com/serialize/}
*/
export function serialize<T extends AnyNode>(this: Cheerio<T>): string {
// Convert form elements into name/value objects
const arr = this.serializeArray();
// Serialize each element into a key/value string
const retArr = arr.map(
(data) =>
`${encodeURIComponent(data.name)}=${encodeURIComponent(data.value)}`,
);
// Return the resulting serialization
return retArr.join('&').replace(r20, '+');
}
/**
* Encode a set of form elements as an array of names and values.
*
* @category Forms
* @example
*
* ```js
* $('<form><input name="foo" value="bar" /></form>').serializeArray();
* //=> [ { name: 'foo', value: 'bar' } ]
* ```
*
* @returns The serialized form.
* @see {@link https://api.jquery.com/serializeArray/}
*/
export function serializeArray<T extends AnyNode>(
this: Cheerio<T>,
): {
name: string;
value: string;
}[] {
// Resolve all form elements from either forms or collections of form elements
return this.map((_, elem) => {
const $elem = this._make(elem);
if (isTag(elem) && elem.name === 'form') {
return $elem.find(submittableSelector).toArray();
}
return $elem.filter(submittableSelector).toArray();
})
.filter(
// Verify elements have a name (`attr.name`) and are not disabled (`:enabled`)
'[name!=""]:enabled' +
// And cannot be clicked (`[type=submit]`) or are used in `x-www-form-urlencoded` (`[type=file]`)
':not(:submit, :button, :image, :reset, :file)' +
// And are either checked/don't have a checkable state
':matches([checked], :not(:checkbox, :radio))',
// Convert each of the elements to its value(s)
)
.map<
AnyNode,
{
name: string;
value: string;
}
>((_, elem) => {
const $elem = this._make(elem);
const name = $elem.attr('name')!; // We have filtered for elements with a name before.
// If there is no value set (e.g. `undefined`, `null`), then default value to empty
const value = $elem.val() ?? '';
// If we have an array of values (e.g. `<select multiple>`), return an array of key/value pairs
if (Array.isArray(value)) {
return value.map((val) =>
/*
* We trim replace any line endings (e.g. `\r` or `\r\n` with `\r\n`) to guarantee consistency across platforms
* These can occur inside of `<textarea>'s`
*/
({ name, value: val.replace(rCRLF, '\r\n') }),
);
}
// Otherwise (e.g. `<input type="text">`, return only one key/value pair
return { name, value: value.replace(rCRLF, '\r\n') };
})
.toArray();
}
+1115
View File
@@ -0,0 +1,1115 @@
/**
* Methods for modifying the DOM structure.
*
* @module cheerio/manipulation
*/
import {
isTag,
Text,
hasChildren,
cloneNode,
Document,
type ParentNode,
type AnyNode,
type Element,
} from 'domhandler';
import { update as updateDOM } from '../parse.js';
import { text as staticText } from '../static.js';
import { domEach, isHtml, isCheerio } from '../utils.js';
import { removeElement } from 'domutils';
import type { Cheerio } from '../cheerio.js';
import type { BasicAcceptedElems, AcceptedElems } from '../types.js';
import { ElementType } from 'htmlparser2';
/**
* Create an array of nodes, recursing into arrays and parsing strings if
* necessary.
*
* @private
* @category Manipulation
* @param elem - Elements to make an array of.
* @param clone - Optionally clone nodes.
* @returns The array of nodes.
*/
export function _makeDomArray<T extends AnyNode>(
this: Cheerio<T>,
elem?: BasicAcceptedElems<AnyNode> | BasicAcceptedElems<AnyNode>[],
clone?: boolean,
): AnyNode[] {
if (elem == null) {
return [];
}
if (typeof elem === 'string') {
return this._parse(elem, this.options, false, null).children.slice(0);
}
if ('length' in elem) {
if (elem.length === 1) {
return this._makeDomArray(elem[0], clone);
}
const result: AnyNode[] = [];
for (let i = 0; i < elem.length; i++) {
const el = elem[i];
if (typeof el === 'object') {
if (el == null) {
continue;
}
if (!('length' in el)) {
result.push(clone ? cloneNode(el, true) : el);
continue;
}
}
result.push(...this._makeDomArray(el, clone));
}
return result;
}
return [clone ? cloneNode(elem, true) : elem];
}
function _insert(
concatenator: (
dom: AnyNode[],
children: AnyNode[],
parent: ParentNode,
) => void,
) {
return function <T extends AnyNode>(
this: Cheerio<T>,
...elems:
| [
(
this: AnyNode,
i: number,
html: string,
) => BasicAcceptedElems<AnyNode>,
]
| BasicAcceptedElems<AnyNode>[]
) {
const lastIdx = this.length - 1;
return domEach(this, (el, i) => {
if (!hasChildren(el)) return;
const domSrc =
typeof elems[0] === 'function'
? elems[0].call(el, i, this._render(el.children))
: (elems as BasicAcceptedElems<AnyNode>[]);
const dom = this._makeDomArray(domSrc, i < lastIdx);
concatenator(dom, el.children, el);
});
};
}
/**
* Modify an array in-place, removing some number of elements and adding new
* elements directly following them.
*
* @private
* @category Manipulation
* @param array - Target array to splice.
* @param spliceIdx - Index at which to begin changing the array.
* @param spliceCount - Number of elements to remove from the array.
* @param newElems - Elements to insert into the array.
* @param parent - The parent of the node.
* @returns The spliced array.
*/
function uniqueSplice(
array: AnyNode[],
spliceIdx: number,
spliceCount: number,
newElems: AnyNode[],
parent: ParentNode,
): AnyNode[] {
const spliceArgs: Parameters<AnyNode[]['splice']> = [
spliceIdx,
spliceCount,
...newElems,
];
const prev = spliceIdx === 0 ? null : array[spliceIdx - 1];
const next =
spliceIdx + spliceCount >= array.length
? null
: array[spliceIdx + spliceCount];
/*
* Before splicing in new elements, ensure they do not already appear in the
* current array.
*/
for (let idx = 0; idx < newElems.length; ++idx) {
const node = newElems[idx];
const oldParent = node.parent;
if (oldParent) {
const oldSiblings: AnyNode[] = oldParent.children;
const prevIdx = oldSiblings.indexOf(node);
if (prevIdx !== -1) {
oldParent.children.splice(prevIdx, 1);
if (parent === oldParent && spliceIdx > prevIdx) {
spliceArgs[0]--;
}
}
}
node.parent = parent;
if (node.prev) {
node.prev.next = node.next ?? null;
}
if (node.next) {
node.next.prev = node.prev ?? null;
}
node.prev = idx === 0 ? prev : newElems[idx - 1];
node.next = idx === newElems.length - 1 ? next : newElems[idx + 1];
}
if (prev) {
prev.next = newElems[0];
}
if (next) {
next.prev = newElems[newElems.length - 1];
}
return array.splice(...spliceArgs);
}
/**
* Insert every element in the set of matched elements to the end of the target.
*
* @category Manipulation
* @example
*
* ```js
* $('<li class="plum">Plum</li>').appendTo('#fruits');
* $.html();
* //=> <ul id="fruits">
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // <li class="plum">Plum</li>
* // </ul>
* ```
*
* @param target - Element to append elements to.
* @returns The instance itself.
* @see {@link https://api.jquery.com/appendTo/}
*/
export function appendTo<T extends AnyNode>(
this: Cheerio<T>,
target: BasicAcceptedElems<AnyNode>,
): Cheerio<T> {
const appendTarget = isCheerio<T>(target) ? target : this._make(target);
appendTarget.append(this);
return this;
}
/**
* Insert every element in the set of matched elements to the beginning of the
* target.
*
* @category Manipulation
* @example
*
* ```js
* $('<li class="plum">Plum</li>').prependTo('#fruits');
* $.html();
* //=> <ul id="fruits">
* // <li class="plum">Plum</li>
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
* ```
*
* @param target - Element to prepend elements to.
* @returns The instance itself.
* @see {@link https://api.jquery.com/prependTo/}
*/
export function prependTo<T extends AnyNode>(
this: Cheerio<T>,
target: BasicAcceptedElems<AnyNode>,
): Cheerio<T> {
const prependTarget = isCheerio<T>(target) ? target : this._make(target);
prependTarget.prepend(this);
return this;
}
/**
* Inserts content as the _last_ child of each of the selected elements.
*
* @category Manipulation
* @example
*
* ```js
* $('ul').append('<li class="plum">Plum</li>');
* $.html();
* //=> <ul id="fruits">
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // <li class="plum">Plum</li>
* // </ul>
* ```
*
* @see {@link https://api.jquery.com/append/}
*/
export const append: <T extends AnyNode>(
this: Cheerio<T>,
...elems:
| [(this: AnyNode, i: number, html: string) => BasicAcceptedElems<AnyNode>]
| BasicAcceptedElems<AnyNode>[]
) => Cheerio<T> = _insert((dom, children, parent) => {
uniqueSplice(children, children.length, 0, dom, parent);
});
/**
* Inserts content as the _first_ child of each of the selected elements.
*
* @category Manipulation
* @example
*
* ```js
* $('ul').prepend('<li class="plum">Plum</li>');
* $.html();
* //=> <ul id="fruits">
* // <li class="plum">Plum</li>
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
* ```
*
* @see {@link https://api.jquery.com/prepend/}
*/
export const prepend: <T extends AnyNode>(
this: Cheerio<T>,
...elems:
| [(this: AnyNode, i: number, html: string) => BasicAcceptedElems<AnyNode>]
| BasicAcceptedElems<AnyNode>[]
) => Cheerio<T> = _insert((dom, children, parent) => {
uniqueSplice(children, 0, 0, dom, parent);
});
function _wrap(
insert: (
el: AnyNode,
elInsertLocation: ParentNode,
wrapperDom: ParentNode[],
) => void,
) {
return function <T extends AnyNode>(
this: Cheerio<T>,
wrapper: AcceptedElems<AnyNode>,
) {
const lastIdx = this.length - 1;
const lastParent = this.parents().last();
for (let i = 0; i < this.length; i++) {
const el = this[i];
const wrap =
typeof wrapper === 'function'
? wrapper.call(el, i, el)
: typeof wrapper === 'string' && !isHtml(wrapper)
? lastParent.find(wrapper).clone()
: wrapper;
const [wrapperDom] = this._makeDomArray(wrap, i < lastIdx);
if (!wrapperDom || !hasChildren(wrapperDom)) continue;
let elInsertLocation = wrapperDom;
/*
* Find the deepest child. Only consider the first tag child of each node
* (ignore text); stop if no children are found.
*/
let j = 0;
while (j < elInsertLocation.children.length) {
const child = elInsertLocation.children[j];
if (isTag(child)) {
elInsertLocation = child;
j = 0;
} else {
j++;
}
}
insert(el, elInsertLocation, [wrapperDom]);
}
return this;
};
}
/**
* The .wrap() function can take any string or object that could be passed to
* the $() factory function to specify a DOM structure. This structure may be
* nested several levels deep, but should contain only one inmost element. A
* copy of this structure will be wrapped around each of the elements in the set
* of matched elements. This method returns the original set of elements for
* chaining purposes.
*
* @category Manipulation
* @example
*
* ```js
* const redFruit = $('<div class="red-fruit"></div>');
* $('.apple').wrap(redFruit);
*
* //=> <ul id="fruits">
* // <div class="red-fruit">
* // <li class="apple">Apple</li>
* // </div>
* // <li class="orange">Orange</li>
* // <li class="plum">Plum</li>
* // </ul>
*
* const healthy = $('<div class="healthy"></div>');
* $('li').wrap(healthy);
*
* //=> <ul id="fruits">
* // <div class="healthy">
* // <li class="apple">Apple</li>
* // </div>
* // <div class="healthy">
* // <li class="orange">Orange</li>
* // </div>
* // <div class="healthy">
* // <li class="plum">Plum</li>
* // </div>
* // </ul>
* ```
*
* @param wrapper - The DOM structure to wrap around each element in the
* selection.
* @see {@link https://api.jquery.com/wrap/}
*/
export const wrap: <T extends AnyNode>(
this: Cheerio<T>,
wrapper: AcceptedElems<AnyNode>,
) => Cheerio<T> = _wrap((el, elInsertLocation, wrapperDom) => {
const { parent } = el;
if (!parent) return;
const siblings: AnyNode[] = parent.children;
const index = siblings.indexOf(el);
updateDOM([el], elInsertLocation);
/*
* The previous operation removed the current element from the `siblings`
* array, so the `dom` array can be inserted without removing any
* additional elements.
*/
uniqueSplice(siblings, index, 0, wrapperDom, parent);
});
/**
* The .wrapInner() function can take any string or object that could be passed
* to the $() factory function to specify a DOM structure. This structure may be
* nested several levels deep, but should contain only one inmost element. The
* structure will be wrapped around the content of each of the elements in the
* set of matched elements.
*
* @category Manipulation
* @example
*
* ```js
* const redFruit = $('<div class="red-fruit"></div>');
* $('.apple').wrapInner(redFruit);
*
* //=> <ul id="fruits">
* // <li class="apple">
* // <div class="red-fruit">Apple</div>
* // </li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
*
* const healthy = $('<div class="healthy"></div>');
* $('li').wrapInner(healthy);
*
* //=> <ul id="fruits">
* // <li class="apple">
* // <div class="healthy">Apple</div>
* // </li>
* // <li class="orange">
* // <div class="healthy">Orange</div>
* // </li>
* // <li class="pear">
* // <div class="healthy">Pear</div>
* // </li>
* // </ul>
* ```
*
* @param wrapper - The DOM structure to wrap around the content of each element
* in the selection.
* @returns The instance itself, for chaining.
* @see {@link https://api.jquery.com/wrapInner/}
*/
export const wrapInner: <T extends AnyNode>(
this: Cheerio<T>,
wrapper: AcceptedElems<AnyNode>,
) => Cheerio<T> = _wrap((el, elInsertLocation, wrapperDom) => {
if (!hasChildren(el)) return;
updateDOM(el.children, elInsertLocation);
updateDOM(wrapperDom, el);
});
/**
* The .unwrap() function, removes the parents of the set of matched elements
* from the DOM, leaving the matched elements in their place.
*
* @category Manipulation
* @example <caption>without selector</caption>
*
* ```js
* const $ = cheerio.load(
* '<div id=test>\n <div><p>Hello</p></div>\n <div><p>World</p></div>\n</div>',
* );
* $('#test p').unwrap();
*
* //=> <div id=test>
* // <p>Hello</p>
* // <p>World</p>
* // </div>
* ```
*
* @example <caption>with selector</caption>
*
* ```js
* const $ = cheerio.load(
* '<div id=test>\n <p>Hello</p>\n <b><p>World</p></b>\n</div>',
* );
* $('#test p').unwrap('b');
*
* //=> <div id=test>
* // <p>Hello</p>
* // <p>World</p>
* // </div>
* ```
*
* @param selector - A selector to check the parent element against. If an
* element's parent does not match the selector, the element won't be
* unwrapped.
* @returns The instance itself, for chaining.
* @see {@link https://api.jquery.com/unwrap/}
*/
export function unwrap<T extends AnyNode>(
this: Cheerio<T>,
selector?: string,
): Cheerio<T> {
this.parent(selector)
.not('body')
.each((_, el) => {
this._make(el).replaceWith(el.children);
});
return this;
}
/**
* The .wrapAll() function can take any string or object that could be passed to
* the $() function to specify a DOM structure. This structure may be nested
* several levels deep, but should contain only one inmost element. The
* structure will be wrapped around all of the elements in the set of matched
* elements, as a single group.
*
* @category Manipulation
* @example <caption>With markup passed to `wrapAll`</caption>
*
* ```js
* const $ = cheerio.load(
* '<div class="container"><div class="inner">First</div><div class="inner">Second</div></div>',
* );
* $('.inner').wrapAll("<div class='new'></div>");
*
* //=> <div class="container">
* // <div class='new'>
* // <div class="inner">First</div>
* // <div class="inner">Second</div>
* // </div>
* // </div>
* ```
*
* @example <caption>With an existing cheerio instance</caption>
*
* ```js
* const $ = cheerio.load(
* '<span>Span 1</span><strong>Strong</strong><span>Span 2</span>',
* );
* const wrap = $('<div><p><em><b></b></em></p></div>');
* $('span').wrapAll(wrap);
*
* //=> <div>
* // <p>
* // <em>
* // <b>
* // <span>Span 1</span>
* // <span>Span 2</span>
* // </b>
* // </em>
* // </p>
* // </div>
* // <strong>Strong</strong>
* ```
*
* @param wrapper - The DOM structure to wrap around all matched elements in the
* selection.
* @returns The instance itself.
* @see {@link https://api.jquery.com/wrapAll/}
*/
export function wrapAll<T extends AnyNode>(
this: Cheerio<T>,
wrapper: AcceptedElems<T>,
): Cheerio<T> {
const el = this[0];
if (el) {
const wrap: Cheerio<AnyNode> = this._make(
typeof wrapper === 'function' ? wrapper.call(el, 0, el) : wrapper,
).insertBefore(el);
// If html is given as wrapper, wrap may contain text elements
let elInsertLocation: Element | undefined;
for (let i = 0; i < wrap.length; i++) {
if (wrap[i].type === ElementType.Tag) {
elInsertLocation = wrap[i] as Element;
}
}
let j = 0;
/*
* Find the deepest child. Only consider the first tag child of each node
* (ignore text); stop if no children are found.
*/
while (elInsertLocation && j < elInsertLocation.children.length) {
const child = elInsertLocation.children[j];
if (child.type === ElementType.Tag) {
elInsertLocation = child;
j = 0;
} else {
j++;
}
}
if (elInsertLocation) this._make(elInsertLocation).append(this);
}
return this;
}
/**
* Insert content next to each element in the set of matched elements.
*
* @category Manipulation
* @example
*
* ```js
* $('.apple').after('<li class="plum">Plum</li>');
* $.html();
* //=> <ul id="fruits">
* // <li class="apple">Apple</li>
* // <li class="plum">Plum</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
* ```
*
* @param elems - HTML string, DOM element, array of DOM elements or Cheerio to
* insert after each element in the set of matched elements.
* @returns The instance itself.
* @see {@link https://api.jquery.com/after/}
*/
export function after<T extends AnyNode>(
this: Cheerio<T>,
...elems:
| [(this: AnyNode, i: number, html: string) => BasicAcceptedElems<AnyNode>]
| BasicAcceptedElems<AnyNode>[]
): Cheerio<T> {
const lastIdx = this.length - 1;
return domEach(this, (el, i) => {
if (!hasChildren(el) || !el.parent) {
return;
}
const siblings: AnyNode[] = el.parent.children;
const index = siblings.indexOf(el);
// If not found, move on
/* istanbul ignore next */
if (index === -1) return;
const domSrc =
typeof elems[0] === 'function'
? elems[0].call(el, i, this._render(el.children))
: (elems as BasicAcceptedElems<AnyNode>[]);
const dom = this._makeDomArray(domSrc, i < lastIdx);
// Add element after `this` element
uniqueSplice(siblings, index + 1, 0, dom, el.parent);
});
}
/**
* Insert every element in the set of matched elements after the target.
*
* @category Manipulation
* @example
*
* ```js
* $('<li class="plum">Plum</li>').insertAfter('.apple');
* $.html();
* //=> <ul id="fruits">
* // <li class="apple">Apple</li>
* // <li class="plum">Plum</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
* ```
*
* @param target - Element to insert elements after.
* @returns The set of newly inserted elements.
* @see {@link https://api.jquery.com/insertAfter/}
*/
export function insertAfter<T extends AnyNode>(
this: Cheerio<T>,
target: BasicAcceptedElems<AnyNode>,
): Cheerio<T> {
if (typeof target === 'string') {
target = this._make<AnyNode>(target);
}
this.remove();
const clones: T[] = [];
for (const el of this._makeDomArray(target)) {
const clonedSelf = this.clone().toArray();
const { parent } = el;
if (!parent) {
continue;
}
const siblings: AnyNode[] = parent.children;
const index = siblings.indexOf(el);
// If not found, move on
/* istanbul ignore next */
if (index === -1) continue;
// Add cloned `this` element(s) after target element
uniqueSplice(siblings, index + 1, 0, clonedSelf, parent);
clones.push(...clonedSelf);
}
return this._make(clones);
}
/**
* Insert content previous to each element in the set of matched elements.
*
* @category Manipulation
* @example
*
* ```js
* $('.apple').before('<li class="plum">Plum</li>');
* $.html();
* //=> <ul id="fruits">
* // <li class="plum">Plum</li>
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
* ```
*
* @param elems - HTML string, DOM element, array of DOM elements or Cheerio to
* insert before each element in the set of matched elements.
* @returns The instance itself.
* @see {@link https://api.jquery.com/before/}
*/
export function before<T extends AnyNode>(
this: Cheerio<T>,
...elems:
| [(this: AnyNode, i: number, html: string) => BasicAcceptedElems<AnyNode>]
| BasicAcceptedElems<AnyNode>[]
): Cheerio<T> {
const lastIdx = this.length - 1;
return domEach(this, (el, i) => {
if (!hasChildren(el) || !el.parent) {
return;
}
const siblings: AnyNode[] = el.parent.children;
const index = siblings.indexOf(el);
// If not found, move on
/* istanbul ignore next */
if (index === -1) return;
const domSrc =
typeof elems[0] === 'function'
? elems[0].call(el, i, this._render(el.children))
: (elems as BasicAcceptedElems<AnyNode>[]);
const dom = this._makeDomArray(domSrc, i < lastIdx);
// Add element before `el` element
uniqueSplice(siblings, index, 0, dom, el.parent);
});
}
/**
* Insert every element in the set of matched elements before the target.
*
* @category Manipulation
* @example
*
* ```js
* $('<li class="plum">Plum</li>').insertBefore('.apple');
* $.html();
* //=> <ul id="fruits">
* // <li class="plum">Plum</li>
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="pear">Pear</li>
* // </ul>
* ```
*
* @param target - Element to insert elements before.
* @returns The set of newly inserted elements.
* @see {@link https://api.jquery.com/insertBefore/}
*/
export function insertBefore<T extends AnyNode>(
this: Cheerio<T>,
target: BasicAcceptedElems<AnyNode>,
): Cheerio<T> {
const targetArr = this._make<AnyNode>(target);
this.remove();
const clones: T[] = [];
domEach(targetArr, (el) => {
const clonedSelf = this.clone().toArray();
const { parent } = el;
if (!parent) {
return;
}
const siblings: AnyNode[] = parent.children;
const index = siblings.indexOf(el);
// If not found, move on
/* istanbul ignore next */
if (index === -1) return;
// Add cloned `this` element(s) after target element
uniqueSplice(siblings, index, 0, clonedSelf, parent);
clones.push(...clonedSelf);
});
return this._make(clones);
}
/**
* Removes the set of matched elements from the DOM and all their children.
* `selector` filters the set of matched elements to be removed.
*
* @category Manipulation
* @example
*
* ```js
* $('.pear').remove();
* $.html();
* //=> <ul id="fruits">
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // </ul>
* ```
*
* @param selector - Optional selector for elements to remove.
* @returns The instance itself.
* @see {@link https://api.jquery.com/remove/}
*/
export function remove<T extends AnyNode>(
this: Cheerio<T>,
selector?: string,
): Cheerio<T> {
// Filter if we have selector
const elems = selector ? this.filter(selector) : this;
domEach(elems, (el) => {
removeElement(el);
el.prev = el.next = el.parent = null;
});
return this;
}
/**
* Replaces matched elements with `content`.
*
* @category Manipulation
* @example
*
* ```js
* const plum = $('<li class="plum">Plum</li>');
* $('.pear').replaceWith(plum);
* $.html();
* //=> <ul id="fruits">
* // <li class="apple">Apple</li>
* // <li class="orange">Orange</li>
* // <li class="plum">Plum</li>
* // </ul>
* ```
*
* @param content - Replacement for matched elements.
* @returns The instance itself.
* @see {@link https://api.jquery.com/replaceWith/}
*/
export function replaceWith<T extends AnyNode>(
this: Cheerio<T>,
content: AcceptedElems<AnyNode>,
): Cheerio<T> {
return domEach(this, (el, i) => {
const { parent } = el;
if (!parent) {
return;
}
const siblings: AnyNode[] = parent.children;
const cont =
typeof content === 'function' ? content.call(el, i, el) : content;
const dom = this._makeDomArray(cont);
/*
* In the case that `dom` contains nodes that already exist in other
* structures, ensure those nodes are properly removed.
*/
updateDOM(dom, null);
const index = siblings.indexOf(el);
// Completely remove old element
uniqueSplice(siblings, index, 1, dom, parent);
if (!dom.includes(el)) {
el.parent = el.prev = el.next = null;
}
});
}
/**
* Removes all children from each item in the selection. Text nodes and comment
* nodes are left as is.
*
* @category Manipulation
* @example
*
* ```js
* $('ul').empty();
* $.html();
* //=> <ul id="fruits"></ul>
* ```
*
* @returns The instance itself.
* @see {@link https://api.jquery.com/empty/}
*/
export function empty<T extends AnyNode>(this: Cheerio<T>): Cheerio<T> {
return domEach(this, (el) => {
if (!hasChildren(el)) return;
for (const child of el.children) {
child.next = child.prev = child.parent = null;
}
el.children.length = 0;
});
}
/**
* Gets an HTML content string from the first selected element.
*
* @category Manipulation
* @example
*
* ```js
* $('.orange').html();
* //=> Orange
*
* $('#fruits').html('<li class="mango">Mango</li>').html();
* //=> <li class="mango">Mango</li>
* ```
*
* @returns The HTML content string.
* @see {@link https://api.jquery.com/html/}
*/
export function html<T extends AnyNode>(this: Cheerio<T>): string | null;
/**
* Replaces each selected element's content with the specified content.
*
* @category Manipulation
* @example
*
* ```js
* $('.orange').html('<li class="mango">Mango</li>').html();
* //=> <li class="mango">Mango</li>
* ```
*
* @param str - The content to replace selection's contents with.
* @returns The instance itself.
* @see {@link https://api.jquery.com/html/}
*/
export function html<T extends AnyNode>(
this: Cheerio<T>,
str: string | Cheerio<T>,
): Cheerio<T>;
export function html<T extends AnyNode>(
this: Cheerio<T>,
str?: string | Cheerio<AnyNode>,
): Cheerio<T> | string | null {
if (str === undefined) {
const el = this[0];
if (!el || !hasChildren(el)) return null;
return this._render(el.children);
}
return domEach(this, (el) => {
if (!hasChildren(el)) return;
for (const child of el.children) {
child.next = child.prev = child.parent = null;
}
const content = isCheerio(str)
? str.toArray()
: this._parse(`${str}`, this.options, false, el).children;
updateDOM(content, el);
});
}
/**
* Turns the collection to a string. Alias for `.html()`.
*
* @category Manipulation
* @returns The rendered document.
*/
export function toString<T extends AnyNode>(this: Cheerio<T>): string {
return this._render(this);
}
/**
* Get the combined text contents of each element in the set of matched
* elements, including their descendants.
*
* @category Manipulation
* @example
*
* ```js
* $('.orange').text();
* //=> Orange
*
* $('ul').text();
* //=> Apple
* // Orange
* // Pear
* ```
*
* @returns The text contents of the collection.
* @see {@link https://api.jquery.com/text/}
*/
export function text<T extends AnyNode>(this: Cheerio<T>): string;
/**
* Set the content of each element in the set of matched elements to the
* specified text.
*
* @category Manipulation
* @example
*
* ```js
* $('.orange').text('Orange');
* //=> <div class="orange">Orange</div>
* ```
*
* @param str - The text to set as the content of each matched element.
* @returns The instance itself.
* @see {@link https://api.jquery.com/text/}
*/
export function text<T extends AnyNode>(
this: Cheerio<T>,
str: string | ((this: AnyNode, i: number, text: string) => string),
): Cheerio<T>;
export function text<T extends AnyNode>(
this: Cheerio<T>,
str?: string | ((this: AnyNode, i: number, text: string) => string),
): Cheerio<T> | string {
// If `str` is undefined, act as a "getter"
if (str === undefined) {
return staticText(this);
}
if (typeof str === 'function') {
// Function support
return domEach(this, (el, i) =>
this._make(el).text(str.call(el, i, staticText([el]))),
);
}
// Append text node to each selected elements
return domEach(this, (el) => {
if (!hasChildren(el)) return;
for (const child of el.children) {
child.next = child.prev = child.parent = null;
}
const textNode = new Text(`${str}`);
updateDOM(textNode, el);
});
}
/**
* Clone the cheerio object.
*
* @category Manipulation
* @example
*
* ```js
* const moreFruit = $('#fruits').clone();
* ```
*
* @returns The cloned object.
* @see {@link https://api.jquery.com/clone/}
*/
export function clone<T extends AnyNode>(this: Cheerio<T>): Cheerio<T> {
const clone = Array.prototype.map.call(
this.get(),
(el) => cloneNode(el, true) as T,
) as T[];
// Add a root node around the cloned nodes
const root = new Document(clone);
for (const node of clone) {
node.parent = root;
}
return this._make(clone);
}
+1175
View File
@@ -0,0 +1,1175 @@
/**
* Methods for traversing the DOM structure.
*
* @module cheerio/traversing
*/
import {
isTag,
type AnyNode,
type Element,
hasChildren,
isDocument,
type Document,
} from 'domhandler';
import type { Cheerio } from '../cheerio.js';
import * as select from 'cheerio-select';
import { domEach, isCheerio } from '../utils.js';
import { contains } from '../static.js';
import {
getChildren,
getSiblings,
nextElementSibling,
prevElementSibling,
uniqueSort,
} from 'domutils';
import type { FilterFunction, AcceptedFilters } from '../types.js';
const reContextSelector = /^\s*(?:[+~]|:scope\b)/;
/**
* Get the descendants of each element in the current set of matched elements,
* filtered by a selector, jQuery object, or element.
*
* @category Traversing
* @example
*
* ```js
* $('#fruits').find('li').length;
* //=> 3
* $('#fruits').find($('.apple')).length;
* //=> 1
* ```
*
* @param selectorOrHaystack - Element to look for.
* @returns The found elements.
* @see {@link https://api.jquery.com/find/}
*/
export function find<T extends AnyNode>(
this: Cheerio<T>,
selectorOrHaystack?: string | Cheerio<Element> | Element,
): Cheerio<Element> {
if (!selectorOrHaystack) {
return this._make([]);
}
if (typeof selectorOrHaystack !== 'string') {
const haystack = isCheerio(selectorOrHaystack)
? selectorOrHaystack.toArray()
: [selectorOrHaystack];
const context = this.toArray();
return this._make(
haystack.filter((elem) => context.some((node) => contains(node, elem))),
);
}
return this._findBySelector(selectorOrHaystack, Number.POSITIVE_INFINITY);
}
/**
* Find elements by a specific selector.
*
* @private
* @category Traversing
* @param selector - Selector to filter by.
* @param limit - Maximum number of elements to match.
* @returns The found elements.
*/
export function _findBySelector<T extends AnyNode>(
this: Cheerio<T>,
selector: string,
limit: number,
): Cheerio<Element> {
const context = this.toArray();
const elems = reContextSelector.test(selector)
? context
: this.children().toArray();
const options = {
context,
root: this._root?.[0],
// Pass options that are recognized by `cheerio-select`
xmlMode: this.options.xmlMode,
lowerCaseTags: this.options.lowerCaseTags,
lowerCaseAttributeNames: this.options.lowerCaseAttributeNames,
pseudos: this.options.pseudos,
quirksMode: this.options.quirksMode,
};
return this._make(select.select(selector, elems, options, limit));
}
/**
* Creates a matcher, using a particular mapping function. Matchers provide a
* function that finds elements using a generating function, supporting
* filtering.
*
* @private
* @param matchMap - Mapping function.
* @returns - Function for wrapping generating functions.
*/
function _getMatcher<P>(
matchMap: (fn: (elem: AnyNode) => P, elems: Cheerio<AnyNode>) => Element[],
) {
return function (
fn: (elem: AnyNode) => P,
...postFns: ((elems: Element[]) => Element[])[]
) {
return function <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
): Cheerio<Element> {
let matched: Element[] = matchMap(fn, this);
if (selector) {
matched = filterArray(
matched,
selector,
this.options.xmlMode,
this._root?.[0],
);
}
return this._make(
// Post processing is only necessary if there is more than one element.
this.length > 1 && matched.length > 1
? postFns.reduce((elems, fn) => fn(elems), matched)
: matched,
);
};
};
}
/** Matcher that adds multiple elements for each entry in the input. */
const _matcher = _getMatcher((fn: (elem: AnyNode) => Element[], elems) => {
let ret: Element[] = [];
for (let i = 0; i < elems.length; i++) {
const value = fn(elems[i]);
if (value.length > 0) ret = ret.concat(value);
}
return ret;
});
/** Matcher that adds at most one element for each entry in the input. */
const _singleMatcher = _getMatcher(
(fn: (elem: AnyNode) => Element | null, elems) => {
const ret: Element[] = [];
for (let i = 0; i < elems.length; i++) {
const value = fn(elems[i]);
if (value !== null) {
ret.push(value);
}
}
return ret;
},
);
/**
* Matcher that supports traversing until a condition is met.
*
* @param nextElem - Function that returns the next element.
* @param postFns - Post processing functions.
* @returns A function usable for `*Until` methods.
*/
function _matchUntil(
nextElem: (elem: AnyNode) => Element | null,
...postFns: ((elems: Element[]) => Element[])[]
) {
// We use a variable here that is used from within the matcher.
let matches: ((el: Element, i: number) => boolean) | null = null;
const innerMatcher = _getMatcher(
(nextElem: (elem: AnyNode) => Element | null, elems) => {
const matched: Element[] = [];
domEach(elems, (elem) => {
for (let next; (next = nextElem(elem)); elem = next) {
// FIXME: `matched` might contain duplicates here and the index is too large.
if (matches?.(next, matched.length)) break;
matched.push(next);
}
});
return matched;
},
)(nextElem, ...postFns);
return function <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element> | null,
filterSelector?: AcceptedFilters<Element>,
): Cheerio<Element> {
// Override `matches` variable with the new target.
matches =
typeof selector === 'string'
? (elem: Element) => select.is(elem, selector, this.options)
: selector
? getFilterFn(selector)
: null;
const ret = innerMatcher.call(this, filterSelector);
// Set `matches` to `null`, so we don't waste memory.
matches = null;
return ret;
};
}
function _removeDuplicates<T extends AnyNode>(elems: T[]): T[] {
return elems.length > 1 ? Array.from(new Set<T>(elems)) : elems;
}
/**
* Get the parent of each element in the current set of matched elements,
* optionally filtered by a selector.
*
* @category Traversing
* @example
*
* ```js
* $('.pear').parent().attr('id');
* //=> fruits
* ```
*
* @param selector - If specified filter for parent.
* @returns The parents.
* @see {@link https://api.jquery.com/parent/}
*/
export const parent: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _singleMatcher(
({ parent }) => (parent && !isDocument(parent) ? (parent as Element) : null),
_removeDuplicates,
);
/**
* Get a set of parents filtered by `selector` of each element in the current
* set of match elements.
*
* @category Traversing
* @example
*
* ```js
* $('.orange').parents().length;
* //=> 2
* $('.orange').parents('#fruits').length;
* //=> 1
* ```
*
* @param selector - If specified filter for parents.
* @returns The parents.
* @see {@link https://api.jquery.com/parents/}
*/
export const parents: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matcher(
(elem) => {
const matched: Element[] = [];
while (elem.parent && !isDocument(elem.parent)) {
matched.push(elem.parent as Element);
elem = elem.parent;
}
return matched;
},
uniqueSort,
// eslint-disable-next-line unicorn/no-array-reverse
(elems) => elems.reverse(),
);
/**
* Get the ancestors of each element in the current set of matched elements, up
* to but not including the element matched by the selector, DOM node, or
* cheerio object.
*
* @category Traversing
* @example
*
* ```js
* $('.orange').parentsUntil('#food').length;
* //=> 1
* ```
*
* @param selector - Selector for element to stop at.
* @param filterSelector - Optional filter for parents.
* @returns The parents.
* @see {@link https://api.jquery.com/parentsUntil/}
*/
export const parentsUntil: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element> | null,
filterSelector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matchUntil(
({ parent }) => (parent && !isDocument(parent) ? (parent as Element) : null),
uniqueSort,
// eslint-disable-next-line unicorn/no-array-reverse
(elems) => elems.reverse(),
);
/**
* For each element in the set, get the first element that matches the selector
* by testing the element itself and traversing up through its ancestors in the
* DOM tree.
*
* @category Traversing
* @example
*
* ```js
* $('.orange').closest();
* //=> []
*
* $('.orange').closest('.apple');
* // => []
*
* $('.orange').closest('li');
* //=> [<li class="orange">Orange</li>]
*
* $('.orange').closest('#fruits');
* //=> [<ul id="fruits"> ... </ul>]
* ```
*
* @param selector - Selector for the element to find.
* @returns The closest nodes.
* @see {@link https://api.jquery.com/closest/}
*/
export function closest<T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
): Cheerio<AnyNode> {
const set: AnyNode[] = [];
if (!selector) {
return this._make(set);
}
const selectOpts = {
xmlMode: this.options.xmlMode,
root: this._root?.[0],
};
const selectFn =
typeof selector === 'string'
? (elem: Element) => select.is(elem, selector, selectOpts)
: getFilterFn(selector);
domEach(this, (elem: AnyNode | null) => {
if (elem && !isDocument(elem) && !isTag(elem)) {
elem = elem.parent;
}
while (elem && isTag(elem)) {
if (selectFn(elem, 0)) {
// Do not add duplicate elements to the set
if (!set.includes(elem)) {
set.push(elem);
}
break;
}
elem = elem.parent;
}
});
return this._make(set);
}
/**
* Gets the next sibling of each selected element, optionally filtered by a
* selector.
*
* @category Traversing
* @example
*
* ```js
* $('.apple').next().hasClass('orange');
* //=> true
* ```
*
* @param selector - If specified filter for sibling.
* @returns The next nodes.
* @see {@link https://api.jquery.com/next/}
*/
export const next: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _singleMatcher((elem) => nextElementSibling(elem));
/**
* Gets all the following siblings of the each selected element, optionally
* filtered by a selector.
*
* @category Traversing
* @example
*
* ```js
* $('.apple').nextAll();
* //=> [<li class="orange">Orange</li>, <li class="pear">Pear</li>]
* $('.apple').nextAll('.orange');
* //=> [<li class="orange">Orange</li>]
* ```
*
* @param selector - If specified filter for siblings.
* @returns The next nodes.
* @see {@link https://api.jquery.com/nextAll/}
*/
export const nextAll: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matcher((elem) => {
const matched = [];
while (elem.next) {
elem = elem.next;
if (isTag(elem)) matched.push(elem);
}
return matched;
}, _removeDuplicates);
/**
* Gets all the following siblings up to but not including the element matched
* by the selector, optionally filtered by another selector.
*
* @category Traversing
* @example
*
* ```js
* $('.apple').nextUntil('.pear');
* //=> [<li class="orange">Orange</li>]
* ```
*
* @param selector - Selector for element to stop at.
* @param filterSelector - If specified filter for siblings.
* @returns The next nodes.
* @see {@link https://api.jquery.com/nextUntil/}
*/
export const nextUntil: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element> | null,
filterSelector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matchUntil(
(el) => nextElementSibling(el),
_removeDuplicates,
);
/**
* Gets the previous sibling of each selected element optionally filtered by a
* selector.
*
* @category Traversing
* @example
*
* ```js
* $('.orange').prev().hasClass('apple');
* //=> true
* ```
*
* @param selector - If specified filter for siblings.
* @returns The previous nodes.
* @see {@link https://api.jquery.com/prev/}
*/
export const prev: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _singleMatcher((elem) => prevElementSibling(elem));
/**
* Gets all the preceding siblings of each selected element, optionally filtered
* by a selector.
*
* @category Traversing
* @example
*
* ```js
* $('.pear').prevAll();
* //=> [<li class="orange">Orange</li>, <li class="apple">Apple</li>]
*
* $('.pear').prevAll('.orange');
* //=> [<li class="orange">Orange</li>]
* ```
*
* @param selector - If specified filter for siblings.
* @returns The previous nodes.
* @see {@link https://api.jquery.com/prevAll/}
*/
export const prevAll: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matcher((elem) => {
const matched = [];
while (elem.prev) {
elem = elem.prev;
if (isTag(elem)) matched.push(elem);
}
return matched;
}, _removeDuplicates);
/**
* Gets all the preceding siblings up to but not including the element matched
* by the selector, optionally filtered by another selector.
*
* @category Traversing
* @example
*
* ```js
* $('.pear').prevUntil('.apple');
* //=> [<li class="orange">Orange</li>]
* ```
*
* @param selector - Selector for element to stop at.
* @param filterSelector - If specified filter for siblings.
* @returns The previous nodes.
* @see {@link https://api.jquery.com/prevUntil/}
*/
export const prevUntil: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element> | null,
filterSelector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matchUntil(
(el) => prevElementSibling(el),
_removeDuplicates,
);
/**
* Get the siblings of each element (excluding the element) in the set of
* matched elements, optionally filtered by a selector.
*
* @category Traversing
* @example
*
* ```js
* $('.pear').siblings().length;
* //=> 2
*
* $('.pear').siblings('.orange').length;
* //=> 1
* ```
*
* @param selector - If specified filter for siblings.
* @returns The siblings.
* @see {@link https://api.jquery.com/siblings/}
*/
export const siblings: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matcher(
(elem) =>
getSiblings(elem).filter((el): el is Element => isTag(el) && el !== elem),
uniqueSort,
);
/**
* Gets the element children of each element in the set of matched elements.
*
* @category Traversing
* @example
*
* ```js
* $('#fruits').children().length;
* //=> 3
*
* $('#fruits').children('.pear').text();
* //=> Pear
* ```
*
* @param selector - If specified filter for children.
* @returns The children.
* @see {@link https://api.jquery.com/children/}
*/
export const children: <T extends AnyNode>(
this: Cheerio<T>,
selector?: AcceptedFilters<Element>,
) => Cheerio<Element> = _matcher(
(elem) => getChildren(elem).filter(isTag),
_removeDuplicates,
);
/**
* Gets the children of each element in the set of matched elements, including
* text and comment nodes.
*
* @category Traversing
* @example
*
* ```js
* $('#fruits').contents().length;
* //=> 3
* ```
*
* @returns The children.
* @see {@link https://api.jquery.com/contents/}
*/
export function contents<T extends AnyNode>(
this: Cheerio<T>,
): Cheerio<AnyNode> {
const elems = this.toArray().reduce<AnyNode[]>(
(newElems, elem) =>
hasChildren(elem) ? newElems.concat(elem.children) : newElems,
[],
);
return this._make(elems);
}
/**
* Iterates over a cheerio object, executing a function for each matched
* element. When the callback is fired, the function is fired in the context of
* the DOM element, so `this` refers to the current element, which is equivalent
* to the function parameter `element`. To break out of the `each` loop early,
* return with `false`.
*
* @category Traversing
* @example
*
* ```js
* const fruits = [];
*
* $('li').each(function (i, elem) {
* fruits[i] = $(this).text();
* });
*
* fruits.join(', ');
* //=> Apple, Orange, Pear
* ```
*
* @param fn - Function to execute.
* @returns The instance itself, useful for chaining.
* @see {@link https://api.jquery.com/each/}
*/
export function each<T>(
this: Cheerio<T>,
fn: (this: T, i: number, el: T) => void | boolean,
): Cheerio<T> {
let i = 0;
const len = this.length;
while (i < len && fn.call(this[i], i, this[i]) !== false) ++i;
return this;
}
/**
* Pass each element in the current matched set through a function, producing a
* new Cheerio object containing the return values. The function can return an
* individual data item or an array of data items to be inserted into the
* resulting set. If an array is returned, the elements inside the array are
* inserted into the set. If the function returns null or undefined, no element
* will be inserted.
*
* @category Traversing
* @example
*
* ```js
* $('li')
* .map(function (i, el) {
* // this === el
* return $(this).text();
* })
* .toArray()
* .join(' ');
* //=> "apple orange pear"
* ```
*
* @param fn - Function to execute.
* @returns The mapped elements, wrapped in a Cheerio collection.
* @see {@link https://api.jquery.com/map/}
*/
export function map<T, M>(
this: Cheerio<T>,
fn: (this: T, i: number, el: T) => M[] | M | null | undefined,
): Cheerio<M> {
let elems: M[] = [];
for (let i = 0; i < this.length; i++) {
const el = this[i];
const val = fn.call(el, i, el);
if (val != null) {
elems = elems.concat(val);
}
}
return this._make(elems);
}
/**
* Creates a function to test if a filter is matched.
*
* @param match - A filter.
* @returns A function that determines if a filter has been matched.
*/
function getFilterFn<T>(
match: FilterFunction<T> | Cheerio<T> | T,
): (el: T, i: number) => boolean {
if (typeof match === 'function') {
return (el, i) => (match as FilterFunction<T>).call(el, i, el);
}
if (isCheerio<T>(match)) {
return (el) => Array.prototype.includes.call(match, el);
}
return function (el) {
return match === el;
};
}
/**
* Iterates over a cheerio object, reducing the set of selector elements to
* those that match the selector or pass the function's test.
*
* This is the definition for using type guards; have a look below for other
* ways to invoke this method. The function is executed in the context of the
* selected element, so `this` refers to the current element.
*
* @category Traversing
* @example <caption>Function</caption>
*
* ```js
* $('li')
* .filter(function (i, el) {
* // this === el
* return $(this).attr('class') === 'orange';
* })
* .attr('class'); //=> orange
* ```
*
* @param match - Value to look for, following the rules above.
* @returns The filtered collection.
* @see {@link https://api.jquery.com/filter/}
*/
export function filter<T, S extends T>(
this: Cheerio<T>,
match: (this: T, index: number, value: T) => value is S,
): Cheerio<S>;
/**
* Iterates over a cheerio object, reducing the set of selector elements to
* those that match the selector or pass the function's test.
*
* - When a Cheerio selection is specified, return only the elements contained in
* that selection.
* - When an element is specified, return only that element (if it is contained in
* the original selection).
* - If using the function method, the function is executed in the context of the
* selected element, so `this` refers to the current element.
*
* @category Traversing
* @example <caption>Selector</caption>
*
* ```js
* $('li').filter('.orange').attr('class');
* //=> orange
* ```
*
* @example <caption>Function</caption>
*
* ```js
* $('li')
* .filter(function (i, el) {
* // this === el
* return $(this).attr('class') === 'orange';
* })
* .attr('class'); //=> orange
* ```
*
* @param match - Value to look for, following the rules above. See
* {@link AcceptedFilters}.
* @returns The filtered collection.
* @see {@link https://api.jquery.com/filter/}
*/
export function filter<T, S extends AcceptedFilters<T>>(
this: Cheerio<T>,
match: S,
): Cheerio<S extends string ? Element : T>;
export function filter<T>(
this: Cheerio<T>,
match: AcceptedFilters<T>,
): Cheerio<unknown> {
return this._make<unknown>(
filterArray(this.toArray(), match, this.options.xmlMode, this._root?.[0]),
);
}
export function filterArray<T>(
nodes: T[],
match: AcceptedFilters<T>,
xmlMode?: boolean,
root?: Document,
): Element[] | T[] {
return typeof match === 'string'
? select.filter(match, nodes as unknown as AnyNode[], { xmlMode, root })
: nodes.filter(getFilterFn<T>(match));
}
/**
* Checks the current list of elements and returns `true` if _any_ of the
* elements match the selector. If using an element or Cheerio selection,
* returns `true` if _any_ of the elements match. If using a predicate function,
* the function is executed in the context of the selected element, so `this`
* refers to the current element.
*
* @category Traversing
* @param selector - Selector for the selection.
* @returns Whether or not the selector matches an element of the instance.
* @see {@link https://api.jquery.com/is/}
*/
export function is<T>(
this: Cheerio<T>,
selector?: AcceptedFilters<T>,
): boolean {
const nodes = this.toArray();
return typeof selector === 'string'
? select.some(
(nodes as unknown as AnyNode[]).filter(isTag),
selector,
this.options,
)
: selector
? nodes.some(getFilterFn<T>(selector))
: false;
}
/**
* Remove elements from the set of matched elements. Given a Cheerio object that
* represents a set of DOM elements, the `.not()` method constructs a new
* Cheerio object from a subset of the matching elements. The supplied selector
* is tested against each element; the elements that don't match the selector
* will be included in the result.
*
* The `.not()` method can take a function as its argument in the same way that
* `.filter()` does. Elements for which the function returns `true` are excluded
* from the filtered set; all other elements are included.
*
* @category Traversing
* @example <caption>Selector</caption>
*
* ```js
* $('li').not('.apple').length;
* //=> 2
* ```
*
* @example <caption>Function</caption>
*
* ```js
* $('li').not(function (i, el) {
* // this === el
* return $(this).attr('class') === 'orange';
* }).length; //=> 2
* ```
*
* @param match - Value to look for, following the rules above.
* @returns The filtered collection.
* @see {@link https://api.jquery.com/not/}
*/
export function not<T extends AnyNode>(
this: Cheerio<T>,
match: AcceptedFilters<T>,
): Cheerio<T> {
let nodes = this.toArray();
if (typeof match === 'string') {
const matches = new Set<AnyNode>(select.filter(match, nodes, this.options));
nodes = nodes.filter((el) => !matches.has(el));
} else {
const filterFn = getFilterFn(match);
nodes = nodes.filter((el, i) => !filterFn(el, i));
}
return this._make(nodes);
}
/**
* Filters the set of matched elements to only those which have the given DOM
* element as a descendant or which have a descendant that matches the given
* selector. Equivalent to `.filter(':has(selector)')`.
*
* @category Traversing
* @example <caption>Selector</caption>
*
* ```js
* $('ul').has('.pear').attr('id');
* //=> fruits
* ```
*
* @example <caption>Element</caption>
*
* ```js
* $('ul').has($('.pear')[0]).attr('id');
* //=> fruits
* ```
*
* @param selectorOrHaystack - Element to look for.
* @returns The filtered collection.
* @see {@link https://api.jquery.com/has/}
*/
export function has(
this: Cheerio<AnyNode | Element>,
selectorOrHaystack: string | Cheerio<Element> | Element,
): Cheerio<AnyNode | Element> {
return this.filter(
typeof selectorOrHaystack === 'string'
? // Using the `:has` selector here short-circuits searches.
`:has(${selectorOrHaystack})`
: (_, el) => this._make(el).find(selectorOrHaystack).length > 0,
);
}
/**
* Will select the first element of a cheerio object.
*
* @category Traversing
* @example
*
* ```js
* $('#fruits').children().first().text();
* //=> Apple
* ```
*
* @returns The first element.
* @see {@link https://api.jquery.com/first/}
*/
export function first<T extends AnyNode>(this: Cheerio<T>): Cheerio<T> {
return this.length > 1 ? this._make(this[0]) : this;
}
/**
* Will select the last element of a cheerio object.
*
* @category Traversing
* @example
*
* ```js
* $('#fruits').children().last().text();
* //=> Pear
* ```
*
* @returns The last element.
* @see {@link https://api.jquery.com/last/}
*/
export function last<T>(this: Cheerio<T>): Cheerio<T> {
return this.length > 0 ? this._make(this[this.length - 1]) : this;
}
/**
* Reduce the set of matched elements to the one at the specified index. Use
* `.eq(-i)` to count backwards from the last selected element.
*
* @category Traversing
* @example
*
* ```js
* $('li').eq(0).text();
* //=> Apple
*
* $('li').eq(-1).text();
* //=> Pear
* ```
*
* @param i - Index of the element to select.
* @returns The element at the `i`th position.
* @see {@link https://api.jquery.com/eq/}
*/
export function eq<T>(this: Cheerio<T>, i: number): Cheerio<T> {
i = +i;
// Use the first identity optimization if possible
if (i === 0 && this.length <= 1) return this;
if (i < 0) i = this.length + i;
return this._make(this[i] ?? []);
}
/**
* Retrieve one of the elements matched by the Cheerio object, at the `i`th
* position.
*
* @category Traversing
* @example
*
* ```js
* $('li').get(0).tagName;
* //=> li
* ```
*
* @param i - Element to retrieve.
* @returns The element at the `i`th position.
* @see {@link https://api.jquery.com/get/}
*/
export function get<T>(this: Cheerio<T>, i: number): T | undefined;
/**
* Retrieve all elements matched by the Cheerio object, as an array.
*
* @category Traversing
* @example
*
* ```js
* $('li').get().length;
* //=> 3
* ```
*
* @returns All elements matched by the Cheerio object.
* @see {@link https://api.jquery.com/get/}
*/
export function get<T>(this: Cheerio<T>): T[];
export function get<T>(this: Cheerio<T>, i?: number): T | T[] {
if (i == null) {
return this.toArray();
}
return this[i < 0 ? this.length + i : i];
}
/**
* Retrieve all the DOM elements contained in the jQuery set as an array.
*
* @example
*
* ```js
* $('li').toArray();
* //=> [ {...}, {...}, {...} ]
* ```
*
* @returns The contained items.
*/
export function toArray<T>(this: Cheerio<T>): T[] {
return (Array.prototype as T[]).slice.call(this);
}
/**
* Search for a given element from among the matched elements.
*
* @category Traversing
* @example
*
* ```js
* $('.pear').index();
* //=> 2 $('.orange').index('li');
* //=> 1
* $('.apple').index($('#fruit, li'));
* //=> 1
* ```
*
* @param selectorOrNeedle - Element to look for.
* @returns The index of the element.
* @see {@link https://api.jquery.com/index/}
*/
export function index<T extends AnyNode>(
this: Cheerio<T>,
selectorOrNeedle?: string | Cheerio<AnyNode> | AnyNode,
): number {
let $haystack: Cheerio<AnyNode>;
let needle: AnyNode;
if (selectorOrNeedle == null) {
$haystack = this.parent().children();
needle = this[0];
} else if (typeof selectorOrNeedle === 'string') {
$haystack = this._make<AnyNode>(selectorOrNeedle);
needle = this[0];
} else {
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
$haystack = this;
needle = isCheerio(selectorOrNeedle)
? selectorOrNeedle[0]
: selectorOrNeedle;
}
return Array.prototype.indexOf.call($haystack, needle);
}
/**
* Gets the elements matching the specified range (0-based position).
*
* @category Traversing
* @example
*
* ```js
* $('li').slice(1).eq(0).text();
* //=> 'Orange'
*
* $('li').slice(1, 2).length;
* //=> 1
* ```
*
* @param start - A position at which the elements begin to be selected. If
* negative, it indicates an offset from the end of the set.
* @param end - A position at which the elements stop being selected. If
* negative, it indicates an offset from the end of the set. If omitted, the
* range continues until the end of the set.
* @returns The elements matching the specified range.
* @see {@link https://api.jquery.com/slice/}
*/
export function slice<T>(
this: Cheerio<T>,
start?: number,
end?: number,
): Cheerio<T> {
return this._make<T>(Array.prototype.slice.call(this, start, end));
}
/**
* End the most recent filtering operation in the current chain and return the
* set of matched elements to its previous state.
*
* @category Traversing
* @example
*
* ```js
* $('li').eq(0).end().length;
* //=> 3
* ```
*
* @returns The previous state of the set of matched elements.
* @see {@link https://api.jquery.com/end/}
*/
export function end<T>(this: Cheerio<T>): Cheerio<AnyNode> {
return (this.prevObject as Cheerio<AnyNode> | null) ?? this._make([]);
}
/**
* Add elements to the set of matched elements.
*
* @category Traversing
* @example
*
* ```js
* $('.apple').add('.orange').length;
* //=> 2
* ```
*
* @param other - Elements to add.
* @param context - Optionally the context of the new selection.
* @returns The combined set.
* @see {@link https://api.jquery.com/add/}
*/
export function add<S extends AnyNode, T extends AnyNode>(
this: Cheerio<T>,
other: string | Cheerio<S> | S | S[],
context?: Cheerio<S> | string,
): Cheerio<S | T> {
const selection = this._make(other, context);
const contents = uniqueSort([...this.get(), ...selection.get()]);
return this._make(contents);
}
/**
* Add the previous set of elements on the stack to the current set, optionally
* filtered by a selector.
*
* @category Traversing
* @example
*
* ```js
* $('li').eq(0).addBack('.orange').length;
* //=> 2
* ```
*
* @param selector - Selector for the elements to add.
* @returns The combined set.
* @see {@link https://api.jquery.com/addBack/}
*/
export function addBack<T extends AnyNode>(
this: Cheerio<T>,
selector?: string,
): Cheerio<AnyNode> {
return this.prevObject
? this.add<AnyNode, T>(
selector ? this.prevObject.filter(selector) : this.prevObject,
)
: this;
}
+143
View File
@@ -0,0 +1,143 @@
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
import type { InternalOptions } from './options.js';
import type { AnyNode, Document, ParentNode } from 'domhandler';
import type { BasicAcceptedElems } from './types.js';
import * as Attributes from './api/attributes.js';
import * as Traversing from './api/traversing.js';
import * as Manipulation from './api/manipulation.js';
import * as Css from './api/css.js';
import * as Forms from './api/forms.js';
import * as Extract from './api/extract.js';
type MethodsType = typeof Attributes &
typeof Traversing &
typeof Manipulation &
typeof Css &
typeof Forms &
typeof Extract;
/**
* The cheerio class is the central class of the library. It wraps a set of
* elements and provides an API for traversing, modifying, and interacting with
* the set.
*
* Loading a document will return the Cheerio class bound to the root element of
* the document. The class will be instantiated when querying the document (when
* calling `$('selector')`).
*
* @example This is the HTML markup we will be using in all of the API examples:
*
* ```html
* <ul id="fruits">
* <li class="apple">Apple</li>
* <li class="orange">Orange</li>
* <li class="pear">Pear</li>
* </ul>
* ```
*/
export abstract class Cheerio<T> implements ArrayLike<T> {
length = 0;
[index: number]: T;
options: InternalOptions;
/**
* The root of the document. Can be set by using the `root` argument of the
* constructor.
*
* @private
*/
_root: Cheerio<Document> | null;
/**
* Instance of cheerio. Methods are specified in the modules. Usage of this
* constructor is not recommended. Please use `$.load` instead.
*
* @private
* @param elements - The new selection.
* @param root - Sets the root node.
* @param options - Options for the instance.
*/
constructor(
elements: ArrayLike<T> | undefined,
root: Cheerio<Document> | null,
options: InternalOptions,
) {
this.options = options;
this._root = root;
if (elements) {
for (let idx = 0; idx < elements.length; idx++) {
this[idx] = elements[idx];
}
this.length = elements.length;
}
}
prevObject: Cheerio<any> | undefined;
/**
* Make a cheerio object.
*
* @private
* @param dom - The contents of the new object.
* @param context - The context of the new object.
* @returns The new cheerio object.
*/
abstract _make<T>(
dom: ArrayLike<T> | T | string,
context?: BasicAcceptedElems<AnyNode>,
): Cheerio<T>;
/**
* Parses some content.
*
* @private
* @param content - Content to parse.
* @param options - Options for parsing.
* @param isDocument - Allows parser to be switched to fragment mode.
* @returns A document containing the `content`.
*/
abstract _parse(
content: string | Document | AnyNode | AnyNode[] | Buffer,
options: InternalOptions,
isDocument: boolean,
context: ParentNode | null,
): Document;
/**
* Render an element or a set of elements.
*
* @private
* @param dom - DOM to render.
* @returns The rendered DOM.
*/
abstract _render(dom: AnyNode | ArrayLike<AnyNode>): string;
}
export interface Cheerio<T> extends MethodsType, Iterable<T> {
cheerio: '[cheerio object]';
splice: typeof Array.prototype.splice;
}
/** Set a signature of the object. */
Cheerio.prototype.cheerio = '[cheerio object]';
/*
* Make cheerio an array-like object
*/
Cheerio.prototype.splice = Array.prototype.splice;
// Support for (const element of $(...)) iteration:
Cheerio.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
// Plug in the API
Object.assign(
Cheerio.prototype,
Attributes,
Traversing,
Manipulation,
Css,
Forms,
Extract,
);
+10
View File
@@ -0,0 +1,10 @@
export type * from './types.js';
export type {
Cheerio,
CheerioAPI,
CheerioOptions,
HTMLParser2Options,
} from './slim.js';
export { contains, merge } from './static.js';
export * from './load-parse.js';
+294
View File
@@ -0,0 +1,294 @@
/**
* @file Batteries-included version of Cheerio. This module includes several
* convenience methods for loading documents from various sources.
*/
export * from './load-parse.js';
export { contains, merge } from './static.js';
export type * from './types.js';
export type {
Cheerio,
CheerioAPI,
CheerioOptions,
HTMLParser2Options,
} from './slim.js';
import { adapter as htmlparser2Adapter } from 'parse5-htmlparser2-tree-adapter';
import * as htmlparser2 from 'htmlparser2';
import { ParserStream as Parse5Stream } from 'parse5-parser-stream';
import {
decodeBuffer,
DecodeStream,
type SnifferOptions,
} from 'encoding-sniffer';
import * as undici from 'undici';
import MIMEType from 'whatwg-mimetype';
import { Writable, finished } from 'node:stream';
import type { CheerioAPI } from './load.js';
import {
flattenOptions,
type InternalOptions,
type CheerioOptions,
} from './options.js';
import { load } from './load-parse.js';
/**
* Sniffs the encoding of a buffer, then creates a querying function bound to a
* document created from the buffer.
*
* @category Loading
* @example
*
* ```js
* import * as cheerio from 'cheerio';
*
* const buffer = fs.readFileSync('index.html');
* const $ = cheerio.loadBuffer(buffer);
* ```
*
* @param buffer - The buffer to sniff the encoding of.
* @param options - The options to pass to Cheerio.
* @returns The loaded document.
*/
export function loadBuffer(
buffer: Buffer,
options: DecodeStreamOptions = {},
): CheerioAPI {
const opts = flattenOptions(options);
const str = decodeBuffer(buffer, {
defaultEncoding: opts?.xmlMode ? 'utf8' : 'windows-1252',
...options.encoding,
});
return load(str, opts);
}
function _stringStream(
options: InternalOptions | undefined,
cb: (err: Error | null | undefined, $: CheerioAPI) => void,
): Writable {
if (options?._useHtmlParser2) {
const parser = htmlparser2.createDocumentStream(
(err, document) => cb(err, load(document, options)),
options,
);
return new Writable({
decodeStrings: false,
write(chunk, _encoding, callback) {
if (typeof chunk !== 'string') {
throw new TypeError('Expected a string');
}
parser.write(chunk);
callback();
},
final(callback) {
parser.end();
callback();
},
});
}
options ??= {};
options.treeAdapter ??= htmlparser2Adapter;
if (options.scriptingEnabled !== false) {
options.scriptingEnabled = true;
}
const stream = new Parse5Stream(options);
finished(stream, (err) => cb(err, load(stream.document, options)));
return stream;
}
/**
* Creates a stream that parses a sequence of strings into a document.
*
* The stream is a `Writable` stream that accepts strings. When the stream is
* finished, the callback is called with the loaded document.
*
* @category Loading
* @example
*
* ```js
* import * as cheerio from 'cheerio';
* import * as fs from 'fs';
*
* const writeStream = cheerio.stringStream({}, (err, $) => {
* if (err) {
* // Handle error
* }
*
* console.log($('h1').text());
* // Output: Hello, world!
* });
*
* fs.createReadStream('my-document.html', { encoding: 'utf8' }).pipe(
* writeStream,
* );
* ```
*
* @param options - The options to pass to Cheerio.
* @param cb - The callback to call when the stream is finished.
* @returns The writable stream.
*/
export function stringStream(
options: CheerioOptions,
cb: (err: Error | null | undefined, $: CheerioAPI) => void,
): Writable {
return _stringStream(flattenOptions(options), cb);
}
export interface DecodeStreamOptions extends CheerioOptions {
encoding?: SnifferOptions;
}
/**
* Parses a stream of buffers into a document.
*
* The stream is a `Writable` stream that accepts buffers. When the stream is
* finished, the callback is called with the loaded document.
*
* @category Loading
* @param options - The options to pass to Cheerio.
* @param cb - The callback to call when the stream is finished.
* @returns The writable stream.
*/
export function decodeStream(
options: DecodeStreamOptions,
cb: (err: Error | null | undefined, $: CheerioAPI) => void,
): Writable {
const { encoding = {}, ...cheerioOptions } = options;
const opts = flattenOptions(cheerioOptions);
// Set the default encoding to UTF-8 for XML mode
encoding.defaultEncoding ??= opts?.xmlMode ? 'utf8' : 'windows-1252';
const decodeStream = new DecodeStream(encoding);
const loadStream = _stringStream(opts, cb);
decodeStream.pipe(loadStream);
return decodeStream;
}
type UndiciStreamOptions = Omit<
undici.Dispatcher.RequestOptions<unknown>,
'path'
>;
export interface CheerioRequestOptions extends DecodeStreamOptions {
/** The options passed to `undici`'s `stream` method. */
requestOptions?: UndiciStreamOptions;
}
const defaultRequestOptions: UndiciStreamOptions = {
method: 'GET',
// Set an Accept header
headers: {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
};
/**
* `fromURL` loads a document from a URL.
*
* By default, redirects are allowed and non-2xx responses are rejected.
*
* @category Loading
* @example
*
* ```js
* import * as cheerio from 'cheerio';
*
* const $ = await cheerio.fromURL('https://example.com');
* ```
*
* @param url - The URL to load the document from.
* @param options - The options to pass to Cheerio.
* @returns The loaded document.
*/
export async function fromURL(
url: string | URL,
options: CheerioRequestOptions = {},
): Promise<CheerioAPI> {
const {
requestOptions = defaultRequestOptions,
encoding = {},
...cheerioOptions
} = options;
let undiciStream: Promise<undici.Dispatcher.StreamData<unknown>> | undefined;
// Add headers if none were supplied.
const urlObject = typeof url === 'string' ? new URL(url) : url;
const streamOptions = {
headers: defaultRequestOptions.headers,
path: urlObject.pathname + urlObject.search,
...requestOptions,
};
const promise = new Promise<CheerioAPI>((resolve, reject) => {
undiciStream = new undici.Client(urlObject.origin)
.compose(undici.interceptors.redirect({ maxRedirections: 5 }))
.stream(streamOptions, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new undici.errors.ResponseError(
'Response Error',
res.statusCode,
{
headers: res.headers,
},
);
}
const contentTypeHeader = res.headers['content-type'] ?? 'text/html';
const mimeType = new MIMEType(
Array.isArray(contentTypeHeader)
? contentTypeHeader[0]
: contentTypeHeader,
);
if (!mimeType.isHTML() && !mimeType.isXML()) {
throw new RangeError(
`The content-type "${mimeType.essence}" is neither HTML nor XML.`,
);
}
// Forward the charset from the header to the decodeStream.
encoding.transportLayerEncodingLabel =
mimeType.parameters.get('charset');
/*
* If we allow redirects, we will have entries in the history.
* The last entry will be the final URL.
*/
const history = (
res.context as
| {
history?: URL[];
}
| undefined
)?.history;
// Set the `baseURI` to the final URL.
const baseURI = history ? history[history.length - 1] : urlObject;
const opts: DecodeStreamOptions = {
encoding,
// Set XML mode based on the MIME type.
xmlMode: mimeType.isXML(),
baseURI,
...cheerioOptions,
};
return decodeStream(opts, (err, $) => (err ? reject(err) : resolve($)));
});
});
// Let's make sure the request is completed before returning the promise.
await undiciStream;
return promise;
}
+39
View File
@@ -0,0 +1,39 @@
import { type CheerioAPI, getLoad } from './load.js';
import { getParse } from './parse.js';
import { renderWithParse5, parseWithParse5 } from './parsers/parse5-adapter.js';
import type { CheerioOptions } from './options.js';
import renderWithHtmlparser2 from 'dom-serializer';
import { parseDocument as parseWithHtmlparser2 } from 'htmlparser2';
import type { AnyNode } from 'domhandler';
const parse = getParse((content, options, isDocument, context) =>
options._useHtmlParser2
? parseWithHtmlparser2(content, options)
: parseWithParse5(content, options, isDocument, context),
);
// Duplicate docs due to https://github.com/TypeStrong/typedoc/issues/1616
/**
* Create a querying function, bound to a document created from the provided
* markup.
*
* Note that similar to web browser contexts, this operation may introduce
* `<html>`, `<head>`, and `<body>` elements; set `isDocument` to `false` to
* switch to fragment mode and disable this.
*
* @category Loading
* @param content - Markup to be loaded.
* @param options - Options for the created instance.
* @param isDocument - Allows parser to be switched to fragment mode.
* @returns The loaded document.
* @see {@link https://cheerio.js.org/docs/basics/loading#load} for additional usage information.
*/
export const load: (
content: string | AnyNode | AnyNode[] | Buffer,
options?: CheerioOptions | null,
isDocument?: boolean,
) => CheerioAPI = getLoad(parse, (dom, options) =>
options._useHtmlParser2
? renderWithHtmlparser2(dom, options)
: renderWithParse5(dom),
);
+282
View File
@@ -0,0 +1,282 @@
import {
type CheerioOptions,
type InternalOptions,
flattenOptions,
} from './options.js';
import * as staticMethods from './static.js';
import { Cheerio } from './cheerio.js';
import { isHtml, isCheerio } from './utils.js';
import type { AnyNode, Document, Element, ParentNode } from 'domhandler';
import type { SelectorType, BasicAcceptedElems } from './types.js';
import { ElementType } from 'htmlparser2';
type StaticType = typeof staticMethods;
/**
* A querying function, bound to a document created from the provided markup.
*
* Also provides several helper methods for dealing with the document as a
* whole.
*/
export interface CheerioAPI extends StaticType {
/**
* This selector method is the starting point for traversing and manipulating
* the document. Like jQuery, it's the primary method for selecting elements
* in the document.
*
* `selector` searches within the `context` scope, which searches within the
* `root` scope.
*
* @example
*
* ```js
* $('ul .pear').attr('class');
* //=> pear
*
* $('li[class=orange]').html();
* //=> Orange
*
* $('.apple', '#fruits').text();
* //=> Apple
* ```
*
* Optionally, you can also load HTML by passing the string as the selector:
*
* ```js
* $('<ul id="fruits">...</ul>');
* ```
*
* Or the context:
*
* ```js
* $('ul', '<ul id="fruits">...</ul>');
* ```
*
* Or as the root:
*
* ```js
* $('li', 'ul', '<ul id="fruits">...</ul>');
* ```
*
* @param selector - Either a selector to look for within the document, or the
* contents of a new Cheerio instance.
* @param context - Either a selector to look for within the root, or the
* contents of the document to query.
* @param root - Optional HTML document string.
*/
<T extends AnyNode, S extends string>(
selector?: S | BasicAcceptedElems<T>,
context?: BasicAcceptedElems<AnyNode> | null,
root?: BasicAcceptedElems<Document>,
options?: CheerioOptions,
): Cheerio<S extends SelectorType ? Element : T>;
/**
* The root the document was originally loaded with.
*
* @private
*/
_root: Document;
/**
* The options the document was originally loaded with.
*
* @private
*/
_options: InternalOptions;
/** Mimic jQuery's prototype alias for plugin authors. */
fn: typeof Cheerio.prototype;
/**
* The `.load` static method defined on the "loaded" Cheerio factory function
* is deprecated. Users are encouraged to instead use the `load` function
* exported by the Cheerio module.
*
* @deprecated Use the `load` function exported by the Cheerio module.
* @category Deprecated
* @example
*
* ```js
* const $ = cheerio.load('<h1>Hello, <span>world</span>.</h1>');
* ```
*/
load: ReturnType<typeof getLoad>;
}
export function getLoad(
parse: Cheerio<AnyNode>['_parse'],
render: (
dom: AnyNode | ArrayLike<AnyNode>,
options: InternalOptions,
) => string,
) {
/**
* Create a querying function, bound to a document created from the provided
* markup.
*
* Note that similar to web browser contexts, this operation may introduce
* `<html>`, `<head>`, and `<body>` elements; set `isDocument` to `false` to
* switch to fragment mode and disable this.
*
* @param content - Markup to be loaded.
* @param options - Options for the created instance.
* @param isDocument - Allows parser to be switched to fragment mode.
* @returns The loaded document.
* @see {@link https://cheerio.js.org/docs/basics/loading#load} for additional usage information.
*/
return function load(
content: string | AnyNode | AnyNode[] | Buffer,
options?: CheerioOptions | null,
isDocument = true,
): CheerioAPI {
if ((content as string | null) == null) {
throw new Error('cheerio.load() expects a string');
}
const internalOpts = flattenOptions(options);
const initialRoot = parse(content, internalOpts, isDocument, null);
/**
* Create an extended class here, so that extensions only live on one
* instance.
*/
class LoadedCheerio<T> extends Cheerio<T> {
_make<T>(
selector?: ArrayLike<T> | T | string,
context?: BasicAcceptedElems<AnyNode> | null,
): Cheerio<T> {
const cheerio = initialize(selector, context);
cheerio.prevObject = this;
return cheerio;
}
_parse(
content: string | Document | AnyNode | AnyNode[] | Buffer,
options: InternalOptions,
isDocument: boolean,
context: ParentNode | null,
) {
return parse(content, options, isDocument, context);
}
_render(dom: AnyNode | ArrayLike<AnyNode>): string {
return render(dom, this.options);
}
}
function initialize<T = AnyNode, S extends string = string>(
selector?: ArrayLike<T> | T | S,
context?: BasicAcceptedElems<AnyNode> | null,
root: BasicAcceptedElems<Document> = initialRoot,
opts?: CheerioOptions,
): Cheerio<S extends SelectorType ? Element : T> {
type Result = S extends SelectorType ? Element : T;
// $($)
if (selector && isCheerio<Result>(selector)) return selector;
const options = flattenOptions(opts, internalOpts);
const r =
typeof root === 'string'
? [parse(root, options, false, null)]
: 'length' in root
? root
: [root];
const rootInstance = isCheerio<Document>(r)
? r
: new LoadedCheerio<Document>(r, null, options);
// Add a cyclic reference, so that calling methods on `_root` never fails.
rootInstance._root = rootInstance;
// $(), $(null), $(undefined), $(false)
if (!selector) {
return new LoadedCheerio<Result>(undefined, rootInstance, options);
}
const elements: AnyNode[] | undefined =
typeof selector === 'string' && isHtml(selector)
? // $(<html>)
parse(selector, options, false, null).children
: isNode(selector)
? // $(dom)
[selector]
: Array.isArray(selector)
? // $([dom])
selector
: undefined;
const instance = new LoadedCheerio(elements, rootInstance, options);
if (elements) {
return instance as Cheerio<Result>;
}
if (typeof selector !== 'string') {
throw new TypeError('Unexpected type of selector');
}
// We know that our selector is a string now.
let search = selector;
const searchContext: Cheerio<AnyNode> | undefined = context
? // If we don't have a context, maybe we have a root, from loading
typeof context === 'string'
? isHtml(context)
? // $('li', '<ul>...</ul>')
new LoadedCheerio<Document>(
[parse(context, options, false, null)],
rootInstance,
options,
)
: // $('li', 'ul')
((search = `${context} ${search}` as S), rootInstance)
: isCheerio<AnyNode>(context)
? // $('li', $)
context
: // $('li', node), $('li', [nodes])
new LoadedCheerio<AnyNode>(
Array.isArray(context) ? context : [context],
rootInstance,
options,
)
: rootInstance;
// If we still don't have a context, return
if (!searchContext) return instance as Cheerio<Result>;
/*
* #id, .class, tag
*/
return searchContext.find(search) as Cheerio<Result>;
}
// Add in static methods & properties
Object.assign(initialize, staticMethods, {
load,
// `_root` and `_options` are used in static methods.
_root: initialRoot,
_options: internalOpts,
// Add `fn` for plugins
fn: LoadedCheerio.prototype,
// Add the prototype here to maintain `instanceof` behavior.
prototype: LoadedCheerio.prototype,
});
return initialize as CheerioAPI;
};
}
function isNode(obj: unknown): obj is AnyNode {
return (
// @ts-expect-error: TS doesn't know about the `name` property.
!!obj.name ||
// @ts-expect-error: TS doesn't know about the `type` property.
obj.type === ElementType.Root ||
// @ts-expect-error: TS doesn't know about the `type` property.
obj.type === ElementType.Text ||
// @ts-expect-error: TS doesn't know about the `type` property.
obj.type === ElementType.Comment
);
}
+136
View File
@@ -0,0 +1,136 @@
import type { DomHandlerOptions } from 'domhandler';
import type { ParserOptions as HTMLParser2ParserOptions } from 'htmlparser2';
import type { ParserOptions as Parse5ParserOptions } from 'parse5';
import type { Htmlparser2TreeAdapterMap } from 'parse5-htmlparser2-tree-adapter';
import type { Options as SelectOptions } from 'cheerio-select';
import type { DomSerializerOptions } from 'dom-serializer';
/**
* Options accepted by htmlparser2, the default parser for XML.
*
* @see https://github.com/fb55/htmlparser2/wiki/Parser-options
*/
export interface HTMLParser2Options
extends DomHandlerOptions, DomSerializerOptions, HTMLParser2ParserOptions {
/** Treat the input as an XML document. */
xmlMode?: boolean;
}
/**
* Options accepted by Cheerio.
*
* Please note that parser-specific options are _only recognized_ if the
* relevant parser is used.
*/
export interface CheerioOptions extends Parse5ParserOptions<Htmlparser2TreeAdapterMap> {
/**
* Recommended way of configuring htmlparser2 when wanting to parse XML.
*
* This will switch Cheerio to use htmlparser2.
*
* @default false
*/
xml?: HTMLParser2Options | boolean;
/**
* Enable xml mode, which will switch Cheerio to use htmlparser2.
*
* @deprecated Please use the `xml` option instead.
* @default false
*/
xmlMode?: boolean;
/** The base URI for the document. Used to resolve the `href` and `src` props. */
baseURI?: string | URL;
/**
* Is the document in quirks mode?
*
* This will lead to `.className` and `#id` being case-insensitive.
*
* @default false
*/
quirksMode?: SelectOptions['quirksMode'];
/**
* Extension point for pseudo-classes.
*
* Maps from names to either strings of functions.
*
* - A string value is a selector that the element must match to be selected.
* - A function is called with the element as its first argument, and optional
* parameters second. If it returns true, the element is selected.
*
* @example
*
* ```js
* const $ = cheerio.load(
* '<div class="foo"></div><div data-bar="boo"></div>',
* {
* pseudos: {
* // `:foo` is an alias for `div.foo`
* foo: 'div.foo',
* // `:bar(val)` is equivalent to `[data-bar=val s]`
* bar: (el, val) => el.attribs['data-bar'] === val,
* },
* },
* );
*
* $(':foo').length; // 1
* $('div:bar(boo)').length; // 1
* $('div:bar(baz)').length; // 0
* ```
*/
pseudos?: SelectOptions['pseudos'];
}
/** Internal options for Cheerio. */
export interface InternalOptions
extends HTMLParser2Options, Omit<CheerioOptions, 'xml'> {
/**
* Whether to use htmlparser2.
*
* This is set to true if `xml` is set to true.
*/
_useHtmlParser2?: boolean;
}
const defaultOpts: InternalOptions = {
_useHtmlParser2: false,
};
/**
* Flatten the options for Cheerio.
*
* This will set `_useHtmlParser2` to true if `xml` is set to true.
*
* @param options - The options to flatten.
* @param baseOptions - The base options to use.
* @returns The flattened options.
*/
export function flattenOptions(
options?: CheerioOptions | null,
baseOptions?: InternalOptions,
): InternalOptions {
if (!options) {
return baseOptions ?? defaultOpts;
}
const opts: InternalOptions = {
_useHtmlParser2: !!options.xmlMode,
...baseOptions,
...options,
};
if (options.xml) {
opts._useHtmlParser2 = true;
opts.xmlMode = true;
if (options.xml !== true) {
Object.assign(opts, options.xml);
}
} else if (options.xmlMode) {
opts._useHtmlParser2 = true;
}
return opts;
}
+105
View File
@@ -0,0 +1,105 @@
import { removeElement } from 'domutils';
import {
type AnyNode,
Document,
type ParentNode,
isDocument as checkIsDocument,
} from 'domhandler';
import type { InternalOptions } from './options.js';
/**
* Get the parse function with options.
*
* @param parser - The parser function.
* @returns The parse function with options.
*/
export function getParse(
parser: (
content: string,
options: InternalOptions,
isDocument: boolean,
context: ParentNode | null,
) => Document,
) {
/**
* Parse a HTML string or a node.
*
* @param content - The HTML string or node.
* @param options - The parser options.
* @param isDocument - If `content` is a document.
* @param context - The context node in the DOM tree.
* @returns The parsed document node.
*/
return function parse(
content: string | Document | AnyNode | AnyNode[] | Buffer,
options: InternalOptions,
isDocument: boolean,
context: ParentNode | null,
): Document {
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(content)) {
content = content.toString();
}
if (typeof content === 'string') {
return parser(content, options, isDocument, context);
}
const doc = content as AnyNode | AnyNode[] | Document;
if (!Array.isArray(doc) && checkIsDocument(doc)) {
// If `doc` is already a root, just return it
return doc;
}
// Add content to new root element
const root = new Document([]);
// Update the DOM using the root
update(doc, root);
return root;
};
}
/**
* Update the dom structure, for one changed layer.
*
* @param newChilds - The new children.
* @param parent - The new parent.
* @returns The parent node.
*/
export function update(
newChilds: AnyNode[] | AnyNode,
parent: ParentNode | null,
): ParentNode | null {
// Normalize
const arr = Array.isArray(newChilds) ? newChilds : [newChilds];
// Update parent
if (parent) {
parent.children = arr;
} else {
parent = null;
}
// Update neighbors
for (let i = 0; i < arr.length; i++) {
const node = arr[i];
// Cleanly remove existing nodes from their previous structures.
if (node.parent && node.parent.children !== arr) {
removeElement(node);
}
if (parent) {
node.prev = arr[i - 1] || null;
node.next = arr[i + 1] || null;
} else {
node.prev = node.next = null;
}
node.parent = parent;
}
return parent;
}
+66
View File
@@ -0,0 +1,66 @@
import {
type AnyNode,
type Document,
type ParentNode,
isDocument,
} from 'domhandler';
import { parse as parseDocument, parseFragment, serializeOuter } from 'parse5';
import { adapter as htmlparser2Adapter } from 'parse5-htmlparser2-tree-adapter';
import type { InternalOptions } from '../options.js';
/**
* Parse the content with `parse5` in the context of the given `ParentNode`.
*
* @param content - The content to parse.
* @param options - A set of options to use to parse.
* @param isDocument - Whether to parse the content as a full HTML document.
* @param context - The context in which to parse the content.
* @returns The parsed content.
*/
export function parseWithParse5(
content: string,
options: InternalOptions,
isDocument: boolean,
context: ParentNode | null,
): Document {
options.treeAdapter ??= htmlparser2Adapter;
if (options.scriptingEnabled !== false) {
options.scriptingEnabled = true;
}
return isDocument
? parseDocument(content, options)
: parseFragment(context, content, options);
}
const renderOpts = { treeAdapter: htmlparser2Adapter };
/**
* Renders the given DOM tree with `parse5` and returns the result as a string.
*
* @param dom - The DOM tree to render.
* @returns The rendered document.
*/
export function renderWithParse5(dom: AnyNode | ArrayLike<AnyNode>): string {
/*
* `dom-serializer` passes over the special "root" node and renders the
* node's children in its place. To mimic this behavior with `parse5`, an
* equivalent operation must be applied to the input array.
*/
const nodes = 'length' in dom ? dom : [dom];
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (isDocument(node)) {
Array.prototype.splice.call(nodes, index, 1, ...node.children);
}
}
let result = '';
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
result += serializeOuter(node, renderOpts);
}
return result;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* @file Alternative entry point for Cheerio that always uses htmlparser2. This
* way, parse5 won't be loaded, saving some memory.
*/
import { type CheerioAPI, getLoad } from './load.js';
import { type CheerioOptions } from './options.js';
import { getParse } from './parse.js';
import type { AnyNode } from 'domhandler';
import render from 'dom-serializer';
import { parseDocument } from 'htmlparser2';
export { contains, merge } from './static.js';
export type * from './types.js';
export type { Cheerio } from './cheerio.js';
export type { CheerioOptions, HTMLParser2Options } from './options.js';
export type { CheerioAPI } from './load.js';
/**
* Create a querying function, bound to a document created from the provided
* markup.
*
* @param content - Markup to be loaded.
* @param options - Options for the created instance.
* @param isDocument - Always `false` here, as we are always using
* `htmlparser2`.
* @returns The loaded document.
* @see {@link https://cheerio.js.org#loading} for additional usage information.
*/
export const load: (
content: string | AnyNode | AnyNode[] | Buffer,
options?: CheerioOptions | null,
isDocument?: boolean,
) => CheerioAPI = getLoad(getParse(parseDocument), render);
+312
View File
@@ -0,0 +1,312 @@
import type { BasicAcceptedElems } from './types.js';
import type { CheerioAPI } from './load.js';
import type { Cheerio } from './cheerio.js';
import type { AnyNode, Document } from 'domhandler';
import { textContent } from 'domutils';
import {
type InternalOptions,
type CheerioOptions,
flattenOptions as flattenOptions,
} from './options.js';
import type { ExtractedMap, ExtractMap } from './api/extract.js';
/**
* Helper function to render a DOM.
*
* @param that - Cheerio instance to render.
* @param dom - The DOM to render. Defaults to `that`'s root.
* @param options - Options for rendering.
* @returns The rendered document.
*/
function render(
that: CheerioAPI,
dom: BasicAcceptedElems<AnyNode> | undefined,
options: InternalOptions,
): string {
if (!that) return '';
return that(dom ?? that._root.children, null, undefined, options).toString();
}
/**
* Checks if a passed object is an options object.
*
* @param dom - Object to check if it is an options object.
* @param options - Options object.
* @returns Whether the object is an options object.
*/
function isOptions(
dom?: BasicAcceptedElems<AnyNode> | CheerioOptions | null,
options?: CheerioOptions,
): dom is CheerioOptions {
return (
!options &&
typeof dom === 'object' &&
dom != null &&
!('length' in dom) &&
!('type' in dom)
);
}
/**
* Renders the document.
*
* @category Static
* @param options - Options for the renderer.
* @returns The rendered document.
*/
export function html(this: CheerioAPI, options?: CheerioOptions): string;
/**
* Renders the document.
*
* @category Static
* @param dom - Element to render.
* @param options - Options for the renderer.
* @returns The rendered document.
*/
export function html(
this: CheerioAPI,
dom?: BasicAcceptedElems<AnyNode>,
options?: CheerioOptions,
): string;
export function html(
this: CheerioAPI,
dom?: BasicAcceptedElems<AnyNode> | CheerioOptions,
options?: CheerioOptions,
): string {
/*
* Be flexible about parameters, sometimes we call html(),
* with options as only parameter
* check dom argument for dom element specific properties
* assume there is no 'length' or 'type' properties in the options object
*/
const toRender = isOptions(dom) ? ((options = dom), undefined) : dom;
/*
* Sometimes `$.html()` is used without preloading html,
* so fallback non-existing options to the default ones.
*/
const opts = {
...this?._options,
...flattenOptions(options),
};
return render(this, toRender, opts);
}
/**
* Render the document as XML.
*
* @category Static
* @param dom - Element to render.
* @returns THe rendered document.
*/
export function xml(
this: CheerioAPI,
dom?: BasicAcceptedElems<AnyNode>,
): string {
const options = { ...this._options, xmlMode: true };
return render(this, dom, options);
}
/**
* Render the document as text.
*
* This returns the `textContent` of the passed elements. The result will
* include the contents of `<script>` and `<style>` elements. To avoid this, use
* `.prop('innerText')` instead.
*
* @category Static
* @param elements - Elements to render.
* @returns The rendered document.
*/
export function text(
this: CheerioAPI | void,
elements?: ArrayLike<AnyNode>,
): string {
const elems = elements ?? (this ? this.root() : []);
let ret = '';
for (let i = 0; i < elems.length; i++) {
ret += textContent(elems[i]);
}
return ret;
}
/**
* Parses a string into an array of DOM nodes. The `context` argument has no
* meaning for Cheerio, but it is maintained for API compatibility with jQuery.
*
* @category Static
* @param data - Markup that will be parsed.
* @param context - Will be ignored. If it is a boolean it will be used as the
* value of `keepScripts`.
* @param keepScripts - If false all scripts will be removed.
* @returns The parsed DOM.
* @alias Cheerio.parseHTML
* @see {@link https://api.jquery.com/jQuery.parseHTML/}
*/
export function parseHTML(
this: CheerioAPI,
data: string,
context?: unknown,
keepScripts?: boolean,
): AnyNode[];
export function parseHTML(this: CheerioAPI, data?: '' | null): null;
export function parseHTML(
this: CheerioAPI,
data?: string | null,
context?: unknown,
keepScripts = typeof context === 'boolean' ? context : false,
): AnyNode[] | null {
if (!data || typeof data !== 'string') {
return null;
}
if (typeof context === 'boolean') {
keepScripts = context;
}
const parsed = this.load(data, this._options, false);
if (!keepScripts) {
parsed('script').remove();
}
/*
* The `children` array is used by Cheerio internally to group elements that
* share the same parents. When nodes created through `parseHTML` are
* inserted into previously-existing DOM structures, they will be removed
* from the `children` array. The results of `parseHTML` should remain
* constant across these operations, so a shallow copy should be returned.
*/
return [...parsed.root()[0].children];
}
/**
* Sometimes you need to work with the top-level root element. To query it, you
* can use `$.root()`.
*
* @category Static
* @example
*
* ```js
* $.root().append('<ul id="vegetables"></ul>').html();
* //=> <ul id="fruits">...</ul><ul id="vegetables"></ul>
* ```
*
* @returns Cheerio instance wrapping the root node.
* @alias Cheerio.root
*/
export function root(this: CheerioAPI): Cheerio<Document> {
return this(this._root);
}
/**
* Checks to see if the `contained` DOM element is a descendant of the
* `container` DOM element.
*
* @category Static
* @param container - Potential parent node.
* @param contained - Potential child node.
* @returns Indicates if the nodes contain one another.
* @alias Cheerio.contains
* @see {@link https://api.jquery.com/jQuery.contains/}
*/
export function contains(container: AnyNode, contained: AnyNode): boolean {
// According to the jQuery API, an element does not "contain" itself
if (contained === container) {
return false;
}
/*
* Step up the descendants, stopping when the root element is reached
* (signaled by `.parent` returning a reference to the same object)
*/
let next: AnyNode | null = contained;
while (next && next !== next.parent) {
next = next.parent;
if (next === container) {
return true;
}
}
return false;
}
/**
* Extract multiple values from a document, and store them in an object.
*
* @category Static
* @param map - An object containing key-value pairs. The keys are the names of
* the properties to be created on the object, and the values are the
* selectors to be used to extract the values.
* @returns An object containing the extracted values.
*/
export function extract<M extends ExtractMap>(
this: CheerioAPI,
map: M,
): ExtractedMap<M> {
return this.root().extract(map);
}
type Writable<T> = { -readonly [P in keyof T]: T[P] };
/**
* $.merge().
*
* @category Static
* @param arr1 - First array.
* @param arr2 - Second array.
* @returns `arr1`, with elements of `arr2` inserted.
* @alias Cheerio.merge
* @see {@link https://api.jquery.com/jQuery.merge/}
*/
export function merge<T>(
arr1: Writable<ArrayLike<T>>,
arr2: ArrayLike<T>,
): ArrayLike<T> | undefined {
if (!isArrayLike(arr1) || !isArrayLike(arr2)) {
return;
}
let newLength = arr1.length;
const len = +arr2.length;
for (let i = 0; i < len; i++) {
arr1[newLength++] = arr2[i];
}
arr1.length = newLength;
return arr1;
}
/**
* Checks if an object is array-like.
*
* @category Static
* @param item - Item to check.
* @returns Indicates if the item is array-like.
*/
function isArrayLike(item: unknown): item is ArrayLike<unknown> {
if (Array.isArray(item)) {
return true;
}
if (
typeof item !== 'object' ||
item === null ||
!('length' in item) ||
typeof item.length !== 'number' ||
item.length < 0
) {
return false;
}
for (let i = 0; i < item.length; i++) {
if (!(i in item)) {
return false;
}
}
return true;
}
+58
View File
@@ -0,0 +1,58 @@
/** @file Types used in signatures of Cheerio methods. */
type LowercaseLetters =
| 'a'
| 'b'
| 'c'
| 'd'
| 'e'
| 'f'
| 'g'
| 'h'
| 'i'
| 'j'
| 'k'
| 'l'
| 'm'
| 'n'
| 'o'
| 'p'
| 'q'
| 'r'
| 's'
| 't'
| 'u'
| 'v'
| 'w'
| 'x'
| 'y'
| 'z';
type AlphaNumeric =
| LowercaseLetters
| Uppercase<LowercaseLetters>
| `${number}`;
type SelectorSpecial = '.' | '#' | ':' | '|' | '>' | '+' | '~' | '[';
/**
* Type for identifying selectors. Allows us to "upgrade" queries using
* selectors to return `Element`s.
*/
export type SelectorType =
| `${SelectorSpecial}${AlphaNumeric}${string}`
| `${AlphaNumeric}${string}`;
import type { Cheerio } from './cheerio.js';
import type { AnyNode } from 'domhandler';
/** Elements that can be passed to manipulation methods. */
export type BasicAcceptedElems<T extends AnyNode> = ArrayLike<T> | T | string;
/** Elements that can be passed to manipulation methods, including functions. */
export type AcceptedElems<T extends AnyNode> =
| BasicAcceptedElems<T>
| ((this: T, i: number, el: T) => BasicAcceptedElems<T>);
/** Function signature, for traversal methods. */
export type FilterFunction<T> = (this: T, i: number, el: T) => boolean;
/** Supported filter types, for traversal methods. */
export type AcceptedFilters<T> = string | FilterFunction<T> | T | Cheerio<T>;
+99
View File
@@ -0,0 +1,99 @@
import type { AnyNode } from 'domhandler';
import type { Cheerio } from './cheerio.js';
/**
* Checks if an object is a Cheerio instance.
*
* @category Utils
* @param maybeCheerio - The object to check.
* @returns Whether the object is a Cheerio instance.
*/
export function isCheerio<T>(
maybeCheerio: unknown,
): maybeCheerio is Cheerio<T> {
return (maybeCheerio as Cheerio<T>).cheerio != null;
}
/**
* Convert a string to camel case notation.
*
* @private
* @category Utils
* @param str - The string to be converted.
* @returns String in camel case notation.
*/
export function camelCase(str: string): string {
return str.replace(/[._-](\w|$)/g, (_, x) => (x as string).toUpperCase());
}
/**
* Convert a string from camel case to "CSS case", where word boundaries are
* described by hyphens ("-") and all characters are lower-case.
*
* @private
* @category Utils
* @param str - The string to be converted.
* @returns String in "CSS case".
*/
export function cssCase(str: string): string {
return str.replace(/[A-Z]/g, '-$&').toLowerCase();
}
/**
* Iterate over each DOM element without creating intermediary Cheerio
* instances.
*
* This is indented for use internally to avoid otherwise unnecessary memory
* pressure introduced by _make.
*
* @category Utils
* @param array - The array to iterate over.
* @param fn - Function to call.
* @returns The original instance.
*/
export function domEach<
T extends AnyNode,
Arr extends ArrayLike<T> = Cheerio<T>,
>(array: Arr, fn: (elem: T, index: number) => void): Arr {
const len = array.length;
for (let i = 0; i < len; i++) fn(array[i], i);
return array;
}
const enum CharacterCode {
LowerA = 97,
LowerZ = 122,
UpperA = 65,
UpperZ = 90,
Exclamation = 33,
}
/**
* Check if string is HTML.
*
* Tests for a `<` within a string, immediate followed by a letter and
* eventually followed by a `>`.
*
* @private
* @category Utils
* @param str - The string to check.
* @returns Indicates if `str` is HTML.
*/
export function isHtml(str: string): boolean {
if (typeof str !== 'string') {
return false;
}
const tagStart = str.indexOf('<');
if (tagStart === -1 || tagStart > str.length - 3) return false;
const tagChar = str.charCodeAt(tagStart + 1) as CharacterCode;
return (
((tagChar >= CharacterCode.LowerA && tagChar <= CharacterCode.LowerZ) ||
(tagChar >= CharacterCode.UpperA && tagChar <= CharacterCode.UpperZ) ||
tagChar === CharacterCode.Exclamation) &&
str.includes('>', tagStart + 2)
);
}