Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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