Skip to content

Instantly share code, notes, and snippets.

@vdsabev
Last active October 6, 2023 13:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vdsabev/71652e95666e52210aa6837993638219 to your computer and use it in GitHub Desktop.
Save vdsabev/71652e95666e52210aa6837993638219 to your computer and use it in GitHub Desktop.
Mutagen - a mini templating engine based on htmx and Mutation Observer
<!DOCTYPE html>
<html>
<head>
<script src="/mutagen.js"></script>
</head>
<body>
<mg-component src="/profile.html" data='{ "user": { "name": "John Doe", "pictureUrl": "https://placehold.co/128" } }' />
</body>
</html>
(() => {
if (window.mutagen) {
return;
}
/** @type {Record<string, Set<HTMLElement>>} */
const components = {}; // TODO: Remove component from cache after removing it from DOM
function observe() {
new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
getAllChildNodes(mutation.addedNodes)
.filter((node) => node.tagName === 'MG-COMPONENT')
.forEach((component) => {
component.style.display = 'none'; // Hide component until render
const src = component.getAttribute('src');
if (!components[src]) {
components[src] = new Set();
loadTemplate(src);
}
components[src].add(component);
});
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** @returns {Node[]} */
function getAllChildNodes(/** @type {NodeList} */ childNodes) {
return [...childNodes].flatMap((childNode) => [
childNode,
...getAllChildNodes(childNode.childNodes),
]);
}
function loadTemplate(/** @type {string} */ src) {
let container = document.getElementById('mutagen-templates');
if (!container) {
container = document.createElement('div');
container.id = 'mutagen-templates';
container.style.display = 'none';
document.body.prepend(container);
}
const template = document.createElement('div');
template.setAttribute('hx-get', src);
template.setAttribute('hx-trigger', 'load');
template.setAttribute('hx-on::after-settle', 'mutagen.render(this)');
container.appendChild(template);
}
function render(/** @type {HTMLElement} */ template) {
const src = template.getAttribute('hx-get');
if (!components[src]) return;
for (const component of components[src]) {
const data = parseData(component.getAttribute('data'));
let processedTemplate = template.innerHTML;
// Process {{text}}
let textMatch;
while (
(textMatch = /{{(\w+(?:\.\w+|\['\w+'\])*)}}/g.exec(processedTemplate))
) {
processedTemplate = processedTemplate.replaceAll(
textMatch[0],
get(data, textMatch[1])
);
}
// Process :attributes
let attributeMatch;
while (
(attributeMatch = /:(\w+)="(\w+(?:\.\w+|\['\w+'\])*)"/g.exec(
processedTemplate
))
) {
processedTemplate = processedTemplate.replaceAll(
attributeMatch[0],
`${attributeMatch[1]}="${get(data, attributeMatch[2])}"`
);
}
component.innerHTML = processedTemplate;
component.style.display = 'contents'; // Show component after render - use `display: contents` to avoid the root element affecting styling
}
}
/** @returns {Record<string, any>} */
function parseData(/** @type {string} */ dataString) {
if (dataString) {
try {
return JSON.parse(dataString);
} catch (error) {
console.error('Invalid mg-component data:', dataString);
}
}
return {};
}
function get(/** Record<string, any> */ data, /** @type {string} */ path) {
const parts = path
.match(/\w+|'\w+'/g)
.map((part) => part.replace(/^'|'$/g, ''));
let result = data;
let index = 0;
while (
index < parts.length &&
typeof result === 'object' &&
result != null
) {
result = result[parts[index]];
index++;
}
return result;
}
window.mutagen = { render };
observe();
})();
<h2>{{user.name}}</h2>
<img :src="user.pictureUrl" />
@vdsabev
Copy link
Author

vdsabev commented Sep 9, 2023

Inspired by how https://github.com/gnat/css-scope-inline works, I did some experimenting with writing a template engine using https://github.com/bigskysoftware/htmx and the Mutation Observer API.

The end result is impractical for real-world use but still was fun to write.

Mutagen uses the Mutation Observer to watch for mg-component elements. The first time it sees a particular HTML file linked in src it uses hx-get to load it and stick it in a hidden container (see loadTemplate). However many times you include the same component, it will only make one request to the server to get the template.

I tried using the <template> tag but it turns out dynamically loading the contents with hx-get doesn't update the template's content or innerHTML - I think that's a browser limitation. So hidden <div> it was.

Then, after the DOM settles, I call the global mutagen.render function with the component element, find the relevant template, replace {{text}} and :attributes with values from the data, and insert that in the component's innerHTML. I also use display: contents on the component so the mg-component element doesn't affect styling if you're in a grid or flex container.

@gnat
Copy link

gnat commented Sep 9, 2023

Really cool!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment