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,85 @@
'use strict';
var configModule = require('../temp/tests/BrandingConfig');
var normalize = configModule.normalizeBrandingConfig;
var siteUrl = 'http://sharepoint/sites/portal';
var passed = 0;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
passed++;
}
function element(raw) {
return normalize({ placeholdertop: { elements: [raw] } }, siteUrl).config.placeholdertop.elements[0];
}
var empty = normalize({}, siteUrl);
assert(empty.config.schemaVersion === 2, 'Das aktuelle Schema muss Version 2 verwenden.');
assert(empty.config.enabled === true, 'Branding muss standardmaessig aktiviert sein.');
var legacy = normalize({ elements: [{ type: 'span', content: 'Alt' }] }, siteUrl);
assert(legacy.config.placeholdertop.elements.length === 1, 'Das Legacy-Root-Array muss weiter funktionieren.');
['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']
.forEach(function (tag) {
var attributes = tag === 'img' ? { alt: '' } : undefined;
var content = tag === 'a' || tag === 'button' ? tag : undefined;
assert(!!element({ type: tag, content: content, attributes: attributes }), 'Erlaubter Tag fehlt: ' + tag);
});
assert(!element({ type: 'script', content: 'alert(1)' }), 'script darf nicht gerendert werden.');
assert(!element({ type: 'iframe' }), 'iframe darf nicht gerendert werden.');
var safeLink = element({
type: 'a',
content: 'Portal',
attributes: { href: '~sitecollection/SitePages/Home.aspx', target: '_blank', onclick: 'alert(1)' }
});
assert(safeLink.attributes.href === siteUrl + '/SitePages/Home.aspx', '~sitecollection wurde nicht aufgeloest.');
assert(safeLink.attributes.rel === 'noopener noreferrer', 'Externe Fenster brauchen noopener/noreferrer.');
assert(safeLink.attributes.onclick === undefined, 'Event-Attribute duerfen nicht uebernommen werden.');
var unsafeLink = element({ type: 'a', content: 'Unsicher', attributes: { href: 'javascript:alert(1)' } });
assert(unsafeLink.attributes === undefined || unsafeLink.attributes.href === undefined, 'javascript: muss blockiert werden.');
var styled = element({
type: 'div',
styles: { color: '#fff', position: 'fixed', background: 'url(javascript:alert(1))' }
});
assert(styled.styles.color === '#fff', 'Erlaubte Styles muessen erhalten bleiben.');
assert(styled.styles.position === undefined, 'Nicht freigegebene Styles muessen entfernt werden.');
assert(styled.styles.background === undefined, 'CSS url() muss blockiert werden.');
assert(!element({ type: 'img', attributes: { src: '/logo.png' } }), 'Bilder ohne alt muessen verworfen werden.');
assert(!!element({ type: 'img', attributes: { src: '/logo.png', alt: '' } }), 'Dekorative Bilder mit leerem alt sind erlaubt.');
assert(!element({ type: 'button' }), 'Leere Buttons muessen verworfen werden.');
var css = normalize({
allowedCssHosts: ['cdn.example.org'],
cssfiles: [
{ path: '/SiteAssets/site.css' },
{ path: 'https://cdn.example.org/portal.css', media: 'screen' },
{ path: 'https://evil.example.org/evil.css' },
{ path: 'http://cdn.example.org/mixed.css' },
{ path: '/SiteAssets/site.css' }
]
}, siteUrl).config.cssfiles;
assert(css.length === 2, 'CSS-Allowlist oder Deduplizierung ist fehlerhaft.');
assert(css[0].media === 'all' && css[1].media === 'screen', 'CSS-Media wurde nicht normalisiert.');
var deep = { type: 'div' };
var cursor = deep;
for (var depth = 0; depth < 12; depth++) {
cursor.children = [{ type: 'div' }];
cursor = cursor.children[0];
}
var deepResult = normalize({ placeholdertop: { elements: [deep] } }, siteUrl);
assert(deepResult.warnings.some(function (warning) { return warning.indexOf('depth') >= 0; }), 'Das Tiefenlimit greift nicht.');
var huge = { placeholdertop: { elements: [] }, padding: new Array(100002).join('x') };
assert(normalize(huge, siteUrl).config.placeholdertop.elements.length === 0, 'Zu grosse Konfiguration muss ignoriert werden.');
console.log('BrandingConfig: ' + passed + ' Pruefungen erfolgreich.');

View File

