diff options
| author | littleblack111 <littleblack11111@gmail.com> | 2023-10-14 15:50:21 +0800 |
|---|---|---|
| committer | littleblack111 <littleblack11111@gmail.com> | 2023-10-14 15:50:21 +0800 |
| commit | 0379526e20e1ff212df23e6326741aa9c06df204 (patch) | |
| tree | 340143b0c4dc4280c88ba1591d137b53477005b3 /node_modules/doiuse/lib | |
| parent | 7aba14fb7c970c6e6af82c805db2aef6d70bfe2b (diff) | |
added doiuse plugin for postcss
Diffstat (limited to 'node_modules/doiuse/lib')
| -rw-r--r-- | node_modules/doiuse/lib/BrowserSelection.js | 181 | ||||
| -rw-r--r-- | node_modules/doiuse/lib/Detector.js | 157 | ||||
| -rw-r--r-- | node_modules/doiuse/lib/DoIUse.js | 130 | ||||
| -rw-r--r-- | node_modules/doiuse/lib/stream/CssUsageDuplex.js | 36 | ||||
| -rw-r--r-- | node_modules/doiuse/lib/stream/RuleUsageTransform.js | 80 | ||||
| -rw-r--r-- | node_modules/doiuse/lib/stream/SourceMapTransform.js | 74 |
6 files changed, 658 insertions, 0 deletions
diff --git a/node_modules/doiuse/lib/BrowserSelection.js b/node_modules/doiuse/lib/BrowserSelection.js new file mode 100644 index 00000000..17467548 --- /dev/null +++ b/node_modules/doiuse/lib/BrowserSelection.js @@ -0,0 +1,181 @@ +import browserslist from 'browserslist'; +import * as caniuse from 'caniuse-lite'; + +import FEATURES from '../data/features.js'; +import { formatBrowserName } from '../utils/util.js'; + +/** + * @template T + * @typedef {{missing: T, partial: T}} Filter + */ + +/** + * @typedef {Object} MissingSupportResultStats + * @prop {string} title + * @prop {caniuse.Feature} caniuseData + * @prop {string} [missing] + * @prop {Record<string, Record<string, string>>} [missingData] + * @prop {string} [partial] + * @prop {Record<string, Record<string, string>>} [partialData] + */ + +/** + * @typedef {Partial<Record<keyof FEATURES, MissingSupportResultStats>>} BrowserSupportStats + */ + +/** + * @typedef {Object} MissingSupportResult + * @prop {string[][]} browsers + * @prop {Partial<Record<keyof FEATURES, MissingSupportResultStats>>} features + */ + +export default class BrowserSelection { + #list; + + /** + * @param {string | string[]} [query] + * @param {string | false} [from] + */ + constructor(query, from) { + this.browsersRequest = query; + const options = from ? { path: from } : {}; + this.#list = browserslist(this.browsersRequest, options).map((browser) => browser.split(' ')); + } + + /** + * @param {string} browser + * @param {string} version + * @return {string[]|undefined} + */ + test(browser, version) { + const versions = version.split('-'); + + if (versions.length === 1) { + versions.push(versions[0]); + } + + return this.#list.find(([b, v]) => b === browser && v >= versions[0] && v <= versions[1]); + } + + list() { + return [...this.#list]; + } + + /** + * @param {Record<string, Record<string, string>>} browserStats + * @return {string} + */ + static lackingBrowsers(browserStats) { + const result = []; + for (const browser of Object.keys(browserStats)) { + result.push(formatBrowserName(browser, Object.keys(browserStats[browser]))); + } + return result.join(', '); + } + + /** + * @param {import('caniuse-lite').StatsByAgentID} stats + * @return {Filter<Record<string,Record<string, string>>>} + */ + filterStats(stats) { + /** @type {Filter<Record<string,Record<string, string>>>} */ + const result = { missing: {}, partial: {} }; + for (const [browser, versions] of Object.entries(stats)) { + /** @type {Filter<Record<string,string>>} */ + const feature = { missing: {}, partial: {} }; + for (const [version, support] of Object.entries(versions)) { + const selected = this.test(browser, version); + + // check if browser is NOT fully (i.e., don't have 'y' in their stats) supported + if (!selected) continue; + if ((/(^|\s)y($|\s)/.test(support))) continue; + // when it's not partially supported ('a'), it's missing + const type = (/(^|\s)a($|\s)/.test(support) ? 'partial' : 'missing'); + + if (!feature[type]) { + feature[type] = {}; + } + + feature[type][selected[1]] = support; + } + if (Object.keys(feature.missing).length > 0) { + result.missing[browser] = feature.missing; + } + + if (Object.keys(feature.partial).length > 0) { + result.partial[browser] = feature.partial; + } + } + return result; + } + + /** + * Get data on CSS features not supported by the given autoprefixer-like + * browser selection. + * @return {BrowserSupportStats} `features` is an array of: + * ``` + * { + * 'feature-name': { + * title: 'Title of feature' + * missing: "IE (8), Chrome (31)" + * missingData: { + * // map of browser -> version -> (lack of)support code + * ie: { '8': 'n' }, + * chrome: { '31': 'n' } + * } + * partialData: { + * // map of browser -> version -> (partial)support code + * ie: { '7': 'a' }, + * ff: { '29': 'a #1' } + * } + * caniuseData: { + * // caniuse-db json data for this feature + * } + * }, + * 'feature-name-2': {} // etc. + * } + * ``` + * + * `feature-name` is a caniuse-db slug. + */ + compileBrowserSupport() { + /** @type {Partial<Record<keyof FEATURES, MissingSupportResultStats>>} */ + const result = {}; + + for (const key of Object.keys(FEATURES)) { + const feature = /** @type {keyof FEATURES} */ (key); + const packedFeature = caniuse.features[feature]; + const featureData = caniuse.feature(packedFeature); + const lackData = this.filterStats(featureData.stats); + const missingData = lackData.missing; + const partialData = lackData.partial; + // browsers with missing or partial support for this feature + const missing = BrowserSelection.lackingBrowsers(missingData); + const partial = BrowserSelection.lackingBrowsers(partialData); + + if (missing.length > 0 || partial.length > 0) { + result[feature] = { + title: featureData.title, + caniuseData: featureData, + ...(missing.length > 0 ? { missingData, missing } : null), + ...(partial.length > 0 ? { partialData, partial } : null), + }; + } + } + + return result; + } + + /** + * @see BrowserSelection.compileBrowserSupport + * @param {ConstructorParameters<typeof this>} constructorParameters + * @return {MissingSupportResult} `{browsers, features}` + */ + static missingSupport(...constructorParameters) { + const selection = new BrowserSelection(...constructorParameters); + return { + browsers: selection.list(), + features: selection.compileBrowserSupport(), + }; + } +} diff --git a/node_modules/doiuse/lib/Detector.js b/node_modules/doiuse/lib/Detector.js new file mode 100644 index 00000000..2858bca9 --- /dev/null +++ b/node_modules/doiuse/lib/Detector.js @@ -0,0 +1,157 @@ +import FEATURES from '../data/features.js'; +import { performFeatureCheck } from '../utils/util.js'; + +/** + * @typedef DetectorCallbackArgument + * @prop {!import('postcss').ChildNode} usage + * @prop {keyof FEATURES} feature + * @prop {(keyof FEATURES & string)[]} ignore + */ + +/** + * @callback DetectorCallback + * @param {DetectorCallbackArgument} result + * @return {any} + */ + +const PLUGIN_OPTION_COMMENT = 'doiuse-'; +const DISABLE_FEATURE_COMMENT = `${PLUGIN_OPTION_COMMENT}disable`; +const ENABLE_FEATURE_COMMENT = `${PLUGIN_OPTION_COMMENT}enable`; + +/** + * Strip the contents of url literals so they aren't matched + * by our naive substring matching. + * @param {string} input + * @return {string} + */ +function stripUrls(input) { + return input.replaceAll(/url\([^)]*\)/g, 'url()'); +} + +/** + * Detect the use of any of a given list of CSS features. + * ``` + * var detector = new Detector(featureList) + * detector.process(css, cb) + * ``` + * + * `featureList`: an array of feature slugs (see caniuse-db) + * `cb`: a callback that gets called for each usage of one of the given features, + * called with an argument like: + * ``` + * { + * usage: {} // postcss node where usage was found + * feature: {} // caniuse-db feature slug + * ignore: {} // caniuse-db feature to ignore in current file + * } + * ``` + */ +export default class Detector { + /** + * @param {(keyof FEATURES & string)[]} featureList an array of feature slugs (see caniuse-db) + */ + constructor(featureList) { + /** @type {Partial<FEATURES>} */ + this.features = {}; + for (const feature of featureList) { + if (FEATURES[feature]) { + this.features[feature] = FEATURES[feature]; + } + } + /** @type {(keyof FEATURES & string)[]} */ + this.ignore = []; + } + + /** + * @param {import('postcss').Comment} comment + * @return {void} + */ + comment(comment) { + const text = comment.text.toLowerCase(); + + if (!text.startsWith(PLUGIN_OPTION_COMMENT)) return; + const option = text.split(' ', 1)[0]; + const value = text.replace(option, '').trim(); + + switch (option) { + case DISABLE_FEATURE_COMMENT: { + if (value === '') { + // @ts-expect-error Skip cast + this.ignore = Object.keys(this.features); + } else { + for (const feat of value.split(',')) { + /** @type {any} */ + const f = feat.trim(); + if (!this.ignore.includes(f)) { + this.ignore.push(f); + } + } + } + break; + } + case ENABLE_FEATURE_COMMENT: { + if (value === '') { + this.ignore = []; + } else { + const without = new Set(value.split(',').map((feat) => feat.trim())); + this.ignore = this.ignore.filter((index) => !without.has(index)); + } + break; + } + default: + } + } + + /** + * @param {import('postcss').Container} node + * @param {DetectorCallback} callback + * @return {void} + */ + node(node, callback) { + node.each((child) => { + if (child.type === 'comment') { + this.comment(child); + return; + } + + for (const [feat] of Object.entries(this.features).filter(([, featValue]) => { + if (!featValue) return false; + if (typeof featValue === 'function') { + return featValue(child); + } + if (Array.isArray(featValue)) { + return featValue.some((function_) => function_(child)); + } + if (child.type !== 'decl') { + return false; + } + + return Object.entries(featValue).some(([property, value]) => { + if (property !== '' && property !== child.prop) return false; + if (value === true) return true; + if (value === false) return false; + return performFeatureCheck(value, stripUrls(child.value)); + }); + })) { + const feature = /** @type {keyof FEATURES} */ (feat); + callback({ usage: child, feature, ignore: this.ignore }); + } + if (child.type !== 'decl') { + this.node(child, callback); + } + }); + } + + /** + * @param {import('postcss').Root} node + * @param {DetectorCallback} callback + * @return {void} + */ + process(node, callback) { + // Reset ignoring rules specified by inline comments per each file + this.ignore = []; + + // Recursively walk nodes in file + this.node(node, callback); + } +} diff --git a/node_modules/doiuse/lib/DoIUse.js b/node_modules/doiuse/lib/DoIUse.js new file mode 100644 index 00000000..78db53c8 --- /dev/null +++ b/node_modules/doiuse/lib/DoIUse.js @@ -0,0 +1,130 @@ +import multimatch from 'multimatch'; + +import BrowserSelection from './BrowserSelection.js'; +import Detector from './Detector.js'; + +/** @typedef {import('../data/features.js').FeatureKeys} FeatureKeys */ + +/** + * @typedef {Object} OnFeatureUsageArguments + * @prop {FeatureKeys} feature + * @prop {import('./BrowserSelection.js').MissingSupportResultStats|undefined} featureData + * @prop {import('postcss').Node} usage + * @prop {string} message + */ + +/** + * @callback OnFeatureUsage + * @param {OnFeatureUsageArguments} result + * @return {any} + */ + +/** + * @typedef {Object} DoIUseOptions + * @prop {ConstructorParameters<typeof BrowserSelection>[0]} [browsers] + * @prop {FeatureKeys[]} [ignore] + * @prop {OnFeatureUsage} [onFeatureUsage] + * @prop {string[]} [ignoreFiles] + */ + +export default class DoIUse { + static default = null; + + /** + * @param {DoIUseOptions} [optionsOrBrowserQuery] + */ + constructor(optionsOrBrowserQuery) { + const options = (typeof optionsOrBrowserQuery === 'string') + ? { browsers: optionsOrBrowserQuery } + : { ...optionsOrBrowserQuery }; + this.browserQuery = options.browsers; + this.onFeatureUsage = options.onFeatureUsage; + this.ignoreOptions = options.ignore; + this.ignoreFiles = options.ignoreFiles; + this.info = this.info.bind(this); + this.postcss = this.postcss.bind(this); + } + + /** + * @param {Object} [options] + * @param {ConstructorParameters<typeof BrowserSelection>[1]} [options.from] + */ + info(options = {}) { + const { browsers, features } = BrowserSelection.missingSupport(this.browserQuery, options.from); + + return { + browsers, + features, + }; + } + + /** @type {import('postcss').TransformCallback} */ + postcss(css, result) { + let from; + if (css.source && css.source.input) { + from = css.source.input.file; + } + const { features } = BrowserSelection.missingSupport(this.browserQuery, from); + // @ts-expect-error Needs cast + const detector = new Detector(Object.keys(features)); + + return detector.process(css, ({ feature, usage, ignore }) => { + if (ignore && ignore.includes(feature)) { + return; + } + + if (this.ignoreOptions && this.ignoreOptions.includes(feature)) { + return; + } + + if (!usage.source) { + throw new Error('No source?'); + } + if (this.ignoreFiles && multimatch(usage.source.input.from, this.ignoreFiles).length > 0) { + return; + } + + const data = features[feature]; + if (!data) { + throw new Error('No feature data?'); + } + const messages = []; + if (data.missing) { + messages.push(`not supported by: ${data.missing}`); + } + if (data.partial) { + messages.push(`only partially supported by: ${data.partial}`); + } + + let message = `${data.title} ${messages.join(' and ')} (${feature})`; + + result.warn(message, { node: usage, plugin: 'doiuse' }); + + if (this.onFeatureUsage) { + if (!usage.source) { + throw new Error('No usage source?'); + } + const { start, input } = usage.source; + if (!start) { + throw new Error('No usage source start?'); + } + + const map = css.source && css.source.input.map; + const mappedStart = map && map.consumer().originalPositionFor(start); + + const file = (mappedStart && mappedStart.source) || input.file || input.from; + const line = (mappedStart && mappedStart.line) || start.line; + const column = (mappedStart && mappedStart.column) || start.column; + + message = `${file}:${line}:${column}: ${message}`; + + this.onFeatureUsage({ + feature, + featureData: features[feature], + usage, + message, + }); + } + }); + } +} diff --git a/node_modules/doiuse/lib/stream/CssUsageDuplex.js b/node_modules/doiuse/lib/stream/CssUsageDuplex.js new file mode 100644 index 00000000..063ed1dc --- /dev/null +++ b/node_modules/doiuse/lib/stream/CssUsageDuplex.js @@ -0,0 +1,36 @@ +/* eslint-disable no-underscore-dangle */ + +import { pipeline } from 'node:stream'; + +import Tokenize from 'css-tokenize'; +import Duplexify from 'duplexify'; + +import RuleUsageTransform from './RuleUsageTransform.js'; +import SourceMapTransform from './SourceMapTransform.js'; + +/** @typedef {import('../../data/features.js').FeatureKeys} FeatureKeys */ + +export default class CssUsageDuplex extends Duplexify { + /** + * @param {Object} options + * @param {string} options.browsers + * @param {FeatureKeys[]} [options.ignore] + * @param {boolean} [options.skipErrors] + * @param {string} [filename] Filename for outputting source code locations. + */ + constructor(options, filename) { + const streams = [ + new Tokenize(), + new SourceMapTransform(), + new RuleUsageTransform(options, filename), + ]; + pipeline( + // @ts-expect-error Bad typings + ...streams, + () => { + // noop (Error handled by Duplexify) + }, + ); + super(streams[0], streams.at(-1), { objectMode: true }); + } +} diff --git a/node_modules/doiuse/lib/stream/RuleUsageTransform.js b/node_modules/doiuse/lib/stream/RuleUsageTransform.js new file mode 100644 index 00000000..a2a79d4d --- /dev/null +++ b/node_modules/doiuse/lib/stream/RuleUsageTransform.js @@ -0,0 +1,80 @@ +/* eslint-disable no-underscore-dangle */ +import { Transform } from 'node:stream'; + +import postcss from 'postcss'; +import sourcemap from 'source-map'; + +import DoIUse from '../DoIUse.js'; + +/** @typedef {import('../../data/features.js').FeatureKeys} FeatureKeys */ +/** @typedef {import('./SourceMapTransform.js').SourceMapTransformation} SourceMapTransformation */ + +export default class RuleUsageTransform extends Transform { + /** @type {import("postcss").Processor} */ + #processor; + + #skipErrors; + + #filename; + + /** + * @param {Object} options + * @param {string} options.browsers + * @param {FeatureKeys[]} [options.ignore] + * @param {boolean} [options.skipErrors] + * @param {string} [filename] Filename for outputting source code locations. + */ + constructor(options, filename = '<streaming css input>') { + super({ + objectMode: true, + }); + this.#processor = postcss([new DoIUse({ + browsers: options.browsers, + ignore: options.ignore, + onFeatureUsage: (usage) => { + this.push(usage); + }, + })]); + this.#filename = filename; + this.#skipErrors = options.skipErrors; + } + + /** + * @param {SourceMapTransformation} rule + * @param {any} encoding + * @param {Function} callback + */ + async _transform(rule, encoding, callback) { + let caughtError; + try { + const mapper = new sourcemap.SourceMapGenerator(); + const lines = rule.content.split('\n'); + let oline = rule.line; + let ocol = rule.column; + for (const [lineNumber, content] of lines.entries()) { + mapper.addMapping({ + generated: { line: lineNumber + 1, column: 1 }, + original: { line: oline, column: ocol }, + source: this.#filename, + }); + mapper.addMapping({ + generated: { line: lineNumber + 1, column: content.length }, + original: { line: oline, column: ocol + content.length }, + source: this.#filename, + }); + oline += 1; + ocol = 1; + } + await this.#processor.process(rule.content, { + from: this.#filename, map: { prev: mapper.toString() }, + }); + } catch (error) { + if (this.#skipErrors) { + this.emit('warning', error); + } else { + caughtError = error; + } + } + callback(caughtError); + } +} diff --git a/node_modules/doiuse/lib/stream/SourceMapTransform.js b/node_modules/doiuse/lib/stream/SourceMapTransform.js new file mode 100644 index 00000000..6295b1d3 --- /dev/null +++ b/node_modules/doiuse/lib/stream/SourceMapTransform.js @@ -0,0 +1,74 @@ +/* eslint-disable no-underscore-dangle */ +import { Transform } from 'node:stream'; + +const NEW_LINE_CHAR = '\n'.codePointAt(0); + +/** + * @typedef {Object} SourceMapTransformation + * @prop {number} line + * @prop {number} column + * @prop {string} content + */ + +export default class SourceMapTransform extends Transform { + constructor() { + super({ objectMode: true }); + } + + /** + * @type {?{location:[number,number], buffers: Buffer[]}} + * buffer for the current incoming rule. + */ + #current = null; + + /** track depth to handle rules nested in at-rules. */ + #depth = 0; + + /** track this and pass it downstream for source mapping. */ + #line = 1; + + /** track this and pass it downstream for source mapping. */ + #column = 1; + + /** + * @param {[string,Buffer]} chunk + * @param {any} encoding + * @param {Function} callback + */ + _transform([type, buf], encoding, callback) { + if ((type === 'rule_start' || type === 'atrule_start')) { + this.#depth += 1; + } + if (this.#depth > 0 && !this.#current) { + this.#current = { location: [this.#line, this.#column], buffers: [] }; + } + if (type === 'rule_end' || type === 'atrule_end') { + this.#depth -= 1; + } + + if (this.#current) { + this.#current.buffers.push(buf); + if (this.#depth === 0) { + // Push rule + this.push({ + line: this.#current.location[0], + column: this.#current.location[1], + content: Buffer.concat(this.#current.buffers).toString(), + }); + this.#current = null; + } + } + + // Update position + for (const char of buf) { + if (char === NEW_LINE_CHAR) { + this.#line += 1; + this.#column = 1; + } else { + this.#column += 1; + } + } + + callback(); + } +} |
