@ou-imdt/utils
Version:
Utility library for interactive media development
246 lines (206 loc) • 8.01 kB
JavaScript
import { default as Base, defaultState } from '../class/Base.js';
import Subber from './SubberModule';
import setObjectProperty from '../setObjectProperty.js';
import getObjectProperty from '../getObjectProperty.js';
export const subber = Symbol('SubberModule');
export default class TemplateEngineModule extends Base {
static get [defaultState]() {
return {
templateOptions: {},
templateData: {},
subberOptions: null
};
};
#renderedContent = [];
constructor() {
super();
this[subber] = new Subber();
}
get subberOptions() {
return this[subber].state;
}
set subberOptions(options = null) {
if (options === null) return;
this[subber].state = options;
}
get _templatesAsData() {
// return this.templateOptions.reduce((result, { name, content }) => Object.assign(result, { [name]: content }));
return Object.fromEntries(this.templateOptions.map(({ name, content }) => [name, content]));
}
update() {
// console.log('update template engine..............', this.templateOptions);
const rendered = this.#renderedContent.slice(0);
this._validateTemplateOptions();
for (let options of this.templateOptions) {
const template = this._renderTemplate(options);
if (rendered.includes(template)) rendered.splice(rendered.indexOf(template), 1);
if (!this.#renderedContent.includes(template)) this.#renderedContent.push(template);
}
// remove remaining
rendered.forEach(el => this._removeTemplate(el));
// console.log('...................templates updated');
}
_validateTemplateOptions() {
// console.log('validate template options', this.templateOptions);
this.templateOptions.forEach((options, index, array) => {
const { name, target } = options;
if (typeof name !== 'string' || name.length === 0) {
throw new Error(`invalid template name [${name}]: templates must have a unique name`);
}
if (array.indexOf(array.find(el => el.name === name)) !== index) {
throw new Error(`duplicate template name [${name}]: templates must have a unique name`);
}
if (!(target instanceof Element)) {
throw new Error(`invalid template target [${name}]: templates must have a target Element`);
}
});
}
/**
*
* @param {object} options - gets
* @param {string} name - template name
* @returns {object} - template if found or null
*/
_getTemplate(name) {
// console.log('get template:', name, this.#renderedContent);
return this.#renderedContent.find(el => el.name === name) ?? null;
}
/**
* gets the data for the template with name, or all data if name undefined
* templateData.root assigned to returned data, with remaining values assigned to data.root
* aliases are resolved within remaining values, i.e. value.alias
* @param {string} name - template name
* @returns {object}
*/
_getTemplateData(name) {
const root = this.templateData;
const { alias, templates, [`${name}Overrides`]: overrides = {}, ...rest } = root;
const { alias: aliasOverrides, templates: templatesOverrides, ...overridesRest } = overrides;
const aliased = this.resolveAliases({ alias: { ...alias, ...aliasOverrides }, ...rest, ...overridesRest });
return {
root,
templates: {
...this._templatesAsData,
...templates,
...templatesOverrides
},
...aliased
};
}
/**
* renders a template with options, creating new or using existing if found
* existing templates only updated if content has changed
* @param {object} options
* @returns
*/
_renderTemplate(options) {
const template = this._getTemplate(options.name);
// console.log('render template:', options.name, template);
// if active append/remove from dom but retain template
if (template) {
this._updateTemplate(template, options);
return template;
}
return this._createTemplate(options);
}
/**
* creates a template instance with the given options and appends result to target
* @param {Object} optionList - options for template
* @param {string} optionList.name - the template name
* @param {string} optionList.content - the template content
* @param {Element} optionList.target - element to append result
* @param {string} [optionList.valueTarget] - element to append values (e.g. shadow host)
*/
_createTemplate(options) {
// console.log(`create template:', options.name, options);
const { name, content, target, valueTarget = null } = options;
const values = [];
const data = this._getTemplateData(name);
const callback = (context) => {
const { value: content } = context;
const fragment = this._createFragment(content || '<template></template>'); // ensure target for updates
const nodes = [...fragment.childNodes];
if (valueTarget) {
valueTarget.append(...nodes);
return '';
}
values.push({ context, content, nodes });
return fragment;
}
const fragment = this[subber].dom(content, data, callback);
const nodes = [...fragment.childNodes];
const template = { ...options, values, nodes };
target.append(fragment);
return template;
}
/**
* updates template values, initially refreshs template content if updated
* @param {object} template - template instance
* @param {object} options - template options
*/
_updateTemplate(template, options) {
// console.log('update template:', options);
const { name } = template;
const data = this._getTemplateData(name);
const hasChanged = (JSON.stringify(options.content) !== JSON.stringify(template?.content));
if (hasChanged) {
template.nodes.forEach(el => el.remove());
// refresh template with options - new content, context etc.
Object.assign(template, this._createTemplate(options));
}
template.values.forEach(options => {
const { context } = options;
const resolved = this[subber].resolveValue(context.path, data);
const content = (typeof resolved === 'function') ? resolved({ ...context, data }) : resolved;
const unChanged = (content === options.content);
if (unChanged) return;
// move to value.update?
const fragment = this._createFragment(content);
const nodes = [...fragment.childNodes];
const [target, ...rest] = options.nodes;
// replace existing target node with updated
target.replaceWith(...nodes);
// remove remaining nodes
rest.forEach(el => el.remove());
// update options
Object.assign(options, { content, nodes });
});
}
/**
* removes stored template and all asssociated elements from DOM
* @param {object} template
*/
_removeTemplate(template) {
// console.log('remove template', options);
const { values } = template;
const rendered = this.#renderedContent;
const nodes = [...template.nodes, ...values.flatMap(({ nodes }) => nodes)];
nodes.forEach(el => el.remove());
rendered.splice(rendered.indexOf(template), 1);
}
_createFragment(content) {
return document.createRange().createContextualFragment(content);
}
resolveAliases(data) {
return Object.entries(data).reduce((result, [key, value]) => {
if (key === 'alias') {
const expanded = Object.entries(value).flatMap(([name, path]) => {
const destructure = name.includes('|');
return name.replace(/\s+/g, '').split('|').map(prop => {
return [prop, (destructure ? `${path}.${prop}` : path)]
});
});
for (const [name, path] of expanded) {
setObjectProperty(result, name, getObjectProperty(data, path) ?? null);
}
} else {
result[key] = value;
}
return result;
}, {});
}
reset() {
const rendered = this.#renderedContent.splice(0);
rendered.forEach(el => this._removeTemplate(el));
}
}