@muban/muban
Version:
Writing components for server-rendered HTML
172 lines (171 loc) • 9.69 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { watch, watchEffect } from '@vue/runtime-core';
import { ref, unref } from '@vue/reactivity';
import { extractFromHTML } from 'html-extract-data';
import typedObjectEntries from '../type-utils/typedObjectEntries';
import typedObjectKeys from '../type-utils/typedObjectKeys';
import { devtoolsComponentUpdated } from '../utils/devtools';
import { recursiveUnref } from '../utils/utils';
import { bindingsList } from './bindings';
function createBindingsHelpers(binding) {
// used to trigger the "has" and "get" bindings when a new binding is added
const bindingProps = ref(typedObjectKeys(binding.props));
return {
hasBinding: (bindingName) => bindingProps.value.includes(bindingName),
getBinding: (bindingName) => bindingProps.value && binding.props[bindingName],
setBinding: (bindingName, bindingValue) => {
// eslint-disable-next-line no-param-reassign
binding.props[bindingName] = bindingValue;
bindingProps.value = typedObjectKeys(binding.props);
},
};
}
export const applyBindings = (bindings, instance) => {
if (bindings) {
return bindings.flatMap((binding) => {
// TODO: Devtools only
watchEffect(() => {
// "trigger" all observables for this binding by unwrapping it
recursiveUnref(binding.props);
// update this component instance itself
devtoolsComponentUpdated(instance);
});
if (binding.type === 'element') {
return watch(() => unref(binding.ref), (element, oldValue, onInvalidate) => {
const bindingHelpers = createBindingsHelpers(binding);
// eslint-disable-next-line no-shadow
const bindings = typedObjectEntries(binding.props).flatMap(([bindingName, bindingValue]) => {
var _a, _b;
if (!(bindingName in bindingsList)) {
// eslint-disable-next-line no-console
console.warn(`No binding exists for "${bindingName}", only supported bindings are [${Object.keys(bindingsList)}]`);
}
else if (element) {
return (_b = (_a = bindingsList)[bindingName]) === null || _b === void 0 ? void 0 : _b.call(_a, element, bindingValue, bindingHelpers);
}
return undefined;
});
onInvalidate(() => {
// TODO debug
// eslint-disable-next-line no-shadow
bindings.forEach((binding) => binding && binding());
});
}, { immediate: true });
}
if (binding.type === 'collection') {
return watch(
// eslint-disable-next-line no-shadow
() => unref(binding.ref).map((ref) => unref(ref)), (elements, oldValue, onInvalidate) => {
const bindingHelpers = createBindingsHelpers(binding);
// eslint-disable-next-line no-shadow
const bindings = typedObjectEntries(binding.props).flatMap(([bindingName, bindingValue]) => {
if (!(bindingName in bindingsList)) {
// eslint-disable-next-line no-console
console.warn(`No binding exists for "${bindingName}", only supported bindings are [${Object.keys(bindingsList)}]`);
}
else if (elements) {
return elements.flatMap((element) => {
var _a, _b;
return (_b = (_a = bindingsList)[bindingName]) === null || _b === void 0 ? void 0 : _b.call(_a, element, bindingValue, bindingHelpers);
});
}
return undefined;
});
onInvalidate(() => {
// eslint-disable-next-line no-shadow
bindings.forEach((binding) => binding && binding());
});
}, { immediate: true });
}
if (binding.type === 'component') {
typedObjectEntries(binding.props).forEach(([propName, bindingValue]) => {
watchEffect(() => {
var _a;
if (propName === '$element') {
typedObjectEntries(bindingValue).forEach(([elementBindingKey, elementBindingValue]) => {
var _a, _b;
if (['css', 'style', 'attr', 'event'].includes(elementBindingKey)) {
const element = (_a = unref(binding.ref)) === null || _a === void 0 ? void 0 : _a.element;
if (element) {
(_b = bindingsList[elementBindingKey]) === null || _b === void 0 ? void 0 : _b.call(bindingsList, element, elementBindingValue);
}
}
});
}
else {
(_a = unref(binding.ref)) === null || _a === void 0 ? void 0 : _a.setProps({
[propName]: unref(bindingValue),
});
}
});
});
}
else if (binding.type === 'componentCollection') {
typedObjectEntries(binding.props).forEach(([propName, bindingValue]) => {
watchEffect(() => {
const reff = unref(binding.ref).map((r) => unref(r));
// eslint-disable-next-line no-shadow
reff === null || reff === void 0 ? void 0 : reff.forEach((ref) => {
watchEffect(() => {
if (propName === '$element') {
typedObjectEntries(bindingValue).forEach(([elementBindingKey, elementBindingValue]) => {
var _a;
if (['css', 'style', 'attr', 'event'].includes(elementBindingKey)) {
(_a = bindingsList[elementBindingKey]) === null || _a === void 0 ? void 0 : _a.call(bindingsList, unref(ref).element, elementBindingValue);
}
});
}
else {
ref === null || ref === void 0 ? void 0 : ref.setProps({
[propName]: unref(bindingValue),
});
}
});
});
});
});
}
else if (binding.type === 'template') {
// eslint-disable-next-line no-shadow
const { ref, extract, forceImmediateRender, onUpdate } = binding.props;
let initialRender = true;
let containerIsEmpty = false;
if (ref && ref.element) {
if (extract) {
const extracted = extractFromHTML(ref.element, extract.config);
extract.onData(extracted);
}
// if the container is empty, we probably want to do an initial render
// otherwise, we might want to leave the container as-is initially
containerIsEmpty = ref.element.innerHTML.trim() === '';
}
watchEffect(() => {
if (ref === null || ref === void 0 ? void 0 : ref.element) {
// if neither of these are true, we should not do an initial render,
// but instead only "watch" the data in the onUpdate function
const shouldRenderUpdate = containerIsEmpty || forceImmediateRender || !initialRender;
// TODO: attach parent component for context
// pass along how the result of the update function is going to be used,
// so the implementation can conditionally only invoke the watched observables, but
// omit the template rendering
const result = onUpdate(!shouldRenderUpdate);
if (shouldRenderUpdate) {
ref.element.innerHTML = Array.isArray(result) ? result.join('') : result !== null && result !== void 0 ? result : '';
}
initialRender = false;
// TODO: make nicer?
// it takes some time for the MutationObserver to detect the newly added DOM elements
// for the "ref" watcher to update and instantiate and add the new children
setTimeout(() => {
instance.children.forEach((component) => component.setup());
}, 1);
}
});
}
else if (binding.type === 'bindMap') {
return binding.dispose;
}
return undefined;
});
}
};