Skip to content

Commit

Permalink
proof-of-concept for a plugin system.
Browse files Browse the repository at this point in the history
  • Loading branch information
botandrose-machine committed Feb 15, 2025
1 parent 7b4f427 commit 86374c5
Show file tree
Hide file tree
Showing 4 changed files with 271 additions and 15 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,26 @@ Idiomorph.morph(document.documentElement, newPageSource, {head:{style: 'morph'}}

The `head` object also offers callbacks for configuring head merging specifics.

### Plugins

Idiomorph supports a plugin system that allows you to extend the functionality of the library, by registering an object of callbacks:

```js
Idiomorph.registerPlugin({
name: 'logger',
onBeforeNodeAdded: function(node) {
console.log('Node added:', node);
},
onBeforeNodeRemoved: function(node) {
console.log('Node removed:', node);
},
});

Idiomorph.plugins // { logger: { ...} };
```

These callbacks will be called in addition to any other callbacks that are registered in `Idiomorph.morph`. Multiple plugins can be registered.

### Setting Defaults

All the behaviors specified above can be set to a different default by mutating the `Idiomorph.defaults` object, including
Expand Down
56 changes: 41 additions & 15 deletions src/idiomorph.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ var Idiomorph = (function () {
restoreFocus: true,
};

let plugins = {};
function addPlugin(plugin) {

Check failure on line 148 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'plugin' implicitly has an 'any' type.

Check failure on line 148 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'plugin' implicitly has an 'any' type.
plugins[plugin.name] = plugin;

Check failure on line 149 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}'.

Check failure on line 149 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{}'.
}

/**
* Core idiomorph function for morphing one DOM tree to another
*
Expand Down Expand Up @@ -348,6 +353,26 @@ var Idiomorph = (function () {
}
}

function withNodeCallbacks(ctx, name, node, fn) {

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'ctx' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'name' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'node' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'fn' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'ctx' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'name' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'node' implicitly has an 'any' type.

Check failure on line 356 in src/idiomorph.js

View workflow job for this annotation

GitHub Actions / typecheck

Parameter 'fn' implicitly has an 'any' type.
const allPlugins = [...Object.values(plugins), ctx.callbacks];

const shouldAbort = allPlugins.some((plugin) => {
const beforeFn = plugin[`beforeNode${name}`];
return beforeFn && beforeFn(node) === false;
});

if (shouldAbort) return;

const resultNode = fn();

allPlugins.reverse().forEach((plugin) => {
const afterFn = plugin[`afterNode${name}`];
afterFn && afterFn(resultNode);
});

return resultNode;
}

/**
* This performs the action of inserting a new node while handling situations where the node contains
* elements with persistent ids and possible state info we can still preserve by moving in and then morphing
Expand All @@ -359,21 +384,20 @@ var Idiomorph = (function () {
* @returns {Node|null}
*/
function createNode(oldParent, newChild, insertionPoint, ctx) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
if (ctx.idMap.has(newChild) && newChild instanceof Element) {
// node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
const newEmptyChild = document.createElement(newChild.tagName);
oldParent.insertBefore(newEmptyChild, insertionPoint);
morphNode(newEmptyChild, newChild, ctx);
ctx.callbacks.afterNodeAdded(newEmptyChild);
return newEmptyChild;
} else {
// optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
oldParent.insertBefore(newClonedChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newClonedChild);
return newClonedChild;
}
return withNodeCallbacks(ctx, "Added", newChild, () => {
if (ctx.idMap.has(newChild) && newChild instanceof Element) {
// node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
const newEmptyChild = document.createElement(newChild.tagName);
oldParent.insertBefore(newEmptyChild, insertionPoint);
morphNode(newEmptyChild, newChild, ctx);
return newEmptyChild;
} else {
// optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
oldParent.insertBefore(newClonedChild, insertionPoint);
return newClonedChild;
}
});
}

