diff --git a/package-lock.json b/package-lock.json index c862dec..1072a63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "css-tree": "^2.3.1" }, "devDependencies": { + "@types/css-tree": "^2.3.4", "microbundle": "^0.15.1", "oxlint": "^0.0.22", "uvu": "^0.5.6" @@ -1921,6 +1922,12 @@ "node": ">=10.13.0" } }, + "node_modules/@types/css-tree": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.4.tgz", + "integrity": "sha512-wdxxe7zEpOXfy5C3FmwinAIc/6p6du/wOKMGZf07JHuHHRIvLtLq8h66zi3Yn7PCyswxbp3Ujx9h+vSuMvfN/w==", + "dev": true + }, "node_modules/@types/estree": { "version": "0.0.39", "dev": true, @@ -6770,6 +6777,12 @@ "version": "0.2.0", "dev": true }, + "@types/css-tree": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.4.tgz", + "integrity": "sha512-wdxxe7zEpOXfy5C3FmwinAIc/6p6du/wOKMGZf07JHuHHRIvLtLq8h66zi3Yn7PCyswxbp3Ujx9h+vSuMvfN/w==", + "dev": true + }, "@types/estree": { "version": "0.0.39", "dev": true diff --git a/package.json b/package.json index 56b9bf1..2919dc0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "scripts": { "test": "uvu", "lint": "oxlint", - "build": "microbundle" + "build": "microbundle", + "check": "tsc" }, "keywords": [ "projectwallace", @@ -52,6 +53,7 @@ "css-tree": "^2.3.1" }, "devDependencies": { + "@types/css-tree": "^2.3.4", "microbundle": "^0.15.1", "oxlint": "^0.0.22", "uvu": "^0.5.6" diff --git a/src/atrules/atrules.js b/src/atrules/atrules.js index e95b3e2..6af7dc3 100644 --- a/src/atrules/atrules.js +++ b/src/atrules/atrules.js @@ -1,4 +1,5 @@ import { strEquals, startsWith, endsWith } from '../string-utils.js' +// @ts-expect-error CSS Tree types are incomplete import walk from 'css-tree/walker' import { Identifier, @@ -16,7 +17,9 @@ import { * @returns true if declaratioNode is the given property: value, false otherwise */ function isPropertyValue(node, property, value) { + if (node.value.type === 'Raw') return false let firstChild = node.value.children.first + if (firstChild === null) return false return strEquals(property, node.property) && firstChild.type === Identifier && strEquals(value, firstChild.name) @@ -72,6 +75,7 @@ export function isMediaBrowserhack(prelude) { if (node.type === MediaFeature) { if (value !== null && value.unit === '\\0') { returnValue = true + // @ts-expect-error TS doesn't know about CSS Tree's walker breaking return this.break } if (strEquals('-moz-images-in-menus', name) @@ -79,6 +83,7 @@ export function isMediaBrowserhack(prelude) { || strEquals('-ms-high-contrast', name) ) { returnValue = true + // @ts-expect-error TS doesn't know about CSS Tree's walker breaking return this.break } if (strEquals('min-resolution', name) @@ -86,12 +91,14 @@ export function isMediaBrowserhack(prelude) { && strEquals('dpcm', value.unit) ) { returnValue = true + // @ts-expect-error TS doesn't know about CSS Tree's walker breaking return this.break } if (strEquals('-webkit-min-device-pixel-ratio', name)) { let val = value.value if ((strEquals('0', val) || strEquals('10000', val))) { returnValue = true + // @ts-expect-error TS doesn't know about CSS Tree's walker breaking return this.break } } diff --git a/src/index.js b/src/index.js index 4958e05..49774bc 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,8 @@ +// @ts-expect-error Typing of css-tree is incomplete import parse from 'css-tree/parser' +// @ts-expect-error Typing of css-tree is incomplete import walk from 'css-tree/walker' +// @ts-expect-error Typing of specificity is incomplete import { calculate } from '@bramus/specificity/core' import { isSupportsBrowserhack, isMediaBrowserhack } from './atrules/atrules.js' import { getCombinators, getComplexity, isAccessibility, isPrefixed } from './selectors/utils.js' @@ -32,6 +35,10 @@ import { /** @typedef {[number, number, number]} Specificity */ +/** + * @param {number} part + * @param {number} total + */ function ratio(part, total) { if (total === 0) return 0 return part / total @@ -57,7 +64,7 @@ export function analyze(css, options = {}) { let start = Date.now() /** - * Recreate the authored CSS from a CSSTree node + * Recreate the authored CSS from a CSSTree node, with trimming * @param {import('css-tree').CssNode} node - Node from CSSTree AST to stringify * @returns {string} str - The stringified node */ @@ -65,8 +72,14 @@ export function analyze(css, options = {}) { return stringifyNodePlain(node).trim() } + /** + * Recreate the authored CSS from a CSSTree node, without trimming + * @param {import('css-tree').CssNode} node - Node from CSSTree AST to stringify + * @returns {string} str - The stringified node + */ function stringifyNodePlain(node) { let loc = node.loc + // @ts-expect-error we *always* have a `loc` property return css.substring(loc.start.offset, loc.end.offset) } @@ -128,9 +141,9 @@ export function analyze(css, options = {}) { let keyframeSelectors = new Collection(useLocations) let uniqueSelectors = new Set() let prefixedSelectors = new Collection(useLocations) - /** @type {Specificity} */ + /** @type {Specificity | undefined} */ let maxSpecificity - /** @type {Specificity} */ + /** @type {Specificity | undefined} */ let minSpecificity let specificityA = new AggregateCollection() let specificityB = new AggregateCollection() @@ -176,7 +189,11 @@ export function analyze(css, options = {}) { let units = new ContextCollection(useLocations) let gradients = new Collection(useLocations) - walk(ast, function (node) { + /** + * @param {import('css-tree').CssNode} node + * @returns {void} + */ + function walker(node) { switch (node.type) { case Atrule: { totalAtRules++ @@ -184,20 +201,23 @@ export function analyze(css, options = {}) { let atRuleName = node.name if (atRuleName === 'font-face') { + /** @type {Record} */ let descriptors = {} if (useLocations) { fontfaces_with_loc.p(node.loc.start.offset, node.loc) } - node.block.children.forEach(descriptor => { - // Ignore 'Raw' nodes in case of CSS syntax errors - if (descriptor.type === Declaration) { - descriptors[descriptor.property] = stringifyNode(descriptor.value) - } - }) + if (node.block !== null) { + node.block.children.forEach(descriptor => { + // Ignore 'Raw' nodes in case of CSS syntax errors + if (descriptor.type === Declaration) { + descriptors[descriptor.property] = stringifyNode(descriptor.value) + } + }) - fontfaces.push(descriptors) + fontfaces.push(descriptors) + } atRuleComplexities.push(1) break } @@ -212,7 +232,7 @@ export function analyze(css, options = {}) { if (atRuleName === 'media') { medias.p(preludeStr, loc) - if (isMediaBrowserhack(prelude)) { + if (prelude.type === 'AtrulePrelude' && isMediaBrowserhack(prelude)) { mediaBrowserhacks.p(preludeStr, loc) complexity++ } @@ -220,7 +240,7 @@ export function analyze(css, options = {}) { supports.p(preludeStr, loc) // TODO: analyze vendor prefixes in @supports // TODO: analyze complexity of @supports 'declaration' - if (isSupportsBrowserhack(prelude)) { + if (prelude.type === 'AtrulePrelude' && isSupportsBrowserhack(prelude)) { supportsBrowserhacks.p(preludeStr, loc) complexity++ } @@ -260,46 +280,57 @@ export function analyze(css, options = {}) { case Rule: { let prelude = node.prelude let block = node.block - let preludeChildren = prelude.children - let blockChildren = block.children - let numSelectors = preludeChildren ? preludeChildren.size : 0 - let numDeclarations = blockChildren ? blockChildren.size : 0 - - ruleSizes.push(numSelectors + numDeclarations) - uniqueRuleSize.p(numSelectors + numDeclarations, node.loc) - selectorsPerRule.push(numSelectors) - uniqueSelectorsPerRule.p(numSelectors, prelude.loc) - declarationsPerRule.push(numDeclarations) - uniqueDeclarationsPerRule.p(numDeclarations, block.loc) + let num_selectors = 0 + let num_declarations = 0 + + if (prelude !== null && prelude.type === 'SelectorList') { + num_selectors = prelude.children.size + } + + if (block !== null && block.type === 'Block') { + num_declarations = block.children.size + } + + ruleSizes.push(num_selectors + num_declarations) + uniqueRuleSize.p(String(num_selectors + num_declarations), node.loc) + selectorsPerRule.push(num_selectors) + uniqueSelectorsPerRule.p(String(num_selectors), prelude.loc) + declarationsPerRule.push(num_declarations) + uniqueDeclarationsPerRule.p(String(num_declarations), block.loc) totalRules++ - if (numDeclarations === 0) { + if (num_declarations === 0) { emptyRules++ } break } case Selector: { + /** @type {import('css-tree').Atrule | null} */ + // @ts-expect-error TS does not understand CSSTree's `this` mechanisms + let atrule = this.atrule let selector = stringifyNode(node) + let loc = node.loc - if (this.atrule && endsWith('keyframes', this.atrule.name)) { - keyframeSelectors.p(selector, node.loc) + if (atrule && endsWith('keyframes', atrule.name)) { + keyframeSelectors.p(selector, loc) + // @ts-expect-error TS does not understand CSSTree's `this` mechanisms return this.skip } if (isAccessibility(node)) { - a11y.p(selector, node.loc) + a11y.p(selector, loc) } let complexity = getComplexity(node) if (isPrefixed(node)) { - prefixedSelectors.p(selector, node.loc) + prefixedSelectors.p(selector, loc) } uniqueSelectors.add(selector) selectorComplexities.push(complexity) - uniqueSelectorComplexities.p(complexity, node.loc) + uniqueSelectorComplexities.p(String(complexity), loc) // #region specificity let [{ value: specificityObj }] = calculate(node) @@ -310,7 +341,7 @@ export function analyze(css, options = {}) { /** @type {Specificity} */ let specificity = [sa, sb, sc] - uniqueSpecificities.p(sa + ',' + sb + ',' + sc, node.loc) + uniqueSpecificities.p(sa + ',' + sb + ',' + sc, loc) specificityA.push(sa) specificityB.push(sb) @@ -336,7 +367,7 @@ export function analyze(css, options = {}) { // #endregion if (sa > 0) { - ids.p(selector, node.loc) + ids.p(selector, loc) } getCombinators(node, function onCombinator(combinator) { @@ -374,15 +405,16 @@ export function analyze(css, options = {}) { embedTypes.total++ embedSize += size - let loc = { + let loc = node.loc + let data_uri_loc = { /** @type {number} */ - line: node.loc.start.line, + line: loc.start.line, /** @type {number} */ - column: node.loc.start.column, + column: loc.start.column, /** @type {number} */ - offset: node.loc.start.offset, + offset: loc.start.offset, /** @type {number} */ - length: node.loc.end.offset - node.loc.start.offset, + length: loc.end.offset - loc.start.offset, } if (embedTypes.unique.has(type)) { @@ -391,7 +423,7 @@ export function analyze(css, options = {}) { item.size += size embedTypes.unique.set(type, item) if (useLocations) { - item.__unstable__uniqueWithLocations.push(loc) + item.__unstable__uniqueWithLocations.push(data_uri_loc) } } else { let item = { @@ -399,13 +431,13 @@ export function analyze(css, options = {}) { size } if (useLocations) { - item.__unstable__uniqueWithLocations = [loc] + item.__unstable__uniqueWithLocations = [data_uri_loc] } embedTypes.unique.set(type, item) } // @deprecated - embeds.p(embed, node.loc) + embeds.p(embed, loc) } break } @@ -418,27 +450,26 @@ export function analyze(css, options = {}) { let declaration = this.declaration let { property, important } = declaration let complexity = 1 + let loc = node.loc + let children = node.children if (isValuePrefixed(node)) { - vendorPrefixedValues.p(stringifyNode(node), node.loc) + vendorPrefixedValues.p(stringifyNode(node), loc) complexity++ } // i.e. `property: value !ie` if (typeof important === 'string') { - valueBrowserhacks.p(stringifyNodePlain(node) + '!' + important, node.loc) + valueBrowserhacks.p(stringifyNodePlain(node) + '!' + important, loc) complexity++ } // i.e. `property: value\9` if (isIe9Hack(node)) { - valueBrowserhacks.p(stringifyNode(node), node.loc) + valueBrowserhacks.p(stringifyNode(node), loc) complexity++ } - let children = node.children - let loc = node.loc - // TODO: should shorthands be counted towards complexity? valueComplexities.push(complexity) @@ -602,7 +633,9 @@ export function analyze(css, options = {}) { importantDeclarations++ complexity++ - if (this.atrule && endsWith('keyframes', this.atrule.name)) { + // @ts-expect-error TS does not understand CSSTree's `this` mechanisms + let atrule = this.atrule + if (atrule && endsWith('keyframes', atrule.name)) { importantsInKeyframes++ complexity++ } @@ -644,7 +677,9 @@ export function analyze(css, options = {}) { break } } - }) + } + + walk(ast, walker) let embeddedContent = embeds.c() delete embeddedContent.__unstable__uniqueWithLocations diff --git a/src/selectors/utils.js b/src/selectors/utils.js index 78d0c24..1c1c5d3 100644 --- a/src/selectors/utils.js +++ b/src/selectors/utils.js @@ -1,3 +1,4 @@ +// @ts-expect-error CSS Tree types are incomplete import walk from 'css-tree/walker' import { startsWith, strEquals } from '../string-utils.js' import { hasVendorPrefix } from '../vendor-prefix.js' @@ -28,6 +29,7 @@ function analyzeList(selectorListAst, cb) { return childSelectors } +/** @param {string} name */ function isPseudoFunction(name) { return ( strEquals(name, 'not') @@ -102,7 +104,7 @@ export function isPrefixed(selector) { /** * Get the Complexity for the AST of a Selector Node * @param {import('css-tree').Selector} selector - AST Node for a Selector - * @return {[number, boolean]} - The numeric complexity of the Selector and whether it's prefixed or not + * @return {number} - The numeric complexity of the Selector and whether it's prefixed or not */ export function getComplexity(selector) { let complexity = 0 diff --git a/src/values/animations.js b/src/values/animations.js index d1e3b8e..08014fa 100644 --- a/src/values/animations.js +++ b/src/values/animations.js @@ -23,7 +23,9 @@ const TIMING_FUNCTION_VALUES = new KeywordSet([ export function analyzeAnimation(children, stringifyNode) { let durationFound = false + /** @type {string[]} */ let durations = [] + /** @type {string[]} */ let timingFunctions = [] children.forEach(child => { diff --git a/src/values/values.js b/src/values/values.js index ba9e1f5..a7b9c50 100644 --- a/src/values/values.js +++ b/src/values/values.js @@ -16,11 +16,10 @@ const keywords = new KeywordSet([ */ export function isValueKeyword(node) { let children = node.children - let size = children.size - if (!children) return false - if (size > 1 || size === 0) return false let firstChild = children.first + if (firstChild === null) return false + return firstChild.type === Identifier && keywords.has(firstChild.name) } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3675c5f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Base options: + "skipLibCheck": true, + "target": "es2022", + "verbatimModuleSyntax": true, + "allowJs": true, + "checkJs": true, + "moduleDetection": "force", + + // Strictness + "strict": true, + "noUncheckedIndexedAccess": true, + + // Type checking, not transpiling + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "noEmit": true, + + // Code runs in the DOM + "lib": ["ES2022", "DOM", "DOM.Iterable"], + }, + "include": [ + "src" + ], + "exclude": ["node_modules", "**/*.test.js"] +} \ No newline at end of file