Skip to content

Commit

Permalink
New: Use mdn-data to derive CSS types
Browse files Browse the repository at this point in the history
Enables webhint to report browser support for an expanded set of
CSS values (e.g. functions like `circle()` or `conic-gradient()`).

- - - - - - - - - - - - - - - - - - - -

Fix #3642
Close #3958
  • Loading branch information
antross authored Aug 24, 2020
1 parent 4e32bb6 commit b166bb5
Show file tree
Hide file tree
Showing 9 changed files with 226 additions and 46 deletions.
1 change: 1 addition & 0 deletions packages/utils-compat-data/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
src/browser-compat-data.ts
src/mdn-css-types.ts
4 changes: 3 additions & 1 deletion packages/utils-compat-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"dependencies": {
"mdn-browser-compat-data": "^1.0.31",
"mdn-data": "^2.0.11",
"postcss": "^7.0.32",
"postcss-selector-parser": "^6.0.2",
"postcss-value-parser": "^4.1.0",
Expand Down Expand Up @@ -63,7 +64,8 @@
"lint:js": "eslint . --cache --ext .js,.md,.ts --ignore-path ../../.eslintignore",
"lint:md": "node ../../scripts/lint-markdown.js",
"prebuild": "npm-run-all prebuild:*",
"prebuild:mdn": "node ./scripts/mdn-browser-compat-data.js",
"prebuild:mdn-bcd": "node ./scripts/mdn-browser-compat-data.js",
"prebuild:mdn-data": "node ./scripts/mdn-data.js",
"test": "npm run lint && npm run build && npm run test-only",
"test-only": "nyc ava",
"test-release": "npm run lint && npm run build-release && ava",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const isUniversalSupportStatement = (browserName, supportStatement) => {
return true; // Count unknown as universal support.
}

if (version === '1') {
if (version === '1' || version === '1.0') {
return true;
}

Expand Down
113 changes: 113 additions & 0 deletions packages/utils-compat-data/scripts/mdn-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const fs = require('fs');
const mdn = require('mdn-browser-compat-data');
const path = require('path');
const filename = path.resolve(`${__dirname}/../src/mdn-css-types.ts`);

/** @typedef {{ syntax: string }} Property */

/** @type {{[key: string]: Property}} */
const properties = require('mdn-data/css/properties.json');

/** Match a reference to a CSS property, e.g. `<'background-color'>` */
const propertyRef = /<'([a-z-]+)'>/gi;

/** Match a reference to a CSS type, e.g. `<color>` */
const typeRef = /<([a-z-]+)>/gi;

/**
* Helper for `[].flatMap` until we move to Node 11+.
* @param {any[]} arr
* @param {(item: any) => any} map
*/
const flatMap = (arr, map) => {
return [].concat(...arr.map(map));
};

/**
* Helper for `string.matchAll` until we move to Node 11+.
* @param {string} str
* @param {RegExp} regex
*/
const matchAll = (str, regex) => {
let match;
const matches = [];

while ((match = regex.exec(str)) !== null) {
matches.push(match);
}

return matches;
};

/**
* Recursively resolve all CSS types which can be used in values for
* the specified property. When a property references another property
* the types from the referenced property will be included directly in
* the flattened result array.
* @param {string} name The name of the property to resolve types for.
* @returns {string[]} A flattened array of all referenced types.
*/
const getTypesForProperty = (name) => {
const { syntax } = properties[name];
const typeRefs = [...matchAll(syntax, typeRef)].map((m) => {
return m[1];
});
/** @type {string[]} */
const propertyRefs = flatMap([...matchAll(syntax, propertyRef)], (m) => {
return getTypesForProperty(m[1]);
});

return typeRefs.concat(propertyRefs);
};

// Resolve unique referenced types for all properties in the dataset.
const props = Object.keys(properties);
const types = props.map((key) => {
return [
key,
[...new Set(getTypesForProperty(key))].filter((type) => {
// Exclude types not present in mdn-browser-compat-data since we won't need them.
return mdn.css.types[type];
})
];
}).filter((entry) => {
return entry[1].length;
});

/*
* Export in a map of property names to array of referenced CSS types.
* E.g.
* ```json
* {
* "border-bottom-color": {
* "syntax": "<'border-top-color'>"
* },
* "border-top-color": {
* "syntax": "<color>"
* }
* }
* ```
*
* becomes
*
* ```js
* new Map([
* ['border-bottom-color', ['color']],
* ['border-top-color', ['color']]
* ])
* ```
*/
const code = `/* eslint-disable */
export const types = new Map([${types.map((type) => {
return `\n ${JSON.stringify(type)}`;
})}
]);
`;

fs.writeFile(filename, code, (err) => {
if (err) {
throw err;
} else {
console.log(`Created: ${filename}`);
}
});
9 changes: 0 additions & 9 deletions packages/utils-compat-data/src/css-types.ts

This file was deleted.

116 changes: 82 additions & 34 deletions packages/utils-compat-data/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { mdn } from './browser-compat-data';
import { getUnsupportedBrowsers, UnsupportedBrowsers } from './browsers';
import { getCachedValue } from './cache';
import { getFeatureData } from './helpers';
import { types } from './css-types';
import { types } from './mdn-css-types';

const selectorParser = require('postcss-selector-parser');
const valueParser = require('postcss-value-parser');
Expand All @@ -24,6 +24,12 @@ export type SelectorQuery = {
selector: string;
};

export type ParsedValue = {
prefix: string;
tokens: [string, string][];
unprefixedValue: string;
};

/**
* Extract relevant tokens from a parsed CSS value.
* Only `function` and `word` are interesting from a
Expand All @@ -50,43 +56,73 @@ const getTokens = (nodes: any[]): [string, string][] => {
};

/**
* Determine if part of a CSS value is supported. Iterates through all
* sub-features in the provided context, using `matches` data to test
* each tokenized string from the value.
* Check if any parts of a value align with an MDN feature's matches clause.
* If so, return browser support based on that feature's data.
*/
const getPartialValueUnsupported = (context: Identifier, value: string, browsers: string[]): UnsupportedBrowsers | null => {
const prefix = vendor.prefix(value);
const unprefixedValue = vendor.unprefixed(value);
const tokens = getTokens(valueParser(value).nodes);
const getValueMatchesUnsupported = (context: Identifier, featureSupport: Identifier, value: ParsedValue, browsers: string[]): UnsupportedBrowsers | null => {
const { prefix, tokens, unprefixedValue } = value;
const matches = featureSupport.__compat && featureSupport.__compat.matches;

for (const entry of Object.values(context)) {
if (!matches) {
return null;
}

const matches = entry.__compat && entry.__compat.matches;
if (matches.regex_value && new RegExp(matches.regex_value).exec(unprefixedValue)) {
return getUnsupportedBrowsers(featureSupport, prefix, browsers, unprefixedValue, context);
}

if (!matches) {
continue;
if (matches.keywords) {
for (const [tokenPrefix, tokenValue] of tokens) {
if (matches.keywords.includes(tokenValue)) {
return getUnsupportedBrowsers(featureSupport, tokenPrefix, browsers, tokenValue, context);
}
}
}

if (matches.regex_value && new RegExp(matches.regex_value).exec(unprefixedValue)) {
return getUnsupportedBrowsers(entry, prefix, browsers, unprefixedValue, context);
}
if (matches.regex_token) {
const regexToken = matches.regex_token && new RegExp(matches.regex_token);

if (matches.keywords) {
for (const [tokenPrefix, tokenValue] of tokens) {
if (matches.keywords.includes(tokenValue)) {
return getUnsupportedBrowsers(entry, tokenPrefix, browsers, tokenValue, context);
}
for (const [tokenPrefix, tokenValue] of tokens) {
if (regexToken && regexToken.exec(tokenValue)) {
return getUnsupportedBrowsers(featureSupport, tokenPrefix, browsers, tokenValue, context);
}
}
}

if (matches.regex_token) {
const regexToken = matches.regex_token && new RegExp(matches.regex_token);
return null;
};

for (const [tokenPrefix, tokenValue] of tokens) {
if (regexToken && regexToken.exec(tokenValue)) {
return getUnsupportedBrowsers(entry, tokenPrefix, browsers, tokenValue, context);
}
}
/**
* Check if any parts of a value align with an MDN feature's name.
* If so, return browser support based on that feature's data.
*/
const getValueTokenUnsupported = (context: Identifier, featureName: string, featureSupport: Identifier, value: ParsedValue, browsers: string[]): UnsupportedBrowsers | null => {
for (const [tokenPrefix, tokenValue] of value.tokens) {
if (featureName === tokenValue) {
return getUnsupportedBrowsers(featureSupport, tokenPrefix, browsers, tokenValue, context);
}
}

return null;
};

/**
* Determine if part of a CSS value is supported. Iterates through all
* sub-features in the provided context, using keys and `matches` data
* to test each tokenized string from the value.
*/
const getPartialValueUnsupported = (context: Identifier, value: ParsedValue, browsers: string[]): UnsupportedBrowsers | null => {
for (const [featureName, featureSupport] of Object.entries(context)) {
if (featureName === '__compat') {
continue;
}

const unsupported = getValueMatchesUnsupported(context, featureSupport, value, browsers) ||
getValueTokenUnsupported(context, featureName, featureSupport, value, browsers) ||
getPartialValueUnsupported(featureSupport, value, browsers);

if (unsupported) {
return unsupported;
}
}

Expand All @@ -96,25 +132,37 @@ const getPartialValueUnsupported = (context: Identifier, value: string, browsers
/**
* Determine if the provided CSS value is supported, first by looking for an
* exact match for the full value, falling back to search for a partial match.
*
* Note: context is missing when a property was omitted due to full support
* (to reduce bundle size), but referenced CSS types with partial support may
* still exist (e.g. "color" and alpha_hex_value).
*/
const getValueUnsupported = (context: Identifier, property: string, value: string, browsers: string[]): UnsupportedBrowsers | null => {
const [data, prefix, unprefixed] = getFeatureData(context, value);
const getValueUnsupported = (context: Identifier | undefined, property: string, value: string, browsers: string[]): UnsupportedBrowsers | null => {
const [data, prefix, unprefixedValue] = getFeatureData(context, value);

if (data) {
return getUnsupportedBrowsers(data, prefix, browsers, unprefixed, data.__compat?.mdn_url ? undefined : context);
return getUnsupportedBrowsers(data, prefix, browsers, unprefixedValue, data.__compat?.mdn_url ? undefined : context);
}

if (property && types.has(property)) {
const parsedValue: ParsedValue = {
prefix,
tokens: getTokens(valueParser(value).nodes),
unprefixedValue
};

// Check browser support for each CSS type associated with the property (if any).
if (types.has(property)) {
for (const type of types.get(property)!) {
const result = getValueUnsupported(mdn.css.types[type], '', value, browsers);
const typeContext = mdn.css.types[type];
const result = typeContext && getPartialValueUnsupported(typeContext, parsedValue, browsers);

if (result) {
return result;
}
}
}

return getPartialValueUnsupported(context, value, browsers);
return context ? getPartialValueUnsupported(context, parsedValue, browsers) : null;
};

/**
Expand All @@ -127,7 +175,7 @@ export const getDeclarationUnsupported = (feature: DeclarationQuery, browsers: s
return getCachedValue(key, browsers, () => {
const [data, prefix, unprefixed] = getFeatureData(mdn.css.properties, feature.property);

if (data && feature.value) {
if (feature.value) {
return getValueUnsupported(data, unprefixed, feature.value, browsers);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/utils-compat-data/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { vendor } from 'postcss';
* @param name The name of the feature, including prefixes (e.g. `-webkit-keyframes`)
* @returns A tuple of the feature and extracted prefix (if any).
*/
export const getFeatureData = (context: Identifier, name: string): [Identifier, string, string] => {
export const getFeatureData = (context: Identifier | undefined, name: string): [Identifier | undefined, string, string] => {
if (!context || context[name]) {
return [context && context[name], '', name];
}
Expand Down
20 changes: 20 additions & 0 deletions packages/utils-compat-data/tests/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,26 @@ test('Property value query works (regex token match)', (t) => {
t.is(unsupported && unsupported[0], 'chrome 61');
});

test('Property value type query works', (t) => {
const unsupported = getUnsupported({
property: 'clip-path',
value: 'circle(6rem at 12rem 8rem)'
}, ['edge 12', 'edge 79']);

t.is(unsupported && unsupported.length, 1);
t.is(unsupported && unsupported[0], 'edge 12');
});

test('Nested property value type query works', (t) => {
const unsupported = getUnsupported({
property: 'border-image',
value: 'conic-gradient(red, orange, yellow, green, blue)'
}, ['edge 12', 'edge 79']);

t.is(unsupported && unsupported.length, 1);
t.is(unsupported && unsupported[0], 'edge 12');
});

test('At-rule query works', (t) => {
const unsupported = getUnsupported(
{ rule: 'supports' },
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7153,6 +7153,11 @@ mdn-browser-compat-data@^1.0.31:
dependencies:
extend "3.0.2"

mdn-data@^2.0.11:
version "2.0.11"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.11.tgz#d2fc3d7fc3ff852375c775fe5283d1e374c4c95d"
integrity sha512-ADeJti8mfWxdXqoudn3HZFHA0UMJ18zaKVnEiL6dTwNg99ZGMMc78CA5TxbqWui4iA417ZOYkWkl+Ssv3ItQDw==

mdurl@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
Expand Down

0 comments on commit b166bb5

Please sign in to comment.