@@ -0,0 +1,56 @@
'use strict';
var jsdom = require('jsdom');
var document = jsdom.jsdom('<!doctype html><html><head></head><body></body></html>', {
url: 'http://sharepoint/sites/portal/SitePages/Home.aspx'
});
global.document = document;
global.window = document.defaultView;
var scheduled = [];
window.setTimeout = function (callback) { scheduled.push(callback); return scheduled.length; };
window.clearTimeout = function () {};
var BrandingCssLoader = require('../temp/tests/BrandingCssLoader').BrandingCssLoader;
var passed = 0;
function assert(condition, message) {
if (!condition) { throw new Error(message); }
passed++;
}
var first = new BrandingCssLoader('first', function () {});
var second = new BrandingCssLoader('second', function () {});
first.load([{ path: '/SiteAssets/a.css', media: 'screen' }, { path: '/SiteAssets/b.css', media: 'all' }]);
second.load([{ path: '/SiteAssets/a.css', media: 'screen' }]);
var links = document.querySelectorAll('link[rel="stylesheet"]');
assert(links.length === 2, 'Identische Stylesheets muessen instanzuebergreifend dedupliziert werden.');
assert(links[0].href.indexOf('/SiteAssets/a.css') >= 0 && links[1].href.indexOf('/SiteAssets/b.css') >= 0,
'Die konfigurierte CSS-Reihenfolge muss stabil bleiben.');
first.dispose();
assert(document.querySelectorAll('link[rel="stylesheet"]').length === 1,
'Eine noch referenzierte Datei darf beim ersten Dispose nicht entfernt werden.');
second.dispose();
assert(document.querySelectorAll('link[rel="stylesheet"]').length === 0,
'Eigene Stylesheets muessen nach der letzten Referenz entfernt werden.');
var existing = document.createElement('link');
existing.rel = 'stylesheet';
existing.href = '/SiteAssets/existing.css';
document.head.appendChild(existing);
var third = new BrandingCssLoader('third', function () {});
third.load([{ path: '/SiteAssets/existing.css', media: 'all' }]);
assert(document.querySelectorAll('link[rel="stylesheet"]').length === 1, 'Vorhandenes fremdes CSS darf nicht dupliziert werden.');
third.dispose();
assert(existing.parentNode === document.head, 'Fremdes CSS darf beim Dispose nicht entfernt werden.');
var messages = [];
var fourth = new BrandingCssLoader('fourth', function (message) { messages.push(message); });
fourth.load([{ path: '/SiteAssets/error.css', media: 'all' }]);
var errorLink = document.querySelector('link[href$="error.css"]');
errorLink.onerror();
assert(messages.indexOf('Stylesheet failed to load.') >= 0, 'CSS-Ladefehler muss protokolliert werden.');
scheduled[scheduled.length - 1]();
assert(messages.indexOf('Stylesheet load timed out.') >= 0, 'CSS-Timeout muss protokolliert werden.');
fourth.dispose();
console.log('BrandingCssLoader: ' + passed + ' Pruefungen erfolgreich.');

View File

@@ -0,0 +1,59 @@
'use strict';
var BrandingDomRenderer = require('../temp/tests/BrandingDomRenderer').BrandingDomRenderer;
var passed = 0;
function assert(condition, message) {
if (!condition) { throw new Error(message); }
passed++;
}
function FakeNode(name, text) {
this.nodeName = name;
this.text = text || '';
this.children = [];
this.attributes = {};
this.parentNode = null;
this.style = {
values: {},
setProperty: function (key, value) { this.values[key] = value; }
};
}
Object.defineProperty(FakeNode.prototype, 'firstChild', {
get: function () { return this.children.length ? this.children[0] : null; }
});
FakeNode.prototype.appendChild = function (child) {
child.parentNode = this;
this.children.push(child);
return child;
};
FakeNode.prototype.removeChild = function (child) {
this.children.splice(this.children.indexOf(child), 1);
child.parentNode = null;
};
FakeNode.prototype.setAttribute = function (name, value) { this.attributes[name] = value; };
global.document = {
createElement: function (name) { return new FakeNode(name); },
createTextNode: function (text) { return new FakeNode('#text', text); }
};
var host = new FakeNode('host');
host.appendChild(new FakeNode('old'));
new BrandingDomRenderer().render(host, [{
type: 'section',
attributes: { class: 'portal-header' },
styles: { color: '#123456' },
children: [{ type: 'strong', content: 'Inhalt' }]
}]);
assert(host.children.length === 1, 'Vorhandener eigener Inhalt muss ersetzt werden.');
assert(host.children[0].nodeName === 'section', 'Der DOM-Tag wurde nicht erzeugt.');
assert(host.children[0].attributes.class === 'portal-header', 'Attribute fehlen.');
assert(host.children[0].style.values.color === '#123456', 'Styles fehlen.');
assert(host.children[0].children[0].children[0].text === 'Inhalt', 'Textknoten fehlt.');
new BrandingDomRenderer().clear(host);
assert(host.children.length === 0, 'clear muss alle eigenen Knoten entfernen.');
console.log('BrandingDomRenderer: ' + passed + ' Pruefungen erfolgreich.');

