Skip to content

Commit

Permalink
feat(framework): dynamic custom elements scoping (#2091)
Browse files Browse the repository at this point in the history
  • Loading branch information
vladitasev committed Aug 19, 2020
1 parent 9128264 commit 3588542
Show file tree
Hide file tree
Showing 104 changed files with 972 additions and 460 deletions.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@
"clean:fiori": "cd packages/fiori && yarn clean",
"prepare:main": "cd packages/main && nps prepare",
"prepare:fiori": "cd packages/fiori && nps prepare",
"scopePrepare:main": "cd packages/main && nps scope.prepare",
"scopePrepare:fiori": "cd packages/fiori && nps scope.prepare",
"dev:base": "cd packages/base && nps watch",
"dev:localization": "cd packages/localization && nps watch",
"dev:main": "cd packages/main && nps dev",
"dev:fiori": "cd packages/fiori && nps dev",
"scopeDev:main": "cd packages/main && nps scope.dev",
"scopeDev:fiori": "cd packages/fiori && nps scope.dev",
"start": "npm-run-all --sequential build:base build:localization build:theme-base build:icons prepare:main prepare:fiori start:all",
"startWithScope": "npm-run-all --sequential build:base build:localization build:theme-base build:icons scopePrepare:main scopePrepare:fiori scopeStart:all",
"start:all": "npm-run-all --parallel dev:base dev:localization dev:main dev:fiori",
"scopeStart:all": "npm-run-all --parallel dev:base dev:localization scopeDev:main scopeDev:fiori",
"start:base": "cd packages/base && yarn start",
"start:main": "cd packages/main && yarn start",
"start:fiori": "cd packages/fiori && yarn start",
Expand Down
108 changes: 108 additions & 0 deletions packages/base/src/CustomElementsScope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
let suf;
let rulesObj = {
include: [/^ui5-/],
exclude: [],
};
const tagsCache = new Map(); // true/false means the tag should/should not be cached, undefined means not known yet.

/**
* Sets the suffix to be used for custom elements scoping, f.e. pass "demo" to get tags such as "ui5-button-demo".
* Note: by default all tags starting with "ui5-" will be scoped, unless you change this by calling "setCustomElementsScopingRules"
*
* @public
* @param suffix The scoping suffix
*/
const setCustomElementsScopingSuffix = suffix => {
if (!suffix.match(/^[a-zA-Z0-9_-]+$/)) {
throw new Error("Only alphanumeric characters and dashes allowed for the scoping suffix");
}

suf = suffix;
};

/**
* Returns the currently set scoping suffix, or undefined if not set.
*
* @public
* @returns {String|undefined}
*/
const getCustomElementsScopingSuffix = () => {
return suf;
};

/**
* Sets the rules, governing which custom element tags to scope and which not, f.e.
* setCustomElementsScopingRules({include: [/^ui5-/]}, exclude: [/^ui5-mylib-/, /^ui5-carousel$/]);
* will scope all elements starting with "ui5-" but not the ones starting with "ui5-mylib-" and not "ui5-carousel".
*
* @public
* @param rules Object with "include" and "exclude" properties, both arrays of regular expressions. Note that "include"
* rules are applied first and "exclude" rules second.
*/
const setCustomElementsScopingRules = rules => {
if (!rules || !rules.include) {
throw new Error(`"rules" must be an object with at least an "include" property`);
}

if (!Array.isArray(rules.include) || rules.include.some(rule => !(rule instanceof RegExp))) {
throw new Error(`"rules.include" must be an array of regular expressions`);
}

if (rules.exclude && (!Array.isArray(rules.exclude) || rules.exclude.some(rule => !(rule instanceof RegExp)))) {
throw new Error(`"rules.exclude" must be an array of regular expressions`);
}

rules.exclude = rules.exclude || [];
rulesObj = rules;
tagsCache.clear(); // reset the cache upon setting new rules
};

/**
* Returns the rules, governing which custom element tags to scope and which not. By default, all elements
* starting with "ui5-" are scoped. The default rules are: {include: [/^ui5-/]}.
*
* @public
* @returns {Object}
*/
const getCustomElementsScopingRules = () => {
return rulesObj;
};

/**
* Determines whether custom elements with the given tag should be scoped or not.
* The tag is first matched against the "include" rules and then against the "exclude" rules and the
* result is cached until new rules are set.
*
* @public
* @param tag
*/
const shouldScopeCustomElement = tag => {
if (!tagsCache.has(tag)) {
const result = rulesObj.include.some(rule => tag.match(rule)) && !rulesObj.exclude.some(rule => tag.match(rule));
tagsCache.set(tag, result);
}

return tagsCache.get(tag);
};

/**
* Returns the currently set scoping suffix, if any and if the tag should be scoped, or undefined otherwise.
*
* @public
* @param tag
* @returns {String}
*/
const getEffectiveScopingSuffixForTag = tag => {
if (shouldScopeCustomElement(tag)) {
return getCustomElementsScopingSuffix();
}
};

export {
setCustomElementsScopingSuffix,
getCustomElementsScopingSuffix,
setCustomElementsScopingRules,
getCustomElementsScopingRules,
shouldScopeCustomElement,
getEffectiveScopingSuffixForTag,
};
3 changes: 2 additions & 1 deletion packages/base/src/StaticAreaItem.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getStaticAreaInstance, removeStaticArea } from "./StaticArea.js";
import RenderScheduler from "./RenderScheduler.js";
import getStylesString from "./theming/getStylesString.js";
import executeTemplate from "./renderer/executeTemplate.js";

