UNPKG

malevic

Version:

Malevič.js - minimalistic reactive UI library

339 lines (327 loc) 9.66 kB
/* malevic@0.20.2 - Aug 10, 2024 */ function createPluginsStore() { const plugins = []; return { add(plugin) { plugins.push(plugin); return this; }, apply(props) { let result; let plugin; const usedPlugins = new Set(); for (let i = plugins.length - 1; i >= 0; i--) { plugin = plugins[i]; if (usedPlugins.has(plugin)) { continue; } result = plugin(props); if (result != null) { return result; } usedPlugins.add(plugin); } return null; }, delete(plugin) { for (let i = plugins.length - 1; i >= 0; i--) { if (plugins[i] === plugin) { plugins.splice(i, 1); break; } } return this; }, empty() { return plugins.length === 0; }, }; } function iterateComponentPlugins(type, pairs, iterator) { pairs .filter(([key]) => type[key]) .forEach(([key, plugins]) => { return type[key].forEach((plugin) => iterator(plugins, plugin)); }); } function addComponentPlugins(type, pairs) { iterateComponentPlugins(type, pairs, (plugins, plugin) => plugins.add(plugin)); } function deleteComponentPlugins(type, pairs) { iterateComponentPlugins(type, pairs, (plugins, plugin) => plugins.delete(plugin)); } function createPluginsAPI(key) { const api = { add(type, plugin) { if (!type[key]) { type[key] = []; } type[key].push(plugin); return api; }, }; return api; } function isObject(value) { return value != null && typeof value === 'object'; } function isSpec(x) { return isObject(x) && x.type != null && x.nodeType == null; } function isNodeSpec(x) { return isSpec(x) && typeof x.type === 'string'; } function isComponentSpec(x) { return isSpec(x) && typeof x.type === 'function'; } function classes(...args) { const classes = []; const process = (c) => { if (!c) return; if (typeof c === 'string') { classes.push(c); } else if (Array.isArray(c)) { c.forEach(process); } else if (typeof c === 'object') { classes.push(...Object.keys(c).filter((key) => Boolean(c[key]))); } }; args.forEach(process); return classes.join(' '); } function styles(declarations) { return Object.keys(declarations) .filter((cssProp) => declarations[cssProp] != null) .map((cssProp) => `${cssProp}: ${declarations[cssProp]};`) .join(' '); } function escapeHTML(s) { return s .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } const PLUGINS_STRINGIFY_ATTRIBUTE = Symbol(); const pluginsStringifyAttribute = createPluginsStore(); function stringifyAttribute(attr, value) { if (!pluginsStringifyAttribute.empty()) { const result = pluginsStringifyAttribute.apply({ attr, value }); if (result != null) { return result; } } if (attr === 'class' && isObject(value)) { const cls = Array.isArray(value) ? classes(...value) : classes(value); return escapeHTML(cls); } if (attr === 'style' && isObject(value)) { return escapeHTML(styles(value)); } if (value === true) { return ''; } return escapeHTML(String(value)); } const PLUGINS_SKIP_ATTRIBUTE = Symbol(); const pluginsSkipAttribute = createPluginsStore(); const specialAttrs = new Set([ 'key', 'oncreate', 'onupdate', 'onrender', 'onremove', ]); function shouldSkipAttribute(attr, value) { if (!pluginsSkipAttribute.empty()) { const result = pluginsSkipAttribute.apply({ attr, value }); if (result != null) { return result; } } return (specialAttrs.has(attr) || attr.startsWith('on') || value == null || value === false); } function processText(text) { return escapeHTML(text); } const PLUGINS_IS_VOID_TAG = Symbol(); const pluginsIsVoidTag = createPluginsStore(); function isVoidTag(tag) { if (!pluginsIsVoidTag.empty()) { const result = pluginsIsVoidTag.apply(tag); if (result != null) { return result; } } return voidTags.has(tag); } const voidTags = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr', ]); let currentContext = null; function getStringifyContext() { return currentContext; } function unbox(spec) { const Component = spec.type; const { props, children } = spec; const prevContext = currentContext; currentContext = {}; const result = Component(props, ...children); currentContext = prevContext; return result; } const stringifyPlugins = [ [PLUGINS_STRINGIFY_ATTRIBUTE, pluginsStringifyAttribute], [PLUGINS_SKIP_ATTRIBUTE, pluginsSkipAttribute], [PLUGINS_IS_VOID_TAG, pluginsIsVoidTag], ]; class VNode { } function leftPad(indent, repeats) { return ''.padEnd(indent.length * repeats, indent); } class VElement extends VNode { constructor(spec) { super(); this.children = []; this.tag = spec.type; this.attrs = new Map(); Object.entries(spec.props) .filter(([attr, value]) => !shouldSkipAttribute(attr, value)) .forEach(([attr, value]) => this.attrs.set(attr, stringifyAttribute(attr, value))); this.isVoid = isVoidTag(this.tag); } stringify({ indent, depth, xmlSelfClosing }) { const lines = []; const left = leftPad(indent, depth); const attrs = Array.from(this.attrs.entries()) .map(([attr, value]) => value === '' ? attr : `${attr}="${value}"`) .join(' '); const isEmptyXML = xmlSelfClosing && this.children.length === 0; const open = `${left}<${this.tag}${attrs ? ` ${attrs}` : ''}${isEmptyXML ? '/>' : '>'}`; if (this.isVoid || isEmptyXML) { lines.push(open); } else { const close = `</${this.tag}>`; if (this.children.length === 0) { lines.push(`${open}${close}`); } else if (this.children.length === 1 && this.children[0] instanceof VText && !this.children[0].text.includes('\n')) { lines.push(`${open}${this.children[0].stringify({ indent, depth: 0, xmlSelfClosing, })}${close}`); } else { lines.push(open); this.children.forEach((child) => lines.push(child.stringify({ indent, depth: depth + 1, xmlSelfClosing, }))); lines.push(`${left}${close}`); } } return lines.join('\n'); } } class VText extends VNode { constructor(text) { super(); this.text = processText(text); } stringify({ indent, depth }) { const left = leftPad(indent, depth); return `${left}${this.text.replace(/\n/g, `\n${left}`)}`; } } class VComment extends VNode { constructor(text) { super(); this.text = escapeHTML(text); } stringify({ indent, depth }) { return `${leftPad(indent, depth)}<!--${this.text}-->`; } } function addVNodes(spec, parent) { if (isNodeSpec(spec)) { const vnode = new VElement(spec); parent.children.push(vnode); spec.children.forEach((s) => addVNodes(s, vnode)); } else if (isComponentSpec(spec)) { if (spec.type === Array) { spec.children.forEach((s) => addVNodes(s, parent)); } else { addComponentPlugins(spec.type, stringifyPlugins); const result = unbox(spec); addVNodes(result, parent); deleteComponentPlugins(spec.type, stringifyPlugins); } } else if (typeof spec === 'string') { const vnode = new VText(spec); parent.children.push(vnode); } else if (spec == null) { const vnode = new VComment(''); parent.children.push(vnode); } else if (Array.isArray(spec)) { spec.forEach((s) => addVNodes(s, parent)); } else { throw new Error('Unable to stringify spec'); } } function buildVDOM(spec) { const root = new VElement({ type: 'div', props: {}, children: [] }); addVNodes(spec, root); return root.children; } function stringify(spec, { indent = ' ', depth = 0, xmlSelfClosing = false } = {}) { if (isSpec(spec)) { const vnodes = buildVDOM(spec); return vnodes .map((vnode) => vnode.stringify({ indent, depth, xmlSelfClosing })) .join('\n'); } throw new Error('Not a spec'); } const plugins = { stringifyAttribute: createPluginsAPI(PLUGINS_STRINGIFY_ATTRIBUTE), skipAttribute: createPluginsAPI(PLUGINS_SKIP_ATTRIBUTE), isVoidTag: createPluginsAPI(PLUGINS_IS_VOID_TAG), }; function isStringifying() { return getStringifyContext() != null; } export { escapeHTML, isStringifying, plugins, stringify };