rimmel
Version:
A Streams-Oriented UI library for the Rx.Observable Universe
917 lines (870 loc) • 39 kB
JavaScript
;
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 <input> 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 <dialog> 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