Skip to content

Commit

Permalink
New: Add helpers to get browser names and support details
Browse files Browse the repository at this point in the history
Ref #2896
  • Loading branch information
antross committed Sep 14, 2019
1 parent 55c6118 commit 5bab286
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 67 deletions.
3 changes: 3 additions & 0 deletions packages/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ hints inside the extended configurations.

### compat

* `getFriendlyName`: Get the friendly name of a browser from an id.
* `getUnsupported`: Get browsers without support for CSS or HTML features.
* `getUnsupportedDetails`: Get browsers without support with details on
when support was added or removed.
* `isSupported`: Query MDN for support of CSS or HTML features.

### configStore
Expand Down
6 changes: 3 additions & 3 deletions packages/utils/scripts/mdn-browser-compat-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ const removeFeatures = (data) => {
*/
const removeBrowserDetails = (browsers) => {
for (const browserName of Object.keys(browsers)) {
browsers[browserName] = /** @type {any} */({});
browsers[browserName] = /** @type {any} */({ name: browsers[browserName].name });
}
};

Expand All @@ -251,10 +251,10 @@ removeFeatures(data.css);
removeFeatures(data.html);

const code = `/* eslint-disable */
import { PrimaryIdentifier } from 'mdn-browser-compat-data/types';
import { Browsers, PrimaryIdentifier } from 'mdn-browser-compat-data/types';
type Data = {
browsers: PrimaryIdentifier;
browsers: Browsers;
css: PrimaryIdentifier;
html: PrimaryIdentifier;
}
Expand Down
104 changes: 80 additions & 24 deletions packages/utils/src/compat/browsers.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { Identifier, SimpleSupportStatement, SupportStatement } from 'mdn-browser-compat-data/types';
import * as semver from 'semver';

export type UnsupportedBrowsers = string[] | null;
import { mdn } from './browser-compat-data';

export type SupportDetails = {
versionAdded?: string;
versionRemoved?: string;
};

export type UnsupportedBrowsers = {
browsers: string[];
details: Map<string, SupportDetails>;
};

const enum Support {
No,
Yes,
Unknown
}

type SupportStatus = SupportDetails & {
support: Support;
};

// Map `browserslist` browser names to MDN ones.
const browserToMDN = new Map([
['and_chr', 'chrome_android'],
Expand All @@ -32,30 +46,34 @@ const coerce = (version: string): string | semver.SemVer => {
return semver.coerce(version) || version;
};

const normalizeBrowserName = (name: string) => {
return browserToMDN.get(name) || name;
};

/**
* Intepret if the provided statement indicates support for the given browser version.
*/
const isSupported = (support: SimpleSupportStatement, prefix: string, rawVersion: string): Support => {
const isSupported = (support: SimpleSupportStatement, prefix: string, rawVersion: string): SupportStatus => {
const version = coerce(rawVersion);

// Ignore support that requires users to enable a flag.
if (support.flags) {
return Support.Unknown;
return { support: Support.Unknown };
}

// If feature doesn't match the same prefix, then it's not supported.
if (prefix !== (support.prefix || '')) {
return Support.No;
return { support: Support.No };
}

// If feature was never added, then it's not supported.
if (support.version_added === false) {
return Support.No;
return { support: Support.No };
}

// If a feature was removed before the target version, it's not supported.
if (typeof support.version_removed === 'string' && semver.lte(coerce(support.version_removed), version)) {
return Support.No;
return { support: Support.No, versionRemoved: support.version_removed };
}

/*
Expand All @@ -67,41 +85,64 @@ const isSupported = (support: SimpleSupportStatement, prefix: string, rawVersion
* https://github.com/mdn/browser-compat-data/issues/3021
*/
if (support.version_added === true) {
return Support.Yes;
return { support: Support.Yes };
}

// If feature was added by the target version, it's supported; if after it's not.
if (typeof support.version_added === 'string') {
return semver.lte(coerce(support.version_added), version) ? Support.Yes : Support.No;
if (semver.lte(coerce(support.version_added), version)) {
return { support: Support.Yes };
}

return { support: Support.No, versionAdded: support.version_added };
}

// Ignore all other cases (e.g. if a feature was removed but we don't know when).
return Support.Unknown;
return { support: Support.Unknown };
};

/**
* Interpret if the provided support statements indicate the given browser version in supported.
* Interpret if the provided support statements indicate the given browser version is supported.
*/
const isBrowserUnsupported = (support: SupportStatement, prefix: string, version: string): boolean => {
const isBrowserSupported = (support: SupportStatement, prefix: string, version: string): SupportStatus => {
// Convert single entries to an array for consistent handling.
const browserSupport = Array.isArray(support) ? support : [support];
let status = Support.Unknown;
const details: SupportStatus = { support: Support.Unknown };

// Items are listed from newest to oldest. The first clear rule wins.
for (const simpleSupport of browserSupport) {
switch (isSupported(simpleSupport, prefix, version)) {
const status = isSupported(simpleSupport, prefix, version);

switch (status.support) {
case Support.Yes:
return false;
return { support: Support.Yes };
case Support.No:
status = Support.No;
details.support = Support.No;
details.versionAdded = status.versionAdded || details.versionAdded;
details.versionRemoved = status.versionRemoved || details.versionRemoved;
break; // Keep looking in case a feature was temporarily removed or is prefixed.
case Support.Unknown:
default:
break;
}
}

return status === Support.No;
if (details.support === Support.Unknown) {
details.support = Support.Yes;
}

return details;
};

/**
* Retrieve the friendly name of the provided browser
* (e.g. "Internet Explorer" for "ie").
*/
export const getFriendlyName = (browser: string): string => {
const [name] = browser.split(' ');
const data = mdn.browsers[normalizeBrowserName(name)];

return data.name;
};

/**
Expand All @@ -112,30 +153,45 @@ const isBrowserUnsupported = (support: SupportStatement, prefix: string, version
* @param feature An MDN feature `Identifier` with `__compat` data.
* @param browsers A list of target browsers (e.g. `['chrome 74', 'ie 11']`).
*/
export const getUnsupportedBrowsers = (feature: Identifier | undefined, prefix: string, browsers: string[]): UnsupportedBrowsers => {
export const getUnsupportedBrowsers = (feature: Identifier | undefined, prefix: string, browsers: string[]): UnsupportedBrowsers | null => {
if (!feature || !feature.__compat) {
return null; // Assume support if no matching feature was provided.
}

const support = feature.__compat.support;
const unsupported: string[] = [];
const details = new Map<string, SupportDetails>();

for (const browser of browsers) {
const [name, versionStr] = browser.split(' ');
const mdnBrowser = browserToMDN.get(name)!;
const mdnBrowser = normalizeBrowserName(name);
const browserSupport = support[mdnBrowser];
const versions = versionStr.split('-'); // Handle 'android 4.4.3-4.4.4'.

if (browserSupport) {
const isUnsupported = versions.some((version) => {
return isBrowserUnsupported(browserSupport, prefix, version);
});
if (!browserSupport) {
continue;
}

for (const version of versions) {
const status = isBrowserSupported(browserSupport, prefix, version);

if (status.support === Support.No) {
const supportDetails: SupportDetails = {};

if (isUnsupported) {
if (status.versionAdded) {
supportDetails.versionAdded = status.versionAdded;
}

if (status.versionRemoved) {
supportDetails.versionRemoved = status.versionRemoved;
}

details.set(browser, supportDetails);
unsupported.push(browser);
break;
}
}
}

return unsupported.length ? unsupported : null;
return unsupported.length ? { browsers: unsupported, details } : null;
};
2 changes: 1 addition & 1 deletion packages/utils/src/compat/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type Value = string[] | null;
type Value = any | null;

const cache = new Map<string, Map<string[], Value>>();

Expand Down
32 changes: 23 additions & 9 deletions packages/utils/src/compat/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const getTokens = (nodes: any[]): [string, string][] => {
* sub-features in the provided context, using `matches` data to test
* each tokenized string from the value.
*/
const getPartialValueUnsupported = (context: Identifier, value: string, browsers: string[]): UnsupportedBrowsers => {
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);
Expand Down Expand Up @@ -96,7 +96,7 @@ 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.
*/
const getValueUnsupported = (context: Identifier, value: string, browsers: string[]): UnsupportedBrowsers => {
const getValueUnsupported = (context: Identifier, value: string, browsers: string[]): UnsupportedBrowsers | null => {
const [data, prefix] = getFeatureData(context, value);

if (data) {
Expand All @@ -110,7 +110,7 @@ const getValueUnsupported = (context: Identifier, value: string, browsers: strin
* Determine if the provided CSS declaration consisting of a property
* and optionally a value is supported (e.g. `border-radius` or `display: grid`).
*/
export const getDeclarationUnsupported = (feature: DeclarationQuery, browsers: string[]): UnsupportedBrowsers => {
export const getDeclarationUnsupported = (feature: DeclarationQuery, browsers: string[]): UnsupportedBrowsers | null => {
const key = `css-declaration:${feature.property}|${feature.value || ''}`;

return getCachedValue(key, browsers, () => {
Expand All @@ -127,15 +127,15 @@ export const getDeclarationUnsupported = (feature: DeclarationQuery, browsers: s
/**
* Determine if the provided CSS at-rule is supported (e.g. `keyframes`).
*/
export const getRuleUnsupported = (feature: RuleQuery, browsers: string[]): UnsupportedBrowsers => {
export const getRuleUnsupported = (feature: RuleQuery, browsers: string[]): UnsupportedBrowsers | null => {
return getCachedValue(`css-rule:${feature.rule}`, browsers, () => {
const [data, prefix] = getFeatureData(mdn.css['at-rules'], feature.rule);

return getUnsupportedBrowsers(data, prefix, browsers);
});
};

const getPseudoSelectorUnsupported = (value: string, browsers: string[]): UnsupportedBrowsers => {
const getPseudoSelectorUnsupported = (value: string, browsers: string[]): UnsupportedBrowsers | null => {
const name = value.replace(/^::?/, ''); // Strip leading `:` or `::`.

return getCachedValue(`css-pseudo-selector:${name}`, browsers, () => {
Expand All @@ -153,20 +153,34 @@ const getPseudoSelectorUnsupported = (value: string, browsers: string[]): Unsupp
* special cases (e.g. newer attribute and combinator selectors) need special
* handling to map to the MDN data (which hasn't been done yet).
*/
export const getSelectorUnsupported = (feature: SelectorQuery, browsers: string[]): UnsupportedBrowsers => {
export const getSelectorUnsupported = (feature: SelectorQuery, browsers: string[]): UnsupportedBrowsers | null => {
const parser = selectorParser();
const root = parser.astSync(feature.selector); // eslint-disable-line no-sync

let unsupported: string[] = [];
const unsupported: UnsupportedBrowsers = {
browsers: [],
details: new Map()
};

// https://github.com/postcss/postcss-selector-parser/blob/master/API.md#containerwalk-proxies
root.walkPseudos((node: { value: string }) => {
const result = getPseudoSelectorUnsupported(node.value, browsers);

if (result) {
unsupported = [...unsupported, ...result];
unsupported.browsers = [...unsupported.browsers, ...result.browsers];

/*
* Note: Details can be incorrect if multiple parts of a selector
* are unsupported. Currently details will be set based on the
* last part of the selector which was unsupported.
*
* TODO: Fix by requiring callers to parse the selector instead.
*/
for (const [browser, details] of result.details) {
unsupported.details.set(browser, details);
}
}
});

return unsupported.length ? unsupported : null;
return unsupported.browsers.length ? unsupported : null;
};
4 changes: 2 additions & 2 deletions packages/utils/src/compat/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type ElementQuery = {
* a context element and/or value is supported (e.g. `{ attribute: 'hidden' }`
* or `{ attribute: 'rel', element: 'link', value: 'stylesheet' }`).
*/
export const getAttributeUnsupported = (feature: AttributeQuery, browsers: string[]): UnsupportedBrowsers => {
export const getAttributeUnsupported = (feature: AttributeQuery, browsers: string[]): UnsupportedBrowsers | null => {
const key = `html-attribute:${feature.element || ''}|${feature.attribute}|${feature.value || ''}`;

return getCachedValue(key, browsers, () => {
Expand Down Expand Up @@ -56,7 +56,7 @@ export const getAttributeUnsupported = (feature: AttributeQuery, browsers: strin
/**
* Determine if the provided HTML element is supported (e.g. `details`).
*/
export const getElementUnsupported = (feature: ElementQuery, browsers: string[]): UnsupportedBrowsers => {
export const getElementUnsupported = (feature: ElementQuery, browsers: string[]): UnsupportedBrowsers | null => {
return getCachedValue(`html-element:${feature.element}`, browsers, () => {
const [data, prefix] = getFeatureData(mdn.html.elements, feature.element);

Expand Down
38 changes: 27 additions & 11 deletions packages/utils/src/compat/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,19 @@ import {
ElementQuery
} from './html';

export {
getFriendlyName,
UnsupportedBrowsers
} from './browsers';

export type FeatureQuery = AttributeQuery | DeclarationQuery | ElementQuery | RuleQuery | SelectorQuery;

/**
* ```js
* getUnsupported({ element: 'details' }, ['chrome 74', 'ie 11']); // ['ie 11']
* getUnsupported({ attribute: 'hidden' }, browsers);
* getUnsupported({ attribute: 'rel', element: 'link', value: 'noopener' }, ['edge 12', 'firefox 63']); // ['edge 12']
* getUnsupported({ property: 'border-radius' }, browsers);
* getUnsupported({ property: 'color', value: '#00FF00FF' }, browsers);
* getUnsupported({ property: 'transform', value: 'translate3d(0, 0, 10px)' }, browsers);
* getUnsupported({ rule: '@supports' }, browsers);
* getUnsupported({ selector: 'input:invalid' }, browsers);
* ```
* Similar to `getUnsupported`, but returns an object with both a list of
* `browsers` which were unsupported and a map of browsers to `browserDetails`
* to get additional information (e.g. what version the feature is added in).
*/
export const getUnsupported = (feature: FeatureQuery, browsers: string[]): UnsupportedBrowsers => {
export const getUnsupportedDetails = (feature: FeatureQuery, browsers: string[]): UnsupportedBrowsers | null => {
if ('attribute' in feature) {
return getAttributeUnsupported(feature, browsers);
} else if ('element' in feature) {
Expand All @@ -44,6 +42,24 @@ export const getUnsupported = (feature: FeatureQuery, browsers: string[]): Unsup
return getSelectorUnsupported(feature, browsers);
};

/**
* ```js
* getUnsupported({ element: 'details' }, ['chrome 74', 'ie 11']); // ['ie 11']
* getUnsupported({ attribute: 'hidden' }, browsers);
* getUnsupported({ attribute: 'rel', element: 'link', value: 'noopener' }, ['edge 12', 'firefox 63']); // ['edge 12']
* getUnsupported({ property: 'border-radius' }, browsers);
* getUnsupported({ property: 'color', value: '#00FF00FF' }, browsers);
* getUnsupported({ property: 'transform', value: 'translate3d(0, 0, 10px)' }, browsers);
* getUnsupported({ rule: '@supports' }, browsers);
* getUnsupported({ selector: 'input:invalid' }, browsers);
* ```
*/
export const getUnsupported = (feature: FeatureQuery, browsers: string[]): string[] | null => {
const data = getUnsupportedDetails(feature, browsers);

return data && data.browsers;
};

export const getSupported = (feature: FeatureQuery, browsers: string[]): string[] | null => {
const unsupported = getUnsupported(feature, browsers);

Expand Down
Loading

0 comments on commit 5bab286

Please sign in to comment.