UNPKG

rimmel

Version:

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

917 lines (870 loc) 39 kB
'use strict'; var rxjs = require('rxjs'); self.RMLREF = ''; const REF_TAG = 'RMLREF+'; // custom attribute and corresponding selector to find just-mounted elements // that need any data binding const RESOLVE_ATTRIBUTE = 'resolve'; // keep lowercase for SVG // An equivalent of the "debugger;" JavaScript expression, for templates const RML_DEBUG = 'rml:debugger'; // Special, non-printable Unicode characters to wrap interactive text nodes // letting Rimmel know they'll need to be rendered as Text Nodes in the DOM, for updates const INTERACTIVE_NODE_START = '\u200B'; const INTERACTIVE_NODE_END = '\u200C'; // FIXME: can't use this const SINK_TAG = 'sink'; // export const configure = () => { // return rml // } const isObservable = (x) => !!x?.subscribe; const isPromise = (x) => !!x?.then; const isFuture = (x) => isPromise(x) || isObservable(x); const isFunction = (fn) => fn instanceof Function; /** * Checks whether the provided template expression is an Observer (Rx Subscribable) * @param expression a template expression to check * @returns is ObserverSourceHandler */ const isObserverSource = (expression) => isFunction(expression?.next); /** * A data source that connects to and feeds an Observer stream or RxJS Subject * * @param handler an Observer stream or RxJS Subject * @returns */ const ObserverSource = (handler) => handler.next.bind(handler); // export type Empty = MaybeFuture<undefined | null | ''>; // export type BindingConfigurationType<T> = T extends SinkBindingConfiguration<infer Q> ? SinkBindingConfiguration<Q> : SourceBindingConfiguration<infer Z>; const isSinkBindingConfiguration = (b) => b.type == 'sink'; const isSourceBindingConfiguration = (b) => b.type == 'source'; const isFutureSinkAttributeValue = (value) => isFuture(value); /** * Get the current value from the given object if it's a BehaviorSubject, otherwise undefined */ const currentValue = (stream) => // FIXME: this relies on a piped BehaviorSubject exposing source and destination, which might not be supported in future releases stream?.value ?? (stream?.destination ? currentValue(stream?.destination) : undefined); const waitingElementHanlders = new Map(); // FIXME: add a unique prefix to prevent collisions with different dupes of the library running in the same context/app const state = { refCount: 0, }; // List of HTML boolean attributes // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML // These enable a certain functionality by their mere presence in a tag. // E.G.: <input disabled="false"> is still disabled, which is unintuitive. // <input disabled="${stream}"> should really set or unset the disabled state depending on the stream's last emitted value! // If you don't like this behaviour, we have a "rml" prefixed set of such attributes, that actually behave like booleans // TODO: review, see if we can convert to a type... don't want all these in the bundles const BOOLEAN_ATTRIBUTES = new Set([ 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'ismap', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'readonly', 'required', 'reversed', 'selected' ]); // /** // * Force a custom Sink through to a template // * @param sink // * @param data // * @returns Sink // */ // export const SinkSpecifier = (source: MaybeFuture<unknown>, sink: Sink<any>, data: unknown) => ({ // type: 'custom', // pattern: (string, resultPlusString, result) => /custom-stuff=/.test(string), // .................. // sink, // data, // }); const PreSink = (tag, sink, source, args) => ({ type: 'sink', t: tag, source, sink, }); //export { AnyContentSink } from "./content-sink"; //export { AppendHTML } from "./append-html-sink"; //export { AttributeObjectSink } from "./attribute-sink"; //export { Blur } from "./blur-sink"; //export { ClassName, ToggleClass } from './class-sink'; //export { Disabled } from "./disabled-sink"; //export { Focus } from "./focus-sink"; //export { InnerHTML } from "./inner-html-sink"; //export { InnerText } from "./inner-text-sink"; //export { JSONDump } from "./json-dump-sink"; //export { PrependHTML } from "./prepend-html-sink"; //export { Removed } from "./removed-sink"; //export { Sanitize } from './sanitize-html-sink'; //export { Signal } from './signal-sink'; // Experimental //export { Suspend } from './suspense'; // Experimental //export { TextContent } from "./text-content-sink"; // const APPEND_HTML_SINK_TAG = 'appendHTML'; const AppendHTMLSink = (node) => node.insertAdjacentHTML.bind(node, 'beforeend'); const BLUR_SINK_TAG = 'blur'; const BlurSink = (node) => node.blur.bind(node); const CHECKED_SINK_TAG = 'checked'; const CheckedSink = (node) => (checked) => { node.checked = checked; }; /** * Get the value of an <input> element matching its type: * boolean, number, date or string, * or innerText if it's any other [contenteditable] element **/ const autoValue = (input) => input.type == 'checkbox' ? input.checked : input.type == "number" ? input.valueAsNumber : input.type == "date" ? input.valueAsDate : input.tagName == 'INPUT' ? input.value : input.tagName == 'SELECT' ? input.value : input.innerText; /** * @deprecated don't use **/ const isObjectSource = (expression) => Array.isArray(expression) && expression.length == 2; /** * A data source that updates an object's property from an &lt;input&gt; element when * a certain event occurs * @param key an ['property', object] or [index, array] pair to update * @returns A data source * @example <input oninput="${[obj, 'property']}"> * @example <input oninput="${ObjectSource('property', obj)}"> * @example <input oninput="${ObjectSource(4, arr)}"> */ const ObjectSource = (key, targetObject) => { const handler = ((targetObject, e) => { const t = e.target; targetObject[key] = autoValue(t); }); return (targetObject ? handler.bind(null, targetObject) : (t2) => handler.bind(null, t2)); }; /** * Checks whether the provided template expression is an EventListenerObject * @param expression a template expression to check * @returns is EventListenerObject */ const isEventListenerObjectSource = (expression) => expression?.handleEvent; /** * A data source that connects to and feeds an EventListenerObject via its handleEvent method * @param handler an Observer stream or RxJS Subject * @returns */ const EventListenerObjectSource = (handler) => handler.handleEvent.bind(handler); // TODO: we used to just chain handler.handleEvent?.bind(handler) ?? .... rather than these type guards. Can we still, somehow? /** * Convert a function, Observer or Observature to a listener function. * @param expression RMLTemplateExpression * @returns function | null a callable event listener function */ const toListener = (expression) => // FIXME: too similar to callable. Merge them isObserverSource(expression) ? ObserverSource(expression) : isFunction(expression) ? expression : isEventListenerObjectSource(expression) ? EventListenerObjectSource(expression) : isObjectSource(expression) ? ObjectSource(...expression) : null // We allow it to be empty. If so, ignore, and don't connect any source. Perhaps add a warning in debug mode? ; // FIXME: remove, use subscribe below instead const asap = (fn, arg) => { arg?.subscribe?.(fn) ?? arg?.then?.(fn.next?.bind(fn) ?? fn) ?? fn(arg); }; const TOGGLE_CLASS_SINK_TAG = 'ToggleClass'; const CLASS_SINK_TAG = 'class'; // Keeping it same as 'class" attribute for now. Don't change yet... const ToggleClassSink = (className) => (node) => node.classList.toggle.bind(node.classList, className); const ClassObjectSink = (node) => { const cl = node.classList; const set = (str) => node.className = str; const add = cl.add.bind(cl); const remove = cl.remove.bind(cl); cl.toggle.bind(cl); return (name) => { typeof name == 'string' ? name.includes(' ') ? set(name) : add(name) // FIXME: is it safe to assume it's an object, at this point? : [].concat(name).forEach(obj => Object.entries(obj) // TODO: support 3-state with toggle .forEach(([k, v]) => asap(v ? add : remove, k))); }; }; const CLOSED_SINK_TAG = 'closed'; /** * A sink that closes a &lt;dialog&gt; element when a source streams emits * @param dialogBox an HTMLDialogElement * @returns */ const ClosedSink = (dialogBox) => dialogBox.close.bind(dialogBox); const camelCase = (s) => s.split('-').map((s, i) => i ? s[0].toLocaleUpperCase() + s.slice(1) : s).join(''); const DatasetSink = (node, key) => { const { dataset } = node; return (str) => { dataset[camelCase(key)] = str; }; }; const DatasetItemPreSink = (key) => (node) => { const { dataset } = node; return (str) => { dataset[camelCase(key)] = str; }; }; const DatasetObjectSink = (node) => { const { dataset } = node; return (data) => { for (const [key, str] of Object.entries(data ?? {})) { const camelKey = camelCase(key); (str === undefined || str == null) ? delete dataset[camelKey] : asap((str) => dataset[camelKey] = str, str); } }; }; const DISABLED_SINK_TAG = 'disabled'; const DisabledSink = (node) => (value) => { node.disabled = value; }; const FOCUS_SINK_TAG = 'focus'; const FocusSink = (node) => (state) => { state ? node.focus?.() : node.blur?.(); }; const HiddenSink = (node) => (hidden) => { node.hidden = hidden; }; const INNER_HTML_SINK_TAG = 'innerHTML'; const InnerHTMLSink = (node) => (html) => { node.innerHTML = html; }; /** * A specialised sink to set the innerHTML of an element * @param source A present or future HTML string * @returns RMLTemplateExpression An HTML-subtree RML template expression * @example <div>${InnerHTML(stream)}</div> */ const InnerHTML = (source) => ({ type: SINK_TAG, t: INNER_HTML_SINK_TAG, source, sink: InnerHTMLSink, }); const INNER_TEXT_SINK_TAG = 'innerText'; const InnerTextSink = (node) => (html) => { node.innerText = html; }; const READ_ONLY_SINK_TAG = 'readonly'; /** * A specialised sink for the "readonly" HTML attribute * @param source A present or future boolean value * @returns RMLTemplateExpression A template expression for the "readonly" attribute * @example <input type="button" readonly="${booleanValue}"> * @example <input type="button" readonly="${booleanPromise}"> * @example <input type="button" readonly="${booleanObservable}"> */ const ReadonlySink = (node) => (value) => { node.readOnly = value; }; const REMOVED_SINK_TAG = 'removed'; /** * A sink that removes the given element from the DOM when the source emits * @param e A DOM element * @returns A sink that removes the element when called */ const RemovedSink = (e) => e.remove.bind(e); const addListener = (node, eventName, listener, options) => { // We also force-add an event listener if we're inside a ShadowRoot (do we really need to?), as events inside web components don't seem to fire otherwise { node.addEventListener(eventName, toListener(listener), options); // #REF49993849837451 // const listenerRef = [eventName, sourceBindingConfiguration.listener, sourceBindingConfiguration.options]; // node.addEventListener(...listenerRef); // listeners.get(node)?.push?.(listenerRef) ?? listeners.set(node, [listenerRef]); } if (/^(?:rml:)?mount/.test(eventName)) { // Will this need to bubble up? (probably no) setTimeout(() => node.dispatchEvent(new Event(eventName))); } }; const isRMLEventListener = (name, arg) => /^(?:rml:)?on/.test(name); const FixedAttributeSink = (node, attributeName) => // data => node.setAttribute(attributeName, data) node.setAttribute.bind(node, attributeName); const FixedAttributePreSink = (attributeName) => (node) => // data => node.setAttribute(attributeName, data) node.setAttribute.bind(node, attributeName); /** * Set the value of a form's element, given its name **/ const FormElementSink = (node, elementName) => (value) => { const e = (node.elements.namedItem(elementName)); // TODO: add checkbox and radio... if (e) { if (e.type == 'checkbox') { e.checked = value; } else if (e.type == 'radio') { e.checked = e.value == value; } else { e.value = value; } } }; /** * An implicit sink for any DOM Attributes (the ones that can be set via node[attr] = value) * @example <div contenteditable="${stream}">...</div> **/ const DOMAttributePreSink = (attributeName) => (node) => (value) => node[attributeName] = value; const AttributeObjectSink = (node) => (attributeobject) => { (Object.entries(attributeobject) ?? []) .forEach(([k, v]) => { // TODO: toggle/remove event listener, if matches /^on/ (or /^off/ maybe?) // N.B.: keep v === false || v == 'false' for transpilers changing it to v == '0' || v == 0 // which is no good, because 0 is no special value for non-boolean attributess. value="0" if (v == null || v === false || v == 'false' || v == undefined) { node.removeAttribute(k); } else if (isRMLEventListener(k)) { //if(k.startsWith('on') /* && isFunction((<Observer<any>><unknown>v).next ?? (<Promise<any>>v).then ?? v) */) { // addListener(node, <RMLEventName>k.substring(2), v); const e = k.replace(/^(rml:)?on/, '$1'); addListener(node, e, v); } else { const sink = k == 'dataset' ? DatasetObjectSink : k.startsWith('data-') ? DatasetItemPreSink(k.substring(5)) : node.tagName == 'FORM' ? FormElementSink : sinkByAttributeName.get(k) ?? FixedAttributeSink; //?? DOMAttributeSink; asap(sink(node, k), v); // TODO: use drain() } }); }; const SubtreeSink = (node) => (subtreeData) => { Object.entries(subtreeData) .forEach(([k, v]) => [...node.querySelectorAll(k)].forEach((e) => AttributeObjectSink(e)(v))); }; const STYLE_OBJECT_SINK_TAG = 'StyleObject'; const getCSSPropertySetter = (style, key) => /^--/.test(key) ? (value) => { value == null ? style.removeProperty(key) : style.setProperty(key, value); } : (value) => { style[key] = value; }; /** * Applies a given CSS value to a specified CSS property of an Element. * * @param Element node - The HTML element to which the CSS property will be applied. * @param CSSProperty key - The CSS property that will be set on the element. * @returns Function A function that takes a CSS value (specific to the CSS property) * and applies it to the element's style. * @example // Applies a background color to a div element * const divElement = document.getElementById('myDiv'); * const setBackgroundColor = styleSink(divElement, 'backgroundColor'); * setBackgroundColor('red'); // Sets the div's background color to red **/ const StyleSink = (node, key) => getCSSPropertySetter(node.style, key); const StylePreSink = (key) => (node) => StyleSink(node, key); const StyleObjectSink = (node) => (kvp) => Object.entries(kvp ?? {}) .forEach(([k, v]) => asap(getCSSPropertySetter(node.style, k), v)); const TextContentSink = (node) => (str) => { node.textContent = str; }; /** * A specialised sink to set the textContent on a node * @param source A present or future string * @returns RMLTemplateExpression A text-node RML template expression * @example <div>${TextContent(stream)}</div> */ const TextContent = (source) => ({ type: SINK_TAG, t: 'TextContent', source: source, sink: TextContentSink, }); /** * An explicit sink that sets the .value of an HTML input element * * @param Element node - The HTML Input element to change the value for * @returns Function A function that takes a value and sets it as the element's .value **/ const ValueSink = (node) => (str) => { node.value = str; }; const sinkByAttributeName = new Map([ ['appendHTML', AppendHTMLSink], ['checked', CheckedSink], ['class', ClassObjectSink], //['contenteditable', ToggleAttributePreSink('contenteditable')], ['data-', DatasetSink], ['dataset', DatasetObjectSink], // Shall we include this, too? ['disabled', DisabledSink], ['hidden', HiddenSink], ['innerHTML', InnerHTMLSink], ['innerText', InnerTextSink], ['readonly', ReadonlySink], ['style', StyleObjectSink], // ['termination', terminationSink], // a sink that runs when an observable completes... will we ever need this? ['textContent', TextContentSink], ['value', ValueSink], ['rml:blur', BlurSink], // ['rml:checked', DisabledSink], // Can make this one act as an enumerated attribute that understands "false" and other values... ['rml:closed', ClosedSink], ['rml:dataset', DatasetObjectSink], // ['rml:disabled', DisabledSink], // Can make this one act as an enumerated attribute that understands "false" and other values... ['rml:focus', FocusSink], // ['rml:readonly', ReadonlySink], // Can make this one act as an enumerated attribute that understands "false" and other values... ['rml:removed', RemovedSink], ['rml:subtree', SubtreeSink], ['removed', RemovedSink], ['subtree', SubtreeSink], ]); const MIXIN_SINK_TAG = 'mixin'; /** * A specialised sink to merge all properties of an object into a target element. * If you pass a plain object it will be merged immediately. You can also merge event listeners on mount. * If you pass a future, it will be merged whenever it emits any data. * @param source A present or future Attribuend Object (a plain object of key-values to merge) that will be merged into the target element * @returns SinkBindingConfiguration an object that tells Rimmel what to mount where and how * You can specify this sink in the following ways: * - implicitly, by passing it into a tag: `<div ...${source}">` * - explicitly, with the `<div ...${Mixin(source)}">` sink * * ## Examples * * ### Add an event listener to a button after 5s * * ```ts * import { rml } from 'rimmel'; * * const delay = (ms: number) => new Promise(resolve => setTimeout(resolve), ms); * * const ClickMixin = async () => { * const listener = (e: Event) => { * debugger; * } * * await delay(5000); * * return { * onclick: listener, * }; * } * * // Using the mixin: * target.innerHTML = rml` * <button ...${ClickMixin()}"> * click me * </button> * `; * }; * ``` * * ### Drag'n'drop with a mixin * * ```ts * import { Subject, fromEvent, map, switchMap, takeUntil, tap } from 'rxjs'; * * const currentXY = (str: string) => /translate\((?<x0>[-.\d]+)px, (?<y0>[-.\d]+)px\)/.exec(str)?.groups ?? {x0: '0', y0: '0'}; * * export const Draggable = () => { * const toCSSTransform = () => map(([Δx, Δy]) => ({ * transform: `translate(${Δx}px, ${Δy}px)` * })); * * const dnd = new Subject().pipe( * tap((e: PointerEvent) => e.preventDefault()), * switchMap((e: PointerEvent) => { * const mousemove = fromEvent(document, 'mousemove') * const mouseup = fromEvent(document, 'mouseup'); * * const {x0, y0} = currentXY((<HTMLElement>e.currentTarget).style.transform); * const [eX, eY] = [e.clientX -parseFloat(x0), e.clientY -parseFloat(y0)]; * * return mousemove.pipe( * map((e: PointerEvent) => [e.clientX -eX, e.clientY -eY]), * toCSSTransform(), * takeUntil(mouseup), * ); * }), * ); * * return { * class: 'draggable', * onmousedown: dnd, * style: dnd, * }; * }; * ``` */ const Mixin = (source) => { return { type: SINK_TAG, t: MIXIN_SINK_TAG, source, sink: AttributeObjectSink, }; }; 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$1(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; } const JSON_DUMP_SINK_TAG = 'jsonDump'; const JSONDumpSink = (node) => (data) => { node.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`; }; const PREPEND_HTML_SINK_TAG = 'prependHTML'; const PrependHTMLSink = (node) => node.insertAdjacentHTML.bind(node, 'afterbegin'); // TODO: Maybe Rimmel should be included client side, and the following map should just // point to the sources and sinks exported from that? // const asap = ${String(asap)}; const HydrationScript = ` <script> const resolvables = new Map([...document.querySelectorAll('[RESOLVE]')].map(n=>[n.getAttribute('resolve'), n])); const no_sink = (name) => (node, data) => console.error(\`[Rimmel]: called unknown hydration sink "\${name}"\`); const sinks = { 'Attribute': node => data => Object.entries(data).forEach(([k, v]) => { k == 'class' ? node.classList.add(v) : node[k] = v; }), '${APPEND_HTML_SINK_TAG}': ${String(AppendHTMLSink)}, '${BLUR_SINK_TAG}': ${String(BlurSink)}, '${CHECKED_SINK_TAG}': ${String(CheckedSink)}, '${CLOSED_SINK_TAG}': ${String(ClosedSink)}, '${DISABLED_SINK_TAG}': ${String(DisabledSink)}, '${FOCUS_SINK_TAG}': ${String(FocusSink)}, '${INNER_HTML_SINK_TAG}': ${String(InnerHTMLSink)}, '${INNER_TEXT_SINK_TAG}': ${String(InnerTextSink)}, '${JSON_DUMP_SINK_TAG}': ${String(JSONDumpSink)}, '${MIXIN_SINK_TAG}': ${String(AttributeObjectSink)}, '${PREPEND_HTML_SINK_TAG}': ${String(PrependHTMLSink)}, '${READ_ONLY_SINK_TAG}': ${String(ReadonlySink)}, '${REMOVED_SINK_TAG}': ${String(RemovedSink)}, '${TOGGLE_CLASS_SINK_TAG}': ${String(ToggleClassSink)}, 'xxx_${CLASS_SINK_TAG}': ${String(ClassObjectSink)}, 'xxx_${STYLE_OBJECT_SINK_TAG}': ${String(StyleSink)}, 'class': node => data => Object.entries(data).forEach(([k, v]) => node.classList.toggle(k, v)), 'style': node => data => Object.entries(data).forEach(([k, v]) => node.style[k] = v), '${STYLE_OBJECT_SINK_TAG}': node => data => Object.entries(data).forEach(([k, v]) => node.style[k] = v), }; function Rimmel_Hydrate(data) { const [key, conf] = data; const node = resolvables.get(key); console.log('SINK type', conf.t, conf.value); const sinkFn = (sinks[conf.t] ?? no_sink(conf.sink))?.(node); sinkFn?.(conf.value); } </script> `; let count = 0; const rml = (strings, ...args) => { const hydrationCall = (data) => `\n<script>Rimmel_Hydrate(${data});</script>`; const str = (rml$1(strings, ...args) + HydrationScript); const tasks = [...waitingElementHanlders.entries()] .flatMap(([key, jobs]) => jobs.map(job => { if (isSinkBindingConfiguration(job)) { const sb = job; const source$ = rxjs.isObservable(sb.source) ? sb.source : rxjs.from(sb.source); return source$.pipe( // tap(data=>console.log('EMIT:', data)), rxjs.map(res => [key, { resolved: 'ssr', ...sb, // handler: sb.handler, value: res, sink: job.t, count: (count++), }])); } else { // TODO: Transpile sources to RPC calls? return null; } }) .filter(x => x)); const asyncStuff = () => rxjs.from(tasks).pipe(rxjs.filter(task => task !== null), rxjs.mergeAll(), rxjs.map(x => hydrationCall(JSON.stringify(x)))); // TODO: just return a string here and pass asynquences separately return rxjs.of(str).pipe(rxjs.mergeWith(asyncStuff()), rxjs.endWith('\n<!-- hydration end -->\n</body>\n</html>'), rxjs.tap(() => waitingElementHanlders.clear())); }; exports.rml = rml; //# sourceMappingURL=index.cjs.map