// Copyright 2024 MichaelB | https://modulojs.org | Modulo v0.0.73 | LGPLv3 // Modulo LGPLv3 NOTICE: Any direct modifications to the Modulo.js source code // must be LGPL or compatible. It is acceptable to distribute dissimilarly // licensed code built with the Modulo framework bundled in the same file for // efficiency instead of "linking", as long as this notice and license remains // intact with the Modulo.js source code itself and any direct modifications. if (typeof window === "undefined") { // Allow for Node.js environment var window = {}; } window.ModuloPrevious = window.Modulo; window.moduloPrevious = window.modulo; window.Modulo = class Modulo { constructor() { window._moduloID = (window._moduloID || 0) + 1; this.window = window; this.id = window._moduloID; // Every Modulo instance gets a unique ID. this._configSteps = 0; // Used to check for an infinite loop during load this.registry = { cparts: { }, coreDefs: { }, utils: { }, core: { }, engines: { }, commands: { }, templateFilters: { }, templateTags: { }, processors: { }, elements: { }, events: { }, listeners: { }, send: { } }; this.registered = {}; // TODO Have this get populated with injected versions after full config (maybe in a Modulo CodeTemplate -> Main?) -- could just subclass everything and attach modulo etc this.config = {}; // Default confs for classes (e.g. all Components) this.definitions = {}; // For specific definitions (e.g. one Component) this.stores = {}; // Global data store (by default, only used by State) this.fs = new Map(); // Add global ultra-lite "in-memory fs" interface } register(type, cls, defaults = undefined) { type = (`${type}s` in this.registry) ? `${type}s` : type; // pluralize if (type in this.registry.registryCallbacks) { cls = this.registry.registryCallbacks[type](this, cls) || cls; } this.assert(type in this.registry, 'Unknown registry type: ' + type); this.registry[type][cls.name] = cls; //this.tools[cls.name] = (...args) => this.registry[type][cls.name](this, ...args); // TODO: FINISH if (cls.name[0].toUpperCase() === cls.name[0]) { // e.g. class FooBar const conf = this.config[cls.name.toLowerCase()] || {}; Object.assign(conf, { Type: cls.name }, cls.defaults, defaults); this.config[cls.name.toLowerCase()] = conf; // e.g. config.foobar //this.tools[cls.name] = extra => this.instance(conf, extra, new this.registry[type][cls.name]); } } instance(def, extra, inst = null) { const isLower = key => key[0].toLowerCase() === key[0]; const coreDefSet = { Component: 1, Artifact: 1 }; // TODO: make compatible with any registration type const registry = (def.Type in coreDefSet) ? 'coreDefs' : 'cparts'; inst = inst || new this.registry[registry][def.Type](this, def, extra.element || null); // TODO rm the element arg const id = ++window._moduloID; //const conf = Object.assign({}, this.config[name.toLowerCase()], def); const conf = Object.assign({}, def); // Just shallow copy "def" const attrs = this.registry.utils.keyFilter(conf, isLower); Object.assign(inst, { id, attrs, conf }, extra, { modulo: this }); if (inst.constructedCallback) { inst.constructedCallback(); } return inst; } instanceParts(def, extra, parts = {}) { // Loop through all children, instancing each class with configuration const allNames = [ def.DefinitionName ].concat(def.ChildrenNames); for (const def of allNames.map(name => this.definitions[name])) { parts[def.RenderObj || def.Name] = this.instance(def, extra); } return parts; } lifecycle(parts, renderObj, lifecycleNames) { for (const lifecycleName of lifecycleNames) { const methodName = lifecycleName + 'Callback'; for (const [ name, obj ] of Object.entries(parts)) { if (!(methodName in obj)) { continue; // Skip if obj has not registered callback } const result = obj[methodName].call(obj, renderObj); if (result) { // TODO: Change to (result !== undefined) and test renderObj[obj.conf.RenderObj || obj.conf.Name] = result; } } } } preprocessAndDefine(cb, prefix = 'Def') { this.fetchQueue.enqueue(() => { this.repeatProcessors(null, prefix + 'Builders', () => { this.repeatProcessors(null, prefix + 'Finalizers', cb || (() => {})); }); }, true); // The "true" causes it to wait for all } loadString(text, parentName = null) { // TODO: Refactor this method away return this.loadFromDOM(this.registry.utils.newNode(text), parentName); } loadFromDOM(elem, parentName = null, quietErrors = false) { // TODO: Refactor this method away const loader = new this.registry.core.DOMLoader(this); return loader.loadFromDOM(elem, parentName, quietErrors); } repeatProcessors(defs, field, cb) { let changed = true; // Run at least once const defaults = this.config.modulo['default' + field] || []; while (changed) { this.assert(this._configSteps++ < 90000, 'Config steps: 90000+'); changed = false; // TODO: Is values deterministic in order? (Solution, if necessary: definitions key order arr) for (const def of (defs || Object.values(this.definitions))) { const processors = def[field] || defaults; //changed = changed || this.applyProcessors(def, processors); const result = this.applyNextProcessor(def, processors); if (result === 'wait') { // TODO: Refactor logic here & changed = null; // null always triggers an enqueue break; } changed = changed || result; } } const repeat = () => this.repeatProcessors(defs, field, cb); if (changed !== null && Object.keys(this.fetchQueue ? this.fetchQueue.queue : {}).length === 0) { // TODO: Remove ?: after core object refactor if (cb) { cb(); // Synchronous path } } else { this.fetchQueue.enqueue(repeat); } } applyNextProcessor(def, processorNameArray) { const cls = this.registry.cparts[def.Type] || this.registry.coreDefs[def.Type] || {}; // TODO: Fix this const { processors } = this.registry; for (const name of processorNameArray) { const [ attrName, aliasedName ] = name.split('|'); if (attrName in def) { const funcName = aliasedName || attrName; const proc = this.registry.processors[funcName.toLowerCase()]; const func = funcName in cls ? cls[funcName].bind(cls) : proc; const value = def[attrName]; // Pluck value & remove attribute delete def[attrName]; // TODO: document 'wait' or rm -v return func(this, def, value) === true ? 'wait' : true; } } return false; // No processors were applied, return false } assert(value, ...info) { if (!value) { console.error(this.id, ...info); throw new Error(`Modulo Error: "${Array.from(info).join(' ')}"`); } } } // TODO: Move to conf window.Modulo.INVALID_WORDS = new Set((` break case catch class const continue debugger default delete do else enum export extends finally for if implements import in instanceof interface new null package private protected public return static super switch throw try typeof var let void while with await async true false `).split(/\s+/ig)); // Create a new modulo instance to be the global default instance window.modulo = new window.Modulo(); if (typeof modulo === "undefined" || modulo.id !== window.modulo.id) { var modulo = window.modulo; // TODO: RM when global modulo is cleaned up } window.modulo.registry.registryCallbacks = { // Set up default registry hooks commands(modulo, cls) { window.m = window.m || {}; // Avoid overwriting existing truthy m window.m[cls.name] = () => cls(modulo); // Attach shortcut to global "m" }, processors(modulo, cls) { modulo.registry.processors[cls.name.toLowerCase()] = cls; // Alias lower }, listeners(modulo, cls) { // Simple sub/pub system const { events, send } = modulo.registry; events[cls.name] = events[cls.name] || []; // Initialize and add events[cls.name].push(cls); send[cls.name] = send[cls.name] || function sender (...args) { return events[cls.name].map(func => func(modulo, ...args)); }; }, core(modulo, cls) { // Global / core class getting registered const lowerName = cls.name[0].toLowerCase() + cls.name.slice(1); modulo[lowerName] = new cls(modulo); modulo.assets = modulo.assetManager; // TODO Rm }, }; modulo.register('coreDef', class Modulo {}, { ChildPrefix: '', // Prevents all children from getting modulo_ prefixed Contains: 'coreDefs', DefLoaders: [ 'DefTarget', 'DefinedAs', 'Src', 'Content' ], defaultDef: { DefTarget: null, DefinedAs: null, DefName: null }, defaultDefLoaders: [ 'DefTarget', 'DefinedAs', 'Src' ], defaultFactory: [ 'RenderObj', 'factoryCallback' ], // TODO: this might be dead code? }); modulo.register('core', class ValueResolver { constructor(contextObj = null) { this.ctxObj = contextObj; this.isJSON = /^(true$|false$|null$|[^a-zA-Z])/; // "If not variable" } get(key, ctxObj = null) { const { get } = window.modulo.registry.utils; // For drilling down "." const obj = ctxObj || this.ctxObj; // Use given one or in general return this.isJSON.test(key) ? JSON.parse(key) : get(obj, key); } set(obj, keyPath, val, autoBind = false) { const index = keyPath.lastIndexOf('.') + 1; // Index at 1 (0 if missing) const key = keyPath.slice(index).replace(/:$/, ''); // Between "." & ":" const prefix = keyPath.slice(0, index - 1); // Get before first "." const target = index ? this.get(prefix, obj) : obj; // Drill down prefix if (keyPath.endsWith(':')) { // If it's a dataProp style attribute const parentKey = val.substr(0, val.lastIndexOf('.')); val = this.get(val); // Resolve "val" from context, or JSON literal if (autoBind && !this.isJSON.test(val) && parentKey.includes('.')) { val = val.bind(this.get(parentKey)); // Parent is sub-obj, bind } } target[key] = val; // Assign the value to it's parent object } }); modulo.register('core', class DOMLoader { constructor(modulo) { this.modulo = modulo; // TODO: need to standardize back references to prevent mismatches } getAllowedChildTags(parentName) { let tagsLower = this.modulo.config.domloader.topLevelTags; // "Modulo" if (/^_[a-z][a-zA-Z]+$/.test(parentName)) { // _likethis, e.g. _artifact tagsLower = [ parentName.toLowerCase().replace('_', '') ]; // Dead code? } else if (parentName) { // Normal parent, e.g. Library, Component etc const parentDef = this.modulo.definitions[parentName]; const msg = `Invalid parent: ${ parentName } (${ parentDef })`; this.modulo.assert(parentDef && parentDef.Contains, msg); const names = Object.keys(this.modulo.registry[parentDef.Contains]); tagsLower = names.map(s => s.toLowerCase()); // Ignore case } return tagsLower; } loadFromDOM(elem, parentName = null, quietErrors = false) { const { defaultDef } = this.modulo.config.modulo; const toCamel = s => s.replace(/-([a-z])/g, g => g[1].toUpperCase()); const tagsLower = this.getAllowedChildTags(parentName); const array = []; for (const node of elem.children || []) { const partTypeLC = this.getDefType(node, tagsLower, quietErrors); if (node._moduloLoadedBy || partTypeLC === null) { continue; // Already loaded, or an ignorable or silenced error } node._moduloLoadedBy = this.modulo.id; // Mark as having loaded this // Valid definition, now create the "def" object const def = Object.assign({ Parent: parentName }, defaultDef); def.Content = node.tagName === 'SCRIPT' ? node.textContent : node.innerHTML; array.push(Object.assign(def, this.modulo.config[partTypeLC])); for (let name of node.getAttributeNames()) { // Loop through attrs const value = node.getAttribute(name); if (partTypeLC === name && !value) { // e.g. continue; // This is the "Type" attribute itself, skip } def[toCamel(name)] = value; // "-kebab-case" to "CamelCase" } } this.modulo.repeatProcessors(array, 'DefLoaders'); return array; } getDefType(node, tagsLower, quiet = false) { const { tagName, nodeType, textContent } = node; if (nodeType !== 1) { // Text nodes, comment nodes, etc if (nodeType === 3 && textContent && textContent.trim() && !quiet) { console.error(`Unexpected text in definition: ${textContent}`); } return null; } let defType = tagName.toLowerCase(); if (defType in this.modulo.config.domloader.genericDefTags) { for (const attrUnknownCase of node.getAttributeNames()) { const attr = attrUnknownCase.toLowerCase(); if (!node.getAttribute(attr) && tagsLower.includes(attr)) { defType = attr; // Has an empty string value, is a def } break; // Always break: We will only look at first attribute } } if (!(tagsLower.includes(defType))) { // Were any discovered? if (defType === 'testsuite') { return null; } /* TODO Remove and add recipe to stub / silence TestSuite not found errors */ if (!quiet) { // Invalid def / cPart: This type is not allowed here console.error(`"${ defType }" is not one of: ${ tagsLower }`); } return null // Return null to signify not a definition } return defType; // Valid, expected definition: Return lowercase type } }, { topLevelTags: [ 'modulo' ], // Only "Modulo" is top genericDefTags: { def: 1, script: 1, template: 1, style: 1 }, }); modulo.register('processor', function src (modulo, def, value) { const { getParentDefPath } = modulo.registry.utils; def.Source = (new window.URL(value, getParentDefPath(modulo, def))).href; modulo.fetchQueue.fetch(def.Source).then(text => { def.Content = (text || '') + (def.Content || ''); }); }); modulo.register('processor', function srcSync (modulo, def, value) { modulo.registry.processors.src(modulo, def, value); return true; // Only difference is return "true" for "wait" (TODO: Refactor to "return def.SrcSync ? false" then specify on Configuration) }); modulo.register('processor', function defTarget (modulo, def, value) { const resolverName = def.DefResolver || 'ValueResolver'; // TODO: document const resolver = new modulo.registry.core[resolverName](modulo); const target = value === null ? def : resolver.get(value); // Target object for (const [ key, defValue ] of Object.entries(def)) { // Resolve all values if (key.endsWith(':') || key.includes('.')) { delete def[key]; // Remove & replace unresolved value //resolver.set(/[^a-z]/.test(key) ? target : def, key, defValue); // TODO: Probably should be this -- not sure how this interacts with if resolver.set(/^[a-z]/.test(key) ? target : def, key, defValue); } } }); modulo.register('processor', function content (modulo, conf, value) { modulo.loadString(value, conf.DefinitionName); }); modulo.register('processor', function definedAs (modulo, def, value) { def.Name = value ? def[value] : (def.Name || def.Type.toLowerCase()); const parentDef = modulo.definitions[def.Parent]; const parentPrefix = parentDef && ('ChildPrefix' in parentDef) ? parentDef.ChildPrefix : (def.Parent ? def.Parent + '_' : ''); def.DefinitionName = parentPrefix + def.Name; // Search for the next free Name by suffixing numbers while (def.DefinitionName in modulo.definitions) { const match = /([0-9]+)$/.exec(def.Name); const number = match ? match[0] : ''; def.Name = def.Name.replace(number, '') + ((number * 1) + 1); def.DefinitionName = parentPrefix + def.Name; } modulo.definitions[def.DefinitionName] = def; // store definition const parentConf = modulo.definitions[def.Parent]; if (parentConf) { parentConf.ChildrenNames = parentConf.ChildrenNames || []; parentConf.ChildrenNames.push(def.DefinitionName); } }); modulo.register('util', function initComponentClass (modulo, def, cls) { // Run factoryCallback static lifecycle method to create initRenderObj const initRenderObj = { elementClass: cls }; for (const defName of def.ChildrenNames) { const cpartDef = modulo.definitions[defName]; const cpartCls = modulo.registry.cparts[cpartDef.Type]; if (cpartCls.factoryCallback) { const result = cpartCls.factoryCallback(initRenderObj, cpartDef, modulo); initRenderObj[cpartDef.RenderObj || cpartDef.Name] = result; } } cls.prototype.init = function init () { this.modulo = modulo; this.isMounted = false; this.isModulo = true; this.originalHTML = null; this.originalChildren = []; this.cparts = modulo.instanceParts(def, { element: this }); // Idea: Reduce all properties to just one called "modulo" (or maybe moduloParts)? }; modulo._connectedQueue = modulo._connectedQueue || []; // Ensure array modulo._drainQueue = () => { // "Clusters" all moduloMount calls while (modulo._connectedQueue.length > 0) { // Drains + invokes modulo._connectedQueue.shift().moduloMount(); } }; cls.prototype.connectedCallback = function connectedCallback () { modulo._connectedQueue.push(this); window.setTimeout(modulo._drainQueue, 0); }; cls.prototype.moduloMount = function moduloMount(force = false) { if ((!this.isMounted && window.document.contains(this)) || force) { this.cparts.component._lifecycle([ 'initialized', 'mount', 'mountRender' ]); } }; cls.prototype.initRenderObj = initRenderObj; cls.prototype.rerender = function (original = null) { if (!this.isMounted) { // Not mounted, do Mount which will also rerender return this.moduloMount(); } this.cparts.component.rerender(original); // Otherwise, normal rerender }; cls.prototype.getCurrentRenderObj = function () { return this.cparts.component.getCurrentRenderObj(); }; modulo.register('element', cls); // All elements get registered centrally }); modulo.register('util', function makeStore (modulo, def) { const isLower = key => key[0].toLowerCase() === key[0]; // skip "-prefixed" let data = modulo.registry.utils.keyFilter(def, isLower); // Get defaults data = JSON.parse(JSON.stringify(data)); // Deep copy to ensure primitives return { data, boundElements: {}, subscribers: [] }; }); modulo.register('processor', function mainRequire (modulo, conf, value) { modulo.assets.mainRequire(value); }); modulo.config.artifact = { BuildCommandFinalizers: [ 'SaveArtifact' ], SaveArtifact: null, DefinedAs: 'name', exclude: '[modulo-asset]', }; modulo.register('coreDef', class Artifact { static SaveArtifact (modulo, def, value) { // Build processor const artifactInstance = modulo.instance(def, { }); artifactInstance.buildCommand(document); // e is document or target return true; // Always wait for next } getBundle(targetElem) { // TODO: Mix in targetElem to QSA const bundledElems = []; for (const elem of targetElem.querySelectorAll(this.conf.bundle)) { const url = elem.getAttribute('src') || elem.getAttribute('href'); if (this.conf.exclude && elem.matches(this.conf.exclude) || !url) { continue; // Need skip, otherwise it chokes assets or blank src } this.modulo.fetchQueue.fetch(url).then(text => { // Enqueue fetch delete this.modulo.fetchQueue.data[url]; // remove from cache elem.bundledContent = text; // attach back to element for later }); bundledElems.push(elem); } return bundledElems; } getTemplateContext(targetElem) { const head = targetElem.head || { innerHTML: '' }; const body = targetElem.body || { innerHTML: '', id: '' }; const htmlPrefix = '' + head.innerHTML; const htmlInterfix = '' + body.innerHTML; const htmlSuffix = ''; const bundle = this.conf.bundle ? this.getBundle(targetElem) : null; const extras = { htmlPrefix, htmlInterfix, htmlSuffix, bundle }; return Object.assign({ }, this.modulo, extras); } buildCommand(targetElem) { const { Template } = this.modulo.registry.cparts; const { saveFileAs, hash } = this.modulo.registry.utils; const { DefinitionName, remove, urlName, name, Content } = this.conf; const def = this.modulo.definitions[DefinitionName]; // Get original def if (remove) { // Need to remove elements from document first targetElem.querySelectorAll(remove).forEach(elem => elem.remove()); } this.templateContext = this.getTemplateContext(targetElem); // Queue up this.modulo.fetchQueue.enqueue(() => { // Drain queue before continue const tmplt = new Template(Content); // Render file Artifact content const content = tmplt.render(this.templateContext); def.FileName = `modulo-build-${ hash(content) }.${ name }`; if (urlName) { // Guess filename based on URL (or use as default) def.FileName = window.location.pathname.split('/').pop() || urlName; } def.OutputPath = saveFileAs(def.FileName, content); // Attempt save }); } }); modulo._DEVLIB_SOURCE = (` {% for elem in bundle %}{{ elem.bundledContent|default:''|safe }} {% endfor %}{% for css in assets.cssAssetsArray %} {{ css|safe }}{% endfor %} window.moduloBuild = window.moduloBuild || { modules: {}, nameToHash: {} }; {% for name, hash in assets.nameToHash %}{% if hash in assets.moduleSources %}{% if name|first is not "_" %} window.moduloBuild.modules["{{ hash }}"] = function {{ name }} (modulo) { {{ assets.moduleSources|get:hash|safe }} }; window.moduloBuild.nameToHash.{{ name }} = "{{ hash }}"; {% endif %}{% endif %}{% endfor %} window.moduloBuild.definitions = { {% for name, value in definitions %} {% if name|first is not "_" %}{{ name }}: {{ value|json|safe }},{% endif %} {% endfor %} }; {% if bundle %} {% for elem in bundle %}{{ elem.bundledContent|default:''|safe }}{% endfor %} modulo.assets.modules = window.moduloBuild.modules; modulo.assets.nameToHash = window.moduloBuild.nameToHash; modulo.definitions = window.moduloBuild.definitions; {% endif %} {% for name in assets.mainRequires %} modulo.assets.require("{{ name|escapejs }}"); {% endfor %} {{ htmlPrefix|safe }} {{ htmlInterfix|safe }}