Compare commits
2 Commits
master
...
promise-tr
Author | SHA1 | Date |
---|---|---|
Kenneth Barbour | 6dac05f3ea | |
Kenneth Barbour | b7201264a5 |
|
@ -2,6 +2,7 @@ module.exports = {
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
|
es6: true,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
|
|
|
@ -42,7 +42,7 @@ const ast = htmlToAst('<p>Hello, <div>{name}</div>!</p>', { fragment: true });
|
||||||
// Now we have an abstract syntax tree (AST) representation of our HTML
|
// Now we have an abstract syntax tree (AST) representation of our HTML
|
||||||
// fragment. First, let's transform the div to a span.
|
// fragment. First, let's transform the div to a span.
|
||||||
|
|
||||||
let updated = transform(
|
let updated = await transform(
|
||||||
// our initial syntax tree
|
// our initial syntax tree
|
||||||
ast,
|
ast,
|
||||||
// A transformer that checks if the node is an element and has a tag name
|
// A transformer that checks if the node is an element and has a tag name
|
||||||
|
|
|
@ -3,9 +3,8 @@
|
||||||
* Provides functionality to apply transformations to Abstract Syntax Trees
|
* Provides functionality to apply transformations to Abstract Syntax Trees
|
||||||
* (ASTs).
|
* (ASTs).
|
||||||
*/
|
*/
|
||||||
|
import { visit, makeVisitor } from './visit.mjs';
|
||||||
|
|
||||||
import { visit, SKIP } from 'unist-util-visit';
|
|
||||||
import { isNode } from './util.mjs';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms an AST using a series of transformer functions.
|
* Transforms an AST using a series of transformer functions.
|
||||||
|
@ -16,68 +15,7 @@ import { isNode } from './util.mjs';
|
||||||
* @returns {Object} The transformed AST.
|
* @returns {Object} The transformed AST.
|
||||||
*/
|
*/
|
||||||
export function transform (ast, ...transformers) {
|
export function transform (ast, ...transformers) {
|
||||||
let tree = JSON.parse(JSON.stringify(ast));
|
return visit(JSON.parse(JSON.stringify(ast)), makeVisitor(transformers));
|
||||||
|
|
||||||
// Visit every node. For each node, pass it into each function in the
|
|
||||||
// fxns array. If the function returns undefined, leave the node as-is
|
|
||||||
// and continue. If the function returns null, delete the node from the
|
|
||||||
// AST and stop processing it. If the function returns an AST node,
|
|
||||||
// replace the node with that AST node and continue processing with the
|
|
||||||
// new node.
|
|
||||||
visit(tree, (node, index, parent) => {
|
|
||||||
let replaced = false;
|
|
||||||
let removed = false;
|
|
||||||
|
|
||||||
let transformed = transformers.reduce((workingNode, transformer) => {
|
|
||||||
// If the current node is null, just skip any further processing.
|
|
||||||
if (workingNode === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the working node through the current function. Make sure
|
|
||||||
// the transformer function knows not only the node but also its
|
|
||||||
// ancestors.
|
|
||||||
let result = transformer(workingNode);
|
|
||||||
|
|
||||||
// If the function returned null, just skip any further processing.
|
|
||||||
if (result === null) {
|
|
||||||
removed = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// If the function returned 'undefined', this means that there's no
|
|
||||||
// specific definition for how the node should be transformed.
|
|
||||||
// Therefore, we do not transform it. We leave it as-is and pass it
|
|
||||||
// to the next transformer.
|
|
||||||
else if (result === undefined) {
|
|
||||||
return workingNode;
|
|
||||||
}
|
|
||||||
// If the function returned an AST node, we replace the working node
|
|
||||||
// with that AST node and continue processing with the new node.
|
|
||||||
else if (isNode(result)) {
|
|
||||||
replaced = true;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For anything else, throw an error.
|
|
||||||
throw new Error(`Invalid transformer result: ${JSON.stringify(result)}`);
|
|
||||||
}, node);
|
|
||||||
|
|
||||||
// If we need to replace the node, do it here.
|
|
||||||
if (replaced && transformed && parent) {
|
|
||||||
parent.children.splice(index, 1, transformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removed && parent) {
|
|
||||||
parent.children.splice(index, 1);
|
|
||||||
// This is special magick required by unist. It hints to the visit
|
|
||||||
// function that we removed this node from the tree, so skip it when
|
|
||||||
// walking further..
|
|
||||||
return [SKIP, index];
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
return tree;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default transform;
|
export default transform;
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { isNode } from './util.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visits a tree structure breadth-first
|
||||||
|
* @param {Object} tree - The tree to be visited.
|
||||||
|
* @param {Function} visitor - The visitor function.
|
||||||
|
* @returns {Promise} A promise that resolves to the transformed tree.
|
||||||
|
*/
|
||||||
|
export async function visit(node, visitor) {
|
||||||
|
if (!isNode(node)) {
|
||||||
|
throw new Error('visit: node must be an AST node');
|
||||||
|
}
|
||||||
|
node.children = (await Promise.all((node?.children || [])
|
||||||
|
.map(child => visit(child, visitor)))).filter(Boolean);
|
||||||
|
if (node.type === 'root') { // don't transform root
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return visitor(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a visitor function from a set of transformers.
|
||||||
|
* Transformers are functions that accept a node and return/resolves either:
|
||||||
|
* - a new node
|
||||||
|
* - null to delete the node
|
||||||
|
* - undefined to leave the node as-is
|
||||||
|
* @param {Array} transformers - An array of transformer functions.
|
||||||
|
* @returns {Function} A visitor function.
|
||||||
|
*/
|
||||||
|
export function makeVisitor(transformers) {
|
||||||
|
return async (node) => {
|
||||||
|
let workingNode = node;
|
||||||
|
for (let transformer of transformers) {
|
||||||
|
|
||||||
|
// workingNode should be a valid node
|
||||||
|
if (!isNode(workingNode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await transformer(workingNode);
|
||||||
|
|
||||||
|
// If the function returned null, just skip any further processing.
|
||||||
|
if (result === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the function returned 'undefined', this means that there's no
|
||||||
|
// specific definition for how the node should be transformed.
|
||||||
|
// Therefore, we do not transform it. We leave it as-is and pass it
|
||||||
|
// to the next transformer.
|
||||||
|
else if (result === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the function returned an AST node, we replace the working node
|
||||||
|
// with that AST node and continue processing with the new node.
|
||||||
|
else if (isNode(result)) {
|
||||||
|
workingNode = result;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For anything else, throw an error.
|
||||||
|
throw new Error(`Invalid transformer result: ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
return workingNode; // identity function if no transformers
|
||||||
|
};
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ describe("transform", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should act as an identity function if there are no tx fxns", () => {
|
it("should act as an identity function if there are no tx fxns", () => {
|
||||||
expect(transform(ast_emptyDiv)).toEqual({
|
expect(transform(ast_emptyDiv)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
|
@ -37,7 +37,7 @@ describe("transform", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should leave the ast untouched for undefined", () => {
|
it("should leave the ast untouched for undefined", () => {
|
||||||
expect(transform(ast_emptyDiv, () => undefined)).toEqual({
|
expect(transform(ast_emptyDiv, () => undefined)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
|
@ -49,23 +49,28 @@ describe("transform", () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove the node for null", () => {
|
it("should remove the node for null", () => {
|
||||||
expect(transform(ast_emptyDiv, () => null)).toEqual({
|
const tx = (node) => {
|
||||||
|
if (node.tagName === "div") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
expect(transform(ast_emptyDiv, tx)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [],
|
"children": [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should barf if non-AST is returned", () => {
|
it("should barf if non-AST is returned", async () => {
|
||||||
expect(() => {transform(ast_emptyDiv, () => "pigs!")}).toThrow();
|
await expect(transform(ast_emptyDiv, () => "pigs!")).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should replace old nodes with transformed nodes", () => {
|
it("should replace old nodes with transformed nodes", () => {
|
||||||
expect(transform(
|
expect(transform(
|
||||||
ast_emptyDiv,
|
ast_emptyDiv,
|
||||||
() => ({type: "element", tagName: "div", children: []})
|
() => ({type: "element", tagName: "div", children: []})
|
||||||
)).toEqual({
|
)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
|
@ -103,7 +108,7 @@ describe("transform", () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)).toEqual({
|
)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
|
@ -141,7 +146,7 @@ describe("transform", () => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)).toEqual({
|
)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
|
@ -158,7 +163,7 @@ describe("transform", () => {
|
||||||
ast_emptyDiv,
|
ast_emptyDiv,
|
||||||
() => null,
|
() => null,
|
||||||
() => ({type: "element", tagName: "div", children: []})
|
() => ({type: "element", tagName: "div", children: []})
|
||||||
)).toEqual({
|
)).resolves.toEqual({
|
||||||
"type": "root",
|
"type": "root",
|
||||||
"children": [],
|
"children": [],
|
||||||
});
|
});
|
||||||
|
@ -166,7 +171,7 @@ describe("transform", () => {
|
||||||
|
|
||||||
it("should handle multiple tx fxns", () => {
|
it("should handle multiple tx fxns", () => {
|
||||||
const output = transform(ast_emptyDiv, () => {return}, () => null);
|
const output = transform(ast_emptyDiv, () => {return}, () => null);
|
||||||
expect(output).toEqual({
|
expect(output).resolves.toEqual({
|
||||||
type: "root",
|
type: "root",
|
||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { visit, makeVisitor } from '../src/visit.mjs';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
const mockTree = () => ({
|
||||||
|
type: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { id: 'foo' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { id: 'bar' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visit', () => {
|
||||||
|
|
||||||
|
it('should call a visitor function on children, then parent', async () => {
|
||||||
|
const visitor = jest.fn(x => x);
|
||||||
|
await visit(mockTree(), visitor);
|
||||||
|
expect(visitor.mock.calls[0][0].properties.id).toBe('foo');
|
||||||
|
expect(visitor.mock.calls[1][0].properties.id).toBe('bar');
|
||||||
|
// root node should NOT be transformed
|
||||||
|
expect(visitor.mock.calls.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes nodes if visitor returns null', async () => {
|
||||||
|
// return null if id is foo
|
||||||
|
const visitor = jest.fn(x => {
|
||||||
|
if (x?.properties?.id === 'foo') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
const result = await visit(mockTree(), visitor);
|
||||||
|
expect(result.children).toEqual([{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { id: 'bar' },
|
||||||
|
children: [],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nodes without children', async () => {
|
||||||
|
const visitor = jest.fn(async x => x);
|
||||||
|
const result = await visit({
|
||||||
|
type: 'root',
|
||||||
|
}, visitor);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'root',
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeVisitor', () => {
|
||||||
|
|
||||||
|
it('should return a visitor function', () => {
|
||||||
|
const visitor = makeVisitor([]);
|
||||||
|
expect(visitor).toBeDefined();
|
||||||
|
expect(typeof visitor).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows transformers to manipulate nodes', async () => {
|
||||||
|
const transformers = [
|
||||||
|
jest.fn(x => x),
|
||||||
|
jest.fn(x => {
|
||||||
|
// set foo=bar if id=foo
|
||||||
|
if (x?.properties?.id === 'foo') {
|
||||||
|
return {
|
||||||
|
...x,
|
||||||
|
properties: {
|
||||||
|
...x.properties,
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const visitor = makeVisitor(transformers);
|
||||||
|
const result = await visit(mockTree(), visitor);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { id: 'foo', foo: 'bar' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { id: 'bar' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows transformers to remove nodes', async () => {
|
||||||
|
const transformers = [
|
||||||
|
jest.fn(x => {
|
||||||
|
if (x?.properties?.id === 'foo') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const visitor = makeVisitor(transformers);
|
||||||
|
const result = await visit(mockTree(), visitor);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { id: 'bar' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when encountering a non-node', async () => {
|
||||||
|
const transformers = [
|
||||||
|
jest.fn(x => x),
|
||||||
|
];
|
||||||
|
const visitor = makeVisitor(transformers);
|
||||||
|
await expect(visit({
|
||||||
|
type: 'root',
|
||||||
|
children: [null],
|
||||||
|
}, visitor)).rejects.toThrow('visit: node must be an AST node');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nullifies non-nodes', async () => {
|
||||||
|
const visitor = makeVisitor([jest.fn(x => x)]);
|
||||||
|
expect(visitor(null)).resolves.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves nodes untouched if transformer returns undefined', async () => {
|
||||||
|
const visitor = makeVisitor([jest.fn(() => undefined)]);
|
||||||
|
const result = await visit(mockTree(), visitor);
|
||||||
|
expect(result).toEqual(mockTree());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if a transformer returns strange data', async () => {
|
||||||
|
const visitor = makeVisitor([jest.fn(() => 'foo')]);
|
||||||
|
await expect(visit(mockTree(), visitor))
|
||||||
|
.rejects.toThrow('Invalid transformer result: "foo"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awaits visitor functions', async () => {
|
||||||
|
// asyncronously add property to element id=foo
|
||||||
|
const fn = jest.fn(async x => {
|
||||||
|
if (x?.properties?.id === 'foo') {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
return {
|
||||||
|
...x,
|
||||||
|
properties: {
|
||||||
|
...x.properties,
|
||||||
|
awaited: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
const visitor = makeVisitor([fn]);
|
||||||
|
const result = await visit(mockTree(), visitor);
|
||||||
|
expect(result.children[0].properties.awaited).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue