Last active
October 6, 2023 13:01
-
-
Save vdsabev/71652e95666e52210aa6837993638219 to your computer and use it in GitHub Desktop.
Mutagen - a mini templating engine based on htmx and Mutation Observer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(() => { | |
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(); | |
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<h2>{{user.name}}</h2> | |
<img :src="user.pictureUrl" /> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 insrc
it useshx-get
to load it and stick it in a hidden container (seeloadTemplate
). 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 withhx-get
doesn't update the template'scontent
orinnerHTML
- 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'sinnerHTML
. I also usedisplay: contents
on the component so themg-component
element doesn't affect styling if you're in a grid or flex container.