/*<script src=Modulo.html></script><script type=mdocs>--- version: v0.1.1 copyright: 2025 Michael Bethencourt - LGPLv3 - NO WARRANTEE OR IMPLIED UTILITY; ANY MODIFICATIONS OR DERIVATIVES OF THE MODULO FRAMEWORK MUST BE LGPLv3+ LGPL Notice: It is acceptable to link ("bundle") and distribute the Modulo Framework with other code as long as the LICENSE and NOTICE remains intact.
// */ // md: [ % ] v0.1.1 [ModuloHTML.org](https://modulohtml.org/) /
var Modulo = function Modulo (OPTS = { }) { const Lib = OPTS.globalLibrary || window.Modulo || Modulo; //md:# ᵐ°dᵘ⁄o Lib.instanceID = Lib.instanceID || 0; this.id = ++Lib.instanceID; const globals = OPTS.globalProperties || [ 'config', 'util', 'engine', 'processor', 'part', 'core', 'templateMode', 'templateTag', 'templateFilter', 'contentType', 'command', 'build', 'definitions', 'stores', 'fetchQueue' ]; for (const name of globals) { const stdLib = Lib[name.charAt(0).toUpperCase() + name.slice(1) + 's']; this[name] = stdLib ? stdLib(this) : { }; // Exe StdLib Module } }
/* md: ###[ % ] Create App »
md:###[ % ] Create Library »
md:###[ % ] Create Markdown »
md:Hint: Click starter template for preview. Click file(s) to save.
md:About: Modulo (or ᵐ°dᵘ⁄o) is a single file frontend
md:framework, squeezing in numerous tools for modern HTML, CSS, and
md:JavaScript. Featuring: Web Components, CSS Scoping, Shadow DOM,
md:SSG / SSR, Bundling, Store and State Management, Templating, and more.
*/
Modulo.Parts = function ComponentParts (modulo) {// md: ## Component Parts
/* md: ### Include
md:html=component<Include> md:<script>document.body.innerHTML += '<h1>ᵐ°dᵘ⁄o</h1>'<-script> md:<style>:root { --c1: #B90183; } body { background: var(--c1); }</style> md:</Include> Include is for global styles, links, and scripts.
*/
class Include {
static LoadMode(modulo, def, value) {
const { bundleHead, newNode, urlReplace, getParentDefPath } = modulo.util;
const text = urlReplace(def.Content, getParentDefPath(modulo, def));
for (const elem of newNode(text).children) { // md: Include loops
bundleHead(modulo, elem); // md: across it's children adding to head,
} // md: and pausing during load. When built, it combines into bundles.
}
static Server({ part, util }, def, value) {
def.Content = (def.Content || '') + new part.Template(def.TagTemplate)
.render({ entries: util.keyFilter(def), value });
}
intitializedCallback(renderObj) {
Include.LoadMode(this.modulo, this.conf, 'lazy');
}
}
class Props { // md: ### Props
static factoryCallback({ elementClass }, def, modulo) {
const isLower = key => key[0].toLowerCase() === key[0]; // skip "-prefixed"
const keys = Array.from(Object.keys(def)).filter(isLower);
elementClass.observedAttributes.push(...keys); // (modify elementClass)
}
// md:html=component<Props quote name="Unknown"></Props> // md:<Template>{{ props.name }} says "{{ props.quote }}"</Template>
initializedCallback() { // md: Props loads attributes from the element
this.data = { }; // md: when the component is initialized (mounted):
Object.keys(this.attrs).forEach(attrName => this.updateProp(attrName));
return this.data; // md: E.g. <x-App name="Jo"></x-App> sets name.
}
updateProp(attrName) { // md: It also rerenders if one of those is changed.
this.data[attrName] = this.element.hasAttribute(attrName) ?
this.element.getAttribute(attrName) : this.attrs[attrName];
}
attrCallback({ attrName }) {
if (attrName in this.attrs) {
this.updateProp(attrName);
this.element.rerender();
}
}
}
//md:### Style
//md:html=component<Template><em class="big">Stylish</em> TEXT</Template> //md:<Style>.big { font-size: 4rem } :host { background: #82d4a4 }</Style>
class Style {
static AutoIsolate(modulo, def, value) { // md: Style "auto-isolates" CSS.
const { AutoIsolate } = modulo.part.Style; // (for recursion)
const { namespace, mode, Name } = modulo.definitions[def.Parent] || {};
if (value === true) { // md: Style uses <Component mode=....> to
AutoIsolate(modulo, def, mode); //md:isolate: mode=regular will
} else if (value === 'regular' && !def.isolateClass) {//md:prefix your
def.prefix = def.prefix || ${namespace}-${Name}; //md:selectors
} else if (value === 'vanish') { //md:with the component name, while
def.isolateClass = def.isolateClass || def.Parent;//md:setting
} // md:mode=vanish adds the class to children outside of slots.
}
domCallback(renderObj) {
const { mode } = modulo.definitions[this.conf.Parent] || {};
const { innerDOM, Parent } = renderObj.component;
const { isolateClass, isolateSelector, shadowContent } = this.conf;
if (isolateClass && isolateSelector && innerDOM) { // Attach classes
const selector = isolateSelector.filter(s => s).join(',\n');
for (const elem of innerDOM.querySelectorAll(selector)){
elem.classList.add(isolateClass);
}
} // md: For mode=shadow, it adds a "private" sheet to the shadow DOM
if (shadowContent && innerDOM) { // md: root during DOM reconciliation.
innerDOM.prepend(this.modulo.util.newNode(shadowContent, 'STYLE'));
}
}
static processSelector (modulo, def, selector) {// md: It also permits
const hostPrefix = def.prefix || ('.' + def.isolateClass);//md:use of
if (def.isolateClass || def.prefix) {//md:the :host "outer" selector.
const hostRegExp = new RegExp(/:(host|root)(([^)]))?/, 'g');
selector = selector.replace(hostRegExp, hostClause => {
hostClause = hostClause.replace(/:(host|root)/gi, '');
return hostPrefix + (hostClause ? :is(${ hostClause }) : '');
});
}
let selectorOnly = selector.replace(/\s[{,]\s*,?$/, '').trim();
if (def.isolateClass && selectorOnly !== hostPrefix) {
// Remove extraneous characters (and strip ',' for isolateSelector)
let suffix = /{\s*$/.test(selector) ? ' {' : ', ';
selectorOnly = selectorOnly.replace(/:(:?[a-z-]+)\s*$/i, (all, pseudo) => {
if (pseudo.startsWith(':') || def.corePseudo.includes(pseudo)) {
suffix = ':' + pseudo + suffix; // Attach to suffix, on outside
return ''; // Strip pseudo from the selectorOnly variable
}
return all;
});
def.isolateSelector.push(selectorOnly); // Add to array for later
selector = .${ def.isolateClass }:is(${ selectorOnly }) + suffix;
}
if (def.prefix && !selector.startsWith(def.prefix)) {
// A prefix was specified, so prepend it if it doesn't have it
selector = ${ def.prefix } ${ selector };
}
return selector;
}
static ProcessCSS (modulo, def, value) {
const { bundleHead, newNode, urlReplace, getParentDefPath } = modulo.util;
value = value.replace(//*.+?(*/)/g, ''); // rm comment, rewrite urls
value = urlReplace(value, getParentDefPath(modulo, def), def.urlMode);
if (def.isolateClass || def.prefix) {
def.isolateSelector = []; // Used to accumulate elements to select
value = value.replace(/([^\r\n,{}]+)(,(?=[^}]{)|\s{)/gi, selector => {
selector = selector.trim();
return /^(from|to|@)/.test(selector) ? selector :
this.processSelector(modulo, def, selector);
});
}
if ((modulo.definitions[def.Parent] || {}).mode === 'shadow') {
def.shadowContent = (def.shadowContent || '') + value;
} else { // md: During build, all non-shadow Style parts get bundled.
bundleHead(modulo, newNode(value, 'STYLE'), modulo.bundles.modstyle);
}
}
}
//md:### StaticData
//md:html=component<Template><pre>{{ staticdata|json:2 }}</pre></Template> //md:<StaticData -data-type=md -src=Modulo.html></StaticData>
class StaticData {// md: Use for bundling unchanging data (e.g. API, files,
prepareCallback() { // md: config, etc) in with a component.
return this.conf.data;
}
}
class Script { // md:### Script
// md:html=component<Script>function hi(){ alert(ref.h1.outerHTML) }<-Script> // md:<Template><h1 on.click=script.hi script.ref>Click me</h1></Template>
// md: Scripts let you embed JavaScript code "inside" your component.
static AutoExport (modulo, def, value) {
const nameRE = /(function|class)\s+(\w+)/; // gather exports
const matches = def.Content.match(new RegExp(nameRE, 'g')) || [];
const isSym = sym => sym && !(sym in modulo.config.syntax.jsReserved);
const symbols = matches.map(sym => sym.match(nameRE)[2]);
const ifUndef = n => "${n}":typeof ${n} !=="undefined"?${n}:undefined;
const expStr = symbols.filter(isSym).map(ifUndef).join(',');
const { ChildrenNames } = modulo.definitions[def.Parent] || { };
const sibs = (ChildrenNames || []).map(n => modulo.definitions[n].Name);
sibs.push('component', 'element', 'parts', 'ref'); // gather locals
const locals = sibs.filter(name => def.Content.includes(name));
const setLoc = locals.map(name => ${ name }=o.${ name }).join(';')
def.Content += locals.length ? ('var ' + locals.join(',')) : '';
def.Content += ;return{_setLocal:function(o){${ setLoc }}, ${ expStr }};
}
initializedCallback(renderObj) { // md: Upon Component initialization, it
const func = modulo.registry.modules[this.conf.DefinitionName];//md:will
this.exports = func.call(window, modulo);// md: run your code, gathering
for (const method of Object.keys(this.exports)) { // md: each export.
if (method === 'initializedCallback' || !method.endsWith('Callback')) {
continue; // md: Named functions (e.g. function foo) and
} // md: classes get "auto-exported", for attachment to events.
this[method] = arg => {
const renderObj = this.element.getCurrentRenderObj();
const script = renderObj[this.conf.Name];
this.eventCallback(renderObj);
Object.assign(script, this.exportsmethod || {}); // Run
};
}
this.ref = { };
this.eventCallback(renderObj);
return Object.assign(this.exports, this.exports.initializedCallback ?
this.exports.initializedCallback(renderObj) : { }); // Run init
}
eventCallback(renderObj) {
this.exports._setLocal(Object.assign({ ref: this.ref,
element: this.element, parts: this.element.cparts }, renderObj));
}
refMount({ el, nameSuffix, value }) { // md: The script.ref directive
const refVal = value ? modulo.util.get(el, value) : el; // md: assigns
this.ref[nameSuffix || el.tagName.toLowerCase()] = refVal; // md: DOM
} // md: references. E.g. <img script.ref> is called ref.img in script.
refUnmount({ el, nameSuffix }) { // md: When unmounted, the reference is
delete this.ref[nameSuffix || el.tagName.toLowerCase()]; // md: deleted.
}
}
// md:### State
// md:html=component<State msg="Lorem" a:=123></State> // md:<Template>{{ state.msg }}: <input state.bind name=msg></Template>
class State { // State declares state variables to bind to forms.
static factoryCallback(renderObj, def, modulo) {
if (def.Store) { // md: If a -store= is specified, it's global.
const store = modulo.util.makeStore(modulo, def);
if (!(def.Store in modulo.stores)) { // md: The first one
modulo.stores[def.Store] = store; // md: encountered
} else { // md: with that name will create the "Store".
Object.assign(modulo.stores[def.Store].data, store.data);
} // md: Subsequent usage will share and react to that one "Store".
} // md: Without -store=, it will be be private to each component.
}
initializedCallback(renderObj) {
const store = this.conf.Store ? this.modulo.stores[this.conf.Store]
: this.modulo.util.makeStore(this.modulo,
Object.assign(this.conf, renderObj[this.conf.Init]));
store.subscribers.push(Object.assign(this, store));
this.types = { range: Number, number: Number }
this.types.checkbox = (v, el) => el.checked;
return store.data;
}
bindMount({ el, nameSuffix, value, listen }) {
const name = value || el.getAttribute('name');
const val = this.modulo.util.get(this.data, name, this.conf.Dot);
this.modulo.assert(val !== undefined, state.bind "${name}" undefined);
const isText = el.tagName === 'TEXTAREA' || el.type === 'text';
const evName = nameSuffix ? nameSuffix : (isText ? 'keyup' : 'change');
// Bind the "listen" event to propagate to all, and trigger initial vals
listen = listen ? listen : () => this.propagate(name, el.value, el);
el.addEventListener(evName, listen);
this.boundElements[name] = this.boundElements[name] || [];
this.boundElements[name].push([ el, evName, listen ]);
this.propagate(name, val, null, [ el ]); // Trigger element assignment
}
bindUnmount({ el, nameSuffix, value }) {
const name = value || el.getAttribute('name');
const remainingBound = [];
for (const row of this.boundElements[name]) {
if (row[0] === el) {
row[0].removeEventListener(row[1], row[2]);
} else {
remainingBound.push(row);
}
}
this.boundElements[name] = remainingBound;
}
stateChangedCallback(name, value, el) {
this.modulo.util.set(this.data, name, value, this.conf.Dot);
if (!this.conf.Only || this.conf.Only.includes(name)) {
this.element.rerender();
}
}
eventCallback() {
this._oldData = Object.assign({}, this.data);
}
propagate(name, val, originalEl = null, arr = null) {
arr = arr ? arr : this.subscribers.concat(
(this.boundElements[name] || []).map(row => row[0]));
const typeConv = this.types[ originalEl ? originalEl.type : null ];
val = typeConv ? typeConv(val, originalEl) : val; // Apply conversion
for (const el of arr) {
if (originalEl && el === originalEl) { // skip
} else if (el.stateChangedCallback) {
el.stateChangedCallback(name, val, originalEl, arr);
} else if (el.type === 'checkbox') {
el.checked = !!val;
} else { // Normal input
el.value = val;
}
}
}
eventCleanupCallback() {
for (const name of Object.keys(this.data)) {
this.modulo.assert(!this.conf.AllowNew && name in this._oldData,
State variable "${ name }" is undeclared (no "-allow-new"));
if (this.data[name] !== this._oldData[name]) {
this.propagate(name, this.data[name], this);
}
}
this._oldData = null;
}
}
class Template { // md: ### Template
// md: Templates run Modulo Template Language to generate HTML.
static CompileTemplate (modulo, def, value) {
const compiled = modulo.util.instance(def, { }).compile(value);
def.Code = return function (CTX, G) { ${ compiled } };;
}
constructedCallback() { // Flatten filters, tags, and modes
this.stack = []; // cause err on unclosed
const { filters, tags, modes } = this.conf;
const { templateFilter, templateTag, templateMode } = this.modulo;
Object.assign(this, this.modulo.config.template, this.conf);
// md: Templates have numerous built-in filters, tags, and modes.
this.filters = Object.assign({ }, templateFilter, filters);
this.tags = Object.assign({ }, templateTag, tags);
this.modes = Object.assign({ }, templateMode, modes);
}
initializedCallback() {
return { render: this.render.bind(this) }; // Export "render" method
}
constructor(text, options = null) { // md:In JavaScript, it's available as:
if (typeof text === 'string') { // md: new Template('Hi {{ a }}')
window.modulo.util.instance(options || { }, null, this); // Setup object
this.conf.DefinitionName = '_template_template' + this.id; // Unique
const code = return function (CTX, G) { ${ this.compile(text) } };;
this.modulo.processor.code(this.modulo, this.conf, code);
}
}
renderCallback(renderObj) {
if (this.conf.Name === 'template' || this.conf.active) { // If primary
renderObj.component.innerHTML = this.render(renderObj); // Do render
}
}
parseExpr(text) {
// Output JS code that evaluates an equivalent template code expression
const filters = text.split('|');
let results = this.parseVal(filters.shift()); // Get left-most val
for (const [ fName, arg ] of filters.map(s => s.trim().split(':'))) {
const argList = arg ? ',' + this.parseVal(arg) : '';
results = G.filters["${fName}"](${results}${argList});
}
return results;
}
parseCondExpr(string) {
// Return an Array that splits around ops in an "if"-style statement
const regExpText = (${this.opTokens.split(',').join('|')});
return string.split(RegExp(regExpText));
}
toCamel(string) { // Takes kebab-case and converts toCamelCase
return string.replace(/-([a-z])/g, g => g[1].toUpperCase());
}
parseVal(string) {
// Parses str literals, de-escaping as needed, numbers, and context vars
const s = string.trim();
if (s.match(/^('.'|".")$/)) { // String literal
return JSON.stringify(s.substr(1, s.length - 2));
}
return s.match(/^\d+$/) ? s : CTX.${ this.toCamel(s) }
}
tokenizeText(text) { // Join all modeTokens with | (OR in regex)
const re = '(' + this.modeTokens.map(modulo.templateFilter.escapere)
.join('|(').replace(/ +/g, ')(.+?)');
return text.split(RegExp(re)).filter(token => token !== undefined);
}
compile(text) {
const { normalize } = this.modulo.util;
let code = 'var OUT=[];\n'; // Variable used to accumulate code
let mode = 'text'; // Start in text mode
const tokens = this.tokenizeText(text);
for (const token of tokens) {
if (mode) { // If in a "mode" (text or token), then call mode func
const result = this.modes[mode](token, this, this.stack);
code += result ? (result + '\n') : '';
} // FSM for mode: ('text' -> null) (null -> token) (* -> 'text')
mode = (mode === 'text') ? null : (mode ? 'text' : token);
}
code += '\nreturn OUT.join("");'
const unclosed = this.stack.map(({ close }) => close).join(', ');
this.modulo.assert(!unclosed, Unclosed tags: ${ unclosed });
return code;
}
render(local) {
if (!this.renderFunc) {
const mod = this.modulo.registry.modules[this.conf.DefinitionName];
this.renderFunc = mod.call(window, this.modulo);
}
return this.renderFunc(Object.assign({ local, global: this.modulo }, local), this);
}
} // md: ---
const cparts = { State, Props, Script, Style, Template, StaticData, Include };
return modulo.util.insObject(cparts);
} // /* End of Component Parts / /#UNLESS#*/
Modulo.TemplateModes = modulo => ({ // md: ## Template Language
'{%': (text, tmplt, stack) => { // md: Modulo Template Language looks for
const tTag = text.trim().split(' ')[0]; // md: syntax like {% ... %}.
const tagFunc = tmplt.tags[tTag]; // md: These are "template tags".
if (stack.length && tTag === stack[stack.length - 1].close) {
return stack.pop().end;
} else if (!tagFunc) {
throw new Error(Unexpected tag "${tTag}": ${text});
}
const result = tagFunc(text.slice(tTag.length + 1), tmplt);
if (result.end) { // md: Most expect an end tag: e.g. {% if %} has
stack.push({ close: end${ tTag }, ...result });//md:{% endif %}.
} // md: However, {% include %} and {% debugger %} do not.
return result.start || result;
}, // md: Code like {{ state.a }} will insert values in the generated HTML.
'{-{': (text, tmplt) => OUT.push('{{${ text }}}');, // md: Escape syntax
'{-%': (text, tmplt) => OUT.push('{%${ text }%}');,//md: is {-% %-}.
'{#': (text, tmplt) => false, // md: Short comments are {# like this #}.
'{{': (text, tmplt) => OUT.push(G.${ tmplt.unsafe }(${ tmplt.parseExpr(text) }));,
text: (text, tmplt) => text && OUT.push(${JSON.stringify(text)});,
}) // md: Simple example of {% if %}:
Modulo.TemplateTags = modulo => ({//md:html=component<Template>{% if state.a %} 'comment':() => ({ start: "/*", end: "*/"}),//md:<p>Y</p>{% else %}<p>N</p> 'debugger': () => 'debugger;', // md:{% endif %}</Template> 'else': () => '} else {', //md:<State a:=true></State>
'elif': (s, tmplt) => '} else ' + tmplt.tags['if'](s, tmplt).start,
'elseif': (s, tmplt) => tmplt.tags.elif(s, tmplt),
'empty': (text, {stack}) => { // Empty only runs if loop doesn't run
const varName = 'G.FORLOOP_NOT_EMPTY' + stack.length;
const oldEndCode = stack.pop().end; // get rid of dangling for
const start = ${varName}=true; ${oldEndCode} if (!${varName}) {;
const end = }${varName} = false;;
return { start, end, close: 'endfor' };
}, // md: {% for %} is useful for "plural info", as it repeat its
'for': (text, tmplt) => { // md: contents in a loop:
const arrName = 'ARR' + tmplt.stack.length;
const [ varExp, arrExp ] = text.split(' in ');
let start = var ${arrName}=${tmplt.parseExpr(arrExp)};;
// TODO: Upgrade to for...of loop (after good testing)
start += for (var KEY in ${arrName}) {;
const [keyVar, valVar] = varExp.split(',').map(s => s.trim());
if (valVar) {//md:html=component<Template> start += `CTX.${keyVar}=KEY;`;//md:{% for foobar in state.d %} }//md:<h2>{{ foobar }}</h2> start += `CTX.${valVar?valVar:varExp}=${arrName}[KEY];`; //md:{% endfor %} return { start, end: '}'};//md:</Template><State d:='["A", "b"]'></State>
},
'if': (text, tmplt) => { // Limit to 3 (L/O/R)
const [ lHand, op, rHand ] = tmplt.parseCondExpr(text);
const condStructure = !op ? 'X' : tmplt.opAliases[op] || X ${op} Y;
const condition = condStructure.replace(/([XY])/g,
(k, m) => tmplt.parseExpr(m === 'X' ? lHand : rHand));
const start = if (${condition}) {;
return { start, end: '}' };
},
'include': (text) => OUT.push(CTX.${ text.trim() }.render(CTX));,
'ignoremissing': () => ({ start: 'try{\n', end: '}catch (e){}\n' }),
'with': (text, tmplt) => {
const [ varExp, varName ] = text.split(' as ');
const code = CTX.${ varName }=${ tmplt.parseExpr(varExp) };\n;
return { start: 'if(1){' + code, end: '}' };
},
}) /#ENDUNLESS#/
Modulo.TemplateFilters = modulo => {//md:Using | we can apply filters:
//md:html=component<Template><h2>Modulo Filters:</h2><dl> //md:{% for fil in global.template-filter|keys|sorted %}<dt>{{fil}}</dt> //md:<dd>"ab1-cd2"|{{fil}}→ {% ignoremissing %}"{{"ab1-cd2"|apply:fil}}" //md:{% endignoremissing %}</dd>{% endfor %}</dl></Template>
const { get } = modulo.util;
const safe = s => Object.assign(new String(s),{ safe: true });
const escapere = s => s.replace(/[.+?^${}()|[]\-]/g, '\$&');
const syntax = (s, arg = 'text') => {
for (const [ find, sub, sArg ] of modulo.config.syntax[arg]) {
s = find ? s.replace(find, sub) : Filters[sub](s, sArg);
}
return s;
};
const tagswap = (s, arg) => {
arg = typeof arg === 'string' ? arg.split(/\s+/) : Object.entries(arg);
for (const row of arg) {
const [ tag, val ] = typeof row === 'string' ? row.split('=') : row;
const swap = (a, prefix, suffix) => prefix + val + suffix;
s = s.replace(RegExp('(</?)' + tag + '(\s|>)', 'gi'), swap);
}
return safe(s);
};
const modeRE = /(mode: | type=)([a-z]+)(>| ;)/; // modeline
const Filters = {
add: (s, arg) => s + arg,
allow: (s, arg) => arg.split(',').includes(s) ? s : '',
apply: (s, arg) => Filtersarg,
camelcase: s => s.replace(/-([a-z])/g, g => g[1].toUpperCase()),
capfirst: s => s.charAt(0).toUpperCase() + s.slice(1),
combine: (s, arg) => s.concat ? s.concat(arg) : Object.assign({}, s, arg),
default: (s, arg) => s || arg,
divide: (s, arg) => (s * 1) / (arg * 1),
divisibleby: (s, arg) => ((s * 1) % (arg * 1)) === 0,
dividedinto: (s, arg) => Math.ceil((s * 1) / (arg * 1)),
escapejs: s => JSON.stringify(String(s)).replace(/(^"|"$)/g, ''),
escape: (s, arg) => s && s.safe ? s : syntax(s + '', arg || 'text'),
first: s => Array.from(s)[0],
join: (s, arg) => (s || []).join(typeof arg === "undefined" ? ", " : arg),
json: (s, arg) => JSON.stringify(s, null, arg || undefined),
guessmode: s => modeRE.test(s.split('\n')[0]) ? modeRE.exec(s)[2] : '',
last: s => s[s.length - 1],
length: s => s ? (s.length !== undefined ? s.length : Object.keys(s).length) : 0,
lines: s => s.split('\n'),
lower: s => s.toLowerCase(),
multiply: (s, arg) => (s * 1) * (arg * 1),
number: (s) => Number(s),
skipfirst: (s, arg) => Array.from(s).slice(arg || 1),
subtract: (s, arg) => s - arg,
sorted: (s, arg) => Array.from(s).sort(arg && ((a, b) => a[arg] > b[arg] ? 1 : -1)),
trim: (s, arg) => s.replace(new RegExp(^\\s*${ arg = arg ? escapere(arg).replace(',', '|') : '|' }\\s*$, 'g'), ''),
trimfile: s => s.replace(/^([^\n]+?script[^\n]+?[ \n]type=[^\n>]+?>)/is, ''),
truncate: (s, arg) => ((s && s.length > arg1) ? (s.substr(0, arg-1) + '…') : s),
type: s => s === null ? 'null' : (Array.isArray(s) ? 'array' : typeof s),
renderas: (rCtx, template) => safe(template.render(rCtx)),
reversed: s => Array.from(s).reverse(),
upper: s => s.toUpperCase(),
urlencode: (s, arg) => windowencodeURI${ arg ? 'Component' : ''}
.replace(/#/g, '%23'), // Ensure # gets encoded
yesno: (s, arg) => ${ arg || 'yes,no' },,.split(',')[s ? 0 : s === null ? 2 : 1],
};
const { values, keys, entries } = Object;
return Object.assign(Filters, Modulo.ContentTypes(modulo),
{ values, keys, entries, tagswap, get, safe, escapere, syntax });
} // md:---
/
md: ## Configuration
md: All definitions "extend" a base configuration. See below:
md:html=component<Template>{% for t, c in global.config %} md:<h4>{{ t }}</h4><pre>{{ c|json:2 }}</pre>{% endfor %}</Template>/
Modulo.Configs = function DefaultConfiguration() {
const CONFIG = { /#UNLESS#/
artifact: {
tagAliases: { 'js': 'script', 'ht': 'html', 'he': 'head', 'bo': 'body' },
pathTemplate: '{{ tag|default:cmd }}-{{ hash }}.{{ def.name }}',
DefLoaders: [ 'DefTarget', 'DefinedAs', 'DataType', 'Src', 'build|Command' ],
CommandBuilders: [ 'FilterContent', 'Collect', 'Bundle', 'LoadElems' ],
CommandFinalizers: [ 'Remove', 'SaveTo' ],
Preprocess: true, // true is "toss code after"
DefinedAs: 'name',
SaveTo: 'BUILD', // Use "BUILD" filesystem-like store interface
FilterContent: 'trimfile|trim|tagswap:config.artifact.tagAliases',
},
component: {
tagAliases: { 'html-table': 'table', 'html-script': 'script', 'js': 'script' },
mode: 'regular',
rerender: 'event',
Contains: 'part',
CustomElement: 'window.HTMLElement', // Used to change base class
DefinedAs: 'name',
BuildLifecycle: 'build',
RenderObj: 'component',
DefLoaders: [ 'DefTarget', 'DefinedAs', 'Src', 'FilterContent', 'Content' ],
DefBuilders: [ 'CustomElement', 'alias|AliasNamespace', 'Code' ],
FilterContent: 'trimfile|trim',
DefFinalizers: [ 'MainRequire' ],
CommandBuilders: [ 'Prebuild|BuildLifecycle', 'BuildLifecycle' ],
Directives: [ 'onMount', 'onUnmount' ],
DirectivePrefix: '', // "component.on.click" -> "on.click"
},
configuration: {
DefTarget: 'config',
DefLoaders: [ 'DefTarget', 'DefinedAs', 'Src|SrcSync', 'Content|Code',
'DefinitionName|MainRequire' ],
},
contentlist: {
DataType: 'CSV',
DefFinalizers: [ 'command|Command' ],
CommandBuilders: [ 'build|BuildAll' ],
build: 'build',
command: '', // (default: def.commands = [])
},
domloader: {
topLevelTags: [ 'modulo', 'file' ],
genericDefTags: { def: 1, script: 1, template: 1, style: 1 },
},
include: {
LoadMode: 'bundle',
ServerTemplate: '{% for p, v in entries %}<script src="https://' +
'{{ server }}/{{ v }}"></' + 'script>{% endfor %}',
DefLoaders: [ 'DefTarget', 'DefinedAs', 'Src', 'Server', 'LoadMode' ],
},
library: {
Contains: 'core',
DefinedAs: 'namespace',
DefTarget: 'config.component',
DefLoaders: [ 'DefTarget', 'DefinedAs', 'Src', 'Content' ],
},
modulo: {
build: { mainModules: [ ] },
defaultContent: '',
fileSelector: "script[type='mdocs'],template[type='mdocs']," +
"style[type='mdocs'],script[type='md'],template[type='md']," +
"script[type='f'],template[type='f'],style[type='f']",
scriptSelector: "script[src$='mdu.js'],script[src$='Modulo.js']," +
"script[src='?'],script[src$='Modulo.html']",
version: '0.1.1',
timeout: 5000,
ChildPrefix: '',
Contains: 'core',
DefLoaders: [ 'DefTarget', 'DefinedAs', 'Src', 'Content' ],
defaultDef: { DefTarget: null, DefinedAs: null, DefName: null },
defaultDefLoaders: [ 'DefTarget', 'DefinedAs', 'DataType', 'Src' ],
defaultDefBuilders: [ 'FilterContent', 'ContentType', 'Load' ],
},
script: {
Directives: [ 'refMount', 'refUnmount' ],
DefFinalizers: [ 'AutoExport', 'Content|Code' ],
AutoExport: '',
},
state: {
Directives: [ 'bindMount', 'bindUnmount' ],
Store: null,
},
style: {
AutoIsolate: true, // true is "default behavior" (autodetect)
isolateSelector: null, // Later has list of selectors
isolateClass: null, // By default, it does not use class isolation
prefix: null, // Used to specify prefix-based isolation (most common)
corePseudo: ['before', 'after', 'first-line', 'last-line' ],
DefBuilders: [ 'FilterContent', 'AutoIsolate', 'Content|ProcessCSS' ],
},
staticdata: { DataType: '?' }, // (? = use ext)
template: {
DefFinalizers: [ 'Content|CompileTemplate', 'Code' ],
FilterContent: 'trimfile|trim|tagswap:config.component.tagAliases',
unsafe: 'filters.escape',
modeTokens: [ '{% %}', '{{ }}', '{# #}', '{-{ }-}', '{-% %-}' ],
opTokens: '==,>,<,>=,<=,!=,not in,is not,is,in,not,gte,lte,gt,lt',
opAliases: {
'==': 'X === Y', 'is': 'X === Y',
'is not': 'X !== Y', '!=': 'X !== Y',
'not': '!(Y)',
'gt': 'X > Y', 'gte': 'X >= Y',
'lt': 'X < Y', 'lte': 'X <= Y',
'in': '(Y).includes ? (Y).includes(X) : (X in Y)',
'not in': '!((Y).includes ? (Y).includes(X) : (X in Y))',
},
},
dev: {
artifact: `
<Artifact name="css" -bundle="link,modstyle,style" build=build,buildvanish,buildlib>
{% for id in def.ids %}{{ def.data|get:id|safe }}{% endfor %}
<Artifact name="js" -bundle="script,modscript" -collect="?" build=build,buildlib>
{% for id in def.ids %}{% if "collected" not in id %}{{ def.data|get:id|safe }}
{% else %}{{ def.data|get:id|syntax:"trimcode"|safe }}{% endif %}{% endfor %}
modulo.definitions = { {% for name, value in definitions %}
{% if name|first is not "" %}{{ name }}: {{ value|json|safe }},{% endif %}
{% endfor %} };
{% for name in config.modulo.build.mainModules %}{% if name|first is not "" %}
modulo.registry.modules.{{ name }}.call(window, modulo);
{% endif %}{% endfor %}
<Artifact name=html path-template="{{ file-path|default:'index.html' }}"
-remove="head iframe,modulo,script[modulo],template[modulo]"
prefix="" build=build,buildvanish>
{{ doc.head.innerHTML|safe }}
{% if "vanish" not in argv|get:0 %}
{% endif %}{{ doc.body.innerHTML|safe }}
<Artifact name=edit -collect=? -save-reqs build=edit>
<Artifact name=vjs -remove="script" build=buildvanish>
</script>
<script Artifact name=new path=index.html build=newapp> \n\t\n\n\n\t \tIpsum
\n <\/script> <script Artifact name=new path=new-lib.html d:='["Lorem","Ipsum"]' build=newlib> \n {% for i,L in def.d %}\n\n{% endfor %}\n\n <\/script> <script Artifact name=new path=new-page.html build=newmd -collect=? -save-reqs> ---\ndate: {{config.date}}\n--- # Title\n### Section\nExample **content**, link: [Edit Me](?argv=edit) <\/script>`, component: ` {% ignoremissing %} {% with local|get:props.store|get:'fdata'|get:props.file|default:null as value %} <iframe style="{% if props.fs %}height:100%;min-height:80vh{% endif %}; width:100%;border:0;border:1px dotted #111;" srcdoc="{% if value is null %}{% else %} {{ value }}{% endif %}" loading=lazy></iframe>{% endwith %} {% endignoremissing %} {% with local|get:props.store|get:'fdata'|get:props.file|default:null as value %} {% with value|lines|length|multiply:props.font|multiply:'1.5' as hg %}{{value|syntax:props.mode|safe}}<textarea style="top:-2px;left:50px;
position:absolute;height:{{hg}}px; font-size:{{props.font}}px"
{{props.readonly|yesno:"readonly,"}} spellcheck=false
{{props.store}}.bind="fdata|{{props.file}}"></textarea></modulo-wrap>
{% endwith %}{% endwith %}
<Style>modulo-line:before{counter-increment:line;content:counter(line); position:absolute;left:0;color:#888;padding:0 0 0 3px}pre{padding:0 0 0 53px;} pre,textarea{counter-reset:line;display:block;color:black;background:transparent; white-space:pre;text-align:start;line-height:1.5;overflow-wrap:break-word; margin:0;box-sizing:content-box;border:1px dotted #111; font-family: monospace}modulo-wrap{display:block;position:relative;width:100%} textarea{resize:none;color:#00000000;caret-color:#000;width:100%;} </Style> {{ props.value|safe }} <script Template -name="demo_component"> \n{{ props.value|safe }}\n\n <\/script>{% for row in proc.log %}
{{ row|reversed|join:" \t" }}
{% endfor %} {% for path, text in build.fdata %}{{ path }} ({{ text|length }})
{% endfor %}{% endif %}CONFIG.syntax = { // Simple RegExp mini langs for |syntax: filter
jsReserved: { // Used by Script tags and JS syntax
'break': 1, 'case': 1, 'catch': 1, 'class': 1, 'const': 1, 'continue': 1,
'debugger': 1, 'default': 1, 'delete': 1, 'do': 1, 'else': 1,
'enum': 1, 'export': 1, 'extends': 1, 'finally': 1, 'for': 1,
'function': 1, 'if': 1, 'implements': 1, 'import': 1, 'in': 1,
'instanceof': 1, 'interface': 1, 'new': 1, 'null': 1, 'package': 1,
'private': 1, 'protected': 1, 'public': 1, 'return': 1, 'static': 1,
'super': 1, 'switch': 1, 'throw': 1, 'try': 1, 'typeof': 1, 'var': 1,
'let': 1, 'void': 1, 'while': 1, 'with': 1, 'await': 1, 'async': 1,
'true': 1, 'false': 1,
},
html: [ // html syntax highlights some common html / templating
[ null, 'syntax', 'txt' ],
[ /({%[^<>]+?%}|{{[^<>]+?}})/gm,
'$1'],
[ /(</?)([a-z]+-[A-Za-z]+)/g,
'$1$2'],
[ /(</?)(script |def |template |)([A-Z][a-z][a-zA-Z])/g,
'$1$2$3'],
[ /(</?[a-z1-6]+|>)/g, '$1'],
],
'md': [ // md is a (very limited) Markdown implementation
[ null, 'syntax', 'text' ],
[ /(<)-(script)(>)/ig, '$1/$2$3' ], // fix <-script>
[ /([a-z]*)([a-z=]*)\n?(.+?)\n?/igs,
'<modulo-Editor mode="$1" demo$2 value="$3">' ],
[ /^(#+)\s(.+)$/gm, '
' ], [ / /g, ' ' ], ], }; CONFIG.syntax.js = Array.from(CONFIG.syntax.html) CONFIG.syntax.js.push([ new RegExp(
(\\b${ Object.keys( CONFIG.syntax.jsReserved).join('\\b|\\b') }\\b), 'g'),
<strong style=color:firebrick>$1</strong> ]);
return CONFIG
};
Modulo.ContentTypes = modulo => ({ // md: ContentTypes: CSV (limited),
CSV: s => (s || '').trim().split('\n').map(r => r.trim().split(',')),
JS: s => Function('return (' + s + ');')(), // md: JS (expression syntax),
JSON: s => JSON.parse(s || '{ }'), // md: JSON (default),
MD: (s, arg) => { //MD - Parses "Markdown Meta" (e.g. in --- at top)
const headerRE = /^([^\n]*---+\n.+?\n---\n)/s;
const obj = { body: s.replace(headerRE, '') };
if (obj.body !== s) { // Meta was specified
let key = null; // Used for continuing / multiline keys
const lines = s.match(headerRE)[0].split(/[\n\r]/g);
for (const line of lines.slice(1, lines.length - 2)) { // omit ---
if (key && (new RegExp('^[ \t]')).test(line)) { // Multiline?
obj[key] += '\n' + line; // Add back \n, verbatim (no trim)
} else if (line.trim() && (key = line.split(':')[0])) { // Key?
obj[key.trim()] = line.substr(key.length + 1).trim();
}
}
}
obj.body = arg ? modulo.templateFilter.syntax(obj.body, arg) : obj.body;
return obj;
},
TXT: s => s, // md: TXT (plain text),
BIN: (s, arg = 'application/octet-stream') => //md: BIN (binary types).
data:${ arg };charset=utf-8,${ window.encodeURIComponent(s) },
});
/* Utility Functions that setup Modulo */ Modulo.Utils = function UtilityFunctions (modulo) {
const Utilities = { escapeRegExp: s => // Escape string for regexp s.replace(/[.+?^${}()|[]\]/g, "\" + "\x24" + "&"), insObject: obj => Object.assign(obj || {}, Utilities.lowObject(obj)), get: (obj, key, sep='.') => (key in obj) ? // Get key path from object obj[key] : (key + '').split(sep).reduce((o, name) => o[name], obj), lowObject: obj => Object.fromEntries(Object.keys(obj || {}).map( key => [ key.toLowerCase(), obj[key] ])), normalize: s => // Normalize space to ' ' & trim around tags s.replace(/\s+/g, ' ').replace(/(^|>)\s(<|$)/g, '$1$2').trim(), set: (obj, keyPath, val, sep = null) => // Set key path in object new modulo.engine.ValueResolver(modulo, sep).set(obj, keyPath, val), trimFileLoader: s => // Remove first lines like "...script...file>" s.replace(/^([^\n]+script[^\n]+[ \n]file[^\n>]+>(*/\n|---\n|\n))/is, '$2'), };
function instance(def, extra, inst = null) {
const registry = (def.Type in modulo.core) ? modulo.core : modulo.part;
inst = inst || new registry[def.Type](modulo, def, extra.element || null);
const id = ++window.Modulo.instanceID; // Unique number
//const conf = Object.assign({}, modulo.config[name.toLowerCase()], def);
const conf = Object.assign({}, def); // Just shallow copy "def"
const attrs = modulo.util.keyFilter(conf);
Object.assign(inst, { id, attrs, conf }, extra, { modulo: modulo });
if (inst.constructedCallback) {
inst.constructedCallback();
}
return inst;
}
function 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 => modulo.definitions[name])) {
parts[def.RenderObj || def.Name] = modulo.util.instance(def, extra);
}
return parts;
}
function initComponentClass (modulo, def, cls) {
// Run factoryCallback static lifecycle method to create initRenderObj
const initRenderObj = { elementClass: cls }; // TODO: "static classCallback"
for (const defName of def.ChildrenNames) {
const cpartDef = modulo.definitions[defName];
const cpartCls = modulo.part[cpartDef.Type];
modulo.assert(cpartCls, 'Unknown Part:' + 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.util.instanceParts(def, { element: this });
};
cls.prototype.connectedCallback = function connectedCallback () {
modulo._connectedQueue.push(this);
window.setTimeout(modulo._drainQueue, 0);
};
cls.prototype.moduloMount = function moduloMount(force = false) {
if ((!this.isMounted && !modulo.paused) || force) {
this.cparts.component._lifecycle([ 'initialized', 'mount' ]);
}
};
cls.prototype.attributeChangedCallback = function (attrName) {
if (this.isMounted) { // pass on info as attr callback
this.cparts.component._lifecycle([ 'attr' ], { attrName });
}
};
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.registry.elements[cls.name] = cls; // Copy class to Modulo
}
function newNode(innerHTML, tag, extra) {
const obj = Object.assign({ innerHTML }, extra);
return Object.assign(window.document.createElement(tag || 'div'), obj);
}
function makeStore (modulo, def) {
const data = JSON.parse(JSON.stringify(modulo.util.keyFilter(def)));
return { data, boundElements: {}, subscribers: [] };
}
function keyFilter (obj, func = null) {
func = func || (key => /^[a-z]/.test(key)); // Start with lower alpha
const keys = func.call ? Object.keys(obj).filter(func) : func;
return Object.fromEntries(keys.map(key => [ key, obj[key] ]));
}
function urlReplace(str, origin, field = 'href') { // Absolutize URLs
const ifURL = (all, pre, url, suf) => /^[a-z]+://./i.test(url) ? all :
${ pre }"${ (new window.URL(origin + '/../' + url))[field] }"${ suf };
return str.replace(/(href=|src=|url()['"]?(.+?)['"]?([>\s)])/gi, ifURL);
}
Object.assign(Utilities, { initComponentClass, instance, instanceParts,
newNode, makeStore, keyFilter, urlReplace })
/#UNLESS#/
function loadString (text, pName) { // Loads string, possibly removing preamble
text = text.replace(/^([^\n]+?script[^\n]+?[ \n]type=[^\n>]+?>)(.)$/is, '$2');
return loadFromDOM(newNode(text), pName);
}
function loadFromDOM(elem, parentName = null, quietErrors = false) {
const loader = new modulo.engine.DOMLoader(modulo);
return loader.loadFromDOM(elem, parentName, quietErrors);
}
function repeatProcessors(defs, field, cb) {
const { WAIT, WAITALL } = modulo.consts;
let changed = true; // Run at least once
const defaults = modulo.config.modulo['default' + field] || [];
while (changed !== false) {
changed = false; // TODO: Make deterministic order e.g. arr
for (const def of (defs || Object.values(modulo.definitions))) {
const processors = def[field] || defaults;
const result = applyNextProcessor(def, processors);
if (result === WAIT || result === WAITALL) {
changed = result
break;
}
changed = changed || result;
}
} // TODO: Refactor this area
const repeat = () => repeatProcessors(defs, field, cb);
if (changed !== WAIT && changed !== WAITALL && Object.keys(
modulo.fetchQueue ? modulo.fetchQueue.queue : {}).length === 0) {
if (cb) { cb(); }
} else {
modulo.fetchQueue.enqueue(repeat, changed === WAITALL);
}
}
function applyNextProcessor (def, processorNameArray) {
const cls = modulo.part[def.Type] || modulo.core[def.Type] || {};
for (const name of processorNameArray) {
modulo.assert(name, ${ def.DefinitionName } - Invalid: ${ processorNameArray }");
const [ attrName, aliasedName ] = name.split('|');
if (attrName in def) {
const funcName = aliasedName || attrName;
const proc = modulo.processor[funcName.toLowerCase()];
const func = funcName in cls ? cls[funcName].bind(cls) : proc;
modulo.assert(func, Invalid processor: "${ funcName }");
const value = def[attrName]; // Pluck value & remove attribute
delete def[attrName];
const ret = func(modulo, def, value);
return ret ? ret : true; // falsy -> true
}
}
return false; // No processors were applied, return false
}
function configureStatic (modulo) { // Setup default content
const { staticDir, rootDir, scriptSelector, fileSelector } = modulo.config.modulo;
const [ cmdName, src ] = modulo.argv;
const dir = staticDir || 'static/';
const mdu = window.document.querySelector(scriptSelector);
const root = rootDir || ((mdu || {}).src || '').split(dir)[0];
modulo.filePath = (window.location + '').replace(root, '').split('?')[0];
const file = window.document.querySelector(fileSelector);
if (file) { // Content file exists, extract data then remove
const preamble = /^([^\n]+?script[^\n]+?[ \n]type=[^\n>]+?>).$/is;
const elem = document[document.body.children.length ? 'body' : 'head'];
const s = elem.innerHTML.replace(preamble, '$1').replace(/=""/g, '');
const text = s.replace(/="([\w_?.:/-]+)"/g, '=$1') + file.innerHTML;
if (window.parent && parent !== window && cmdName === '_load') {
modulo.assert(s.length < 200, Header Too Long: ${ src });
parent.postMessage(JSON.stringify({ FL: [ text, src ] }), '*');
modulo.util.repeatProcessors = () => {} // stop future loading
return;
} else if (!modulo.definitions.modulo) {
modulo.fetchQueue.enqueue(() => { // loads default viewer
document.body.innerHTML += modulo.config.modulo.defaultContent;
}, true);
}
modulo.stores.CACHE.setItem(modulo.filePath, text)
file.remove();
}
const rPath = modulo.filePath.split('/').slice(1).map(s => '..').join('/');
modulo.rootPath = rPath ? (rPath + '/') : '';
if (!modulo.definitions.modulo && mdu && root !== mdu.src && // (No Modulo)
!modulo.filePath.startsWith(dir)) { // (Not static)
modulo.util.loadString(<Modulo -src="${ root + dir }">); // Load default
}
}
function hash (str) { // Returns base32 hash
let h = 0; // Simple, insecure, "hashCode()" implementation
for (let i = 0; i < str.length; i++) {
h = Math.imul(31, h) + str.charCodeAt(i) | 0;
} //h = ((h << 5 - h) + str.charCodeAt(i)) | 0;
const hash8 = ('---------' + (h || 0).toString(32)).slice(-8);
return hash8.replace(/-/g, 'x'); // Pad with 'x'
}
function bundleHead(modulo, elem, bundle = null, doc = null) {
doc = doc || window.document;
const { newNode, hash } = modulo.util;
const url = elem.getAttribute('src') || elem.getAttribute('href');
const id = 'include' + hash(elem.name || url || elem.textContent);
bundle = bundle || modulo.bundles[elem.tagName.toLowerCase()];
if (doc.getElementById(id) || bundle.includes(id)) {
return; // already included in this bundle!
}
bundle.push(id); // Keep ordering of insertion in this list
const newElem = newNode(elem.innerHTML, elem.tagName);
if (elem.tagName === 'SCRIPT' && url && !elem.hasAttribute('async')) {
modulo.fetchQueue.queue[id] = [ ] // Add a "waitable" queue
newElem.onload = () => modulo.fetchQueue.receiveData(null, id);
}
for (const attr of elem.attributes || []) { // Copy all attributes from old elem
newElem.setAttributeNode(attr.cloneNode(true)); // ...to new elem
}
newElem.setAttribute('id', id);
newElem.textContent = elem.textContent; // Evaluate code
doc.head.append(newElem); // add to document
}
function getParentDefPath(modulo, def) {
const pDef = def.Parent ? modulo.definitions[def.Parent] : null;
const url = String(window.location).split('?')[0]; // Remove ? info
return pDef ? pDef.Source || getParentDefPath(modulo, pDef) : url;
}
function makeStoreFS(modulo) { // TODO: Refactor with state
const store = modulo.util.makeStore(modulo, { fdata: { }, log: [ ] });
return Object.assign(store, { types: {} }, {
propagate: modulo.part.State.prototype.propagate.bind(store),
key: i => Object.keys(store.data.fdata)[i],
getItem: key => key in store.data.fdata ? store.data.fdata[key] : null,
removeItem: (key, val) => store.setItem(key, null),
setItem: (key, val) => {
store.data.fdata[key] = val;
store.data.log.push([ key, (new Date()).getTime() / 1000]);
store.propagate('fdata', store.data.fdata);
},
});
}
function setupDevLib(modulo, subFS = null) {
// Setup config info, sets up the "FS" stores (or parent's stores + queue)
const { config, util, stores, fetchQueue, assert } = modulo;
config.pathName = window.location.pathname.split('/').pop();
config.date = (new Date()) + ''; // String version of date
for (const name of config.modulo.fs || [ 'BUILD', 'CACHE', 'PROC' ]) {
try { stores[name] = window.parent._moduloFS[name];
} catch (e) { } // silence XSS or undefined
stores[name] = stores[name] || util.makeStoreFS(modulo); // default
}
const { timeout, devLoad } = config.modulo;
for (const type of devLoad || [ 'artifact', 'component' ]) {
const str = config._dev[type].replace(/\n[ \n\r]+/gm, '\n'); // norm ws
util.loadString(str.replace(/\t/g, ' '), _${ type }); // indent
}
modulo._loadTimeout = timeout && setTimeout(() => assert(!fetchQueue.queue.length,
timeout, '[TIMEOUT]', ...Object.keys(fetchQueue.queue)), timeout);
modulo.DEV = true;
}
function getCommand(modulo) {
const cmdName = modulo.argv.length > 0 ? modulo.argv[0] : '_default';
return () => modulo.commandcmdName;
}
Object.assign(Utilities, { applyNextProcessor, configureStatic, hash,
loadString, bundleHead, getParentDefPath, loadFromDOM, makeStoreFS,
setupDevLib, getCommand, repeatProcessors }) /#ENDUNLESS#/
return Utilities; }; /* End of UtilityFunctions */
Modulo.Processors = function DefProcessors (modulo) { /#UNLESS#/
// md: ### Content Processors
function src (modulo, def, value) { // md: -src="path..." - Loads content
const { getParentDefPath } = modulo.util;
try { def.Source = (new window.URL(value, getParentDefPath(modulo, def))).href;
} catch { }
modulo.fetchQueue.fetch(def.Source || value).then(text => {
//def.Content = trimFileLoader(text || '') + (def.Content || '');
def.Content = text || '' + (def.Content || '');
});
}
function srcSync (modulo, def, value) { // md: -src-sync="path..." - Like
modulo.processor.src(modulo, def, value); // md: src, except it waits.
return modulo.consts.WAIT;
}
function filterContent (modulo, def, value) { //md: -filter-content= allows
if (def.Content && value) { // md: for a mini-template of just filters
const miniTemplate = {{ def.Content|${ value }|safe }}; //md: to
const tmplt = new modulo.part.Template(miniTemplate); //md: apply
def.Content = tmplt.render({ def, config: modulo.config }); //md:to
} //md: the definition's content before loading it.
}
function defTarget (modulo, def, value) { // saves def const resolverName = def.DefResolver || 'ValueResolver'; const resolver = new modulo.engineresolverName; 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); } } }
function command (modulo, def, value) { def.commands = (value || ' ').split(/,/.test(value) ? ',' : '\n'); for (const cmd of def.commands) { // Register dev commands const commandName = cmd.trim() || 'build'; modulo.command[commandName] = function build (modulo) { for (const [ key, obj ] of Object.entries(modulo.definitions)) { if (obj.commands && !obj.commands.includes(commandName)) { delete obj.CommandBuilders; // stop cmd delete obj.CommandFinalizers; } } modulo.COMMAND = commandName; // record globally modulo._drainQueue(); // wait for mounts const { BUILD } = modulo.stores; // pass BUILD const fs = [ BUILD.data.fdata, modulo.fetchQueue.data ]; window._moduloFS = { fs, BUILD }; // pass "fs stack" modulo.preprocessAndDefine(modulo.cmdCallback, 'Command'); } } }
function content (modulo, conf, value) { modulo.util.loadString(value, conf.DefinitionName); }
function mainRequire (modulo, conf, value) { modulo.config.modulo.build.mainModules.push(value); modulo.registry.modules[value].call(window, modulo); }
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); } }
function dataType (modulo, def, value) { if (value === '?') { // '?' means determine based on extension const ext = def.Src && def.Src.match(/.([a-z]+)$/i); value = ext ? ext[1] : 'json'; // If extension, use; else use "json" } def.ContentType = [ value.toUpperCase(), def.Hint ]; }
function code (modulo, def, value) { const { newNode, bundleHead } = modulo.util; const name = def.DefinitionName; // Defines global module with name modulo.assert(!(name in modulo.registry.modules), 'Duplicate module name'); const prefix = 'modulo.registry.modules.' + name + ' = function ' + name; const content = prefix + ' (modulo) { ' + value + '}'; if (document && document.head && name[0] !== '_' && !def.Preprocess) { bundleHead(modulo, newNode(content, 'SCRIPT'), modulo.bundles.modscript); } else { // Else: Do not bundle, run in Function Function('window', 'modulo', content)(window, modulo); } }
function contentType (modulo, def, value) { def.data = modulo.contentType[value[0]](def.Content, value[1]); delete def.Content; }
return modulo.util.insObject({ src, srcSync, defTarget, command, content, mainRequire, definedAs, dataType, filterContent, code, contentType }) /#ENDUNLESS#/ } /* End of Modulo.DefProcessors */
Modulo.Engines = function Engines (modulo) {
class DOMLoader {/#UNLESS#/
getAllowedChildTags(parentName) {
let tagsLower = 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 = modulo.definitions[parentName];
const msg = Invalid parent: ${ parentName } (${ parentDef });
modulo.assert(parentDef && parentDef.Contains, msg);
const names = Object.keys(modulo[parentDef.Contains]);
tagsLower = names.map(s => s.toLowerCase()); // Ignore case
}
return tagsLower;
}
loadFromDOM(elem, Parent = null, quietErrors = false) {
const { defaultDef } = modulo.config.modulo;
const toCamel = s => s.replace(/-([a-z])/g, g => g[1].toUpperCase());
const tagsLower = this.getAllowedChildTags(Parent);
const array = [];
for (const node of elem.children || []) {
const Type = this.getDefType(node, tagsLower, quietErrors);
if (node._moduloLoadedBy || Type === null) {
continue; // Already loaded, or an ignorable or silenced error
} // Valid definition, now create the "def" object
node._moduloLoadedBy = modulo.id; // Mark as having loaded this
const Content = node.tagName === 'SCRIPT' ? node.textContent : node.innerHTML;
const def = Object.assign({ Type, Parent, Content }, defaultDef);
array.push(Object.assign(def, modulo.config[Type]));
for (let name of node.getAttributeNames()) { // Loop through attrs
const value = node.getAttribute(name);
if (Type === name && !value) { // e.g.
continue; // This is the "Type" attribute itself, skip
}
def[toCamel(name)] = value; // "-kebab-case" to "CamelCase"
}
}
modulo.util.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 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 (!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
}/#ENDUNLESS#/
}
class ValueResolver { constructor(contextObj = null, sep = null) { this.ctxObj = contextObj; this.sep = sep || '.'; this.isJSON = /^(true$|false$|null$|[^a-zA-Z])/; // "If not variable" } get(key, ctxObj = null) { const { get } = window.modulo.util; // 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, this.sep); } set(obj, keyPath, val, autoBind = false) { const index = keyPath.lastIndexOf(this.sep) + 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(this.sep)); val = this.get(val); // Resolve "val" from context, or JSON literal /if (autoBind && !this.isJSON.test(val) && parentKey.includes(this.sep)) { val = val.bind(this.get(parentKey)); }/ } target[key] = val; // Assign the value to it's parent object } }
class FetchQueue {
constructor() {
this.queue = {}
this.data = {}
this.frames = {}
this.protos = { 'file:': 1, 'about:': 1 }
if (location.protocol in this.protos) { // check for "fs stack"
try { this.fs = (window._moduloFS || parent._moduloFS).fs } catch { }
const load = ({ data }) => this.receiveData(...JSON.parse(data)._FL);
window.addEventListener('message', load, false);
}
}
fetch(src) { // "thennable" that resembling window.fetch
src = src === '?' ? modulo.config.pathName : src; // resolve '?'
src = src.endsWith('/') ? ${ src }index.html : src; // auto index.html
return { then: callback => this.request(src, callback, console.error) };
}
request(src, resolve, reject) { // Do fetch & do enqueue
if (src in this.data) { // Cache
resolve(this.data[src], src); // (sync route)
} else if (this.fs && src in Object.assign({ }, ...this.fs)) {
resolve(Object.assign({ }, ...this.fs)[src], src); // child route
} else if (!(src in this.queue)) { // No cache, no queue
this.queue[src] = [ resolve ]; // First time, create the queue Array
if (location.protocol in this.protos) { // Use "IFRAME" transit
this.frames[src] = window.document.createElement('IFRAME');
this.frames[src].style = 'display: none';
this.frames[src].src = ${ src }?argv=_load&argv=${ src };
document.head.append(this.frames[src])
} else {
window.fetch(src, { cache: 'no-store' })
.then(response => response.text())
.then(text => this.receiveData(text, src))
.catch(reject);
}
} else { // Already requested, only enqueue function
this.queue[src].push(resolve);
}
}
receiveData(text, src) {
if (src in this.frames) {
this.frames[src].remove();
delete this.frames[src];
}
this.data[src] = text;
const resolveCallbacks = this.queue[src]; // "Consume" entire queue
delete this.queue[src];
for (const dataCallback of resolveCallbacks) {
dataCallback(text, src);
}
}
enqueue(callback, waitForAll = false) { // Wait for current queue (or all)
const allQueues = Array.from(Object.values(this.queue)); // Copy array
const { length } = allQueues;
if (length === 0) {
return callback(); // Synchronous route
} else if (waitForAll) { // Doing a wait -- setup re-enqueue loop
return this.enqueue(() => Object.keys(this.queue).length === 0 ?
callback() : this.enqueue(callback, true));
}
let count = 0; // Using count we only do callback() when ALL returned
const check = () => ((++count >= length) ? callback() : 0);
allQueues.forEach(queue => queue.push(check)); // Add to every queue
}
}
class DOMCursor {
constructor(parentNode, parentRival, slots) {
this.slots = slots || {}; // Slottables keyed by name (default is '')
this.instanceStack = []; // Used for implementing DFS non-recursively
this._rivalQuerySelector = parentRival.querySelector.bind(parentRival);
this._querySelector = parentNode.querySelector.bind(parentNode);
this.initialize(parentNode, parentRival);
}
initialize(parentNode, parentRival) {
this.parentNode = parentNode;
this.nextChild = parentNode.firstChild;
this.nextRival = parentRival.firstChild;
this.activeExcess = null;
this.activeSlot = null;
if (parentRival.tagName === 'SLOT') { // Parent will "consume" a slot
const slotName = parentRival.getAttribute('name') || '';
this.activeSlot = this.slots[slotName] || null; // Mark active
if (this.activeSlot) { // Children were specified for this slot!
delete this.slots[slotName]; // (prevent "dupe slot" bug)
this._setNextRival(null); // Move the cursor to the first elem
}
}
}
saveToStack() { // Creates an object copied with all cursor state
this.instanceStack.push(Object.assign({ }, this)); // Copy to empty obj
}
loadFromStack() { // Remaining stack to "walk back" (non-recursive DFS)
const stack = this.instanceStack;
return stack.length > 0 && Object.assign(this, stack.pop());
}
loadFromSlots() { // There are "excess" slots (copied, but deeply nested)
const name = Object.keys(this.slots).pop(); // Get next ("pop" from obj)
if (name === '' || name) { // Is name valid? (String of 0 or more)
const sel = name ? slot[name="${ name }"] : 'slot:not([name])';
const rivalSlot = this._rivalQuerySelector(sel);
if (!rivalSlot) { // No slot (e.g., conditionally rendered, or typo)
delete this.slots[name]; // (Ensure "consumed", if not init'ed)
return this.loadFromSlots(); // If no elem, try popping again
}
this.initialize(this._querySelector(sel) || rivalSlot, rivalSlot);
return true; // Indicate success: Child and rival slots are ready
}
}
hasNext() {
if (this.nextChild || this.nextRival) {
return true; // Is pointing at another node
} else if (this.loadFromStack() || this.loadFromSlots()) { // Walk back
return this.hasNext(); // Possibly loaded nodes nextChild, nextRival
}
return false; // Every load attempt is "false" (empty), end iteration
}
_setNextRival(rival) { // Traverse this.nextRival based on DOM or SLOT
if (this.activeSlot !== null) { // Use activeSlot array for next instead
if (this.activeSlot.length > 0) {
this.nextRival = this.activeSlot.shift(); // Pop off next one
this.nextRival._moduloIgnoreOnce = true; // Ensure no descend
} else {
this.nextRival = null;
}
} else {
this.nextRival = rival ? rival.nextSibling : null; // Normal DOM traversal
}
}
next() {
let child = this.nextChild;
let rival = this.nextRival;
if (!rival && this.activeExcess && this.activeExcess.length > 0) {
return this.activeExcess.shift(); // Return the first pair
}
this.nextChild = child ? child.nextSibling : null;
this._setNextRival(rival); // Traverse initially
return [ child, rival ];
}
}
class DOMReconciler { constructor() { this.directives = {}; this.patches = []; this.patch = this.pushPatch; } applyPatches(patches) { for (const patch of patches) { this.applyPatch(patch[0], patch[1], patch[2], patch[3]); } } registerDirectives(thisObj, def) { const prefix = 'DirectivePrefix' in def ? def.DirectivePrefix : (def.RenderObj || def.Name) + '.'; for (const method of def.Directives || []) { this.directives[prefix + method] = thisObj; } } reconcileChildren(childParent, rivalParent, slots) { const cursor = new modulo.engine.DOMCursor(childParent, rivalParent, slots); while (cursor.hasNext()) { // "rival" is node we wish "child" to match const [ child, rival ] = cursor.next(); const needReplace = child && rival && ( // If both exist... child.nodeType !== rival.nodeType || // And type is inequal child.nodeName !== rival.nodeName); // OR the tagName differs if ((child && !rival) || needReplace) { // we have more rival, delete child this.patchAndDescendants(child, 'Unmount'); this.patch(cursor.parentNode, 'removeChild', child); } if (needReplace) { // do swap with insertBefore this.patch(cursor.parentNode, 'insertBefore', rival, child.nextSibling); this.patchAndDescendants(rival, 'Mount'); } if (!child && rival) { // we have less than rival, take rival this.patch(cursor.parentNode, 'appendChild', rival); this.patchAndDescendants(rival, 'Mount'); } if (child && rival && !needReplace) { // Both exist and same type if (child.nodeType !== 1) { // text or comment node if (child.nodeValue !== rival.nodeValue) { // update this.patch(child, 'node-value', rival.nodeValue); } } else if (!child.isEqualNode(rival)) { // sync if not equal this.reconcileAttributes(child, rival); if (rival.hasAttribute('modulo-ignore')) { // Don't descend // console.log('Skipping ignored node'); } else if (child.isModulo) { // is a Modulo component this.patch(child, 'rerender', rival); // TODO rm! } else { //} else if (!this.shouldNotDescend) { cursor.saveToStack(); cursor.initialize(child, rival); } } } } } pushPatch(node, method, arg, arg2 = null) { this.patches.push([ node, method, arg, arg2 ]); } applyPatch(node, method, arg, arg2) { // take that, rule of 3! if (method === 'node-value') { node.nodeValue = arg; } else if (method === 'insertBefore') { node.insertBefore(arg, arg2); // Needs 2 arguments } else { node[method].call(node, arg); // invoke method } } patchDirective(el, rawName, suffix, copyFromEl = null) { const split = rawName.split(/./g); if (split.length < 2) { //if (!(rawName in this.directiveLiterals)) { return; // Fast route: not a directive } const value = (copyFromEl || el).getAttribute(rawName); // Get value let dName = split.shift() // Start with left of '.' while (split.length > 0 && !((dName + suffix) in this.directives)) { dName += '.' + split.shift() // Build potential directive prefix } const nameSuffix = split.join('.'); // e.g. "on.click" -> "click" const fullName = dName + suffix; // e.g. "state.bind" -> "state.bindMount" const patchName = (fullName.split('.')[1] || fullName); const directive = { el, value, nameSuffix, rawName }; // Obj to pass this.patch(this.directives[fullName], patchName, directive); } reconcileAttributes(node, rival) { const myAttrs = new Set(node ? node.getAttributeNames() : []); const rivalAttributes = new Set(rival.getAttributeNames()); // Check for new and changed attributes for (const rawName of rivalAttributes) { const attr = rival.getAttributeNode(rawName); if (myAttrs.has(rawName) && node.getAttribute(rawName) === attr.value) { continue; // Already matches, on to next } if (myAttrs.has(rawName)) { // If exists, trigger Unmount first this.patchDirective(node, rawName, 'Unmount'); } // Set attribute node, and then Mount based on rival value this.patch(node, 'setAttributeNode', attr.cloneNode(true)); this.patchDirective(node, rawName, 'Mount', rival); } // Check for old attributes that were removed (ignoring modulo- prefixed ones) for (const rawName of myAttrs) { if (!rivalAttributes.has(rawName) && !rawName.startsWith('modulo-')) { this.patchDirective(node, rawName, 'Unmount'); this.patch(node, 'removeAttribute', rawName); } } } patchAndDescendants(parentNode, actionSuffix) { if (parentNode.nodeType !== 1) { return; // (not element) } if (parentNode._moduloIgnoreOnce) { // used by slot DOMCursor delete parentNode._moduloIgnoreOnce; return; } const searchNodes = Array.from(parentNode.querySelectorAll('*')); for (const node of [ parentNode ].concat(searchNodes)) { for (const rawName of node.getAttributeNames()) { this.patchDirective(node, rawName, actionSuffix); } } } }
return { FetchQueue, DOMLoader, ValueResolver, DOMReconciler, DOMCursor } } /* End of Modulo.Engines */
Modulo.FetchQueues = function FetchQueues (modulo) {
Object.assign(modulo, {
_connectedQueue: [],
_drainQueue: () => {
while (modulo._connectedQueue.length > 0) {
modulo._connectedQueue.shift().moduloMount();
}
},
cmdCallback: (cmdStatus = 0, edit = null, html = null) => {
modulo.cmdStatus = cmdStatus;
if (edit || edit === null) { // null = most recent, false = no replace
const { log } = modulo.stores.BUILD.data; // Edit last logged
edit = edit || log.length ? log[log.length - 1][0] : '';
const att = full=full view="${ edit }" edit="${ edit }";
window.document.body.innerHTML = html || <modulo-Editor${ att }>;
}
},
preprocessAndDefine(cb, prefix = 'Def') {
cb = cb || (() => {});
modulo.fetchQueue.enqueue(() => {
modulo.util.repeatProcessors(null, prefix + 'Builders', () => {
modulo.util.repeatProcessors(null, prefix + 'Finalizers', cb)
});
}, true); // The "true" causes it to wait for all
},
assert: (value, ...info) => {
if (!value) { // md:---
console.error('%cᵐ°dᵘ⁄o', 'background:red', modulo.id, ...info);
throw new Error(Assert : "${ Array.from(info).join(' ') }");
}
},
bundles: { script: [], style: [], link: [], meta: [],
modscript: [], modstyle: [] },
registry: { bundle: { }, elements: { }, modules: { } },
consts: { WAIT: 900, WAITALL: 901 },
});
modulo.argv = new window.URLSearchParams(window.location.search).getAll('argv');
Object.assign(modulo.registry, { utils: modulo.util, cparts: modulo.part,
coreDefs: modulo.core, processors: modulo.processor }) // TODO Legacy alias
return new modulo.engine.FetchQueue();
}
Modulo.Cores = function CoreDefinitions (modulo) { //md: ### Core Definitions const core = { };
core.Component = class Component { //md: Component - Register a component (Most used)
static CustomElement (modulo, def, value) {
if (!def.ChildrenNames || def.ChildrenNames.length === 0) {
console.warn('MODULO: Empty ChildrenNames:', def.DefinitionName);
return;
} else if (def.namespace === null || def.alias) { // Auto-gen
def.namespace = def.namespace || def.DefinitionName;
} else if (!def.namespace) { // Otherwise default to the Modulo def conf
def.namespace = modulo.config.namespace || 'x'; // or simply 'x-'
}
def.name = def.name || def.DefName || def.Name;
def.TagName = ${ def.namespace }-${ def.name }.toLowerCase();
def.MainRequire = def.DefinitionName;
def.className = def.className || ${ def.namespace }_${ def.name };
def.Code = const def = modulo.definitions['${ def.DefinitionName }']; class ${ def.className } extends window.HTMLElement { constructor(){ super(); this.init(); } static observedAttributes = []; } modulo.util.initComponentClass(modulo, def, ${ def.className }); window.customElements.define(def.TagName, ${ def.className }); return ${ def.className };.replace(/\n\s+/g, '\n');
}
static BuildLifecycle (modulo, def, value) {
for (const elem of document.querySelectorAll(def.TagName)) {
elem.cparts.component._lifecycle([ value ]); // Run the lifecycle
}
return true;
}
static AliasNamespace (modulo, def, value) {
const fullAlias = ${ value }-${ def.name }; // Combine new NS and name
modulo.config.component.tagAliases[fullAlias] = def.TagName;
}
rerender(original = null) {
if (original) {
if (this.element.originalHTML === null) {
this.element.originalHTML = original.innerHTML;
}
this.element.originalChildren = Array.from(
original.hasChildNodes() ? original.childNodes : []);
}
this._lifecycle([ 'prepare', 'render', 'dom', 'reconcile', 'update' ]);
}
getCurrentRenderObj() {
return (this.element.eventRenderObj || this.element.renderObj ||
this.element.initRenderObj);
}
_lifecycle(lifecycleNames, rObj={ }) {
const renderObj = Object.assign({}, rObj, this.getCurrentRenderObj());
this.element.renderObj = renderObj;
this.runLifecycle(this.element.cparts, renderObj, lifecycleNames);
//this.element.renderObj = null; // ?rendering is over, set to null
}
runLifecycle(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;
}
const result = obj[methodName].call(obj, renderObj);
if (result !== undefined) {
renderObj[obj.conf.RenderObj || obj.conf.Name] = result;
}
}
}
}
buildCallback() {
this.element.setAttribute('modulo-mount-html', this.element.originalHTML)
for (const elem of this.element.querySelectorAll('*')) {
for (const name of elem.getAttributeNames()) {
if (!(new RegExp('^[a-z0-9-]+$', 'i').exec(name))) {
elem.removeAttribute(name); // Not alnum or dash
}
}
}
}
initializedCallback() {
this.modulo.paused = true;
const { newNode } = this.modulo.util;
const html = this.element.getAttribute('modulo-mount-html'); // Hydrate?
this._mountRival = html === null ? this.element : newNode(html);
this.element.originalHTML = html === null ? this.element.innerHTML : html;
this.resolver = new this.modulo.engine.ValueResolver(this.modulo);
this.reconciler = new this.modulo.engine.DOMReconciler(this.modulo);
for (const part of Object.values(this.element.cparts)) { // Setup parts
this.reconciler.registerDirectives(part, part.conf);
}
}
mountCallback() { // First "mount", trigger render & hydration
this.rerender(this._mountRival); // render + mount childNodes
delete this._mountRival; // Clear the temporary reference
this.element.isMounted = true; // Mark as mounted
}
prepareCallback() {
return { // Create the initial Component renderObj obj
originalHTML: this.element.originalHTML, // HTML received at mount
id: this.id, // Universally unique ID number
innerHTML: null, // String to copy (default: null is "no-op")
innerDOM: null, // Node to copy (default: null sets innerHTML)
patches: null, // Patch array (default: reconcile vs innerDOM)
slots: { }, // Populate with slots to be filled when reconciling
};
}
domCallback({ component }) {
let { slots, root, innerHTML, innerDOM } = component;
if (this.attrs.mode === 'regular' || this.attrs.mode === 'vanish') {
root = this.element; // default, use element as root
} else if (this.attrs.mode === 'shadow') {
if (!this.element.shadowRoot) {
this.element.attachShadow({ mode: 'open' });
}
root = this.element.shadowRoot; // render into attached shadow
} else if (!root) {
this.modulo.assert(this.attrs.mode === 'custom-root', 'Bad mode')
}
if (innerHTML !== null && !innerDOM) { // Use component.innerHTML as DOM
innerDOM = this.modulo.util.newNode(innerHTML);
}
if (innerDOM && this.attrs.mode !== 'shadow') {
for (const elem of this.element.originalChildren) {
const name = (elem.getAttribute && elem.getAttribute('slot')) || '';
elem.remove(); // Remove from DOM so it can't self-match
if (!(name in slots)) {
slots[name] = [ elem ]; // Sorting into new slot arrays
} else {
slots[name].push(elem); // Or pushing into existing
}
}
}
return { root, innerHTML, innerDOM, slots };
}
reconcileCallback({ component }) {
let { innerHTML, innerDOM, patches, root, slots } = component;
if (innerDOM) {
this.reconciler.patches = []; // Reset reconciler patches
this.reconciler.reconcileChildren(root, innerDOM, slots);
patches = this.reconciler.patches;
}
return { patches, innerHTML }; // TODO remove innerHTML from here
}
updateCallback({ component }) {
this.modulo.paused = false; // Re-enable children mounting
if (component.patches) {
this.reconciler.applyPatches(component.patches);
}
if (this.attrs.mode === 'vanish') {
this.element.replaceWith(...this.element.childNodes);
}
}
handleEvent(func, payload, ev) {
this._lifecycle([ 'event' ]);
func(typeof payload === "undefined" ? ev : payload);
this._lifecycle([ 'eventCleanup' ]);
if (this.attrs.rerender !== 'manual') {
ev.preventDefault(); // Prevent navigation from stopping rerender etc
this.element.rerender(); // Rerender after event
}
}
onMount({ el, value, nameSuffix, rawName, listen }) { // on.click=script.show
this.modulo.assert(this.resolve(value), Not found: ${ rawName }=${ value });
const getOr = (key, key2) => key2 && el.hasAttribute(key2) ?
getOr(key2) : this.resolve(el.getAttribute(key));
listen = listen ? listen : (ev) => { // Define a event func to run handleEvent
const payload = getOr(nameSuffix + '.payload:', 'payload:')
|| el.getAttribute('payload');
this.handleEvent(this.resolve(value, null, true), payload, ev);
}
el.moduloEvents = el.moduloEvents || {}; // Attach if not already
el.moduloEvents[nameSuffix] = listen;
el.addEventListener(nameSuffix, listen);
}
onUnmount({ el, nameSuffix }) {
el.removeEventListener(nameSuffix, el.moduloEvents[nameSuffix]);
delete el.moduloEvents[nameSuffix];
}
resolve(key, defaultVal, autoBind = false) {
const { ValueResolver } = this.modulo.engine;
const resolver = new ValueResolver(this.getCurrentRenderObj());
let val = resolver.get(key, defaultVal);
if (autoBind && typeof val === 'function' && key.includes(resolver.sep)) {
const parentKey = key.substr(0, key.lastIndexOf(resolver.sep));
val = val.bind(this.resolve(parentKey)); // Parent is sub-obj, bind
}
return val
}
}
/#UNLESS#/ class Artifact { // md: Artifact - Registers build and scaffolding commands static Remove (modulo, def, value) { // Delete given excess elements for (const elem of window.document.querySelectorAll(value)) { elem.remove(); } } static Collect (modulo, def, value) { // Gathers any extra elements value = value === '?' ? modulo.config.modulo.scriptSelector : value; def.LoadElems = def.LoadElems || []; // initialize for next processor for (const elem of window.document.querySelectorAll(value)) { elem.id = 'collected_' + (def.LoadElems.length + 1000).toString(); def.LoadElems.push(elem); } } static Bundle (modulo, def, value) { // Runs first to queue up ctx def.LoadElems = def.LoadElems || []; // initialize for next processor for (const bundleName of value.split(',')) { for (const id of modulo.bundles[bundleName]) { def.LoadElems.push(window.document.getElementById(id)); } } } static LoadElems (modulo, def, value) { // Actually enqueues content def.data = def.data || []; // initialize for template def.ids = def.ids || []; // initialize for template if ('SaveReqs' in def) { value.push(modulo.util.newNode('', 'SCRIPT', { src: '?' })); } for (const elem of value) { let url = elem.getAttribute('src') || elem.getAttribute('href') || null; if (url) { // Retrieve from URL modulo.fetchQueue.fetch(url).then(text => { def.data[elem.id] = text; // Attach back to element if ('SaveReqs' in def) { url = url.replace(/^?$/, modulo.config.pathName).split('/').pop(); modulo.stores[def.SaveReqs || 'BUILD'].setItem(url, text); } }); } else { // Retrieve text content def.data[elem.id] = elem.textContent; } def.ids.push(elem.id); // List in order elem.remove(); // Remove from DOM so it doesn't get doubled } } static SaveTo (modulo, def, value, doc = null) { // Build processor const [ cmd, tag ] = modulo.argv const ctx = Object.assign({ def, cmd, tag, doc: doc || window.document }, modulo); const render = s => new modulo.part.Template(s).render(ctx); const text = (def.prefix || '') + render(def.Content); // Execute template if (text) { // Never save an empty string (e.g. ' ' or '\n' is ok) ctx.hash = modulo.util.hash(text); // Compute hash for path def.path = def.path || render(def.pathTemplate); // Render path template modulo.stores[value].setItem(def.path, text); // Save to given FS } } }
class Configuration { } //md:Configuration - Set global modulo.config
class ContentList { // md: ContentList - CMS system for pages and content.
static Load (modulo, def, value) { // md: Specify -load=md to gather
value = value.toUpperCase() || 'TXT'; // md: file list as markdown.
const cache = { }
for (const row of def.data) {
modulo.fetchQueue.fetch(modulo.rootPath + row[0]).then(data => {
const body = modulo.contentType[value](data, def.LoadHint);
cache[row[0]] = typeof body !== 'object' ? { body } : body;
cache[row[0]].Source = row[0];
if (Object.keys(cache).length === def.data.length) {
def.files = def.data.map(row => cache[row[0]]);
}
})
}
return modulo.consts.WAIT;
}
static BuildAll (modulo, def, value) { // md: Use command=buildall to
for (const row of def.data) { // md: loop through and build each file.
modulo.stores.PROC.setItem(row[0] + ?argv=${ value }, '');
}
}
}
const Include = modulo.part.Include;//md: Include - Add global CSS or JS
class Library { } //md:Library - Like <Modulo>, but prefices definitions
class Modulo { } //md:Modulo - The outermost definition to "launch" Modulo
Object.assign(core, { Artifact, Configuration, ContentList, File, Include, Library, Modulo });
/#ENDUNLESS#/ return modulo.util.insObject(core); } /* End of CoreDefinitions */
var modulo = new Modulo(); // Global Instance
/#UNLESS#/ if (typeof window === "undefined") { var window = { } } // non-browsers Object.assign(window, { modulo, Modulo }) // Export
window.modulo.command._load = () => {
console.error('%cᵐ°dᵘ⁄o FAIL; NO TYPE', 'background:orange', modulo.argv[1])
parent.postMessage(JSON.stringify({ _FL: [ null, modulo.argv[1] ] }), '');
}
window.modulo.command._default = function default (modulo) { // [modu/o] menu
const font = 'font-size: 28px; padding:0 8px 0 8px; border:2px solid #000;';
const names = Object.keys(modulo.command).filter(s => !s.startsWith(''));
const gets = names.map(s => get ${ s }(){location.href+="?argv=${ s }"});
const aStr = JSON.stringify([ '%cᵐ°dᵘ⁄o', font, names.join(', ') ]);
const suffix = window.parent !== window ? '"[CHILD THREAD] [NO OP]"'
: "[MAIN THREAD]",new (class {${ gets.join('\n') }});
Function(console.log(...${ aStr },${ suffix }))();
modulo.cmdCallback(0, false); // default behavior, do not show Editor
}
if (typeof window.document !== 'undefined' && !window.PAUSE_MODULO) { // Browser
modulo.util.loadFromDOM(window.document.head, null, true); // Blocking head
modulo.util.setupDevLib(modulo); // Loads default devlib
window.document.addEventListener('DOMContentLoaded', () => {
modulo.util.loadFromDOM(window.document.head, null, true); // Defer head
modulo.util.loadFromDOM(window.document.body, null, true); // Defer body
modulo.util.configureStatic(modulo); // Run any default loads
modulo.preprocessAndDefine(modulo.util.getCommand(modulo));
});
} else if (typeof module !== 'undefined') { // Node.js
module.exports = { Modulo, window };
} /#ENDUNLESS#*/