//=============================================================================
Expand Down Expand Up @@ -1277,5 +1301,7 @@ var Idiomorph = (function () {
return {
morph,
defaults,
addPlugin,
plugins,
};
})();
1 change: 1 addition & 0 deletions test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ <h2>Mocha Test Suite</h2>
<script src="hooks.js"></script>
<script src="htmx-integration.js"></script>
<script src="ops.js"></script>
<script src="plugins.js"></script>
<script src="preserve-focus.js"></script>
<script src="restore-focus.js"></script>
<script src="retain-hidden-state.js"></script>
Expand Down
209 changes: 209 additions & 0 deletions test/plugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
describe("Plugin system", function () {
setup();

afterEach(() => {
const obj = Idiomorph.plugins;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
delete obj[key];
}
}
});

it("can add plugins", function () {
let calls = [];

const plugin = {
name: "foo",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin);
Idiomorph.plugins.foo.should.equal(plugin);

Idiomorph.morph(make("<p>"), "<p><hr>");

calls.should.eql([
["beforeNodeAdded", "<hr>"],
["afterNodeAdded", "<hr>"],
]);
});

it("can add multiple plugins", function () {
let calls = [];

const plugin1 = {
name: "foo",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded1", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded1", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin1);

const plugin2 = {
name: "bar",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded2", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded2", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin2);

Idiomorph.plugins.foo.should.equal(plugin1);
Idiomorph.plugins.bar.should.equal(plugin2);

Idiomorph.morph(make("<p>"), "<p><hr>");

calls.should.eql([
["beforeNodeAdded1", "<hr>"],
["beforeNodeAdded2", "<hr>"],
["afterNodeAdded2", "<hr>"],
["afterNodeAdded1", "<hr>"],
]);
});

it("can add multiple plugins alongside callbacks", function () {
let calls = [];

const plugin1 = {
name: "foo",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded1", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded1", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin1);

const plugin2 = {
name: "bar",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded2", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded2", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin2);

Idiomorph.plugins.foo.should.equal(plugin1);
Idiomorph.plugins.bar.should.equal(plugin2);

Idiomorph.morph(make("<p>"), "<p><hr>", {
callbacks: {
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAddedCallback", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAddedCallback", node.outerHTML]);
},
},
});

calls.should.eql([
["beforeNodeAdded1", "<hr>"],
["beforeNodeAdded2", "<hr>"],
["beforeNodeAddedCallback", "<hr>"],
["afterNodeAddedCallback", "<hr>"],
["afterNodeAdded2", "<hr>"],
["afterNodeAdded1", "<hr>"],
]);
});

it("the first beforeNodeAdded => false halts the entire operation", function () {
let calls = [];

const plugin1 = {
name: "foo",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded1", node.outerHTML]);
return false;
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded1", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin1);

const plugin2 = {
name: "bar",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded2", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded2", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin2);

Idiomorph.plugins.foo.should.equal(plugin1);
Idiomorph.plugins.bar.should.equal(plugin2);

Idiomorph.morph(make("<p>"), "<p><hr>", {
callbacks: {
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAddedCallback", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAddedCallback", node.outerHTML]);
},
},
});

calls.should.eql([
["beforeNodeAdded1", "<hr>"]
]);
});

it("plugin callbacks are not all required to exist", function () {
let calls = [];

const plugin1 = {
name: "foo",
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAdded1", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAdded1", node.outerHTML]);
},
};
Idiomorph.addPlugin(plugin1);

const plugin2 = {
name: "bar",
};
Idiomorph.addPlugin(plugin2);

Idiomorph.plugins.foo.should.equal(plugin1);
Idiomorph.plugins.bar.should.equal(plugin2);

Idiomorph.morph(make("<p>"), "<p><hr>", {
callbacks: {
beforeNodeAdded: function (node) {
calls.push(["beforeNodeAddedCallback", node.outerHTML]);
},
afterNodeAdded: function (node) {
calls.push(["afterNodeAddedCallback", node.outerHTML]);
},
},
});

calls.should.eql([
["beforeNodeAdded1", "<hr>"],
["beforeNodeAddedCallback", "<hr>"],
["afterNodeAddedCallback", "<hr>"],
["afterNodeAdded1", "<hr>"],
]);
});
});

0 comments on commit 86374c5

Please sign in to comment.