/**
* Methods for getting and modifying attributes.
*
* @module cheerio/attributes
*/
import { text } from '../static.js';
import { isTag, domEach, camelCase, cssCase } from '../utils.js';
import { innerText, textContent } from 'domutils';
const hasOwn = Object.prototype.hasOwnProperty;
const rspace = /\s+/;
const dataAttrPrefix = 'data-';
/*
* Lookup table for coercing string data-* attributes to their corresponding
* JavaScript primitives
*/
const primitives = {
null: null,
true: true,
false: false,
};
// 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 = /^{[^]*}$|^\[[^]*]$/;
function getAttr(elem, name, xmlMode) {
var _a;
if (!elem || !isTag(elem))
return undefined;
(_a = elem.attribs) !== null && _a !== void 0 ? _a : (elem.attribs = {});
// Return the entire attribs object if no attribute specified
if (!name) {
return elem.attribs;
}
if (hasOwn.call(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, name, value) {
if (value === null) {
removeAttribute(el, name);
}
else {
el.attribs[name] = `${value}`;
}
}
export function attr(name, value) {
// 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') {
Object.keys(name).forEach((objName) => {
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.
* @returns The prop's value.
*/
function getProp(el, name, xmlMode) {
return name in el
? // @ts-expect-error TS doesn't like us accessing the value directly here.
el[name]
: !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.
*/
function setProp(el, name, value, xmlMode) {
if (name in el) {
// @ts-expect-error Overriding value
el[name] = value;
}
else {
setAttr(el, name, !xmlMode && rboolean.test(name) ? (value ? '' : null) : `${value}`);
}
}
export function prop(name, value) {
var _a;
if (typeof name === 'string' && value === undefined) {
const el = this[0];
if (!el || !isTag(el))
return undefined;
switch (name) {
case 'style': {
const property = this.css();
const keys = Object.keys(property);
keys.forEach((p, i) => {
property[i] = p;
});
property.length = keys.length;
return property;
}
case 'tagName':
case 'nodeName': {
return el.name.toUpperCase();
}
case 'href':
case 'src': {
const prop = (_a = el.attribs) === null || _a === void 0 ? void 0 : _a[name];
/* eslint-disable node/no-unsupported-features/node-builtins */
if (typeof URL !== 'undefined' &&
((name === 'href' && (el.tagName === 'a' || el.name === '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;
}
/* eslint-enable node/no-unsupported-features/node-builtins */
return prop;
}
case 'innerText': {
return innerText(el);
}
case 'textContent': {
return textContent(el);
}
case 'outerHTML':
return this.clone().wrap('').parent().html();
case 'innerHTML':
return this.html();
default:
return getProp(el, name, this.options.xmlMode);
}
}
if (typeof name === 'object' || value !== undefined) {
if (typeof value === 'function') {
if (typeof name === 'object') {
throw new Error('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') {
Object.keys(name).forEach((key) => {
const val = name[key];
setProp(el, key, val, this.options.xmlMode);
});
}
else {
setProp(el, name, value, this.options.xmlMode);
}
});
}
return undefined;
}
/**
* Sets the value of a data attribute.
*
* @private
* @param el - The element to set the data attribute on.
* @param name - The data attribute's name.
* @param value - The data attribute's value.
*/
function setData(el, name, value) {
var _a;
const elem = el;
(_a = elem.data) !== null && _a !== void 0 ? _a : (elem.data = {});
if (typeof name === 'object')
Object.assign(elem.data, name);
else if (typeof name === 'string' && value !== undefined) {
elem.data[name] = value;
}
}
/**
* Read the specified attribute from the equivalent HTML5 `data-*` attribute,
* and (if present) cache the value in the node's internal data store. If no
* attribute name is specified, read _all_ HTML5 `data-*` attributes in this manner.
*
* @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, or a map with all of the data attributes.
*/
function readData(el, name) {
let domNames;
let jsNames;
let value;
if (name == null) {
domNames = Object.keys(el.attribs).filter((attrName) => attrName.startsWith(dataAttrPrefix));
jsNames = domNames.map((domName) => camelCase(domName.slice(dataAttrPrefix.length)));
}
else {
domNames = [dataAttrPrefix + cssCase(name)];
jsNames = [name];
}
for (let idx = 0; idx < domNames.length; ++idx) {
const domName = domNames[idx];
const jsName = jsNames[idx];
if (hasOwn.call(el.attribs, domName) &&
!hasOwn.call(el.data, jsName)) {
value = el.attribs[domName];
if (hasOwn.call(primitives, value)) {
value = primitives[value];
}
else if (value === String(Number(value))) {
value = Number(value);
}
else if (rbrace.test(value)) {
try {
value = JSON.parse(value);
}
catch (e) {
/* Ignore */
}
}
el.data[jsName] = value;
}
}
return name == null ? el.data : value;
}
export function data(name, value) {
var _a;
const elem = this[0];
if (!elem || !isTag(elem))
return;
const dataEl = elem;
(_a = dataEl.data) !== null && _a !== void 0 ? _a : (dataEl.data = {});
// Return the entire data object if no data specified
if (!name) {
return readData(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;
}
if (hasOwn.call(dataEl.data, name)) {
return dataEl.data[name];
}
return readData(dataEl, name);
}
export function val(value) {
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);
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 (let i = 0; i < values.length; i++) {
this.find(`option[value="${values[i]}"]`).attr('selected', '');
}
return this;
}
return this.attr('multiple')
? option.toArray().map((el) => text(el.children))
: option.attr('value');
}
case 'input':
case 'option':
return querying
? this.attr('value')
: this.attr('value', value);
}
return undefined;
}
/**
* Remove an attribute.
*
* @private
* @param elem - Node to remove attribute from.
* @param name - Name of the attribute to remove.
*/
function removeAttribute(elem, name) {
if (!elem.attribs || !hasOwn.call(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) {
return names ? names.trim().split(rspace) : [];
}
/**
* Method for removing attributes by `name`.
*
* @category Attributes
* @example
*
* ```js
* $('.pear').removeAttr('class').html();
* //=>
Pear
*
* $('.apple').attr('id', 'favorite');
* $('.apple').removeAttr('id class').html();
* //=> Apple
* ```
*
* @param name - Name of the attribute.
* @returns The instance itself.
* @see {@link https://api.jquery.com/removeAttr/}
*/
export function removeAttr(name) {
const attrNames = splitNames(name);
for (let i = 0; i < attrNames.length; i++) {
domEach(this, (elem) => {
if (isTag(elem))
removeAttribute(elem, attrNames[i]);
});
}
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(className) {
return this.toArray().some((elem) => {
const clazz = isTag(elem) && elem.attribs['class'];
let idx = -1;
if (clazz && className.length) {
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').html();
* //=> Pear
*
* $('.apple').addClass('fruit red').html();
* //=> Apple
* ```
*
* @param value - Name of new class.
* @returns The instance itself.
* @see {@link https://api.jquery.com/addClass/}
*/
export function addClass(value) {
// 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) {
setAttr(el, 'class', classNames.join(' ').trim());
}
else {
let setClass = ` ${className} `;
// Check if class already exists
for (let j = 0; j < classNames.length; j++) {
const appendClass = `${classNames[j]} `;
if (!setClass.includes(` ${appendClass}`))
setClass += appendClass;
}
setAttr(el, 'class', setClass.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').html();
* //=> Pear
*
* $('.apple').addClass('red').removeClass().html();
* //=> Apple
* ```
*
* @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(name) {
// 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 >= 0) {
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').html();
* //=> Apple
*
* $('.apple.green').toggleClass('fruit green red', true).html();
* //=> Apple
* ```
*
* @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(value, stateVal) {
// 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 < 0) {
elementClasses.push(classNames[j]);
}
else if (state <= 0 && index >= 0) {
// Otherwise remove but only if the item exists
elementClasses.splice(index, 1);
}
}
el.attribs['class'] = elementClasses.join(' ');
}
return this;
}
//# sourceMappingURL=attributes.js.map