Compare commits
2 Commits
master
...
promise-tr
Author | SHA1 | Date |
---|---|---|
Kenneth Barbour | 6dac05f3ea | |
Kenneth Barbour | b7201264a5 |
|
@ -2,6 +2,7 @@ module.exports = {
|
|||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: [
|
||||
"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
|
||||
// fragment. First, let's transform the div to a span.
|
||||
|
||||
let updated = transform(
|
||||
let updated = await transform(
|
||||
// our initial syntax tree
|
||||
ast,
|
||||
// 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
|
||||
* (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.
|
||||
|
@ -16,68 +15,7 @@ import { isNode } from './util.mjs';
|
|||
* @returns {Object} The transformed AST.
|
||||
*/
|
||||
export function transform (ast, ...transformers) {
|
||||
let tree = JSON.parse(JSON.stringify(ast));
|
||||
|
||||
// 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;
|
||||
return visit(JSON.parse(JSON.stringify(ast)), makeVisitor(transformers));
|
||||
}
|
||||
|
||||
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", () => {
|
||||
expect(transform(ast_emptyDiv)).toEqual({
|
||||
expect(transform(ast_emptyDiv)).resolves.toEqual({
|
||||
"type": "root",
|
||||
"children": [
|
||||
{
|
||||
|
@ -37,7 +37,7 @@ describe("transform", () => {
|
|||
});
|
||||
|
||||
it("should leave the ast untouched for undefined", () => {
|
||||
expect(transform(ast_emptyDiv, () => undefined)).toEqual({
|
||||
expect(transform(ast_emptyDiv, () => undefined)).resolves.toEqual({
|
||||
"type": "root",
|
||||
"children": [
|
||||
{
|
||||
|
@ -49,23 +49,28 @@ describe("transform", () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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",
|
||||
"children": [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should barf if non-AST is returned", () => {
|
||||
expect(() => {transform(ast_emptyDiv, () => "pigs!")}).toThrow();
|
||||
it("should barf if non-AST is returned", async () => {
|
||||
await expect(transform(ast_emptyDiv, () => "pigs!")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should replace old nodes with transformed nodes", () => {
|
||||
expect(transform(
|
||||
ast_emptyDiv,
|
||||
() => ({type: "element", tagName: "div", children: []})
|
||||
)).toEqual({
|
||||
)).resolves.toEqual({
|
||||
"type": "root",
|
||||
"children": [
|
||||
{
|
||||
|
@ -103,7 +108,7 @@ describe("transform", () => {
|
|||
};
|
||||
}
|
||||
}
|
||||
)).toEqual({
|
||||
)).resolves.toEqual({
|
||||
"type": "root",
|
||||
"children": [
|
||||
{
|
||||
|
@ -141,7 +146,7 @@ describe("transform", () => {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
)).toEqual({
|
||||
)).resolves.toEqual({
|
||||
"type": "root",
|
||||
"children": [
|
||||
{
|
||||
|
@ -158,7 +163,7 @@ describe("transform", () => {
|
|||
ast_emptyDiv,
|
||||
() => null,
|
||||
() => ({type: "element", tagName: "div", children: []})
|
||||
)).toEqual({
|
||||
)).resolves.toEqual({
|
||||
"type": "root",
|
||||
"children": [],
|
||||
});
|
||||
|
@ -166,7 +171,7 @@ describe("transform", () => {
|
|||
|
||||
it("should handle multiple tx fxns", () => {
|
||||
const output = transform(ast_emptyDiv, () => {return}, () => null);
|
||||
expect(output).toEqual({
|
||||
expect(output).resolves.toEqual({
|
||||
type: "root",
|
||||
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