Compare commits

...

2 Commits

Author SHA1 Message Date
Kenneth Barbour 6dac05f3ea Add a test for asynchronous visitor 2024-02-26 15:20:14 -05:00
Kenneth Barbour b7201264a5 support for async transformers
This includes a visit function that supports transformers that return promises.  The AST tree is traversed in a breadth-first order to allow parallel transformations.
2024-02-23 16:13:36 -05:00
6 changed files with 267 additions and 76 deletions

View File

@ -2,6 +2,7 @@ module.exports = {
env: { env: {
node: true, node: true,
jest: true, jest: true,
es6: true,
}, },
extends: [ extends: [
"eslint:recommended", "eslint:recommended",

View File

@ -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

View File

@ -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;

67
src/visit.mjs 100644
View File

@ -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
};
}

View File

@ -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: [],
}); });

180
test/visit.test.mjs 100644
View File

@ -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);
});
});