malevic
Version:
Malevič.js - minimalistic reactive UI library
339 lines (327 loc) • 9.66 kB
JavaScript
/* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 };