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

View File

@@ -0,0 +1,306 @@
// tslint:disable:no-any max-line-length
import {
BrandingElementType,
IBrandingElement,
IBrandingNormalizationResult,
ICssFile,
ICustomBrandingApplicationCustomizerProperties,
ICustomBrandingConfig
} from './BrandingTypes';
export const BrandingSchemaVersion: number = 2;
export const MaxBrandingDepth: number = 8;
export const MaxBrandingElements: number = 200;
export const MaxBrandingConfigurationLength: number = 100000;
const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
const GlobalAttributes: string[] = ['id', 'class', 'title', 'role', 'aria-label', 'aria-hidden', 'aria-current', 'aria-live'];
const AllowedStyles: string[] = [
'align-items', 'background', 'background-color', 'border', 'border-bottom', 'border-color', 'border-left',
'border-radius', 'border-right', 'border-style', 'border-top', 'border-width', 'box-sizing', 'color',
'display', 'flex', 'flex-basis', 'flex-direction', 'flex-grow', 'flex-shrink', 'flex-wrap', 'font-family',
'font-size', 'font-style', 'font-weight', 'gap', 'grid-template-columns', 'height', 'justify-content',
'line-height', 'margin', 'margin-bottom', 'margin-left', 'margin-right', 'margin-top', 'max-height',
'max-width', 'min-height', 'min-width', 'opacity', 'overflow', 'padding', 'padding-bottom', 'padding-left',
'padding-right', 'padding-top', 'text-align', 'text-decoration', 'text-transform', 'white-space', 'width'
];
interface INormalizationState {
count: number;
warnings: string[];
siteCollectionUrl: string;
}
export function normalizeBrandingConfig(
rawProperties: ICustomBrandingApplicationCustomizerProperties | any,
siteCollectionUrl: string
): IBrandingNormalizationResult {
const warnings: string[] = [];
const raw: any = rawProperties && typeof rawProperties === 'object' ? rawProperties : {};
let serializedLength: number = 0;
try {
serializedLength = JSON.stringify(raw).length;
} catch (error) {
warnings.push('Configuration could not be serialized and was ignored.');
}
if (serializedLength > MaxBrandingConfigurationLength) {
warnings.push('Configuration exceeds the maximum size and was ignored.');
return { config: createEmptyConfig(), warnings: warnings };
}
const allowedCssHosts: string[] = normalizeHosts(raw.allowedCssHosts);
const state: INormalizationState = { count: 0, warnings: warnings, siteCollectionUrl: siteCollectionUrl || '' };
const topSource: any[] = raw.placeholdertop && Array.isArray(raw.placeholdertop.elements)
? raw.placeholdertop.elements
: (Array.isArray(raw.elements) ? raw.elements : []);
const bottomSource: any[] = raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements)
? raw.placeholderbottom.elements
: [];
const config: ICustomBrandingConfig = {
schemaVersion: BrandingSchemaVersion,
enabled: raw.enabled !== false,
debug: raw.debug === true,
allowedCssHosts: allowedCssHosts,
cssfiles: normalizeCssFiles(raw.cssfiles, siteCollectionUrl, allowedCssHosts, warnings),
placeholdertop: { elements: normalizeElements(topSource, 1, state) },
placeholderbottom: { elements: normalizeElements(bottomSource, 1, state) }
};
return { config: config, warnings: warnings };
}
export function sanitizeNavigationUrl(value: string, siteCollectionUrl: string, allowMailto: boolean): string | undefined {
const raw: string = String(value || '').trim();
if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) {
return undefined;
}
const resolved: string = raw.toLowerCase().indexOf('~sitecollection') === 0
? String(siteCollectionUrl || '').replace(/\/+$/, '') + raw.substring('~sitecollection'.length)
: raw;
const protocolMatch: RegExpMatchArray | null = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
if (!protocolMatch) {
return resolved;
}
const protocol: string = protocolMatch[1].toLowerCase();
return protocol === 'http' || protocol === 'https' || (allowMailto && protocol === 'mailto')
? resolved
: undefined;
}
export function sanitizeStylesheetUrl(
value: string,
siteCollectionUrl: string,
allowedCssHosts: string[]
): string | undefined {
const resolved: string = sanitizeNavigationUrl(value, siteCollectionUrl, false);
if (!resolved) {
return undefined;
}
const absolute: RegExpMatchArray | null = resolved.match(/^(https?):\/\/([^/]+)/i);
if (!absolute) {
return resolved;
}
const site: RegExpMatchArray | null = String(siteCollectionUrl || '').match(/^(https?):\/\/([^/]+)/i);
const protocol: string = absolute[1].toLowerCase();
const host: string = absolute[2].toLowerCase();
if (site && protocol === site[1].toLowerCase() && host === site[2].toLowerCase()) {
return resolved;
}
if (protocol !== 'https' || allowedCssHosts.indexOf(host) < 0) {
return undefined;
}
return resolved;
}
export function sanitizeStyle(propertyName: string, value: string): string | undefined {
const property: string = String(propertyName || '').trim().toLowerCase();
const styleValue: string = String(value || '').trim();
if (AllowedStyles.indexOf(property) < 0 || !styleValue || styleValue.length > 512) {
return undefined;
}
if (/[\u0000-\u001f\u007f]/.test(styleValue)
|| /(url\s*\(|expression\s*\(|javascript\s*:|@import|behavior\s*:|-moz-binding)/i.test(styleValue)) {
return undefined;
}
return styleValue;
}
function createEmptyConfig(): ICustomBrandingConfig {
return {
schemaVersion: BrandingSchemaVersion,
enabled: true,
debug: false,
allowedCssHosts: [],
cssfiles: [],
placeholdertop: { elements: [] },
placeholderbottom: { elements: [] }
};
}
function normalizeHosts(value: any): string[] {
if (!Array.isArray(value)) {
return [];
}
const result: string[] = [];
for (let i: number = 0; i < value.length; i++) {
const host: string = String(value[i] || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
if (/^[a-z0-9.-]+(?::\d+)?$/.test(host) && result.indexOf(host) < 0) {
result.push(host);
}
}
return result;
}
function normalizeCssFiles(
value: any,
siteCollectionUrl: string,
allowedCssHosts: string[],
warnings: string[]
): ICssFile[] {
if (!Array.isArray(value)) {
return [];
}
const result: ICssFile[] = [];
const seen: string[] = [];
for (let i: number = 0; i < value.length && result.length < 20; i++) {
const item: any = value[i];
const path: string = sanitizeStylesheetUrl(item && item.path, siteCollectionUrl, allowedCssHosts);
if (!path) {
warnings.push('Stylesheet at index ' + i + ' was rejected.');
continue;
}
const normalizedKey: string = path.toLowerCase();
if (seen.indexOf(normalizedKey) >= 0) {
continue;
}
seen.push(normalizedKey);
const media: string = item && typeof item.media === 'string' && /^[a-z0-9 (),.:/-]{1,80}$/i.test(item.media)
? item.media.trim()
: 'all';
result.push({ path: path, media: media });
}
return result;
}
function normalizeElements(value: any[], depth: number, state: INormalizationState): IBrandingElement[] {
const result: IBrandingElement[] = [];
if (!Array.isArray(value) || depth > MaxBrandingDepth) {
if (depth > MaxBrandingDepth) {
state.warnings.push('Maximum element depth exceeded.');
}
return result;
}
for (let i: number = 0; i < value.length && state.count < MaxBrandingElements; i++) {
const normalized: IBrandingElement = normalizeElement(value[i], depth, state);
if (normalized) {
result.push(normalized);
}
}
if (state.count >= MaxBrandingElements) {
state.warnings.push('Maximum element count reached.');
}
return result;
}
function normalizeElement(value: any, depth: number, state: INormalizationState): IBrandingElement | undefined {
if (!value || typeof value !== 'object') {
state.warnings.push('Invalid element was ignored.');
return undefined;
}
const tag: string = String(value.type || '').trim().toLowerCase();
if (AllowedTags.indexOf(tag) < 0) {
state.warnings.push('Element type "' + tag + '" was rejected.');
return undefined;
}
state.count++;
const attributes: { [key: string]: string } = normalizeAttributes(tag, value.attributes, state);
if (tag === 'img' && attributes.alt === undefined) {
state.warnings.push('Image without alt attribute was rejected.');
return undefined;
}
const styles: { [key: string]: string } = {};
if (value.styles && typeof value.styles === 'object') {
for (const styleName in value.styles) {
if (value.styles.hasOwnProperty(styleName)) {
const safeStyle: string = sanitizeStyle(styleName, value.styles[styleName]);
if (safeStyle) {
styles[styleName.toLowerCase()] = safeStyle;
} else {
state.warnings.push('Style "' + styleName + '" was rejected.');
}
}
}
}
const content: string = value.content === undefined || value.content === null
? ''
: String(value.content).substring(0, 4000);
const children: IBrandingElement[] = tag === 'img'
? []
: normalizeElements(value.children, depth + 1, state);
if ((tag === 'a' || tag === 'button') && !content && children.length === 0 && !attributes['aria-label']) {
state.warnings.push('Empty interactive element was rejected.');
return undefined;
}
return {
type: tag as BrandingElementType,
content: content || undefined,
attributes: hasKeys(attributes) ? attributes : undefined,
styles: hasKeys(styles) ? styles : undefined,
children: children.length > 0 ? children : undefined
};
}
function normalizeAttributes(tag: string, value: any, state: INormalizationState): { [key: string]: string } {
const result: { [key: string]: string } = {};
if (!value || typeof value !== 'object') {
if (tag === 'button') { result.type = 'button'; }
return result;
}
for (const rawName in value) {
if (!value.hasOwnProperty(rawName)) { continue; }
const name: string = String(rawName || '').trim().toLowerCase();
const rawValue: string = String(value[rawName] === undefined ? '' : value[rawName]).substring(0, 2048);
if (name.indexOf('on') === 0 || !isAttributeAllowed(tag, name)) {
state.warnings.push('Attribute "' + name + '" was rejected.');
continue;
}
if (name === 'href' || name === 'src') {
const safeUrl: string = sanitizeNavigationUrl(rawValue, state.siteCollectionUrl, name === 'href');
if (safeUrl) { result[name] = safeUrl; } else { state.warnings.push('URL attribute was rejected.'); }
} else if (name === 'target') {
if (rawValue === '_blank' || rawValue === '_self') { result[name] = rawValue; }
} else if (name === 'id' || name === 'class') {
if (/^[a-z0-9 _-]{1,256}$/i.test(rawValue)) { result[name] = rawValue; }
} else if (name === 'width' || name === 'height') {
if (/^\d{1,4}$/.test(rawValue)) { result[name] = rawValue; }
} else if (name === 'aria-hidden') {
if (rawValue === 'true' || rawValue === 'false') { result[name] = rawValue; }
} else if (name === 'type' && tag === 'button') {
result.type = 'button';
} else {
result[name] = rawValue;
}
}
if (tag === 'button') { result.type = 'button'; }
if (tag === 'a' && result.target === '_blank') { result.rel = 'noopener noreferrer'; }
return result;
}
function isAttributeAllowed(tag: string, name: string): boolean {
if (GlobalAttributes.indexOf(name) >= 0) { return true; }
if (tag === 'a') { return ['href', 'target'].indexOf(name) >= 0; }
if (tag === 'img') { return ['src', 'alt', 'width', 'height'].indexOf(name) >= 0; }
if (tag === 'button') { return ['type', 'disabled', 'aria-expanded', 'aria-controls'].indexOf(name) >= 0; }
return false;
}
function hasKeys(value: { [key: string]: string }): boolean {
for (const key in value) {
if (value.hasOwnProperty(key)) { return true; }
}
return false;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,44 @@
import { IBrandingElement } from './BrandingTypes';
export class BrandingDomRenderer {
public render(container: HTMLElement, elements: IBrandingElement[]): void {
this.clear(container);
for (let i: number = 0; i < elements.length; i++) {
container.appendChild(this.createElement(elements[i]));
}
}
public clear(container: HTMLElement): void {
while (container.firstChild) {
container.removeChild(container.firstChild);
}
}
private createElement(config: IBrandingElement): HTMLElement {
const element: HTMLElement = document.createElement(config.type);
if (config.attributes) {
for (const name in config.attributes) {
if (config.attributes.hasOwnProperty(name)) {
element.setAttribute(name, config.attributes[name]);
}
}
}
if (config.styles) {
for (const propertyName in config.styles) {
if (config.styles.hasOwnProperty(propertyName)) {
element.style.setProperty(propertyName, config.styles[propertyName]);
}
}
}
if (config.content) {
element.appendChild(document.createTextNode(config.content));
}
if (config.children) {
for (let i: number = 0; i < config.children.length; i++) {
element.appendChild(this.createElement(config.children[i]));
}
}
return element;
}
}

View File

@@ -0,0 +1,45 @@
export type BrandingElementType = 'div' | 'span' | 'p' | 'a' | 'button' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em' | 'nav' | 'section';
export interface ICssFile {
path: string;
media?: string;
}
export interface IBrandingElement {
type: BrandingElementType;
content?: string;
attributes?: { [key: string]: string };
styles?: { [key: string]: string };
children?: IBrandingElement[];
}
export interface IPlaceholderConfig {
elements: IBrandingElement[];
}
export interface ICustomBrandingConfig {
schemaVersion: number;
enabled: boolean;
debug: boolean;
allowedCssHosts: string[];
cssfiles: ICssFile[];
placeholdertop: IPlaceholderConfig;
placeholderbottom: IPlaceholderConfig;
}
export interface ICustomBrandingApplicationCustomizerProperties {
schemaVersion?: number;
enabled?: boolean;
debug?: boolean;
allowedCssHosts?: string[];
cssfiles?: ICssFile[];
placeholdertop?: IPlaceholderConfig;
placeholderbottom?: IPlaceholderConfig;
elements?: IBrandingElement[];
}
export interface IBrandingNormalizationResult {
config: ICustomBrandingConfig;
warnings: string[];
}

View File

@@ -1,373 +1,175 @@
import { override } from '@microsoft/decorators';
// Legacy SPFx 1.4 implementation; incremental modernization is tracked separately.
// tslint:disable:max-line-length no-consecutive-blank-lines no-function-expression no-trailing-whitespace typedef
// tslint:disable:no-any max-line-length
import { override } from '@microsoft/decorators';
import { Log } from '@microsoft/sp-core-library';
import {
BaseApplicationCustomizer,
PlaceholderContent,
PlaceholderName
} from '@microsoft/sp-application-base';
import * as strings from 'CustomBrandingApplicationCustomizerStrings';
import { BrandingCssLoader } from './BrandingCssLoader';
import { normalizeBrandingConfig } from './BrandingConfig';
import { BrandingDomRenderer } from './BrandingDomRenderer';
import {
IBrandingNormalizationResult,
IBrandingElement,
ICustomBrandingApplicationCustomizerProperties,
ICustomBrandingConfig
} from './BrandingTypes';
const LOG_SOURCE: string = 'CustomBrandingApplicationCustomizer';
const COMPONENT_ID: string = '035ba968-6488-4d42-86b3-0470ffcc95b9';
/**
* CSS-Datei Definition
*/
export interface ICssFile {
path: string;
}
/**
* Definition eines HTML-Elements
*/
export interface IBrandingElement {
type: 'div' | 'span' | 'p' | 'a' | 'button' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em';
content?: string;
attributes?: { [key: string]: string };
styles?: { [key: string]: string };
children?: IBrandingElement[];
}
export interface IPlaceholderConfig {
elements?: IBrandingElement[];
}
/**
* Hauptkonfiguration fÃÆÃ†â€™Ãƒâ€šÃ¼r das Branding
*/
export interface IBrandingConfig {
cssfiles?: ICssFile[];
placeholdertop?: IBrandingElement[];
placeholderbottom?: IBrandingElement[];
}
/**
* Properties fÃÆÃ†â€™Ãƒâ€šÃ¼r den CustomBranding Application Customizer
*/
export interface ICustomBrandingApplicationCustomizerProperties {
/**
* Array von CSS-Dateien die geladen werden sollen
*/
cssfiles?: ICssFile[];
/**
* Array von HTML-Elementen fÃÆÃ†â€™Ãƒâ€šÃ¼r den Placeholder Top
*/
placeholdertop?: IPlaceholderConfig;
/**
* Array von HTML-Elementen fÃÆÃ†â€™Ãƒâ€šÃ¼r den Placeholder Bottom
*/
placeholderbottom?: IPlaceholderConfig;
}
/**
* CustomBranding Application Customizer
* Kompiliert JSON-Konfiguration zu HTML und fÃÆÃ†â€™Ãƒâ€šÃ¼gt es in den Top Placeholder ein
* LÃÆÃ†â€™Ãƒâ€šÃ¤dt optional CSS-Dateien
*/
export default class CustomBrandingApplicationCustomizer
extends BaseApplicationCustomizer<ICustomBrandingApplicationCustomizerProperties> {
private _topPlaceholder: PlaceholderContent | undefined;
private _bottomPlaceholder: PlaceholderContent | undefined;
private _loadedCssFiles: string[] = [];
private _topHost: HTMLElement | undefined;
private _bottomHost: HTMLElement | undefined;
private _renderer: BrandingDomRenderer = new BrandingDomRenderer();
private _cssLoader: BrandingCssLoader | undefined;
private _config: ICustomBrandingConfig | undefined;
private _isDisposed: boolean = false;
@override
public onInit(): Promise<void> {
Log.info(LOG_SOURCE, 'Initialized CustomBrandingApplicationCustomizer');
const result: IBrandingNormalizationResult = normalizeBrandingConfig(
this.properties,
this.context.pageContext.site.absoluteUrl
);
this._config = result.config;
this.debug(strings.Initialized + ' 3.0.0.', {
schemaVersion: this._config.schemaVersion,
cssFileCount: this._config.cssfiles.length,
warningCount: result.warnings.length
});
for (let i: number = 0; i < result.warnings.length; i++) {
this.debug('Configuration warning: ' + result.warnings[i]);
}
// CSS-Dateien laden
this._loadCssFiles();
// Auf Placeholder-ÃÆÃ†â€™ÃƒÂ¢Ã¢â€šÂ¬Ã…¾nderungen reagieren
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceHolders);
// Initial rendern
this._renderPlaceHolders();
if (!this._config.enabled) {
this.debug('CustomBranding is disabled by configuration.');
return Promise.resolve();
}
this._cssLoader = new BrandingCssLoader(COMPONENT_ID, this.debug.bind(this));
this._cssLoader.load(this._config.cssfiles);
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders);
this._renderPlaceholders();
return Promise.resolve();
}
/**
* LÃÆÃ†â€™Ãƒâ€šÃ¤dt CSS-Dateien aus der Konfiguration
*/
private _loadCssFiles(): void {
if (this.properties && this.properties.cssfiles && Array.isArray(this.properties.cssfiles)) {
console.log('CustomBranding: Loading CSS files...');
private _renderPlaceholders(): void {
if (this._isDisposed || !this._config || !this._config.enabled) { return; }
for (let i = 0; i < this.properties.cssfiles.length; i++) {
const cssFile = this.properties.cssfiles[i];
if (cssFile && cssFile.path) {
this._injectCssFile(cssFile.path);
}
}
}
}
/**
* FÃÆÃ†â€™Ãƒâ€šÃ¼gt eine CSS-Datei in den Head ein
*/
private _injectCssFile(cssPath: string): void {
// PrÃÆÃ†â€™Ãƒâ€šÃ¼fen ob die Datei bereits geladen wurde
for (let i = 0; i < this._loadedCssFiles.length; i++) {
if (this._loadedCssFiles[i] === cssPath) {
console.log('CustomBranding: CSS file already loaded: ' + cssPath);
return;
}
}
try {
// Link-Element erstellen
const linkElement: HTMLLinkElement = document.createElement('link');
linkElement.rel = 'stylesheet';
linkElement.type = 'text/css';
linkElement.href = cssPath;
linkElement.setAttribute('data-custom-branding', 'true');
// Event-Handler fÃÆÃ†â€™Ãƒâ€šÃ¼r erfolgreiches Laden
linkElement.onload = function () {
console.log('CustomBranding: CSS loaded successfully: ' + cssPath);
};
// Event-Handler fÃÆÃ†â€™Ãƒâ€šÃ¼r Fehler
linkElement.onerror = function () {
console.error('CustomBranding: Failed to load CSS: ' + cssPath);
};
// In Head einfÃÆÃ†â€™Ãƒâ€šÃ¼gen
document.head.appendChild(linkElement);
// Zur Liste hinzufÃÆÃ†â€™Ãƒâ€šÃ¼gen
this._loadedCssFiles.push(cssPath);
console.log('CustomBranding: CSS file injected: ' + cssPath);
} catch (error) {
console.error('CustomBranding: Error injecting CSS file: ' + cssPath, error);
}
}
private _renderPlaceHolders(): void {
console.log('CustomBrandingApplicationCustomizer._renderPlaceHolders()');
// PrÃÆÃ†â€™Ãƒâ€šÃ¼fen ob Top Placeholder verfÃÆÃ†â€™Ãƒâ€šÃ¼gbar ist
if (!this._topPlaceholder && !this._bottomPlaceholder) {
if (!this._topPlaceholder) {
this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(
PlaceholderName.Top,
{ onDispose: this._onDispose }
{ onDispose: this._onTopPlaceholderDisposed }
);
}
if (!this._bottomPlaceholder) {
this._bottomPlaceholder = this.context.placeholderProvider.tryCreateContent(
PlaceholderName.Bottom,
{ onDispose: this._onDispose }
{ onDispose: this._onBottomPlaceholderDisposed }
);
}
// Falls Placeholder nicht verfÃÆÃ†â€™Ãƒâ€šÃ¼gbar, abbrechen
if (!this._topPlaceholder) {
console.error('CustomBranding: Top placeholder not found');
return;
}
// Falls Placeholder nicht verfÃÆÃ†â€™Ãƒâ€šÃ¼gbar, abbrechen
if (!this._bottomPlaceholder) {
console.error('CustomBranding: Bottom placeholder not found');
return;
}
if (this._topPlaceholder.domElement && this._bottomPlaceholder.domElement) {
// Container erstellen mit hoher PrioritÃÆÃ†â€™Ãƒâ€šÃ¤t
this.renderPlaceHolder(this._topPlaceholder.domElement, this._bottomPlaceholder.domElement);
console.log('CustomBranding: HTML injected successfully');
}
if (this._topPlaceholder && this._topPlaceholder.domElement) {
this._topPlaceholder.domElement.id = 'CustomHeader';
this._topHost = this.getOrCreateOwnedHost(
this._topPlaceholder.domElement,
'CustomBrandingTopHost'
);
this.renderSafely(this._topHost, this._config.placeholdertop.elements);
}
if (this._bottomPlaceholder && this._bottomPlaceholder.domElement) {
this._bottomPlaceholder.domElement.id = 'CustomFooter';
this._bottomHost = this.getOrCreateOwnedHost(
this._bottomPlaceholder.domElement,
'CustomBrandingBottomHost'
);
this.renderSafely(this._bottomHost, this._config.placeholderbottom.elements);
}
}
private renderPlaceHolder(topcontainer: HTMLElement, bottomcontainer: HTMLElement) {
if (!this.properties) {
console.log('CustomBranding: No properties provided');
return;
private getOrCreateOwnedHost(parent: HTMLElement, id: string): HTMLElement {
let host: HTMLElement = parent.querySelector('#' + id) as HTMLElement;
if (!host) {
host = document.createElement('div');
host.id = id;
host.setAttribute('data-custom-branding-owner', COMPONENT_ID);
parent.insertBefore(host, parent.firstChild);
}
return host;
}
private renderSafely(host: HTMLElement, elements: IBrandingElement[]): void {
try {
// Top-Konfiguration unverÃÆÃ†â€™Ãƒâ€šÃ¤ndert ÃÆÃ†â€™Ãƒâ€šÃ¼bernehmen
const topConfig: IPlaceholderConfig | undefined =
this.properties.placeholdertop;
// Bottom-Konfiguration klonen oder initialisieren
const bottomConfig: IPlaceholderConfig =
this.properties.placeholderbottom
? { ...this.properties.placeholderbottom }
: { elements: [] };
// Admin-Link nur fÃÆÃ†â€™Ãƒâ€šÃ¼r Site Collection Admins ergÃÆÃ†â€™Ãƒâ€šÃ¤nzen
if (this._isSiteAdmin()) {
bottomConfig.elements = bottomConfig.elements || [];
bottomConfig.elements.push(this._getAdminFooterElement());
}
const config: IBrandingConfig = {
cssfiles: this.properties.cssfiles,
placeholdertop: topConfig.elements,
placeholderbottom: bottomConfig.elements
};
console.log('CustomBranding: Compiling JSON to HTML...');
const compiled = this._compileToHtml(config);
topcontainer.id = 'CustomHeader';
topcontainer.innerHTML = compiled.top;
bottomcontainer.id = 'CustomFooter';
bottomcontainer.innerHTML = compiled.bottom;
this._renderer.render(host, elements);
} catch (error) {
console.error('CustomBranding: Error compiling configuration', error);
this._renderer.clear(host);
const status: HTMLElement = document.createElement('div');
status.className = 'custom-branding-status';
status.setAttribute('role', 'status');
status.textContent = strings.RenderError;
host.appendChild(status);
this.debug('Rendering failed.', error);
}
}
/**
* Kompiliert die JSON-Konfiguration zu HTML
*/
private _compileToHtml(config: IBrandingConfig): { top: string; bottom: string } {
// Compile Top and Bottom separately
let topHtml: string = '';
let bottomHtml: string = '';
if (config.placeholdertop && Array.isArray(config.placeholdertop)) {
for (let i = 0; i < config.placeholdertop.length; i++) { topHtml += this._createElement(config.placeholdertop[i]); }
}
if (config.placeholderbottom && Array.isArray(config.placeholderbottom)) {
for (let i = 0; i < config.placeholderbottom.length; i++) { bottomHtml += this._createElement(config.placeholderbottom[i]); }
}
return { top: topHtml, bottom: bottomHtml };
private _onTopPlaceholderDisposed = (): void => {
this.removeHost(this._topHost);
this._topHost = undefined;
this._topPlaceholder = undefined;
this.debug('Top placeholder disposed; waiting for a new placeholder.');
}
/**
* Erstellt HTML fÃÆÃ†â€™Ãƒâ€šÃ¼r ein einzelnes Element
*/
private _createElement(element: IBrandingElement): string {
const tag = element.type || 'div';
let html = '<' + tag;
// Attribute hinzufÃÆÃ†â€™Ãƒâ€šÃ¼gen
if (element.attributes) {
for (const key in element.attributes) {
if (element.attributes.hasOwnProperty(key)) {
const value = element.attributes[key];
html += ' ' + key + '="' + this._escapeHtml(value) + '"';
}
}
}
// Styles hinzufÃÆÃ†â€™Ãƒâ€šÃ¼gen
if (element.styles) {
const styleArray: string[] = [];
for (const key in element.styles) {
if (element.styles.hasOwnProperty(key)) {
const value = element.styles[key];
styleArray.push(key + ':' + value);
}
}
if (styleArray.length > 0) {
const styleString = styleArray.join(';');
html += ' style="' + styleString + '"';
}
}
html += '>';
// Content hinzufÃÆÃ†â€™Ãƒâ€šÃ¼gen
if (element.content) {
html += this._escapeHtml(element.content);
}
// Kinder hinzufÃÆÃ†â€™Ãƒâ€šÃ¼gen
if (element.children && Array.isArray(element.children)) {
for (let i = 0; i < element.children.length; i++) {
html += this._createElement(element.children[i]);
}
}
// Self-closing Tags behandeln
const selfClosingTags = ['img', 'br', 'hr', 'input'];
let isSelfClosing = false;
for (let i = 0; i < selfClosingTags.length; i++) {
if (selfClosingTags[i] === tag) {
isSelfClosing = true;
break;
}
}
if (!isSelfClosing) {
html += '</' + tag + '>';
}
return html;
private _onBottomPlaceholderDisposed = (): void => {
this.removeHost(this._bottomHost);
this._bottomHost = undefined;
this._bottomPlaceholder = undefined;
this.debug('Bottom placeholder disposed; waiting for a new placeholder.');
}
private _isSiteAdmin(): boolean {
return this.context.pageContext.legacyPageContext.isSiteAdmin === true;
@override
protected onDispose(): void {
if (this._isDisposed) { return; }
this._isDisposed = true;
this.context.placeholderProvider.changedEvent.remove(this, this._renderPlaceholders);
this.removeHost(this._topHost);
this.removeHost(this._bottomHost);
if (this._cssLoader) {
this._cssLoader.dispose();
}
this._topHost = undefined;
this._bottomHost = undefined;
this._topPlaceholder = undefined;
this._bottomPlaceholder = undefined;
this._cssLoader = undefined;
this.debug('CustomBranding disposed.');
this._config = undefined;
}
private _getAdminFooterElement(): IBrandingElement {
const siteUrl = this.context.pageContext.site.absoluteUrl;
return {
type: 'div',
styles: {
'text-align': 'right',
'padding': '8px 16px',
'border-top': '1px solid #e1e1e1',
'background-color': '#f8f8f8',
'font-size': '13px'
},
children: [
{
type: 'a',
content: 'Einstellungen',
attributes: {
href: `${siteUrl}/SitePages/PortalSettings.aspx`
},
styles: {
'text-decoration': 'none',
'font-weight': '600'
}
}
]
};
private removeHost(host: HTMLElement | undefined): void {
if (host && host.parentNode) {
host.parentNode.removeChild(host);
}
}
/**
* Escaped HTML-Zeichen fÃÆÃ†â€™Ãƒâ€šÃ¼r Sicherheit
*/
private _escapeHtml(text: string): string {
const map: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, function (m) {
return map[m];
});
}
private _onDispose(): void {
console.log('CustomBrandingApplicationCustomizer._onDispose()');
// CSS-Dateien beim Dispose entfernen
const cssLinks = document.querySelectorAll('link[data-custom-branding="true"]');
for (let i = 0; i < cssLinks.length; i++) {
const link = cssLinks[i];
if (link.parentNode) {
link.parentNode.removeChild(link);
}
private debug(message: string, data?: any): void {
if (!this._config || !this._config.debug) { return; }
Log.info(LOG_SOURCE, message);
if (window.console && window.console.log) {
console.log('[CustomBranding] ' + message, data || '');
}
}
}
export {
IBrandingElement,
ICssFile,
ICustomBrandingApplicationCustomizerProperties,
IPlaceholderConfig
} from './BrandingTypes';

View File

@@ -0,0 +1,7 @@
define([], function() {
return {
"Title": "CustomBrandingApplicationCustomizer",
"Initialized": "CustomBranding wurde initialisiert",
"RenderError": "Das konfigurierte Branding konnte nicht dargestellt werden."
};
});

View File

@@ -1,5 +1,7 @@
define([], function() {
return {
"Title": "CustomBrandingApplicationCustomizer"
"Title": "CustomBrandingApplicationCustomizer",
"Initialized": "CustomBranding initialized",
"RenderError": "The configured branding could not be rendered."
}
});
});

View File

@@ -1,5 +1,7 @@
declare interface ICustomBrandingApplicationCustomizerStrings {
Title: string;
Initialized: string;
RenderError: string;
}
declare module 'CustomBrandingApplicationCustomizerStrings' {

View File

@@ -1 +1,3 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler
export * from './extensions/customBranding/BrandingTypes';
export * from './extensions/customBranding/BrandingConfig';
export * from './extensions/customBranding/BrandingDomRenderer';