Skip to content

Commit

Permalink
Add experimental utils (mdn#9441)
Browse files Browse the repository at this point in the history
* Copy ddbeck/bcd-utils into tree

From https://github.com/ddbeck/bcd-utils/tree/4f2499c81cd3c683b034f53e3176e9289460379b/src

* Apply this repo's prettier config

* Expand tests to cover any *.test.js file

* Make BCD require wrapper work

* Provide more descriptive text on one of the tests

* Make test of visit vs walk comprehensive

* Fix typo

* Make iterSupport return more lifelike data for non-existent browsers

* Add missing `new` keyword

* Switch to strict assertion mode

https://nodejs.org/api/assert.html#assert_strict_assertion_mode

* Add support for optional `data` for all utils

* Remove wrapper around BCD

* Rework signature for `visit()`

* From walk, don't yield undefined values

* Unbreak tests

* Document in-repo the experimental status of utilities
  • Loading branch information
ddbeck authored Mar 31, 2021
1 parent c79b18e commit 44f3b7e
Show file tree
Hide file tree
Showing 14 changed files with 404 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ schemas
CODE_OF_CONDUCT.md
CONTRIBUTING.md
GOVERNANCE.md
utils
scripts
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
},
"scripts": {
"confluence": "node ./node_modules/mdn-confluence/main/generate.es6.js --output-dir=. --bcd-module=./index.js",
"mocha": "mocha \"test/**.test.js\"",
"mocha": "mocha \"*/**.test.js\"",
"lint": "node test/lint",
"fix": "node scripts/fix",
"mirror": "node scripts/mirror",
Expand Down
3 changes: 3 additions & 0 deletions utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Experimental utilities

These are experimental utilities for working with compat data, which may inform future work on the project's API. The modules in this directory are NOT included in the released `@mdn/browser-compat-data` package. They are NOT covered by the [_Semantic versioning policy_](../README.md#Semantic-versioning-policy); backwards compatibility is not assured. With those limitations in mind, you're free to experiment with these utilities and [provide feedback](https://github.com/mdn/browser-compat-data/issues/new/choose).
11 changes: 11 additions & 0 deletions utils/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const iterSupport = require('./iter-support');
const query = require('./query');
const { walk } = require('./walk');
const visit = require('./visit');

module.exports = {
iterSupport,
query,
walk,
visit,
};
10 changes: 10 additions & 0 deletions utils/iter-support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
function iterSupport(compat, browser) {
if (browser in compat.support) {
const data = compat.support[browser];
return Array.isArray(data) ? data : [data];
}

return [{ version_added: null }];
}

module.exports = iterSupport;
27 changes: 27 additions & 0 deletions utils/iter-support.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const assert = require('assert').strict;

const iterSupport = require('./iter-support');

describe('iterSupport()', function () {
it('returns a `"version_added": null` support statement for non-existent browsers', function () {
assert.deepEqual(iterSupport({ support: { firefox: [] } }, 'chrome'), [
{ version_added: null },
]);
});

it('returns a single support statement as an array', function () {
assert.deepEqual(
iterSupport({ support: { firefox: { version_added: true } } }, 'firefox'),
[{ version_added: true }],
);
});

it('returns an array of support statements as an array', function () {
const compatObj = {
support: { firefox: [{ version_added: true }, { version_added: '1' }] },
};
const support = [{ version_added: true }, { version_added: '1' }];

assert.deepEqual(iterSupport(compatObj, 'firefox'), support);
});
});
26 changes: 26 additions & 0 deletions utils/query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const bcd = require('..');

/**
* Get a subtree of compat data.
*
* @param {string} path Dotted path to a given feature (e.g., `css.properties.background`)
* @param {*} [data=bcd] A tree to query. All of BCD, by default.
* @returns {*} A BCD subtree
* @throws {ReferenceError} For invalid identifiers
*/
function query(path, data = bcd) {
const pathElements = path.split('.');
let lookup = data;
while (pathElements.length) {
const next = pathElements.shift();
lookup = lookup[next];
if (lookup === undefined) {
throw new ReferenceError(
`${path} is not a valid tree identifier (failed at '${next}')`,
);
}
}
return lookup;
}

module.exports = query;
41 changes: 41 additions & 0 deletions utils/query.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const assert = require('assert').strict;

const query = require('./query');

describe('query()', function () {
describe('should throw on non-existent features', function () {
assert.throws(() => query('nonExistentNameSpace'), ReferenceError);
assert.throws(() => query('api.NonExistentFeature'), ReferenceError);
assert.throws(
() => query('api.NonExistentFeature.subFeature'),
ReferenceError,
);
});

it('should return the expected point in the tree (namespace)', function () {
const obj = query('css');

assert.ok(!('__compat' in obj));
assert.ok('properties' in obj);
assert.ok('at-rules' in obj);
});

it('should return the expected point in the tree (feature)', function () {
const obj = query('api.HTMLAnchorElement.href');

assert.ok('support' in obj.__compat);
assert.ok('status' in obj.__compat);
assert.equal(
'https://developer.mozilla.org/docs/Web/API/HTMLAnchorElement/href',
obj.__compat.mdn_url,
);
});

it('should return the expected point in the tree (feature with children)', function () {
const obj = query('api.HTMLAnchorElement');

assert.ok('__compat' in obj);
assert.ok('charset' in obj);
assert.ok('href' in obj);
});
});
39 changes: 39 additions & 0 deletions utils/visit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const bcd = require('..');
const query = require('./query');
const { descendantKeys, joinPath, isFeature } = require('./walkingUtils');

const BREAK = Symbol('break');
const CONTINUE = Symbol('continue');

function visit(visitor, options = {}) {
const { entryPoint, data } = options;
const test = options.test !== undefined ? options.test : () => true;

const tree = entryPoint === undefined ? bcd : query(entryPoint, data);

let outcome;
if (isFeature(tree) && test(entryPoint, tree.__compat)) {
outcome = visitor(entryPoint, tree.__compat);
}
if (outcome === BREAK) {
return outcome;
}
if (outcome !== CONTINUE) {
for (const key of descendantKeys(tree)) {
const suboutcome = visit(visitor, {
entryPoint: joinPath(entryPoint, key),
test,
data,
});
if (suboutcome === BREAK) {
return suboutcome;
}
}
}
return outcome;
}

visit.BREAK = BREAK;
visit.CONTINUE = CONTINUE;

module.exports = visit;
63 changes: 63 additions & 0 deletions utils/visit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const assert = require('assert').strict;

const visit = require('./visit');
const { walk } = require('./walk');

describe('visit()', function () {
it('runs the function on all features if no other entry point is specified', function () {
const walker = walk();
visit(visitorPath => {
assert.equal(visitorPath, walker.next().value.path);
});
});

it('skips features not selected by testFn', function () {
const hits = new Set();
const misses = new Set();
visit(
path => {
hits.add(path);
},
{
entryPoint: 'css',
test(path) {
if (path.includes('at-rules')) {
return true;
}
misses.add(path);
return false;
},
},
);

for (const miss in misses) {
assert.ok(!hits.has(miss));
}
});

it('visitorFn can break iteration', function () {
visit(path => {
if (path.startsWith('css')) {
return visit.BREAK;
}
if (path.startsWith('html')) {
assert.fail(
`visitorFn should never be invoked after the css tree. Reached ${path}`,
);
}
});
});

it('visitorFn can skip traversal of children', function () {
visit(path => {
if (path === 'css.at-rules.counter-style') {
return visit.CONTINUE;
}
if (path.startsWith('css.at-rules-counter-style.')) {
assert.fail(
`visitorFn should never reach a child of counter-style. Reached ${path}`,
);
}
});
});
});
50 changes: 50 additions & 0 deletions utils/walk.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const bcd = require('..');
const { isBrowser, descendantKeys, joinPath } = require('./walkingUtils');
const query = require('./query');

function* lowLevelWalk(data = bcd, path, depth = Infinity) {
if (path !== undefined) {
const next = {
path,
data,
};

if (isBrowser(data)) {
next.browser = data;
} else if (data.__compat !== undefined) {
next.compat = data.__compat;
}
yield next;
}

if (depth > 0) {
for (const key of descendantKeys(data)) {
yield* lowLevelWalk(data[key], joinPath(path, key), depth - 1);
}
}
}

function* walk(entryPoints, data = bcd) {
const walkers = [];

if (entryPoints === undefined) {
walkers.push(lowLevelWalk(data));
} else {
entryPoints = Array.isArray(entryPoints) ? entryPoints : [entryPoints];
walkers.push(
...entryPoints.map(entryPoint =>
lowLevelWalk(query(entryPoint, data), entryPoint),
),
);
}

for (const walker of walkers) {
for (const step of walker) {
if (step.compat) {
yield step;
}
}
}
}

module.exports = { walk, lowLevelWalk };
65 changes: 65 additions & 0 deletions utils/walk.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const assert = require('assert').strict;

const bcd = require('..');
const { walk } = require('./index');
const { lowLevelWalk } = require('./walk');

describe('lowLevelWalk()', function () {
it('visits every top-level tree', function () {
const expectedPaths = [
'api',
'browsers',
'css',
'html',
'http',
'javascript',
'mathml',
'svg',
'webdriver',
'webextensions',
'xpath',
'xslt',
];

const steps = Array.from(lowLevelWalk(undefined, undefined, 1));
const paths = steps.map(step => step.path);
assert.equal(steps.length, expectedPaths.length);
assert.deepEqual(paths, expectedPaths);
});
it('visits every point in the tree', function () {
const paths = Array.from(lowLevelWalk()).map(step => step.path);
assert.ok(paths.length > 13000);
});
});

describe('walk()', function () {
it('should visit deeply nested features', function () {
let results = Array.from(walk('html')).map(feature => feature.path);
assert.ok(results.includes('html.elements.a.href.href_top'));
});

it('should walk a single tree', function () {
let results = Array.from(walk('api.Notification'));
assert.equal(results.length, 27);
assert.equal(results[0].path, 'api.Notification');
assert.equal(results[1].path, 'api.Notification.Notification');
});

it('should walk multiple trees', function () {
let results = Array.from(
walk(['api.Notification', 'css.properties.color']),
);
assert.equal(results.length, 28);
assert.equal(results[0].path, 'api.Notification');
assert.equal(results[results.length - 1].path, 'css.properties.color');
});

it('should yield every feature by default', function () {
const featureCountFromString = JSON.stringify(bcd, undefined, 2)
.split('\n')
.filter(line => line.includes('__compat')).length;
const featureCountFromWalk = Array.from(walk()).length;

assert.equal(featureCountFromString, featureCountFromWalk);
});
});
30 changes: 30 additions & 0 deletions utils/walkingUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
function joinPath() {
return Array.from(arguments).filter(Boolean).join('.');
}

function isFeature(obj) {
return '__compat' in obj;
}
function isBrowser(obj) {
return 'name' in obj && 'releases' in obj;
}

function descendantKeys(data) {
if (isFeature(data)) {
return Object.keys(data).filter(key => key !== '__compat');
}

if (isBrowser(data)) {
// Browsers never have independently meaningful descendants
return [];
}

return Object.keys(data);
}

module.exports = {
joinPath,
isFeature,
isBrowser,
descendantKeys,
};
Loading

0 comments on commit 44f3b7e

Please sign in to comment.