diff --git a/packages/base/bundle.esm.js b/packages/base/bundle.esm.js
index 678e6a1d52c6..a5692aac72ad 100644
--- a/packages/base/bundle.esm.js
+++ b/packages/base/bundle.esm.js
@@ -31,6 +31,7 @@ import applyDirection from "./dist/locale/applyDirection.js";
import { getCurrentRuntimeIndex } from "./dist/Runtimes.js";
import { startMultipleDrag } from "./dist/DragAndDrop.js";
import LegacyDateFormats from "./dist/features/LegacyDateFormats.js";
+import { boot } from "./dist/Boot.js";
window["sap-ui-webcomponents-bundle"] = {
configuration : {
@@ -54,6 +55,7 @@ window["sap-ui-webcomponents-bundle"] = {
parseProperties,
registerI18nLoader,
getI18nBundle,
+ boot,
renderFinished,
applyDirection,
EventProvider,
diff --git a/packages/base/cypress/specs/ThemeRoot.cy.tsx b/packages/base/cypress/specs/ThemeRoot.cy.tsx
new file mode 100644
index 000000000000..2cf252aecb34
--- /dev/null
+++ b/packages/base/cypress/specs/ThemeRoot.cy.tsx
@@ -0,0 +1,180 @@
+import { internals } from "../../src/Location.js";
+import TestGeneric from "../../test/test-elements/Generic.js";
+import { resetConfiguration } from "../../src/InitialConfiguration.js";
+import { getTheme } from "../../src/config/Theme.js";
+import { getThemeRoot } from "../../src/config/ThemeRoot.js";
+import applyTheme from "../../src/theming/applyTheme.js";
+
+const THEME = "sap_horizon";
+
+const addMetaTag = (content: string) => {
+ cy.window().then($win => {
+ const metaTag = $win.document.createElement("meta");
+ metaTag.name = "sap-allowed-theme-origins";
+ metaTag.content = content;
+ $win.document.head.append(metaTag);
+ });
+};
+
+const removeMetaTag = () => {
+ cy.window().then($win => {
+ $win.document.head.querySelector("[name='sap-allowed-theme-origins']")?.remove();
+ });
+};
+
+const removeThemeLink = () => {
+ cy.window().then($win => {
+ $win.document.head.querySelector("link[sap-ui-webcomponents-theme]")?.remove();
+ });
+};
+
+const applyAndCheckLink = (theme: string) => {
+ cy.wrap({ applyTheme, getTheme })
+ .invoke("getTheme")
+ .then(() => {
+ return cy.wrap({ applyTheme }).invoke("applyTheme", theme);
+ });
+};
+
+// ─── Without meta tag ────────────────────────────────────────────────────────
+
+describe("ThemeRoot via URL param — without meta tag", () => {
+ afterEach(() => {
+ removeThemeLink();
+ });
+
+ [
+ { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/", blocked: true },
+ { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/", blocked: true },
+ { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/", blocked: true },
+ { label: "absolute URL (same host, no meta tag)", themeRoot: "http://example.com/themes/", blocked: true },
+ { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/", blocked: true },
+ { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/", blocked: true },
+ { label: "protocol-relative (same host, no meta tag)", themeRoot: "//example.com/themes/", blocked: true },
+ { label: "root-relative", themeRoot: "/themes/", blocked: false },
+ { label: "relative (current dir)", themeRoot: "./themes/", blocked: false },
+ { label: "relative (parent dir)", themeRoot: "../themes/", blocked: false },
+ ].forEach(({ label, themeRoot, blocked }) => {
+ describe(`${label}: ${themeRoot}`, () => {
+ before(() => {
+ cy.stub(internals, "search").callsFake(() => {
+ return `sap-ui-theme=${THEME}@${themeRoot}`;
+ });
+
+ cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
+ cy.mount( );
+ });
+
+ it(blocked ? "should not create a link element" : "should create a link element (same-origin, no meta tag needed)", () => {
+ applyAndCheckLink(THEME);
+ if (blocked) {
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
+ } else {
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
+ .should("exist")
+ .and("have.attr", "href")
+ .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
+ }
+ });
+ });
+ });
+});
+
+// ─── With meta tag ────────────────────────────────────────────────────────────
+
+describe("ThemeRoot via URL param — with meta tag (allowed: http://example.com)", () => {
+ afterEach(() => {
+ removeThemeLink();
+ });
+
+ after(() => {
+ removeMetaTag();
+ });
+
+ before(() => {
+ addMetaTag("http://example.com");
+ });
+
+ const blocked = [
+ { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/" },
+ { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/" },
+ { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/" },
+ { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/" },
+ { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/" },
+ ];
+
+ const allowed = [
+ {
+ label: "absolute URL (matches allowed origin)",
+ themeRoot: "http://example.com/themes/",
+ expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
+ },
+ {
+ label: "protocol-relative (resolves to allowed origin)",
+ themeRoot: "//example.com/themes/",
+ expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
+ },
+ ];
+
+ const sameOriginAllowed = [
+ { label: "root-relative", themeRoot: "/themes/" },
+ { label: "relative (current dir)", themeRoot: "./themes/" },
+ { label: "relative (parent dir)", themeRoot: "../themes/" },
+ ];
+
+ blocked.forEach(({ label, themeRoot }) => {
+ describe(`blocked — ${label}: ${themeRoot}`, () => {
+ before(() => {
+ cy.stub(internals, "search").callsFake(() => {
+ return `sap-ui-theme=${THEME}@${themeRoot}`;
+ });
+ cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
+ cy.mount( );
+ });
+
+ it("should not create a link element", () => {
+ applyAndCheckLink(THEME);
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
+ });
+ });
+ });
+
+ allowed.forEach(({ label, themeRoot, expectedHref }) => {
+ describe(`allowed — ${label}: ${themeRoot}`, () => {
+ before(() => {
+ cy.stub(internals, "search").callsFake(() => {
+ return `sap-ui-theme=${THEME}@${themeRoot}`;
+ });
+ cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
+ cy.mount( );
+ });
+
+ it("should create a link element with correct href", () => {
+ applyAndCheckLink(THEME);
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
+ .should("exist")
+ .and("have.attr", "href", expectedHref);
+ });
+ });
+ });
+
+ sameOriginAllowed.forEach(({ label, themeRoot }) => {
+ describe(`allowed (same-origin) — ${label}: ${themeRoot}`, () => {
+ before(() => {
+ cy.stub(internals, "search").callsFake(() => {
+ return `sap-ui-theme=${THEME}@${themeRoot}`;
+ });
+ cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
+ cy.mount( );
+ });
+
+ it("should create a link element", () => {
+ applyAndCheckLink(THEME);
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
+ .should("exist")
+ .and("have.attr", "href")
+ .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
+ });
+ });
+ });
+});
diff --git a/packages/base/cypress/specs/ThemeRootAPI.cy.tsx b/packages/base/cypress/specs/ThemeRootAPI.cy.tsx
new file mode 100644
index 000000000000..2c9fae10aeab
--- /dev/null
+++ b/packages/base/cypress/specs/ThemeRootAPI.cy.tsx
@@ -0,0 +1,148 @@
+import TestGeneric from "../../test/test-elements/Generic.js";
+import { setThemeRoot } from "../../src/config/ThemeRoot.js";
+import { getTheme } from "../../src/config/Theme.js";
+import applyTheme from "../../src/theming/applyTheme.js";
+
+const THEME = "sap_horizon";
+
+const addMetaTag = (content: string) => {
+ cy.window().then($win => {
+ const metaTag = $win.document.createElement("meta");
+ metaTag.name = "sap-allowed-theme-origins";
+ metaTag.content = content;
+ $win.document.head.append(metaTag);
+ });
+};
+
+const removeMetaTag = () => {
+ cy.window().then($win => {
+ $win.document.head.querySelector("[name='sap-allowed-theme-origins']")?.remove();
+ });
+};
+
+const removeThemeLink = () => {
+ cy.window().then($win => {
+ $win.document.head.querySelector("link[sap-ui-webcomponents-theme]")?.remove();
+ });
+};
+
+const applyAndCheckLink = (theme: string) => {
+ cy.wrap({ applyTheme, getTheme })
+ .invoke("getTheme")
+ .then(() => {
+ return cy.wrap({ applyTheme }).invoke("applyTheme", theme);
+ });
+};
+
+// ─── Without meta tag ────────────────────────────────────────────────────────
+
+describe("ThemeRoot via setThemeRoot API — without meta tag", () => {
+ before(() => {
+ cy.mount( );
+ });
+
+ afterEach(() => {
+ removeThemeLink();
+ cy.wrap({ setThemeRoot }).invoke("setThemeRoot", undefined);
+ });
+
+ [
+ { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/", blocked: true },
+ { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/", blocked: true },
+ { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/", blocked: true },
+ { label: "absolute URL (same host, no meta tag)", themeRoot: "http://example.com/themes/", blocked: true },
+ { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/", blocked: true },
+ { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/", blocked: true },
+ { label: "protocol-relative (same host, no meta tag)", themeRoot: "//example.com/themes/", blocked: true },
+ { label: "root-relative", themeRoot: "/themes/", blocked: false },
+ { label: "relative (current dir)", themeRoot: "./themes/", blocked: false },
+ { label: "relative (parent dir)", themeRoot: "../themes/", blocked: false },
+ ].forEach(({ label, themeRoot, blocked }) => {
+ it(`${blocked ? "should not create" : "should create"} a link element — ${label}: ${themeRoot}`, () => {
+ cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
+ applyAndCheckLink(THEME);
+ if (blocked) {
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
+ } else {
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
+ .should("exist")
+ .and("have.attr", "href")
+ .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
+ }
+ });
+ });
+});
+
+// ─── With meta tag ────────────────────────────────────────────────────────────
+
+describe("ThemeRoot via setThemeRoot API — with meta tag (allowed: http://example.com)", () => {
+ before(() => {
+ addMetaTag("http://example.com");
+ cy.mount( );
+ });
+
+ afterEach(() => {
+ removeThemeLink();
+ cy.wrap({ setThemeRoot }).invoke("setThemeRoot", undefined);
+ });
+
+ after(() => {
+ removeMetaTag();
+ });
+
+ const blocked = [
+ { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/" },
+ { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/" },
+ { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/" },
+ { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/" },
+ { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/" },
+ ];
+
+ const allowed = [
+ {
+ label: "absolute URL (matches allowed origin)",
+ themeRoot: "http://example.com/themes/",
+ expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
+ },
+ {
+ label: "protocol-relative (resolves to allowed origin)",
+ themeRoot: "//example.com/themes/",
+ expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
+ },
+ ];
+
+ const sameOriginAllowed = [
+ { label: "root-relative", themeRoot: "/themes/" },
+ { label: "relative (current dir)", themeRoot: "./themes/" },
+ { label: "relative (parent dir)", themeRoot: "../themes/" },
+ ];
+
+ blocked.forEach(({ label, themeRoot }) => {
+ it(`should not create a link element — blocked: ${label}: ${themeRoot}`, () => {
+ cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
+ applyAndCheckLink(THEME);
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
+ });
+ });
+
+ allowed.forEach(({ label, themeRoot, expectedHref }) => {
+ it(`should create a link element with correct href — allowed: ${label}: ${themeRoot}`, () => {
+ cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
+ applyAndCheckLink(THEME);
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
+ .should("exist")
+ .and("have.attr", "href", expectedHref);
+ });
+ });
+
+ sameOriginAllowed.forEach(({ label, themeRoot }) => {
+ it(`should create a link element — same-origin: ${label}: ${themeRoot}`, () => {
+ cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
+ applyAndCheckLink(THEME);
+ cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
+ .should("exist")
+ .and("have.attr", "href")
+ .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
+ });
+ });
+});
diff --git a/packages/base/src/validateThemeRoot.ts b/packages/base/src/validateThemeRoot.ts
index f9d02d673be4..f338996fa4ea 100644
--- a/packages/base/src/validateThemeRoot.ts
+++ b/packages/base/src/validateThemeRoot.ts
@@ -31,7 +31,7 @@ const validateThemeRoot = (themeRoot: string) => {
let isSameOrigin = false;
try {
- if (themeRoot.startsWith(".") || themeRoot.startsWith("/")) {
+ if (themeRoot.startsWith(".") || (themeRoot.startsWith("/") && !themeRoot.startsWith("//"))) {
// Handle relative url
// new URL("/newExmPath", "http://example.com/exmPath") => http://example.com/newExmPath
// new URL("./newExmPath", "http://example.com/exmPath") => http://example.com/exmPath/newExmPath
@@ -39,7 +39,8 @@ const validateThemeRoot = (themeRoot: string) => {
resultUrl = new URL(themeRoot, getLocationHref()).toString();
isSameOrigin = true;
} else {
- const themeRootURL = new URL(themeRoot);
+ // Protocol-relative URLs (//host/path) need a base to resolve the protocol
+ const themeRootURL = themeRoot.startsWith("//") ? new URL(themeRoot, getLocationHref()) : new URL(themeRoot);
const origin = themeRootURL.origin;
const currentOrigin = new URL(getLocationHref()).origin;
diff --git a/packages/base/test/pages/ThemeRoot.html b/packages/base/test/pages/ThemeRoot.html
new file mode 100644
index 000000000000..fa7bd73c144d
--- /dev/null
+++ b/packages/base/test/pages/ThemeRoot.html
@@ -0,0 +1,115 @@
+
+
+
+
+
+ Base package default test page
+
+
+
+
+
+
+
+ Without meta tag
+ Reset URL
+
+ ❌ ?sap-ui-theme=sap_horizon@http://example2.com/themes/
+ http://example2.com/ — no meta tag present, blocked
+ Test setThemeRoot
+
+ ❌ ?sap-ui-theme=sap_horizon@https://example.com/themes/
+ https://example.com/ — no meta tag present, blocked
+ Test setThemeRoot
+
+ ❌ ?sap-ui-theme=sap_horizon@http://example:9090.com/themes/
+ http://example:9090.com/ — no meta tag present, blocked
+ Test setThemeRoot
+
+ ❌ ?sap-ui-theme=sap_horizon@http://example.com/themes/
+ http://example.com/ — no meta tag present, blocked
+ Test setThemeRoot
+
+ ❌ ?sap-ui-theme=sap_horizon@//example2.com/themes/
+ //example2.com/ — inherits current page protocol (e.g. http://example2.com/) — no meta tag present, blocked
+ Test setThemeRoot
+
+ ❌ ?sap-ui-theme=sap_horizon@//example:9090.com/themes/
+ //example:9090.com/ — inherits current page protocol (e.g. http://example:9090.com/) — no meta tag present, blocked
+ Test setThemeRoot
+
+ ❌ ?sap-ui-theme=sap_horizon@//example.com/themes/
+ //example.com/ — inherits current page protocol (e.g. http://example.com/) — no meta tag present, blocked
+ Test setThemeRoot
+
+ ✅ ?sap-ui-theme=sap_horizon@/themes/ /themes/
+ (resolves to the current page's origin, e.g. http://localhost:8080/themes/) Same-origin — expected link element
+ Test setThemeRoot
+
+ ✅ ?sap-ui-theme=sap_horizon@./themes/ ./themes/
+ (resolves relative to the current page's URL, e.g. http://localhost:8080/test/pages/themes/) Same-origin — expected link element
+ Test setThemeRoot
+
+ ✅ ?sap-ui-theme=sap_horizon@../themes/ ../themes/
+ (resolves relative to the current page's URL, e.g. http://localhost:8080/test/themes/) Same-origin — expected link element
+ Test setThemeRoot
+
+
+
+
+
+
diff --git a/packages/base/test/pages/ThemeRoot2.html b/packages/base/test/pages/ThemeRoot2.html
new file mode 100644
index 000000000000..4d37344c5116
--- /dev/null
+++ b/packages/base/test/pages/ThemeRoot2.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+ Base package default test page
+
+
+
+
+
+
+
+ With meta tag
+ Reset URL
+ Following page is using the meta tag sap-allowed-theme-origins to specify allowed theme origins with http://example.com as the allowed origin.
+
+
+
+
+