feat: Add custom branding functionality with CSS and JSON configuration

- Introduced custom branding CSS styles in `custom-branding.css`.
- Created example JSON configuration for custom branding in `custom-branding.example.json`.
- Implemented branding configuration logic in `BrandingConfig.ts` to normalize and validate branding settings.
- Developed CSS loader to manage loading and unloading of custom stylesheets in `BrandingCssLoader.ts`.
- Added DOM rendering capabilities for branding elements in `BrandingDomRenderer.ts`.
- Defined types and interfaces for branding elements and configurations in `BrandingTypes.ts`.
- Included localization support for German in `de-de.js`.
- Added unit tests for branding configuration, CSS loader, and DOM renderer.
- Validated project structure and static assets with new validation scripts.
This commit is contained in:
Torsten Brendgen
2026-07-20 22:55:47 +02:00
parent b99ac31f4d
commit 490e9adbd8
29 changed files with 1809 additions and 588 deletions
@@ -0,0 +1,97 @@
import { ICssFile } from './BrandingTypes';
interface ISharedCssEntry {
element: HTMLLinkElement;
references: number;
owned: boolean;
}
export class BrandingCssLoader {
private static _registry: { [url: string]: ISharedCssEntry } = {};
private _loadedKeys: string[] = [];
constructor(private ownerId: string, private debugLog: (message: string, data?: any) => void) { } // tslint:disable-line:no-any
public load(files: ICssFile[]): void {
for (let i: number = 0; i < files.length; i++) {
this.loadOne(files[i]);
}
}
public dispose(): void {
for (let i: number = 0; i < this._loadedKeys.length; i++) {
const key: string = this._loadedKeys[i];
const entry: ISharedCssEntry = BrandingCssLoader._registry[key];
if (!entry) { continue; }
entry.references--;
if (entry.references <= 0) {
if (entry.owned && entry.element.parentNode) {
entry.element.parentNode.removeChild(entry.element);
}
delete BrandingCssLoader._registry[key];
}
}
this._loadedKeys = [];
}
private loadOne(file: ICssFile): void {
const absoluteUrl: string = this.toAbsoluteUrl(file.path);
const key: string = absoluteUrl.toLowerCase();
if (this._loadedKeys.indexOf(key) >= 0) { return; }
let entry: ISharedCssEntry = BrandingCssLoader._registry[key];
if (entry) {
entry.references++;
this._loadedKeys.push(key);
return;
}
const existing: HTMLLinkElement = this.findExistingStylesheet(key);
if (existing) {
BrandingCssLoader._registry[key] = { element: existing, references: 1, owned: false };
this._loadedKeys.push(key);
return;
}
const link: HTMLLinkElement = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = absoluteUrl;
link.media = file.media || 'all';
link.setAttribute('data-custom-branding-owner', this.ownerId);
link.setAttribute('data-custom-branding-url', key);
const timeout: number = window.setTimeout((): void => {
this.debugLog('Stylesheet load timed out.', { path: file.path });
}, 10000);
link.onload = (): void => {
window.clearTimeout(timeout);
this.debugLog('Stylesheet loaded.', { path: file.path });
};
link.onerror = (): void => {
window.clearTimeout(timeout);
this.debugLog('Stylesheet failed to load.', { path: file.path });
};
document.head.appendChild(link);
entry = { element: link, references: 1, owned: true };
BrandingCssLoader._registry[key] = entry;
this._loadedKeys.push(key);
}
private toAbsoluteUrl(path: string): string {
const anchor: HTMLAnchorElement = document.createElement('a');
anchor.href = path;
return anchor.href;
}
private findExistingStylesheet(key: string): HTMLLinkElement | undefined {
const links: NodeListOf<HTMLLinkElement> = document.getElementsByTagName('link');
for (let i: number = 0; i < links.length; i++) {
if (String(links[i].rel || '').toLowerCase() === 'stylesheet'
&& String(links[i].href || '').toLowerCase() === key) {
return links[i];
}
}
return undefined;
}
}