17
tests/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"skipLibCheck": true,
"types": [],
"lib": ["es5", "dom", "es2015.collection"]
},
"files": [
"../src/extensions/customBranding/BrandingTypes.ts",
"../src/extensions/customBranding/BrandingConfig.ts",
"../src/extensions/customBranding/BrandingDomRenderer.ts",
"../src/extensions/customBranding/BrandingCssLoader.ts"
]
}

View File

@@ -0,0 +1,23 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$files = @(
(Join-Path $root 'deployment\add-custombranding.ps1'),
(Join-Path $root 'build-classic.ps1')
)
foreach ($file in $files) {
$tokens = $null
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors)
if ($errors.Count -gt 0) {
throw "PowerShell-Syntaxfehler in $file`: $($errors[0].Message)"
}
}
$localizationFiles = Get-ChildItem (Join-Path $root 'src\extensions\customBranding\loc') -Filter '*.js'
if ($localizationFiles.Count -lt 2) {
throw 'Deutsche und englische Lokalisierungsdateien werden erwartet.'
}
Write-Host "Projektvalidierung: $($files.Count) PowerShell- und $($localizationFiles.Count) Lokalisierungsdateien erfolgreich geprueft."

View File

@@ -0,0 +1,46 @@
'use strict';
var fs = require('fs');
var path = require('path');
var root = path.resolve(__dirname, '..');
var passed = 0;
function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); }
function json(file) { return JSON.parse(read(file)); }
function assert(condition, message) {
if (!condition) { throw new Error(message); }
passed++;
}
['config/config.json', 'config/package-solution.json', 'config/serve.json',
'src/extensions/customBranding/CustomBrandingApplicationCustomizer.manifest.json',
'examples/custom-branding.example.json']
.forEach(function (file) { json(file); passed++; });
var packageJson = json('package.json');
var solution = json('config/package-solution.json').solution;
var serveText = read('config/serve.json').toLowerCase();
var appSource = read('src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts');
var rendererSource = read('src/extensions/customBranding/BrandingDomRenderer.ts');
var classicSource = read('classic/custom-branding-classic.js');
assert(packageJson.version === '3.0.0', 'package.json hat nicht Version 3.0.0.');
assert(solution.version === '3.0.0.0', 'Solution-Version ist inkonsistent.');
assert(solution.skipFeatureDeployment === true, 'Tenantweite Bereitstellung ist nicht aktiviert.');
assert(!solution.features, 'Die alte web-scoped Feature-Registrierung ist noch vorhanden.');
assert(!fs.existsSync(path.join(root, 'sharepoint/assets/elements.xml')), 'elements.xml muss entfernt sein.');
assert(serveText.indexOf('onclick') < 0, 'serve.json enthaelt ein Event-Attribut.');
assert(serveText.indexOf('placeholdertop') >= 0, 'serve.json verwendet nicht das echte Schema.');
assert(rendererSource.indexOf('innerHTML') < 0 && appSource.indexOf('innerHTML') < 0, 'Der moderne Renderer darf innerHTML nicht verwenden.');
assert(classicSource.indexOf('innerHTML') < 0, 'Die Classic-Runtime darf innerHTML nicht verwenden.');
assert(appSource.indexOf('changedEvent.remove') >= 0, 'Lifecycle-Cleanup fuer changedEvent fehlt.');
assert(appSource.indexOf('_onTopPlaceholderDisposed') >= 0 && appSource.indexOf('_onBottomPlaceholderDisposed') >= 0,
'Top- und Bottom-Placeholder brauchen unabhaengige Dispose-Handler.');
assert(appSource.indexOf("'CustomBrandingTopHost'") >= 0 && appSource.indexOf("'CustomBrandingBottomHost'") >= 0,
'Stabile Branding-Hosts fehlen.');
assert(packageJson.scripts.package.indexOf('npm test') === 0, 'Paketierung muss mit Tests beginnen.');
assert(!packageJson.dependencies['@microsoft/sp-dialog'], 'Nicht verwendete Dialog-Abhaengigkeit ist noch vorhanden.');
new Function(classicSource);
passed++;
console.log('Statische Assets: ' + passed + ' Pruefungen erfolgreich.');