You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
601 lines
19 KiB
601 lines
19 KiB
/** |
|
* 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('<container />').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(); |
|
* //=> <li>Pear</li> |
|
* |
|
* $('.apple').attr('id', 'favorite'); |
|
* $('.apple').removeAttr('id class').html(); |
|
* //=> <li>Apple</li> |
|
* ``` |
|
* |
|
* @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(); |
|
* //=> <li class="pear fruit">Pear</li> |
|
* |
|
* $('.apple').addClass('fruit red').html(); |
|
* //=> <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(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(); |
|
* //=> <li class="">Pear</li> |
|
* |
|
* $('.apple').addClass('red').removeClass().html(); |
|
* //=> <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(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(); |
|
* //=> <li class="apple fruit red">Apple</li> |
|
* |
|
* $('.apple.green').toggleClass('fruit green red', true).html(); |
|
* //=> <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(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
|