diff --git a/package.json b/package.json index 9ac77b0..a31041f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "build": "pnpm -r build", "build:main": "pnpm --filter beasties run build", "build:webpack": "pnpm --filter beasties-webpack-plugin run build", + "postinstall": "pnpm -r build:stub", "docs": "pnpm -r docs", "lint": "eslint .", "release": "bumpp && pnpm publish", @@ -36,8 +37,9 @@ "devDependencies": { "@antfu/eslint-config": "3.8.0", "@codspeed/vitest-plugin": "3.1.1", + "@types/node": "18.13.0", "@vitest/coverage-v8": "2.1.3", - "bumpp": "^9.7.1", + "bumpp": "9.7.1", "cheerio": "1.0.0", "css-what": "6.1.0", "eslint": "9.13.0", diff --git a/packages/beasties/package.json b/packages/beasties/package.json index ef8ffa2..5f730ee 100644 --- a/packages/beasties/package.json +++ b/packages/beasties/package.json @@ -30,26 +30,26 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/beasties.mjs", - "require": "./dist/beasties.js", - "default": "./dist/beasties.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "default": "./dist/index.mjs" } }, - "main": "dist/beasties.js", - "module": "dist/beasties.mjs", - "source": "src/index.js", + "main": "dist/index.cjs", + "module": "dist/index.mjs", "types": "./dist/index.d.ts", "files": [ - "dist", - "src" + "dist" ], "scripts": { - "build": "microbundle --target node --no-sourcemap -f cjs,esm && cp src/index.d.ts dist/index.d.ts", + "build": "unbuild && cp src/index.d.ts dist/index.d.ts", + "build:stub": "unbuild --stub && cp src/index.d.ts dist/index.d.ts", "docs": "documentation readme src -q --no-markdown-toc -a public -s Usage --sort-order alpha", "prepack": "npm run -s build" }, "dependencies": { "css-select": "^5.1.0", + "css-what": "^6.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^9.0.0", @@ -60,6 +60,6 @@ "devDependencies": { "@types/postcss-media-query-parser": "0.2.4", "documentation": "14.0.3", - "microbundle": "0.15.1" + "unbuild": "^2.0.0" } } diff --git a/packages/beasties/src/css.js b/packages/beasties/src/css.ts similarity index 61% rename from packages/beasties/src/css.js rename to packages/beasties/src/css.ts index 88d7c55..7c9c2de 100644 --- a/packages/beasties/src/css.js +++ b/packages/beasties/src/css.ts @@ -14,29 +14,35 @@ * the License. */ +import type { AnyNode, ChildNode, Rule } from 'postcss' +import type Root_ from 'postcss/lib/root' import { parse, stringify } from 'postcss' -import mediaParser from 'postcss-media-query-parser' +import mediaParser, { type Child, type Root } from 'postcss-media-query-parser' /** * Parse a textual CSS Stylesheet into a Stylesheet instance. * Stylesheet is a mutable postcss AST with format similar to CSSOM. * @see https://github.com/postcss/postcss/ * @private - * @param {string} stylesheet - * @returns {css.Stylesheet} ast */ -export function parseStylesheet(stylesheet) { +export function parseStylesheet(stylesheet: string) { return parse(stylesheet) } +/** + * Options used by the stringify logic + */ +interface SerializeStylesheetOptions { + /** Compress CSS output (removes comments, whitespace, etc) */ + compress?: boolean +} + /** * Serialize a postcss Stylesheet to a String of CSS. * @private - * @param {css.Stylesheet} ast A Stylesheet to serialize, such as one returned from `parseStylesheet()` - * @param {object} options Options used by the stringify logic - * @param {boolean} [options.compress] Compress CSS output (removes comments, whitespace, etc) + * @param ast A Stylesheet to serialize, such as one returned from `parseStylesheet()` */ -export function serializeStylesheet(ast, options) { +export function serializeStylesheet(ast: AnyNode, options: SerializeStylesheetOptions) { let cssStr = '' stringify(ast, (result, node, type) => { @@ -61,7 +67,7 @@ export function serializeStylesheet(ast, options) { } if (type === 'start') { - if (node.type === 'rule' && node.selectors) { + if (node?.type === 'rule' && node.selectors) { cssStr += `${node.selectors.join(',')}{` } else { @@ -80,33 +86,36 @@ export function serializeStylesheet(ast, options) { return cssStr } +type SingleIterator = (item: T) => boolean | void + /** * Converts a walkStyleRules() iterator to mark nodes with `.$$remove=true` instead of actually removing them. * This means they can be removed in a second pass, allowing the first pass to be nondestructive (eg: to preserve mirrored sheets). * @private - * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node. - * @returns {(rule) => void} nonDestructiveIterator + * @param predicate Invoked on each node in the tree. Return `false` to remove that node. */ -export function markOnly(predicate) { +export function markOnly(predicate: SingleIterator): (rule: Rule | ChildNode | Root_) => void { return (rule) => { - const sel = rule.selectors + const sel = 'selectors' in rule ? rule.selectors : undefined if (predicate(rule) === false) { rule.$$remove = true } - rule.$$markedSelectors = rule.selectors + if ('selectors' in rule) { + rule.$$markedSelectors = rule.selectors + rule.selectors = sel! + } if (rule._other) { rule._other.$$markedSelectors = rule._other.selectors } - rule.selectors = sel } } /** * Apply filtered selectors to a rule from a previous markOnly run. * @private - * @param {css.Rule} rule The Rule to apply marked selectors to (if they exist). + * @param rule The Rule to apply marked selectors to (if they exist). */ -export function applyMarkedSelectors(rule) { +export function applyMarkedSelectors(rule: Rule) { if (rule.$$markedSelectors) { rule.selectors = rule.$$markedSelectors } @@ -118,11 +127,14 @@ export function applyMarkedSelectors(rule) { /** * Recursively walk all rules in a stylesheet. * @private - * @param {css.Rule} node A Stylesheet or Rule to descend into. - * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node. + * @param node A Stylesheet or Rule to descend into. + * @param iterator Invoked on each node in the tree. Return `false` to remove that node. */ -export function walkStyleRules(node, iterator) { - node.nodes = node.nodes.filter((rule) => { +export function walkStyleRules(node: ChildNode | Root_, iterator: SingleIterator) { + if (!('nodes' in node)) { + return + } + node.nodes = node.nodes?.filter((rule) => { if (hasNestedRules(rule)) { walkStyleRules(rule, iterator) } @@ -135,23 +147,23 @@ export function walkStyleRules(node, iterator) { /** * Recursively walk all rules in two identical stylesheets, filtering nodes into one or the other based on a predicate. * @private - * @param {css.Rule} node A Stylesheet or Rule to descend into. - * @param {css.Rule} node2 A second tree identical to `node` - * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node from the first tree, true to remove it from the second. + * @param node A Stylesheet or Rule to descend into. + * @param node2 A second tree identical to `node` + * @param iterator Invoked on each node in the tree. Return `false` to remove that node from the first tree, true to remove it from the second. */ -export function walkStyleRulesWithReverseMirror(node, node2, iterator) { - if (node2 === null) +export function walkStyleRulesWithReverseMirror(node: Rule | Root_, node2: Rule | Root_ | undefined | null, iterator: SingleIterator) { + if (!node2) return walkStyleRules(node, iterator); [node.nodes, node2.nodes] = splitFilter( node.nodes, node2.nodes, - (rule, index, rules, rules2) => { - const rule2 = rules2[index] + (rule, index, _rules, rules2) => { + const rule2 = rules2?.[index] if (hasNestedRules(rule)) { - walkStyleRulesWithReverseMirror(rule, rule2, iterator) + walkStyleRulesWithReverseMirror(rule, rule2 as Rule, iterator) } - rule._other = rule2 + rule._other = rule2 as Rule rule.filterSelectors = filterSelectors return iterator(rule) !== false }, @@ -160,33 +172,35 @@ export function walkStyleRulesWithReverseMirror(node, node2, iterator) { // Checks if a node has nested rules, like @media // @keyframes are an exception since they are evaluated as a whole -function hasNestedRules(rule) { +function hasNestedRules(rule: ChildNode): rule is Rule { return ( - rule.nodes?.length - && rule.name !== 'keyframes' - && rule.name !== '-webkit-keyframes' + 'nodes' in rule + && !!rule.nodes?.length + && (!('name' in rule) || (rule.name !== 'keyframes' && rule.name !== '-webkit-keyframes')) && rule.nodes.some(n => n.type === 'rule' || n.type === 'atrule') ) } // Like [].filter(), but applies the opposite filtering result to a second copy of the Array without a second pass. // This is just a quicker version of generating the compliment of the set returned from a filter operation. -function splitFilter(a, b, predicate) { - const aOut = [] - const bOut = [] +type SplitIterator = (item: T, index: number, a: T[], b?: T[]) => boolean +function splitFilter(a: T[], b: T[], predicate: SplitIterator) { + const aOut: T[] = [] + const bOut: T[] = [] for (let index = 0; index < a.length; index++) { - if (predicate(a[index], index, a, b)) { - aOut.push(a[index]) + const item = a[index]! + if (predicate(item, index, a, b)) { + aOut.push(item) } else { - bOut.push(a[index]) + bOut.push(item) } } - return [aOut, bOut] + return [aOut, bOut] as const } // can be invoked on a style rule to subset its selectors (with reverse mirroring) -function filterSelectors(predicate) { +function filterSelectors(this: Rule, predicate: SplitIterator) { if (this._other) { const [a, b] = splitFilter( this.selectors, @@ -218,7 +232,7 @@ const MEDIA_FEATURES = new Set( ].flatMap(feature => [feature, `min-${feature}`, `max-${feature}`]), ) -function validateMediaType(node) { +function validateMediaType(node: Child | Root) { const { type: nodeType, value: nodeValue } = node if (nodeType === 'media-type') { return MEDIA_TYPES.has(nodeValue) @@ -232,24 +246,23 @@ function validateMediaType(node) { } /** - * - * @param {string} Media query to validate - * @returns {boolean} * * This function performs a basic media query validation * to ensure the values passed as part of the 'media' config * is HTML safe and does not cause any injection issue + * + * @param query Media query to validate */ -export function validateMediaQuery(query) { +export function validateMediaQuery(query: string): boolean { // The below is needed for consumption with webpack. - const mediaParserFn = 'default' in mediaParser ? mediaParser.default : mediaParser + const mediaParserFn = 'default' in mediaParser ? mediaParser.default as unknown as typeof mediaParser : mediaParser const mediaTree = mediaParserFn(query) const nodeTypes = new Set(['media-type', 'keyword', 'media-feature']) - const stack = [mediaTree] + const stack: Array = [mediaTree] while (stack.length > 0) { - const node = stack.pop() + const node = stack.pop()! if (nodeTypes.has(node.type) && !validateMediaType(node)) { return false @@ -262,3 +275,12 @@ export function validateMediaQuery(query) { return true } + +declare module 'postcss' { + interface Node { + _other?: Rule + $$remove?: boolean + $$markedSelectors?: string[] + filterSelectors?: typeof filterSelectors + } +} diff --git a/packages/beasties/src/dom.js b/packages/beasties/src/dom.js deleted file mode 100644 index 285f412..0000000 --- a/packages/beasties/src/dom.js +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Copyright 2018 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -import { selectAll, selectOne } from 'css-select' -import { parse as selectorParser } from 'css-what' -import render from 'dom-serializer' -import { Element, Text } from 'domhandler' -import { DomUtils, parseDocument } from 'htmlparser2' - -let classCache = null -let idCache = null - -function buildCache(container) { - classCache = new Set() - idCache = new Set() - const queue = [container] - - while (queue.length) { - const node = queue.shift() - - if (node.hasAttribute('class')) { - const classList = node.getAttribute('class').trim().split(' ') - classList.forEach((cls) => { - classCache.add(cls) - }) - } - - if (node.hasAttribute('id')) { - const id = node.getAttribute('id').trim() - idCache.add(id) - } - - queue.push(...node.children.filter(child => child.type === 'tag')) - } -} - -/** - * Parse HTML into a mutable, serializable DOM Document. - * The DOM implementation is an htmlparser2 DOM enhanced with basic DOM mutation methods. - * @param {string} html HTML to parse into a Document instance - */ -export function createDocument(html) { - const document = /** @type {HTMLDocument} */ (parseDocument(html, { decodeEntities: false })) - - // eslint-disable-next-line no-use-before-define - defineProperties(document, DocumentExtensions) - - // Extend Element.prototype with DOM manipulation methods. - // eslint-disable-next-line no-use-before-define - defineProperties(Element.prototype, ElementExtensions) - - // Beasties container is the viewport to evaluate critical CSS - let beastiesContainer = document.querySelector('[data-beasties-container]') - - if (!beastiesContainer) { - document.documentElement.setAttribute('data-beasties-container', '') - beastiesContainer = document.documentElement - } - - document.beastiesContainer = beastiesContainer - buildCache(beastiesContainer) - - return document -} - -/** - * Serialize a Document to an HTML String - * @param {HTMLDocument} document A Document, such as one created via `createDocument()` - */ -export function serializeDocument(document) { - return render(document, { decodeEntities: false }) -} - -/** @typedef {treeAdapter.Document & typeof ElementExtensions} HTMLDocument */ - -/** - * Methods and descriptors to mix into Element.prototype - * @private - */ -const ElementExtensions = { - /** @extends treeAdapter.Element.prototype */ - - nodeName: { - get() { - return this.tagName.toUpperCase() - }, - }, - - id: reflectedProperty('id'), - - className: reflectedProperty('class'), - - insertBefore(child, referenceNode) { - if (!referenceNode) - return this.appendChild(child) - DomUtils.prepend(referenceNode, child) - return child - }, - - appendChild(child) { - DomUtils.appendChild(this, child) - return child - }, - - removeChild(child) { - DomUtils.removeElement(child) - }, - - remove() { - DomUtils.removeElement(this) - }, - - textContent: { - get() { - return DomUtils.getText(this) - }, - - set(text) { - this.children = [] - DomUtils.appendChild(this, new Text(text)) - }, - }, - - setAttribute(name, value) { - if (this.attribs == null) - this.attribs = {} - if (value == null) - value = '' - this.attribs[name] = value - }, - - removeAttribute(name) { - if (this.attribs != null) { - delete this.attribs[name] - } - }, - - getAttribute(name) { - return this.attribs != null && this.attribs[name] - }, - - hasAttribute(name) { - return this.attribs != null && this.attribs[name] != null - }, - - getAttributeNode(name) { - const value = this.getAttribute(name) - if (value != null) - return { specified: true, value } - }, - - exists(sel) { - return cachedQuerySelector(sel, this) - }, - - querySelector(sel) { - return selectOne(sel, this) - }, - - querySelectorAll(sel) { - return selectAll(sel, this) - }, -} - -/** - * Methods and descriptors to mix into the global document instance - * @private - */ -const DocumentExtensions = { - /** @extends treeAdapter.Document.prototype */ - - // document is just an Element in htmlparser2, giving it a nodeType of ELEMENT_NODE. - // TODO: verify if these are needed for css-select - nodeType: { - get() { - return 9 - }, - }, - - contentType: { - get() { - return 'text/html' - }, - }, - - nodeName: { - get() { - return '#document' - }, - }, - - documentElement: { - get() { - // Find the first element within the document - return this.children.find( - child => String(child.tagName).toLowerCase() === 'html', - ) - }, - }, - - head: { - get() { - return this.querySelector('head') - }, - }, - - body: { - get() { - return this.querySelector('body') - }, - }, - - createElement(name) { - return new Element(name) - }, - - createTextNode(text) { - // there is no dedicated createTextNode equivalent exposed in htmlparser2's DOM - return new Text(text) - }, - - exists(sel) { - return cachedQuerySelector(sel, this) - }, - - querySelector(sel) { - return selectOne(sel, this) - }, - - querySelectorAll(sel) { - if (sel === ':root') { - return this - } - return selectAll(sel, this) - }, -} - -/** - * Essentially `Object.defineProperties()`, except function values are assigned as value descriptors for convenience. - * @private - */ -function defineProperties(obj, properties) { - for (const i in properties) { - const value = properties[i] - Object.defineProperty( - obj, - i, - typeof value === 'function' ? { value } : value, - ) - } -} - -/** - * Create a property descriptor defining a getter/setter pair alias for a named attribute. - * @private - */ -function reflectedProperty(attributeName) { - return { - get() { - return this.getAttribute(attributeName) - }, - set(value) { - this.setAttribute(attributeName, value) - }, - } -} - -function cachedQuerySelector(sel, node) { - const selectorTokens = selectorParser(sel) - for (const tokens of selectorTokens) { - // Check if the selector is a class selector - if (tokens.length === 1) { - const token = tokens[0] - if (token.type === 'attribute' && token.name === 'class') { - return classCache.has(token.value) - } - if (token.type === 'attribute' && token.name === 'id') { - return idCache.has(token.value) - } - } - } - return !!selectOne(sel, node) -} diff --git a/packages/beasties/src/dom.ts b/packages/beasties/src/dom.ts new file mode 100644 index 0000000..ce16625 --- /dev/null +++ b/packages/beasties/src/dom.ts @@ -0,0 +1,359 @@ +/** + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +import type { ChildNode, Node, NodeWithChildren } from 'domhandler' + +import { selectAll, selectOne } from 'css-select' + +import { parse as selectorParser } from 'css-what' +import render from 'dom-serializer' +import { Element, Text } from 'domhandler' +import { DomUtils, parseDocument } from 'htmlparser2' + +type ParsedDocument = ReturnType + +let classCache: null | Set = null +let idCache: null | Set = null + +function buildCache(container: Node | HTMLDocument) { + classCache = new Set() + idCache = new Set() + const queue = [container] + + while (queue.length) { + const node = queue.shift()! + + if (node.hasAttribute('class')) { + const classList = node.getAttribute('class').trim().split(' ') + classList.forEach((cls) => { + classCache!.add(cls) + }) + } + + if (node.hasAttribute('id')) { + const id = node.getAttribute('id').trim() + idCache.add(id) + } + + if ('children' in node) { + queue.push(...node.children.filter(child => child.type === 'tag')) + } + } +} + +/** + * Parse HTML into a mutable, serializable DOM Document. + * The DOM implementation is an htmlparser2 DOM enhanced with basic DOM mutation methods. + * @param html HTML to parse into a Document instance + */ +export function createDocument(html: string) { + const document = parseDocument(html, { decodeEntities: false }) + + extendDocument(document) + + // Extend Element.prototype with DOM manipulation methods. + extendElement(Element.prototype) + + // Beasties container is the viewport to evaluate critical CSS + let beastiesContainer: Node | HTMLDocument = document.querySelector('[data-beasties-container]') as Node + + if (!beastiesContainer) { + document.documentElement.setAttribute('data-beasties-container', '') + beastiesContainer = document.documentElement + } + + document.beastiesContainer = beastiesContainer + buildCache(beastiesContainer) + + return document +} + +/** + * Serialize a Document to an HTML String + */ +export function serializeDocument(document: HTMLDocument) { + return render(document, { decodeEntities: false }) +} + +declare module 'domhandler' { + interface Node { + nodeName: string + id: string + className: string + insertBefore: (child: ChildNode, referenceNode: ChildNode | null) => ChildNode + appendChild: (child: ChildNode) => ChildNode + removeChild: (child: ChildNode) => void + remove: () => void + textContent: string + setAttribute: (name: string, value: string) => void + removeAttribute: (name: string) => void + getAttribute: (name: string) => string + hasAttribute: (name: string) => boolean + getAttributeNode: (name: string) => undefined | { specified: true, value: string } + exists: (sel: string) => boolean + querySelector: (sel: string) => Node + querySelectorAll: (sel: string) => Node[] + // internal properties + $$external?: boolean + $$name?: string + $$reduce?: boolean + $$links?: ChildNode[] + } +} + +/** + * Methods and descriptors to mix into Element.prototype + * @private + */ +let extended = false +function extendElement(element: typeof Element.prototype) { + if (extended) { + return + } + extended = true + + Object.defineProperties(element, { + nodeName: { + get() { + return this.tagName.toUpperCase() + }, + }, + + id: { + get() { + return this.getAttribute('id') + }, + set(value) { + this.setAttribue('id', value) + }, + }, + + className: { + get() { + return this.getAttribute('class') + }, + set(value) { + this.setAttribute('class', value) + }, + }, + + insertBefore: { + value(child: ChildNode, referenceNode: ChildNode | null) { + if (!referenceNode) + return this.appendChild(child) + DomUtils.prepend(referenceNode, child) + return child + }, + }, + + appendChild: { + value(child: ChildNode) { + DomUtils.appendChild(this, child) + return child + }, + }, + + removeChild: { + value(child: ChildNode) { + DomUtils.removeElement(child) + }, + }, + + remove: { + value() { + DomUtils.removeElement(this) + }, + }, + + textContent: { + get() { + return DomUtils.getText(this) + }, + + set(text) { + this.children = [] + DomUtils.appendChild(this, new Text(text)) + }, + }, + + setAttribute: { + value(name: string, value: string) { + if (this.attribs == null) + this.attribs = {} + if (value == null) + value = '' + this.attribs[name] = value + }, + }, + + removeAttribute: { + value(name: string) { + if (this.attribs != null) { + delete this.attribs[name] + } + }, + }, + + getAttribute: { + value(name: string) { + return this.attribs != null && this.attribs[name] + }, + }, + + hasAttribute: { + value(name: string) { + return this.attribs != null && this.attribs[name] != null + }, + }, + + getAttributeNode: { + value(name: string) { + const value = this.getAttribute(name) + if (value != null) + return { specified: true, value } + }, + }, + + exists: { + value(sel: string) { + return cachedQuerySelector(sel, this) + }, + }, + + querySelector: { + value(sel: string) { + return selectOne(sel, this) + }, + }, + + querySelectorAll: { + value(sel: string) { + return selectAll(sel, this) + }, + }, + }) +} + +export interface HTMLDocument extends ParsedDocument { + nodeType: 9 + contentType: 'text/html' + nodeName: '#document' + documentElement: HTMLDocument + head: Element + body: Element + createElement: (name: string) => Element + createTextNode: (text: string) => Text + exists: (sel: string) => boolean + querySelector: (sel: string) => Node + querySelectorAll: (sel: string) => Node[] + beastiesContainer?: HTMLDocument | Node +} + +function extendDocument(document: ParsedDocument): asserts document is HTMLDocument { + Object.defineProperties(document, { + // document is just an Element in htmlparser2, giving it a nodeType of ELEMENT_NODE. + // TODO: verify if these are needed for css-select + nodeType: { + get() { + return 9 + }, + }, + + contentType: { + get() { + return 'text/html' + }, + }, + + nodeName: { + get() { + return '#document' + }, + }, + + documentElement: { + get() { + // Find the first element within the document + return (this as NodeWithChildren).children.find( + child => 'tagName' in child && String(child.tagName).toLowerCase() === 'html', + ) + }, + }, + + head: { + get() { + return this.querySelector('head') + }, + }, + + body: { + get() { + return this.querySelector('body') + }, + }, + + createElement: { + value(name: string) { + return new Element(name, {}) + }, + }, + + createTextNode: { + value(text: string) { + // there is no dedicated createTextNode equivalent exposed in htmlparser2's DOM + return new Text(text) + }, + }, + + exists: { + value(sel: string) { + return cachedQuerySelector(sel, this) + }, + }, + + querySelector: { + value(sel: string) { + return selectOne(sel, this) + }, + }, + + querySelectorAll: { + value(sel: string) { + if (sel === ':root') { + return this + } + return selectAll(sel, this) + }, + }, + }) +} + +function cachedQuerySelector(sel: string, node: Node | Node[]) { + const selectorTokens = selectorParser(sel) + for (const tokens of selectorTokens) { + // Check if the selector is a class selector + if (tokens.length === 1) { + const token = tokens[0]! + if (token.type === 'attribute' && token.name === 'class') { + return classCache!.has(token.value) + } + if (token.type === 'attribute' && token.name === 'id') { + return idCache!.has(token.value) + } + } + } + return !!selectOne(sel, node) +} diff --git a/packages/beasties/src/index.d.ts b/packages/beasties/src/index.d.ts index e93d37c..c5ee766 100644 --- a/packages/beasties/src/index.d.ts +++ b/packages/beasties/src/index.d.ts @@ -22,8 +22,8 @@ export default class Beasties { constructor(options: Options) /** * Process an HTML document to inline critical CSS from its stylesheets. - * @param {string} html String containing a full HTML document to be parsed. - * @returns {string} A modified copy of the provided HTML with critical CSS inlined. + * @param html String containing a full HTML document to be parsed. + * @returns A modified copy of the provided HTML with critical CSS inlined. */ process(html: string): Promise /** diff --git a/packages/beasties/src/index.js b/packages/beasties/src/index.ts similarity index 68% rename from packages/beasties/src/index.js rename to packages/beasties/src/index.ts index 3bb05ff..4af7da4 100644 --- a/packages/beasties/src/index.js +++ b/packages/beasties/src/index.ts @@ -14,120 +14,37 @@ * the License. */ +import type { ChildNode, Node } from 'domhandler' + +import type { HTMLDocument } from './dom' +import type { Logger, Options } from './types' + import { readFile } from 'node:fs' import path from 'node:path' -import { - applyMarkedSelectors, - markOnly, - parseStylesheet, - serializeStylesheet, - validateMediaQuery, - walkStyleRules, - walkStyleRulesWithReverseMirror, -} from './css' + +import { applyMarkedSelectors, markOnly, parseStylesheet, serializeStylesheet, validateMediaQuery, walkStyleRules, walkStyleRulesWithReverseMirror } from './css' import { createDocument, serializeDocument } from './dom' import { createLogger, isSubpath } from './util' -/** - * The mechanism to use for lazy-loading stylesheets. - * - * Note: JS indicates a strategy requiring JavaScript (falls back to `