@webqit/oohtml
Version:
A suite of new DOM features that brings language support for modern UI development paradigms: a component-based architecture, data binding, and reactivity.
422 lines (406 loc) • 19.7 kB
JavaScript
/**
* @imports
*/
import { isDocument, isShadowRoot } from '@webqit/realdom';
import DOMNamingContext from './DOMNamingContext.js';
import { _wq, _init, _splitOuter, _fromHash, _toHash, getInternalAttrInteraction, internalAttrInteraction } from '../util.js';
/**
* @init
*
* @param Object $config
*/
export default function init($config = {}) {
const { config, window } = _init.call(this, 'namespaced-html', $config, {
attr: { namespace: 'namespace', lid: 'id', },
api: { namespace: 'namespace', },
tokens: { lidrefPrefix: '~', lidrefSeparator: ':' },
target: { className: ':target', eventName: ':target', scrolling: true },
});
config.lidSelector = `[${window.CSS.escape(config.attr.lid)}]`;
config.namespaceSelector = `[${window.CSS.escape(config.attr.namespace)}]`;
window.webqit.DOMNamingContext = DOMNamingContext;
exposeAPIs.call(window, config);
realtime.call(window, config);
}
/**
* @init
*
* @param Object config
*
* @return String
*/
function lidUtil(config) {
const { lidrefPrefix, lidrefSeparator, } = config.tokens;
return {
escape(str, mode = 1) { return [...str].map(x => !/\w/.test(x) ? (mode === 2 ? `\\\\${x}` : `\\${x}`) : x).join(''); },
lidrefPrefix(escapeMode = 0) { return escapeMode ? this.escape(lidrefPrefix, escapeMode) : lidrefPrefix; },
lidrefSeparator(escapeMode = 0) { return escapeMode ? this.escape(lidrefSeparator, escapeMode) : lidrefSeparator; },
isUuid(str, escapeMode = 0) { return str.startsWith(this.lidrefPrefix(escapeMode)) && str.includes(this.lidrefSeparator(escapeMode)); },
//isLidref( str, escapeMode = 0 ) { return str.startsWith( this.lidrefPrefix( escapeMode ) ) && !str.includes( this.lidrefSeparator( escapeMode ) ); },
toUuid(hash, lid, escapeMode = 0) { return hash.endsWith('-root') ? lid : `${this.lidrefPrefix(escapeMode)}${hash}${this.lidrefSeparator(escapeMode)}${lid}`; },
uuidToId(str, escapeMode = 0) { return this.isUuid(str) ? str.split(this.lidrefSeparator(escapeMode))[1] : str; },
uuidToLidref(str, escapeMode = 0) { return this.isUuid(str) ? `${this.lidrefPrefix(escapeMode)}${str.split(this.lidrefSeparator(escapeMode))[1]}` : str; },
}
}
/**
* @rewriteSelector
*
* @param String selectorText
* @param String namespaceUUID
* @param String scopeSelector
* @param Bool escapeMode
*
* @return String
*/
export function rewriteSelector(selectorText, namespaceUUID, scopeSelector = null, escapeMode = 0) {
const window = this, { webqit: { oohtml: { configs: { NAMESPACED_HTML: config } } } } = window;
const $lidUtil = lidUtil(config);
// Match :scope and relative ID selector
const regex = new RegExp(`${scopeSelector ? `:scope|` : ''}#(${$lidUtil.lidrefPrefix(escapeMode + 1)})?([\\w]+${$lidUtil.lidrefSeparator(escapeMode + 1)})?((?:[\\w-]|\\\\.)+)`, 'g');
// Parse potentially combined selectors individually and categorise into categories per whether they have :scope or not
const [cat1, cat2] = _splitOuter(selectorText, ',').reduce(([cat1, cat2], selector) => {
// The deal: match and replace
let quotesMatch, hadScopeSelector;
selector = selector.replace(regex, (match, lidrefPrefixMatch, lidrefSeparatorMatch, id, index) => {
if (!quotesMatch) { // Lazy: stuff
// Match strings between quotes (single or double) and use that qualify matches above
// The string: String.raw`She said, "Hello, John. I\"m your friend." or "you're he're" 'f\'"j\'"f'jfjf`;
// Should yield: `"Hello, John. I\\"m your friend."`, `"you're he're"`, `'f\\'"j\\'"f'`
quotesMatch = [...selector.matchAll(/(["'])(?:(?=(\\?))\2.)*?\1/g)];
}
// Qualify match
if (quotesMatch.some(q => index > q.index && index + match.length < q.index + q[0].length)) return match;
// Replace :scope
if (match === ':scope') {
hadScopeSelector = true;
return scopeSelector;
}
const isLidref = lidrefPrefixMatch && !lidrefSeparatorMatch;
const isUuid = lidrefPrefixMatch && lidrefSeparatorMatch;
if (isUuid) {
return `#${$lidUtil.escape(match.replace('#', ''), 1)}`;
}
// Rewrite relative ID selector
if (isLidref) {
if (config.attr.lid === 'id' && namespaceUUID && !namespaceUUID.endsWith('-root')) {
return `#${$lidUtil.toUuid(namespaceUUID, id, 1)}`;
}
// Fallback to attr-based
}
// Rewrite absolute ID selector
let rewrite;
if (config.attr.lid === 'id') {
rewrite = `:is(#${id},[id^="${$lidUtil.lidrefPrefix(escapeMode)}"][id$="${$lidUtil.lidrefSeparator(escapeMode)}${id}"])`;
} else {
rewrite = `:is(#${id},[${window.CSS.escape(config.attr.lid)}="${id}"])`;
}
return isLidref ? `:is(${rewrite}):not(${scopeSelector ? scopeSelector + ' ' : ''}${config.namespaceSelector} *)` : rewrite;
});
// Category 2 has :scope and category 1 does not
return hadScopeSelector ? [cat1, cat2.concat(selector)] : [cat1.concat(selector), cat2];
}, [[], []]);
// Do the upgrade
let newSelectorText;
if (scopeSelector && cat1.length) {
newSelectorText = [cat1.length > 1 ? `${scopeSelector} :is(${cat1.join(', ')})` : `${scopeSelector} ${cat1[0]}`, cat2.join(', ')].filter(x => x).join(', ');
} else {
newSelectorText = [...cat1, ...cat2].join(', ');
}
return newSelectorText;
}
/**
* @param Element node
*
* @return Object
*/
export function getOwnNamespaceObject(node) {
const window = this;
if (!_wq(node).has('namespace')) {
const namespaceObj = Object.create(null);
_wq(node).set('namespace', namespaceObj);
const isDocumentRoot = isDocument(node) || isShadowRoot(node);
Object.defineProperty(namespaceObj, Symbol.toStringTag, {
get() {
return isDocumentRoot ? 'RootNamespaceRegistry' : 'NamespaceRegistry';
}
});
}
return _wq(node).get('namespace');
}
/**
* @param Element node
* @param Bool forID
*
* @return Object
*/
export function getOwnerNamespaceObject(node, forID = false) {
const window = this, { webqit: { oohtml: { configs: { NAMESPACED_HTML: config } } } } = window;
const isDocumentRoot = isDocument(node) || isShadowRoot(node);
return getOwnNamespaceObject.call(window, isDocumentRoot ? node : ((forID ? node.parentNode : node)?.closest/*can be documentFragment when Shadow DOM*/?.(config.namespaceSelector) || node.getRootNode()));
}
/**
* @param Object namespaceObj
*
* @return String
*/
export function getNamespaceUUID(namespaceObj) {
const isDocumentRoot = Object.prototype.toString.call(namespaceObj) === '[object RootNamespaceRegistry]';
return (_fromHash(namespaceObj) || _toHash(namespaceObj)) + (isDocumentRoot ? '-root' : '');
}
/**
* Exposes Namespaced HTML with native APIs.
*
* @param Object config
*
* @return Void
*/
function exposeAPIs(config) {
const window = this, { webqit: { Observer } } = window;
// The Namespace API
[window.Document.prototype, window.Element.prototype, window.ShadowRoot.prototype].forEach(prototype => {
// No-conflict assertions
const type = prototype === window.Document.prototype ? 'Document' : (prototype === window.ShadowRoot.prototype ? 'ShadowRoot' : 'Element');
if (config.api.namespace in prototype) { throw new Error(`The ${type} prototype already has a "${config.api.namespace}" API!`); }
// Definitions
Object.defineProperty(prototype, config.api.namespace, {
get: function () {
return Observer.proxy(getOwnNamespaceObject.call(window, this));
}
});
});
}
/**
* Performs realtime capture of elements and builds their relationships.
*
* @param Object config
*
* @return Void
*/
function realtime(config) {
const window = this, { webqit: { Observer, realdom, oohtml: { configs }, DOMNamingContext } } = window;
// ------------
// APIS
// ------------
// See https://wicg.github.io/aom/aria-reflection-explainer.html & https://github.com/whatwg/html/issues/3515 for the ARIA refelction properties idea
// See https://www.w3.org/TR/wai-aria-1.1/#attrs_relationships for the relational ARIA attributes
const idRefsAttrs = ['aria-owns', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'aria-flowto',];
const idRefAttrs = ['for', 'list', 'form', 'aria-activedescendant', 'aria-details', 'aria-errormessage', 'popovertarget'];
const attrList = [config.attr.lid, ...idRefsAttrs, ...idRefAttrs];
const relMap = { id: 'id'/* just in case it's in attrList */, for: 'htmlFor', 'aria-owns': 'ariaOwns', 'aria-controls': 'ariaControls', 'aria-labelledby': 'ariaLabelledBy', 'aria-describedby': 'ariaDescribedBy', 'aria-flowto': 'ariaFlowto', 'aria-activedescendant': 'ariaActiveDescendant', 'aria-details': 'ariaDetails', 'aria-errormessage': 'ariaErrorMessage', 'popovertarget': 'popoverTargetElement' };
const $lidUtil = lidUtil(config);
const uuidsToLidrefs = (node, attrName, getter) => {
if (!getInternalAttrInteraction(node, attrName) && _wq(node, 'attrOriginals').has(attrName)) {
return _wq(node, 'attrOriginals').get(attrName);
}
const value = getter();
if (getInternalAttrInteraction(node, attrName)) return value;
return value && value.split(' ').map(x => (x = x.trim()) && (attrName === config.attr.lid ? $lidUtil.uuidToId : $lidUtil.uuidToLidref).call($lidUtil, x)).join(' ');
};
// Intercept getElementById()
const getElementByIdDescr = Object.getOwnPropertyDescriptor(window.Document.prototype, 'getElementById');
Object.defineProperty(window.Document.prototype, 'getElementById', {
...getElementByIdDescr, value(id) {
return this.querySelector(`#${id}`); // To be rewritten at querySelector()
}
});
// Intercept querySelector() and querySelectorAll()
for (const queryApi of ['querySelector', 'querySelectorAll']) {
for (const nodeApi of [window.Document, window.Element]) {
const querySelectorDescr = Object.getOwnPropertyDescriptor(nodeApi.prototype, queryApi);
Object.defineProperty(nodeApi.prototype, queryApi, {
...querySelectorDescr, value(selector) {
return querySelectorDescr.value.call(this, rewriteSelector.call(window, selector, getNamespaceUUID(getOwnNamespaceObject.call(window, this))));
}
});
}
}
// Intercept getAttribute()
const getAttributeDescr = Object.getOwnPropertyDescriptor(window.Element.prototype, 'getAttribute');
Object.defineProperty(window.Element.prototype, 'getAttribute', {
...getAttributeDescr, value(attrName) {
const getter = () => getAttributeDescr.value.call(this, attrName);
return attrList.includes(attrName) && !_wq(this, 'lock').get(attrName) ? uuidsToLidrefs(this, attrName, getter) : getter();
}
});
// Hide implementation details on the Attr node too.
const propertyDescr = Object.getOwnPropertyDescriptor(window.Attr.prototype, 'value');
Object.defineProperty(window.Attr.prototype, 'value', {
...propertyDescr, get() {
const getter = () => propertyDescr.get.call(this);
return attrList.includes(this.name) ? uuidsToLidrefs(this.ownerElement, this.name, getter) : getter();
}
});
const propertyDescr2 = Object.getOwnPropertyDescriptor(window.Node.prototype, 'nodeValue');
Object.defineProperty(window.Node.prototype, 'nodeValue', {
...propertyDescr2, get() {
const getter = () => propertyDescr2.get.call(this);
return this instanceof window.Attr && attrList.includes(this.name) ? uuidsToLidrefs(this.ownerElement, this.name, getter) : getter();
}
});
// These APIs should return LIDREFS minus the hash part
for (const attrName of attrList) {
if (!(attrName in relMap)) continue;
const domApis = attrName === 'for' ? [window.HTMLLabelElement, window.HTMLOutputElement]
: (attrName === 'popovertarget' ? [window.HTMLButtonElement, window.HTMLInputElement] : [window.Element]);
for (const domApi of domApis) {
const propertyDescr = Object.getOwnPropertyDescriptor(domApi.prototype, relMap[attrName]);
if (!propertyDescr) continue;
Object.defineProperty(domApi.prototype, relMap[attrName], {
...propertyDescr, get() {
const getter = () => propertyDescr.get.call(this, attrName);
return uuidsToLidrefs(this, attrName, getter);
}
});
}
}
if (config.attr.lid !== 'id') {
// Reflect the custom [config.attr.lid] attribute
Object.defineProperty(window.Element.prototype, config.attr.lid, {
configurable: true, enumerable: true, get() {
return this.getAttribute(config.attr.lid);
}, set(value) {
return this.setAttribute(config.attr.lid, value);
}
});
}
// ------------
// LOCAL IDS & IDREFS
// ------------
const attrChange = (entry, attrName, value, callback) => {
return internalAttrInteraction(entry, attrName, () => {
if (typeof value === 'function') value = value();
return callback(value);
});
};
const setupBinding = (entry, attrName, value, newNamespaceObj = null) => {
attrChange(entry, attrName, value, value => {
const isLidAttr = attrName === config.attr.lid;
const namespaceObj = newNamespaceObj || getOwnerNamespaceObject.call(window, entry, isLidAttr);
const namespaceUUID = getNamespaceUUID(namespaceObj);
if (isLidAttr) {
const id = $lidUtil.uuidToId(value);
if (Observer.get(namespaceObj, id) !== entry) {
const uuid = $lidUtil.toUuid(namespaceUUID, id);
if (uuid !== value) { entry.setAttribute('id', uuid); }
Observer.set(namespaceObj, id, entry);
}
} else {
_wq(entry, 'attrOriginals').set(attrName, value); // Save original before rewrite
const newAttrValue = value.split(' ').map(idref => (idref = idref.trim()) && $lidUtil.isUuid(idref) ? idref : $lidUtil.toUuid(namespaceUUID, idref)).join(' ');
entry.setAttribute(attrName, newAttrValue);
_wq(namespaceObj).set('idrefs', _wq(namespaceObj).get('idrefs') || new Set);
_wq(namespaceObj).get('idrefs').add(entry);
}
});
};
const cleanupBinding = (entry, attrName, oldValue, prevNamespaceObj = null) => {
attrChange(entry, attrName, oldValue, oldValue => {
const isLidAttr = attrName === config.attr.lid;
const namespaceObj = prevNamespaceObj || getOwnerNamespaceObject.call(window, entry, isLidAttr);
if (isLidAttr) {
const id = $lidUtil.uuidToId(oldValue);
if (Observer.get(namespaceObj, id) === entry) {
Observer.deleteProperty(namespaceObj, id);
}
} else {
const newAttrValue = _wq(entry, 'attrOriginals').get(attrName);// oldValue.split( ' ' ).map( lid => ( lid = lid.trim() ) && $lidUtil.uuidToLidref( lid ) ).join( ' ' );
if (entry.hasAttribute(attrName)) entry.setAttribute(attrName, newAttrValue);
_wq(namespaceObj).get('idrefs')?.delete(entry);
}
});
};
// ------------
// NAMESPACE
// ------------
realdom.realtime(window.document).query(config.namespaceSelector, record => {
const reAssociate = (entry, attrName, oldNamespaceObj, newNamespaceObj) => {
if (!entry.hasAttribute(attrName)) return;
const attrValue = () => entry.getAttribute(attrName);
cleanupBinding(entry, attrName, attrValue/* Current resolved value as-is */, oldNamespaceObj);
if (entry.isConnected) { setupBinding(entry, attrName, _wq(entry, 'attrOriginals').get(attrName)/* Saved original value */ || attrValue/* Lest it's ID */, newNamespaceObj); }
};
record.exits.forEach(entry => {
if (entry.isConnected) {
const namespaceObj = getOwnNamespaceObject.call(window, entry);
// Detach ID and IDREF associations
for (const node of new Set([...Object.values(namespaceObj), ...(_wq(namespaceObj).get('idrefs') || [])])) {
for (const attrName of attrList) { reAssociate(node, attrName, namespaceObj); }
}
}
// Detach ID associations
const contextsApi = entry[configs.CONTEXT_API.api.contexts];
const ctx = contextsApi.find(DOMNamingContext.kind);
// Detach namespace instance
if (ctx) { contextsApi.detach(ctx); }
});
record.entrants.forEach(entry => {
// Claim ID and IDREF associations
let newSuperNamespaceObj;
const superNamespaceObj = getOwnerNamespaceObject.call(window, entry, true);
for (const node of new Set([...Object.values(superNamespaceObj), ...(_wq(superNamespaceObj).get('idrefs') || [])])) {
if ((newSuperNamespaceObj = getOwnerNamespaceObject.call(window, node, true)) === superNamespaceObj) continue;
for (const attrName of attrList) { reAssociate(node, attrName, superNamespaceObj, newSuperNamespaceObj); }
}
// Attach namespace instance
const contextsApi = entry[configs.CONTEXT_API.api.contexts];
if (!contextsApi.find(DOMNamingContext.kind)) { contextsApi.attach(new DOMNamingContext); }
});
}, { id: 'namespace-html:namespace', live: true, subtree: 'cross-roots', timing: 'sync', staticSensitivity: true, eventDetails: true });
// DOM realtime
realdom.realtime(window.document).query(`[${attrList.map(attrName => window.CSS.escape(attrName)).join('],[')}]`, record => {
// We do some caching to prevent redundanct lookups
const namespaceNodesToTest = { forID: new Map, forOther: new Map, };
for (const attrName of attrList) {
// Point to the right cache
const _namespaceNodesToTest = attrName === config.attr.lid ? namespaceNodesToTest.forID : namespaceNodesToTest.forOther;
record.exits.forEach(entry => {
if (!entry.hasAttribute(attrName)) return;
// Point to the right namespace node
let namespaceNodeToTest = _namespaceNodesToTest.get(entry);
if (typeof namespaceNodeToTest === 'undefined') {
namespaceNodeToTest = (attrName === config.attr.lid ? entry.parentNode : entry)?.closest/*can be documentFragment when Shadow DOM*/?.(config.namespaceSelector) || entry.getRootNode().host;
_namespaceNodesToTest.set(entry, namespaceNodeToTest);
}
if (namespaceNodeToTest && !namespaceNodeToTest.isConnected) return;
cleanupBinding(entry, attrName, () => entry.getAttribute(attrName)/* Current resolved value as-is */);
});
record.entrants.forEach(entry => {
if (!entry.hasAttribute(attrName) || entry.hasAttribute('namespaceesc')) return;
setupBinding(entry, attrName, () => entry.getAttribute(attrName)/* Raw value (as-is) that will be saved as original */);
});
}
namespaceNodesToTest.forID.clear();
namespaceNodesToTest.forOther.clear();
}, { id: 'namespace-html:attrs', live: true, subtree: 'cross-roots', timing: 'sync' });
// Attr realtime
realdom.realtime(window.document, 'attr').observe(attrList, records => {
for (const record of records) {
if (record.oldValue && record.value !== record.oldValue) {
cleanupBinding(record.target, record.name, record.oldValue/* Current resolved value as-is */);
}
if (record.value && record.value !== record.oldValue) {
setupBinding(record.target, record.name, record.value/* Raw value (as-is) that will be saved as original */);
}
}
}, { id: 'namespace-html:attr(attrs)', subtree: 'cross-roots', timing: 'sync', newValue: true, oldValue: true });
// ------------
// TARGETS
// ------------
let prevTarget;
const activateTarget = () => {
if (!window.location.hash?.startsWith(`#${$lidUtil.lidrefPrefix()}`)) return;
const path = window.location.hash?.substring(`#${$lidUtil.lidrefPrefix()}`.length).split('/').map(s => s.trim()).filter(s => s) || [];
const currTarget = path.reduce((prev, segment) => prev && prev[config.api.namespace][segment], window.document);
if (prevTarget && config.target.className) { prevTarget.classList.toggle(config.target.className, false); }
if (currTarget && currTarget !== window.document) {
if (config.target.className) { currTarget.classList.toggle(config.target.className, true); }
if (config.target.eventName) { currTarget.dispatchEvent(new window.CustomEvent(config.target.eventName)); }
if (config.target.scrolling && path.length > 1) { currTarget.scrollIntoView(); }
prevTarget = currTarget;
}
};
// "hash" realtime
window.addEventListener('hashchange', activateTarget);
realdom.ready(activateTarget);
// ----------------
}