/**
* @class
Expand All @@ -22,7 +23,7 @@ class StaticAreaItem {
* @protected
*/
_updateFragment() {
const renderResult = this.ui5ElementContext.constructor.staticAreaTemplate(this.ui5ElementContext),
const renderResult = executeTemplate(this.ui5ElementContext.constructor.staticAreaTemplate, this.ui5ElementContext),
stylesToAdd = window.ShadyDOM ? false : getStylesString(this.ui5ElementContext.constructor.staticAreaStyles);

if (!this.staticAreaItemDomRef) {
Expand Down
57 changes: 53 additions & 4 deletions packages/base/src/UI5Element.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import merge from "./thirdparty/merge.js";
import boot from "./boot.js";
import UI5ElementMetadata from "./UI5ElementMetadata.js";
import executeTemplate from "./renderer/executeTemplate.js";
import StaticAreaItem from "./StaticAreaItem.js";
import RenderScheduler from "./RenderScheduler.js";
import { registerTag, isTagRegistered, recordTagRegistrationFailure } from "./CustomElementsRegistry.js";
Expand All @@ -26,6 +27,7 @@ const metadata = {
let autoId = 0;

const elementTimeouts = new Map();
const uniqueDependenciesCache = new Map();

const GLOBAL_CONTENT_DENSITY_CSS_VAR = "--_ui5_content_density";
const GLOBAL_DIR_CSS_VAR = "--_ui5_dir";
Expand Down Expand Up @@ -98,6 +100,8 @@ class UI5Element extends HTMLElement {
* @private
*/
async connectedCallback() {
this.setAttribute(this.constructor.getMetadata().getPureTag(), "");

const needsShadowDOM = this.constructor._needsShadowDOM();
const slotsAreManaged = this.constructor.getMetadata().slotsAreManaged();

Expand Down Expand Up @@ -549,7 +553,7 @@ class UI5Element extends HTMLElement {
}

let styleToPrepend;
const renderResult = this.constructor.template(this);
const renderResult = executeTemplate(this.constructor.template, this);

// IE11, Edge
if (window.ShadyDOM) {
Expand Down Expand Up @@ -968,6 +972,50 @@ class UI5Element extends HTMLElement {
return "";
}

/**
* Returns an array with the dependencies for this UI5 Web Component, which could be:
* - composed components (used in its shadow root or static area item)
* - slotted components that the component may need to communicate with
*
* @protected
*/
static get dependencies() {
return [];
}

/**
* Returns a list of the unique dependencies for this UI5 Web Component
*
* @public
*/
static getUniqueDependencies() {
if (!uniqueDependenciesCache.has(this)) {
const filtered = this.dependencies.filter((dep, index, deps) => deps.indexOf(dep) === index);
uniqueDependenciesCache.set(this, filtered);
}

return uniqueDependenciesCache.get(this);
}

/**
* Returns a promise that resolves whenever all dependencies for this UI5 Web Component have resolved
*
* @returns {Promise<any[]>}
*/
static whenDependenciesDefined() {
return Promise.all(this.getUniqueDependencies().map(dep => dep.define()));
}

/**
* Hook that will be called upon custom element definition
*
* @protected
* @returns {Promise<void>}
*/
static async onDefine() {
return Promise.resolve();
}

/**
* Registers a UI5 Web Component in the browser window object
* @public
Expand All @@ -976,9 +1024,10 @@ class UI5Element extends HTMLElement {
static async define() {
await boot();

if (this.onDefine) {
await this.onDefine();
}
await Promise.all([
this.whenDependenciesDefined(),
this.onDefine(),
]);

const tag = this.getMetadata().getTag();
const altTag = this.getMetadata().getAltTag();
Expand Down
29 changes: 27 additions & 2 deletions packages/base/src/UI5ElementMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import DataType from "./types/DataType.js";
import isDescendantOf from "./util/isDescendantOf.js";
import { camelToKebabCase } from "./util/StringHelper.js";
import isSlot from "./util/isSlot.js";
import { getEffectiveScopingSuffixForTag } from "./CustomElementsScope.js";

/**
*
Expand Down Expand Up @@ -33,20 +34,44 @@ class UI5ElementMetadata {
return validateSingleSlot(value, slotData);
}

/**
* Returns the tag of the UI5 Element without the scope
* @public
*/
getPureTag() {
return this.metadata.tag;
}

/**
* Returns the tag of the UI5 Element
* @public
*/
getTag() {
return this.metadata.tag;
const pureTag = this.metadata.tag;
const suffix = getEffectiveScopingSuffixForTag(pureTag);
if (!suffix) {
return pureTag;
}

return `${pureTag}-${suffix}`;
}

/**
* Used to get the tag we need to register for backwards compatibility
* @public
*/
getAltTag() {
return this.metadata.altTag;
const pureAltTag = this.metadata.altTag;
if (!pureAltTag) {
return;
}

const suffix = getEffectiveScopingSuffixForTag(pureAltTag);
if (!suffix) {
return pureAltTag;
}

return `${pureAltTag}-${suffix}`;
}

/**
Expand Down
19 changes: 17 additions & 2 deletions packages/base/src/renderer/LitRenderer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { html, render } from "lit-html/lit-html.js";
import { html, svg, render } from "lit-html/lit-html.js";
import scopeHTML from "./scopeHTML.js";

let tags;
let suffix;

const setTags = t => {
tags = t;
};
const setSuffix = s => {
suffix = s;
};

const litRender = (templateResult, domNode, styles, { eventContext } = {}) => {
if (styles) {
Expand All @@ -7,7 +18,11 @@ const litRender = (templateResult, domNode, styles, { eventContext } = {}) => {
render(templateResult, domNode, { eventContext });
};

export { html, svg } from "lit-html/lit-html.js";
const scopedHtml = (strings, ...values) => html(scopeHTML(strings, tags, suffix), ...values);
const scopedSvg = (strings, ...values) => svg(scopeHTML(strings, tags, suffix), ...values);

export { setTags, setSuffix };
export { scopedHtml as html, scopedSvg as svg };
export { repeat } from "lit-html/directives/repeat.js";
export { classMap } from "lit-html/directives/class-map.js";
export { styleMap } from "lit-html/directives/style-map.js";
Expand Down
17 changes: 17 additions & 0 deletions packages/base/src/renderer/executeTemplate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getCustomElementsScopingSuffix, shouldScopeCustomElement } from "../CustomElementsScope.js";

/**
* Runs a component's template with the component's current state, while also scoping HTML
*
* @param template - the template to execute
* @param component - the component
* @public
* @returns {*}
*/
const executeTemplate = (template, component) => {
const tagsToScope = component.constructor.getUniqueDependencies().map(dep => dep.getMetadata().getPureTag()).filter(shouldScopeCustomElement);
const scope = getCustomElementsScopingSuffix();
return template(component, tagsToScope, scope);
};

export default executeTemplate;
32 changes: 32 additions & 0 deletions packages/base/src/renderer/scopeHTML.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const cache = new Map();

const scopeHTML = (strings, tags, suffix) => {
if (suffix && tags && tags.length) {
strings = strings.map(string => {
if (cache.has(string)) {
return cache.get(string);
}

/*
const allTags = [...string.matchAll(/<(ui5-.*?)[> ]/g)].map(x => x[1]);
allTags.forEach(t => {
if (!tags.includes(t)) {
throw new Error(`${t} not found in ${string}`);
// console.log(t, " in ", string);
}
});
*/

let result = string;
tags.forEach(tag => {
result = result.replace(new RegExp(`(</?)(${tag})(/?[> \t\n])`, "g"), `$1$2-${suffix}$3`);
});
cache.set(string, result);
return result;
});
}

return strings;
};

export default scopeHTML;
Loading

0 comments on commit 3588542

Please sign in to comment.