UNPKG

rimmel

Version:

A Streams-Oriented UI library for the Rx.Observable Universe

282 lines (279 loc) 16 kB
import { isSourceBindingConfiguration, isSinkBindingConfiguration, isFutureSinkAttributeValue } from '../types/internal.js'; import { currentValue } from '../lib/current-value.js'; import { waitingElementHanlders, state } from '../internal-state.js'; import { BOOLEAN_ATTRIBUTES } from '../definitions/boolean-attributes.js'; import { RML_DEBUG, RESOLVE_ATTRIBUTE, INTERACTIVE_NODE_START, INTERACTIVE_NODE_END, REF_TAG } from '../constants.js'; import { PreSink } from '../sinks/index.js'; import { sinkByAttributeName } from './sink-map.js'; import { DOMAttributePreSink, FixedAttributePreSink } from '../sinks/attribute-sink.js'; import { Mixin } from '../sinks/mixin-sink.js'; import { InnerHTML } from '../sinks/inner-html-sink.js'; import { TextContent } from '../sinks/text-content-sink.js'; import { STYLE_OBJECT_SINK_TAG, StylePreSink, StyleObjectSink } from '../sinks/style-sink.js'; import { isFunction } from '../utils/is-function.js'; import { isObservable, isPromise } from '../types/futures.js'; import { isRMLEventListener } from '../types/event-listener.js'; const addRef = (ref, data) => { waitingElementHanlders.get(ref)?.push(data) ?? waitingElementHanlders.set(ref, [data]); }; const getEventName = (eventAttributeString) => { const x = /\s+(?<attr>(?<prefix>rml:)?on(?<event>\w+))=['"]?$/.exec(eventAttributeString)?.groups; return x ? [`${x.prefix ?? ''}${x?.event}`, x.attr] : []; }; /** * rml — the main entry point for Rimmel.js * * rml is a tag function. You call it by tagging it with an ES6 template literal * of HTML text interleaved with references to any JavaScript entity that's in scope. * * Any JavaScript expression inside the template literal will be evaluated and the * resulting value will be inserted into the HTML template literal. Async expressions * such as Promises and Observables will be rendered as they resolve/emit. * * ## Example * * ```ts * import { rml } from 'rimmel'; * * const Component = () => { * const content = 'hello world'; * * return rml` * <div>${content}</div> * `; * }; * ``` * * ## Example * * ```ts * import { rml } from 'rimmel'; * * const Component = () => { * const num = 123; * * return rml` * <input type="number" value="${number}"> * `; * }; * ``` * * ## Example * * ```ts * import { rml } from 'rimmel'; * * const Component = () => { * const data = fetch('/api').then(res => res.text()); * * return rml` * <div>${data}</div> * `; * }; * ``` **/ function rml(strings, ...expressions) { let acc = ''; const strlen = strings.length - 1; for (let i = 0; i < strlen; i++) { const string = strings[i]; const accPlusString = acc + string; const lastTag = accPlusString.lastIndexOf('<'); const expression = expressions[i]; const [eventName, eventAttributeName] = getEventName(string); const r = (accPlusString).match(/<\w[\w-]*\s+[^>]*resolve="(?<existingRef>[^"]+)"\s*[^>]*(?:>\s*[^<]*|[^>]*)$/); const existingRef = r?.groups?.existingRef; const ref = existingRef ?? `${REF_TAG}${state.refCount++}`; // Determine in which template context is any given expression appearing // Then, depending on the context, call matching parser modules and (yet-to-be-created) registered parser plugins //const context = // />\s*$/.test(string) && /^\s*<\s*/.test(nextString) ? 'child/subtree' // : /(?<attribute>[a-z0-9\-_]+)\=(?<quote>['"]?)(?<otherValues>[^"]*)$/.exec(resultPlusString) ? 'attribute' // #IFDEF ENABLE_RML_DEBUGGER if (string.includes(RML_DEBUG)) { const nl = (str) => str.replace(/\t/g, ' '); const reducer = (str, next, j) => str.concat((j > 0 ? '}' : '') + nl(j == i ? next.replace(RML_DEBUG, `%c${RML_DEBUG}%c`) : next), j == i && next.includes(RML_DEBUG) ? ['color: red', ''] : [], (j <= strlen - 1 ? '${' : '') + (j < strlen ? expressions[j] : [])); const currentTemplate = strings.reduce(reducer, []); console.log(...currentTemplate); /* Stopped parsing a RML template */ debugger; } // #ENDIF ENABLE_RML_DEBUGGER // We treat and render any null or undefined values as empty strings // as a graceful handling of non-values (should this be configurable for a better QA?) // const printableExpressionType = typeof expression ?? 'undefined'; const printableExpressionType = typeof (expression ?? ''); if (['string', 'number', 'boolean'].includes(printableExpressionType)) { // Static expressions, no data binding. Just concatenate acc = accPlusString + (expression ?? ''); } else if (eventName) { // Event Source // so feed it to an Rx Subject | Observer | Handler Function | POJO | Array // TODO: support EventListenerObject // Use Cases: // <a onclick="${subject}"> // <a onclick="${()=>doSomething}"> // TODO: do we want the following? // <input type="text" onchange="${[object, 'attributeToSet']}"> will feed it the .value of the input field // <input type="text" onchange="${[array, pos]}"> will feed it the .value of the input field // TODO: Shall we support arrays of streams to feed multiple subscriptions at once? (may conflict with the array syntax above? // <button onclick="${[stream1, stream2, stream3]}"> will feed each of the supplied streams? // or explicitly: // <button onclick="${Multi(stream1, stream2, stream3)}"> with "multi source" let listener; if (isSourceBindingConfiguration(expression)) { listener = expression.listener; addRef(ref, { ...expression, eventName }); } else { // listener = toListener(expression); listener = expression; if (listener) { addRef(ref, { type: 'source', listener, eventName }); } // TODO: shall we add some notifications, otherwise, rather than silently ignore? } acc = accPlusString //.replace(new RegExp(`\\s${eventAttributeName}=(['"]?)$`), ` _${eventAttributeName}=$1`) .replace(/\s((?:rml:)?on\w+=['"]?)$/, ' _$1') + (!listener || existingRef ? '' : `${ref}" ${RESOLVE_ATTRIBUTE}="${ref}`); } else { // Data Sink. // Determine its type before connecting. // // Custom/user-defined sink // ????? // addRef(ref, <RMLTemplateExpressions.GenericHandler>{ type: 'sink', sink: expression.sink }); // // addRef(ref, expression); // acc = accPlusString.replace(/<(\w[\w-]*)\s*([^>]*)(>?)\s*$/, `<$1 ${existingRef?'':`resolve="${ref}" `}$2$3`); // // } else if(typeof ((<Observable<unknown>>expression).subscribe ?? (<Promise<unknown>>expression).then) == 'function' && i<strings.length -1 || typeof expression == 'object') { // } else if( if (Array.isArray(expression)) { // If sinking an array, we're likely just mapping it acc = accPlusString + expression.join(''); } else { // expression is a future or an object const nextString = strings[i + 1]; // if it's a BehaviorSubject or any other sync stream (e.g.: startWith operator), pick its initial/current value to render it synchronously const initialValue = currentValue(expression); const isAttribute = /(?<attribute>[:a-z0-9\-_]+)\=(?<quote>['"]?)(?<otherValues>[^"]*)$/.exec(accPlusString); if (isAttribute) { const quotationMarks = isAttribute.groups.quote; if (new RegExp(`^(?:[^>${quotationMarks}]*)${quotationMarks}?`).test(nextString)) { // Attribute Sink // Use Cases: // <some-tag some-attributes another-attribute="${observable}" other-stuff></some-tag> // <some-tag some-attributes class="some classes ${observable} and more" other-stuff></some-tag> // <some-tag some-attributes data-xxx="123" data-yyy="${observable}" other-stuff></some-tag> let sink; let isBooleanAttribute = false; let handler; const attributeName = isAttribute.groups.attribute; if (attributeName == 'style') { const CSSAttribute = /;?(?<key>[a-z\-][a-z0-9\-_]*)\s*:\s*$/.exec(string)?.groups?.key; sink = CSSAttribute ? StylePreSink(CSSAttribute) : StyleObjectSink; handler = PreSink(STYLE_OBJECT_SINK_TAG, sink, expression); } else { isBooleanAttribute = BOOLEAN_ATTRIBUTES.has(attributeName); const isDatasetAttribute = attributeName.startsWith('data-'); sink = (sinkByAttributeName.get(attributeName) ?? (isBooleanAttribute && DOMAttributePreSink(attributeName)) ?? (isDatasetAttribute && DatasetItemPresink(attributeName))) || FixedAttributePreSink(attributeName); // TODO: hard-match attributeName with a corresponding SINK_TAG... handler = PreSink(attributeName, sink, expression); } // addRef(ref, <RMLTemplateExpressions.GenericHandler>{ handler: expression, type: attributeType, attribute: attributeName }); addRef(ref, handler); // TODO: remove boolean attributes if they are bound to streams: disabled="${stream}" // should not be disabled by its mere presence, but depending on the value emitted by the stream. const prefix = isBooleanAttribute && (!initialValue || !expression) // REF0000266279391633 ? accPlusString.replace(new RegExp(`${attributeName}=['"]+$`), `_${attributeName}="`) // TODO: or maybe clean it up completely? : accPlusString; // acc += (<ClassRecord[]>[]).concat(<ClassRecord>expression) // .flatMap(cls => // typeof cls == 'string' // ? cls // : Object.entries(cls ?? {}) // .filter(([, v]) => typeof v != 'string') // .map(([k]) => k) // ) // .join(' ') // ; acc = (prefix + (initialValue ?? '')).replace(/<(\w[\w-]*)\s+([^>]+)$/, `<$1 ${existingRef ? '' : `${RESOLVE_ATTRIBUTE}="${ref}" `}$2`); } // } else if(/<\S+(?:\s+[a-z0-9_][a-z0-9_-]*(?:=(?:'[^']*'|"[^"]*"|\S+|[^>]+))?)*(?:\s+\.\.\.)?$/.test(accPlusString.substring(lastTag)) && /^(?:[^<]*>|\s+\.\.\.)/.test(nextString)) { } else if (/<[a-z_][a-z0-9_-]*[^>]*(?:\s+\.\.\.)?$/ig.test(accPlusString.substring(lastTag))) { // Mixin Sink // Use Cases: // <some-tag some-attribute="some-value" ${mixin}></some-tag> // <some-tag some-attribute="some-value" ...${mixin}></some-tag> // <some-tag some-attributes ...${mixin} other-stuff>...</some-tag> // will bind multiple attributes and values let sink; acc += string.replace(/\.\.\.$/, ''); if (isSinkBindingConfiguration(expression)) { // acc = accPlusString; sink = expression; } else if (isObservable(expression) || isPromise(expression)) { // If we have a Promise, we wait for it to resolve and then render the mixin} sink = Mixin(expression); } else { // Merge static (string, number) properties of the mixin inline in the rendered HTML // and pass the rest as a future sink const [staticAttributes, deferredAttributes] = Object.entries(expression || {}) .reduce((acc, [k, v]) => (acc[+(isFutureSinkAttributeValue(v) || isRMLEventListener(k) && isFunction(v) || /^(?:rml:)?onmount$/.test(k))].push([k, v]), acc), [[], []]); acc += staticAttributes.map(([k, v]) => `${k}="${v}"`).join(' '); // if(split[0].length) sink = Mixin(Object.fromEntries(deferredAttributes)); } addRef(ref, sink); acc = acc.replace(/<(\w[\w-]*)\s+([^<]*)$/, `<$1 ${existingRef ? '' : `${RESOLVE_ATTRIBUTE}="${ref}" `}$2`); } else if (/>\s*$/.test(string) && /^\s*</.test(nextString)) { // Content Sink // Use Cases: // <some-tag>${observable}</some-tag> // <some-tag>${BehaviorSubject}</some-tag> // will synchronously set the initial value of the BehaviorSubject, then update the element on subsequent emissions (good for SSR and to reduce repaints) const sinkExpression = expression; // If we have an initialValue, we treat it as a BehaviorSubject, // take its current .value, render is synchronously to avoid reflows // and then subscribe to subsequent emissions // FIXME: any chance an expression could be mistaken for a BehaviorSubject here? A Generator, or other stuff??? May want to have a better isBehaviorSubject check here... // const _source = <MaybeFuture<HTMLString>>(initialValue // ? (expression?.source ?? expression)?.pipe?.( skip(1) ) // : sinkExpression // ); const _source = sinkExpression; // addRef(ref, <RMLTemplateExpressions.GenericHandler>{ handler, type: sinkType, error: errorHandler, ...sinkType == 'collection' && {attribute: expression} || {} /*, termination: terminationHandler */ }); addRef(ref, isSinkBindingConfiguration(_source) ? _source : InnerHTML(_source)); acc = acc + (existingRef ? string : string.replace(/\s*>\s*$/, ` ${RESOLVE_ATTRIBUTE}="${ref}">`)) + (initialValue || ''); } else if (/>?\s*[^<]*$/m.test(string) && /^\s*[^<]*\s*<?/m.test(nextString)) { // TODO // will set the textContent of the given textNode addRef(ref, TextContent(expression)); // FIXME: tbd // FIXME: are we adding #REF multiple times? //acc = existingRef?accPlusString:acc +string.replace(/\s*>/, ` ${RESOLVE_ATTRIBUTE}="${ref}">`) +ref; acc += (existingRef ? string : string.replace(/\s*>(?=[^<]*$)/, ` ${RESOLVE_ATTRIBUTE}="${ref}">`)) + INTERACTIVE_NODE_START + (initialValue ?? '') + INTERACTIVE_NODE_END; } else { acc = accPlusString; // ??? } } } } acc += strings[strlen]; return acc; } export { addRef, rml }; //# sourceMappingURL=parser.js.map