Initial commit: Crypto trader application

This commit is contained in:
2025-12-25 20:20:40 -05:00
commit 07a04c1bb8
47895 changed files with 2042266 additions and 0 deletions

View File

@@ -0,0 +1,353 @@
/*!
* DOM Selector - A CSS selector engine.
* @license MIT
* @copyright asamuzaK (Kazz)
* @see {@link https://github.com/asamuzaK/domSelector/blob/main/LICENSE}
*/
/* import */
import { LRUCache } from 'lru-cache';
import { Finder } from './js/finder.js';
import { filterSelector, getType, initNwsapi } from './js/utility.js';
/* constants */
import {
DOCUMENT_NODE,
DOCUMENT_FRAGMENT_NODE,
ELEMENT_NODE,
TARGET_ALL,
TARGET_FIRST,
TARGET_LINEAL,
TARGET_SELF
} from './js/constant.js';
const MAX_CACHE = 1024;
/**
* @typedef {object} CheckResult
* @property {boolean} match - The match result.
* @property {string?} pseudoElement - The pseudo-element, if any.
*/
/* DOMSelector */
export class DOMSelector {
/* private fields */
#window;
#document;
#finder;
#idlUtils;
#nwsapi;
#cache;
/**
* Creates an instance of DOMSelector.
* @param {Window} window - The window object.
* @param {Document} document - The document object.
* @param {object} [opt] - Options.
*/
constructor(window, document, opt = {}) {
const { idlUtils } = opt;
this.#window = window;
this.#document = document ?? window.document;
this.#finder = new Finder(window);
this.#idlUtils = idlUtils;
this.#nwsapi = initNwsapi(window, document);
this.#cache = new LRUCache({
max: MAX_CACHE
});
}
/**
* Clears the internal cache of finder results.
* @returns {void}
*/
clear = () => {
this.#finder.clearResults(true);
};
/**
* Checks if an element matches a CSS selector.
* @param {string} selector - The CSS selector to check against.
* @param {Element} node - The element node to check.
* @param {object} [opt] - Optional parameters.
* @returns {CheckResult} An object containing the check result.
*/
check = (selector, node, opt = {}) => {
if (!node?.nodeType) {
const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
return this.#finder.onError(e, opt);
} else if (node.nodeType !== ELEMENT_NODE) {
const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
return this.#finder.onError(e, opt);
}
const document = node.ownerDocument;
if (
document === this.#document &&
document.contentType === 'text/html' &&
document.documentElement &&
node.parentNode
) {
const cacheKey = `check_${selector}`;
let filterMatches = false;
if (this.#cache.has(cacheKey)) {
filterMatches = this.#cache.get(cacheKey);
} else {
filterMatches = filterSelector(selector, TARGET_SELF);
this.#cache.set(cacheKey, filterMatches);
}
if (filterMatches) {
try {
const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
const match = this.#nwsapi.match(selector, n);
return {
match,
pseudoElement: null
};
} catch (e) {
// fall through
}
}
}
let res;
try {
if (this.#idlUtils) {
node = this.#idlUtils.wrapperForImpl(node);
}
opt.check = true;
opt.noexept = true;
opt.warn = false;
this.#finder.setup(selector, node, opt);
res = this.#finder.find(TARGET_SELF);
} catch (e) {
this.#finder.onError(e, opt);
}
return res;
};
/**
* Returns true if the element matches the selector.
* @param {string} selector - The CSS selector to match against.
* @param {Element} node - The element node to test.
* @param {object} [opt] - Optional parameters.
* @returns {boolean} `true` if the element matches, or `false` otherwise.
*/
matches = (selector, node, opt = {}) => {
if (!node?.nodeType) {
const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
return this.#finder.onError(e, opt);
} else if (node.nodeType !== ELEMENT_NODE) {
const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
return this.#finder.onError(e, opt);
}
const document = node.ownerDocument;
if (
document === this.#document &&
document.contentType === 'text/html' &&
document.documentElement &&
node.parentNode
) {
const cacheKey = `matches_${selector}`;
let filterMatches = false;
if (this.#cache.has(cacheKey)) {
filterMatches = this.#cache.get(cacheKey);
} else {
filterMatches = filterSelector(selector, TARGET_SELF);
this.#cache.set(cacheKey, filterMatches);
}
if (filterMatches) {
try {
const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
const res = this.#nwsapi.match(selector, n);
return res;
} catch (e) {
// fall through
}
}
}
let res;
try {
if (this.#idlUtils) {
node = this.#idlUtils.wrapperForImpl(node);
}
this.#finder.setup(selector, node, opt);
const nodes = this.#finder.find(TARGET_SELF);
res = nodes.size;
} catch (e) {
this.#finder.onError(e, opt);
}
return !!res;
};
/**
* Traverses up the DOM tree to find the first node that matches the selector.
* @param {string} selector - The CSS selector to match against.
* @param {Element} node - The element from which to start traversing.
* @param {object} [opt] - Optional parameters.
* @returns {?Element} The first matching ancestor element, or `null`.
*/
closest = (selector, node, opt = {}) => {
if (!node?.nodeType) {
const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
return this.#finder.onError(e, opt);
} else if (node.nodeType !== ELEMENT_NODE) {
const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
return this.#finder.onError(e, opt);
}
const document = node.ownerDocument;
if (
document === this.#document &&
document.contentType === 'text/html' &&
document.documentElement &&
node.parentNode
) {
const cacheKey = `closest_${selector}`;
let filterMatches = false;
if (this.#cache.has(cacheKey)) {
filterMatches = this.#cache.get(cacheKey);
} else {
filterMatches = filterSelector(selector, TARGET_LINEAL);
this.#cache.set(cacheKey, filterMatches);
}
if (filterMatches) {
try {
const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
const res = this.#nwsapi.closest(selector, n);
return res;
} catch (e) {
// fall through
}
}
}
let res;
try {
if (this.#idlUtils) {
node = this.#idlUtils.wrapperForImpl(node);
}
this.#finder.setup(selector, node, opt);
const nodes = this.#finder.find(TARGET_LINEAL);
if (nodes.size) {
let refNode = node;
while (refNode) {
if (nodes.has(refNode)) {
res = refNode;
break;
}
refNode = refNode.parentNode;
}
}
} catch (e) {
this.#finder.onError(e, opt);
}
return res ?? null;
};
/**
* Returns the first element within the subtree that matches the selector.
* @param {string} selector - The CSS selector to match.
* @param {Document|DocumentFragment|Element} node - The node to find within.
* @param {object} [opt] - Optional parameters.
* @returns {?Element} The first matching element, or `null`.
*/
querySelector = (selector, node, opt = {}) => {
if (!node?.nodeType) {
const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
return this.#finder.onError(e, opt);
}
/*
const document =
node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument;
if (
document === this.#document &&
document.contentType === 'text/html' &&
document.documentElement &&
(node.nodeType !== DOCUMENT_FRAGMENT_NODE || !node.host)
) {
const cacheKey = `querySelector_${selector}`;
let filterMatches = false;
if (this.#cache.has(cacheKey)) {
filterMatches = this.#cache.get(cacheKey);
} else {
filterMatches = filterSelector(selector, TARGET_FIRST);
this.#cache.set(cacheKey, filterMatches);
}
if (filterMatches) {
try {
const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
const res = this.#nwsapi.first(selector, n);
return res;
} catch (e) {
// fall through
}
}
}
*/
let res;
try {
if (this.#idlUtils) {
node = this.#idlUtils.wrapperForImpl(node);
}
this.#finder.setup(selector, node, opt);
const nodes = this.#finder.find(TARGET_FIRST);
if (nodes.size) {
[res] = [...nodes];
}
} catch (e) {
this.#finder.onError(e, opt);
}
return res ?? null;
};
/**
* Returns an array of elements within the subtree that match the selector.
* Note: This method returns an Array, not a NodeList.
* @param {string} selector - The CSS selector to match.
* @param {Document|DocumentFragment|Element} node - The node to find within.
* @param {object} [opt] - Optional parameters.
* @returns {Array<Element>} An array of elements, or an empty array.
*/
querySelectorAll = (selector, node, opt = {}) => {
if (!node?.nodeType) {
const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`);
return this.#finder.onError(e, opt);
}
const document =
node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument;
if (
document === this.#document &&
document.contentType === 'text/html' &&
document.documentElement &&
(node.nodeType !== DOCUMENT_FRAGMENT_NODE || !node.host)
) {
const cacheKey = `querySelectorAll_${selector}`;
let filterMatches = false;
if (this.#cache.has(cacheKey)) {
filterMatches = this.#cache.get(cacheKey);
} else {
filterMatches = filterSelector(selector, TARGET_ALL);
this.#cache.set(cacheKey, filterMatches);
}
if (filterMatches) {
try {
const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
const res = this.#nwsapi.select(selector, n);
return res;
} catch (e) {
// fall through
}
}
}
let res;
try {
if (this.#idlUtils) {
node = this.#idlUtils.wrapperForImpl(node);
}
this.#finder.setup(selector, node, opt);
const nodes = this.#finder.find(TARGET_ALL);
if (nodes.size) {
res = [...nodes];
}
} catch (e) {
this.#finder.onError(e, opt);
}
return res ?? [];
};
}

View File

@@ -0,0 +1,129 @@
/**
* constant.js
*/
/* string */
export const ATRULE = 'Atrule';
export const ATTR_SELECTOR = 'AttributeSelector';
export const CLASS_SELECTOR = 'ClassSelector';
export const COMBINATOR = 'Combinator';
export const IDENT = 'Identifier';
export const ID_SELECTOR = 'IdSelector';
export const NOT_SUPPORTED_ERR = 'NotSupportedError';
export const NTH = 'Nth';
export const OPERATOR = 'Operator';
export const PS_CLASS_SELECTOR = 'PseudoClassSelector';
export const PS_ELEMENT_SELECTOR = 'PseudoElementSelector';
export const RULE = 'Rule';
export const SCOPE = 'Scope';
export const SELECTOR = 'Selector';
export const SELECTOR_LIST = 'SelectorList';
export const STRING = 'String';
export const SYNTAX_ERR = 'SyntaxError';
export const TARGET_ALL = 'all';
export const TARGET_FIRST = 'first';
export const TARGET_LINEAL = 'lineal';
export const TARGET_SELF = 'self';
export const TYPE_SELECTOR = 'TypeSelector';
/* numeric */
export const BIT_01 = 1;
export const BIT_02 = 2;
export const BIT_04 = 4;
export const BIT_08 = 8;
export const BIT_16 = 0x10;
export const BIT_32 = 0x20;
export const BIT_FFFF = 0xffff;
export const DUO = 2;
export const HEX = 16;
export const TYPE_FROM = 8;
export const TYPE_TO = -1;
/* Node */
export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const DOCUMENT_NODE = 9;
export const DOCUMENT_FRAGMENT_NODE = 11;
export const DOCUMENT_POSITION_PRECEDING = 2;
export const DOCUMENT_POSITION_CONTAINS = 8;
export const DOCUMENT_POSITION_CONTAINED_BY = 0x10;
/* NodeFilter */
export const SHOW_ALL = 0xffffffff;
export const SHOW_CONTAINER = 0x501;
export const SHOW_DOCUMENT = 0x100;
export const SHOW_DOCUMENT_FRAGMENT = 0x400;
export const SHOW_ELEMENT = 1;
/* selectors */
export const ALPHA_NUM = '[A-Z\\d]+';
export const CHILD_IDX = '(?:first|last|only)-(?:child|of-type)';
export const DIGIT = '(?:0|[1-9]\\d*)';
export const LANG_PART = `(?:-${ALPHA_NUM})*`;
export const PSEUDO_CLASS = `(?:any-)?link|${CHILD_IDX}|checked|empty|indeterminate|read-(?:only|write)|target`;
export const ANB = `[+-]?(?:${DIGIT}n?|n)|(?:[+-]?${DIGIT})?n\\s*[+-]\\s*${DIGIT}`;
// combinators
export const COMBO = '\\s?[\\s>~+]\\s?';
export const DESCEND = '\\s?[\\s>]\\s?';
export const SIBLING = '\\s?[+~]\\s?';
// LOGIC_IS: :is()
export const LOGIC_IS = `:is\\(\\s*[^)]+\\s*\\)`;
// N_TH: excludes An+B with selector list, e.g. :nth-child(2n+1 of .foo)
export const N_TH = `nth-(?:last-)?(?:child|of-type)\\(\\s*(?:even|odd|${ANB})\\s*\\)`;
// SUB_TYPE: attr, id, class, pseudo-class, note that [foo|=bar] is excluded
export const SUB_TYPE = '\\[[^|\\]]+\\]|[#.:][\\w-]+';
export const SUB_TYPE_WO_PSEUDO = '\\[[^|\\]]+\\]|[#.][\\w-]+';
// TAG_TYPE: *, tag
export const TAG_TYPE = '\\*|[A-Za-z][\\w-]*';
export const TAG_TYPE_I = '\\*|[A-Z][\\w-]*';
export const COMPOUND = `(?:${TAG_TYPE}|(?:${TAG_TYPE})?(?:${SUB_TYPE})+)`;
export const COMPOUND_L = `(?:${TAG_TYPE}|(?:${TAG_TYPE})?(?:${SUB_TYPE}|${LOGIC_IS})+)`;
export const COMPOUND_I = `(?:${TAG_TYPE_I}|(?:${TAG_TYPE_I})?(?:${SUB_TYPE})+)`;
export const COMPOUND_WO_PSEUDO = `(?:${TAG_TYPE}|(?:${TAG_TYPE})?(?:${SUB_TYPE_WO_PSEUDO})+)`;
export const COMPLEX = `${COMPOUND}(?:${COMBO}${COMPOUND})*`;
export const COMPLEX_L = `${COMPOUND_L}(?:${COMBO}${COMPOUND_L})*`;
export const HAS_COMPOUND = `has\\([\\s>]?\\s*${COMPOUND_WO_PSEUDO}\\s*\\)`;
export const LOGIC_COMPOUND = `(?:is|not)\\(\\s*${COMPOUND_L}(?:\\s*,\\s*${COMPOUND_L})*\\s*\\)`;
export const LOGIC_COMPLEX = `(?:is|not)\\(\\s*${COMPLEX_L}(?:\\s*,\\s*${COMPLEX_L})*\\s*\\)`;
/* forms and input types */
export const FORM_PARTS = Object.freeze([
'button',
'input',
'select',
'textarea'
]);
export const INPUT_BUTTON = Object.freeze(['button', 'reset', 'submit']);
export const INPUT_CHECK = Object.freeze(['checkbox', 'radio']);
export const INPUT_DATE = Object.freeze([
'date',
'datetime-local',
'month',
'time',
'week'
]);
export const INPUT_TEXT = Object.freeze([
'email',
'password',
'search',
'tel',
'text',
'url'
]);
export const INPUT_EDIT = Object.freeze([
...INPUT_DATE,
...INPUT_TEXT,
'number'
]);
export const INPUT_LTR = Object.freeze([
...INPUT_CHECK,
'color',
'date',
'image',
'number',
'range',
'time'
]);
/* logical combination pseudo-classes */
export const KEYS_LOGICAL = new Set(['has', 'is', 'not', 'where']);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,587 @@
/**
* matcher.js
*/
/* import */
import { generateCSS, parseAstName, unescapeSelector } from './parser.js';
import {
generateException,
getDirectionality,
getLanguageAttribute,
getType,
isContentEditable,
isCustomElement,
isNamespaceDeclared
} from './utility.js';
/* constants */
import {
ALPHA_NUM,
FORM_PARTS,
IDENT,
INPUT_EDIT,
LANG_PART,
NOT_SUPPORTED_ERR,
PS_ELEMENT_SELECTOR,
STRING,
SYNTAX_ERR
} from './constant.js';
const KEYS_FORM_PS_DISABLED = new Set([
...FORM_PARTS,
'fieldset',
'optgroup',
'option'
]);
const KEYS_INPUT_EDIT = new Set(INPUT_EDIT);
const REG_LANG_VALID = new RegExp(`^(?:\\*-)?${ALPHA_NUM}${LANG_PART}$`, 'i');
const REG_TAG_NAME = /[A-Z][\\w-]*/i;
/**
* Validates a pseudo-element selector.
* @param {string} astName - The name of the pseudo-element from the AST.
* @param {string} astType - The type of the selector from the AST.
* @param {object} [opt] - Optional parameters.
* @param {boolean} [opt.forgive] - If true, ignores unknown pseudo-elements.
* @param {boolean} [opt.warn] - If true, throws an error for unsupported ones.
* @throws {DOMException} If the selector is invalid or unsupported.
* @returns {void}
*/
export const matchPseudoElementSelector = (astName, astType, opt = {}) => {
const { forgive, globalObject, warn } = opt;
if (astType !== PS_ELEMENT_SELECTOR) {
// Ensure the AST node is a pseudo-element selector.
throw new TypeError(`Unexpected ast type ${getType(astType)}`);
}
switch (astName) {
case 'after':
case 'backdrop':
case 'before':
case 'cue':
case 'cue-region':
case 'first-letter':
case 'first-line':
case 'file-selector-button':
case 'marker':
case 'placeholder':
case 'selection':
case 'target-text': {
// Warn if the pseudo-element is known but unsupported.
if (warn) {
throw generateException(
`Unsupported pseudo-element ::${astName}`,
NOT_SUPPORTED_ERR,
globalObject
);
}
break;
}
case 'part':
case 'slotted': {
// Warn if the functional pseudo-element is known but unsupported.
if (warn) {
throw generateException(
`Unsupported pseudo-element ::${astName}()`,
NOT_SUPPORTED_ERR,
globalObject
);
}
break;
}
default: {
// Handle vendor-prefixed or unknown pseudo-elements.
if (astName.startsWith('-webkit-')) {
if (warn) {
throw generateException(
`Unsupported pseudo-element ::${astName}`,
NOT_SUPPORTED_ERR,
globalObject
);
}
// Throw an error for unknown pseudo-elements if not forgiven.
} else if (!forgive) {
throw generateException(
`Unknown pseudo-element ::${astName}`,
SYNTAX_ERR,
globalObject
);
}
}
}
};
/**
* Matches the :dir() pseudo-class against an element's directionality.
* @param {object} ast - The AST object for the pseudo-class.
* @param {object} node - The element node to match against.
* @throws {TypeError} If the AST does not contain a valid direction value.
* @returns {boolean} - True if the directionality matches, otherwise false.
*/
export const matchDirectionPseudoClass = (ast, node) => {
const { name } = ast;
// The :dir() pseudo-class requires a direction argument (e.g., "ltr").
if (!name) {
const type = name === '' ? '(empty String)' : getType(name);
throw new TypeError(`Unexpected ast type ${type}`);
}
// Get the computed directionality of the element.
const dir = getDirectionality(node);
// Compare the expected direction with the element's actual direction.
return name === dir;
};
/**
* Matches the :lang() pseudo-class against an element's language.
* @see https://datatracker.ietf.org/doc/html/rfc4647#section-3.3.1
* @param {object} ast - The AST object for the pseudo-class.
* @param {object} node - The element node to match against.
* @returns {boolean} - True if the language matches, otherwise false.
*/
export const matchLanguagePseudoClass = (ast, node) => {
const { name, type, value } = ast;
let langPattern;
// Determine the language pattern from the AST.
if (type === STRING && value) {
langPattern = value;
} else if (type === IDENT && name) {
langPattern = unescapeSelector(name);
}
// If no valid language pattern is provided, it cannot match.
if (typeof langPattern !== 'string') {
return false;
}
// Get the effective language attribute for the current node.
const elementLang = getLanguageAttribute(node);
// If the element has no language, it cannot match a specific pattern.
if (elementLang === null) {
return false;
}
// Handle the universal selector '*' for :lang.
if (langPattern === '*') {
// It matches any language unless attribute is not empty.
return elementLang !== '';
}
// Validate the provided language pattern structure.
if (!REG_LANG_VALID.test(langPattern)) {
return false;
}
// Build a regex for extended language range matching.
let matcherRegex;
if (langPattern.indexOf('-') > -1) {
// Handle complex patterns with wildcards and sub-tags (e.g., '*-US').
const [langMain, langSub, ...langRest] = langPattern.split('-');
const extendedMain =
langMain === '*' ? `${ALPHA_NUM}${LANG_PART}` : `${langMain}${LANG_PART}`;
const extendedSub = `-${langSub}${LANG_PART}`;
let extendedRest = '';
// Use a standard for loop for performance as per the rules.
for (let i = 0; i < langRest.length; i++) {
extendedRest += `-${langRest[i]}${LANG_PART}`;
}
matcherRegex = new RegExp(
`^${extendedMain}${extendedSub}${extendedRest}$`,
'i'
);
} else {
// Handle simple language patterns (e.g., 'en').
matcherRegex = new RegExp(`^${langPattern}${LANG_PART}$`, 'i');
}
// Test the element's language against the constructed regex.
return matcherRegex.test(elementLang);
};
/**
* Matches the :disabled and :enabled pseudo-classes.
* @param {string} astName - pseudo-class name
* @param {object} node - Element node
* @returns {boolean} - True if matched
*/
export const matchDisabledPseudoClass = (astName, node) => {
const { localName, parentNode } = node;
if (
!KEYS_FORM_PS_DISABLED.has(localName) &&
!isCustomElement(node, { formAssociated: true })
) {
return false;
}
let isDisabled = false;
if (node.disabled || node.hasAttribute('disabled')) {
isDisabled = true;
} else if (localName === 'option') {
if (
parentNode &&
parentNode.localName === 'optgroup' &&
(parentNode.disabled || parentNode.hasAttribute('disabled'))
) {
isDisabled = true;
}
} else if (localName !== 'optgroup') {
let current = parentNode;
while (current) {
if (
current.localName === 'fieldset' &&
(current.disabled || current.hasAttribute('disabled'))
) {
// The first <legend> in a disabled <fieldset> is not disabled.
let legend;
let element = current.firstElementChild;
while (element) {
if (element.localName === 'legend') {
legend = element;
break;
}
element = element.nextElementSibling;
}
if (!legend || !legend.contains(node)) {
isDisabled = true;
}
// Found the containing fieldset, stop searching up.
break;
}
current = current.parentNode;
}
}
if (astName === 'disabled') {
return isDisabled;
}
return !isDisabled;
};
/**
* Match the :read-only and :read-write pseudo-classes
* @param {string} astName - pseudo-class name
* @param {object} node - Element node
* @returns {boolean} - True if matched
*/
export const matchReadOnlyPseudoClass = (astName, node) => {
const { localName } = node;
let isReadOnly = false;
switch (localName) {
case 'textarea':
case 'input': {
const isEditableInput = !node.type || KEYS_INPUT_EDIT.has(node.type);
if (localName === 'textarea' || isEditableInput) {
isReadOnly =
node.readOnly ||
node.hasAttribute('readonly') ||
node.disabled ||
node.hasAttribute('disabled');
} else {
// Non-editable input types are always read-only
isReadOnly = true;
}
break;
}
default: {
isReadOnly = !isContentEditable(node);
}
}
if (astName === 'read-only') {
return isReadOnly;
}
return !isReadOnly;
};
/**
* Matches an attribute selector against an element.
* This function handles various attribute matchers like '=', '~=', '^=', etc.,
* and considers namespaces and case sensitivity based on document type.
* @param {object} ast - The AST for the attribute selector.
* @param {object} node - The element node to match against.
* @param {object} [opt] - Optional parameters.
* @param {boolean} [opt.check] - True if running in an internal check.
* @param {boolean} [opt.forgive] - True to forgive certain syntax errors.
* @returns {boolean} - True if the attribute selector matches, otherwise false.
*/
export const matchAttributeSelector = (ast, node, opt = {}) => {
const {
flags: astFlags,
matcher: astMatcher,
name: astName,
value: astValue
} = ast;
const { check, forgive, globalObject } = opt;
// Validate selector flags ('i' or 's').
if (typeof astFlags === 'string' && !/^[is]$/i.test(astFlags) && !forgive) {
const css = generateCSS(ast);
throw generateException(
`Invalid selector ${css}`,
SYNTAX_ERR,
globalObject
);
}
const { attributes } = node;
// An element with no attributes cannot match.
if (!attributes || !attributes.length) {
return false;
}
// Determine case sensitivity based on document type and flags.
const contentType = node.ownerDocument.contentType;
let caseInsensitive;
if (contentType === 'text/html') {
if (typeof astFlags === 'string' && /^s$/i.test(astFlags)) {
caseInsensitive = false;
} else {
caseInsensitive = true;
}
} else if (typeof astFlags === 'string' && /^i$/i.test(astFlags)) {
caseInsensitive = true;
} else {
caseInsensitive = false;
}
// Prepare the attribute name from the selector for matching.
let astAttrName = unescapeSelector(astName.name);
if (caseInsensitive) {
astAttrName = astAttrName.toLowerCase();
}
// A set to store the values of attributes whose names match.
const attrValues = new Set();
// Handle namespaced attribute names (e.g., [*|attr], [ns|attr]).
if (astAttrName.indexOf('|') > -1) {
const { prefix: astPrefix, localName: astLocalName } =
parseAstName(astAttrName);
for (const item of attributes) {
let { name: itemName, value: itemValue } = item;
if (caseInsensitive) {
itemName = itemName.toLowerCase();
itemValue = itemValue.toLowerCase();
}
switch (astPrefix) {
case '': {
if (astLocalName === itemName) {
attrValues.add(itemValue);
}
break;
}
case '*': {
if (itemName.indexOf(':') > -1) {
const [, ...restItemName] = itemName.split(':');
const itemLocalName = restItemName.join(':').replace(/^:/, '');
if (itemLocalName === astLocalName) {
attrValues.add(itemValue);
}
} else if (astLocalName === itemName) {
attrValues.add(itemValue);
}
break;
}
default: {
if (!check) {
if (forgive) {
return false;
}
const css = generateCSS(ast);
throw generateException(
`Invalid selector ${css}`,
SYNTAX_ERR,
globalObject
);
}
if (itemName.indexOf(':') > -1) {
const [itemPrefix, ...restItemName] = itemName.split(':');
const itemLocalName = restItemName.join(':').replace(/^:/, '');
// Ignore the 'xml:lang' attribute.
if (itemPrefix === 'xml' && itemLocalName === 'lang') {
continue;
} else if (
astPrefix === itemPrefix &&
astLocalName === itemLocalName
) {
const namespaceDeclared = isNamespaceDeclared(astPrefix, node);
if (namespaceDeclared) {
attrValues.add(itemValue);
}
}
}
}
}
}
// Handle non-namespaced attribute names.
} else {
for (let { name: itemName, value: itemValue } of attributes) {
if (caseInsensitive) {
itemName = itemName.toLowerCase();
itemValue = itemValue.toLowerCase();
}
if (itemName.indexOf(':') > -1) {
const [itemPrefix, ...restItemName] = itemName.split(':');
const itemLocalName = restItemName.join(':').replace(/^:/, '');
// The attribute is starting with ':'.
if (!itemPrefix && astAttrName === `:${itemLocalName}`) {
attrValues.add(itemValue);
// Ignore the 'xml:lang' attribute.
} else if (itemPrefix === 'xml' && itemLocalName === 'lang') {
continue;
} else if (astAttrName === itemLocalName) {
attrValues.add(itemValue);
}
} else if (astAttrName === itemName) {
attrValues.add(itemValue);
}
}
}
if (!attrValues.size) {
return false;
}
// Prepare the value from the selector's RHS for comparison.
const { name: astIdentValue, value: astStringValue } = astValue ?? {};
let attrValue;
if (astIdentValue) {
if (caseInsensitive) {
attrValue = astIdentValue.toLowerCase();
} else {
attrValue = astIdentValue;
}
} else if (astStringValue) {
if (caseInsensitive) {
attrValue = astStringValue.toLowerCase();
} else {
attrValue = astStringValue;
}
} else if (astStringValue === '') {
attrValue = astStringValue;
}
// Perform the final match based on the specified matcher.
switch (astMatcher) {
case '=': {
return typeof attrValue === 'string' && attrValues.has(attrValue);
}
case '~=': {
if (attrValue && typeof attrValue === 'string') {
for (const value of attrValues) {
const item = new Set(value.split(/\s+/));
if (item.has(attrValue)) {
return true;
}
}
}
return false;
}
case '|=': {
if (attrValue && typeof attrValue === 'string') {
for (const value of attrValues) {
if (value === attrValue || value.startsWith(`${attrValue}-`)) {
return true;
}
}
}
return false;
}
case '^=': {
if (attrValue && typeof attrValue === 'string') {
for (const value of attrValues) {
if (value.startsWith(`${attrValue}`)) {
return true;
}
}
}
return false;
}
case '$=': {
if (attrValue && typeof attrValue === 'string') {
for (const value of attrValues) {
if (value.endsWith(`${attrValue}`)) {
return true;
}
}
}
return false;
}
case '*=': {
if (attrValue && typeof attrValue === 'string') {
for (const value of attrValues) {
if (value.includes(`${attrValue}`)) {
return true;
}
}
}
return false;
}
case null:
default: {
// This case handles attribute existence checks (e.g., '[disabled]').
return true;
}
}
};
/**
* match type selector
* @param {object} ast - AST
* @param {object} node - Element node
* @param {object} [opt] - options
* @param {boolean} [opt.check] - running in internal check()
* @param {boolean} [opt.forgive] - forgive undeclared namespace
* @returns {boolean} - result
*/
export const matchTypeSelector = (ast, node, opt = {}) => {
const astName = unescapeSelector(ast.name);
const { localName, namespaceURI, prefix } = node;
const { check, forgive, globalObject } = opt;
let { prefix: astPrefix, localName: astLocalName } = parseAstName(
astName,
node
);
if (
node.ownerDocument.contentType === 'text/html' &&
(!namespaceURI || namespaceURI === 'http://www.w3.org/1999/xhtml') &&
REG_TAG_NAME.test(localName)
) {
astPrefix = astPrefix.toLowerCase();
astLocalName = astLocalName.toLowerCase();
}
let nodePrefix;
let nodeLocalName;
// just in case that the namespaced content is parsed as text/html
if (localName.indexOf(':') > -1) {
[nodePrefix, nodeLocalName] = localName.split(':');
} else {
nodePrefix = prefix || '';
nodeLocalName = localName;
}
switch (astPrefix) {
case '': {
if (
!nodePrefix &&
!namespaceURI &&
(astLocalName === '*' || astLocalName === nodeLocalName)
) {
return true;
}
return false;
}
case '*': {
if (astLocalName === '*' || astLocalName === nodeLocalName) {
return true;
}
return false;
}
default: {
if (!check) {
if (forgive) {
return false;
}
const css = generateCSS(ast);
throw generateException(
`Invalid selector ${css}`,
SYNTAX_ERR,
globalObject
);
}
const astNS = node.lookupNamespaceURI(astPrefix);
const nodeNS = node.lookupNamespaceURI(nodePrefix);
if (astNS === nodeNS && astPrefix === nodePrefix) {
if (astLocalName === '*' || astLocalName === nodeLocalName) {
return true;
}
return false;
} else if (!forgive && !astNS) {
throw generateException(
`Undeclared namespace ${astPrefix}`,
SYNTAX_ERR,
globalObject
);
}
return false;
}
}
};

View File

@@ -0,0 +1,431 @@
/**
* parser.js
*/
/* import */
import * as cssTree from 'css-tree';
import { getType } from './utility.js';
/* constants */
import {
ATTR_SELECTOR,
BIT_01,
BIT_02,
BIT_04,
BIT_08,
BIT_16,
BIT_32,
BIT_FFFF,
CLASS_SELECTOR,
DUO,
HEX,
ID_SELECTOR,
KEYS_LOGICAL,
NTH,
PS_CLASS_SELECTOR,
PS_ELEMENT_SELECTOR,
SELECTOR,
SYNTAX_ERR,
TYPE_SELECTOR
} from './constant.js';
const AST_SORT_ORDER = new Map([
[PS_ELEMENT_SELECTOR, BIT_01],
[ID_SELECTOR, BIT_02],
[CLASS_SELECTOR, BIT_04],
[TYPE_SELECTOR, BIT_08],
[ATTR_SELECTOR, BIT_16],
[PS_CLASS_SELECTOR, BIT_32]
]);
const KEYS_PS_CLASS_STATE = new Set([
'checked',
'closed',
'disabled',
'empty',
'enabled',
'in-range',
'indeterminate',
'invalid',
'open',
'out-of-range',
'placeholder-shown',
'read-only',
'read-write',
'valid'
]);
const KEYS_SHADOW_HOST = new Set(['host', 'host-context']);
const REG_EMPTY_PS_FUNC =
/(?<=:(?:dir|has|host(?:-context)?|is|lang|not|nth-(?:last-)?(?:child|of-type)|where))\(\s+\)/g;
const REG_SHADOW_PS_ELEMENT = /^part|slotted$/;
const U_FFFD = '\uFFFD';
/**
* Unescapes a CSS selector string.
* @param {string} selector - The CSS selector to unescape.
* @returns {string} The unescaped selector string.
*/
export const unescapeSelector = (selector = '') => {
if (typeof selector === 'string' && selector.indexOf('\\', 0) >= 0) {
const arr = selector.split('\\');
const selectorItems = [arr[0]];
const l = arr.length;
for (let i = 1; i < l; i++) {
const item = arr[i];
if (item === '' && i === l - 1) {
selectorItems.push(U_FFFD);
} else {
const hexExists = /^([\da-f]{1,6}\s?)/i.exec(item);
if (hexExists) {
const [, hex] = hexExists;
let str;
try {
const low = parseInt('D800', HEX);
const high = parseInt('DFFF', HEX);
const deci = parseInt(hex, HEX);
if (deci === 0 || (deci >= low && deci <= high)) {
str = U_FFFD;
} else {
str = String.fromCodePoint(deci);
}
} catch (e) {
str = U_FFFD;
}
let postStr = '';
if (item.length > hex.length) {
postStr = item.substring(hex.length);
}
selectorItems.push(`${str}${postStr}`);
// whitespace
} else if (/^[\n\r\f]/.test(item)) {
selectorItems.push(`\\${item}`);
} else {
selectorItems.push(item);
}
}
}
return selectorItems.join('');
}
return selector;
};
/**
* Preprocesses a selector string according to the specification.
* @see https://drafts.csswg.org/css-syntax-3/#input-preprocessing
* @param {string} value - The value to preprocess.
* @returns {string} The preprocessed selector string.
*/
export const preprocess = value => {
// Non-string values will be converted to string.
if (typeof value !== 'string') {
if (value === undefined || value === null) {
return getType(value).toLowerCase();
} else if (Array.isArray(value)) {
return value.join(',');
} else if (Object.hasOwn(value, 'toString')) {
return value.toString();
} else {
throw new DOMException(`Invalid selector ${value}`, SYNTAX_ERR);
}
}
let selector = value;
let index = 0;
while (index >= 0) {
// @see https://drafts.csswg.org/selectors/#id-selectors
index = selector.indexOf('#', index);
if (index < 0) {
break;
}
const preHash = selector.substring(0, index + 1);
let postHash = selector.substring(index + 1);
const codePoint = postHash.codePointAt(0);
if (codePoint > BIT_FFFF) {
const str = `\\${codePoint.toString(HEX)} `;
if (postHash.length === DUO) {
postHash = str;
} else {
postHash = `${str}${postHash.substring(DUO)}`;
}
}
selector = `${preHash}${postHash}`;
index++;
}
return selector
.replace(/\f|\r\n?/g, '\n')
.replace(/[\0\uD800-\uDFFF]|\\$/g, U_FFFD)
.replace(/\x26/g, ':scope');
};
/**
* Creates an Abstract Syntax Tree (AST) from a CSS selector string.
* @param {string} sel - The CSS selector string.
* @returns {object} The parsed AST object.
*/
export const parseSelector = sel => {
const selector = preprocess(sel);
// invalid selectors
if (/^$|^\s*>|,\s*$/.test(selector)) {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
try {
const ast = cssTree.parse(selector, {
context: 'selectorList',
parseCustomProperty: true
});
return cssTree.toPlainObject(ast);
} catch (e) {
const { message } = e;
if (
/^(?:"\]"|Attribute selector [()\s,=~^$*|]+) is expected$/.test(
message
) &&
!selector.endsWith(']')
) {
const index = selector.lastIndexOf('[');
const selPart = selector.substring(index);
if (selPart.includes('"')) {
const quotes = selPart.match(/"/g).length;
if (quotes % 2) {
return parseSelector(`${selector}"]`);
}
return parseSelector(`${selector}]`);
}
return parseSelector(`${selector}]`);
} else if (message === '")" is expected') {
// workaround for https://github.com/csstree/csstree/issues/283
if (REG_EMPTY_PS_FUNC.test(selector)) {
return parseSelector(`${selector.replaceAll(REG_EMPTY_PS_FUNC, '()')}`);
} else if (!selector.endsWith(')')) {
return parseSelector(`${selector})`);
} else {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
} else {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
}
};
/**
* Walks the provided AST to collect selector branches and gather information
* about its contents.
* @param {object} ast - The AST to traverse.
* @returns {{branches: Array<object>, info: object}} An object containing the selector branches and info.
*/
export const walkAST = (ast = {}) => {
const branches = new Set();
const info = {
hasForgivenPseudoFunc: false,
hasHasPseudoFunc: false,
hasLogicalPseudoFunc: false,
hasNotPseudoFunc: false,
hasNthChildOfSelector: false,
hasNestedSelector: false,
hasStatePseudoClass: false
};
const opt = {
enter(node) {
switch (node.type) {
case CLASS_SELECTOR: {
if (/^-?\d/.test(node.name)) {
throw new DOMException(
`Invalid selector .${node.name}`,
SYNTAX_ERR
);
}
break;
}
case ID_SELECTOR: {
if (/^-?\d/.test(node.name)) {
throw new DOMException(
`Invalid selector #${node.name}`,
SYNTAX_ERR
);
}
break;
}
case PS_CLASS_SELECTOR: {
if (KEYS_LOGICAL.has(node.name)) {
info.hasNestedSelector = true;
info.hasLogicalPseudoFunc = true;
if (node.name === 'has') {
info.hasHasPseudoFunc = true;
} else if (node.name === 'not') {
info.hasNotPseudoFunc = true;
} else {
info.hasForgivenPseudoFunc = true;
}
} else if (KEYS_PS_CLASS_STATE.has(node.name)) {
info.hasStatePseudoClass = true;
} else if (
KEYS_SHADOW_HOST.has(node.name) &&
Array.isArray(node.children) &&
node.children.length
) {
info.hasNestedSelector = true;
}
break;
}
case PS_ELEMENT_SELECTOR: {
if (REG_SHADOW_PS_ELEMENT.test(node.name)) {
info.hasNestedSelector = true;
}
break;
}
case NTH: {
if (node.selector) {
info.hasNestedSelector = true;
info.hasNthChildOfSelector = true;
}
break;
}
case SELECTOR: {
branches.add(node.children);
break;
}
default:
}
}
};
cssTree.walk(ast, opt);
if (info.hasNestedSelector === true) {
cssTree.findAll(ast, (node, item, list) => {
if (list) {
if (node.type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(node.name)) {
const itemList = list.filter(i => {
const { name, type } = i;
return type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(name);
});
for (const { children } of itemList) {
// SelectorList
for (const { children: grandChildren } of children) {
// Selector
for (const { children: greatGrandChildren } of grandChildren) {
if (branches.has(greatGrandChildren)) {
branches.delete(greatGrandChildren);
}
}
}
}
} else if (
node.type === PS_CLASS_SELECTOR &&
KEYS_SHADOW_HOST.has(node.name) &&
Array.isArray(node.children) &&
node.children.length
) {
const itemList = list.filter(i => {
const { children, name, type } = i;
const res =
type === PS_CLASS_SELECTOR &&
KEYS_SHADOW_HOST.has(name) &&
Array.isArray(children) &&
children.length;
return res;
});
for (const { children } of itemList) {
// Selector
for (const { children: grandChildren } of children) {
if (branches.has(grandChildren)) {
branches.delete(grandChildren);
}
}
}
} else if (
node.type === PS_ELEMENT_SELECTOR &&
REG_SHADOW_PS_ELEMENT.test(node.name)
) {
const itemList = list.filter(i => {
const { name, type } = i;
const res =
type === PS_ELEMENT_SELECTOR && REG_SHADOW_PS_ELEMENT.test(name);
return res;
});
for (const { children } of itemList) {
// Selector
for (const { children: grandChildren } of children) {
if (branches.has(grandChildren)) {
branches.delete(grandChildren);
}
}
}
} else if (node.type === NTH && node.selector) {
const itemList = list.filter(i => {
const { selector, type } = i;
const res = type === NTH && selector;
return res;
});
for (const { selector } of itemList) {
const { children } = selector;
// Selector
for (const { children: grandChildren } of children) {
if (branches.has(grandChildren)) {
branches.delete(grandChildren);
}
}
}
}
}
});
}
return {
info,
branches: [...branches]
};
};
/**
* Comparison function for sorting AST nodes based on specificity.
* @param {object} a - The first AST node.
* @param {object} b - The second AST node.
* @returns {number} -1, 0 or 1, depending on the sort order.
*/
export const compareASTNodes = (a, b) => {
const bitA = AST_SORT_ORDER.get(a.type);
const bitB = AST_SORT_ORDER.get(b.type);
if (bitA === bitB) {
return 0;
} else if (bitA > bitB) {
return 1;
} else {
return -1;
}
};
/**
* Sorts a collection of AST nodes based on CSS specificity rules.
* @param {Array<object>} asts - A collection of AST nodes to sort.
* @returns {Array<object>} A new array containing the sorted AST nodes.
*/
export const sortAST = asts => {
const arr = [...asts];
if (arr.length > 1) {
arr.sort(compareASTNodes);
}
return arr;
};
/**
* Parses a type selector's name, which may include a namespace prefix.
* @param {string} selector - The type selector name (e.g., 'ns|E' or 'E').
* @returns {{prefix: string, localName: string}} An object with `prefix` and
* `localName` properties.
*/
export const parseAstName = selector => {
let prefix;
let localName;
if (selector && typeof selector === 'string') {
if (selector.indexOf('|') > -1) {
[prefix, localName] = selector.split('|');
} else {
prefix = '*';
localName = selector;
}
} else {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
return {
prefix,
localName
};
};
/* Re-exported from css-tree. */
export { find as findAST, generate as generateCSS } from 'css-tree';

File diff suppressed because it is too large Load Diff