From f6eec0484629f842b8eb36265c3eff590a0e8118 Mon Sep 17 00:00:00 2001 From: Robbie Date: Fri, 26 May 2023 21:53:19 -0600 Subject: [PATCH] check partially support with MDN --- data/features/outline.js | 1 + lib/DoIUse.js | 16 +++- lib/mdn/checkPartialSupport.js | 154 ++++++++++++++++++++++++++++++++ lib/mdn/convertMdnBrowser.js | 97 ++++++++++++++++++++ package-lock.json | 11 +++ package.json | 1 + scripts/update-caniuse.sh | 2 +- test/cases/features/outline.css | 3 +- test/cli.js | 4 +- test/mdn/checkPartialSupport.js | 68 ++++++++++++++ test/mdn/generic.js | 70 +++++++++++++++ tsconfig.json | 5 +- 12 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 lib/mdn/checkPartialSupport.js create mode 100644 lib/mdn/convertMdnBrowser.js create mode 100644 test/mdn/checkPartialSupport.js create mode 100644 test/mdn/generic.js diff --git a/data/features/outline.js b/data/features/outline.js index 17e6206..049a2e8 100644 --- a/data/features/outline.js +++ b/data/features/outline.js @@ -3,4 +3,5 @@ export default { 'outline-style': true, 'outline-width': true, 'outline-color': true, + 'outline-offset': true, }; diff --git a/lib/DoIUse.js b/lib/DoIUse.js index 21945b7..3ed8535 100644 --- a/lib/DoIUse.js +++ b/lib/DoIUse.js @@ -2,6 +2,7 @@ import multimatch from 'multimatch'; import BrowserSelection from './BrowserSelection.js'; import Detector from './Detector.js'; +import { checkPartialSupport } from './mdn/checkPartialSupport.js'; /** @typedef {import('../data/features.js').FeatureKeys} FeatureKeys */ @@ -86,9 +87,22 @@ export default class DoIUse { messages.push(`not supported by: ${data.missing}`); } if (data.partial) { - messages.push(`only partially supported by: ${data.partial}`); + const partialSupportDetails = checkPartialSupport( + usage, + this.browserQuery, + ); + + if (partialSupportDetails.partialSupportMessage) { + messages.push(partialSupportDetails.partialSupportMessage); + } else if (!partialSupportDetails.ignorePartialSupport) { + messages.push(`only partially supported by: ${data.partial}`); + } } + // because messages can be suppressed by checkPartialSupport, we need to make sure + // we still have messages before we warn + if (messages.length === 0) return; + let message = `${data.title} ${messages.join(' and ')} (${feature})`; result.warn(message, { node: usage, plugin: 'doiuse' }); diff --git a/lib/mdn/checkPartialSupport.js b/lib/mdn/checkPartialSupport.js new file mode 100644 index 0000000..538533f --- /dev/null +++ b/lib/mdn/checkPartialSupport.js @@ -0,0 +1,154 @@ +import { createRequire } from 'module'; + +import browserslist from 'browserslist'; + +import { formatBrowserName } from '../../utils/util.js'; + +import { convertMdnSupportToBrowsers } from './convertMdnBrowser.js'; + +const require = createRequire(import.meta.url); +/** @type {import('@mdn/browser-compat-data').CompatData} */ +const bcd = require('@mdn/browser-compat-data'); + +/* browser compat data is littered with dangleys */ +/* eslint-disable no-underscore-dangle */ + +/** + * @typedef {Object} PartialSupport + * @prop {boolean} ignorePartialSupport if true, the feature is fully supported in this use case and no warning should be shown + * @prop {string} [partialSupportMessage] if the feature is not fully supported, a better warning message to be provided to the user + */ + +/** + * checks the MDN compatibility data for partial support of a CSS property + * @param {string} propertyName the name of the property, e.g. 'display' + * @param {string} propertyValue the value of the property, e.g. 'block' + * @return {import('./convertMdnBrowser.js').MdnSupportData | false} information about the support of the property (or false if no information is available) + */ +export function checkProperty(propertyName, propertyValue) { + const support = bcd.css.properties[propertyName]; + if (!support) return false; + let needsManualChecking = false; + + // here's how we extract value names from the MDN data: + // if the compat entry has no description, the support key is the css value + // if the compat entry does have a description, extract css value names from tags + // if there's a description but no code tags, support needs to be checked manually (which is not implemented yet) so report as normal + + const compatEntries = Object.entries(support).map(([key, value]) => { + if (!('__compat' in value)) return undefined; // ignore keys without compat data + if (key === '__compat') return undefined; // ignore the base __compat key + const hasDescription = value.__compat?.description; + + if (hasDescription) { + const valueNames = value.__compat.description.match(/(.*?)<\/code>/g)?.map((match) => match.replace(/<\/?code>/g, '')) ?? []; + + if (valueNames.length === 0) { + needsManualChecking = true; + return false; + } // no code tags, needs manual checking + + return { + values: valueNames, + supportData: value.__compat.support, + }; + } + + return { + values: [key], + supportData: value.__compat.support, + }; + }); + + const applicableCompatEntry = compatEntries.find((entry) => { + if (entry === undefined) return false; + if (entry === false) return false; + if (entry.values.includes(propertyValue)) return true; + return false; + }); + + if (applicableCompatEntry) { + return convertMdnSupportToBrowsers(applicableCompatEntry.supportData); + } + + // if there's no applicable entry, fall back on the default __compat entry and ignore the specific value + if (!applicableCompatEntry && !needsManualChecking) { + const defaultCompatEntry = support.__compat; + if (!defaultCompatEntry) return false; + return convertMdnSupportToBrowsers(defaultCompatEntry.support, true); + } + + return false; +} + +/** + * checks a browser against the MDN compatibility data + * @param {string} browser the name of the browser, e.g. 'chrome 89' + * @param {import('./convertMdnBrowser.js').MdnSupportData} supportData the support data for the property + * @return {boolean} true if the browser supports the property, false if not + */ +function checkBrowser(browser, supportData) { + const browserName = browser.split(' ')[0]; + const browserSupport = supportData[browserName]; + + if (!browserSupport) return false; + + const { versionAdded, versionRemoved = Number.POSITIVE_INFINITY } = browserSupport; + + const version = Number.parseFloat(browser.split(' ')[1]); + + if (version < versionAdded) return false; + if (version > versionRemoved) return false; + + return true; +} + +/** + * checks MDN for more detailed information about a partially supported feature + * in order to provide a more detailed warning message to the user + * @param {import('postcss').ChildNode} node the node to check + * @param {readonly string[] | string} browsers the browserslist query for browsers to support + * @return {PartialSupport} + */ +export function checkPartialSupport(node, browsers) { + const browsersToCheck = browserslist(browsers); + if (node.type === 'decl') { + const supportData = checkProperty(node.prop, node.value); + if (!supportData) return { ignorePartialSupport: false }; + const unsupportedBrowsers = browsersToCheck.filter((browser) => !checkBrowser(browser, supportData)); + + if (unsupportedBrowsers.length === 0) { + return { + ignorePartialSupport: true, + }; + } + + /** @type {Record} */ + const browserVersions = {}; + for (const browser of unsupportedBrowsers) { + const [browserName, browserVersion] = browser.split(' '); + if (!browserVersions[browserName]) browserVersions[browserName] = []; + browserVersions[browserName].push(browserVersion); + } + + const formattedUnsupportedBrowsers = Object.entries(browserVersions) + .map(([browserName, versions]) => formatBrowserName(browserName, versions)); + + // check if the value matters + if (Object.values(supportData).some((data) => data.ignoreValue)) { + return { + ignorePartialSupport: false, + partialSupportMessage: `${node.prop} is not supported by: ${formattedUnsupportedBrowsers.join(', ')}`, + }; + } + + return { + ignorePartialSupport: false, + partialSupportMessage: `value of ${node.value} is not supported by: ${formattedUnsupportedBrowsers.join(', ')}`, + }; + } + + return { + ignorePartialSupport: false, + }; +} diff --git a/lib/mdn/convertMdnBrowser.js b/lib/mdn/convertMdnBrowser.js new file mode 100644 index 0000000..f072973 --- /dev/null +++ b/lib/mdn/convertMdnBrowser.js @@ -0,0 +1,97 @@ +/** + * @typedef {Record} MdnSupportData + */ + +/** + * converts browser names from MDN to caniuse + * @param {string} browser + */ +function convertMdnBrowser(browser) { + if (browser === 'samsunginternet_android') { + return 'samsung'; + } if (browser === 'safari_ios') { + return 'ios_saf'; + } if (browser === 'opera_android') { + return 'op_mob'; + } if (browser === 'chrome_android') { + return 'and_chr'; + } if (browser === 'firefox_android') { + return 'and_ff'; + } if (browser === 'webview_android') { + return 'android'; + } + + return browser; +} + +/** + * + * @param {string | boolean} version the version string from MDN + * @return {number} as a number + */ +function mdnVersionToNumber(version) { + // sometimes the version is 'true', which means support is old + if (version === true) { + return 0; + } + // sometimes the version is 'false', which means support is not yet implemented + if (version === false) { + return Number.POSITIVE_INFINITY; + } + + return Number.parseFloat(version); +} + +/** + * + * convert raw MDN data to a format the uses caniuse browser names and real numbers + * @param {import("@mdn/browser-compat-data").SupportBlock} supportData + * @param {boolean} ignoreValue is this warning about a specific value, or the property in general? + * @return {MdnSupportData} browsers + */ +export function convertMdnSupportToBrowsers(supportData, ignoreValue = false) { + /** + * @type {MdnSupportData} + */ + const browsers = {}; + + /** + * + * @param {string} browser + * @param {import("@mdn/browser-compat-data").SimpleSupportStatement} data + */ + const addToBrowsers = (browser, data) => { + // TODO handle prefixes and alternative names + if (data.alternative_name) return; + if (data.prefix) return; + if (data.partial_implementation) return; + if (data.flags) return; + + if (data.version_added) { + browsers[browser] = { + versionAdded: mdnVersionToNumber(data.version_added), + ignoreValue, + }; + } + + if (data.version_removed) { + browsers[browser].versionRemoved = mdnVersionToNumber(data.version_removed); + } + }; + + Object.entries(supportData).forEach(([browser, data]) => { + const caniuseBrowser = convertMdnBrowser(browser); + + if (Array.isArray(data)) { + data.forEach((d) => { + addToBrowsers(caniuseBrowser, d); + }); + } else { addToBrowsers(caniuseBrowser, data); } + }); + + return browsers; +} diff --git a/package-lock.json b/package-lock.json index aa090de..bba1dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "6.0.0", "license": "MIT", "dependencies": { + "@mdn/browser-compat-data": "^5.2.59", "browserslist": "^4.21.5", "caniuse-lite": "^1.0.30001487", "css-tokenize": "^1.0.1", @@ -766,6 +767,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@mdn/browser-compat-data": { + "version": "5.2.59", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.2.59.tgz", + "integrity": "sha512-f7VxPGqVLCSe+0KdDas33JyGY+eHJTfBkgAsoiM1BSv7e238FYJMTFwfGLhjnhOwc33wUwsnjiHSfXukwzbqog==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8821,6 +8827,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@mdn/browser-compat-data": { + "version": "5.2.59", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.2.59.tgz", + "integrity": "sha512-f7VxPGqVLCSe+0KdDas33JyGY+eHJTfBkgAsoiM1BSv7e238FYJMTFwfGLhjnhOwc33wUwsnjiHSfXukwzbqog==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index f6e5ef8..7922ff8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "node": ">=16" }, "dependencies": { + "@mdn/browser-compat-data": "^5.2.59", "browserslist": "^4.21.5", "caniuse-lite": "^1.0.30001487", "css-tokenize": "^1.0.1", diff --git a/scripts/update-caniuse.sh b/scripts/update-caniuse.sh index 83cc1f3..30c4f96 100755 --- a/scripts/update-caniuse.sh +++ b/scripts/update-caniuse.sh @@ -1,2 +1,2 @@ -npm i caniuse-lite +npm i caniuse-lite @mdn/browser-compat-data npm i caniuse-db -D \ No newline at end of file diff --git a/test/cases/features/outline.css b/test/cases/features/outline.css index 793fd05..e5f3e9f 100644 --- a/test/cases/features/outline.css +++ b/test/cases/features/outline.css @@ -10,9 +10,10 @@ See: https://caniuse.com/outline /* expect: -outline: 1 +outline: 2 */ .test { outline: 1px solid red; + outline-offset: 1px; } diff --git a/test/cli.js b/test/cli.js index 4a3971c..53efaf8 100644 --- a/test/cli.js +++ b/test/cli.js @@ -92,8 +92,8 @@ test('--list-only should work', (t) => { test('-c config file should work as input parameters', (t) => { const configFile = joinPath(selfPath, './fixtures/doiuse.config.json'); - const overflowWrapCssFile = joinPath(selfPath, './cases/generic/overflow-wrap.css'); - const expectedOverflowWrapConfig = ':7:1: CSS3 Overflow-wrap only partially supported by: IE (11) (wordwrap)\n'; + const overflowWrapCssFile = joinPath(selfPath, './cases/generic/resize.css'); + const expectedOverflowWrapConfig = ':7:1: CSS resize property not supported by: IE (11) (css-resize)\n'; cpExec(`${commands.doiuse}-c ${configFile} ${overflowWrapCssFile}`, (error, stdout) => { t.equal(stdout, expectedOverflowWrapConfig.replace(//g, overflowWrapCssFile)); diff --git a/test/mdn/checkPartialSupport.js b/test/mdn/checkPartialSupport.js new file mode 100644 index 0000000..4f503f2 --- /dev/null +++ b/test/mdn/checkPartialSupport.js @@ -0,0 +1,68 @@ +import postcss from 'postcss'; +import { test } from 'tap'; + +import DoIUse from '../../lib/DoIUse.js'; + +test('partial support flags unsupported key: value pairs', async (t) => { + const css = '.test {appearance: auto; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['chrome 81'], + onFeatureUsage: (usageinfo) => { + const messageWithoutFile = usageinfo.message.replace(/<.*>/, ''); + t.equal(messageWithoutFile, ':1:8: CSS Appearance value of auto is not supported by: Chrome (81) (css-appearance)'); + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was called + t.equal(featureCount, 1); +}); + +test('partial support does not flag supported key: value pairs', async (t) => { + const css = '.test { appearance: none; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['chrome 81'], + onFeatureUsage: () => { + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was called + t.equal(featureCount, 0); +}); + +test('partial support does not flag formerly unsupported key: value pairs', async (t) => { + const css = '.test { appearance: auto; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['chrome 83'], + onFeatureUsage: () => { + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was called + t.equal(featureCount, 0); +}); + +test('partial support messages with many versions are formatted correctly', async (t) => { + const css = '.test {appearance: auto;}'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['chrome > 70', 'firefox > 60'], + onFeatureUsage: (usageinfo) => { + const messageWithoutFile = usageinfo.message.replace(/<.*>/, ''); + t.equal(messageWithoutFile, ':1:8: CSS Appearance value of auto is not supported by: Chrome (81,80,79,78,77,76,75,74,73,72,71), Firefox (79,78,77,76,75,74,73,72,71,70,69,68,67,66,65,64,63,62,61) (css-appearance)'); + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was called + t.equal(featureCount, 1); +}); diff --git a/test/mdn/generic.js b/test/mdn/generic.js new file mode 100644 index 0000000..7ddadb8 --- /dev/null +++ b/test/mdn/generic.js @@ -0,0 +1,70 @@ +import postcss from 'postcss'; +import { test } from 'tap'; + +import DoIUse from '../../lib/DoIUse.js'; + +// tests for https://github.com/anandthakker/doiuse/issues/70 + +test('partial support does not flag outline in ie 11', async (t) => { + const css = '.test { outline: 1px solid black; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['ie 11'], + onFeatureUsage: () => { + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was not called + t.equal(featureCount, 0); +}); + +test('partial support does flag outline-offset in ie 11', async (t) => { + const css = '.test { outline-offset: 1px; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['ie 11'], + onFeatureUsage: (usageinfo) => { + const messageWithoutFile = usageinfo.message.replace(/<.*>/, ''); + t.equal(messageWithoutFile, ':1:9: CSS outline properties outline-offset is not supported by: IE (11) (outline)'); + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was called + t.equal(featureCount, 1); +}); + +// tests for https://github.com/anandthakker/doiuse/issues/106 + +test('partial support does not flag text-decoration-style for firefox (65+) and chrome (71+)', async (t) => { + const css = '.test { text-decoration-style: solid; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['chrome >= 71', 'firefox >= 65'], + onFeatureUsage: () => { + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was not called + t.equal(featureCount, 0); +}); + +test('partial support does not flag text-decoration-line in chrome 89', async (t) => { + const css = '.test { text-decoration-line: underline; }'; + let featureCount = 0; + + await postcss(new DoIUse({ + browsers: ['chrome 89'], + onFeatureUsage: () => { + featureCount += 1; + }, + })).process(css); + + // make sure onFeatureUsage was not called + t.equal(featureCount, 0); +}); diff --git a/tsconfig.json b/tsconfig.json index 92123ac..5be08c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "useUnknownInCatchVariables": false, "outDir": "types", "removeComments": false, + "resolveJsonModule": true }, - "include": ["exports/**/*.js", "lib/**/*.js", "data/**/*.js"], -} \ No newline at end of file + "include": ["exports/**/*.js", "lib/**/*.js", "data/**/*.js"] +}