Skip to content

Commit

Permalink
check partially support with MDN
Browse files Browse the repository at this point in the history
  • Loading branch information
RJWadley committed Jun 8, 2023
1 parent 9c410c9 commit f6eec04
Show file tree
Hide file tree
Showing 12 changed files with 425 additions and 7 deletions.
1 change: 1 addition & 0 deletions data/features/outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export default {
'outline-style': true,
'outline-width': true,
'outline-color': true,
'outline-offset': true,
};
16 changes: 15 additions & 1 deletion lib/DoIUse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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' });
Expand Down
154 changes: 154 additions & 0 deletions lib/mdn/checkPartialSupport.js
Original file line number Diff line number Diff line change
@@ -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 <code> 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>(.*?)<\/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<string, string[]>} */
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,
};
}
97 changes: 97 additions & 0 deletions lib/mdn/convertMdnBrowser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @typedef {Record<string, {
* versionAdded: number;
* versionRemoved?: number;
* ignoreValue?: boolean;
* }>} 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;
}
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion scripts/update-caniuse.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
npm i caniuse-lite
npm i caniuse-lite @mdn/browser-compat-data
npm i caniuse-db -D
3 changes: 2 additions & 1 deletion test/cases/features/outline.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ See: https://caniuse.com/outline

/*
expect:
outline: 1
outline: 2
*/

.test {
outline: 1px solid red;
outline-offset: 1px;
}
4 changes: 2 additions & 2 deletions test/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<streaming css input>:7:1: CSS3 Overflow-wrap only partially supported by: IE (11) (wordwrap)\n';
const overflowWrapCssFile = joinPath(selfPath, './cases/generic/resize.css');
const expectedOverflowWrapConfig = '<streaming css input>: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(/<streaming css input>/g, overflowWrapCssFile));
Expand Down
Loading

0 comments on commit f6eec04

Please sign in to comment.